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..43268e942 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,60 @@ 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), - }, - ], - settings: { - "files.readonlyInclude": { "**/*": true }, - "window.title": `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Review`, - }, - }); + // 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 ?? { + folders: [], + settings: { "files.readonlyInclude": { "**/*": true } }, + }; + + const next = { + ...workspace, + folders: [{ path: rootPath, name }, ...workspace.folders.slice(1)], + settings: { ...workspace.settings, "window.title": title }, + }; + + if (JSON.stringify(next) !== JSON.stringify(current)) + await writePrivateJsonAtomic(workspacePath, next); + } let filePath: string | undefined; @@ -1684,3 +1715,29 @@ function pathspecMatches( path !== undefined && (path === prefix || path.startsWith(prefix + "/")), ); } + +/** 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> { + const text = await readFile(file, "utf8").catch((error) => { + if (isMissingFileError(error)) return undefined; + throw error; + }); + + if (text === undefined) return undefined; + + try { + return codeWorkspaceSchema.safeParse(JSON.parse(text)).data ?? null; + } catch { + return null; + } +}