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:
parent
8bb080345e
commit
baa00dfc5c
8 changed files with 526 additions and 29 deletions
177
src/components/SearchBar.tsx
Normal file
177
src/components/SearchBar.tsx
Normal 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}
|
||||
>
|
||||
↑
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue