Add find-in-scrollback, unicode11, and keyboard pane navigation

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>
This commit is contained in:
megaproxy 2026-05-28 21:51:29 +01:00
parent 8bb080345e
commit baa00dfc5c
8 changed files with 526 additions and 29 deletions

View file

@ -0,0 +1,177 @@
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}
>
&#8593;
</button>
<button
className="search-nav"
title="Next match (Enter)"
aria-label="Next match"
onClick={findNext}
>
&#8595;
</button>
<button
className="search-close"
title="Close (Escape)"
aria-label="Close search"
onClick={onClose}
>
×
</button>
</div>
);
}