From 6dc04e20a9a9963cac5f9d52a87673dc6aef8aea Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Wed, 23 Sep 2026 11:54:29 +1000 Subject: [PATCH] fix: open isolated multi-repo runs as a multi-root workspace "Open in" on an isolated run opened the anchor worktree as a plain folder, so the other repos were missing from the editor. The worktree fan-out now writes a copy of the project's .code-workspace into the per-thread directory, with each repo folder pointed at its worktree and non-repo folders left at their original path. The client opens that copy for fanned-out threads. Threads created before this have no copy, so the launcher falls back to the directory when the workspace file is missing. Fixes #125 --- .../src/process/externalLauncher.test.ts | 27 +++++++++++++----- apps/server/src/process/externalLauncher.ts | 10 +++++-- apps/server/src/vcs/WorktreeFanout.test.ts | 23 +++++++++++++++ apps/server/src/vcs/WorktreeFanout.ts | 21 ++++++++++++++ apps/server/src/ws.ts | 28 ++++++++++++++++++- apps/web/src/components/ChatView.tsx | 17 +++++++---- packages/shared/src/path.test.ts | 16 +++++++++++ packages/shared/src/path.ts | 18 ++++++++++++ 8 files changed, 145 insertions(+), 15 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index a482448b7bb7..5052a39c53f2 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1217,18 +1217,14 @@ const makeExecutableStubs = Effect.fn("makeExecutableStubs")(function* ( const ANCHOR_DIR = "/workspace/anchor"; const WORKSPACE_FILE = "/workspace/anchor/project.code-workspace"; -it.effect("opens a workspace file in editors that understand one", () => +const launchVscodeWithWorkspaceFile = (workspaceFile: string) => Effect.gen(function* () { const binDir = yield* makeExecutableStubs(["code"]); let spawned: ChildProcess.StandardCommand | undefined; yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "vscode", - cwd: ANCHOR_DIR, - workspaceFile: WORKSPACE_FILE, - }); + yield* launcher.launchEditor({ editor: "vscode", cwd: ANCHOR_DIR, workspaceFile }); }).pipe( Effect.provide( testLayer({ @@ -1242,7 +1238,24 @@ it.effect("opens a workspace file in editors that understand one", () => ); assert.ok(spawned); - assert.deepEqual(spawned.args, [WORKSPACE_FILE]); + return spawned.args; + }); + +it.effect("opens a workspace file in editors that understand one", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const dir = yield* fileSystem.makeTempDirectoryScoped(); + const workspaceFile = `${dir}/project.code-workspace`; + yield* fileSystem.writeFileString(workspaceFile, "{}"); + + assert.deepEqual(yield* launchVscodeWithWorkspaceFile(workspaceFile), [workspaceFile]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// An isolated run created before threads got a generated workspace file. +it.effect("falls back to the directory when the workspace file is missing", () => + Effect.gen(function* () { + assert.deepEqual(yield* launchVscodeWithWorkspaceFile(WORKSPACE_FILE), [ANCHOR_DIR]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index e660c6afb87b..8b6fd1fba0dd 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -526,11 +526,17 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( } // A multi-repo project passes its `.code-workspace` alongside the anchor dir; - // only editors that understand workspace files get the file itself. + // only editors that understand workspace files get the file itself. An + // isolated run created before its thread got a generated workspace file has + // none on disk, so fall back to the directory rather than a failed open. + const fileSystem = yield* FileSystem.FileSystem; + const workspaceFileExists = input.workspaceFile + ? yield* fileSystem.exists(input.workspaceFile).pipe(Effect.orElseSucceed(() => false)) + : false; const target = resolveEditorTarget({ editor: editorDef, cwd: input.cwd, - workspaceFile: input.workspaceFile, + workspaceFile: workspaceFileExists ? input.workspaceFile : undefined, }); if (editorDef.commands) { diff --git a/apps/server/src/vcs/WorktreeFanout.test.ts b/apps/server/src/vcs/WorktreeFanout.test.ts index de6c3c376b58..11b397aa0e28 100644 --- a/apps/server/src/vcs/WorktreeFanout.test.ts +++ b/apps/server/src/vcs/WorktreeFanout.test.ts @@ -6,6 +6,7 @@ import { GitCommandError, type VcsCreateWorktreeInput } from "@t3tools/contracts import { createThreadWorktrees, removeThreadWorktrees, + threadWorkspaceFolders, worktreePlacement, type WorktreeFanoutDeps, } from "./WorktreeFanout.ts"; @@ -146,3 +147,25 @@ describe("removeThreadWorktrees", () => { }), ); }); + +describe("threadWorkspaceFolders", () => { + it("points repo folders at their worktrees and keeps the rest in place", () => { + expect( + threadWorkspaceFolders({ + folders: [ + { absolutePath: "/code/web/app", name: "web" }, + { absolutePath: "/code/docs", name: "docs" }, + { absolutePath: "/code/api/app", name: "api" }, + ], + worktrees: [ + { repoRoot: "/code/web/app", worktreePath: "/t3/worktrees/p/t/app" }, + { repoRoot: "/code/api/app", worktreePath: "/t3/worktrees/p/t/app-2" }, + ], + }), + ).toEqual([ + { path: "./app", name: "web" }, + { path: "/code/docs", name: "docs" }, + { path: "./app-2", name: "api" }, + ]); + }); +}); diff --git a/apps/server/src/vcs/WorktreeFanout.ts b/apps/server/src/vcs/WorktreeFanout.ts index e32171644bed..839ba14e2c9d 100644 --- a/apps/server/src/vcs/WorktreeFanout.ts +++ b/apps/server/src/vcs/WorktreeFanout.ts @@ -181,3 +181,24 @@ export const createThreadWorktrees = ( return created; }); + +/** + * Folder entries for a thread's generated `.code-workspace`: each folder of the + * project's workspace file that is a fanned-out repo root points at its + * worktree (relative, since the file sits in the per-thread directory beside + * them); folders without a worktree keep their original absolute path. Names + * carry over so the editor shows the project's repo names, not placement + * suffixes like `app-2`. + */ +export function threadWorkspaceFolders(input: { + readonly folders: ReadonlyArray<{ readonly absolutePath: string; readonly name: string }>; + readonly worktrees: ReadonlyArray<{ readonly repoRoot: string; readonly worktreePath: string }>; +}): ReadonlyArray<{ readonly path: string; readonly name: string }> { + return input.folders.map((folder) => { + const worktree = input.worktrees.find((entry) => entry.repoRoot === folder.absolutePath); + return { + path: worktree ? `./${basenameOf(worktree.worktreePath)}` : folder.absolutePath, + name: folder.name, + }; + }); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9db420769c93..e6bd313befbf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -86,6 +86,7 @@ import { import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveAnchorRepoRoot } from "@t3tools/shared/git"; +import { threadWorkspaceFilePath } from "@t3tools/shared/path"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -142,7 +143,11 @@ import * as WorkspaceGitScan from "./workspace/WorkspaceGitScan.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; -import { createThreadWorktrees, type WorktreeFanoutTarget } from "./vcs/WorktreeFanout.ts"; +import { + createThreadWorktrees, + threadWorkspaceFolders, + type WorktreeFanoutTarget, +} from "./vcs/WorktreeFanout.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; @@ -1678,6 +1683,27 @@ const makeWsRpcLayer = ( repoRoot: entry.repoRoot, worktreePath: entry.worktreePath, })); + // Give the fanned-out run its own `.code-workspace` so "Open in" + // lands on the worktrees rather than the original checkouts. It + // lives in the per-thread directory, so it goes when that does. + const projectWorkspaceFile = projectShell?.workspaceFile; + if (cousinRepoRoots.length > 0 && projectWorkspaceFile) { + yield* workspaceFile.read(projectWorkspaceFile).pipe( + Effect.flatMap((resolved) => + workspaceFile.write({ + workspaceFilePath: threadWorkspaceFilePath({ + anchorWorktreePath: anchorWorktree.worktreePath, + projectWorkspaceFile, + }), + document: workspaceFile.withFolders( + resolved.document, + threadWorkspaceFolders({ folders: resolved.folders, worktrees }), + ), + }), + ), + Effect.ignoreCause({ log: true }), + ); + } const checkoutEndedAt = yield* nowIso; yield* worktreeSetupTracker.update(threadId, (snapshot) => ({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b3c134fc11d..f83ac8ab54e8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -77,6 +77,7 @@ import { } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { threadWorkspaceFilePath } from "@t3tools/shared/path"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -3650,12 +3651,18 @@ export default function ChatView(props: ChatViewProps) { : null; // `workspaceRoot` is only the anchor directory for a workspace-file project, // so opening it gives a plain folder. Hand the `.code-workspace` itself to - // editors that can open it as a multi-root workspace. Isolated runs are - // excluded: their fanned-out worktrees have no workspace file, and the - // project's would point back at the original checkouts. - const openInWorkspaceFile = activeThread?.worktreePath + // editors that can open it as a multi-root workspace. A fanned-out isolated + // run gets the copy generated beside its worktrees instead, since the + // project's own file points back at the original checkouts. + const projectWorkspaceFile = activeProject?.workspaceFile ?? null; + const anchorWorktreePath = activeThread?.worktrees[0]?.worktreePath ?? null; + const openInWorkspaceFile = !projectWorkspaceFile ? null - : (activeProject?.workspaceFile ?? null); + : !activeThread?.worktreePath + ? projectWorkspaceFile + : anchorWorktreePath && activeThread.worktrees.length > 1 + ? threadWorkspaceFilePath({ anchorWorktreePath, projectWorkspaceFile }) + : null; // For a multi-repo `.code-workspace` project, fan git status out over every // repo root. For a single-repo project keep the worktree-aware status cwd so // isolated runs report on the worktree (Phase 4 will make multi-repo diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 799f225fb6e2..406b6acdf3e1 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -6,6 +6,7 @@ import { isWindowsDrivePath, normalizeProjectPathForComparison, normalizeProjectPathForDispatch, + threadWorkspaceFilePath, } from "./path.ts"; describe("path helpers", () => { @@ -43,4 +44,19 @@ describe("path helpers", () => { // Non-root drive paths keep their trailing separator trimmed as before. expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); }); + + it("places a thread's workspace file beside its per-root worktrees", () => { + expect( + threadWorkspaceFilePath({ + anchorWorktreePath: "/home/u/.t3/worktrees/p1/t1/api/", + projectWorkspaceFile: "/home/u/code/shop.code-workspace", + }), + ).toBe("/home/u/.t3/worktrees/p1/t1/shop.code-workspace"); + expect( + threadWorkspaceFilePath({ + anchorWorktreePath: "C:\\t3\\worktrees\\p1\\t1\\api", + projectWorkspaceFile: "C:\\code\\shop.code-workspace", + }), + ).toBe("C:\\t3\\worktrees\\p1\\t1\\shop.code-workspace"); + }); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index b6758d814aa8..a7baa62d820f 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -53,3 +53,21 @@ export function normalizeProjectPathForComparison(value: string): string { } return normalized; } + +/** + * Where an isolated multi-repo run keeps its generated `.code-workspace`: in the + * per-thread directory next to the per-root worktrees, named after the project's + * own file so the editor window keeps the project's title. The server writes it + * during the worktree fan-out; clients hand it to "Open in". + */ +export function threadWorkspaceFilePath(input: { + readonly anchorWorktreePath: string; + readonly projectWorkspaceFile: string; +}): string { + const lastSeparator = (value: string) => + Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); + const anchor = trimTrailingPathSeparators(input.anchorWorktreePath); + const anchorSeparator = lastSeparator(anchor); + const fileName = input.projectWorkspaceFile.slice(lastSeparator(input.projectWorkspaceFile) + 1); + return anchorSeparator === -1 ? fileName : `${anchor.slice(0, anchorSeparator + 1)}${fileName}`; +}