Three xterm.js features, implemented together because they share the XtermPane mount + the single attachCustomKeyEventHandler: - Unicode 11: load @xterm/addon-unicode11, set activeVersion='11' after the canvas renderer so emoji/CJK/box-drawing widths stop drifting. - Find in scrollback: @xterm/addon-search + a new per-pane SearchBar overlay (Ctrl+Shift+F to open, Enter/Shift+Enter next/prev, regex + case toggles, Esc to close & refocus). Overlay is an absolutely- positioned sibling in a position:relative wrapper so fit() is unaffected. - Pane navigation: Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial neighbour via findNeighborInDirection) and Alt+1..9 (Nth leaf in walkLeaves order). XtermPane emits a NavigateIntent; App resolves the target leaf and sets it active, reusing the existing isActive->focusTrigger refocus chain. All chords live in one attachCustomKeyEventHandler (xterm replaces the handler on each call). Shortcuts added to shortcuts.ts (SoT for README + Help), including the Alt+digit shell-conflict caveat. tsc clean apart from the three not-yet-installed addon modules. Needs pnpm install on the Windows host + runtime verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
177 lines
4.8 KiB
TypeScript
177 lines
4.8 KiB
TypeScript
import { useRef, useEffect, useState } from "react";
|
||
import type { SearchAddon } from "@xterm/addon-search";
|
||
import "./SearchBar.css";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SearchBar — per-pane find-in-scrollback overlay.
|
||
//
|
||
// Rendered as an absolutely-positioned sibling of the xterm canvas inside
|
||
// XtermPane's container div (position: relative). The SearchAddon instance
|
||
// is owned by XtermPane and passed down as a prop; no IPC or Context needed.
|
||
//
|
||
// Toggle state (caseSensitive, regex) uses useState so aria-pressed reflects
|
||
// the live value on every render — refs alone don't trigger re-renders.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface SearchBarProps {
|
||
searchAddon: SearchAddon;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export default function SearchBar({ searchAddon, onClose }: SearchBarProps) {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const queryRef = useRef("");
|
||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||
const [useRegex, setUseRegex] = useState(false);
|
||
|
||
// Keep stable refs to toggle values so findNext/findPrev closures always
|
||
// see the current value without needing to be recreated on each state change.
|
||
const caseSensitiveRef = useRef(caseSensitive);
|
||
const useRegexRef = useRef(useRegex);
|
||
useEffect(() => { caseSensitiveRef.current = caseSensitive; }, [caseSensitive]);
|
||
useEffect(() => { useRegexRef.current = useRegex; }, [useRegex]);
|
||
|
||
// Autofocus the input when the bar mounts.
|
||
useEffect(() => {
|
||
queueMicrotask(() => inputRef.current?.focus());
|
||
}, []);
|
||
|
||
function getOptions() {
|
||
return {
|
||
caseSensitive: caseSensitiveRef.current,
|
||
regex: useRegexRef.current,
|
||
// Highlight all matches and mark the active one distinctly.
|
||
decorations: {
|
||
matchBackground: "#3a3a00",
|
||
matchBorder: "#888800",
|
||
matchOverviewRuler: "#888800",
|
||
activeMatchBackground: "#b5890080",
|
||
activeMatchBorder: "#e6c000",
|
||
activeMatchColorOverviewRuler: "#e6c000",
|
||
},
|
||
};
|
||
}
|
||
|
||
function findNext() {
|
||
if (!queryRef.current) return;
|
||
searchAddon.findNext(queryRef.current, getOptions());
|
||
}
|
||
|
||
function findPrev() {
|
||
if (!queryRef.current) return;
|
||
searchAddon.findPrevious(queryRef.current, getOptions());
|
||
}
|
||
|
||
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||
queryRef.current = e.target.value;
|
||
// Live-search: jump to next match as you type.
|
||
if (queryRef.current) {
|
||
searchAddon.findNext(queryRef.current, getOptions());
|
||
}
|
||
}
|
||
|
||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
onClose();
|
||
} else if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
findPrev();
|
||
} else {
|
||
findNext();
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleCase() {
|
||
setCaseSensitive((v) => {
|
||
const next = !v;
|
||
caseSensitiveRef.current = next;
|
||
// Re-run with the new option so decorations update immediately.
|
||
if (queryRef.current) {
|
||
searchAddon.findNext(queryRef.current, {
|
||
...getOptions(),
|
||
caseSensitive: next,
|
||
});
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function toggleRegex() {
|
||
setUseRegex((v) => {
|
||
const next = !v;
|
||
useRegexRef.current = next;
|
||
if (queryRef.current) {
|
||
searchAddon.findNext(queryRef.current, {
|
||
...getOptions(),
|
||
regex: next,
|
||
});
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="search-bar" role="search" aria-label="Find in terminal">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="Find…"
|
||
onChange={handleInput}
|
||
onKeyDown={handleKeyDown}
|
||
aria-label="Search term"
|
||
spellCheck={false}
|
||
/>
|
||
|
||
<button
|
||
className="search-toggle"
|
||
title="Case-sensitive"
|
||
aria-label="Toggle case-sensitive"
|
||
aria-pressed={caseSensitive ? "true" : "false"}
|
||
onClick={toggleCase}
|
||
>
|
||
Aa
|
||
</button>
|
||
|
||
<button
|
||
className="search-toggle"
|
||
title="Regular expression"
|
||
aria-label="Toggle regular expression"
|
||
aria-pressed={useRegex ? "true" : "false"}
|
||
onClick={toggleRegex}
|
||
>
|
||
.*
|
||
</button>
|
||
|
||
<button
|
||
className="search-nav"
|
||
title="Previous match (Shift+Enter)"
|
||
aria-label="Previous match"
|
||
onClick={findPrev}
|
||
>
|
||
↑
|
||
</button>
|
||
|
||
<button
|
||
className="search-nav"
|
||
title="Next match (Enter)"
|
||
aria-label="Next match"
|
||
onClick={findNext}
|
||
>
|
||
↓
|
||
</button>
|
||
|
||
<button
|
||
className="search-close"
|
||
title="Close (Escape)"
|
||
aria-label="Close search"
|
||
onClick={onClose}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|