From b7757b4027a59b3e5f650ba09afb1bcba0ebff1a Mon Sep 17 00:00:00 2001 From: Casper Engelmann Date: Fri, 28 Aug 2026 18:37:45 +0200 Subject: [PATCH 1/2] feat(web): reuse existing worktrees --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../server/src/git/GitWorkflowService.test.ts | 15 ++ apps/server/src/git/GitWorkflowService.ts | 15 ++ apps/server/src/vcs/GitVcsDriver.ts | 5 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 82 +++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 142 ++++++++++++++--- apps/server/src/ws.ts | 16 +- .../components/BranchToolbar.logic.test.ts | 87 ----------- .../web/src/components/BranchToolbar.logic.ts | 48 ------ apps/web/src/components/BranchToolbar.tsx | 109 ++++++------- .../BranchToolbarEnvModeSelector.tsx | 146 ++++++++---------- .../components/ExistingWorktreesMenuSub.tsx | 37 +++++ docs/user/composer.md | 4 + packages/client-runtime/src/state/vcs.test.ts | 8 + packages/client-runtime/src/state/vcs.ts | 25 ++- packages/contracts/src/git.test.ts | 10 ++ packages/contracts/src/git.ts | 22 ++- packages/contracts/src/rpc.ts | 10 ++ 18 files changed, 473 insertions(+), 309 deletions(-) create mode 100644 apps/web/src/components/ExistingWorktreesMenuSub.tsx diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..b86b849f74e0 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -95,6 +95,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, + [WS_METHODS.vcsListWorktrees]: AuthOrchestrationReadScope, [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe2..2edbfebfc061 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -135,6 +135,21 @@ describe("GitWorkflowService", () => { ), ); + it.effect("returns an empty worktree list when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const result = yield* workflow.listWorktrees({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(result, { worktrees: [], isRepo: false }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + it.effect("structures workflow detection failures without exposing upstream details", () => { const cause = new VcsRepositoryDetectionError({ operation: "VcsDriverRegistry.detect", diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index a73aa59d5516..5578cc0edc82 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -13,6 +13,8 @@ import { type VcsCreateWorktreeResult, type VcsListRefsInput, type VcsListRefsResult, + type VcsListWorktreesInput, + type VcsListWorktreesResult, type GitManagerServiceError, type GitPreparePullRequestThreadInput, type GitPreparePullRequestThreadResult, @@ -62,6 +64,9 @@ export class GitWorkflowService extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly listWorktrees: ( + input: VcsListWorktreesInput, + ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; @@ -137,6 +142,10 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } +function nonRepositoryListWorktrees(): VcsListWorktreesResult { + return { worktrees: [], isRepo: false }; +} + export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; @@ -302,6 +311,12 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), + listWorktrees: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listWorktrees", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.listWorktrees(input) : Effect.succeed(nonRepositoryListWorktrees()), + ), + ), createWorktree: (input) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6cf4400c62eb..df360ae0b6ca 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -25,6 +25,8 @@ import { type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, + type VcsListWorktreesInput, + type VcsListWorktreesResult, type VcsPullResult, type VcsRemoveWorktreeInput, type VcsStatusInput, @@ -269,6 +271,9 @@ export class GitVcsDriver extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly listWorktrees: ( + input: VcsListWorktreesInput, + ) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 587a3e4abbde..1901ed1d9415 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -74,7 +74,9 @@ const writeTextFile = ( const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const filePath = pathService.join(cwd, relativePath); - yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); + yield* fileSystem.makeDirectory(pathService.dirname(filePath), { + recursive: true, + }); yield* fileSystem.writeFileString(filePath, contents); }); @@ -129,7 +131,10 @@ const initRepoWithCommit = ( }); it.effect("uses stable diagnostics for every parsed non-repository command", () => { - const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; + const commands: Array<{ + readonly args: ReadonlyArray; + readonly lcAll?: string; + }> = []; const spawner = ChildProcessSpawner.make((command) => Effect.sync(() => { if (!ChildProcess.isStandardCommand(command)) { @@ -1030,7 +1035,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* driver.initRepo({ cwd }); const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]); - const status = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + const status = yield* driver.statusDetailsRemote(cwd, { + refreshUpstream: false, + }); assert.equal(status.isRepo, true); assert.equal(status.branch, initialBranch); @@ -1261,7 +1268,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { false, ); - const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true }); + const complete = yield* driver.listRefs({ + cwd, + includeMatchingRemoteRefs: true, + }); assert.equal( complete.refs.some((ref) => ref.name === initialBranch), true, @@ -1310,7 +1320,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.createRef({ cwd, refName: "feature/original" }); - const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); + const switchRef = yield* driver.switchRef({ + cwd, + refName: "feature/original", + }); assert.equal(switchRef.refName, "feature/original"); const renamed = yield* driver.renameBranch({ @@ -1348,6 +1361,61 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("lists other branch and detached worktrees without pagination", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const branchPath = pathService.join(worktreesRoot, "branch"); + const detachedPath = pathService.join(worktreesRoot, "detached"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["worktree", "add", "-b", "feature/linked", branchPath]); + yield* git(cwd, ["worktree", "add", "--detach", detachedPath, "HEAD"]); + + const result = yield* driver.listWorktrees({ cwd }); + + assert.equal(result.isRepo, true); + assert.deepStrictEqual( + result.worktrees + .map(({ path, branch }) => ({ path, branch })) + .toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { + path: yield* fileSystem.realPath(branchPath), + branch: "feature/linked", + }, + { path: yield* fileSystem.realPath(detachedPath), branch: null }, + ].toSorted((left, right) => left.path.localeCompare(right.path)), + ); + }), + ); + + it.effect("excludes the requested checkout when cwd is a symlink", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const branchPath = pathService.join(worktreesRoot, "branch"); + const cwdLink = pathService.join(worktreesRoot, "cwd-link"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["worktree", "add", "-b", "feature/linked", branchPath]); + yield* fileSystem.symlink(cwd, cwdLink); + + const result = yield* driver.listWorktrees({ cwd: cwdLink }); + + assert.deepStrictEqual( + result.worktrees.map(({ path, branch }) => ({ path, branch })), + [{ path: yield* fileSystem.realPath(branchPath), branch: "feature/linked" }], + ); + }), + ); + it.effect("preserves newline characters in worktree paths when listing refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1943,7 +2011,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["remote", "add", "origin", originRemote]); yield* git(cwd, ["remote", "add", "origin-1", publishRemote]); - const pushed = yield* driver.pushCurrentBranch(cwd, null, { remoteName: "origin-1" }); + const pushed = yield* driver.pushCurrentBranch(cwd, null, { + remoteName: "origin-1", + }); assert.deepInclude(pushed, { status: "pushed", diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 71e478cbaa3d..0295584704d3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -165,7 +165,11 @@ function parseBranchAb(value: string): { ahead: number; behind: number } { function parseNumstatEntries( stdout: string, ): Array<{ path: string; insertions: number; deletions: number }> { - const entries: Array<{ path: string; insertions: number; deletions: number }> = []; + const entries: Array<{ + path: string; + insertions: number; + deletions: number; + }> = []; for (const line of stdout.split(/\r?\n/g)) { if (line.trim().length === 0) continue; const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); @@ -242,18 +246,32 @@ function paginateBranches(input: { }; } -function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { - const worktreePaths = new Map(); +function parseWorktrees(stdout: string): ReadonlyArray<{ + path: string; + branch: string | null; + head: string; +}> { + const worktrees: Array<{ + path: string; + branch: string | null; + head: string; + }> = []; let currentPath: string | null = null; let currentBranch: string | null = null; + let currentHead: string | null = null; let currentPrunable = false; const flush = () => { - if (currentPath !== null && currentBranch !== null && !currentPrunable) { - worktreePaths.set(currentBranch, currentPath); + if (currentPath !== null && currentHead !== null && !currentPrunable) { + worktrees.push({ + path: currentPath, + branch: currentBranch, + head: currentHead, + }); } currentPath = null; currentBranch = null; + currentHead = null; currentPrunable = false; }; @@ -264,13 +282,23 @@ function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { currentPath = field.slice("worktree ".length); } else if (field.startsWith("branch refs/heads/")) { currentBranch = field.slice("branch refs/heads/".length); + } else if (field.startsWith("HEAD ")) { + currentHead = field.slice("HEAD ".length); } else if (field === "prunable" || field.startsWith("prunable ")) { currentPrunable = true; } } flush(); - return worktreePaths; + return worktrees; +} + +function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { + return new Map( + parseWorktrees(stdout).flatMap((worktree) => + worktree.branch === null ? [] : [[worktree.branch, worktree.path] as const], + ), + ); } function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { @@ -537,7 +565,10 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_start") { const now = yield* DateTime.now; - hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) }); + hookStartByChildKey.set(childKey, { + hookName, + startedAtMs: DateTime.toEpochMillis(now), + }); yield* addCurrentSpanEvent("git.hook.started", { hookName, }); @@ -1742,7 +1773,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const numstatEntries = parseNumstatEntries(numstatStdout); const fileStatMap = new Map(); for (const entry of numstatEntries) { - fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions }); + fileStatMap.set(entry.path, { + insertions: entry.insertions, + deletions: entry.deletions, + }); } let insertions = 0; @@ -1751,7 +1785,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .map(([filePath, stat]) => { insertions += stat.insertions; deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + return { + path: filePath, + insertions: stat.insertions, + deletions: stat.deletions, + }; }) .toSorted((a, b) => a.path.localeCompare(b.path)); @@ -1891,9 +1929,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : { ...options.progress, onStdoutLine: (line: string) => - options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, + options.progress?.onOutputLine?.({ + stream: "stdout", + text: line, + }) ?? Effect.void, onStderrLine: (line: string) => - options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, + options.progress?.onOutputLine?.({ + stream: "stderr", + text: line, + }) ?? Effect.void, }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), @@ -2070,7 +2114,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"], { timeoutMs: null }); + yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"], { + timeoutMs: null, + }); return { status: "pushed" as const, branch, @@ -2597,8 +2643,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { concurrency: 16 }, ); const worktreeMap = new Map(existingWorktreeEntries); - const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; - const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; + const localBranches: Array<{ + readonly ref: VcsRef; + readonly lastCommit: number; + }> = []; + const remoteBranches: Array<{ + readonly ref: VcsRef; + readonly lastCommit: number; + }> = []; for (const line of refsResult.stdout.split("\n")) { if (line.length === 0) continue; @@ -2702,7 +2754,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const epoch = bumpListRefsEpoch(cacheKey.gitCommonDir); return Cache.get( listRefsSnapshotCache, - new GitRefsSnapshotCacheKey({ gitCommonDir: cacheKey.gitCommonDir, epoch }), + new GitRefsSnapshotCacheKey({ + gitCommonDir: cacheKey.gitCommonDir, + epoch, + }), ); }), { @@ -2730,7 +2785,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ) : yield* Cache.get( listRefsSnapshotCache, - new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }), + new GitRefsSnapshotCacheKey({ + gitCommonDir, + epoch: currentEpoch, + }), ); if (currentListRefsGeneration(gitCommonDir) === generation) { return snapshot; @@ -2820,6 +2878,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const listWorktrees: GitVcsDriver.GitVcsDriver["Service"]["listWorktrees"] = Effect.fn( + "listWorktrees", + )(function* (input) { + const args = ["worktree", "list", "--porcelain", "-z"] as const; + const currentWorktreePath = yield* fileSystem.realPath(input.cwd).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.listWorktrees", + cwd: input.cwd, + args, + }), + detail: "Failed to resolve the current worktree path.", + cause, + }), + ), + ); + const stdout = yield* runGitStdout("GitVcsDriver.listWorktrees", input.cwd, args); + return { + worktrees: yield* Effect.filter( + parseWorktrees(stdout).map((worktree) => ({ + ...worktree, + path: path.normalize(path.resolve(worktree.path)), + })), + (worktree) => + fileSystem.stat(worktree.path).pipe( + Effect.as(worktree.path !== currentWorktreePath), + Effect.orElseSucceed(() => false), + ), + { concurrency: 16 }, + ), + isRepo: true, + }; + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { @@ -2932,7 +3026,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = Effect.fn("refreshCheckedOutBranch")(function* (input) { - const { commitSha: headCommit } = yield* resolveCommit({ cwd: input.cwd, revision: "HEAD" }); + const { commitSha: headCommit } = yield* resolveCommit({ + cwd: input.cwd, + revision: "HEAD", + }); if (headCommit === input.targetCommit) { return { headCommit, moved: false, onTarget: true }; } @@ -2967,7 +3064,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.refreshCheckedOutBranch.keepPrevious", input.cwd, ["update-ref", "refs/t3code/pre-refresh", headCommit], - { fallbackErrorDetail: "git failed to record the previous checkout commit" }, + { + fallbackErrorDetail: "git failed to record the previous checkout commit", + }, ); } @@ -3098,7 +3197,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, ); return yield* new GitCommandError({ - ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }), + ...gitCommandContext({ + operation: "GitVcsDriver.removeWorktree", + cwd: input.cwd, + args, + }), detail: "git worktree remove failed", ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, @@ -3302,6 +3405,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffFileContents, readConfigValue, listRefs, + listWorktrees, createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 00192e9eeaaa..eb4ecc9aade8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1393,7 +1393,9 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }).pipe( + Queue.offer(liveBuffer, { + kind: "synchronized" as const, + }).pipe( Effect.andThen(Queue.takeAll(liveBuffer)), Effect.flatMap(coalesceShellLiveInputs), ), @@ -1543,7 +1545,9 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), + Queue.offer(liveBuffer, { + kind: "synchronized" as const, + }), ).pipe(Stream.drain), bufferedLiveStream, ) @@ -1585,7 +1589,9 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), + Queue.offer(liveBuffer, { + kind: "synchronized" as const, + }), ).pipe(Stream.drain), bufferedLiveStream, ) @@ -2184,6 +2190,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { "rpc.aggregate": "vcs", }), + [WS_METHODS.vcsListWorktrees]: (input) => + observeRpcEffect(WS_METHODS.vcsListWorktrees, gitWorkflow.listWorktrees(input), { + "rpc.aggregate": "vcs", + }), [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsCreateWorktree, diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 251b07688121..9cf6f110d4d5 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -14,8 +14,6 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, resolveLocalCheckoutBranchMismatch, - resolvePreviousWorktreeLabel, - resolvePreviousWorktreeSeed, sanitizeNewRefName, shouldIncludeBranchPickerItem, shouldShowComposerContextStrip, @@ -25,91 +23,6 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); -describe("resolvePreviousWorktreeSeed", () => { - it("picks the most recently updated worktree thread", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/older", - worktreePath: "/repo/.t3/worktrees/older", - updatedAt: "2026-07-20T00:00:00.000Z", - }, - { - branch: "t3/newer", - worktreePath: "/repo/.t3/worktrees/newer", - updatedAt: "2026-07-22T00:00:00.000Z", - }, - { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" }, - ], - currentWorktreePath: null, - }), - ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" }); - }); - - it("skips the worktree the composer already points at", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/current", - worktreePath: "/repo/.t3/worktrees/current", - updatedAt: "2026-07-22T00:00:00.000Z", - }, - ], - currentWorktreePath: "/repo/.t3/worktrees/current", - }), - ).toBeNull(); - }); - - it("returns null when no thread has a worktree", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }], - currentWorktreePath: null, - }), - ).toBeNull(); - }); - - it("ignores archived threads and threads with unparseable timestamps", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/archived", - worktreePath: "/repo/.t3/worktrees/archived", - updatedAt: "2026-07-23T00:00:00.000Z", - archivedAt: "2026-07-23T01:00:00.000Z", - }, - { - branch: "t3/garbage-timestamp", - worktreePath: "/repo/.t3/worktrees/garbage", - updatedAt: "not-a-date", - }, - { - branch: "t3/live", - worktreePath: "/repo/.t3/worktrees/live", - updatedAt: "2026-07-21T00:00:00.000Z", - archivedAt: null, - }, - ], - currentWorktreePath: null, - }), - ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" }); - }); -}); - -describe("resolvePreviousWorktreeLabel", () => { - it("includes the branch when known", () => { - expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( - "Previous worktree (t3/fix-thing)", - ); - expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( - "Previous worktree", - ); - }); -}); - describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0a8e07d1958b..6850ee9f8dbe 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,6 +1,5 @@ import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, @@ -74,53 +73,6 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Worktree" : "Local checkout"; } -export interface PreviousWorktreeSeed { - branch: string | null; - worktreePath: string; -} - -// The most recently touched worktree in the project that the composer isn't -// already pointing at. Backs the "Previous worktree" entry in the workspace -// selector so a follow-up thread can hop back into the worktree you just -// worked in without hunting for its branch. Archived threads don't compete — -// the rest of the UI hides them, so their worktrees shouldn't resurface here. -export function resolvePreviousWorktreeSeed(input: { - threads: ReadonlyArray<{ - branch: string | null; - worktreePath: string | null; - updatedAt: string; - archivedAt?: string | null; - }>; - currentWorktreePath: string | null; -}): PreviousWorktreeSeed | null { - let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null; - for (const thread of input.threads) { - if ( - !thread.worktreePath || - thread.worktreePath === input.currentWorktreePath || - (thread.archivedAt ?? null) !== null - ) { - continue; - } - const updatedAt = toSortableTimestamp(thread.updatedAt); - if (updatedAt === null) { - continue; - } - if (latest === null || updatedAt > latest.updatedAt) { - latest = { - branch: thread.branch, - worktreePath: thread.worktreePath, - updatedAt, - }; - } - } - return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; -} - -export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { - return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree"; -} - export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5d11cce11fbe..fc2af7443f1f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,18 +1,19 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId, VcsWorktree } from "@t3tools/contracts"; import { ChevronDownIcon, CloudIcon, FolderGit2Icon, FolderGitIcon, FolderIcon, - HistoryIcon, MonitorIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; +import { useProject, useThread } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { vcsEnvironment } from "../state/vcs"; import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, @@ -21,13 +22,12 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, - resolvePreviousWorktreeLabel, - resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { ExistingWorktreesMenuSub } from "./ExistingWorktreesMenuSub"; import { Button } from "./ui/button"; import { Menu, @@ -70,8 +70,8 @@ interface MobileRunContextSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; - previousWorktreeLabel: string | null; - onUsePreviousWorktree: () => void; + existingWorktrees: ReadonlyArray; + onUseExistingWorktree: (worktree: VcsWorktree) => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -85,8 +85,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ effectiveEnvMode, activeWorktreePath, onEnvModeChange, - previousWorktreeLabel, - onUsePreviousWorktree, + existingWorktrees, + onUseExistingWorktree, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, @@ -174,13 +174,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Workspace { - if (value === "previous-worktree") { - onUsePreviousWorktree(); - return; - } - onEnvModeChange(value as EnvMode); - }} + onValueChange={(value) => onEnvModeChange(value as EnvMode)} > @@ -200,16 +194,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {resolveEnvModeLabel("worktree")} - {previousWorktreeLabel ? ( - - - - {previousWorktreeLabel} - - - ) : null} + {existingWorktrees.length > 0 ? : null} + ); @@ -397,7 +389,9 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverThread = useThread(threadRef, { + waitForShell: draftThread !== null, + }); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) @@ -416,39 +410,36 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); - // "Previous worktree" hops a draft into the most recently active worktree - // of this project — the "keep going where I just was" follow-up flow. Only - // drafts can hop; started server threads have their workspace pinned. - const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; - const projectRefsForWorktreeLookup = useMemo( - () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), - [canUsePreviousWorktree, activeProjectRef], + const canUseExistingWorktree = draftThread !== null && serverThread === null && !envModeLocked; + const worktreesQuery = useEnvironmentQuery( + canUseExistingWorktree && activeProject + ? vcsEnvironment.listWorktrees({ + environmentId, + input: { cwd: activeProject.workspaceRoot }, + }) + : null, ); - const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); - const previousWorktreeSeed = useMemo( + const existingWorktrees = useMemo( () => - canUsePreviousWorktree - ? resolvePreviousWorktreeSeed({ - threads: projectThreads, - currentWorktreePath: activeWorktreePath, - }) - : null, - [activeWorktreePath, canUsePreviousWorktree, projectThreads], + canUseExistingWorktree && activeProject + ? (worktreesQuery.data?.worktrees ?? []).filter( + (worktree) => worktree.path !== activeWorktreePath, + ) + : [], + [activeProject, activeWorktreePath, canUseExistingWorktree, worktreesQuery.data?.worktrees], + ); + const onUseExistingWorktree = useCallback( + (worktree: VcsWorktree) => { + if (!activeProjectRef) return; + setDraftThreadContext(draftId ?? threadRef, { + branch: worktree.branch, + worktreePath: worktree.path, + envMode: "worktree", + projectRef: activeProjectRef, + }); + }, + [activeProjectRef, draftId, setDraftThreadContext, threadRef], ); - const previousWorktreeLabel = previousWorktreeSeed - ? resolvePreviousWorktreeLabel(previousWorktreeSeed) - : null; - const onUsePreviousWorktree = useCallback(() => { - if (!previousWorktreeSeed || !activeProjectRef) return; - // Same shape the branch selector writes when picking a branch that - // already lives in a worktree: point the draft at the existing tree. - setDraftThreadContext(draftId ?? threadRef, { - branch: previousWorktreeSeed.branch, - worktreePath: previousWorktreeSeed.worktreePath, - envMode: "worktree", - projectRef: activeProjectRef, - }); - }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, @@ -483,8 +474,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} - previousWorktreeLabel={previousWorktreeLabel} - onUsePreviousWorktree={onUsePreviousWorktree} + existingWorktrees={existingWorktrees} + onUseExistingWorktree={onUseExistingWorktree} /> ) : (
@@ -511,8 +502,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} - previousWorktreeLabel={previousWorktreeLabel} - onUsePreviousWorktree={onUsePreviousWorktree} + existingWorktrees={existingWorktrees} + onUseExistingWorktree={onUseExistingWorktree} /> ) : null}
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 9fc2d4892e27..bb82547feed9 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,5 +1,6 @@ -import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react"; -import { memo, useMemo } from "react"; +import { ChevronDownIcon, FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; +import { memo } from "react"; +import type { VcsWorktree } from "@t3tools/contracts"; import { resolveCurrentWorkspaceLabel, @@ -7,25 +8,26 @@ import { resolveLockedWorkspaceLabel, type EnvMode, } from "./BranchToolbar.logic"; +import { ExistingWorktreesMenuSub } from "./ExistingWorktreesMenuSub"; +import { Button } from "./ui/button"; import { - Select, - SelectGroup, - SelectGroupLabel, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "./ui/select"; - -export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; + Menu, + MenuGroup, + MenuGroupLabel, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; - previousWorktreeLabel?: string | null; - onUsePreviousWorktree?: () => void; + existingWorktrees: ReadonlyArray; + onUseExistingWorktree: (worktree: VcsWorktree) => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ @@ -33,21 +35,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe effectiveEnvMode, activeWorktreePath, onEnvModeChange, - previousWorktreeLabel, - onUsePreviousWorktree, + existingWorktrees, + onUseExistingWorktree, }: BranchToolbarEnvModeSelectorProps) { - const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); - const envModeItems = useMemo( - () => [ - { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, - { value: "worktree", label: resolveEnvModeLabel("worktree") }, - ...(showPreviousWorktree && previousWorktreeLabel - ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] - : []), - ], - [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], - ); - if (envLocked) { return ( {activeWorktreePath ? ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + ) : ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + )} + {resolveLockedWorkspaceLabel(activeWorktreePath)} ); } + const workspaceLabel = + effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath); + return ( - + + + + {existingWorktrees.length > 0 ? : null} + + + ); }); diff --git a/apps/web/src/components/ExistingWorktreesMenuSub.tsx b/apps/web/src/components/ExistingWorktreesMenuSub.tsx new file mode 100644 index 000000000000..5bbdb6747b91 --- /dev/null +++ b/apps/web/src/components/ExistingWorktreesMenuSub.tsx @@ -0,0 +1,37 @@ +import { FolderGitIcon } from "lucide-react"; +import { memo } from "react"; +import { vcsWorktreeLabel } from "@t3tools/client-runtime/state/vcs"; +import type { VcsWorktree } from "@t3tools/contracts"; + +import { MenuItem, MenuSub, MenuSubPopup, MenuSubTrigger } from "./ui/menu"; + +interface ExistingWorktreesMenuSubProps { + worktrees: ReadonlyArray; + disabled: boolean; + onSelect: (worktree: VcsWorktree) => void; +} + +export const ExistingWorktreesMenuSub = memo(function ExistingWorktreesMenuSub({ + worktrees, + disabled, + onSelect, +}: ExistingWorktreesMenuSubProps) { + if (worktrees.length === 0) return null; + + return ( + + + + Worktrees + + + {worktrees.map((worktree) => ( + onSelect(worktree)}> + + {vcsWorktreeLabel(worktree)} + + ))} + + + ); +}); diff --git a/docs/user/composer.md b/docs/user/composer.md index 35d634556d88..a354922fe64f 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -25,3 +25,7 @@ On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from start it in the background. T3 Code opens another new thread and shows an **Open** action for the thread that started. The new thread keeps the selected workspace mode and base branch. If **New worktree** is selected, each background thread creates its own worktree. + +For Git projects, the workspace menu can use the current checkout, create a new worktree, or reuse +an existing worktree. Existing worktrees are listed by branch or as detached at a commit and remain +attached to their original directory. diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0a6264c62078..2b390a4bba67 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -32,6 +32,7 @@ import { commitVcsRefsRefresh, createVcsEnvironmentAtoms, makeCachedVcsRefsChanges, + vcsWorktreeLabel, } from "./vcs.ts"; import { invalidateCachedVcsRefs, @@ -55,6 +56,13 @@ const CONNECTED_CONNECTION_STATE: SupervisorConnectionState = { generation: 1, }; +describe("vcsWorktreeLabel", () => { + it("uses the branch name or detached commit", () => { + expect(vcsWorktreeLabel({ branch: "feature", head: "1234567890" })).toBe("feature"); + expect(vcsWorktreeLabel({ branch: null, head: "1234567890" })).toBe("Detached at 1234567"); + }); +}); + const CACHED_REFS: VcsListRefsResult = { refs: [ { diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 042548336720..295a67176786 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -3,6 +3,7 @@ import { type VcsListRefsInput, type VcsListRefsResult, type VcsStatusResult, + type VcsWorktree, WS_METHODS, } from "@t3tools/contracts"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; @@ -15,7 +16,11 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; @@ -37,6 +42,10 @@ const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( ), ); +export function vcsWorktreeLabel(worktree: Pick): string { + return worktree.branch ?? `Detached at ${worktree.head.slice(0, 7)}`; +} + function canUseVcsRefsCache(input: VcsListRefsInput): boolean { return ( input.query === undefined && @@ -61,7 +70,9 @@ export const commitVcsRefsRefresh = Effect.fn("CachedVcsRefsState.commitRefresh" return yield* withVcsRefsPersistenceLock( input.environmentId, Effect.gen(function* () { - const stateAtom = vcsRefsCacheStateAtom({ environmentId: input.environmentId }); + const stateAtom = vcsRefsCacheStateAtom({ + environmentId: input.environmentId, + }); const state = registry.get(stateAtom); if (state.revision !== input.expectedRevision) { return false; @@ -263,7 +274,10 @@ export function createVcsEnvironmentAtoms( readonly input: VcsListRefsInput; }) => listRefsFamily(JSON.stringify([target.environmentId, target.input])); const invalidateRefs = ( - target: { readonly environmentId: EnvironmentId; readonly input: { readonly cwd: string } }, + target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly cwd: string }; + }, registry: AtomRegistry.AtomRegistry, ) => invalidateCachedVcsRefs(registry, { @@ -273,6 +287,11 @@ export function createVcsEnvironmentAtoms( return { listRefs, + listWorktrees: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:list-worktrees", + tag: WS_METHODS.vcsListWorktrees, + staleTimeMs: 5_000, + }), status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", subscribe: (input: EnvironmentRpcInput) => diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 4ea86670ff8f..09a94a6079c8 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -7,6 +7,7 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, + VcsWorktree, } from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); @@ -16,6 +17,15 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); +const decodeWorktree = Schema.decodeUnknownSync(VcsWorktree); + +describe("VcsWorktree", () => { + it("preserves surrounding whitespace in paths", () => { + const path = "/tmp/worktree "; + + expect(decodeWorktree({ path, branch: "main", head: "abc123" }).path).toBe(path); + }); +}); describe("VcsCreateWorktreeInput", () => { it("accepts omitted newRefName for existing-refName worktrees", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 915c3627c9b9..39ec3763e2b0 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -4,6 +4,7 @@ import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceC import { VcsDriverKind } from "./vcs.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; +const NonEmptyStringSchema = Schema.String.check(Schema.isNonEmpty()); const GIT_LIST_BRANCHES_MAX_LIMIT = 200; // Domain Types @@ -83,10 +84,16 @@ export const VcsRef = Schema.Struct({ }); export type VcsRef = typeof VcsRef.Type; -const VcsWorktree = Schema.Struct({ +const VcsCreatedWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, }); +export const VcsWorktree = Schema.Struct({ + path: NonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + head: TrimmedNonEmptyStringSchema, +}); +export type VcsWorktree = typeof VcsWorktree.Type; const GitResolvedPullRequest = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, @@ -134,6 +141,11 @@ export const VcsListRefsInput = Schema.Struct({ }); export type VcsListRefsInput = typeof VcsListRefsInput.Type; +export const VcsListWorktreesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsListWorktreesInput = typeof VcsListWorktreesInput.Type; + export const VcsCreateWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, @@ -270,8 +282,14 @@ export const VcsListRefsResult = Schema.Struct({ }); export type VcsListRefsResult = typeof VcsListRefsResult.Type; +export const VcsListWorktreesResult = Schema.Struct({ + worktrees: Schema.Array(VcsWorktree), + isRepo: Schema.Boolean, +}); +export type VcsListWorktreesResult = typeof VcsListWorktreesResult.Type; + export const VcsCreateWorktreeResult = Schema.Struct({ - worktree: VcsWorktree, + worktree: VcsCreatedWorktree, }); export type VcsCreateWorktreeResult = typeof VcsCreateWorktreeResult.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..5f18d2cdee53 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -39,6 +39,8 @@ import { VcsInitInput, VcsListRefsInput, VcsListRefsResult, + VcsListWorktreesInput, + VcsListWorktreesResult, GitManagerServiceError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, @@ -233,6 +235,7 @@ export const WS_METHODS = { vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", + vcsListWorktrees: "vcs.listWorktrees", vcsCreateWorktree: "vcs.createWorktree", vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", @@ -744,6 +747,12 @@ export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsListWorktreesRpc = Rpc.make(WS_METHODS.vcsListWorktrees, { + payload: VcsListWorktreesInput, + success: VcsListWorktreesResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, @@ -1079,6 +1088,7 @@ export const WsRpcGroup = RpcGroup.make( WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, + WsVcsListWorktreesRpc, WsVcsCreateWorktreeRpc, WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, From e023cdee0244774ec0a585baee85e5b77f1910ae Mon Sep 17 00:00:00 2001 From: Casper Engelmann Date: Fri, 28 Aug 2026 18:37:46 +0200 Subject: [PATCH 2/2] feat(mobile): reuse existing worktrees --- apps/mobile/src/Stack.tsx | 8 ++ .../threads/NewTaskContextPickerScreens.tsx | 103 +++++++++++++++++- .../features/threads/NewTaskDraftScreen.tsx | 26 +++-- .../threads/new-task-flow-provider.tsx | 44 +++++++- .../projectThreadCreationValidation.test.ts | 16 +++ .../projectThreadCreationValidation.ts | 4 + 6 files changed, 186 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..db9f48652510 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -42,6 +42,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr import { NewTaskBranchPickerRouteScreen, NewTaskEnvironmentPickerRouteScreen, + NewTaskWorkspacePickerRouteScreen, } from "./features/threads/NewTaskContextPickerScreens"; import { ExistingThreadSettingsRouteProvider, @@ -273,6 +274,13 @@ const NewTaskSheetStack = createNativeStackNavigator({ title: "Environment", }, }), + NewTaskWorkspace: createNativeStackScreen({ + screen: NewTaskWorkspacePickerRouteScreen, + linking: "draft/workspace", + options: { + title: "Workspace", + }, + }), NewTaskBranch: createNativeStackScreen({ screen: NewTaskBranchPickerRouteScreen, linking: "draft/branch", diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 97bb2ab98291..10ebc81e298a 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -1,4 +1,4 @@ -import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { vcsWorktreeLabel, type VcsRef } from "@t3tools/client-runtime/state/vcs"; import { LegendList } from "@legendapp/list/react-native"; import { isAtomCommandInterrupted, @@ -27,6 +27,7 @@ import { useFontFamily } from "../../lib/useFontFamily"; import { useThemeColor } from "../../lib/useThemeColor"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useAtomCommand } from "../../state/use-atom-command"; +import { useEnvironmentQuery } from "../../state/query"; import { vcsEnvironment } from "../../state/vcs"; import { createNativeMailSearchToolbarItem, @@ -141,6 +142,91 @@ function PickerSurface(props: { readonly children: ReactNode }) { return {props.children}; } +export function NewTaskWorkspacePickerRouteScreen() { + const flow = useNewTaskFlow(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const worktreesQuery = useEnvironmentQuery( + flow.selectedProject + ? vcsEnvironment.listWorktrees({ + environmentId: flow.selectedProject.environmentId, + input: { cwd: flow.selectedProject.workspaceRoot }, + }) + : null, + ); + const worktrees = worktreesQuery.data?.worktrees ?? []; + + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + { + void Haptics.selectionAsync(); + flow.setWorkspaceMode("local"); + navigation.goBack(); + }} + selected={flow.workspaceMode === "local" && flow.selectedWorktreePath === null} + title="Current checkout" + /> + { + void Haptics.selectionAsync(); + flow.setWorkspaceMode("worktree"); + navigation.goBack(); + }} + selected={flow.workspaceMode === "worktree"} + title="New worktree" + /> + + {worktrees.length > 0 ? ( + + + Worktrees + + + {worktrees.map((worktree, index) => ( + { + void Haptics.selectionAsync(); + flow.selectWorktree(worktree); + navigation.goBack(); + }} + selected={ + flow.workspaceMode === "local" && flow.selectedWorktreePath === worktree.path + } + subtitle={worktree.path} + title={vcsWorktreeLabel(worktree)} + /> + ))} + + + ) : null} + + + ); +} + export function NewTaskEnvironmentPickerRouteScreen() { const flow = useNewTaskFlow(); const navigation = useNavigation(); @@ -194,7 +280,9 @@ export function NewTaskBranchPickerRouteScreen() { const placeholderColor = useThemeColor("--color-placeholder"); const foregroundColor = useThemeColor("--color-foreground"); const fontFamily = useFontFamily("regular"); - const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { + reportFailure: false, + }); const [switchingBranchName, setSwitchingBranchName] = useState(null); const selectingBranchNameRef = useRef(null); const allowSelectionNavigationRef = useRef(false); @@ -310,7 +398,10 @@ export function NewTaskBranchPickerRouteScreen() { const renderBranch = useCallback( ({ item, index }: { readonly item: VcsRef; readonly index: number }) => ( diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..10b99031a363 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -241,7 +241,9 @@ export function NewTaskDraftScreen(props: { // route, otherwise the transfer guard can swallow the fallback action. const frame = requestAnimationFrame(() => { navigation.dispatch( - StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }), + StackActions.replace("NewTask", { + incomingShareId: props.incomingShareId, + }), ); }); return () => cancelAnimationFrame(frame); @@ -596,6 +598,7 @@ export function NewTaskDraftScreen(props: { selectedBranch: flow.selectedBranchName ?? (flow.workspaceMode === "worktree" ? availableCurrentBranchName : null), + selectedWorktreePath: flow.selectedWorktreePath, currentCheckoutBranch: flow.currentCheckoutBranchName, }); const selectedBranchLabel = resolveNewTaskBranchLabel({ @@ -613,7 +616,9 @@ export function NewTaskDraftScreen(props: { if (isIncomingShareTransferPending) { return; } - const result = await pickComposerImages({ existingCount: flow.attachments.length }); + const result = await pickComposerImages({ + existingCount: flow.attachments.length, + }); if (result.images.length > 0) { flow.appendAttachments(result.images); } @@ -726,6 +731,7 @@ export function NewTaskDraftScreen(props: { const creationBranch = resolveProjectThreadCreationBranch({ workspaceMode, selectedBranch: selectedBranchName, + selectedWorktreePath, currentCheckoutBranch: flow.currentCheckoutBranchName, }); const result = await createProjectThread({ @@ -830,7 +836,11 @@ export function NewTaskDraftScreen(props: { paddingHorizontal: 4, paddingVertical: 4, }} - textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }} + textStyle={{ + ...bodyText, + color: foregroundColor, + fontFamily: regularFontFamily, + }} /> ); @@ -851,7 +861,9 @@ export function NewTaskDraftScreen(props: { void KeyboardController.dismiss({ animated: true }); navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId })); }; - const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => { + const openContextPicker = ( + routeName: "NewTaskBranch" | "NewTaskEnvironment" | "NewTaskWorkspace", + ) => { if (isIncomingShareTransferPending) { return; } @@ -927,7 +939,7 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} - showChevron={false} + onPress={() => openContextPicker("NewTaskWorkspace")} + showChevron /> void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; readonly selectBranch: (branch: VcsRef) => void; + readonly selectWorktree: (worktree: VcsWorktree) => void; readonly setStartFromOrigin: (value: boolean) => void; readonly beginEditingPendingTask: (messageId: string) => boolean; readonly finishEditingPendingTask: () => void; @@ -367,7 +369,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProject !== null && selectedProject.workspaceRoot !== "" ? projectEnvironment.readFile({ environmentId: selectedProject.environmentId, - input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, + input: { + cwd: selectedProject.workspaceRoot, + relativePath: T3_PROJECT_FILE_NAME, + }, }) : null, ); @@ -652,7 +657,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceSelection: { mode, branch: mode === "local" ? localSelection.branch : selectedBranchName, - worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, + worktreePath: + mode === "local" + ? localSelection.worktreePath + : resolveNewTaskBranchWorktreePath({ + workspaceMode: mode, + projectCwd: selectedProject.workspaceRoot, + branchWorktreePath: selectedWorktreePath, + }), ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); @@ -723,6 +735,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [draftStartFromOrigin, selectedProject, selectedProjectDraftKey, workspaceMode], ); + const selectWorktree = useCallback( + (worktree: VcsWorktree) => { + if (!selectedProjectDraftKey) { + return; + } + pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: "local", + branch: worktree.branch, + worktreePath: worktree.path, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), + }, + }); + }, + [draftStartFromOrigin, selectedProjectDraftKey], + ); + const setStartFromOrigin = useCallback( (value: boolean) => { if (!selectedProjectDraftKey) { @@ -779,7 +809,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const setRuntimeMode = useCallback( (value: RuntimeMode) => { if (selectedProjectDraftKey) { - updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); + updateComposerDraftSettings(selectedProjectDraftKey, { + runtimeMode: value, + }); } }, [selectedProjectDraftKey], @@ -787,7 +819,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const setInteractionMode = useCallback( (value: ProviderInteractionMode) => { if (selectedProjectDraftKey) { - updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value }); + updateComposerDraftSettings(selectedProjectDraftKey, { + interactionMode: value, + }); } }, [selectedProjectDraftKey], @@ -1037,6 +1071,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSelectedModelKey, setWorkspaceMode, selectBranch, + selectWorktree, setStartFromOrigin, beginEditingPendingTask, finishEditingPendingTask, @@ -1097,6 +1132,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedWorktreePath, setProject, selectBranch, + selectWorktree, selectEnvironment, setInteractionMode, setPrompt, diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts index 5c4980e6e03d..6e0b353e3285 100644 --- a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts @@ -8,6 +8,7 @@ describe("resolveProjectThreadCreationBranch", () => { resolveProjectThreadCreationBranch({ workspaceMode: "local", selectedBranch: null, + selectedWorktreePath: null, currentCheckoutBranch: "feature/x", }), ).toBe("feature/x"); @@ -18,6 +19,7 @@ describe("resolveProjectThreadCreationBranch", () => { resolveProjectThreadCreationBranch({ workspaceMode: "local", selectedBranch: "main", + selectedWorktreePath: null, currentCheckoutBranch: "feature/x", }), ).toBe("main"); @@ -28,6 +30,7 @@ describe("resolveProjectThreadCreationBranch", () => { resolveProjectThreadCreationBranch({ workspaceMode: "local", selectedBranch: null, + selectedWorktreePath: null, currentCheckoutBranch: null, }), ).toBeNull(); @@ -38,6 +41,7 @@ describe("resolveProjectThreadCreationBranch", () => { resolveProjectThreadCreationBranch({ workspaceMode: "worktree", selectedBranch: null, + selectedWorktreePath: null, currentCheckoutBranch: "feature/x", }), ).toBeNull(); @@ -48,8 +52,20 @@ describe("resolveProjectThreadCreationBranch", () => { resolveProjectThreadCreationBranch({ workspaceMode: "worktree", selectedBranch: "main", + selectedWorktreePath: null, currentCheckoutBranch: "feature/x", }), ).toBe("main"); }); + + it("keeps a selected detached worktree detached", () => { + expect( + resolveProjectThreadCreationBranch({ + workspaceMode: "local", + selectedBranch: null, + selectedWorktreePath: "/worktrees/detached", + currentCheckoutBranch: "main", + }), + ).toBeNull(); + }); }); diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts index f2c98bdfb1b4..c38949de60be 100644 --- a/apps/mobile/src/features/threads/projectThreadCreationValidation.ts +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts @@ -44,11 +44,15 @@ export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationV export function resolveProjectThreadCreationBranch(input: { readonly workspaceMode: "local" | "worktree"; readonly selectedBranch: string | null; + readonly selectedWorktreePath: string | null; readonly currentCheckoutBranch: string | null; }): string | null { if (input.selectedBranch !== null) { return input.selectedBranch; } + if (input.selectedWorktreePath !== null) { + return null; + } return input.workspaceMode === "local" ? input.currentCheckoutBranch : null; }