fix(lint): split overlay slot and eviction-exempt tabs under max-lines

Static analysis failed because TerminalPaneOverlayLayer (401) and
terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the
slot component and eviction-exempt helpers into dedicated modules.
This commit is contained in:
Jinjing
2026-07-28 15:06:49 -07:00
parent 31e0c9f375
commit 4b2c2ebf9c
9 changed files with 369 additions and 348 deletions
+1 -1
View File
@@ -101,11 +101,11 @@ import { captureForceParkedWorktreeBuffers } from './terminal-pane/force-park-bu
import { warnTerminalLifecycleAnomaly } from './terminal-pane/terminal-lifecycle-diagnostics'
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
import { useColdParkedTerminalPresentation } from './terminal-pane/use-cold-parked-terminal-presentation'
import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs'
import {
canWatcherCoverParkedTerminalTab,
disposeAllParkedTerminalWatchers,
pruneParkedTerminalWatchers,
selectEvictionExemptTerminalTabIds,
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers
} from './terminal-pane/terminal-parked-tab-watchers'
@@ -0,0 +1,290 @@
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useAppStore } from '../../store'
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal'
import TerminalPane from './TerminalPane'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
import {
FALLBACK_RECT_MIN_CHANGE_PX,
MIN_OVERLAY_FIT_HEIGHT_PX,
MIN_OVERLAY_FIT_WIDTH_PX,
shouldUseCssAnchorPositioning
} from './terminal-overlay-positioning'
type MeasuredFallbackRect = {
top: number
left: number
width: number
height: number
}
type TerminalOverlaySlotProps = {
terminalTabId: string
terminalGeneration: number | undefined
worktreeId: string
worktreePath: string
startupCwd: string | undefined
groupId: string | undefined
isWorktreeActive: boolean
isWorktreePresented: boolean
isVisible: boolean
isPresented: boolean
isActive: boolean
activityTerminalPortal: ActivityTerminalPortalTarget | null
onFocusOwningGroup: ((groupId: string) => void) | undefined
consumeSuppressedPtyExit: (ptyId: string) => boolean
leaveWorktreeIfEmpty: () => void
onInitialRenderSettled?: () => void
}
export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
terminalTabId,
terminalGeneration,
worktreeId,
worktreePath,
startupCwd,
groupId,
isWorktreeActive,
isWorktreePresented,
isVisible,
isPresented,
isActive,
activityTerminalPortal,
onFocusOwningGroup,
consumeSuppressedPtyExit,
leaveWorktreeIfEmpty,
onInitialRenderSettled
}: TerminalOverlaySlotProps): React.JSX.Element {
const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined
const overlayRef = useRef<HTMLDivElement | null>(null)
const [measuredFallbackRect, setMeasuredFallbackRect] = useState<MeasuredFallbackRect | null>(
null
)
const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState(
() => useAppStore.getState().pendingStartupByTabId[terminalTabId] !== undefined
)
useLayoutEffect(() => {
if (isVisible && shouldMeasureHiddenStartup) {
setShouldMeasureHiddenStartup(false)
}
}, [isVisible, shouldMeasureHiddenStartup])
useLayoutEffect(() => {
if (!anchorName || shouldUseCssAnchorPositioning() || !groupId) {
return
}
const findBody = (): HTMLElement | null => {
for (const candidate of document.querySelectorAll<HTMLElement>('[data-tab-group-body-id]')) {
if (candidate.dataset.tabGroupBodyId === groupId) {
return candidate
}
}
return null
}
const updateRect = (): void => {
const overlay = overlayRef.current
const parent = overlay?.parentElement
const body = findBody()
if (!parent || !body) {
setMeasuredFallbackRect(null)
return
}
const parentRect = parent.getBoundingClientRect()
const bodyRect = body.getBoundingClientRect()
const next: MeasuredFallbackRect = {
top: bodyRect.top - parentRect.top,
left: bodyRect.left - parentRect.left,
width: bodyRect.width,
height: bodyRect.height
}
// Why: ResizeObserver and xterm fit can otherwise amplify sub-pixel jitter forever.
setMeasuredFallbackRect((prev) =>
prev &&
Math.abs(prev.top - next.top) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.left - next.left) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.width - next.width) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.height - next.height) < FALLBACK_RECT_MIN_CHANGE_PX
? prev
: next
)
}
updateRect()
const body = findBody()
const parent = overlayRef.current?.parentElement
const resizeObserver = new ResizeObserver(updateRect)
if (body) {
resizeObserver.observe(body)
}
if (parent) {
resizeObserver.observe(parent)
}
window.addEventListener('resize', updateRect)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', updateRect)
}
}, [anchorName, groupId, isVisible])
useLayoutEffect(() => {
if (!isVisible || !anchorName) {
return
}
const dispatchFitIfMeasurable = (): void => {
const rect = overlayRef.current?.getBoundingClientRect()
if (
!rect ||
rect.width < MIN_OVERLAY_FIT_WIDTH_PX ||
rect.height < MIN_OVERLAY_FIT_HEIGHT_PX
) {
return
}
window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT))
}
// Why: tab switches can resume visibility before anchor/fallback geometry
// settles. Re-fit only after the overlay has real dimensions so the PTY
// never stays pinned at a stale ~2-col width.
const frameId = requestAnimationFrame(() => {
dispatchFitIfMeasurable()
})
const retryId = window.setTimeout(() => {
dispatchFitIfMeasurable()
}, 50)
const settledRetryId = window.setTimeout(() => {
dispatchFitIfMeasurable()
}, 150)
return () => {
cancelAnimationFrame(frameId)
window.clearTimeout(retryId)
window.clearTimeout(settledRetryId)
}
}, [anchorName, isVisible, measuredFallbackRect])
const showPresentedTerminal = isPresented && (isWorktreeActive || isWorktreePresented)
const isInteractive = isVisible && isPresented && isWorktreePresented
const style: React.CSSProperties = useMemo(
() =>
anchorName && shouldUseCssAnchorPositioning()
? {
position: 'absolute',
positionAnchor: anchorName,
top: `anchor(${anchorName} top)`,
left: `anchor(${anchorName} left)`,
width: `anchor-size(${anchorName} width)`,
height: `anchor-size(${anchorName} height)`,
display:
isVisible || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
opacity: showPresentedTerminal ? 1 : 0,
pointerEvents: isInteractive ? 'auto' : 'none'
}
: anchorName
? {
// Why: Chrome builds without CSS anchor positioning otherwise
// mount the terminal into a 0x0 overlay. Measure the tab-group
// body so the fallback does not cover the tab strip.
position: 'absolute',
top: measuredFallbackRect?.top ?? 32,
left: measuredFallbackRect?.left ?? 0,
width: measuredFallbackRect?.width ?? '100%',
height: measuredFallbackRect?.height ?? 'calc(100% - 32px)',
display:
isVisible || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
opacity: showPresentedTerminal ? 1 : 0,
pointerEvents: isInteractive ? 'auto' : 'none'
}
: {
position: 'absolute',
top: 0,
left: 0,
width: 0,
height: 0,
display: 'none',
pointerEvents: 'none'
},
[
anchorName,
isInteractive,
isVisible,
measuredFallbackRect,
shouldMeasureHiddenStartup,
showPresentedTerminal
]
)
const focusGroup = useCallback(() => {
if (groupId !== undefined && onFocusOwningGroup) {
onFocusOwningGroup(groupId)
}
}, [groupId, onFocusOwningGroup])
const terminalPane = (
<TerminalPane
key={`${terminalTabId}-${terminalGeneration ?? 0}`}
tabId={terminalTabId}
worktreeId={worktreeId}
cwd={startupCwd ?? worktreePath}
isActive={
(isActive && isPresented && isWorktreePresented) || activityTerminalPortal?.active === true
}
// Why: split-group changes reparent TabGroupPanel subtrees. Keeping the
// TerminalPane mounted here preserves alt-screen TUI state while this
// flag still lets hidden tabs throttle rendering.
isVisible={isVisible || showPresentedTerminal || activityTerminalPortal !== null}
isWorktreeActive={isWorktreeActive || isWorktreePresented || activityTerminalPortal !== null}
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
onPtyExit={(ptyId) => {
if (consumeSuppressedPtyExit(ptyId)) {
return
}
// Why: a parked multi-leaf tab has no PaneManager to promote split
// siblings, so closing the tab here would kill them; the reveal
// remount handles dead PTYs per leaf instead.
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
return
}
closeTerminalTab(terminalTabId, {
reason: 'pty-exit',
lifecyclePtyId: ptyId,
onClosed: leaveWorktreeIfEmpty
})
}}
onCloseTab={() => {
// Why: route through closeTerminalTab (not the raw store closeTab) so a
// pinned tab hits the confirmation guard. The overlay's direct
// store.closeTab was the path that closed pinned terminals silently.
closeTerminalTab(terminalTabId, { onClosed: leaveWorktreeIfEmpty })
}}
onInitialRenderSettled={onInitialRenderSettled}
/>
)
if (activityTerminalPortal) {
return createPortal(
terminalPane,
activityTerminalPortal.target,
`activity-terminal-${terminalTabId}`
)
}
return (
<div
ref={overlayRef}
style={style}
data-terminal-overlay-tab-id={terminalTabId}
data-terminal-overlay-presented={showPresentedTerminal ? 'true' : 'false'}
inert={!isInteractive}
aria-hidden={!isInteractive}
onPointerDown={focusGroup}
onFocusCapture={focusGroup}
>
{terminalPane}
{/* The chat/terminal toggle now lives in the pane header's action cluster
(TerminalPaneHeaderOverlay), beside split/close — not as a separate
floating overlay. */}
</div>
)
})
@@ -19,7 +19,7 @@ vi.mock('../../store', () => ({
})
}))
import { TerminalOverlaySlot } from './TerminalPaneOverlayLayer'
import { TerminalOverlaySlot } from './TerminalOverlaySlot'
const GROUP_ID = 'group-react185'
const TAB_ID = 'tab-react185'
@@ -1,309 +1,23 @@
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { memo, useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
import {
findActivityTerminalPortal,
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
import TerminalPane from './TerminalPane'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount'
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
import { useTerminalOverlayPresentation } from './use-terminal-overlay-presentation'
import { buildTerminalOverlayAssignments } from './terminal-overlay-assignments'
import {
FALLBACK_RECT_MIN_CHANGE_PX,
MIN_OVERLAY_FIT_HEIGHT_PX,
MIN_OVERLAY_FIT_WIDTH_PX,
shouldUseCssAnchorPositioning
} from './terminal-overlay-positioning'
import { TerminalOverlaySlot } from './TerminalOverlaySlot'
const EMPTY_TERMINAL_TABS: readonly TerminalTab[] = []
const EMPTY_UNIFIED_TABS: readonly Tab[] = []
const EMPTY_GROUPS: readonly TabGroup[] = []
const EMPTY_ACTIVITY_PORTALS: ActivityTerminalPortalTarget[] = []
type MeasuredFallbackRect = {
top: number
left: number
width: number
height: number
}
type TerminalOverlaySlotProps = {
terminalTabId: string
terminalGeneration: number | undefined
worktreeId: string
worktreePath: string
startupCwd: string | undefined
groupId: string | undefined
isWorktreeActive: boolean
isWorktreePresented: boolean
isVisible: boolean
isPresented: boolean
isActive: boolean
activityTerminalPortal: ActivityTerminalPortalTarget | null
onFocusOwningGroup: ((groupId: string) => void) | undefined
consumeSuppressedPtyExit: (ptyId: string) => boolean
leaveWorktreeIfEmpty: () => void
onInitialRenderSettled?: () => void
}
export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
terminalTabId,
terminalGeneration,
worktreeId,
worktreePath,
startupCwd,
groupId,
isWorktreeActive,
isWorktreePresented,
isVisible,
isPresented,
isActive,
activityTerminalPortal,
onFocusOwningGroup,
consumeSuppressedPtyExit,
leaveWorktreeIfEmpty,
onInitialRenderSettled
}: TerminalOverlaySlotProps): React.JSX.Element {
const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined
const overlayRef = useRef<HTMLDivElement | null>(null)
const [measuredFallbackRect, setMeasuredFallbackRect] = useState<MeasuredFallbackRect | null>(
null
)
const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState(
() => useAppStore.getState().pendingStartupByTabId[terminalTabId] !== undefined
)
useLayoutEffect(() => {
if (isVisible && shouldMeasureHiddenStartup) {
setShouldMeasureHiddenStartup(false)
}
}, [isVisible, shouldMeasureHiddenStartup])
useLayoutEffect(() => {
if (!anchorName || shouldUseCssAnchorPositioning() || !groupId) {
return
}
const findBody = (): HTMLElement | null => {
for (const candidate of document.querySelectorAll<HTMLElement>('[data-tab-group-body-id]')) {
if (candidate.dataset.tabGroupBodyId === groupId) {
return candidate
}
}
return null
}
const updateRect = (): void => {
const overlay = overlayRef.current
const parent = overlay?.parentElement
const body = findBody()
if (!parent || !body) {
setMeasuredFallbackRect(null)
return
}
const parentRect = parent.getBoundingClientRect()
const bodyRect = body.getBoundingClientRect()
const next: MeasuredFallbackRect = {
top: bodyRect.top - parentRect.top,
left: bodyRect.left - parentRect.left,
width: bodyRect.width,
height: bodyRect.height
}
// Why: ResizeObserver and xterm fit can otherwise amplify sub-pixel jitter forever.
setMeasuredFallbackRect((prev) =>
prev &&
Math.abs(prev.top - next.top) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.left - next.left) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.width - next.width) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.height - next.height) < FALLBACK_RECT_MIN_CHANGE_PX
? prev
: next
)
}
updateRect()
const body = findBody()
const parent = overlayRef.current?.parentElement
const resizeObserver = new ResizeObserver(updateRect)
if (body) {
resizeObserver.observe(body)
}
if (parent) {
resizeObserver.observe(parent)
}
window.addEventListener('resize', updateRect)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', updateRect)
}
}, [anchorName, groupId, isVisible])
useLayoutEffect(() => {
if (!isVisible || !anchorName) {
return
}
const dispatchFitIfMeasurable = (): void => {
const rect = overlayRef.current?.getBoundingClientRect()
if (
!rect ||
rect.width < MIN_OVERLAY_FIT_WIDTH_PX ||
rect.height < MIN_OVERLAY_FIT_HEIGHT_PX
) {
return
}
window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT))
}
// Why: tab switches can resume visibility before anchor/fallback geometry
// settles. Re-fit only after the overlay has real dimensions so the PTY
// never stays pinned at a stale ~2-col width.
const frameId = requestAnimationFrame(() => {
dispatchFitIfMeasurable()
})
const retryId = window.setTimeout(() => {
dispatchFitIfMeasurable()
}, 50)
const settledRetryId = window.setTimeout(() => {
dispatchFitIfMeasurable()
}, 150)
return () => {
cancelAnimationFrame(frameId)
window.clearTimeout(retryId)
window.clearTimeout(settledRetryId)
}
}, [anchorName, isVisible, measuredFallbackRect])
const showPresentedTerminal = isPresented && (isWorktreeActive || isWorktreePresented)
const isInteractive = isVisible && isPresented && isWorktreePresented
const style: React.CSSProperties = useMemo(
() =>
anchorName && shouldUseCssAnchorPositioning()
? {
position: 'absolute',
positionAnchor: anchorName,
top: `anchor(${anchorName} top)`,
left: `anchor(${anchorName} left)`,
width: `anchor-size(${anchorName} width)`,
height: `anchor-size(${anchorName} height)`,
display:
isVisible || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
opacity: showPresentedTerminal ? 1 : 0,
pointerEvents: isInteractive ? 'auto' : 'none'
}
: anchorName
? {
// Why: Chrome builds without CSS anchor positioning otherwise
// mount the terminal into a 0x0 overlay. Measure the tab-group
// body so the fallback does not cover the tab strip.
position: 'absolute',
top: measuredFallbackRect?.top ?? 32,
left: measuredFallbackRect?.left ?? 0,
width: measuredFallbackRect?.width ?? '100%',
height: measuredFallbackRect?.height ?? 'calc(100% - 32px)',
display:
isVisible || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
opacity: showPresentedTerminal ? 1 : 0,
pointerEvents: isInteractive ? 'auto' : 'none'
}
: {
position: 'absolute',
top: 0,
left: 0,
width: 0,
height: 0,
display: 'none',
pointerEvents: 'none'
},
[
anchorName,
isInteractive,
isVisible,
measuredFallbackRect,
shouldMeasureHiddenStartup,
showPresentedTerminal
]
)
const focusGroup = useCallback(() => {
if (groupId !== undefined && onFocusOwningGroup) {
onFocusOwningGroup(groupId)
}
}, [groupId, onFocusOwningGroup])
const terminalPane = (
<TerminalPane
key={`${terminalTabId}-${terminalGeneration ?? 0}`}
tabId={terminalTabId}
worktreeId={worktreeId}
cwd={startupCwd ?? worktreePath}
isActive={
(isActive && isPresented && isWorktreePresented) || activityTerminalPortal?.active === true
}
// Why: split-group changes reparent TabGroupPanel subtrees. Keeping the
// TerminalPane mounted here preserves alt-screen TUI state while this
// flag still lets hidden tabs throttle rendering.
isVisible={isVisible || showPresentedTerminal || activityTerminalPortal !== null}
isWorktreeActive={isWorktreeActive || isWorktreePresented || activityTerminalPortal !== null}
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
onPtyExit={(ptyId) => {
if (consumeSuppressedPtyExit(ptyId)) {
return
}
// Why: a parked multi-leaf tab has no PaneManager to promote split
// siblings, so closing the tab here would kill them; the reveal
// remount handles dead PTYs per leaf instead.
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
return
}
closeTerminalTab(terminalTabId, {
reason: 'pty-exit',
lifecyclePtyId: ptyId,
onClosed: leaveWorktreeIfEmpty
})
}}
onCloseTab={() => {
// Why: route through closeTerminalTab (not the raw store closeTab) so a
// pinned tab hits the confirmation guard. The overlay's direct
// store.closeTab was the path that closed pinned terminals silently.
closeTerminalTab(terminalTabId, { onClosed: leaveWorktreeIfEmpty })
}}
onInitialRenderSettled={onInitialRenderSettled}
/>
)
if (activityTerminalPortal) {
return createPortal(
terminalPane,
activityTerminalPortal.target,
`activity-terminal-${terminalTabId}`
)
}
return (
<div
ref={overlayRef}
style={style}
data-terminal-overlay-tab-id={terminalTabId}
data-terminal-overlay-presented={showPresentedTerminal ? 'true' : 'false'}
inert={!isInteractive}
aria-hidden={!isInteractive}
onPointerDown={focusGroup}
onFocusCapture={focusGroup}
>
{terminalPane}
{/* The chat/terminal toggle now lives in the pane header's action cluster
(TerminalPaneHeaderOverlay), beside split/close — not as a separate
floating overlay. */}
</div>
)
})
const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
worktreePath,
@@ -0,0 +1,62 @@
/**
* Eviction-exempt parked terminal tabs.
*
* Why: force-park unmounts restorable tabs under the hidden-worktree retention
* budget, but some live local PTYs cannot reattach on remount. Those tabs must
* keep their panes mounted so eviction does not orphan a live shell.
*/
import { useAppStore } from '@/store'
import { isEvictionExemptTerminalPty } from './terminal-hidden-worktree-retention'
import {
resolveParkedTerminalPaneCandidates,
type ParkableTerminalTabModel
} from './terminal-parked-tab-watchers'
/**
* Whether force-park must keep this tab's panes mounted: ANY of its pane PTYs is
* one a remount could not reattach. Resolved from the same pane candidates as
* canWatcherCoverParkedTerminalTab — tab.ptyId is only the first leaf's PTY, so
* a split tab whose SECOND leaf holds the unrestorable PTY fails coverage (and
* so becomes a retention candidate) yet would otherwise look exempt-free and
* unmount, orphaning that live shell. tab.ptyId stays in the union in case pane
* resolution misses it (no layout, no capture).
*
* Accepted residual: detection is per pane but retention is per TAB — the whole
* PaneManager stays mounted, so one exempt leaf also pins its restorable split
* siblings, and the retention budget cannot bound exempt tabs. Capping them is
* not an option (eviction orphans the live shell); the real fix is making those
* pty classes reattachable. Sibling TABS are unaffected — the worktree-level
* veto was removed (selectRetentionForceParkedTerminalWorktrees never consults
* tab exemptions).
*/
export function isEvictionExemptTerminalTab(
tab: ParkableTerminalTabModel,
worktreeId: string
): boolean {
if (isEvictionExemptTerminalPty(tab.ptyId, worktreeId)) {
return true
}
return resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()).some((pane) =>
isEvictionExemptTerminalPty(pane.ptyId, worktreeId)
)
}
/**
* Eviction-exempt tab ids for one worktree's tabs, resolved in a single pass.
*
* Why: each isEvictionExemptTerminalTab call re-reads the store and can walk the
* layout tree, so render and watcher-sync consume a memoized set instead of
* re-asking per tab on every unrelated re-render.
*/
export function selectEvictionExemptTerminalTabIds(
worktreeId: string,
tabs: readonly ParkableTerminalTabModel[]
): ReadonlySet<string> {
const exemptTabIds = new Set<string>()
for (const tab of tabs) {
if (isEvictionExemptTerminalTab(tab, worktreeId)) {
exemptTabIds.add(tab.id)
}
}
return exemptTabIds
}
@@ -92,6 +92,10 @@ vi.mock('@/store', () => ({
useAppStore: { getState: () => mockStoreState }
}))
import {
isEvictionExemptTerminalTab,
selectEvictionExemptTerminalTabIds
} from './terminal-eviction-exempt-tabs'
import {
canWatcherCoverParkedTerminalTab,
captureParkedTerminalPaneCandidates,
@@ -99,9 +103,7 @@ import {
disposeParkedTerminalWatchersForWorktree,
fallbackParkedPaneCandidates,
getParkedTerminalWatcherTabIds,
isEvictionExemptTerminalTab,
pruneParkedTerminalWatchers,
selectEvictionExemptTerminalTabIds,
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
@@ -21,7 +21,6 @@ import {
isParkRestorableTerminalPty,
type TerminalParkRestorePolicy
} from './terminal-hidden-view-parking'
import { isEvictionExemptTerminalPty } from './terminal-hidden-worktree-retention'
import {
resolveTabTitleAfterPaneClose,
shouldClearLaunchAgentForClosedPane
@@ -85,7 +84,7 @@ export function fallbackParkedPaneCandidates(
}
// Why: start path and eligibility check must resolve identical candidates, or a tab passes the check then starts uncoverable.
function resolveParkedTerminalPaneCandidates(
export function resolveParkedTerminalPaneCandidates(
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
): ParkedTerminalPaneCapture[] {
@@ -107,55 +106,6 @@ function parkRestorePolicyFromState(state: {
return { sshParkingEnabled: state.settings?.terminalSshViewParking !== false }
}
/**
* Whether force-park must keep this tab's panes mounted: ANY of its pane PTYs is
* one a remount could not reattach. Resolved from the same pane candidates as
* canWatcherCoverParkedTerminalTab — tab.ptyId is only the first leaf's PTY, so
* a split tab whose SECOND leaf holds the unrestorable PTY fails coverage (and
* so becomes a retention candidate) yet would otherwise look exempt-free and
* unmount, orphaning that live shell. tab.ptyId stays in the union in case pane
* resolution misses it (no layout, no capture).
*
* Accepted residual: detection is per pane but retention is per TAB — the whole
* PaneManager stays mounted, so one exempt leaf also pins its restorable split
* siblings, and the retention budget cannot bound exempt tabs. Capping them is
* not an option (eviction orphans the live shell); the real fix is making those
* pty classes reattachable. Sibling TABS are unaffected — the worktree-level
* veto was removed (selectRetentionForceParkedTerminalWorktrees never consults
* tab exemptions).
*/
export function isEvictionExemptTerminalTab(
tab: ParkableTerminalTabModel,
worktreeId: string
): boolean {
if (isEvictionExemptTerminalPty(tab.ptyId, worktreeId)) {
return true
}
return resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()).some((pane) =>
isEvictionExemptTerminalPty(pane.ptyId, worktreeId)
)
}
/**
* Eviction-exempt tab ids for one worktree's tabs, resolved in a single pass.
*
* Why: each isEvictionExemptTerminalTab call re-reads the store and can walk the
* layout tree, so render and watcher-sync consume a memoized set instead of
* re-asking per tab on every unrelated re-render.
*/
export function selectEvictionExemptTerminalTabIds(
worktreeId: string,
tabs: readonly ParkableTerminalTabModel[]
): ReadonlySet<string> {
const exemptTabIds = new Set<string>()
for (const tab of tabs) {
if (isEvictionExemptTerminalTab(tab, worktreeId)) {
exemptTabIds.add(tab.id)
}
}
return exemptTabIds
}
/**
* Whether parked byte watchers can fully cover this tab's PTYs (every candidate
* has a park-restorable PTY on a valid leaf). Hosts must refuse to park a tab
@@ -16,13 +16,16 @@ vi.mock('../../store', () => ({
useAppStore: (selector: (state: unknown) => unknown) => selector(mocks.storeState)
}))
vi.mock('./terminal-parked-tab-watchers', () => ({
canWatcherCoverParkedTerminalTab: () => true,
disposeParkedTerminalWatchersForWorktree: vi.fn(),
vi.mock('./terminal-eviction-exempt-tabs', () => ({
selectEvictionExemptTerminalTabIds: (_worktreeId: string, tabs: readonly { id: string }[]) => {
mocks.exemptSelectCalls += 1
return new Set(tabs.filter((tab) => mocks.exemptTabIds.has(tab.id)).map((tab) => tab.id))
},
}
}))
vi.mock('./terminal-parked-tab-watchers', () => ({
canWatcherCoverParkedTerminalTab: () => true,
disposeParkedTerminalWatchersForWorktree: vi.fn(),
syncParkedTerminalTabWatchers: vi.fn()
}))
@@ -24,10 +24,10 @@ import {
type ParkVerdictFlipRecord
} from './terminal-park-verdict-flip-telemetry'
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
import { selectEvictionExemptTerminalTabIds } from './terminal-eviction-exempt-tabs'
import {
canWatcherCoverParkedTerminalTab,
disposeParkedTerminalWatchersForWorktree,
selectEvictionExemptTerminalTabIds,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'