mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(terminal): make park synchronization cache independent
This commit is contained in:
@@ -10,7 +10,6 @@ import { shouldMountBackgroundWorktreeTab } from '../terminal/background-termina
|
||||
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
|
||||
import { TerminalOverlaySlot } from './TerminalOverlaySlot'
|
||||
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
|
||||
import { useWorktreeParkTerminalAssignments } from './use-worktree-park-terminal-assignments'
|
||||
|
||||
type TerminalOverlayAssignment = {
|
||||
unifiedTabId: string
|
||||
@@ -100,12 +99,10 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
}
|
||||
return entries
|
||||
}, [groupActiveTabById, unifiedTabs])
|
||||
const parkingAssignments = useWorktreeParkTerminalAssignments(assignments, coldParkTerminalPanes)
|
||||
|
||||
const parkedTerminalTabIds = useTerminalTabColdParking({
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
assignments: parkingAssignments,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes,
|
||||
isForceParked,
|
||||
|
||||
+27
@@ -1,5 +1,6 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { act, StrictMode } from 'react'
|
||||
import type * as ReactModule from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types'
|
||||
@@ -8,12 +9,24 @@ import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types'
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
worktreeId: 'repo::/cold-park-pre-gate',
|
||||
discardMemoCaches: false,
|
||||
coverageCalls: 0,
|
||||
syncCalls: 0,
|
||||
slotRenders: 0,
|
||||
renderedParkedSets: [] as string[][]
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useMemo: ((calculate, dependencies) =>
|
||||
harness.discardMemoCaches
|
||||
? calculate()
|
||||
: actual.useMemo(calculate, dependencies)) as typeof actual.useMemo
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../store', async () => {
|
||||
const { create } = await import('zustand')
|
||||
const useAppStore = create(() => ({
|
||||
@@ -164,6 +177,7 @@ describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => {
|
||||
let root: Root | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
harness.discardMemoCaches = false
|
||||
harness.coverageCalls = 0
|
||||
harness.syncCalls = 0
|
||||
harness.slotRenders = 0
|
||||
@@ -310,4 +324,17 @@ describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => {
|
||||
expect(renderProductionLayer(root)).toBeNull()
|
||||
expect(harness.renderedParkedSets.at(-1)).toEqual([...TAB_IDS])
|
||||
})
|
||||
|
||||
it('settles equivalent publications after memo cache discard', () => {
|
||||
harness.discardMemoCaches = true
|
||||
root = createRoot(container)
|
||||
|
||||
const thrown = renderProductionLayer(root)
|
||||
|
||||
expect(thrown).toBeNull()
|
||||
expect(harness.syncCalls).toBeLessThan(10)
|
||||
expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual(
|
||||
new Set([TAB_IDS.join(',')])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { syncParkedTerminalTabWatchers } from './terminal-parked-tab-watchers'
|
||||
|
||||
type TerminalParkingAssignment = {
|
||||
groupId: string
|
||||
isActiveInGroup: boolean
|
||||
}
|
||||
|
||||
export function getTerminalParkingInputsKey(terminalTabs: readonly TerminalTab[]): string {
|
||||
return JSON.stringify(terminalTabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn]))
|
||||
}
|
||||
|
||||
export function getTerminalParkingAssignmentsKey(
|
||||
assignments: ReadonlyMap<string, TerminalParkingAssignment>
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
Array.from(assignments, ([tabId, assignment]) => [
|
||||
tabId,
|
||||
assignment.groupId,
|
||||
assignment.isActiveInGroup
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function getWatcherSynchronizationKey(args: {
|
||||
worktreeId: string
|
||||
inputsKey: string
|
||||
assignmentsKey: string
|
||||
parkedTabIds: ReadonlySet<string>
|
||||
activationDeferredMountTabIds?: ReadonlySet<string> | null
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
args.worktreeId,
|
||||
args.inputsKey,
|
||||
args.assignmentsKey,
|
||||
Array.from(args.parkedTabIds),
|
||||
Array.from(args.activationDeferredMountTabIds ?? []).sort()
|
||||
])
|
||||
}
|
||||
|
||||
export function useParkedTerminalWatcherSynchronization(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly TerminalTab[]
|
||||
inputsKey: string
|
||||
assignmentsKey: string
|
||||
parkedTabIds: ReadonlySet<string>
|
||||
activationDeferredMountTabIds?: ReadonlySet<string> | null
|
||||
}): void {
|
||||
const { worktreeId, terminalTabs, parkedTabIds, activationDeferredMountTabIds } = args
|
||||
const synchronizationKey = getWatcherSynchronizationKey(args)
|
||||
const synchronizationKeyRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (synchronizationKeyRef.current === synchronizationKey) {
|
||||
return
|
||||
}
|
||||
synchronizationKeyRef.current = synchronizationKey
|
||||
syncParkedTerminalTabWatchers({
|
||||
worktreeId,
|
||||
tabs: terminalTabs,
|
||||
parkedTabIds,
|
||||
// Why: activation-deferred tabs have no prior pane-owned title slot.
|
||||
restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined
|
||||
})
|
||||
}, [activationDeferredMountTabIds, parkedTabIds, synchronizationKey, terminalTabs, worktreeId])
|
||||
}
|
||||
@@ -33,10 +33,13 @@ import {
|
||||
import { selectSleepingRecordParkExemptTabIds } from './sleeping-record-park-exemption'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
syncParkedTerminalTabWatchers
|
||||
disposeParkedTerminalWatchersForWorktree
|
||||
} from './terminal-parked-tab-watchers'
|
||||
import { useWorktreeParkTerminalTabs } from './use-worktree-park-terminal-tabs'
|
||||
import {
|
||||
getTerminalParkingAssignmentsKey,
|
||||
getTerminalParkingInputsKey,
|
||||
useParkedTerminalWatcherSynchronization
|
||||
} from './use-parked-terminal-watcher-synchronization'
|
||||
|
||||
type TerminalOverlayTabAssignment = {
|
||||
groupId: string
|
||||
@@ -78,7 +81,7 @@ export function useTerminalTabColdParking(args: {
|
||||
}): ReadonlySet<string> {
|
||||
const {
|
||||
worktreeId,
|
||||
terminalTabs: publishedTerminalTabs,
|
||||
terminalTabs,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes,
|
||||
@@ -87,7 +90,14 @@ export function useTerminalTabColdParking(args: {
|
||||
activityTerminalPortals,
|
||||
activationDeferredMountTabIds
|
||||
} = args
|
||||
const terminalTabs = useWorktreeParkTerminalTabs(publishedTerminalTabs, coldParkTerminalPanes)
|
||||
const terminalParkingInputsKey = getTerminalParkingInputsKey(terminalTabs)
|
||||
const terminalParkingAssignmentsKey = getTerminalParkingAssignmentsKey(assignments)
|
||||
const terminalParkingTabsDependency = coldParkTerminalPanes
|
||||
? terminalParkingInputsKey
|
||||
: terminalTabs
|
||||
const terminalParkingAssignmentsDependency = coldParkTerminalPanes
|
||||
? terminalParkingAssignmentsKey
|
||||
: assignments
|
||||
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore(
|
||||
(state) => state.settings?.terminalHiddenViewParking !== false
|
||||
@@ -249,17 +259,18 @@ export function useTerminalTabColdParking(args: {
|
||||
timers.set(tabId, timer)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- semantic keys own the tab and assignment dependencies.
|
||||
}, [
|
||||
activityTerminalPortals,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
pendingStartupByTabId,
|
||||
pairedRuntimeParkingEnvironmentIds,
|
||||
runtimeStatusByEnvironmentId,
|
||||
shouldMeasureHiddenWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalSshParkingEnabled,
|
||||
terminalTabParkingRevision,
|
||||
terminalTabs,
|
||||
terminalParkingAssignmentsDependency,
|
||||
terminalParkingTabsDependency,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
@@ -359,16 +370,14 @@ export function useTerminalTabColdParking(args: {
|
||||
// panes — watcher disposal therefore lands before any PTY data IPC can
|
||||
// reach a freshly remounted pane, and watcher start lands after the parked
|
||||
// pane's unmount capture.
|
||||
useEffect(() => {
|
||||
syncParkedTerminalTabWatchers({
|
||||
worktreeId,
|
||||
tabs: terminalTabs,
|
||||
parkedTabIds: parkedTerminalTabIds,
|
||||
// Why: activation-deferred tabs have no prior pane-owned title slot;
|
||||
// pull main's title-only snapshot when their watcher starts.
|
||||
restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined
|
||||
})
|
||||
}, [activationDeferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId])
|
||||
useParkedTerminalWatcherSynchronization({
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
assignmentsKey: terminalParkingAssignmentsKey,
|
||||
inputsKey: terminalParkingInputsKey,
|
||||
parkedTabIds: parkedTerminalTabIds,
|
||||
activationDeferredMountTabIds
|
||||
})
|
||||
|
||||
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type WorktreeParkTerminalAssignment = {
|
||||
unifiedTabId: string
|
||||
groupId: string
|
||||
isActiveInGroup: boolean
|
||||
}
|
||||
|
||||
function getTerminalParkingAssignmentsKey(
|
||||
assignments: ReadonlyMap<string, WorktreeParkTerminalAssignment>
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
Array.from(assignments, ([tabId, assignment]) => [
|
||||
tabId,
|
||||
assignment.unifiedTabId,
|
||||
assignment.groupId,
|
||||
assignment.isActiveInGroup
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function useWorktreeParkTerminalAssignments(
|
||||
assignments: ReadonlyMap<string, WorktreeParkTerminalAssignment>,
|
||||
coldParkTerminalPanes: boolean
|
||||
): ReadonlyMap<string, WorktreeParkTerminalAssignment> {
|
||||
const parkingAssignmentsKey = coldParkTerminalPanes
|
||||
? getTerminalParkingAssignmentsKey(assignments)
|
||||
: assignments
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- decorative unified labels must not retrigger dominated pre-gate work.
|
||||
return useMemo(() => assignments, [parkingAssignmentsKey])
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
|
||||
function getTerminalParkingInputsKey(terminalTabs: readonly TerminalTab[]): string {
|
||||
return JSON.stringify(terminalTabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn]))
|
||||
}
|
||||
|
||||
export function useWorktreeParkTerminalTabs(
|
||||
terminalTabs: readonly TerminalTab[],
|
||||
coldParkTerminalPanes: boolean
|
||||
): readonly TerminalTab[] {
|
||||
const parkingInputsKey = coldParkTerminalPanes
|
||||
? getTerminalParkingInputsKey(terminalTabs)
|
||||
: terminalTabs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- decorative tab publications must not retrigger dominated pre-gate work.
|
||||
return useMemo(() => terminalTabs, [parkingInputsKey])
|
||||
}
|
||||
Reference in New Issue
Block a user