From 4946e41990eb486f61f6fc3ee7bd90da4b677012 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:21:25 -0700 Subject: [PATCH] fix: root monorepo subfolder imports at the git repo while opening the first terminal in the subfolder Add Project -> Browse folder on a monorepo subfolder (e.g. /tmp/monorepo/packages/web) stored the subfolder as Repo.path because isGitRepo() uses `git rev-parse --is-inside-work-tree`, which is true for any subdirectory. Authoritative worktree resolution then snapped activation back to the repo root, so the import identity mismatched and the first terminal landed at the repo root rather than the selected subfolder. Canonicalize local git imports to the actual repo root via a new getGitRepoRoot() (mirroring the SSH import path that already snaps to check.rootPath, including its post-resolution dedup), and preserve the user-selected subfolder as a one-shot initial terminal cwd consumed only by the first activation-created pane. Selecting the repo root applies no override, and re-importing a subfolder of an existing project dedupes. Fixes #6336 Co-authored-by: Rod Boev --- src/main/git/repo-detection.test.ts | 22 +++++- src/main/git/repo.ts | 22 ++++++ src/main/ipc/repos-remote.test.ts | 42 +++++++++- src/main/ipc/repos.ts | 21 ++++- .../AddProjectFromFolderDialog.test.tsx | 3 + .../sidebar/AddProjectFromFolderDialog.tsx | 1 + .../project-added-default-checkout.test.ts | 32 ++++++++ .../sidebar/project-added-default-checkout.ts | 30 +++++++- .../terminal-link-handlers.test.ts | 23 +++++- .../terminal-pane/terminal-link-handlers.ts | 8 +- .../use-terminal-pane-lifecycle.test.ts | 74 ++++++++++++++++++ .../use-terminal-pane-lifecycle.ts | 68 +++++++++++++++- .../src/lib/worktree-activation.test.ts | 77 ++++++++++++++++++- src/renderer/src/lib/worktree-activation.ts | 5 ++ .../src/store/slices/store-cascades.test.ts | 5 ++ .../store/slices/terminal-orphan-helpers.ts | 6 ++ src/renderer/src/store/slices/terminals.ts | 29 +++++++ 17 files changed, 452 insertions(+), 16 deletions(-) diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index 67597384782..71f3cbce302 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import * as path from 'path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { isGitRepo } from './repo' +import { getGitRepoRoot, isGitRepo } from './repo' function git(cwd: string, args: string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }) @@ -34,4 +34,24 @@ describe('isGitRepo', () => { expect(isGitRepo(bareRepo)).toBe(true) }) + + it('resolves a contained path to the worktree root', () => { + const repoRoot = path.join(tmpDir, 'repo') + const nestedDir = path.join(repoRoot, 'packages', 'web') + mkdirSync(nestedDir, { recursive: true }) + git(tmpDir, ['init', '--quiet', repoRoot]) + + // Why: derive the expected root from git's own --show-toplevel so the + // assertion matches getGitRepoRoot's canonicalization (e.g. macOS resolves + // the /var tmpdir symlink to /private/var) across all platforms. + const expectedRoot = git(repoRoot, ['rev-parse', '--show-toplevel']).trim().replace(/\\/g, '/') + expect(getGitRepoRoot(nestedDir)).toBe(expectedRoot) + }) + + it('preserves bare repository paths when no worktree root exists', () => { + const bareRepo = path.join(tmpDir, 'bare.git') + git(tmpDir, ['init', '--bare', '--quiet', bareRepo]) + + expect(getGitRepoRoot(bareRepo)).toBe(bareRepo) + }) }) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index e3d12f140f8..299d7ed4fbe 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -5,6 +5,7 @@ import { basename } from 'path' import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl, @@ -89,6 +90,27 @@ export function isGitRepo(path: string): boolean { } } +export function getGitRepoRoot(path: string): string { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return path + } + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + return normalizeRuntimePathSeparators( + gitExecFileSync(['rev-parse', '--show-toplevel'], { + cwd: path + }).trim() + ) + } + } catch { + // Fall through to preserving the original path. + } + return path +} + /** * Get a human-readable name for the repo from its path. */ diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index b7aa15bbc95..62860a5c691 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'os' import { join } from 'path' import type * as RepoModule from '../git/repo' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -import { isGitRepo } from '../git/repo' +import { getGitRepoRoot, isGitRepo } from '../git/repo' const { handleMock, @@ -93,6 +93,7 @@ vi.mock('../git/repo', async () => { ...actual, // Stub only the functions that spawn git / touch the filesystem. isGitRepo: vi.fn().mockReturnValue(true), + getGitRepoRoot: vi.fn((path: string) => path), getGitUsername: vi.fn().mockReturnValue(''), getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), @@ -185,6 +186,8 @@ describe('projectGroups IPC validation', () => { listWorktreeGraphMock.mockResolvedValue([]) vi.mocked(isGitRepo).mockReset() vi.mocked(isGitRepo).mockReturnValue(true) + vi.mocked(getGitRepoRoot).mockReset() + vi.mocked(getGitRepoRoot).mockImplementation((path: string) => path) mockMultiplexer.notify.mockReset() mockMultiplexer.request.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() @@ -1756,6 +1759,43 @@ describe('repos:add + repos:clone', () => { ) }) + it('canonicalizes local git repos:add to the detected root path', async () => { + vi.mocked(getGitRepoRoot).mockReturnValue('/tmp/from-add') + + const result = await handlers.get('repos:add')!(null, { + path: '/tmp/from-add/packages/web', + kind: 'git' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/from-add', + displayName: 'from-add' + }) + ) + expect(result).toHaveProperty('repo.path', '/tmp/from-add') + }) + + it('dedupes local git repos:add after canonical root resolution', async () => { + const existing = { + id: 'repo-add-existing-root', + path: '/tmp/from-add', + displayName: 'from-add', + kind: 'git', + badgeColor: '#22c55e' + } + mockStore.getRepos.mockReturnValue([existing]) + vi.mocked(getGitRepoRoot).mockReturnValue('/tmp/from-add') + + const result = await handlers.get('repos:add')!(null, { + path: '/tmp/from-add/packages/web', + kind: 'git' + }) + + expect(result).toEqual({ repo: existing }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('returns existing badgeColor unchanged on repos:add dedupe', async () => { const existing = { id: 'repo-add-existing', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index e3959bb48af..75273dba20d 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -60,6 +60,7 @@ import { import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target' import { isGitRepo, + getGitRepoRoot, getGitUsername, getRepoName, getBaseRefDefault, @@ -183,6 +184,7 @@ async function addLocalRepoFromPath( return { error: `Not a valid git repository: ${path}` } } + const resolvedPath = repoKind === 'git' ? getGitRepoRoot(path) : path const pathKey = normalizeRuntimePathForComparison(path) const existing = store .getRepos() @@ -191,11 +193,24 @@ async function addLocalRepoFromPath( return { repo: existing, alreadyExisted: true } } - const detected = await detectRepoIconAndUpstream({ repoPath: path, kind: repoKind }) + const resolvedPathKey = normalizeRuntimePathForComparison(resolvedPath) + if (resolvedPathKey !== pathKey) { + const existingAfterRootResolve = store + .getRepos() + .find( + (repo) => + !repo.connectionId && normalizeRuntimePathForComparison(repo.path) === resolvedPathKey + ) + if (existingAfterRootResolve) { + return { repo: existingAfterRootResolve, alreadyExisted: true } + } + } + + const detected = await detectRepoIconAndUpstream({ repoPath: resolvedPath, kind: repoKind }) const repo: Repo = { id: randomUUID(), - path, - displayName: getRepoName(path), + path: resolvedPath, + displayName: getRepoName(resolvedPath), badgeColor: DEFAULT_REPO_BADGE_COLOR, ...detected, addedAt: Date.now(), diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx index 5b7b95c8468..56d32c5aef8 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx @@ -157,6 +157,7 @@ describe('AddProjectFromFolderDialog', () => { expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({ repoId: repo.id, source: 'local_folder_picker', + selectedPath: '/projects/child', closeModal: mocks.state.closeModal, setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace }) @@ -206,6 +207,7 @@ describe('AddProjectFromFolderDialog', () => { expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({ repoId: repo.id, source: 'ssh_remote_path', + selectedPath: '/srv/projects/child', closeModal: mocks.state.closeModal, setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace }) @@ -229,6 +231,7 @@ describe('AddProjectFromFolderDialog', () => { expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({ repoId: repo.id, source: 'local_folder_picker', + selectedPath: '/projects/child', closeModal: mocks.state.closeModal, setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace }) diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx index 12a6ae4222b..483476e9c83 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx @@ -118,6 +118,7 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo await finishProjectAddWithDefaultCheckout({ repoId: repo.id, source: connectionId ? 'ssh_remote_path' : 'local_folder_picker', + selectedPath: folderPath, closeModal, setHideDefaultBranchWorkspace }) diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts index d51337dc814..e7c3c061c27 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts @@ -169,6 +169,38 @@ describe('finishProjectAddWithDefaultCheckout', () => { expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::/repo') }) + it('passes a contained selected path through as the initial terminal cwd', async () => { + mocks.state.worktreesByRepo = { + 'repo-1': [makeWorktree()] + } + + await openProjectDefaultCheckout({ + repoId: 'repo-1', + source: 'local_folder_picker', + selectedPath: '/repo/packages/web', + setHideDefaultBranchWorkspace: vi.fn() + }) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::/repo', { + initialCwd: '/repo/packages/web' + }) + }) + + it('skips the initial cwd override when the selected path is the repo root', async () => { + mocks.state.worktreesByRepo = { + 'repo-1': [makeWorktree()] + } + + await openProjectDefaultCheckout({ + repoId: 'repo-1', + source: 'local_folder_picker', + selectedPath: '/repo', + setHideDefaultBranchWorkspace: vi.fn() + }) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::/repo') + }) + it('shows a hidden detected default checkout before activating it', async () => { const defaultCheckout = makeWorktree() mocks.state.detectedWorktreesByRepo = { diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.ts index e522f7ce119..d8ed9fb4ff9 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.ts @@ -6,6 +6,7 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/types' +import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization' @@ -94,13 +95,26 @@ async function findDetectedDefaultCheckout(repoId: string): Promise<{ } } +function resolveInitialCwdForDefaultCheckout( + defaultCheckout: Worktree, + selectedPath: string | undefined +): string | undefined { + if (!selectedPath) { + return undefined + } + const relativePath = relativePathInsideRoot(defaultCheckout.path, selectedPath) + return relativePath && relativePath.length > 0 ? selectedPath : undefined +} + export async function openProjectDefaultCheckout({ repoId, source, + selectedPath, setHideDefaultBranchWorkspace }: { repoId: string source: AddRepoDefaultCheckoutHandoffSource + selectedPath?: string setHideDefaultBranchWorkspace: (value: boolean) => void }): Promise { let defaultCheckout = getProjectDefaultCheckout( @@ -135,7 +149,12 @@ export async function openProjectDefaultCheckout({ result: 'opened_default_checkout', reason }) - activateAndRevealWorktree(defaultCheckout.id) + const initialCwd = resolveInitialCwdForDefaultCheckout(defaultCheckout, selectedPath) + if (initialCwd) { + activateAndRevealWorktree(defaultCheckout.id, { initialCwd }) + } else { + activateAndRevealWorktree(defaultCheckout.id) + } return } @@ -150,15 +169,22 @@ export async function openProjectDefaultCheckout({ export async function finishProjectAddWithDefaultCheckout({ repoId, source, + selectedPath, closeModal, setHideDefaultBranchWorkspace }: { repoId: string source: AddRepoDefaultCheckoutHandoffSource + selectedPath?: string closeModal: () => void setHideDefaultBranchWorkspace: (value: boolean) => void }): Promise { await markOnboardingProjectAdded('addedRepo') closeModal() - await openProjectDefaultCheckout({ repoId, source, setHideDefaultBranchWorkspace }) + await openProjectDefaultCheckout({ + repoId, + source, + selectedPath, + setHideDefaultBranchWorkspace + }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 417fa613bea..5cbc01a0903 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -1016,7 +1016,8 @@ describe('createFilePathLinkProvider range bounds', () => { ['/repo/package.json', true], ['/repo/Folder With Space/content.js', true], ['/repo/My Folder', true] - ]) + ]), + depsOverrides: Partial[1]> = {} ) { const pane = makePane(rows) const managerRef = { @@ -1031,7 +1032,8 @@ describe('createFilePathLinkProvider range bounds', () => { startupCwd: '/repo', managerRef, linkProviderDisposablesRef: { current: new Map() }, - pathExistsCache + pathExistsCache, + ...depsOverrides }, linkTooltip, getTerminalFileOpenHint() @@ -1556,6 +1558,23 @@ describe('createFilePathLinkProvider range bounds', () => { expect(links.map((link) => link.text)).toEqual(['/repo/My Folder']) }) + it('uses the pane-specific cwd instead of a stale lifecycle startup cwd', async () => { + vi.mocked(window.api.shell.pathExists).mockImplementation(async (pathValue) => { + return pathValue === '/repo/package.json' + }) + const { provider } = createProviderSetup([makeBufferLine('package.json')], new Map(), { + startupCwd: '/repo/packages/web', + getPaneLinkCwd: () => '/repo' + }) + + const links = await new Promise((resolve) => { + provider.provideLinks(1, (provided) => resolve(provided ?? [])) + }) + + expect(links.map((link) => link.text)).toEqual(['package.json']) + expect(window.api.shell.pathExists).toHaveBeenCalledWith('/repo/package.json') + }) + it('opens an existing extensionless spaced prefix from direct fallback cache', async () => { setPlatform('Macintosh') const line = 'see /repo/My Folder now' diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 6b515226810..0e111027e37 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -46,6 +46,7 @@ export type LinkHandlerDeps = { worktreeId: string worktreePath: string startupCwd: string + getPaneLinkCwd?: (paneId: number) => string | null managerRef: React.RefObject linkProviderDisposablesRef: React.RefObject> pathExistsCache: Map @@ -123,8 +124,9 @@ export function createFilePathLinkProvider( logicalLines.flatMap((logicalLine) => extractTerminalFileLinkCandidates(logicalLine.text).map( async (parsed): Promise => { - const resolved = startupCwd - ? resolveTerminalFileLink(parsed, startupCwd, deps.terminalHomePath) + const paneLinkCwd = deps.getPaneLinkCwd?.(paneId) ?? startupCwd + const resolved = paneLinkCwd + ? resolveTerminalFileLink(parsed, paneLinkCwd, deps.terminalHomePath) : null if (!resolved) { return null @@ -289,7 +291,7 @@ export function installFilePathLinkClickFallback( position, terminal.cols, { - startupCwd: deps.startupCwd, + startupCwd: deps.getPaneLinkCwd?.(paneId) ?? deps.startupCwd, terminalHomePath: deps.terminalHomePath, worktreeId: deps.worktreeId, worktreePath: deps.worktreePath, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index e5aaba85a86..f76801870f5 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { + clearQueuedInitialCwdAfterFirstPane, mapRestoredPaneTitlesByPaneId, + resolvePaneLinkCwd, + resolvePaneSeedCwd, + resolveQueuedInitialCwd, shouldDetachPaneTransportOnUnmount, splitPaneWithOneShotStartup, suppressIntentionalPaneCloseExit @@ -161,6 +165,76 @@ describe('mapRestoredPaneTitlesByPaneId', () => { }) }) +describe('resolveQueuedInitialCwd', () => { + it('consumes the queued initial cwd once when the ref is unset', () => { + const consumeTabInitialCwd = vi.fn(() => '/repo/packages/web') + + expect(resolveQueuedInitialCwd(undefined, consumeTabInitialCwd, '/repo')).toEqual({ + queuedInitialCwd: '/repo/packages/web', + startupCwd: '/repo/packages/web' + }) + expect(consumeTabInitialCwd).toHaveBeenCalledTimes(1) + }) + + it('reuses the existing queued state without re-reading the store', () => { + const consumeTabInitialCwd = vi.fn(() => '/repo/packages/web') + + expect(resolveQueuedInitialCwd(null, consumeTabInitialCwd, '/repo')).toEqual({ + queuedInitialCwd: null, + startupCwd: '/repo' + }) + expect(resolveQueuedInitialCwd('/repo/packages/web', consumeTabInitialCwd, '/repo')).toEqual({ + queuedInitialCwd: '/repo/packages/web', + startupCwd: '/repo/packages/web' + }) + expect(consumeTabInitialCwd).not.toHaveBeenCalled() + }) +}) + +describe('clearQueuedInitialCwdAfterFirstPane', () => { + it('clears the one-shot cwd and restores the default cwd after the first pane', () => { + expect( + clearQueuedInitialCwdAfterFirstPane('/repo/packages/web', '/repo', '/repo/packages/web') + ).toEqual({ + queuedInitialCwd: null, + ptyCwd: '/repo' + }) + }) + + it('leaves the cwd unchanged when no one-shot override is queued', () => { + expect(clearQueuedInitialCwdAfterFirstPane(null, '/repo', '/repo')).toEqual({ + queuedInitialCwd: null, + ptyCwd: '/repo' + }) + }) +}) + +describe('resolvePaneLinkCwd', () => { + it('prefers the pane-specific cwd when one has been seeded or confirmed', () => { + expect( + resolvePaneLinkCwd( + new Map([[2, { cwd: '/repo/packages/web', confirmed: false }]]), + 2, + '/repo' + ) + ).toBe('/repo/packages/web') + }) + + it('falls back to the lifecycle startup cwd when the pane has no cached cwd yet', () => { + expect(resolvePaneLinkCwd(new Map(), 2, '/repo')).toBe('/repo') + }) +}) + +describe('resolvePaneSeedCwd', () => { + it('prefers the inherited split cwd before OSC 7 confirms the pane cwd', () => { + expect(resolvePaneSeedCwd('/repo/packages/web', '/repo')).toBe('/repo/packages/web') + }) + + it('falls back to the lifecycle cwd when the pane has no split override', () => { + expect(resolvePaneSeedCwd(undefined, '/repo')).toBe('/repo') + }) +}) + describe('suppressIntentionalPaneCloseExit', () => { it('suppresses the pane PTY exit before intentional close teardown destroys the transport', () => { const suppressPtyExit = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index fe67c1ad83a..e0a838029fd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -302,6 +302,42 @@ function hydrateTerminalScrollbackRefs(layout: TerminalLayoutSnapshot): { : { layout, hydrated } } +export function resolveQueuedInitialCwd( + queuedInitialCwd: string | null | undefined, + consumeTabInitialCwd: () => string | null, + defaultTabCwd: string +): { queuedInitialCwd: string | null; startupCwd: string } { + const nextQueuedInitialCwd = + queuedInitialCwd === undefined ? consumeTabInitialCwd() : queuedInitialCwd + return { + queuedInitialCwd: nextQueuedInitialCwd, + startupCwd: nextQueuedInitialCwd ?? defaultTabCwd + } +} + +export function clearQueuedInitialCwdAfterFirstPane( + queuedInitialCwd: string | null | undefined, + defaultTabCwd: string, + currentPtyCwd: string +): { queuedInitialCwd: string | null | undefined; ptyCwd: string } { + if (!queuedInitialCwd) { + return { queuedInitialCwd, ptyCwd: currentPtyCwd } + } + return { queuedInitialCwd: null, ptyCwd: defaultTabCwd } +} + +export function resolvePaneLinkCwd( + paneCwdMap: PaneCwdMap, + paneId: number, + fallbackCwd: string +): string { + return paneCwdMap.get(paneId)?.cwd ?? fallbackCwd +} + +export function resolvePaneSeedCwd(splitPaneCwd: string | undefined, fallbackCwd: string): string { + return splitPaneCwd ?? fallbackCwd +} + type SplitStartupPayload = { command: string; env?: Record } type SplitWithStartupDeps = { @@ -435,6 +471,7 @@ export function useTerminalPaneLifecycle({ const mouseHideDisposablesRef = useRef(new Map()) const imeCompositionDisposablesRef = useRef(new Map()) const imePunctuationForwarderDisposablesRef = useRef(new Map()) + const queuedInitialCwdRef = useRef(undefined) const applyAppearance = (manager: PaneManager): void => { const currentSettings = settingsRef.current @@ -509,8 +546,17 @@ export function useTerminalPaneLifecycle({ .find((candidate) => candidate.id === worktreeId)?.path ?? cwd ?? '' - const startupCwd = cwd ?? worktreePath + const defaultTabCwd = cwd ?? worktreePath + const initialCwdResolution = resolveQueuedInitialCwd( + queuedInitialCwdRef.current, + () => useAppStore.getState().consumeTabInitialCwd(tabId), + defaultTabCwd + ) + queuedInitialCwdRef.current = initialCwdResolution.queuedInitialCwd + const startupCwd = initialCwdResolution.startupCwd const terminalHomePath = resolveTerminalHomePathFromEnv(startup?.env) + const getPaneLinkCwd = (paneId: number): string => + resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd) // Why: existence probes can cross SSH/runtime boundaries. This cache is // lifecycle-scoped, so external mutations and the initial 'active' runtime // fallback can temporarily leave stale entries. @@ -519,6 +565,7 @@ export function useTerminalPaneLifecycle({ worktreeId, worktreePath, startupCwd, + getPaneLinkCwd, terminalHomePath, managerRef, linkProviderDisposablesRef, @@ -578,7 +625,7 @@ export function useTerminalPaneLifecycle({ const ptyDeps = { tabId, worktreeId, - cwd, + cwd: startupCwd, startup, paneTransportsRef, paneMode2031Ref, @@ -618,7 +665,7 @@ export function useTerminalPaneLifecycle({ const fileOpenLinkHint = getTerminalFileOpenHint() const urlOpenLinkHint = getTerminalUrlOpenHint() - const osc7UncHost = extractUncHost(cwd) + const osc7UncHost = extractUncHost(startupCwd) let releaseWebviewDragPassthrough: (() => void) | null = null @@ -671,6 +718,12 @@ export function useTerminalPaneLifecycle({ // Return true so xterm marks the sequence handled. If a future // consumer registers on code 7, registration order decides who sees // each sequence. + if (!paneCwdRef.current.has(pane.id)) { + paneCwdRef.current.set(pane.id, { + cwd: resolvePaneSeedCwd(spawnHints?.cwd, ptyDeps.cwd), + confirmed: false + }) + } const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => { const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost }) if (parsedCwd) { @@ -875,6 +928,7 @@ export function useTerminalPaneLifecycle({ activate: (event, text) => { handleOscLink(text, event as MouseEvent | undefined, { ...linkDeps, + startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, requestOpenLinksInAppPreference }) @@ -926,6 +980,13 @@ export function useTerminalPaneLifecycle({ // sets deps.startup immediately before splitPane() and is therefore // unaffected by this clear. ptyDeps.startup = null + const nextInitialCwdState = clearQueuedInitialCwdAfterFirstPane( + queuedInitialCwdRef.current, + defaultTabCwd, + ptyDeps.cwd + ) + queuedInitialCwdRef.current = nextInitialCwdState.queuedInitialCwd + ptyDeps.cwd = nextInitialCwdState.ptyCwd panePtyBindings.set(pane.id, panePtyBinding) syncPaneCount() scheduleRuntimeGraphSync() @@ -1180,6 +1241,7 @@ export function useTerminalPaneLifecycle({ const activePane = managerRef.current?.getActivePane() void handleOscLink(url, event, { ...linkDeps, + startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane ? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null) : null, diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 7321641de5c..d93c439f8c5 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: these activation cases share one mock store and assert ordering across startup, setup, issue commands, and default tabs. */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SetupScriptLaunchMode } from '../../../shared/types' -import { ensureWorktreeHasInitialTerminal } from './worktree-activation' +import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from './worktree-activation' import { useAppStore } from '@/store' function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void { @@ -35,6 +35,7 @@ function createMockStore(overrides: Record = {}) { markDefaultTerminalTabsApplied: vi.fn(), reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), queueTabStartupCommand: vi.fn(), + queueTabInitialCwd: vi.fn(), queueTabSetupSplit: vi.fn(), queueTabIssueCommandSplit: vi.fn(), ...overrides @@ -402,3 +403,77 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) }) + +describe('activateAndRevealWorktree', () => { + afterEach(() => { + useAppStore.setState({ + activeRepoId: null, + activeWorktreeId: null, + activeView: 'terminal', + filterRepoIds: [], + isNavigatingHistory: false + }) + }) + + it('queues a one-shot initial cwd for the primary activation-created tab', () => { + const queueTabInitialCwd = vi.fn() + useAppStore.setState({ + activeRepoId: null, + activeWorktreeId: null, + activeView: 'settings', + filterRepoIds: [], + isNavigatingHistory: false, + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo', + displayName: 'main', + branch: 'main', + head: 'abc', + isBare: false, + isMainWorktree: true + } + ] + }, + getKnownWorktreeById: (worktreeId: string) => + worktreeId === 'wt-1' + ? ({ + id: 'wt-1', + repoId: 'repo-1', + path: '/repo', + displayName: 'main', + branch: 'main', + head: 'abc', + isBare: false, + isMainWorktree: true + } as never) + : null, + setActiveRepo: vi.fn(), + setActiveView: vi.fn(), + setActiveWorktree: vi.fn(), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), + createTab: vi.fn(() => ({ id: 'tab-1' })), + setActiveTab: vi.fn(), + setTabCustomTitle: vi.fn(), + setTabColor: vi.fn(), + markDefaultTerminalTabsApplied: vi.fn(), + queueTabStartupCommand: vi.fn(), + queueTabInitialCwd, + queueTabSetupSplit: vi.fn(), + queueTabIssueCommandSplit: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as never) + + const result = activateAndRevealWorktree('wt-1', { + initialCwd: '/repo/packages/web' + }) + + expect(result).toEqual({ primaryTabId: 'tab-1' }) + expect(queueTabInitialCwd).toHaveBeenCalledWith('tab-1', '/repo/packages/web') + }) +}) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 818ed7959f6..54c7882a2d4 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -122,6 +122,7 @@ type WorktreeActivationStore = Partial & { tabId: string, startup: { command: string; env?: Record } ) => void + queueTabInitialCwd: (tabId: string, cwd: string) => void } /** @@ -265,6 +266,7 @@ export function activateAndRevealWorktree( worktreeId: string, opts?: { startup?: WorktreeStartupPayload + initialCwd?: string setup?: WorktreeSetupLaunch defaultTabs?: WorktreeDefaultTabsLaunch issueCommand?: IssueCommandLaunch @@ -348,6 +350,9 @@ export function activateAndRevealWorktree( opts?.issueCommand, opts?.defaultTabs ) + if (primaryTabId && opts?.initialCwd) { + useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) + } // 5. Clear sidebar filters that would hide the target worktree // Why: revealWorktreeInSidebar relies on the worktree card being rendered diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 4f2d06848e0..5c8b38072e4 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1705,6 +1705,7 @@ describe('setActiveWorktree', () => { 'canExpandPaneByTabId', 'terminalLayoutsByTabId', 'pendingStartupByTabId', + 'pendingInitialCwdByTabId', 'pendingSetupSplitByTabId', 'pendingIssueCommandSplitByTabId', 'tabBarOrderByWorktree', @@ -1745,6 +1746,9 @@ describe('setActiveWorktree', () => { pendingStartupByTabId: { [orphanId]: { command: 'codex' } }, + pendingInitialCwdByTabId: { + [orphanId]: '/repo/packages/web' + }, tabBarOrderByWorktree: { [wt]: [orphanId] }, @@ -1765,6 +1769,7 @@ describe('setActiveWorktree', () => { expect(s.runtimePaneTitlesByTabId[orphanId]).toBeUndefined() expect(s.terminalLayoutsByTabId[orphanId]).toBeUndefined() expect(s.pendingStartupByTabId[orphanId]).toBeUndefined() + expect(s.pendingInitialCwdByTabId[orphanId]).toBeUndefined() expect(s.cacheTimerByKey[`${orphanId}:seed`]).toBeUndefined() expect(s.terminalLayoutsByTabId[replacement.id]).toEqual(makeLayout()) }) diff --git a/src/renderer/src/store/slices/terminal-orphan-helpers.ts b/src/renderer/src/store/slices/terminal-orphan-helpers.ts index 36b2069ba33..bb388fad3ae 100644 --- a/src/renderer/src/store/slices/terminal-orphan-helpers.ts +++ b/src/renderer/src/store/slices/terminal-orphan-helpers.ts @@ -14,6 +14,7 @@ type OrphanTerminalCleanupState = Pick< | 'canExpandPaneByTabId' | 'terminalLayoutsByTabId' | 'pendingStartupByTabId' + | 'pendingInitialCwdByTabId' | 'pendingSetupSplitByTabId' | 'pendingIssueCommandSplitByTabId' | 'tabBarOrderByWorktree' @@ -59,6 +60,7 @@ export function buildOrphanTerminalCleanupPatch( | 'canExpandPaneByTabId' | 'terminalLayoutsByTabId' | 'pendingStartupByTabId' + | 'pendingInitialCwdByTabId' | 'pendingSetupSplitByTabId' | 'pendingIssueCommandSplitByTabId' | 'tabBarOrderByWorktree' @@ -75,6 +77,7 @@ export function buildOrphanTerminalCleanupPatch( canExpandPaneByTabId: state.canExpandPaneByTabId, terminalLayoutsByTabId: state.terminalLayoutsByTabId, pendingStartupByTabId: state.pendingStartupByTabId, + pendingInitialCwdByTabId: state.pendingInitialCwdByTabId, pendingSetupSplitByTabId: state.pendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId, tabBarOrderByWorktree: state.tabBarOrderByWorktree, @@ -93,6 +96,7 @@ export function buildOrphanTerminalCleanupPatch( const nextCanExpandPaneByTabId = { ...state.canExpandPaneByTabId } const nextTerminalLayoutsByTabId = { ...state.terminalLayoutsByTabId } const nextPendingStartupByTabId = { ...state.pendingStartupByTabId } + const nextPendingInitialCwdByTabId = { ...state.pendingInitialCwdByTabId } const nextPendingSetupSplitByTabId = { ...state.pendingSetupSplitByTabId } const nextPendingIssueCommandSplitByTabId = { ...state.pendingIssueCommandSplitByTabId } const nextTabBarOrderByWorktree = { @@ -114,6 +118,7 @@ export function buildOrphanTerminalCleanupPatch( delete nextCanExpandPaneByTabId[orphanTabId] delete nextTerminalLayoutsByTabId[orphanTabId] delete nextPendingStartupByTabId[orphanTabId] + delete nextPendingInitialCwdByTabId[orphanTabId] delete nextPendingSetupSplitByTabId[orphanTabId] delete nextPendingIssueCommandSplitByTabId[orphanTabId] for (const key of Object.keys(nextCacheTimerByKey)) { @@ -141,6 +146,7 @@ export function buildOrphanTerminalCleanupPatch( canExpandPaneByTabId: nextCanExpandPaneByTabId, terminalLayoutsByTabId: nextTerminalLayoutsByTabId, pendingStartupByTabId: nextPendingStartupByTabId, + pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, tabBarOrderByWorktree: nextTabBarOrderByWorktree, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index b99b248a503..eab7393fb40 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -309,6 +309,7 @@ export type TerminalSlice = { telemetry?: AgentStartedTelemetry } > + pendingInitialCwdByTabId: Record /** Queued setup-split requests — when present, TerminalPane creates the * initial pane clean, then splits (vertical or horizontal per user setting) * and runs the command in the new pane so the main terminal stays @@ -459,6 +460,8 @@ export type TerminalSlice = { telemetry?: AgentStartedTelemetry } ) => void + queueTabInitialCwd: (tabId: string, cwd: string) => void + consumeTabInitialCwd: (tabId: string) => string | null consumeTabStartupCommand: (tabId: string) => { command: string delivery?: 'terminal-paste' @@ -528,6 +531,7 @@ export const createTerminalSlice: StateCreator canExpandPaneByTabId: {}, terminalLayoutsByTabId: {}, pendingStartupByTabId: {}, + pendingInitialCwdByTabId: {}, pendingSetupSplitByTabId: {}, pendingIssueCommandSplitByTabId: {}, tabBarOrderByWorktree: {}, @@ -964,6 +968,8 @@ export const createTerminalSlice: StateCreator } const nextPendingStartupByTabId = { ...s.pendingStartupByTabId } delete nextPendingStartupByTabId[tabId] + const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } + delete nextPendingInitialCwdByTabId[tabId] const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } delete nextPendingSetupSplitByTabId[tabId] const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId } @@ -1038,6 +1044,7 @@ export const createTerminalSlice: StateCreator canExpandPaneByTabId: nextCanExpand, terminalLayoutsByTabId: nextLayouts, pendingStartupByTabId: nextPendingStartupByTabId, + pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, cacheTimerByKey: nextCacheTimer, @@ -2373,6 +2380,28 @@ export const createTerminalSlice: StateCreator })) }, + queueTabInitialCwd: (tabId, cwd) => { + set((s) => ({ + pendingInitialCwdByTabId: { + ...s.pendingInitialCwdByTabId, + [tabId]: cwd + } + })) + }, + + consumeTabInitialCwd: (tabId) => { + const pending = get().pendingInitialCwdByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingInitialCwdByTabId } + delete next[tabId] + return { pendingInitialCwdByTabId: next } + }) + return pending + }, + consumeTabStartupCommand: (tabId) => { const pending = get().pendingStartupByTabId[tabId] if (!pending) {