Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/review/src/review-api/local-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
95 changes: 76 additions & 19 deletions packages/review/src/review-api/local-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
listCommitRange,
listTrackedFilesAtCommit,
readFileAtCommit,
resolveRepoContext,
splitGitPatchFiles,
} from "@dev.fast/local-vcs";
import { structuralChangeCounts } from "@dev.fast/review-protocol";
Expand Down Expand Up @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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<z.infer<typeof codeWorkspaceSchema> | 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;
}
}
Loading