/** * When the content pane follows the sidebar highlight (docs/PREVIEW.md). * * The highlight itself is never scheduled — that is a class on a row and lands * on the same frame as the input. This governs only the pane swap behind it, * which mounts a terminal and, for a session the connection pool isn't already * holding, opens an attach socket the daemon answers with a `tmux capture-pane`. * * A plain debounce would put that cost in front of every preview, including the * one preview the operator actually meant. Instead the first move shows at once * and the rest of a burst is coalesced: walking a list with `j` held down, or * sweeping the pointer from the top of the sidebar to the bottom, costs one * preview at the start and one at the row you stopped on, not one per row. */ /** Shortest gap between two pane swaps. Long enough that a fast walk mounts a * couple of terminals rather than one per row; short enough that a deliberate * second move never waits on it. */ export const PREVIEW_MIN_GAP_MS = 120; export interface PreviewScheduleDeps { now?: () => number; setTimer?: (fn: () => void, ms: number) => number; clearTimer?: (handle: number) => void; /** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */ minGapMs?: number; } export interface PreviewSchedule { (sessionId: string | null): void; /** Drop a pending trailing preview without showing it. */ cancel(): void; } /** * Wrap `show` so it fires on the leading edge and at most once per gap. * * `show` is called with the session to preview, or `null` to put back whatever * the operator actually opened. */ export function createPreviewSchedule( show: (sessionId: string | null) => void, deps: PreviewScheduleDeps = {}, ): PreviewSchedule { const now = deps.now ?? (() => Date.now()); const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms)); const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h)); const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS; let lastShownAt = Number.NEGATIVE_INFINITY; let shown: string | null | undefined; let pendingTimer: number | undefined; let pendingId: string | null = null; const emit = (id: string | null) => { // Nothing to do when the burst ended on the view already up: re-showing it // would tear down and re-mount the pane for no visible change. if (shown === id) return; shown = id; lastShownAt = now(); show(id); }; const cancelPending = () => { if (pendingTimer === undefined) return; clearTimer(pendingTimer); pendingTimer = undefined; }; const schedule = ((sessionId: string | null) => { pendingId = sessionId; const waited = now() - lastShownAt; if (waited >= minGap) { cancelPending(); emit(sessionId); return; } if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins pendingTimer = setTimer(() => { pendingTimer = undefined; emit(pendingId); }, minGap - waited); }) as PreviewSchedule; schedule.cancel = cancelPending; return schedule; }