Files
byre/web/dist/files.js
T

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