Reliable per-pane context tracking isn't achievable from transcripts: we can't distinguish 'claude is live in this pane' from 'a shell sitting in a directory that recently had a claude session' (claude renders inline, not alt-screen; no WSL foreground-process access), and the 200k-vs-1M window isn't recorded so % is unreliable. Removed the context indicator, its OSC 7 cwd injection (pty.rs), the get_pane_context backend (usage.rs), src/lib/usage.ts, the orchestration paneContext map, and the App poll. The narrow-pane toolbar reflow (leaf--narrow/xnarrow tiers, label shrink, close × pinned) is KEPT — it's verified and independent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
566 lines
22 KiB
TypeScript
566 lines
22 KiB
TypeScript
import { useRef, useEffect, useState } from "react";
|
|
import { Terminal } from "@xterm/xterm";
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
import { CanvasAddon } from "@xterm/addon-canvas";
|
|
import { SearchAddon } from "@xterm/addon-search";
|
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
|
import SearchBar from "./SearchBar";
|
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
|
import {
|
|
readText as clipboardReadText,
|
|
writeText as clipboardWriteText,
|
|
} from "@tauri-apps/plugin-clipboard-manager";
|
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
import {
|
|
spawnPane,
|
|
writeToPane,
|
|
resizePane,
|
|
killPane,
|
|
onPaneData,
|
|
onPaneExit,
|
|
getPaneRing,
|
|
claimPane,
|
|
type PaneId,
|
|
type SpawnSpec,
|
|
} from "../ipc";
|
|
import type { NavigateIntent } from "../lib/layout/orchestration";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// base64 helpers (private to this module)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function b64ToBytes(b64: string): Uint8Array {
|
|
const bin = atob(b64);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
function bytesToB64(bytes: Uint8Array): string {
|
|
let s = "";
|
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
return btoa(s);
|
|
}
|
|
|
|
function stringToB64(s: string): string {
|
|
// xterm.js's onData emits a JS string; UTF-8 encode before base64.
|
|
return bytesToB64(new TextEncoder().encode(s));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Props
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface XtermPaneProps {
|
|
/** Spec describing what to spawn into this pane's PTY. Read once at mount;
|
|
* changing it later does NOT respawn — callers force a respawn by
|
|
* changing the React `key` (see Pane.svelte / LeafPane). */
|
|
spec: SpawnSpec;
|
|
/** Attach to an existing PTY (transferred from another window) instead of
|
|
* spawning a new one. When set: spec is ignored at the spawn step, the
|
|
* scrollback ring is replayed into xterm.js, the live data listener is
|
|
* attached, and the transfer refcount is claimed (decremented) so the
|
|
* source window's killPane is no longer suppressed. */
|
|
existingPaneId?: PaneId;
|
|
onStatus?: (msg: string, ok: boolean) => void;
|
|
/** Fired once when the backend PTY is alive and we have its PaneId. */
|
|
onSpawn?: (paneId: PaneId) => void;
|
|
/** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */
|
|
onInput?: (dataB64: string) => void;
|
|
/** Fired whenever output arrives from the PTY. Used for idle detection. */
|
|
onDataReceived?: () => void;
|
|
/** Fired when xterm's textarea gains focus (i.e., user clicked here). */
|
|
onFocus?: () => void;
|
|
/** Increment to refocus the terminal programmatically (palette etc.). */
|
|
focusTrigger?: number;
|
|
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
|
|
fontSize?: number;
|
|
/** Called when the user presses a tiling-WM navigation chord inside the
|
|
* terminal. XtermPane only emits the intent; the parent (LeafPane/App)
|
|
* resolves the target leaf from the current layout and sets it active.
|
|
* Defined as an optional callback so single-pane windows don't require
|
|
* wiring it up. */
|
|
onNavigate?: (intent: NavigateIntent) => void;
|
|
}
|
|
|
|
const DEFAULT_XTERM_FONT_SIZE = 13;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function XtermPane({
|
|
spec,
|
|
existingPaneId,
|
|
onStatus,
|
|
onSpawn,
|
|
onInput,
|
|
onDataReceived,
|
|
onFocus,
|
|
focusTrigger = 0,
|
|
fontSize,
|
|
onNavigate,
|
|
}: XtermPaneProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<Terminal | null>(null);
|
|
const fitRef = useRef<FitAddon | null>(null);
|
|
const paneIdRef = useRef<PaneId | null>(null);
|
|
const searchAddonRef = useRef<SearchAddon | null>(null);
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
// Stash the most recent `fontSize` prop so the mount effect can pick
|
|
// up the initial value without re-running when it changes (the secondary
|
|
// effect below handles dynamic updates).
|
|
const initialFontSizeRef = useRef(fontSize);
|
|
|
|
// Stable refs for callbacks so the mount effect doesn't need to re-run when
|
|
// parents pass new inline functions, while still always calling the latest version.
|
|
const onStatusRef = useRef(onStatus);
|
|
const onSpawnRef = useRef(onSpawn);
|
|
const onInputRef = useRef(onInput);
|
|
const onDataReceivedRef = useRef(onDataReceived);
|
|
const onFocusRef = useRef(onFocus);
|
|
const onNavigateRef = useRef(onNavigate);
|
|
// Stable ref for setSearchOpen so it can be called from inside the
|
|
// attachCustomKeyEventHandler closure without the closure going stale.
|
|
const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen);
|
|
|
|
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
|
|
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
|
|
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
|
|
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
|
|
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
|
|
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
|
|
useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Mount / unmount: create terminal, spawn PTY, wire listeners
|
|
// -------------------------------------------------------------------------
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
let term: Terminal | null = new Terminal({
|
|
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
|
|
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
|
|
cursorBlink: true,
|
|
theme: {
|
|
background: "#0c0c0c",
|
|
foreground: "#e6e6e6",
|
|
},
|
|
scrollback: 5000,
|
|
convertEol: false,
|
|
allowProposedApi: true,
|
|
});
|
|
termRef.current = term;
|
|
|
|
const fit = new FitAddon();
|
|
fitRef.current = fit;
|
|
term.loadAddon(fit);
|
|
// Underlines http(s) URLs in the terminal output and routes clicks
|
|
// through Tauri's opener plugin so they open in the user's default
|
|
// browser (WebView2 won't navigate on a plain window.open).
|
|
term.loadAddon(
|
|
new WebLinksAddon((_event, uri) => {
|
|
void openUrl(uri).catch((err) =>
|
|
console.warn("openUrl failed:", err),
|
|
);
|
|
}),
|
|
);
|
|
term.open(container);
|
|
|
|
// Use the canvas renderer instead of xterm's default DOM renderer.
|
|
// The DOM renderer draws the cursor as a separate layered element and,
|
|
// under the Claude TUI's rapid hide/show (\x1b[?25l/h) + cursorBlink,
|
|
// leaves a stale cursor block frozen where the cursor used to be (the
|
|
// "stuck white marker"). The canvas renderer composites the cursor into
|
|
// the same surface as the text, so hide/show transitions clear cleanly.
|
|
// Chosen over the WebGL addon because tiletopia runs many panes at once
|
|
// and Chromium/WebView2 caps live WebGL contexts (~16) — canvas has no
|
|
// such hard limit. Loaded after open() so the core renderer exists.
|
|
try {
|
|
term.loadAddon(new CanvasAddon());
|
|
} catch (e) {
|
|
// If canvas init fails for any reason, xterm falls back to the DOM
|
|
// renderer on its own — degrade gracefully rather than blank the pane.
|
|
console.warn("CanvasAddon load failed; using DOM renderer:", e);
|
|
}
|
|
|
|
// Load Unicode 11 addon for correct width handling of emoji, CJK, and
|
|
// box-drawing characters. This prevents cursor drift in TUIs that rely on
|
|
// Unicode 11 character widths. Loaded after CanvasAddon so the renderer
|
|
// surface is set before width calculations begin.
|
|
try {
|
|
term.loadAddon(new Unicode11Addon());
|
|
term.unicode.activeVersion = "11";
|
|
} catch (e) {
|
|
console.warn("Unicode11Addon load failed:", e);
|
|
}
|
|
|
|
// Load the search addon so find-in-scrollback works. Must be loaded
|
|
// after open() so the terminal viewport exists for decoration rendering,
|
|
// and after CanvasAddon since it decorates the same canvas surface.
|
|
const search = new SearchAddon();
|
|
searchAddonRef.current = search;
|
|
term.loadAddon(search);
|
|
|
|
// Initial size — fit before asking the PTY for its dimensions.
|
|
fit.fit();
|
|
|
|
let paneId: PaneId | null = null;
|
|
let unlistenData: UnlistenFn | null = null;
|
|
let unlistenExit: UnlistenFn | null = null;
|
|
let ro: ResizeObserver | null = null;
|
|
let destroyed = false;
|
|
|
|
(async () => {
|
|
const cols = term!.cols;
|
|
const rows = term!.rows;
|
|
|
|
if (existingPaneId != null) {
|
|
// Adoption path: a window-transfer landed us here with an existing
|
|
// PTY id. Don't spawn — replay the scrollback ring first (so the
|
|
// user sees recent output like a thinking Claude session), then
|
|
// attach the live listener, resize the PTY to this window's grid,
|
|
// and release the transfer-refcount.
|
|
paneId = existingPaneId;
|
|
paneIdRef.current = paneId;
|
|
onStatusRef.current?.(`pane ${paneId} adopted`, true);
|
|
onSpawnRef.current?.(paneId);
|
|
try {
|
|
const ringB64 = await getPaneRing(paneId);
|
|
if (destroyed) return;
|
|
if (ringB64) {
|
|
term?.write(b64ToBytes(ringB64));
|
|
}
|
|
} catch (e) {
|
|
console.warn("getPaneRing failed:", e);
|
|
}
|
|
if (destroyed) return;
|
|
unlistenData = await onPaneData(paneId, (b64) => {
|
|
term?.write(b64ToBytes(b64));
|
|
onDataReceivedRef.current?.();
|
|
});
|
|
// `destroyed` may have flipped during the await — the sync cleanup
|
|
// already ran and captured a null unlisten, so unlisten here or the
|
|
// subscription leaks.
|
|
if (destroyed) {
|
|
unlistenData?.();
|
|
return;
|
|
}
|
|
unlistenExit = await onPaneExit(paneId, () => {
|
|
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
|
onStatusRef.current?.(`pane ${paneId} exited`, false);
|
|
});
|
|
if (destroyed) {
|
|
unlistenData?.();
|
|
unlistenExit?.();
|
|
return;
|
|
}
|
|
// Match the PTY to our cell grid (the source window may have had
|
|
// different dimensions).
|
|
try {
|
|
await resizePane(paneId, cols, rows);
|
|
} catch (e) {
|
|
console.warn("resizePane on adopt failed:", e);
|
|
}
|
|
// Release the transfer refcount so future killPane calls on this
|
|
// id are no longer suppressed.
|
|
try {
|
|
await claimPane(paneId);
|
|
} catch (e) {
|
|
console.warn("claimPane failed:", e);
|
|
}
|
|
} else {
|
|
try {
|
|
paneId = await spawnPane({ spec, cols, rows });
|
|
if (destroyed) {
|
|
void killPane(paneId);
|
|
return;
|
|
}
|
|
paneIdRef.current = paneId;
|
|
onStatusRef.current?.(`pane ${paneId} alive`, true);
|
|
onSpawnRef.current?.(paneId);
|
|
} catch (e) {
|
|
if (destroyed) return;
|
|
const msg = `spawn_pane failed: ${e}`;
|
|
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
|
|
onStatusRef.current?.(msg, false);
|
|
return;
|
|
}
|
|
|
|
unlistenData = await onPaneData(paneId, (b64) => {
|
|
term?.write(b64ToBytes(b64));
|
|
onDataReceivedRef.current?.();
|
|
});
|
|
if (destroyed) {
|
|
unlistenData?.();
|
|
return;
|
|
}
|
|
|
|
unlistenExit = await onPaneExit(paneId, () => {
|
|
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
|
onStatusRef.current?.(`pane ${paneId} exited`, false);
|
|
});
|
|
if (destroyed) {
|
|
unlistenData?.();
|
|
unlistenExit?.();
|
|
return;
|
|
}
|
|
}
|
|
|
|
term?.onData((data) => {
|
|
if (paneId == null) return;
|
|
const b64 = stringToB64(data);
|
|
void writeToPane(paneId, b64);
|
|
onInputRef.current?.(b64);
|
|
});
|
|
|
|
// Intercept tiling-WM chords before the PTY sees them. All families
|
|
// share ONE attachCustomKeyEventHandler call — xterm.js replaces the
|
|
// previous handler on every call, so a second call anywhere would
|
|
// silently discard all earlier interceptions.
|
|
//
|
|
// Family 1: Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste.
|
|
// Uses tauri-plugin-clipboard-manager so WebView2 never shows its
|
|
// native "Allow clipboard access?" prompt. term.paste() routes
|
|
// through onData → writeToPane so broadcasting + bracketed paste
|
|
// keep working for free.
|
|
//
|
|
// Family 2: Ctrl+Shift+F — open/focus the find-in-scrollback bar.
|
|
// Swallowed before xterm or the PTY sees the raw keypress. Uses the
|
|
// stable setSearchOpenRef so the closure never goes stale.
|
|
//
|
|
// Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L — spatial pane focus.
|
|
// XtermPane emits onNavigate({ kind: "direction", dir }) and returns
|
|
// false so the chord is swallowed before it reaches the PTY. The
|
|
// parent (LeafPane → App) resolves the neighbour and bumps
|
|
// focusTrigger on the new active pane.
|
|
//
|
|
// Family 4: Alt+1..9 — index-based pane focus.
|
|
// Emits onNavigate({ kind: "index", n }) and swallows. Note: bare
|
|
// Alt+digit is used by some shells (readline digit-argument, vim/nvim)
|
|
// — this interception is an accepted v1 trade-off (see shortcuts.ts).
|
|
term?.attachCustomKeyEventHandler((e) => {
|
|
if (e.type !== "keydown") return true;
|
|
|
|
// --- Family 1 & 2: Ctrl+Shift+* (no Alt) ---------------------------
|
|
if (e.ctrlKey && e.shiftKey && !e.altKey) {
|
|
if (e.code === "KeyF") {
|
|
// Ctrl+Shift+F — open find-in-scrollback bar.
|
|
e.preventDefault();
|
|
setSearchOpenRef.current(true);
|
|
return false;
|
|
}
|
|
if (e.code === "KeyC") {
|
|
// Ctrl+Shift+C — copy selection to clipboard.
|
|
const sel = term?.getSelection();
|
|
if (sel) {
|
|
void clipboardWriteText(sel).catch((err) =>
|
|
console.warn("clipboard write failed:", err),
|
|
);
|
|
}
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
if (e.code === "KeyV") {
|
|
// Ctrl+Shift+V — paste from clipboard via term.paste() so
|
|
// broadcasting and bracketed paste work for free.
|
|
e.preventDefault();
|
|
clipboardReadText()
|
|
.then((text) => {
|
|
if (text && term) term.paste(text);
|
|
})
|
|
.catch((err) => console.warn("clipboard read failed:", err));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L (spatial nav) -----
|
|
if (e.ctrlKey && e.altKey && !e.shiftKey && onNavigateRef.current) {
|
|
// Arrow keys
|
|
const ARROW_DIR: Record<string, "left" | "right" | "up" | "down"> = {
|
|
ArrowLeft: "left",
|
|
ArrowRight: "right",
|
|
ArrowUp: "up",
|
|
ArrowDown: "down",
|
|
};
|
|
// Vim-style HJKL
|
|
const VIM_DIR: Record<string, "left" | "right" | "up" | "down"> = {
|
|
KeyH: "left",
|
|
KeyJ: "down",
|
|
KeyK: "up",
|
|
KeyL: "right",
|
|
};
|
|
const dir = ARROW_DIR[e.code] ?? VIM_DIR[e.code];
|
|
if (dir) {
|
|
e.preventDefault();
|
|
onNavigateRef.current({ kind: "direction", dir });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- Family 4: Alt+1..9 (index-based pane focus) -------------------
|
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && onNavigateRef.current) {
|
|
const digit = e.code.match(/^Digit([1-9])$/);
|
|
if (digit) {
|
|
e.preventDefault();
|
|
onNavigateRef.current({ kind: "index", n: parseInt(digit[1], 10) });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Focus detection: xterm.js doesn't expose onFocus as a first-class event
|
|
// in all versions, so try the proposed API first then fall back to the DOM.
|
|
term?.onSelectionChange(() => {}); // ensure addon system is initialised; noop
|
|
const termAny = term as unknown as { onFocus?: (cb: () => void) => void };
|
|
if (typeof termAny.onFocus === "function") {
|
|
termAny.onFocus(() => onFocusRef.current?.());
|
|
} else {
|
|
const ta = container.querySelector(".xterm-helper-textarea");
|
|
if (ta) ta.addEventListener("focus", () => onFocusRef.current?.(), true);
|
|
}
|
|
|
|
// Re-fit on container resize. xterm.fit() + a forced refresh run
|
|
// immediately (visual must stay smooth during a drag), but the
|
|
// actual PTY resize call is debounced: every SIGWINCH makes bash
|
|
// redraw the prompt, and if we send 60+ of them per second during a
|
|
// gutter drag, the redraws corrupt each other and the terminal
|
|
// fills with garbled half-prompts. The debounce means the PTY
|
|
// hears about resizes ~150 ms after you stop dragging, at the
|
|
// final size — bash gets a single clean redraw.
|
|
let resizeRaf: number | null = null;
|
|
let resizePtyTimer: number | null = null;
|
|
let lastSentCols = -1;
|
|
let lastSentRows = -1;
|
|
ro = new ResizeObserver(() => {
|
|
if (resizeRaf != null) return;
|
|
resizeRaf = requestAnimationFrame(() => {
|
|
resizeRaf = null;
|
|
if (!term) return;
|
|
try {
|
|
fit.fit();
|
|
term.refresh(0, term.rows - 1);
|
|
if (resizePtyTimer != null) clearTimeout(resizePtyTimer);
|
|
resizePtyTimer = window.setTimeout(() => {
|
|
resizePtyTimer = null;
|
|
if (paneId == null || !term) return;
|
|
// Skip if the cell grid didn't actually change — saves a
|
|
// pointless SIGWINCH that would make bash redraw its prompt
|
|
// (which feeds back into onDataReceived and causes the idle
|
|
// indicator to flap; see the analysis around v0.2.2).
|
|
if (term.cols === lastSentCols && term.rows === lastSentRows) {
|
|
return;
|
|
}
|
|
lastSentCols = term.cols;
|
|
lastSentRows = term.rows;
|
|
void resizePane(paneId, term.cols, term.rows);
|
|
}, 150);
|
|
} catch (e) {
|
|
console.warn("resize failed", e);
|
|
}
|
|
});
|
|
});
|
|
ro.observe(container);
|
|
|
|
// Focus so typing immediately lands in the terminal — but ONLY if the
|
|
// host container is actually visible. With multiple tabs (workspaces),
|
|
// a pane in a hidden tab still mounts and spawns; we must not yank
|
|
// focus into a tab the user can't see. CSS `visibility: hidden` is
|
|
// inherited, so the computed style on the container reflects whether
|
|
// any ancestor (workspace-layer) is hiding us.
|
|
if (
|
|
container.isConnected &&
|
|
getComputedStyle(container).visibility !== "hidden"
|
|
) {
|
|
term?.focus();
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
destroyed = true;
|
|
ro?.disconnect();
|
|
unlistenData?.();
|
|
unlistenExit?.();
|
|
if (paneId != null) void killPane(paneId);
|
|
term?.dispose();
|
|
term = null;
|
|
termRef.current = null;
|
|
fitRef.current = null;
|
|
searchAddonRef.current = null;
|
|
paneIdRef.current = null;
|
|
};
|
|
// spec is read once at mount; intentionally omitted from deps so we
|
|
// don't remount on parent re-renders. Callers force a respawn by
|
|
// bumping the React `key` (changeShell swaps the leaf id for that).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// focusTrigger: programmatic refocus from parent (palette navigation etc.)
|
|
// -------------------------------------------------------------------------
|
|
useEffect(() => {
|
|
if (focusTrigger > 0 && containerRef.current) {
|
|
const ta = containerRef.current.querySelector<HTMLTextAreaElement>(
|
|
".xterm-helper-textarea",
|
|
);
|
|
ta?.focus();
|
|
}
|
|
}, [focusTrigger]);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Live font-size changes (Ctrl+Shift+= / - / 0).
|
|
//
|
|
// Setting term.options.fontSize re-rasterises glyphs immediately, but the
|
|
// cols/rows the terminal thinks it has are still based on the OLD cell
|
|
// size — so we have to fit() to recompute, refresh() to repaint, then
|
|
// ship the new dimensions to the PTY so bash redraws the prompt at the
|
|
// right width.
|
|
// -------------------------------------------------------------------------
|
|
useEffect(() => {
|
|
const term = termRef.current;
|
|
const fit = fitRef.current;
|
|
if (!term || !fit) return;
|
|
const target = fontSize ?? DEFAULT_XTERM_FONT_SIZE;
|
|
if (term.options.fontSize === target) return;
|
|
try {
|
|
term.options.fontSize = target;
|
|
fit.fit();
|
|
term.refresh(0, term.rows - 1);
|
|
const paneId = paneIdRef.current;
|
|
if (paneId != null) void resizePane(paneId, term.cols, term.rows);
|
|
} catch (e) {
|
|
console.warn("font-size apply failed", e);
|
|
}
|
|
}, [fontSize]);
|
|
|
|
// Close the search bar and return focus to the xterm textarea so the user
|
|
// can resume typing immediately. Queries the well-known xterm helper
|
|
// textarea selector — the same pattern used in the focusTrigger effect.
|
|
function closeSearch() {
|
|
setSearchOpen(false);
|
|
const ta = containerRef.current?.querySelector<HTMLTextAreaElement>(
|
|
".xterm-helper-textarea",
|
|
);
|
|
ta?.focus();
|
|
}
|
|
|
|
// The outer wrapper is position:relative so the absolutely-positioned
|
|
// SearchBar anchors inside the pane without escaping to a positioned
|
|
// ancestor further up the tree. The FitAddon measures containerRef's div
|
|
// (the inner one), which still fills 100% of the wrapper — no sizing break.
|
|
return (
|
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
|
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
|
{searchOpen && searchAddonRef.current && (
|
|
<SearchBar
|
|
searchAddon={searchAddonRef.current}
|
|
onClose={closeSearch}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|