diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 0c2982043ff..e730d4328ff 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -137,6 +137,10 @@ import { translate } from '@/i18n/i18n' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' +import { + combineTerminalWorktreeParkIds, + useManualTerminalWorktreeParking +} from './terminal-pane/use-manual-terminal-worktree-parking' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -738,6 +742,15 @@ function Terminal(): React.JSX.Element | null { const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( () => new Set() ) + const manuallyParkedTerminalWorktreeIds = useManualTerminalWorktreeParking({ + activeView, + renderedActiveWorktreeId + }) + const effectiveParkedTerminalWorktreeIds = useMemo( + () => + combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds), + [manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds] + ) // Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs. const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) // Why: only cold-activation deferral (not targeted mounts, which share the map above) creates watcher coverage for every unmounted tab. @@ -1055,9 +1068,9 @@ function Terminal(): React.JSX.Element | null { const coldWorktreePresentationTargets = useMemo( () => activeView === 'terminal' && activeTabType === 'terminal' - ? parkedTerminalWorktreeIds + ? effectiveParkedTerminalWorktreeIds : new Set(), - [activeTabType, activeView, parkedTerminalWorktreeIds] + [activeTabType, activeView, effectiveParkedTerminalWorktreeIds] ) const { presentationByScope: worktreePresentationByScope, @@ -1088,7 +1101,7 @@ function Terminal(): React.JSX.Element | null { const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const parked = - !isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id) + !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) if (parked) { for (const tab of tabs) { const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { @@ -1135,7 +1148,7 @@ function Terminal(): React.JSX.Element | null { backgroundMountRevision, getEffectiveLayoutForWorktree, groupsByWorktree, - parkedTerminalWorktreeIds, + effectiveParkedTerminalWorktreeIds, pendingStartupByTabId, renderedActiveWorktreeId, tabsByWorktree, @@ -2107,9 +2120,7 @@ function Terminal(): React.JSX.Element | null { const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const shouldColdParkTerminalPanes = - !isVisible && - !shouldMeasureHiddenWorktree && - parkedTerminalWorktreeIds.has(workspace.id) + !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) return ( ) : null} + {!isMultiContext ? ( + <> + + + + ) : null} void +} + +const menuItems = vi.hoisted(() => ({ values: [] as MenuItemProps[] })) +const requestManualPark = vi.hoisted(() => vi.fn()) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const React_ = await import('react') + const passthrough = ({ children }: { children?: ReactNode }) => + React_.createElement(React_.Fragment, null, children) + return { + DropdownMenuSub: passthrough, + DropdownMenuSubContent: passthrough, + DropdownMenuSubTrigger: passthrough, + DropdownMenuItem: (props: MenuItemProps) => { + menuItems.values.push(props) + return React_.createElement(React_.Fragment, null, props.children) + } + } +}) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/lib/manual-terminal-worktree-parking', () => ({ + requestManualTerminalWorktreePark: requestManualPark +})) + +describe('WorktreeDeveloperMenu', () => { + beforeEach(() => { + menuItems.values = [] + requestManualPark.mockReset() + }) + + it('shows the developer submenu with the parking action', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Developer') + expect(markup).toContain('Park terminal') + expect(markup).toContain('lucide-code-xml') + expect(markup).toContain('lucide-square-parking') + }) + + it('requests parking for the context worktree', () => { + renderToStaticMarkup() + + menuItems.values[0]?.onSelect?.() + + expect(requestManualPark).toHaveBeenCalledWith('worktree-1') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx new file mode 100644 index 00000000000..a0cb257c105 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx @@ -0,0 +1,32 @@ +import { + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { CodeXml, SquareParking } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { requestManualTerminalWorktreePark } from '@/lib/manual-terminal-worktree-parking' + +export function WorktreeDeveloperMenu({ + worktreeId, + disabled +}: { + worktreeId: string + disabled: boolean +}): React.JSX.Element { + return ( + + + + {translate('auto.components.sidebar.WorktreeDeveloperMenu.developer', 'Developer')} + + + requestManualTerminalWorktreePark(worktreeId)}> + + {translate('auto.components.sidebar.WorktreeDeveloperMenu.parkTerminal', 'Park terminal')} + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index 5c8026b74d0..cb1c698fc15 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -7,6 +7,7 @@ import { TERMINAL_TAB_HOT_RETAIN_MS, TERMINAL_WORKTREE_HOT_RETAIN_MS, TERMINAL_WORKTREE_PARK_DELAY_MS, + canManuallyParkTerminalWorktreeRenderers, canParkTerminalTabRenderer, canParkTerminalWorktreeRenderers, getTerminalTabColdParkRecheckDelayMs, @@ -181,6 +182,36 @@ describe('canParkTerminalTabRenderer', () => { }) }) +describe('canManuallyParkTerminalWorktreeRenderers', () => { + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + parkingEnabled: true + } + + it('bypasses visibility timing for snapshot-backed local terminals', () => { + expect(canManuallyParkTerminalWorktreeRenderers(base)).toBe(true) + }) + + it('preserves safety gates for empty, pending, remote, and disabled terminals', () => { + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs: [] })).toBe(false) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': ['echo', 'pending'] } + }) + ).toBe(false) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:ssh-1@@pty-1' }] + }) + ).toBe(false) + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) + }) +}) + describe('selectColdParkedTerminalWorktrees', () => { const nowMs = 500_000 diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index f4b434c948b..4701ff727a3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -107,6 +107,26 @@ export function canParkTerminalWorktreeRenderers(args: { }) } +export function canManuallyParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean +}): boolean { + return ( + args.terminalTabs.length > 0 && + canParkTerminalWorktreeRenderers({ + ...args, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs: 0, + nowMs: 0, + coldParkDelayMs: 0 + }) + ) +} + export function canParkTerminalTabRenderer(args: { worktreeId: string terminalTab: TerminalTabColdParkCandidate diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts new file mode 100644 index 00000000000..d3d9b44d544 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { combineTerminalWorktreeParkIds } from './use-manual-terminal-worktree-parking' + +describe('combineTerminalWorktreeParkIds', () => { + it('adds manually parked worktrees without dropping automatic parks', () => { + expect(combineTerminalWorktreeParkIds(new Set(['automatic']), new Set(['manual']))).toEqual( + new Set(['automatic', 'manual']) + ) + }) + + it('keeps the automatic set reference when there are no manual parks', () => { + const automatic = new Set(['automatic']) + + expect(combineTerminalWorktreeParkIds(automatic, new Set())).toBe(automatic) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts new file mode 100644 index 00000000000..2d1ed09afce --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { + MANUAL_TERMINAL_WORKTREE_PARK_EVENT, + takeAllPendingManualTerminalWorktreeParks, + takePendingManualTerminalWorktreePark, + type ManualTerminalWorktreeParkDetail +} from '@/lib/manual-terminal-worktree-parking' +import { canManuallyParkTerminalWorktreeRenderers } from './terminal-hidden-view-parking' +import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' + +function addWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet { + if (current.has(worktreeId)) { + return current + } + return new Set([...current, worktreeId]) +} + +function removeWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet { + if (!current.has(worktreeId)) { + return current + } + const next = new Set(current) + next.delete(worktreeId) + return next +} + +export function combineTerminalWorktreeParkIds( + automaticIds: ReadonlySet, + manualIds: ReadonlySet +): ReadonlySet { + if (manualIds.size === 0) { + return automaticIds + } + return new Set([...automaticIds, ...manualIds]) +} + +export function useManualTerminalWorktreeParking(args: { + activeView: string + renderedActiveWorktreeId: string | null +}): ReadonlySet { + const [manuallyParkedWorktreeIds, setManuallyParkedWorktreeIds] = useState>( + () => new Set() + ) + + const parkWorktree = useCallback((worktreeId: string) => { + const state = useAppStore.getState() + const terminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const canPark = + canManuallyParkTerminalWorktreeRenderers({ + worktreeId, + terminalTabs, + pendingStartupByTabId: state.pendingStartupByTabId, + parkingEnabled: state.settings?.terminalHiddenViewParking !== false + }) && terminalTabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab)) + if (!canPark) { + toast.warning( + translate( + 'auto.components.terminalPane.useManualTerminalWorktreeParking.cannotPark', + 'These terminals cannot be parked safely.' + ) + ) + return + } + setManuallyParkedWorktreeIds((current) => addWorktreeId(current, worktreeId)) + }, []) + + useEffect(() => { + const handleParkRequest = (event: Event): void => { + const worktreeId = (event as CustomEvent).detail?.worktreeId + if (!worktreeId) { + return + } + takePendingManualTerminalWorktreePark(worktreeId) + parkWorktree(worktreeId) + } + window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, handleParkRequest as EventListener) + for (const worktreeId of takeAllPendingManualTerminalWorktreeParks()) { + parkWorktree(worktreeId) + } + return () => + window.removeEventListener( + MANUAL_TERMINAL_WORKTREE_PARK_EVENT, + handleParkRequest as EventListener + ) + }, [parkWorktree]) + + useEffect(() => { + const revealedWorktreeId = args.renderedActiveWorktreeId + if (args.activeView !== 'terminal' || !revealedWorktreeId) { + return + } + setManuallyParkedWorktreeIds((current) => removeWorktreeId(current, revealedWorktreeId)) + }, [args.activeView, args.renderedActiveWorktreeId]) + + return manuallyParkedWorktreeIds +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 01a53e11044..ff9f7702a09 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4939,6 +4939,10 @@ "close": "Close", "image": "Image", "expand": "Expand image" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13963,6 +13967,11 @@ "undo": "Undo", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index df89084b3bc..a9c4997a5d5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4916,6 +4916,10 @@ "close": "Cerrar", "image": "Imagen", "expand": "Ampliar imagen" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13940,6 +13944,11 @@ "undo": "Deshacer", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 62fd5330a68..45a9e137069 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4916,6 +4916,10 @@ "close": "閉じる", "image": "画像", "expand": "画像を拡大" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13940,6 +13944,11 @@ "undo": "元に戻す", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index df331ec2e3c..1ba309520a2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4916,6 +4916,10 @@ "close": "닫기", "image": "이미지", "expand": "이미지 확대" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13940,6 +13944,11 @@ "undo": "실행 취소", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a6075fd7fb2..3d1a8f12598 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4916,6 +4916,10 @@ "close": "关闭", "image": "图像", "expand": "展开图像" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13940,6 +13944,11 @@ "undo": "撤销", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts b/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts new file mode 100644 index 00000000000..e70ca180b01 --- /dev/null +++ b/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MANUAL_TERMINAL_WORKTREE_PARK_EVENT, + requestManualTerminalWorktreePark, + takeAllPendingManualTerminalWorktreeParks, + takePendingManualTerminalWorktreePark, + type ManualTerminalWorktreeParkDetail +} from './manual-terminal-worktree-parking' + +describe('manual terminal worktree parking requests', () => { + beforeEach(() => { + takeAllPendingManualTerminalWorktreeParks() + }) + + it('dispatches the target worktree and records it for a late Terminal mount', () => { + const listener = vi.fn((event: Event) => { + expect((event as CustomEvent).detail).toEqual({ + worktreeId: 'worktree-1' + }) + }) + window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener) + + requestManualTerminalWorktreePark('worktree-1') + + expect(listener).toHaveBeenCalledOnce() + expect(takePendingManualTerminalWorktreePark('worktree-1')).toBe(true) + window.removeEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener) + }) + + it('ignores an empty worktree id', () => { + const listener = vi.fn() + window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener) + + requestManualTerminalWorktreePark('') + + expect(listener).not.toHaveBeenCalled() + expect(takeAllPendingManualTerminalWorktreeParks()).toEqual([]) + window.removeEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener) + }) +}) diff --git a/src/renderer/src/lib/manual-terminal-worktree-parking.ts b/src/renderer/src/lib/manual-terminal-worktree-parking.ts new file mode 100644 index 00000000000..19113da5767 --- /dev/null +++ b/src/renderer/src/lib/manual-terminal-worktree-parking.ts @@ -0,0 +1,29 @@ +export const MANUAL_TERMINAL_WORKTREE_PARK_EVENT = 'orca-manual-terminal-worktree-park' + +export type ManualTerminalWorktreeParkDetail = { + worktreeId: string +} + +const pendingWorktreeIds = new Set() + +export function requestManualTerminalWorktreePark(worktreeId: string): void { + if (!worktreeId) { + return + } + pendingWorktreeIds.add(worktreeId) + window.dispatchEvent( + new CustomEvent(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, { + detail: { worktreeId } + }) + ) +} + +export function takePendingManualTerminalWorktreePark(worktreeId: string): boolean { + return pendingWorktreeIds.delete(worktreeId) +} + +export function takeAllPendingManualTerminalWorktreeParks(): string[] { + const pending = [...pendingWorktreeIds] + pendingWorktreeIds.clear() + return pending +}