199 lines
7.1 KiB
JavaScript
199 lines
7.1 KiB
JavaScript
import { get } from "./api.js";
|
|
import { errorText, h, replace } from "./dom.js";
|
|
import { onEach, state } from "./state.js";
|
|
const places = new Map();
|
|
function join(dir, name) {
|
|
return dir ? `${dir}/${name}` : name;
|
|
}
|
|
function parent(path) {
|
|
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
}
|
|
function bytes(n) {
|
|
if (n < 1024) {
|
|
return `${n} B`;
|
|
}
|
|
if (n < 1024 * 1024) {
|
|
return `${(n / 1024).toFixed(1)} KiB`;
|
|
}
|
|
return `${(n / 1024 / 1024).toFixed(1)} MiB`;
|
|
}
|
|
function shown(c) {
|
|
return new TextEncoder().encode(c.text).length;
|
|
}
|
|
export function mountFiles(root) {
|
|
const head = h("header", { class: "files-header" });
|
|
const body = h("div", { class: "files-body" });
|
|
let listing;
|
|
let content;
|
|
let error = "";
|
|
let target = 0;
|
|
let timer;
|
|
let disposed = false;
|
|
const place = () => {
|
|
const taskId = state.selectedTaskId;
|
|
return (taskId && places.get(taskId)) || { dir: "" };
|
|
};
|
|
const go = (p) => {
|
|
const taskId = state.selectedTaskId;
|
|
if (!taskId) {
|
|
return;
|
|
}
|
|
places.set(taskId, p);
|
|
listing = undefined;
|
|
content = undefined;
|
|
error = "";
|
|
render();
|
|
body.scrollTop = 0;
|
|
void load();
|
|
};
|
|
const load = async () => {
|
|
const taskId = state.selectedTaskId;
|
|
if (!taskId) {
|
|
render();
|
|
return;
|
|
}
|
|
const started = ++target;
|
|
const p = place();
|
|
try {
|
|
if (p.file !== undefined) {
|
|
const fresh = await get("/api/tasks/{taskId}/file", { path: { taskId }, query: { path: p.file } });
|
|
if (started === target) {
|
|
content = fresh;
|
|
listing = undefined;
|
|
}
|
|
}
|
|
else {
|
|
const fresh = await get("/api/tasks/{taskId}/dir", { path: { taskId }, query: { path: p.dir } });
|
|
if (started === target) {
|
|
listing = fresh;
|
|
content = undefined;
|
|
}
|
|
}
|
|
if (started === target) {
|
|
error = "";
|
|
}
|
|
}
|
|
catch (err) {
|
|
if (started === target) {
|
|
error = errorText(err);
|
|
listing = undefined;
|
|
content = undefined;
|
|
}
|
|
}
|
|
if (disposed || started !== target) {
|
|
return;
|
|
}
|
|
const scroll = body.scrollTop;
|
|
render();
|
|
body.scrollTop = scroll;
|
|
};
|
|
const soon = () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => void load(), 1500);
|
|
};
|
|
const crumbs = () => {
|
|
const p = place();
|
|
const path = p.file ?? p.dir;
|
|
const parts = path ? path.split("/") : [];
|
|
const link = (label, dir, last) => {
|
|
if (last) {
|
|
return h("span", { class: "crumb current" }, label);
|
|
}
|
|
const b = h("button", { type: "button", class: "crumb" }, label);
|
|
b.onclick = () => go({ dir });
|
|
return b;
|
|
};
|
|
const items = [link("worktree", "", parts.length === 0)];
|
|
parts.forEach((part, i) => {
|
|
items.push("/");
|
|
items.push(link(part, parts.slice(0, i + 1).join("/"), i === parts.length - 1));
|
|
});
|
|
return h("nav", { class: "crumbs" }, ...items);
|
|
};
|
|
const summary = () => {
|
|
if (error) {
|
|
return "";
|
|
}
|
|
if (content) {
|
|
if (content.binary) {
|
|
return `${bytes(content.size)} · binary`;
|
|
}
|
|
return content.truncated ? `${bytes(content.size)} · showing the first ${bytes(shown(content))}` : bytes(content.size);
|
|
}
|
|
if (listing) {
|
|
return `${listing.entries.length}${listing.truncated ? "+" : ""} ${listing.entries.length === 1 ? "entry" : "entries"}`;
|
|
}
|
|
return state.selectedTaskId ? "Loading…" : "";
|
|
};
|
|
const entryRow = (dir, e) => {
|
|
const label = e.type === "dir" ? `${e.name}/` : e.name;
|
|
const row = h(e.type === "other" ? "div" : "button", { class: `entry ${e.type}${e.ignored ? " ignored" : ""}`, title: e.type === "other" ? "Leaves the worktree, points nowhere, or isn't a file or directory" : "" }, h("span", { class: "entry-name" }, label, e.symlink ? h("span", { class: "symlink" }, " →") : null), h("span", { class: "entry-meta" }, e.ignored ? "ignored" : null, e.ignored && e.type === "file" ? " · " : null, e.type === "file" ? bytes(e.size) : null));
|
|
if (e.type === "dir") {
|
|
row.onclick = () => go({ dir: join(dir, e.name) });
|
|
}
|
|
else if (e.type === "file") {
|
|
row.onclick = () => go({ dir, file: join(dir, e.name) });
|
|
}
|
|
return row;
|
|
};
|
|
const render = () => {
|
|
replace(head, crumbs(), h("span", { class: "files-summary" }, summary()));
|
|
if (!state.selectedTaskId) {
|
|
replace(body, h("div", { class: "empty" }, "No task selected."));
|
|
return;
|
|
}
|
|
if (error) {
|
|
replace(body, h("div", { class: "empty error-text" }, error));
|
|
return;
|
|
}
|
|
if (content) {
|
|
if (content.binary) {
|
|
replace(body, h("div", { class: "empty" }, "Binary file."));
|
|
return;
|
|
}
|
|
const lines = content.text.split("\n");
|
|
if (lines.at(-1) === "") {
|
|
lines.pop();
|
|
}
|
|
replace(body, h("pre", { class: "file-text" }, ...lines.map((l) => h("span", { class: "line" }, l || " "))), content.truncated ? h("div", { class: "hint file-cut" }, `Showing the first ${bytes(shown(content))} of ${bytes(content.size)}.`) : null);
|
|
return;
|
|
}
|
|
if (!listing) {
|
|
replace(body, h("div", { class: "empty" }, "Loading…"));
|
|
return;
|
|
}
|
|
const l = listing;
|
|
const up = l.path ? h("button", { type: "button", class: "entry dir" }, h("span", { class: "entry-name" }, "../")) : null;
|
|
if (up) {
|
|
up.onclick = () => go({ dir: parent(l.path) });
|
|
}
|
|
replace(body, h("div", { class: "entries" }, up, ...l.entries.map((e) => entryRow(l.path, e)), l.entries.length === 0 ? h("div", { class: "empty" }, "Empty directory.") : null, l.truncated ? h("div", { class: "hint" }, `Only the first ${l.entries.length} entries are shown.`) : null));
|
|
};
|
|
const offs = [
|
|
onEach(["selection"], () => {
|
|
target++;
|
|
clearTimeout(timer);
|
|
listing = undefined;
|
|
content = undefined;
|
|
error = "";
|
|
render();
|
|
void load();
|
|
}),
|
|
onEach(["transcript"], (fresh) => {
|
|
if (fresh.some((ev) => ev.kind === "agent.tool_result" || ev.kind === "turn.ended")) {
|
|
soon();
|
|
}
|
|
}),
|
|
];
|
|
replace(root, h("div", { class: "files-view" }, head, body));
|
|
render();
|
|
void load();
|
|
return () => {
|
|
disposed = true;
|
|
clearTimeout(timer);
|
|
for (const off of offs) {
|
|
off();
|
|
}
|
|
};
|
|
}
|
|
//# sourceMappingURL=files.js.map
|