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
|
|
@ -1,8 +1,11 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
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,
|
||||
|
|
@ -21,6 +24,7 @@ import {
|
|||
type PaneId,
|
||||
type SpawnSpec,
|
||||
} from "../ipc";
|
||||
import type { NavigateIntent } from "../lib/layout/orchestration";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// base64 helpers (private to this module)
|
||||
|
|
@ -72,6 +76,12 @@ interface XtermPaneProps {
|
|||
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;
|
||||
|
|
@ -90,11 +100,14 @@ export default function XtermPane({
|
|||
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).
|
||||
|
|
@ -107,12 +120,18 @@ export default function XtermPane({
|
|||
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
|
||||
|
|
@ -167,6 +186,24 @@ export default function XtermPane({
|
|||
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();
|
||||
|
||||
|
|
@ -279,36 +316,100 @@ export default function XtermPane({
|
|||
onInputRef.current?.(b64);
|
||||
});
|
||||
|
||||
// Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste from clipboard.
|
||||
// Runs before xterm consumes the key, so the textarea never sees a raw
|
||||
// Ctrl+V (which would otherwise inject ^V into the PTY). term.paste()
|
||||
// routes through onData → writeToPane, so broadcasting and bracketed
|
||||
// paste both keep working for free.
|
||||
// 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.
|
||||
//
|
||||
// Uses tauri-plugin-clipboard-manager instead of navigator.clipboard so
|
||||
// WebView2 doesn't surface its native "Allow clipboard access?" prompt.
|
||||
// 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;
|
||||
if (!e.ctrlKey || !e.shiftKey || e.altKey) return true;
|
||||
if (e.code === "KeyC") {
|
||||
const sel = term?.getSelection();
|
||||
if (sel) {
|
||||
void clipboardWriteText(sel).catch((err) =>
|
||||
console.warn("clipboard write failed:", err),
|
||||
);
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
if (e.code === "KeyV") {
|
||||
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;
|
||||
});
|
||||
|
||||
|
|
@ -389,6 +490,7 @@ export default function XtermPane({
|
|||
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
|
||||
|
|
@ -435,5 +537,30 @@ export default function XtermPane({
|
|||
}
|
||||
}, [fontSize]);
|
||||
|
||||
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue