fix(cli): record back/forward history for CLI-activated worktrees (#1166)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
75c6f25887
commit
dd1ed7c697
|
|
@ -1338,3 +1338,205 @@ describe('useIpcEvents shortcut hint clearing', () => {
|
|||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIpcEvents CLI-created worktree activation', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Why: regression guard. The CLI "create agent" flow emits
|
||||
// `ui:activateWorktree` to switch the user to the new workspace. A prior
|
||||
// implementation hand-rolled the activation (setActiveRepo + setActiveView
|
||||
// + setActiveWorktree + ensureWorktreeHasInitialTerminal +
|
||||
// revealWorktreeInSidebar), which bypassed recordWorktreeVisit and left
|
||||
// the back/forward buttons ignoring the CLI-driven switch. This test pins
|
||||
// the handler to the canonical `activateAndRevealWorktree` helper, which
|
||||
// is the single place that records the visit in history.
|
||||
it('routes CLI-driven activation through activateAndRevealWorktree so back/forward history is recorded', async () => {
|
||||
const activateAndRevealWorktree = vi.fn()
|
||||
const fetchWorktrees = vi.fn().mockResolvedValue(undefined)
|
||||
const activateWorktreeListenerRef: {
|
||||
current:
|
||||
| ((data: {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
|
||||
}) => void)
|
||||
| null
|
||||
} = { current: null }
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
effect()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
fetchRepos: vi.fn(),
|
||||
fetchWorktrees,
|
||||
setUpdateStatus: vi.fn(),
|
||||
activeModal: null,
|
||||
closeModal: vi.fn(),
|
||||
openModal: vi.fn(),
|
||||
activeWorktreeId: 'wt-old',
|
||||
activeView: 'terminal',
|
||||
setActiveView: vi.fn(),
|
||||
setActiveRepo: vi.fn(),
|
||||
setActiveWorktree: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
setIsFullScreen: vi.fn(),
|
||||
updateBrowserPageState: vi.fn(),
|
||||
activeTabType: 'terminal',
|
||||
editorFontZoomLevel: 0,
|
||||
setEditorFontZoomLevel: vi.fn(),
|
||||
setRateLimitsFromPush: vi.fn(),
|
||||
setSshConnectionState: vi.fn(),
|
||||
setSshTargetLabels: vi.fn(),
|
||||
setPortForwards: vi.fn(),
|
||||
clearPortForwards: vi.fn(),
|
||||
setDetectedPorts: vi.fn(),
|
||||
enqueueSshCredentialRequest: vi.fn(),
|
||||
removeSshCredentialRequest: vi.fn(),
|
||||
clearTabPtyId: vi.fn(),
|
||||
settings: { terminalFontSize: 13 }
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/ui-zoom', () => ({
|
||||
applyUIZoom: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree,
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
|
||||
getVisibleWorktreeIds: () => []
|
||||
}))
|
||||
vi.doMock('@/lib/editor-font-zoom', () => ({
|
||||
nextEditorFontZoomLevel: vi.fn(() => 0),
|
||||
computeEditorFontSize: vi.fn(() => 13)
|
||||
}))
|
||||
vi.doMock('@/components/settings/SettingsConstants', () => ({
|
||||
zoomLevelToPercent: vi.fn(() => 100),
|
||||
ZOOM_MIN: -3,
|
||||
ZOOM_MAX: 3
|
||||
}))
|
||||
vi.doMock('@/lib/zoom-events', () => ({
|
||||
dispatchZoomLevelChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
repos: { onChanged: () => () => {} },
|
||||
worktrees: { onChanged: () => () => {} },
|
||||
ui: {
|
||||
onOpenSettings: () => () => {},
|
||||
onToggleLeftSidebar: () => () => {},
|
||||
onToggleRightSidebar: () => () => {},
|
||||
onToggleWorktreePalette: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
onWorktreeHistoryNavigate: () => () => {},
|
||||
onActivateWorktree: (
|
||||
listener: (data: {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
|
||||
}) => void
|
||||
) => {
|
||||
activateWorktreeListenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
onCreateTerminal: () => () => {},
|
||||
onRequestTerminalCreate: () => () => {},
|
||||
replyTerminalCreate: () => {},
|
||||
onSplitTerminal: () => () => {},
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
onRequestTabClose: () => () => {},
|
||||
replyTabClose: () => {},
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: () => () => {},
|
||||
onSwitchTab: () => () => {},
|
||||
onSwitchTerminalTab: () => () => {},
|
||||
onToggleStatusBar: () => () => {},
|
||||
onFullscreenChanged: () => () => {},
|
||||
onTerminalZoom: () => () => {},
|
||||
getZoomLevel: () => 0,
|
||||
set: vi.fn()
|
||||
},
|
||||
updater: {
|
||||
getStatus: () => Promise.resolve({ state: 'idle' }),
|
||||
onStatus: () => () => {},
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
onUpdate: () => () => {}
|
||||
},
|
||||
ssh: {
|
||||
listTargets: () => Promise.resolve([]),
|
||||
listPortForwards: () => Promise.resolve([]),
|
||||
listDetectedPorts: () => Promise.resolve([]),
|
||||
getState: () => Promise.resolve(null),
|
||||
onStateChanged: () => () => {},
|
||||
onCredentialRequest: () => () => {},
|
||||
onPortForwardsChanged: () => () => {},
|
||||
onDetectedPortsChanged: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof activateWorktreeListenerRef.current !== 'function') {
|
||||
throw new Error('Expected onActivateWorktree listener to be registered')
|
||||
}
|
||||
|
||||
const setup = { runnerScriptPath: '/tmp/setup.sh', envVars: { FOO: 'bar' } }
|
||||
activateWorktreeListenerRef.current({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-new',
|
||||
setup
|
||||
})
|
||||
|
||||
// Wait for the async IPC handler (it awaits fetchWorktrees before activating).
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// Worktrees must be fetched first so activateAndRevealWorktree can resolve
|
||||
// the CLI-created worktree out of store state.
|
||||
expect(fetchWorktrees).toHaveBeenCalledWith('repo-1')
|
||||
|
||||
// The core regression guard: the handler must delegate to the canonical
|
||||
// activation helper (which records the visit in history) rather than
|
||||
// hand-rolling the activation steps and skipping recordWorktreeVisit.
|
||||
// `setup` must be passed through the `setup` opt — not positionally
|
||||
// mis-aliased into `startup`, which was a latent bug in the original
|
||||
// hand-rolled path.
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { setup })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useAppStore } from '../store'
|
||||
import { applyUIZoom } from '@/lib/ui-zoom'
|
||||
import {
|
||||
activateAndRevealWorktree,
|
||||
ensureWorktreeHasInitialTerminal
|
||||
} from '@/lib/worktree-activation'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
|
||||
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
|
||||
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
|
||||
|
|
@ -141,18 +138,16 @@ export function useIpcEvents(): void {
|
|||
unsubs.push(
|
||||
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup }) => {
|
||||
void (async () => {
|
||||
const store = useAppStore.getState()
|
||||
await store.fetchWorktrees(repoId)
|
||||
// Why: CLI-created worktrees should feel identical to UI-created
|
||||
// worktrees. The renderer owns the "active worktree -> first tab"
|
||||
// behavior today, so we explicitly replay that activation sequence
|
||||
// after the runtime creates a worktree outside the renderer.
|
||||
store.setActiveRepo(repoId)
|
||||
store.setActiveView('terminal')
|
||||
store.setActiveWorktree(worktreeId)
|
||||
ensureWorktreeHasInitialTerminal(store, worktreeId, setup)
|
||||
|
||||
store.revealWorktreeInSidebar(worktreeId)
|
||||
// Why: fetch worktrees first so the activation helper can resolve
|
||||
// the CLI-created worktree via findWorktreeById — it arrived from
|
||||
// the main process and is not yet in the renderer state.
|
||||
await useAppStore.getState().fetchWorktrees(repoId)
|
||||
// Why: route through activateAndRevealWorktree so CLI-created
|
||||
// worktrees share the canonical activation path with UI-created
|
||||
// ones. This records the visit in the back/forward history stack
|
||||
// (recordWorktreeVisit), without which the nav buttons would
|
||||
// ignore the CLI-driven workspace switch.
|
||||
activateAndRevealWorktree(worktreeId, { setup })
|
||||
})().catch((error) => {
|
||||
console.error('Failed to activate CLI-created worktree:', error)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue