Merge remote-tracking branch 'origin/main' into brennanb2025/floating-add-worktree

This commit is contained in:
brennanb2025
2026-06-28 16:39:22 -07:00
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,
@@ -184,6 +185,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()
@@ -192,11 +194,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,7 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyTerminalScrollbackRowsToMountedPanes,
clearQueuedInitialCwdAfterFirstPane,
mapRestoredPaneTitlesByPaneId,
resolvePaneLinkCwd,
resolvePaneSeedCwd,
resolveQueuedInitialCwd,
scheduleVisibilityReconcilePass,
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup,
@@ -193,6 +197,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()
@@ -318,6 +318,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 = {
@@ -474,6 +510,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
@@ -548,8 +585,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.
@@ -558,6 +604,7 @@ export function useTerminalPaneLifecycle({
worktreeId,
worktreePath,
startupCwd,
getPaneLinkCwd,
terminalHomePath,
managerRef,
linkProviderDisposablesRef,
@@ -617,7 +664,7 @@ export function useTerminalPaneLifecycle({
const ptyDeps = {
tabId,
worktreeId,
cwd,
cwd: startupCwd,
startup,
paneTransportsRef,
paneMode2031Ref,
@@ -657,7 +704,7 @@ export function useTerminalPaneLifecycle({
const fileOpenLinkHint = getTerminalFileOpenHint()
const urlOpenLinkHint = getTerminalUrlOpenHint()
const osc7UncHost = extractUncHost(cwd)
const osc7UncHost = extractUncHost(startupCwd)
let releaseWebviewDragPassthrough: (() => void) | null = null
@@ -710,6 +757,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) {
@@ -917,6 +970,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
})
@@ -968,6 +1022,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()
@@ -1221,6 +1282,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
@@ -712,3 +713,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')
})
})
@@ -131,6 +131,7 @@ type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
tabId: string,
startup: { command: string; env?: Record<string, string> }
) => void
queueTabInitialCwd: (tabId: string, cwd: string) => void
settings?: Pick<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'> | null
}
@@ -276,6 +277,7 @@ export function activateAndRevealWorktree(
worktreeId: string,
opts?: {
startup?: WorktreeStartupPayload
initialCwd?: string
setup?: WorktreeSetupLaunch
defaultTabs?: WorktreeDefaultTabsLaunch
issueCommand?: IssueCommandLaunch
@@ -359,6 +361,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
@@ -1803,6 +1803,7 @@ describe('setActiveWorktree', () => {
'canExpandPaneByTabId',
'terminalLayoutsByTabId',
'pendingStartupByTabId',
'pendingInitialCwdByTabId',
'pendingSetupSplitByTabId',
'pendingIssueCommandSplitByTabId',
'tabBarOrderByWorktree',
@@ -1843,6 +1844,9 @@ describe('setActiveWorktree', () => {
pendingStartupByTabId: {
[orphanId]: { command: 'codex' }
},
pendingInitialCwdByTabId: {
[orphanId]: '/repo/packages/web'
},
tabBarOrderByWorktree: {
[wt]: [orphanId]
},
@@ -1863,6 +1867,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,
@@ -345,6 +345,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
@@ -500,6 +501,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'
@@ -569,6 +572,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
canExpandPaneByTabId: {},
terminalLayoutsByTabId: {},
pendingStartupByTabId: {},
pendingInitialCwdByTabId: {},
pendingSetupSplitByTabId: {},
pendingIssueCommandSplitByTabId: {},
tabBarOrderByWorktree: {},
@@ -1013,6 +1017,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 }
@@ -1087,6 +1093,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
canExpandPaneByTabId: nextCanExpand,
terminalLayoutsByTabId: nextLayouts,
pendingStartupByTabId: nextPendingStartupByTabId,
pendingInitialCwdByTabId: nextPendingInitialCwdByTabId,
pendingSetupSplitByTabId: nextPendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,
cacheTimerByKey: nextCacheTimer,
@@ -2422,6 +2429,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) {