diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 3dab8a9712e..6632ec63d35 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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' diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx new file mode 100644 index 00000000000..1da822d7cef --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx @@ -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(null) + const [measuredFallbackRect, setMeasuredFallbackRect] = useState( + 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('[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 = ( +