mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
feat(terminal): add manual parking developer action
This commit is contained in:
@@ -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<ReadonlySet<string>>(
|
||||
() => 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<string, ReadonlySet<string>>())
|
||||
// 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<string>(),
|
||||
[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 (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${workspace.id}`}
|
||||
@@ -2159,9 +2170,8 @@ function Terminal(): React.JSX.Element | null {
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
effectiveParkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
|
||||
@@ -55,6 +55,7 @@ import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-st
|
||||
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
|
||||
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
|
||||
import { WorktreeParentPickerPopover } from './WorktreeParentPickerPopover'
|
||||
import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
|
||||
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
|
||||
import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -924,6 +925,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isMultiContext ? (
|
||||
<>
|
||||
<WorktreeDeveloperMenu worktreeId={worktree.id} disabled={isDeleting} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
|
||||
|
||||
type MenuItemProps = {
|
||||
children?: ReactNode
|
||||
onSelect?: () => 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(
|
||||
<WorktreeDeveloperMenu worktreeId="worktree-1" disabled={false} />
|
||||
)
|
||||
|
||||
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(<WorktreeDeveloperMenu worktreeId="worktree-1" disabled={false} />)
|
||||
|
||||
menuItems.values[0]?.onSelect?.()
|
||||
|
||||
expect(requestManualPark).toHaveBeenCalledWith('worktree-1')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={disabled}>
|
||||
<CodeXml className="size-3.5" />
|
||||
{translate('auto.components.sidebar.WorktreeDeveloperMenu.developer', 'Developer')}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-44">
|
||||
<DropdownMenuItem onSelect={() => requestManualTerminalWorktreePark(worktreeId)}>
|
||||
<SquareParking className="size-3.5" />
|
||||
{translate('auto.components.sidebar.WorktreeDeveloperMenu.parkTerminal', 'Park terminal')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -107,6 +107,26 @@ export function canParkTerminalWorktreeRenderers(args: {
|
||||
})
|
||||
}
|
||||
|
||||
export function canManuallyParkTerminalWorktreeRenderers(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly ColdParkableTerminalTab[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
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
|
||||
|
||||
+16
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>, worktreeId: string): ReadonlySet<string> {
|
||||
if (current.has(worktreeId)) {
|
||||
return current
|
||||
}
|
||||
return new Set([...current, worktreeId])
|
||||
}
|
||||
|
||||
function removeWorktreeId(current: ReadonlySet<string>, worktreeId: string): ReadonlySet<string> {
|
||||
if (!current.has(worktreeId)) {
|
||||
return current
|
||||
}
|
||||
const next = new Set(current)
|
||||
next.delete(worktreeId)
|
||||
return next
|
||||
}
|
||||
|
||||
export function combineTerminalWorktreeParkIds(
|
||||
automaticIds: ReadonlySet<string>,
|
||||
manualIds: ReadonlySet<string>
|
||||
): ReadonlySet<string> {
|
||||
if (manualIds.size === 0) {
|
||||
return automaticIds
|
||||
}
|
||||
return new Set([...automaticIds, ...manualIds])
|
||||
}
|
||||
|
||||
export function useManualTerminalWorktreeParking(args: {
|
||||
activeView: string
|
||||
renderedActiveWorktreeId: string | null
|
||||
}): ReadonlySet<string> {
|
||||
const [manuallyParkedWorktreeIds, setManuallyParkedWorktreeIds] = useState<ReadonlySet<string>>(
|
||||
() => 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<ManualTerminalWorktreeParkDetail>).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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<ManualTerminalWorktreeParkDetail>).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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
|
||||
export function requestManualTerminalWorktreePark(worktreeId: string): void {
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
pendingWorktreeIds.add(worktreeId)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ManualTerminalWorktreeParkDetail>(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
|
||||
}
|
||||
Reference in New Issue
Block a user