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
|
|
@ -20,6 +20,8 @@
|
|||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"react": "^18.3.0",
|
||||
|
|
|
|||
34
src/App.tsx
34
src/App.tsx
|
|
@ -98,7 +98,7 @@ import {
|
|||
presetTwoRows,
|
||||
presetTwoByTwo,
|
||||
} from "./lib/layout/tree";
|
||||
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
|
||||
import { OrchestrationProvider, type Orchestration, type NavigateIntent } from "./lib/layout/orchestration";
|
||||
import LeafPane from "./lib/layout/LeafPane";
|
||||
import Gutter from "./lib/layout/Gutter";
|
||||
import Notifications, { type Toast } from "./components/Notifications";
|
||||
|
|
@ -717,6 +717,36 @@ export default function App() {
|
|||
setActiveLeafId(leafId);
|
||||
}, []);
|
||||
|
||||
// navigateTo — called from XtermPane's attachCustomKeyEventHandler via
|
||||
// LeafPane's onNavigate prop. Resolves the target leaf from the current
|
||||
// layout tree and sets it active; the LeafPane isActive→focusTrigger
|
||||
// effect then refocuses the xterm textarea automatically.
|
||||
const navigateTo = useCallback((intent: NavigateIntent) => {
|
||||
const currentTree = treeRef.current;
|
||||
const currentActiveId = activeLeafId;
|
||||
|
||||
if (intent.kind === "direction") {
|
||||
const layout = flattenLayout(currentTree);
|
||||
if (!currentActiveId) {
|
||||
const first = layout.leaves[0]?.leaf.id;
|
||||
if (first) setActiveLeafId(first);
|
||||
return;
|
||||
}
|
||||
const nextId = findNeighborInDirection(
|
||||
layout.leaves,
|
||||
currentActiveId,
|
||||
intent.dir,
|
||||
);
|
||||
if (nextId) setActiveLeafId(nextId);
|
||||
} else {
|
||||
// intent.kind === "index"
|
||||
const leaves = Array.from(walkLeaves(currentTree));
|
||||
// Clamp: Alt+9 with 3 panes picks the 3rd pane.
|
||||
const target = leaves[Math.min(intent.n, leaves.length) - 1];
|
||||
if (target) setActiveLeafId(target.id);
|
||||
}
|
||||
}, [activeLeafId]); // treeRef is a ref — stable, intentionally not listed
|
||||
|
||||
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
||||
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
||||
|
||||
|
|
@ -1242,6 +1272,7 @@ export default function App() {
|
|||
toggleMcpAllow,
|
||||
openHostManager,
|
||||
setActive,
|
||||
navigateTo,
|
||||
registerPaneId,
|
||||
broadcastFrom,
|
||||
notify,
|
||||
|
|
@ -1266,6 +1297,7 @@ export default function App() {
|
|||
toggleMcpAllow,
|
||||
openHostManager,
|
||||
setActive,
|
||||
navigateTo,
|
||||
registerPaneId,
|
||||
broadcastFrom,
|
||||
notify,
|
||||
|
|
|
|||
105
src/components/SearchBar.css
Normal file
105
src/components/SearchBar.css
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
SearchBar — find-in-scrollback overlay.
|
||||
|
||||
Positioned absolutely inside XtermPane's container div (which must be
|
||||
position: relative). Sits at the top-right of the pane, z-index 10 so it
|
||||
floats above the xterm canvas but below any app-level modals (z-index 100).
|
||||
Colour palette matches Palette.css / Help.css: #181818 surface, #2a2a2a
|
||||
borders, #e6e6e6 text, #1a3a5c accent.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
.search-bar {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: #181818;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.55);
|
||||
padding: 3px 4px;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: #e6e6e6;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
padding: 3px 7px;
|
||||
outline: none;
|
||||
width: 180px;
|
||||
caret-color: #e6e6e6;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #1a3a5c;
|
||||
box-shadow: 0 0 0 1px #1a3a5c;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Toggle buttons (Aa / .*) */
|
||||
.search-toggle {
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
padding: 2px 5px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.search-toggle:hover,
|
||||
.search-toggle[aria-pressed="true"] {
|
||||
background: #1a3a5c;
|
||||
border-color: #1a5c8a;
|
||||
color: #cce6ff;
|
||||
}
|
||||
|
||||
/* Prev / Next navigation arrows */
|
||||
.search-nav {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
color: #aaa;
|
||||
padding: 1px 6px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.search-nav:hover {
|
||||
background: #2a2a2a;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.search-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 1px 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.search-close:hover {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,14 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
[orch.setActive, leaf.id],
|
||||
);
|
||||
|
||||
// Delegate keyboard navigation intents from XtermPane up to App via
|
||||
// orch.navigateTo. XtermPane stays dumb (emits intent only); App resolves
|
||||
// the target leaf from the current layout and bumps focusTrigger.
|
||||
const onPaneNavigate = useCallback(
|
||||
(intent: Parameters<typeof orch.navigateTo>[0]) => orch.navigateTo(intent),
|
||||
[orch.navigateTo],
|
||||
);
|
||||
|
||||
const onStatus = useCallback((msg: string, ok: boolean) => {
|
||||
setStatus(msg);
|
||||
setStatusOk(ok);
|
||||
|
|
@ -575,6 +583,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
onInput={onTerminalInput}
|
||||
onDataReceived={onDataReceived}
|
||||
onFocus={onXtermFocus}
|
||||
onNavigate={onPaneNavigate}
|
||||
focusTrigger={focusTrigger}
|
||||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { Orientation, NodeId, LeafShellSpec } from "./tree";
|
||||
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
|
||||
import type { PaneId, SshHost } from "../../ipc";
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +62,16 @@ export interface Orchestration {
|
|||
* The PTY stays alive across the move (the new window's XtermPane
|
||||
* adopts the existing PaneId; scrollback ring is replayed). */
|
||||
moveToNewWindow: (leafId: NodeId) => void;
|
||||
/**
|
||||
* Navigate focus from within a pane's key-handler. XtermPane emits the
|
||||
* intent; LeafPane/App resolve the target leaf and set it active.
|
||||
*
|
||||
* `{ kind: "direction", dir }` — move to the spatial neighbour in that
|
||||
* direction using the same flattenLayout geometry as Ctrl+Shift+Arrow.
|
||||
* `{ kind: "index", n }` — focus the Nth leaf in DFS (walkLeaves) order,
|
||||
* 1-indexed, clamped to the leaf count (so Alt+9 with 3 panes picks pane 3).
|
||||
*/
|
||||
navigateTo: (intent: NavigateIntent) => void;
|
||||
/** Returns a PaneId only for leaves that just arrived via a window
|
||||
* transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip
|
||||
* the spawn). One-shot — App clears the entry once the pane has
|
||||
|
|
@ -69,6 +79,13 @@ export interface Orchestration {
|
|||
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
|
||||
}
|
||||
|
||||
/** Discriminated intent emitted by XtermPane's key handler. App resolves
|
||||
* the actual target leaf from the current tree without XtermPane needing
|
||||
* to know anything about layout geometry or leaf ordering. */
|
||||
export type NavigateIntent =
|
||||
| { kind: "direction"; dir: Direction }
|
||||
| { kind: "index"; n: number };
|
||||
|
||||
const OrchestrationContext = createContext<Orchestration | null>(null);
|
||||
|
||||
export function OrchestrationProvider({
|
||||
|
|
|
|||
|
|
@ -66,7 +66,23 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
|||
{ keys: "Ctrl+K", description: "Open jump-to-pane palette" },
|
||||
{
|
||||
keys: "Ctrl+Shift+← / → / ↑ / ↓",
|
||||
description: "Focus neighbour pane in that direction",
|
||||
description:
|
||||
"Focus neighbour pane in that direction (window-level — works even when no terminal is focused)",
|
||||
},
|
||||
{
|
||||
keys: "Ctrl+Alt+← / → / ↑ / ↓",
|
||||
description:
|
||||
"Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it)",
|
||||
},
|
||||
{
|
||||
keys: "Ctrl+Alt+H / J / K / L",
|
||||
description:
|
||||
"Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right)",
|
||||
},
|
||||
{
|
||||
keys: "Alt+1 … Alt+9",
|
||||
description:
|
||||
"Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -100,6 +116,18 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
|||
keys: "Ctrl+Shift+C / Ctrl+Shift+V",
|
||||
description: "Copy selection / paste in terminal",
|
||||
},
|
||||
{
|
||||
keys: "Ctrl+Shift+F",
|
||||
description: "Open find-in-scrollback bar for the focused pane",
|
||||
},
|
||||
{
|
||||
keys: "Enter / Shift+Enter",
|
||||
description: "Next / previous match (while search bar is focused)",
|
||||
},
|
||||
{
|
||||
keys: "Escape",
|
||||
description: "Close find bar and return focus to terminal",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue