import { useCallback, useEffect, useRef } from 'react'; import type { Terminal } from '@xterm/xterm'; // useTerminalFit — terminal measurement + the refit triggers that are purely // about sizing (fonts.ready rAF, 150ms settle timeout, ResizeObserver, // visualViewport resize). The two refit triggers that are entangled with the // connection lifecycle — the init rAF (fit → /start → connect) and the // visibilitychange handler (reconnect-or-refit) — live in useTerminalSocket // and call this hook's `fit()`; that keeps each of those single-rAF / if-else // sequences intact rather than splitting one listener across two hooks. // // Exposes a stable `fit()` (the FitAddon-bypass measurement, with the iOS // keyboard clip) and `getSize()`/`setSize()` over the read-latest size mirror. // xterm 5 ships no public dimensions API — `_core._renderService.dimensions` // is internal but stable across the package versions we ship. This is the ONE // place that reaches into the private cell metrics; everything else funnels // through `cellSize` or `readCellMetrics`. function readCellMetrics(term: Terminal): { w: number; h: number } | null { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dims = (term as any)._core?._renderService?.dimensions?.css?.cell; if (dims && dims.width > 0 && dims.height > 0) { return { w: dims.width, h: dims.height }; } return null; } // Cell metrics with a getBoundingClientRect fallback for callers (touch // point→cell mapping) that need an estimate even before xterm has measured. // `fit()` deliberately does NOT use this fallback — it bails until real // metrics exist so it never resizes against a fallback estimate. export function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } { const m = readCellMetrics(term); if (m) return m; const rect = container.getBoundingClientRect(); return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) }; } interface FitDeps { termRef: React.MutableRefObject; containerRef: React.MutableRefObject; sessionId: string; paneId: string; } export interface TerminalFit { fit: () => void; getSize: () => { cols: number; rows: number }; setSize: (cols: number, rows: number) => void; } export function useTerminalFit({ termRef, containerRef, sessionId, paneId }: FitDeps): TerminalFit { // Last size pushed through term.onResize. Mirrors xterm's authoritative // cols/rows; consulted on every WS open so the resize frame carries the // currently-measured size, not a stale lastSize from before the latest fit. const lastSizeRef = useRef<{ cols: number; rows: number }>({ cols: 80, rows: 24 }); const getSize = useCallback(() => lastSizeRef.current, []); const setSize = useCallback((cols: number, rows: number) => { lastSizeRef.current = { cols, rows }; }, []); // Bypass FitAddon's proposeDimensions(), which subtracts the native // scrollbar's reserved width even after CSS hides it (boolab fix). We // compute cols/rows directly from host.clientWidth/Height ÷ cell metrics. // Falls through (no-op) if cell metrics aren't measured yet — a later // refit (fonts.ready / setTimeout / ResizeObserver) will catch it. // // v1.10.8d: row count uses the VISIBLE height (clipped against the // visualViewport) rather than raw clientHeight. h-dvh on the root only // shrinks for the URL bar — not the iOS keyboard — so without the clip // we allocate rows for the area hidden behind the keyboard, and bash's // cursor (pushed down by MOTD on SSH login) ends up below the fold. const fit = useCallback((): void => { const t = termRef.current; if (!t) return; const host = containerRef.current; if (!host) return; if (!t.element || !(t.element as HTMLElement).offsetParent) return; const m = readCellMetrics(t); if (!m) return; const cellW = m.w; const cellH = m.h; let visibleHeight = host.clientHeight; const vp2 = typeof window !== 'undefined' ? window.visualViewport : null; if (vp2) { const rect = host.getBoundingClientRect(); // visualViewport.height is the layout viewport minus the keyboard. // rect.bottom is in layout-viewport coordinates. clip to whichever // is smaller. offsetTop/pageTop adjustments aren't needed at zoom=1. const visibleBottom = Math.min(rect.bottom, vp2.height); const clipped = Math.max(0, visibleBottom - rect.top); if (clipped > 0) visibleHeight = clipped; } const cols = Math.max(2, Math.floor(host.clientWidth / cellW)); const rows = Math.max(1, Math.floor(visibleHeight / cellH)); if (cols !== t.cols || rows !== t.rows) { try { t.resize(cols, rows); } catch { /* ignore */ } } }, [termRef, containerRef]); // Pure-fit refit triggers. Re-run on session/pane change so a recreated // terminal gets the same fonts.ready / settle / ResizeObserver / vpResize // coverage the pre-split single effect gave it. useEffect(() => { const term = termRef.current; const ctr = containerRef.current; if (!term || !ctr) return; let disposed = false; // Boolab pattern: term measures cell metrics on open() against whatever // font is registered at that instant; if the font hasn't loaded yet we // get a fallback measurement and the wrong cols. Refit when // document.fonts.ready resolves. let fontRaf: number | null = null; if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) { document.fonts.ready .then(() => { if (disposed) return; fontRaf = requestAnimationFrame(() => fit()); }) .catch(() => { /* ignore */ }); } // Second-pass refit: mobile browsers can settle layout slightly late, // causing the rAF fit to measure a stale clientWidth. const delayedFit = setTimeout(() => fit(), 150); // ResizeObserver: refit on any container size change. No debounce — // term.onResize itself is the throttle (no-op when cols/rows unchanged). const ro = new ResizeObserver(() => { fit(); }); ro.observe(ctr); // v1.10.8d: visualViewport.resize fires when the iOS keyboard opens or // closes — the layout viewport stays full-height (so our h-dvh root // doesn't shrink), but the visual viewport contracts above the keyboard. // Refit so xterm's row count matches the visible area, then scroll to // bottom so the cursor stays above the keyboard fold (boolab pattern). let onVpResize: (() => void) | null = null; const vp = typeof window !== 'undefined' ? window.visualViewport : null; if (vp) { onVpResize = (): void => { if (disposed) return; fit(); const t = termRef.current; if (t) { try { t.scrollToBottom(); } catch { /* ignore */ } } }; vp.addEventListener('resize', onVpResize); } return () => { disposed = true; if (fontRaf !== null) cancelAnimationFrame(fontRaf); clearTimeout(delayedFit); ro.disconnect(); if (vp && onVpResize) vp.removeEventListener('resize', onVpResize); }; // sessionId/paneId drive re-run on terminal recreation (term is rebuilt by // the orchestrator on those deps); fit is stable. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, paneId, fit]); return { fit, getSize, setSize }; }