diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index 7c620a75..dc61c33b 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -55,7 +55,7 @@ collections, `file` (type `File`) and `commit` (type `Commit`). | `/workspace/{slug}/commit/{id}/diff` | The commit's diff from its first parent, one `Diff` resource per changed file, paged. | | `/workspace/{slug}/commit/{id}/diff/{path}` | One changed file's diff: its status and unified diff, cut at 20,000 characters (`patch_truncated`). | | `/workspace/{slug}/file/{path}` | The tree at HEAD: a directory as a collection of its entries, a file as a resource with its text, cut at 100,000 characters (`content_truncated`). A listed file carries `media_type` only when the extension table names its extension; the file's own resource always carries it. | -| `/workspace/{slug}/commit/{id}/file/{path}` | The same, at that commit. | +| `/workspace/{slug}/commit/{id}/file/{path}` | The same, at that commit. A file links the same path at HEAD as `head` when HEAD holds a file there. | | `/workspace/{slug}/paths` | Every file's path in the tree at HEAD, in name order, as a `paths` array: what the chat offers to mention. Directories and submodules are not listed. At most 5,000 paths; `total` counts them all and `truncated` says when some are left out. The top directory at HEAD links it as `paths`. | | `/workspace/{slug}/raw/{path}` | A file's committed bytes at HEAD, as its media type. A file over 20 MiB gets a 413 that names its size. | | `/workspace/{slug}/commit/{id}/raw/{path}` | The same, at that commit. | @@ -154,7 +154,7 @@ The browser also keeps whether each section is folded. The sections list: | Section | Lists | Opening an item shows | | --- | --- | --- | -| File Explorer | The workspace's files at HEAD, as a tree. A directory's entries are fetched when it is first opened. Each file's icon and colour come from its listed `media_type` (`file-icons.tsx`); a file listed without one has the plain file icon. The box that marks the open file slides to the next file opened. A file opened from anywhere else, such as the graph or a link, opens the folders above it. Its filter lists the files whose paths hold every word typed, from `/paths`, so files in folders not yet opened are found. | The file, shown as its media type. | +| File Explorer | The workspace's files at HEAD, as a tree. A directory's entries are fetched when it is first opened. Each file's icon and colour come from its listed `media_type` (`file-icons.tsx`); a file listed without one has the plain file icon. The box that marks the open file slides to the next file opened. A file opened from anywhere else, such as the graph or a link, opens the folders above it. A file opened at a commit, as a commit's changes and the graph's file nodes open it, is followed to the same path at HEAD, which the file links as `head` when HEAD holds it. Its filter lists the files whose paths hold every word typed, from `/paths`, so files in folders not yet opened are found. | The file, shown as its media type. | | Source Explorer | Every branch's commits, newest first, with each branch's line drawn beside them as `git log --graph` draws it, and branch names on the commits they point to. A branch picker opens on HEAD's branch and scrolls to the picked branch's tip. The box that marks the open commit slides to the next commit opened. Its filter narrows the commits loaded so far, by id, message, author or branch, and says how many it searched. | The commit's properties, message, parents, changed files and diff. A changed file opens as it was at that commit. | | Record Explorer | The workspace's collections of records and acts, and the items of the one picked. The `file` and `commit` collections are left out. | The record. | diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index 7eaf1b32..84c90a5f 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -480,6 +480,27 @@ test.describe("the sidebar", () => { ); }); + test("a file opened from a commit's changes opens its folders in File Explorer and marks the same path at HEAD", async ({ + page, + }) => { + await page.goto("/app/workspace/alpha/Q_1"); + await explorer(page, "File Explorer").click(); + await explorer(page, "Source Explorer").click(); + const tree = page.locator(".file-tree"); + await expect(tree.getByRole("button", { name: "README.md", exact: true })).toBeVisible(); + await page.locator(".history .commit-row", { hasText: "Change a, plan the work" }).click(); + await expect(page.locator(".header-type")).toHaveText("Commit"); + await page + .locator(".kv:has(.change-status)") + .getByRole("button", { name: "notes/plan.md" }) + .click(); + await expect(header(page)).toHaveText("plan.md"); + await expect(page).toHaveURL(/\/commit\/[0-9a-f]+\/file\/notes\/plan\.md/); + const row = tree.getByRole("button", { name: "plan.md", exact: true }); + await expect(row).toBeVisible(); + await expect(row).toHaveClass(/active/); + }); + test("in Source Explorer, a box marks the open commit and slides to the next one opened", async ({ page, }) => { diff --git a/packages/app-web/src/server/git-handler.ts b/packages/app-web/src/server/git-handler.ts index 5838e24f..f02c591f 100644 --- a/packages/app-web/src/server/git-handler.ts +++ b/packages/app-web/src/server/git-handler.ts @@ -692,6 +692,9 @@ function file( atCommit ? `${base}/${COMMIT_SLUG}/${commit}/${route}/${pathHref(path)}` : `${base}/${route}/${pathHref(path)}`; + // A file read at a commit links the same path in the tree at HEAD, when it is there, so File + // Explorer, which shows HEAD, can follow it. + const atHead = atCommit && pathAtHead(repo, path); const blob = entry.toObject(repo).peelToBlob(); const size = Number(blob.size()); const binary = blob.isBinary(); @@ -700,6 +703,9 @@ function file( self: { href: self, type: FILE_TYPE }, index: { href: parent }, commit: { href: `${base}/${COMMIT_SLUG}/${commit}`, type: COMMIT_TYPE }, + ...(atHead + ? { head: { href: `${base}/${FILE_SLUG}/${pathHref(path)}`, type: FILE_TYPE } } + : {}), history: { href: sibling("history") }, raw: { href: sibling("raw"), type: mediaType }, [REL.changedBy]: changedBy.map((c) => ({ ...c._links.self, dir: "in" })), @@ -860,6 +866,19 @@ function rawFile( }); } +/** Whether the tree at HEAD holds a file at `path`. A repository with no commits holds none. */ +function pathAtHead(repo: Repository, path: string): boolean { + let tree: Tree; + try { + tree = repo.getCommit(repo.revparseSingle("HEAD")).tree(); + } catch (err) { + if (isNotFound(err)) return false; + throw err; + } + const entry = tree.getPath(path); + return entry !== null && typeOf(entry) === FILE_TYPE; +} + /** * Every file's path in the tree at HEAD, in BY_NAME order of the whole path: what can be mentioned * in a message to the agent, and what File Explorer's filter searches. The templated `file` link diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx index ba62985f..380170de 100644 --- a/packages/app-web/src/ui/trace/TraceConsole.tsx +++ b/packages/app-web/src/ui/trace/TraceConsole.tsx @@ -61,6 +61,7 @@ import { type HalDocument, type Held, type HeldIndex, + hrefOf, humanRel, itemView, keyOf, @@ -186,6 +187,10 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { const openEntry = useDocument(path === "" ? undefined : resourcePath(path)); const openDoc = docOf(openEntry); const openIsCollection = openDoc !== undefined && typeof openDoc.type !== "string"; + // File Explorer shows HEAD: a file open at a commit is followed to the same path at HEAD, which + // the file links as `head` when HEAD holds it. + const headHref = openDoc === undefined ? undefined : hrefOf(openDoc, "head"); + const fileKey = headHref === undefined ? path : keyOf(headHref); // Opening what is already open adds no history entry. Opening a resource of another type than // the listed collection's lists the collection that one is listed in instead, which closes the @@ -286,6 +291,7 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { listKey={listKey} root={listKey !== undefined && listKey === workspaceIndex} openKey={path} + fileKey={fileKey} held={held} expanded={narrow ? drawer === "items" : sidebarShown} onToggle={() => @@ -424,6 +430,7 @@ function Lists({ listKey, root, openKey, + fileKey, held, expanded, onToggle, @@ -434,6 +441,8 @@ function Lists({ className: string; /** The open workspace's directory, `/workspace//`, or undefined when none is open. */ workspace: string | undefined; + /** The key File Explorer marks: the open file, or for a file open at a commit, the same path at HEAD. */ + fileKey: string; /** The collection Record Explorer lists; undefined when no workspace is in scope. */ listKey: string | undefined; root: boolean; @@ -654,7 +663,7 @@ function Lists({ {filesKey === undefined ? null : ( diff --git a/packages/app-web/tests/git-api.test.ts b/packages/app-web/tests/git-api.test.ts index 9eb55357..e280e234 100644 --- a/packages/app-web/tests/git-api.test.ts +++ b/packages/app-web/tests/git-api.test.ts @@ -183,6 +183,17 @@ describe("a workspace's files", () => { expect(body._links.index.href).toBe(`${commitHref(first)}/file/src`); }); + test("a file at a commit links the same path at HEAD when HEAD holds it, and only then", async () => { + const first = git.commits[0]!; + const kept = await get(`/workspace/alpha/commit/${first}/file/src/a.ts`); + expect(kept.body._links.head).toEqual({ href: fileHref("src/a.ts"), type: "File" }); + // The blob was dropped in a later commit, so HEAD holds no such file. + const dropped = await get(`/workspace/alpha/commit/${first}/file/data/blob.bin`); + expect(dropped.body._links.head).toBeUndefined(); + // A file read at HEAD is already there. + expect((await get("/workspace/alpha/file/src/a.ts")).body._links.head).toBeUndefined(); + }); + test("a binary file has its size and no text", async () => { const { body } = await get(`/workspace/alpha/commit/${git.commits[0]}/file/data/blob.bin`); expect(body).toMatchObject({ binary: true, size: 6 });