Add a read-only Files tab browsing a task's worktree
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user