Add a read-only Files tab browsing a task's worktree

This commit is contained in:
Ian Gulliver
2026-10-15 07:31:53 -07:00
parent 4ca6f88861
commit de5b944a0c
16 changed files with 1460 additions and 123 deletions
+127
View File
@@ -350,6 +350,44 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/dir": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
/** @description One directory of the task's worktree, directories first, then by name. Entries named .git are left out; gitignored entries are included and marked. A path that leaves the worktree, through .. or a symlink, is refused. */
get: operations["getTaskDir"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/file": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
/** @description A regular file in the task's worktree. Text is returned up to a size limit; binary files return no content. A path that leaves the worktree, through .. or a symlink, is refused. */
get: operations["getTaskFile"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/snapshots": {
parameters: {
query?: never;
@@ -722,6 +760,39 @@ export interface components {
path: string;
patch: string;
};
DirListing: {
/** @description The directory, relative to the worktree root; empty for the root. */
path: string;
entries: components["schemas"]["DirEntry"][];
/** @description The directory has more entries than were returned. */
truncated: boolean;
};
DirEntry: {
name: string;
/** @description git ignores the entry: .gitignore and the other exclude files match it or a directory above it. */
ignored: boolean;
/**
* @description What the entry is, following a symlink that stays in the worktree. other: a symlink that leaves the worktree or points nowhere, or a device, socket or pipe.
* @enum {string}
*/
type: "dir" | "file" | "other";
symlink: boolean;
/**
* Format: int64
* @description file: its size in bytes.
*/
size: number;
};
FileContent: {
path: string;
/** Format: int64 */
size: number;
/** @description The file isn't UTF-8 text; text is empty. */
binary: boolean;
/** @description text holds only the start of the file. */
truncated: boolean;
text: string;
};
Snapshot: {
id: string;
task_id: string;
@@ -1535,6 +1606,62 @@ export interface operations {
409: components["responses"]["Error"];
};
};
getTaskDir: {
parameters: {
query?: {
/** @description Slash-separated, relative to the worktree root; empty or absent for the root. */
path?: string;
};
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The directory's entries. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DirListing"];
};
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
409: components["responses"]["Error"];
};
};
getTaskFile: {
parameters: {
query: {
/** @description Slash-separated, relative to the worktree root. */
path: string;
};
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The file. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["FileContent"];
};
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
409: components["responses"]["Error"];
};
};
listSnapshots: {
parameters: {
query?: never;
+236
View File
@@ -0,0 +1,236 @@
import { get, type ByreEvent, type Schemas } from "./api.js";
import { errorText, h, replace } from "./dom.js";
import { onEach, state } from "./state.js";
type Listing = Schemas["DirListing"];
type Entry = Schemas["DirEntry"];
type Content = Schemas["FileContent"];
type Place = { dir: string; file?: string };
const places = new Map<string, Place>();
function join(dir: string, name: string): string {
return dir ? `${dir}/${name}` : name;
}
function parent(path: string): string {
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
}
function bytes(n: number): string {
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: Content): number {
return new TextEncoder().encode(c.text).length;
}
export function mountFiles(root: HTMLElement): () => void {
const head = h("header", { class: "files-header" });
const body = h("div", { class: "files-body" });
let listing: Listing | undefined;
let content: Content | undefined;
let error = "";
let target = 0;
let timer: number | undefined;
let disposed = false;
const place = (): Place => {
const taskId = state.selectedTaskId;
return (taskId && places.get(taskId)) || { dir: "" };
};
const go = (p: Place) => {
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 = (): HTMLElement => {
const p = place();
const path = p.file ?? p.dir;
const parts = path ? path.split("/") : [];
const link = (label: string, dir: string, last: boolean) => {
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: (HTMLElement | string)[] = [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 = (): string => {
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: string, e: Entry): HTMLElement => {
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 as ByreEvent[]).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();
}
};
}
+4 -2
View File
@@ -1,17 +1,19 @@
import { mountAdmin } from "./admin.js";
import { mountChanges } from "./changes.js";
import { h, replace } from "./dom.js";
import { mountFiles } from "./files.js";
import { mountInbox } from "./inbox.js";
import { mountSettings } from "./settings.js";
import { mountTranscript } from "./task.js";
import { mountTasks } from "./tasks.js";
import { mountTodo } from "./todo.js";
export type View = "transcript" | "changes" | "tasks" | "todo" | "inbox" | "admin" | "settings";
export type View = "transcript" | "changes" | "files" | "tasks" | "todo" | "inbox" | "admin" | "settings";
const views: Record<View, { label: string; mount: (root: HTMLElement) => () => void }> = {
transcript: { label: "Transcript", mount: mountTranscript },
changes: { label: "Changes", mount: (root) => mountChanges(root) },
files: { label: "Files", mount: mountFiles },
tasks: { label: "Tasks", mount: mountTasks },
todo: { label: "Todo", mount: mountTodo },
inbox: { label: "Inbox", mount: mountInbox },
@@ -19,7 +21,7 @@ const views: Record<View, { label: string; mount: (root: HTMLElement) => () => v
settings: { label: "Settings", mount: mountSettings },
};
const order: View[] = ["transcript", "changes", "tasks", "todo", "inbox", "admin", "settings"];
const order: View[] = ["transcript", "changes", "files", "tasks", "todo", "inbox", "admin", "settings"];
const panes: Pane[] = [];