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
27 changes: 20 additions & 7 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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)),
);

Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/vcs/WorktreeFanout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GitCommandError, type VcsCreateWorktreeInput } from "@t3tools/contracts
import {
createThreadWorktrees,
removeThreadWorktrees,
threadWorkspaceFolders,
worktreePlacement,
type WorktreeFanoutDeps,
} from "./WorktreeFanout.ts";
Expand Down Expand Up @@ -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" },
]);
});
});
21 changes: 21 additions & 0 deletions apps/server/src/vcs/WorktreeFanout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});
}
28 changes: 27 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) => ({
Expand Down
17 changes: 12 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isWindowsDrivePath,
normalizeProjectPathForComparison,
normalizeProjectPathForDispatch,
threadWorkspaceFilePath,
} from "./path.ts";

describe("path helpers", () => {
Expand Down Expand Up @@ -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");
});
});
18 changes: 18 additions & 0 deletions packages/shared/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Loading