f8d3876c68
- Font change now applies to already-open terminals: the WebGL renderer caches glyphs in a texture atlas keyed by the old font/size, so the live re-apply effect now calls webglAddon.clearTextureAtlas() (via a new ref) after updating fontFamily/fontSize, before refitting. - Daemon uptime now reflects a restart: the Settings daemon section ticks every second for live uptime, and Stop/Restart trigger an onReload callback that re-fetches health/status in App so a restarted daemon's new started_at_ms is shown instead of the stale value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
128 lines
4.6 KiB
TypeScript
128 lines
4.6 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { Terminal } from "@xterm/xterm";
|
|
import { WebglAddon } from "@xterm/addon-webgl";
|
|
import { SearchAddon } from "@xterm/addon-search";
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
|
|
import { registerSearch, unregisterSearch } from "./searchRegistry";
|
|
|
|
const decoder = new TextDecoder();
|
|
const encoder = new TextEncoder();
|
|
|
|
function xtermTheme(p: Record<string, string>) {
|
|
return {
|
|
background: p["bg-panel"],
|
|
foreground: p["text-primary"],
|
|
cursor: p["text-primary"],
|
|
selectionBackground: p["search-match"],
|
|
};
|
|
}
|
|
|
|
export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<Terminal | null>(null);
|
|
const fitRef = useRef<FitAddon | null>(null);
|
|
const webglRef = useRef<WebglAddon | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!ref.current) return;
|
|
// allowProposedApi is required by the search addon: its match decorations
|
|
// call registerMarker/registerDecoration (proposed API). Without it findNext
|
|
// throws and the scrollback search counter never updates.
|
|
const term = new Terminal({
|
|
fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
|
|
fontSize: font?.size ?? 13,
|
|
convertEol: false,
|
|
scrollback: 10000,
|
|
allowProposedApi: true,
|
|
theme: palette ? xtermTheme(palette) : undefined,
|
|
});
|
|
termRef.current = term;
|
|
|
|
try {
|
|
const webgl = new WebglAddon();
|
|
term.loadAddon(webgl);
|
|
webglRef.current = webgl;
|
|
} catch {
|
|
// webgl unavailable → fall back to canvas/dom renderer silently
|
|
}
|
|
term.open(ref.current);
|
|
|
|
const search = new SearchAddon();
|
|
term.loadAddon(search);
|
|
registerSearch(surfaceId, search);
|
|
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
fitRef.current = fit;
|
|
|
|
// Fit the grid to the container and tell the daemon the new size. Coalesced
|
|
// through rAF so a burst of resize callbacks yields one resize per frame.
|
|
let rafId = 0;
|
|
let lastCols = 0, lastRows = 0;
|
|
const doFit = () => {
|
|
rafId = 0;
|
|
try { fit.fit(); } catch { return; }
|
|
if (term.cols !== lastCols || term.rows !== lastRows) {
|
|
lastCols = term.cols;
|
|
lastRows = term.rows;
|
|
void resizeSurface(surfaceId, term.cols, term.rows);
|
|
}
|
|
};
|
|
const scheduleFit = () => { if (!rafId) rafId = requestAnimationFrame(doFit); };
|
|
|
|
const ro = new ResizeObserver(scheduleFit);
|
|
ro.observe(ref.current);
|
|
|
|
// Input → daemon.
|
|
const inputDisposable = term.onData((data) => {
|
|
void sendInput(surfaceId, encoder.encode(data));
|
|
});
|
|
|
|
let disposed = false;
|
|
|
|
// Attach: fresh xterm instance, write snapshot, then stream live output.
|
|
void attachSurface(surfaceId, (bytes) => {
|
|
if (!disposed) term.write(decoder.decode(bytes));
|
|
}).then((res) => {
|
|
if (disposed) return;
|
|
if (res.snapshot) term.write(res.snapshot);
|
|
// Fit to the actual container rather than the daemon's stored geometry,
|
|
// then push the resulting size back so the PTY reflows to match.
|
|
scheduleFit();
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
ro.disconnect();
|
|
inputDisposable.dispose();
|
|
void detachSurface(surfaceId);
|
|
unregisterSearch(surfaceId);
|
|
term.dispose();
|
|
termRef.current = null;
|
|
fitRef.current = null;
|
|
webglRef.current = null;
|
|
};
|
|
}, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Live re-apply font and theme when config changes without remounting.
|
|
// font and palette are memoized in App.tsx so stable identity = no spurious re-applies.
|
|
useEffect(() => {
|
|
const t = termRef.current;
|
|
if (!t) return;
|
|
if (font) {
|
|
t.options.fontFamily = `'${font.family}', monospace`;
|
|
t.options.fontSize = font.size;
|
|
// The WebGL renderer caches rasterized glyphs in a texture atlas keyed by
|
|
// the old font/size; without clearing it the grid keeps rendering stale
|
|
// glyphs after a font change.
|
|
webglRef.current?.clearTextureAtlas();
|
|
}
|
|
if (palette) t.options.theme = xtermTheme(palette);
|
|
requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch { /* ignore */ } });
|
|
}, [font, palette]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
|
|
}
|