mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/floating-add-worktree
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user