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 <rodboev@users.noreply.github.com>
This commit is contained in:
Neil
2026-06-27 23:21:25 -07:00
co-authored by Rod Boev
parent b45cb545c5
commit 4946e41990
17 changed files with 452 additions and 16 deletions
+21 -1
View File
@@ -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)
})
})
+22
View File
@@ -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.
*/
+41 -1
View File
@@ -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',
+18 -3
View File
@@ -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(),
@@ -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
})
@@ -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
})
@@ -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 = {
@@ -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<void> {
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<void> {
await markOnboardingProjectAdded('addedRepo')
closeModal()
await openProjectDefaultCheckout({ repoId, source, setHideDefaultBranchWorkspace })
await openProjectDefaultCheckout({
repoId,
source,
selectedPath,
setHideDefaultBranchWorkspace
})
}
@@ -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<Parameters<typeof createFilePathLinkProvider>[1]> = {}
) {
const pane = makePane(rows)
const managerRef = {
@@ -1031,7 +1032,8 @@ describe('createFilePathLinkProvider range bounds', () => {
startupCwd: '/repo',
managerRef,
linkProviderDisposablesRef: { current: new Map<number, IDisposable>() },
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<ILink[]>((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'
@@ -46,6 +46,7 @@ export type LinkHandlerDeps = {
worktreeId: string
worktreePath: string
startupCwd: string
getPaneLinkCwd?: (paneId: number) => string | null
managerRef: React.RefObject<PaneManager | null>
linkProviderDisposablesRef: React.RefObject<Map<number, IDisposable>>
pathExistsCache: Map<string, boolean>
@@ -123,8 +124,9 @@ export function createFilePathLinkProvider(
logicalLines.flatMap((logicalLine) =>
extractTerminalFileLinkCandidates(logicalLine.text).map(
async (parsed): Promise<ProvidedFileLink | null> => {
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,
@@ -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()
@@ -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<string, string> }
type SplitWithStartupDeps = {
@@ -435,6 +471,7 @@ export function useTerminalPaneLifecycle({
const mouseHideDisposablesRef = useRef(new Map<number, IDisposable>())
const imeCompositionDisposablesRef = useRef(new Map<number, IDisposable>())
const imePunctuationForwarderDisposablesRef = useRef(new Map<number, IDisposable>())
const queuedInitialCwdRef = useRef<string | null | undefined>(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,
@@ -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<string, unknown> = {}) {
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')
})
})
@@ -122,6 +122,7 @@ type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
tabId: string,
startup: { command: string; env?: Record<string, string> }
) => 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
@@ -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())
})
@@ -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,
@@ -309,6 +309,7 @@ export type TerminalSlice = {
telemetry?: AgentStartedTelemetry
}
>
pendingInitialCwdByTabId: Record<string, string>
/** 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<AppState, [], [], TerminalSlice>
canExpandPaneByTabId: {},
terminalLayoutsByTabId: {},
pendingStartupByTabId: {},
pendingInitialCwdByTabId: {},
pendingSetupSplitByTabId: {},
pendingIssueCommandSplitByTabId: {},
tabBarOrderByWorktree: {},
@@ -964,6 +968,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
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<AppState, [], [], TerminalSlice>
canExpandPaneByTabId: nextCanExpand,
terminalLayoutsByTabId: nextLayouts,
pendingStartupByTabId: nextPendingStartupByTabId,
pendingInitialCwdByTabId: nextPendingInitialCwdByTabId,
pendingSetupSplitByTabId: nextPendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,
cacheTimerByKey: nextCacheTimer,
@@ -2373,6 +2380,28 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}))
},
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) {