From 106d20d6c6c6092ca12857751459c31e11752fe6 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 23 Sep 2026 18:22:25 -0400 Subject: [PATCH 1/2] Name source workspaces after the repository, not the checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Whiteboard registered from a linked worktree labeled its native source window with the worktree directory (e.g. autocompact-500k-f92161). Name the workspace file and root folder from the GitHub repo name, falling back to the git common dir's parent, matching Home's repository grouping. Update the window title suffix to "— Whiteboard". Existing workspaces keep their settings: an old checkout-named file seeds the new one and stays in place for any window still open on it. Co-Authored-By: Claude Opus 5.5 Agent-Session: 8b958fd7-34fd-47a8-b6dd-c7d8c2f89db4 --- .../review/src/review-api/local-data.test.ts | 71 ++++++++++++++ packages/review/src/review-api/local-data.ts | 95 +++++++++++++++---- 2 files changed, 149 insertions(+), 17 deletions(-) diff --git a/packages/review/src/review-api/local-data.test.ts b/packages/review/src/review-api/local-data.test.ts index 49779be16..760994db7 100644 --- a/packages/review/src/review-api/local-data.test.ts +++ b/packages/review/src/review-api/local-data.test.ts @@ -1187,6 +1187,77 @@ it("keeps a live navigator attached to the live checkout without preparing it", ).rejects.toThrow("leaves the selected worktree"); }); +it("names a linked worktree's source workspace after its repository", async () => { + const worktree = path.join(directory, "autocompact-500k-f92161"); + git("worktree", "add", "--detach", worktree, pins.head); + const registered = await local.data.register(worktree); + + const { reviewId } = await local.store.execute( + command({ + type: "create", + title: "Worktree source", + target: { kind: "worktree", repositoryId: registered.id }, + }), + ); + + const open = async () => { + const { workspacePath } = await local.data.navigatorWorkspace( + local.store.read(reviewId), + ); + + return { + workspacePath, + workspace: JSON.parse(readFileSync(workspacePath, "utf8")), + }; + }; + + const first = await open(); + expect(path.basename(first.workspacePath)).toBe("repository.code-workspace"); + expect(first.workspace.folders).toEqual([ + { path: realpathSync(worktree), name: "repository" }, + ]); + expect(first.workspace.settings["window.title"]).toBe( + "Worktree source — Live source — Whiteboard", + ); + + // A workspace named after the checkout directory carries its preferences + // over and stays in place for a window still open on it. + const legacy = path.join( + path.dirname(first.workspacePath), + "autocompact-500k-f92161.code-workspace", + ); + + writeFileSync( + legacy, + JSON.stringify({ + folders: [ + { path: realpathSync(worktree), name: "autocompact-500k-f92161" }, + ], + settings: { + "files.readonlyInclude": { "**/*": true }, + "editor.wordWrap": "on", + "window.title": "Worktree source — Live source — Review", + }, + }), + ); + rmSync(first.workspacePath); + + const migrated = await open(); + expect(migrated.workspacePath).toBe(first.workspacePath); + expect(migrated.workspace.folders[0].name).toBe("repository"); + expect(migrated.workspace.settings).toEqual({ + "files.readonlyInclude": { "**/*": true }, + "editor.wordWrap": "on", + "window.title": "Worktree source — Live source — Whiteboard", + }); + expect(existsSync(legacy)).toBe(true); + + git("remote", "add", "origin", "git@github.com:acme/whiteboard.git"); + const remote = await open(); + expect(path.basename(remote.workspacePath)).toBe("whiteboard.code-workspace"); + expect(remote.workspace.folders[0].name).toBe("whiteboard"); +}); + it("browses committed directories, including history, without listing untracked files", async () => { mkdirSync(path.join(repository, "nested", "deeper"), { recursive: true }); writeFileSync( diff --git a/packages/review/src/review-api/local-data.ts b/packages/review/src/review-api/local-data.ts index 5cbf4673b..519c7f982 100644 --- a/packages/review/src/review-api/local-data.ts +++ b/packages/review/src/review-api/local-data.ts @@ -20,6 +20,7 @@ import { listCommitRange, listTrackedFilesAtCommit, readFileAtCommit, + resolveRepoContext, splitGitPatchFiles, } from "@dev.fast/local-vcs"; import { structuralChangeCounts } from "@dev.fast/review-protocol"; @@ -344,9 +345,9 @@ export class LocalReviewData { role: "navigator", }); - const commonDir = await gitCommonDir(repository); + const context = await resolveRepoContext(repository); - if (!rootPath || !commonDir) + if (!rootPath || !context) throw new ReviewInputError( "Could not open the selected source checkout.", 409, @@ -368,30 +369,66 @@ export class LocalReviewData { ); } - const workspacePath = path.join( - reviewManagedCheckoutRoot(commonDir, snapshot.reviewId), + // Name the workspace after the repository, not the registered checkout: + // a linked worktree's directory is an arbitrary branch slug. This matches + // the repository label on Home. + const name = + context.githubSlug?.split("/").at(-1) ?? + path.basename(path.dirname(context.commonDir)); + + const workspaceDirectory = path.join( + reviewManagedCheckoutRoot(context.commonDir, snapshot.reviewId), "navigator", "workspaces", live ? "worktree" : ref, - `${path.basename(repository)}.code-workspace`, + ); + + // VS Code labels a saved workspace by its file name and identifies its + // window by the file's path, so the file name is the repository name. + const workspacePath = path.join( + workspaceDirectory, + `${name}.code-workspace`, ); // A native workspace gives VS Code stable restoration, search scope and // editor read-only behavior without changing files in the source checkout. - // Leave subsequent workspace preferences to VS Code and the user. - if (!existsSync(workspacePath)) - await writePrivateJsonAtomic(workspacePath, { - folders: [ - { - path: rootPath, - name: path.basename(repository), - }, - ], + // Keep the preferences VS Code and the user add, carrying them over from a + // workspace previously named after the checkout directory. That file stays + // in place for any window still open on it. + const current = await readWorkspace(workspacePath); + + if (current !== null) { + const previous = + current ?? + (await readWorkspace( + path.join( + workspaceDirectory, + `${path.basename(repository)}.code-workspace`, + ), + )); + + const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`; + + const workspace = previous ?? { + settings: { "files.readonlyInclude": { "**/*": true } }, + }; + + const [, ...otherFolders] = Array.isArray(workspace.folders) + ? workspace.folders + : []; + + const next = { + ...workspace, + folders: [{ path: rootPath, name }, ...otherFolders], settings: { - "files.readonlyInclude": { "**/*": true }, - "window.title": `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Review`, + ...(isJsonObject(workspace.settings) ? workspace.settings : {}), + "window.title": title, }, - }); + }; + + if (JSON.stringify(next) !== JSON.stringify(current)) + await writePrivateJsonAtomic(workspacePath, next); + } let filePath: string | undefined; @@ -1684,3 +1721,27 @@ function pathspecMatches( path !== undefined && (path === prefix || path.startsWith(prefix + "/")), ); } + +const isJsonObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A workspace file's contents, undefined when it does not exist, or null when + * it is not plain JSON (VS Code accepts comments), which is left untouched. */ +async function readWorkspace( + file: string, +): Promise | null | undefined> { + const text = await readFile(file, "utf8").catch((error) => { + if (isMissingFileError(error)) return undefined; + throw error; + }); + + if (text === undefined) return undefined; + + try { + const workspace: unknown = JSON.parse(text); + + return isJsonObject(workspace) ? workspace : null; + } catch { + return null; + } +} From 7a7599e2b4fb97b9a2e67b8c5f2b69615ab0b81f Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 23 Sep 2026 18:24:53 -0400 Subject: [PATCH 2/2] Parse navigator workspace files with a schema Satisfies the anti-slop lint rules: no unknown-valued dictionaries, typeof narrowing or conditional spreads. Co-Authored-By: Claude Opus 5.5 Agent-Session: 8b958fd7-34fd-47a8-b6dd-c7d8c2f89db4 --- packages/review/src/review-api/local-data.ts | 26 +++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/review/src/review-api/local-data.ts b/packages/review/src/review-api/local-data.ts index 519c7f982..43268e942 100644 --- a/packages/review/src/review-api/local-data.ts +++ b/packages/review/src/review-api/local-data.ts @@ -410,20 +410,14 @@ export class LocalReviewData { const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`; const workspace = previous ?? { + folders: [], settings: { "files.readonlyInclude": { "**/*": true } }, }; - const [, ...otherFolders] = Array.isArray(workspace.folders) - ? workspace.folders - : []; - const next = { ...workspace, - folders: [{ path: rootPath, name }, ...otherFolders], - settings: { - ...(isJsonObject(workspace.settings) ? workspace.settings : {}), - "window.title": title, - }, + folders: [{ path: rootPath, name }, ...workspace.folders.slice(1)], + settings: { ...workspace.settings, "window.title": title }, }; if (JSON.stringify(next) !== JSON.stringify(current)) @@ -1722,14 +1716,18 @@ function pathspecMatches( ); } -const isJsonObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +/** The parts of a VS Code workspace file the navigator owns; everything else + * VS Code or the user adds is kept as is. */ +const codeWorkspaceSchema = z.looseObject({ + folders: z.array(z.json()).default([]), + settings: z.record(z.string(), z.json()).default({}), +}); /** A workspace file's contents, undefined when it does not exist, or null when * it is not plain JSON (VS Code accepts comments), which is left untouched. */ async function readWorkspace( file: string, -): Promise | null | undefined> { +): Promise | null | undefined> { const text = await readFile(file, "utf8").catch((error) => { if (isMissingFileError(error)) return undefined; throw error; @@ -1738,9 +1736,7 @@ async function readWorkspace( if (text === undefined) return undefined; try { - const workspace: unknown = JSON.parse(text); - - return isJsonObject(workspace) ? workspace : null; + return codeWorkspaceSchema.safeParse(JSON.parse(text)).data ?? null; } catch { return null; }