mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 20:30:45 +08:00
Harden WebCRT interactive sessions for more CRT-like device login.
Improve async connect, binary stdout coalescing, paste throttling with progress, SSH keepalive, login prompt settle, and optional SFTP while keeping core terminal UX focused. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cb865254e4
commit
accd28c9a1
13 changed files with 1884 additions and 224 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
|
@ -6,9 +6,14 @@ import "@xterm/xterm/css/xterm.css";
|
|||
export type WebTerminalHandle = {
|
||||
clear: () => void;
|
||||
copyAll: () => Promise<string>;
|
||||
copySelection: () => Promise<string>;
|
||||
getText: () => string;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
sendBreak: () => void;
|
||||
sendText: (data: string, opts?: { throttle?: boolean }) => void;
|
||||
findNext: (term: string) => void;
|
||||
findPrevious: (term: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
|
@ -16,6 +21,9 @@ type Props = {
|
|||
title?: string;
|
||||
recording?: boolean;
|
||||
autoFocus?: boolean;
|
||||
encoding?: string;
|
||||
pasteDelayMs?: number;
|
||||
copyOnSelect?: boolean;
|
||||
onStatus?: (state: string, message?: string) => void;
|
||||
onReady?: () => void;
|
||||
onStdout?: (data: string) => void;
|
||||
|
|
@ -35,11 +43,10 @@ function serializeTerminal(term: Terminal): string {
|
|||
function isSidebarSearchTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
|
||||
// xterm's hidden textarea must still receive keys normally.
|
||||
if (target.classList.contains("xterm-helper-textarea")) return false;
|
||||
return true;
|
||||
}
|
||||
return Boolean(target.closest(".webcrt-sidebar__search"));
|
||||
return Boolean(target.closest(".webcrt-sidebar__search") || target.closest(".webcrt-ctx") || target.closest(".webcrt-find"));
|
||||
}
|
||||
|
||||
function isXtermTextarea(target: EventTarget | null): boolean {
|
||||
|
|
@ -49,7 +56,7 @@ function isXtermTextarea(target: EventTarget | null): boolean {
|
|||
/** Map a browser key event to the bytes xterm/onData would normally emit. */
|
||||
function keyEventToStdin(e: KeyboardEvent): string | null {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return null;
|
||||
if (e.key === "Backspace") return "\x08"; // BS — common SecureCRT/VT default
|
||||
if (e.key === "Backspace") return "\x08";
|
||||
if (e.key === "Enter") return "\r";
|
||||
if (e.key === "Tab") return "\t";
|
||||
if (e.key === "Escape") return "\x1b";
|
||||
|
|
@ -64,19 +71,65 @@ function keyEventToStdin(e: KeyboardEvent): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function decodeBytes(buf: ArrayBuffer, _encoding: string): string {
|
||||
// Server normalizes device encodings (e.g. GBK) to UTF-8 on the wire.
|
||||
try {
|
||||
return new TextDecoder("utf-8").decode(buf);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const PREF_KEY = "netx.webcrt.termPrefs";
|
||||
|
||||
function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREF_KEY);
|
||||
if (!raw) return { copyOnSelect: true, pasteDelayMs: 40 };
|
||||
const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
|
||||
return {
|
||||
copyOnSelect: j.copyOnSelect !== false,
|
||||
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs) || 40)),
|
||||
};
|
||||
} catch {
|
||||
return { copyOnSelect: true, pasteDelayMs: 40 };
|
||||
}
|
||||
}
|
||||
|
||||
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
|
||||
{ wsUrl, title, recording, autoFocus = true, onStatus, onReady, onStdout },
|
||||
{
|
||||
wsUrl,
|
||||
title,
|
||||
recording,
|
||||
autoFocus = true,
|
||||
encoding = "utf-8",
|
||||
pasteDelayMs,
|
||||
copyOnSelect,
|
||||
onStatus,
|
||||
onReady,
|
||||
onStdout,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const findIndexRef = useRef(0);
|
||||
const onStatusRef = useRef(onStatus);
|
||||
const onReadyRef = useRef(onReady);
|
||||
const onStdoutRef = useRef(onStdout);
|
||||
const recordingRef = useRef(!!recording);
|
||||
const autoFocusRef = useRef(autoFocus);
|
||||
const encodingRef = useRef(encoding);
|
||||
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
|
||||
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
|
||||
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [findQuery, setFindQuery] = useState("");
|
||||
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
|
||||
const findInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusRef.current = onStatus;
|
||||
|
|
@ -92,6 +145,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
autoFocusRef.current = autoFocus;
|
||||
}, [autoFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
encodingRef.current = encoding;
|
||||
}, [encoding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
|
||||
}, [pasteDelayMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
|
||||
}, [copyOnSelect]);
|
||||
|
||||
const focusTerminal = () => {
|
||||
try {
|
||||
termRef.current?.focus();
|
||||
|
|
@ -100,6 +165,46 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
};
|
||||
|
||||
const sendJson = (payload: Record<string, unknown>) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
};
|
||||
|
||||
const sendStdinImmediate = (data: string) => {
|
||||
if (!data) return;
|
||||
sendJson({ type: "stdin", data });
|
||||
};
|
||||
|
||||
/** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */
|
||||
const sendStdinThrottled = (data: string) => {
|
||||
if (!data) return;
|
||||
const delay = pasteDelayRef.current;
|
||||
if (delay <= 0 || data.length < 8) {
|
||||
sendStdinImmediate(data);
|
||||
return;
|
||||
}
|
||||
pasteQueueRef.current = pasteQueueRef.current.then(async () => {
|
||||
const lines = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const total = Math.max(1, lines.length);
|
||||
setPasteStatus({ done: 0, total });
|
||||
try {
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const chunk = i < lines.length - 1 ? `${line}\r` : line;
|
||||
if (chunk) sendStdinImmediate(chunk);
|
||||
setPasteStatus({ done: i + 1, total });
|
||||
if (i < lines.length - 1) {
|
||||
await new Promise((r) => window.setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setPasteStatus(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clear: () => {
|
||||
termRef.current?.clear();
|
||||
|
|
@ -112,6 +217,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
return text;
|
||||
},
|
||||
copySelection: async () => {
|
||||
const text = termRef.current?.getSelection() || "";
|
||||
if (text && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
|
||||
fit: () => {
|
||||
try {
|
||||
|
|
@ -121,6 +233,39 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
},
|
||||
focus: focusTerminal,
|
||||
sendBreak: () => sendJson({ type: "break" }),
|
||||
sendText: (data: string, opts?: { throttle?: boolean }) => {
|
||||
if (opts?.throttle) sendStdinThrottled(data);
|
||||
else sendStdinImmediate(data);
|
||||
},
|
||||
findNext: (q: string) => {
|
||||
const term = termRef.current;
|
||||
if (!term || !q) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = q.toLowerCase();
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
// Approximate scroll: each buffer line ~1 row.
|
||||
const line = text.slice(0, idx).split("\n").length - 1;
|
||||
term.scrollToLine(Math.max(0, line - 2));
|
||||
}
|
||||
},
|
||||
findPrevious: (q: string) => {
|
||||
const term = termRef.current;
|
||||
if (!term || !q) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = q.toLowerCase();
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
const line = text.slice(0, idx).split("\n").length - 1;
|
||||
term.scrollToLine(Math.max(0, line - 2));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -131,10 +276,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
scrollback: 10000,
|
||||
theme: {
|
||||
background: "#0b1220",
|
||||
foreground: "#e2e8f0",
|
||||
cursor: "#e2e8f0",
|
||||
selectionBackground: "#334155",
|
||||
},
|
||||
convertEol: true,
|
||||
});
|
||||
|
|
@ -155,7 +302,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
if (!autoFocusRef.current) return;
|
||||
focusTerminal();
|
||||
};
|
||||
// Focus immediately so the first keystroke is not lost to the sidebar/body.
|
||||
maybeFocus();
|
||||
requestAnimationFrame(() => {
|
||||
doFit();
|
||||
|
|
@ -167,22 +313,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
});
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
onStatusRef.current?.("connecting");
|
||||
|
||||
const sendJson = (payload: Record<string, unknown>) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
};
|
||||
|
||||
const sendStdin = (data: string) => {
|
||||
if (!data) return;
|
||||
sendJson({ type: "stdin", data });
|
||||
};
|
||||
|
||||
// Display only what the device echoes (chars, Tab completion, BS erase, etc.).
|
||||
// No local echo / local erase — those desync on Tab complete and prompt redraw.
|
||||
const writeStdout = (raw: string) => {
|
||||
if (raw) term.write(raw);
|
||||
if (recordingRef.current) onStdoutRef.current?.(raw);
|
||||
|
|
@ -201,6 +335,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
||||
void ev.data.arrayBuffer().then((buf) => {
|
||||
writeStdout(decodeBytes(buf, encodingRef.current));
|
||||
maybeFocus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(String(ev.data || "{}")) as {
|
||||
type?: string;
|
||||
|
|
@ -215,7 +361,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
if (msg.type === "status") {
|
||||
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
||||
if (msg.state === "connected") {
|
||||
if (msg.state === "connected" || msg.state === "connecting") {
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
|
|
@ -243,27 +389,41 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
};
|
||||
|
||||
// When focused, xterm onData sends keystrokes.
|
||||
const dataDisposable = term.onData((data) => {
|
||||
// Normalize Backspace: xterm emits DEL(0x7f); devices expect BS(0x08) like default SecureCRT.
|
||||
const normalized = data.replace(/\x7f/g, "\x08");
|
||||
sendStdin(normalized);
|
||||
// Large pastes from xterm arrive as one onData blob.
|
||||
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) {
|
||||
sendStdinThrottled(normalized);
|
||||
} else {
|
||||
sendStdinImmediate(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
const selDisposable = term.onSelectionChange(() => {
|
||||
if (!copyOnSelectRef.current) return;
|
||||
const sel = term.getSelection();
|
||||
if (sel && navigator.clipboard?.writeText) {
|
||||
void navigator.clipboard.writeText(sel).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// When NOT focused (sidebar still focused after click), capture keys and forward
|
||||
// so the first character / Backspace are not lost to the browser.
|
||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||
if (!autoFocusRef.current) return;
|
||||
if (host.closest("[hidden]")) return;
|
||||
if (isSidebarSearchTarget(e.target)) return;
|
||||
|
||||
// Always block browser "Backspace = history back" while this session pane is active.
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
|
||||
e.preventDefault();
|
||||
setFindOpen(true);
|
||||
window.setTimeout(() => findInputRef.current?.focus(), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (isXtermTextarea(e.target)) {
|
||||
// Let xterm onData handle it; we only prevented browser back above.
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
|
|
@ -274,10 +434,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
maybeFocus();
|
||||
sendStdin(data);
|
||||
sendStdinImmediate(data);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDownCapture, true);
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
host.addEventListener("contextmenu", onContextMenu);
|
||||
|
||||
const onPasteCapture = (e: ClipboardEvent) => {
|
||||
if (!autoFocusRef.current) return;
|
||||
if (host.closest("[hidden]")) return;
|
||||
if (isSidebarSearchTarget(e.target)) return;
|
||||
const text = e.clipboardData?.getData("text") || "";
|
||||
if (!text) return;
|
||||
// Intercept paste so we always apply delay (even if focus is odd).
|
||||
if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) {
|
||||
e.preventDefault();
|
||||
sendStdinThrottled(text);
|
||||
maybeFocus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPasteCapture, true);
|
||||
|
||||
const pingTimer = window.setInterval(() => {
|
||||
sendJson({ type: "ping" });
|
||||
}, 25000);
|
||||
|
|
@ -297,8 +478,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
window.clearInterval(pingTimer);
|
||||
window.removeEventListener("resize", onWinResize);
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
window.removeEventListener("paste", onPasteCapture, true);
|
||||
host.removeEventListener("contextmenu", onContextMenu);
|
||||
ro?.disconnect();
|
||||
dataDisposable.dispose();
|
||||
selDisposable.dispose();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
|
|
@ -311,13 +495,147 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
};
|
||||
}, [wsUrl, title]);
|
||||
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
sendStdinThrottled(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCtxMenu(null);
|
||||
focusTerminal();
|
||||
};
|
||||
|
||||
const copySelection = async () => {
|
||||
const text = termRef.current?.getSelection() || "";
|
||||
if (text && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
setCtxMenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="webcrt-term"
|
||||
ref={hostRef}
|
||||
onMouseDown={() => {
|
||||
focusTerminal();
|
||||
}}
|
||||
/>
|
||||
<div className="webcrt-term-wrap">
|
||||
{pasteStatus ? (
|
||||
<div className="webcrt-paste-status" role="status" aria-live="polite">
|
||||
粘贴中 {pasteStatus.done}/{pasteStatus.total} 行…
|
||||
</div>
|
||||
) : null}
|
||||
{findOpen ? (
|
||||
<div className="webcrt-find">
|
||||
<input
|
||||
ref={findInputRef}
|
||||
type="search"
|
||||
value={findQuery}
|
||||
placeholder="Find…"
|
||||
onChange={(e) => setFindQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
if (e.shiftKey) {
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
} else {
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setFindOpen(false);
|
||||
focusTerminal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
}}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
}}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFindOpen(false);
|
||||
focusTerminal();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="webcrt-term"
|
||||
ref={hostRef}
|
||||
onMouseDown={() => {
|
||||
focusTerminal();
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
/>
|
||||
{ctxMenu ? (
|
||||
<div
|
||||
className="webcrt-ctx"
|
||||
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={() => void copySelection()}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" onClick={() => void pasteFromClipboard()}>
|
||||
Paste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
termRef.current?.clear();
|
||||
termRef.current?.write("\x1b[H\x1b[2J");
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue