mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
fix: root monorepo subfolder imports at the git repo while keeping the first terminal in the subfolder (#6336) (#6574)
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 getGitRepoRoot() (mirroring the SSH import path that 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. Supersedes community PR #6362. Fixes #6336 Co-authored-by: Rod Boev <rodboev@users.noreply.github.com>
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(),
|
||||
|
||||
@@ -34,9 +34,7 @@ export function NativeChatInteractiveCard({
|
||||
)
|
||||
// Thread the sibling `toolName` from the same status entry so the question
|
||||
// parser can dispatch through the tool's registered parser (mobile parity).
|
||||
const interactiveToolName = useAppStore(
|
||||
(s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null
|
||||
)
|
||||
const interactiveToolName = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null)
|
||||
const { sendAnswer, sendRaw, cancel } = send
|
||||
|
||||
const card = useMemo(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NATIVE_CHAT_SOURCE_PRIORITY, type NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
NATIVE_CHAT_SOURCE_PRIORITY,
|
||||
type NativeChatMessage
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { mergeNativeChatMessagesWith } from '../../../../shared/native-chat-merge'
|
||||
import { assembleNativeChatSession } from './native-chat-session-assembler'
|
||||
|
||||
|
||||
@@ -58,12 +58,27 @@ describe('incremental assembler — oracle differential', () => {
|
||||
// empty batch
|
||||
[],
|
||||
// re-emit id 'b' from transcript — supersedes the hook copy in place
|
||||
[msg({ id: 'b', timestamp: 20, source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })],
|
||||
[
|
||||
msg({
|
||||
id: 'b',
|
||||
timestamp: 20,
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'text', text: 'final' }]
|
||||
})
|
||||
],
|
||||
// out-of-order timestamp: sorts BEFORE the current tail → forces re-sort
|
||||
[msg({ id: 'd', timestamp: 5, blocks: [{ type: 'text', text: 'early' }] })],
|
||||
// turnKey collision with 'a': same role+text+timestamp, DIFFERENT source
|
||||
// (scrape) — lower priority, must be dropped by the cross-source gate
|
||||
[msg({ id: 'a-scrape', timestamp: 10, role: 'user', source: 'scrape', blocks: [{ type: 'text', text: 'hello' }] })],
|
||||
[
|
||||
msg({
|
||||
id: 'a-scrape',
|
||||
timestamp: 10,
|
||||
role: 'user',
|
||||
source: 'scrape',
|
||||
blocks: [{ type: 'text', text: 'hello' }]
|
||||
})
|
||||
],
|
||||
// same-source identical prompt (distinct id) — must NOT collapse (#10)
|
||||
[msg({ id: 'e', timestamp: 40, role: 'user', blocks: [{ type: 'text', text: 'hello' }] })],
|
||||
// null timestamp append → forces re-sort, sorts to the front
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -788,7 +788,7 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
|
||||
const mirroredId = patch.tabsByWorktree?.[WT]?.[0]?.id
|
||||
console.error("PATCH tabs", JSON.stringify(patch.tabsByWorktree?.[WT]))
|
||||
console.error('PATCH tabs', JSON.stringify(patch.tabsByWorktree?.[WT]))
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]?.viewMode).toBe('chat')
|
||||
expect(
|
||||
patch.unifiedTabsByWorktree?.[WT]?.find((tab) => tab.entityId === mirroredId)?.viewMode
|
||||
|
||||
@@ -538,9 +538,7 @@ function buildMirroredTerminalTabs(
|
||||
// the client's own record during the optimistic echo window and adopt the host
|
||||
// value only when this client has no prior tab (first reconcile / other client).
|
||||
const hostViewModeSurface = surfaces.find((surface) => surface.viewMode)
|
||||
const viewMode = existing
|
||||
? existing.viewMode
|
||||
: hostViewModeSurface?.viewMode
|
||||
const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode
|
||||
return {
|
||||
tab: {
|
||||
id: localTabId,
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -221,7 +221,11 @@ function mirrorTabPinnedToHost(state: AppState, tabId: string, isPinned: boolean
|
||||
// the host or it's lost on reconnect/restart and never reaches paired clients.
|
||||
// Only the user/RPC-set action path mirrors — never the reconcile that applies a
|
||||
// host value — so the echoed snapshot can't re-trigger an outbound RPC (no loop).
|
||||
function mirrorTabViewModeToHost(state: AppState, tabId: string, viewMode: 'terminal' | 'chat'): void {
|
||||
function mirrorTabViewModeToHost(
|
||||
state: AppState,
|
||||
tabId: string,
|
||||
viewMode: 'terminal' | 'chat'
|
||||
): void {
|
||||
const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId)
|
||||
// Why: only terminal tab viewMode is persisted host-side; skip the RPC for other
|
||||
// types instead of a no-op round trip.
|
||||
|
||||
@@ -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