mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
fix: import hidden worktrees before terminal activation (STA-6846)
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalPaneLayoutNode
|
||||
} from '../../../../shared/terminal-tab-types'
|
||||
import { hasTerminalWorktreeRow, hiddenTerminalWorktreeError } from './terminal-worktree-visibility'
|
||||
import type { AppState } from '../../store/types'
|
||||
|
||||
export function resolveTerminalPresentation(data: {
|
||||
@@ -36,6 +37,9 @@ export function focusTerminalInitiatedTab(
|
||||
}
|
||||
|
||||
export function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void {
|
||||
if (!hasTerminalWorktreeRow(store, worktreeId)) {
|
||||
throw hiddenTerminalWorktreeError()
|
||||
}
|
||||
store.setActiveView('terminal')
|
||||
store.setActiveWorktree(worktreeId)
|
||||
store.markWorktreeVisited(worktreeId)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ensureTerminalWorktreeVisible,
|
||||
hasTerminalWorktreeRow
|
||||
} from './terminal-worktree-visibility'
|
||||
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
|
||||
import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph'
|
||||
import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount'
|
||||
@@ -22,7 +26,7 @@ import {
|
||||
export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): void {
|
||||
unsubs.push(
|
||||
window.api.ui.onCreateTerminal(
|
||||
({
|
||||
async ({
|
||||
requestId,
|
||||
worktreeId,
|
||||
command,
|
||||
@@ -46,7 +50,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
|
||||
splitTelemetrySource
|
||||
}) => {
|
||||
try {
|
||||
const store = useAppStore.getState()
|
||||
let store = useAppStore.getState()
|
||||
const terminalPresentation = resolveTerminalPresentation({
|
||||
presentation,
|
||||
activate,
|
||||
@@ -54,6 +58,10 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
|
||||
})
|
||||
const shouldActivate = terminalPresentation === 'focused'
|
||||
const shouldSurfaceOwner = terminalPresentation !== 'background' && surfaceOwner !== false
|
||||
if (shouldActivate && !hasTerminalWorktreeRow(store, worktreeId)) {
|
||||
await ensureTerminalWorktreeVisible(worktreeId)
|
||||
store = useAppStore.getState()
|
||||
}
|
||||
if (shouldActivate) {
|
||||
activateTerminalInitiatedWorktree(store, worktreeId)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ensureTerminalWorktreeVisible,
|
||||
hasTerminalWorktreeRow
|
||||
} from './terminal-worktree-visibility'
|
||||
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode'
|
||||
@@ -13,9 +17,9 @@ import {
|
||||
|
||||
export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void {
|
||||
unsubs.push(
|
||||
window.api.ui.onRequestTerminalCreate((data) => {
|
||||
window.api.ui.onRequestTerminalCreate(async (data) => {
|
||||
try {
|
||||
const store = useAppStore.getState()
|
||||
let store = useAppStore.getState()
|
||||
const worktreeId = data.worktreeId ?? store.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
window.api.ui.replyTerminalCreate({
|
||||
@@ -50,6 +54,10 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void {
|
||||
const shouldActivate = terminalPresentation === 'focused'
|
||||
const shouldSurfaceOwner =
|
||||
terminalPresentation !== 'background' && data.surfaceOwner !== false
|
||||
if (shouldActivate && !hasTerminalWorktreeRow(store, worktreeId)) {
|
||||
await ensureTerminalWorktreeVisible(worktreeId)
|
||||
store = useAppStore.getState()
|
||||
}
|
||||
if (shouldActivate) {
|
||||
activateTerminalInitiatedWorktree(store, worktreeId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../../store/types'
|
||||
import { setupTerminalCreateSurfacing } from '../ipc-events-terminal-create-test-harness'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function setup(hostId = 'local') {
|
||||
const scenario = await setupTerminalCreateSurfacing(() => false)
|
||||
const state = scenario.storeState as unknown as AppState
|
||||
const worktreeId = 'repo-1::/hidden'
|
||||
const hidden = {
|
||||
id: worktreeId,
|
||||
repoId: 'repo-1',
|
||||
path: '/hidden',
|
||||
hostId,
|
||||
visible: false,
|
||||
ownership: 'external'
|
||||
}
|
||||
Object.assign(state, {
|
||||
repos: [
|
||||
{ id: 'repo-1', executionHostId: hostId, importedExternalWorktreePaths: ['/existing'] }
|
||||
],
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-1': { authoritative: true, worktrees: [hidden] }
|
||||
},
|
||||
// Why: detected rows are known too; only the visible catalog proves a sidebar row exists.
|
||||
getKnownWorktreeById: () => hidden
|
||||
})
|
||||
const updateRepo = vi.fn(async (_id, updates) => {
|
||||
state.repos = state.repos.map((repo) => ({ ...repo, ...updates }))
|
||||
return true
|
||||
})
|
||||
const fetchWorktrees = vi.fn(async () => {
|
||||
state.worktreesByRepo = {
|
||||
...state.worktreesByRepo,
|
||||
'repo-1': [...state.worktreesByRepo['repo-1'], hidden as never]
|
||||
}
|
||||
return true
|
||||
})
|
||||
Object.assign(state, { updateRepo, fetchWorktrees })
|
||||
return { ...scenario, state, worktreeId, updateRepo, fetchWorktrees }
|
||||
}
|
||||
|
||||
describe.each(['reveal', 'create'] as const)('hidden worktree %s bridge', (bridge) => {
|
||||
async function invoke(scenario: Awaited<ReturnType<typeof setup>>, focused = true) {
|
||||
const payload = {
|
||||
requestId: 'request',
|
||||
worktreeId: scenario.worktreeId,
|
||||
presentation: focused ? ('focused' as const) : ('background' as const),
|
||||
source: 'runtime-session' as const
|
||||
}
|
||||
await (bridge === 'reveal'
|
||||
? scenario.createTerminalListenerRef.current!(payload)
|
||||
: scenario.requestTerminalCreateListenerRef.current!(payload))
|
||||
}
|
||||
|
||||
it.each(['local', 'ssh:server', 'runtime:server'])(
|
||||
'imports on the %s owner before activating and replying',
|
||||
async (hostId) => {
|
||||
const s = await setup(hostId)
|
||||
let finishRefresh!: () => void
|
||||
s.fetchWorktrees.mockImplementationOnce(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishRefresh = resolve
|
||||
})
|
||||
s.state.worktreesByRepo['repo-1'].push({ id: s.worktreeId } as never)
|
||||
return true
|
||||
})
|
||||
const pending = invoke(s)
|
||||
await vi.waitFor(() => expect(s.fetchWorktrees).toHaveBeenCalled())
|
||||
expect(s.setActiveWorktree).not.toHaveBeenCalled()
|
||||
expect(s.createTab).not.toHaveBeenCalled()
|
||||
expect(s.replyTerminalCreate).not.toHaveBeenCalled()
|
||||
finishRefresh()
|
||||
await pending
|
||||
expect(s.updateRepo).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
{
|
||||
importedExternalWorktreePaths: ['/existing', '/hidden'],
|
||||
externalWorktreeInboxBaselinePaths: ['/hidden']
|
||||
},
|
||||
{ hostId }
|
||||
)
|
||||
expect(s.fetchWorktrees).toHaveBeenCalledWith('repo-1', {
|
||||
requireAuthoritative: true,
|
||||
executionHostId: hostId
|
||||
})
|
||||
expect(s.setActiveWorktree).toHaveBeenCalledWith(s.worktreeId)
|
||||
expect(s.revealWorktreeInSidebar).toHaveBeenCalledWith(s.worktreeId)
|
||||
expect(s.replyTerminalCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: 'tab-new' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['update', 'refresh', 'missing-row', 'unknown'])(
|
||||
'replies with an error without navigation when %s fails',
|
||||
async (failure) => {
|
||||
const s = await setup()
|
||||
if (failure === 'update') {
|
||||
s.updateRepo.mockResolvedValue(false)
|
||||
}
|
||||
if (failure === 'refresh') {
|
||||
s.fetchWorktrees.mockResolvedValue(false)
|
||||
}
|
||||
if (failure === 'missing-row') {
|
||||
s.fetchWorktrees.mockResolvedValue(true)
|
||||
}
|
||||
if (failure === 'unknown') {
|
||||
s.state.detectedWorktreesByRepo = {}
|
||||
}
|
||||
await invoke(s)
|
||||
expect(s.replyTerminalCreate).toHaveBeenCalledWith({
|
||||
requestId: 'request',
|
||||
error: expect.stringContaining('worktree_hidden')
|
||||
})
|
||||
expect(s.setActiveView).not.toHaveBeenCalled()
|
||||
expect(s.setActiveWorktree).not.toHaveBeenCalled()
|
||||
expect(s.recordWorktreeVisit).not.toHaveBeenCalled()
|
||||
expect(s.createTab).not.toHaveBeenCalled()
|
||||
expect(s.revealWorktreeInSidebar).not.toHaveBeenCalled()
|
||||
if (failure === 'refresh') {
|
||||
expect(s.updateRepo).toHaveBeenLastCalledWith(
|
||||
'repo-1',
|
||||
{
|
||||
importedExternalWorktreePaths: ['/existing'],
|
||||
externalWorktreeInboxBaselinePaths: []
|
||||
},
|
||||
{ hostId: 'local' }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps background requests hidden without activating', async () => {
|
||||
const s = await setup()
|
||||
await invoke(s, false)
|
||||
expect(s.updateRepo).not.toHaveBeenCalled()
|
||||
expect(s.setActiveWorktree).not.toHaveBeenCalled()
|
||||
expect(s.createTab).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a folder workspace without importing it as a git worktree', async () => {
|
||||
const s = await setup()
|
||||
s.worktreeId = 'folder:folder-1'
|
||||
s.state.folderWorkspaces = [{ id: 'folder-1' } as never]
|
||||
await invoke(s)
|
||||
expect(s.updateRepo).not.toHaveBeenCalled()
|
||||
expect(s.setActiveWorktree).toHaveBeenCalledWith(s.worktreeId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal worktree activation safety', () => {
|
||||
it('rejects direct activation of a detected hidden row', async () => {
|
||||
const s = await setup()
|
||||
const { activateTerminalInitiatedWorktree } = await import('./terminal-command-state')
|
||||
expect(() => activateTerminalInitiatedWorktree(s.state, s.worktreeId)).toThrow(
|
||||
'worktree_hidden'
|
||||
)
|
||||
expect(s.setActiveView).not.toHaveBeenCalled()
|
||||
expect(s.setActiveWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('coalesces simultaneous reveals of the same hidden worktree', async () => {
|
||||
const s = await setup()
|
||||
const { ensureTerminalWorktreeVisible } = await import('./terminal-worktree-visibility')
|
||||
await Promise.all([
|
||||
ensureTerminalWorktreeVisible(s.worktreeId),
|
||||
ensureTerminalWorktreeVisible(s.worktreeId)
|
||||
])
|
||||
expect(s.updateRepo).toHaveBeenCalledTimes(1)
|
||||
expect(s.fetchWorktrees).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves both imports for simultaneous reveals in the same repo', async () => {
|
||||
const s = await setup()
|
||||
const second = {
|
||||
...s.state.detectedWorktreesByRepo['repo-1'].worktrees[0],
|
||||
id: 'repo-1::/second',
|
||||
path: '/second'
|
||||
}
|
||||
s.state.detectedWorktreesByRepo['repo-1'].worktrees.push(second)
|
||||
s.fetchWorktrees.mockImplementation(async () => {
|
||||
s.state.worktreesByRepo['repo-1'] = s.state.detectedWorktreesByRepo[
|
||||
'repo-1'
|
||||
].worktrees.filter((row) =>
|
||||
s.state.repos[0].importedExternalWorktreePaths?.includes(row.path)
|
||||
)
|
||||
return true
|
||||
})
|
||||
const { ensureTerminalWorktreeVisible } = await import('./terminal-worktree-visibility')
|
||||
await Promise.all([
|
||||
ensureTerminalWorktreeVisible(s.worktreeId),
|
||||
ensureTerminalWorktreeVisible(second.id)
|
||||
])
|
||||
expect(s.state.repos[0].importedExternalWorktreePaths).toEqual([
|
||||
'/existing',
|
||||
'/hidden',
|
||||
'/second'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AppState } from '../../store/types'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { importNewExternalWorktreeInboxPaths } from '@/components/sidebar/new-external-worktrees-inbox-actions'
|
||||
import { resolveWorktreeOperationRoute } from '@/lib/worktree-operation-route'
|
||||
import { findRepoForHost, getRepoHostIdentity } from '@/store/slices/repo-host-identity'
|
||||
import { findWorktreeById, getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import {
|
||||
worktreeHostMatchOptions,
|
||||
worktreeMatchesHost
|
||||
} from '@/store/slices/worktrees/listing/worktree-host-ownership'
|
||||
import { useAppStore } from '../../store'
|
||||
|
||||
const pendingImports = new Map<string, Promise<void>>()
|
||||
|
||||
export function hasTerminalWorktreeRow(state: AppState, worktreeId: string): boolean {
|
||||
if (parseWorkspaceKey(worktreeId)?.type === 'folder') {
|
||||
return Boolean(state.getKnownWorktreeById(worktreeId))
|
||||
}
|
||||
return Boolean(findWorktreeById(state.worktreesByRepo, worktreeId))
|
||||
}
|
||||
|
||||
export function hiddenTerminalWorktreeError(): Error {
|
||||
return new Error('worktree_hidden: Terminal workspace could not be shown in the sidebar')
|
||||
}
|
||||
|
||||
export async function ensureTerminalWorktreeVisible(worktreeId: string): Promise<void> {
|
||||
const state = useAppStore.getState()
|
||||
if (hasTerminalWorktreeRow(state, worktreeId)) {
|
||||
return
|
||||
}
|
||||
const route = resolveWorktreeOperationRoute(state, worktreeId)
|
||||
const hostId = route?.executionHostId
|
||||
if (!hostId) {
|
||||
throw hiddenTerminalWorktreeError()
|
||||
}
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = findRepoForHost(state.repos, repoId, { hostId })
|
||||
if (!repo) {
|
||||
throw hiddenTerminalWorktreeError()
|
||||
}
|
||||
const scope = getRepoHostIdentity(repo)
|
||||
// Why: concurrent reveals must merge imports against the preceding persisted update.
|
||||
const previous = pendingImports.get(scope) ?? Promise.resolve()
|
||||
const pending = previous
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
const current = useAppStore.getState()
|
||||
if (hasTerminalWorktreeRow(current, worktreeId)) {
|
||||
return
|
||||
}
|
||||
const targetRepo = findRepoForHost(current.repos, repoId, { hostId })
|
||||
const detected = current.detectedWorktreesByRepo[repoId]?.worktrees.filter(
|
||||
(worktree) =>
|
||||
worktree.id === worktreeId &&
|
||||
worktreeMatchesHost(worktree, hostId, worktreeHostMatchOptions(current, repoId, hostId))
|
||||
)
|
||||
if (!targetRepo || detected?.length !== 1 || detected[0].visible) {
|
||||
throw hiddenTerminalWorktreeError()
|
||||
}
|
||||
let imported = false
|
||||
await importNewExternalWorktreeInboxPaths({
|
||||
projectId: repoId,
|
||||
repo: targetRepo,
|
||||
worktreePaths: [detected[0].path],
|
||||
updateRepo: (id, updates) => current.updateRepo(id, updates, { hostId }),
|
||||
fetchWorktrees: (id, options) =>
|
||||
current.fetchWorktrees(id, { ...options, executionHostId: hostId }),
|
||||
setInboxState: (_id, status) => {
|
||||
imported = status === null
|
||||
}
|
||||
})
|
||||
if (!imported || !hasTerminalWorktreeRow(useAppStore.getState(), worktreeId)) {
|
||||
throw hiddenTerminalWorktreeError()
|
||||
}
|
||||
})
|
||||
pendingImports.set(scope, pending)
|
||||
try {
|
||||
await pending
|
||||
} finally {
|
||||
if (pendingImports.get(scope) === pending) {
|
||||
pendingImports.delete(scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user