mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 06:40:45 +08:00
fix(webcrt): keep device echo intact and stop dual-WS steal.
Use raw Telnet reads for ANSI backspace, drop local erase so Tab/completion stays in sync, and make WebSocket attach exclusive so StrictMode remounts cannot drop the first echo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fae26cf9bb
commit
3b8342acfb
5 changed files with 298 additions and 41 deletions
|
|
@ -8,12 +8,14 @@ export type WebTerminalHandle = {
|
|||
copyAll: () => Promise<string>;
|
||||
getText: () => string;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
wsUrl: string;
|
||||
title?: string;
|
||||
recording?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onStatus?: (state: string, message?: string) => void;
|
||||
onReady?: () => void;
|
||||
onStdout?: (data: string) => void;
|
||||
|
|
@ -30,8 +32,40 @@ function serializeTerminal(term: Terminal): string {
|
|||
return lines.join("\n").replace(/\s+$/g, "");
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
function isXtermTextarea(target: EventTarget | null): boolean {
|
||||
return target instanceof HTMLElement && target.classList.contains("xterm-helper-textarea");
|
||||
}
|
||||
|
||||
/** 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 === "Enter") return "\r";
|
||||
if (e.key === "Tab") return "\t";
|
||||
if (e.key === "Escape") return "\x1b";
|
||||
if (e.key === "ArrowUp") return "\x1b[A";
|
||||
if (e.key === "ArrowDown") return "\x1b[B";
|
||||
if (e.key === "ArrowRight") return "\x1b[C";
|
||||
if (e.key === "ArrowLeft") return "\x1b[D";
|
||||
if (e.key === "Home") return "\x1b[H";
|
||||
if (e.key === "End") return "\x1b[F";
|
||||
if (e.key === "Delete") return "\x1b[3~";
|
||||
if (e.key.length === 1) return e.key;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
|
||||
{ wsUrl, title, recording, onStatus, onReady, onStdout },
|
||||
{ wsUrl, title, recording, autoFocus = true, onStatus, onReady, onStdout },
|
||||
ref,
|
||||
) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -42,6 +76,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
const onReadyRef = useRef(onReady);
|
||||
const onStdoutRef = useRef(onStdout);
|
||||
const recordingRef = useRef(!!recording);
|
||||
const autoFocusRef = useRef(autoFocus);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusRef.current = onStatus;
|
||||
|
|
@ -53,6 +88,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
recordingRef.current = !!recording;
|
||||
}, [recording]);
|
||||
|
||||
useEffect(() => {
|
||||
autoFocusRef.current = autoFocus;
|
||||
}, [autoFocus]);
|
||||
|
||||
const focusTerminal = () => {
|
||||
try {
|
||||
termRef.current?.focus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clear: () => {
|
||||
termRef.current?.clear();
|
||||
|
|
@ -73,6 +120,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
/* ignore */
|
||||
}
|
||||
},
|
||||
focus: focusTerminal,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -103,9 +151,19 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
/* ignore */
|
||||
}
|
||||
};
|
||||
const maybeFocus = () => {
|
||||
if (!autoFocusRef.current) return;
|
||||
focusTerminal();
|
||||
};
|
||||
// Focus immediately so the first keystroke is not lost to the sidebar/body.
|
||||
maybeFocus();
|
||||
requestAnimationFrame(() => {
|
||||
doFit();
|
||||
window.setTimeout(doFit, 50);
|
||||
maybeFocus();
|
||||
window.setTimeout(() => {
|
||||
doFit();
|
||||
maybeFocus();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
|
@ -118,6 +176,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const sendResize = () => {
|
||||
doFit();
|
||||
sendJson({ type: "resize", cols: term.cols, rows: term.rows });
|
||||
|
|
@ -127,6 +197,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
onStatusRef.current?.("open");
|
||||
sendResize();
|
||||
onReadyRef.current?.();
|
||||
window.setTimeout(maybeFocus, 30);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
|
|
@ -138,13 +209,16 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
message?: string;
|
||||
};
|
||||
if (msg.type === "stdout" && typeof msg.data === "string") {
|
||||
term.write(msg.data);
|
||||
if (recordingRef.current) onStdoutRef.current?.(msg.data);
|
||||
writeStdout(msg.data);
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
if (msg.type === "status") {
|
||||
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
||||
if (msg.state === "connected") return;
|
||||
if (msg.state === "connected") {
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
if (msg.state === "closed" || msg.state === "error") {
|
||||
const detail = msg.message ? `: ${msg.message}` : "";
|
||||
term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`);
|
||||
|
|
@ -153,9 +227,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
if (msg.type === "pong") return;
|
||||
} catch {
|
||||
const raw = String(ev.data || "");
|
||||
term.write(raw);
|
||||
if (recordingRef.current) onStdoutRef.current?.(raw);
|
||||
writeStdout(String(ev.data || ""));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -171,10 +243,41 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
};
|
||||
|
||||
// When focused, xterm onData sends keystrokes.
|
||||
const dataDisposable = term.onData((data) => {
|
||||
sendJson({ type: "stdin", data });
|
||||
// Normalize Backspace: xterm emits DEL(0x7f); devices expect BS(0x08) like default SecureCRT.
|
||||
const normalized = data.replace(/\x7f/g, "\x08");
|
||||
sendStdin(normalized);
|
||||
});
|
||||
|
||||
// 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.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (isXtermTextarea(e.target)) {
|
||||
// Let xterm onData handle it; we only prevented browser back above.
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = keyEventToStdin(e);
|
||||
if (data == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
maybeFocus();
|
||||
sendStdin(data);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDownCapture, true);
|
||||
|
||||
const pingTimer = window.setInterval(() => {
|
||||
sendJson({ type: "ping" });
|
||||
}, 25000);
|
||||
|
|
@ -193,6 +296,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
return () => {
|
||||
window.clearInterval(pingTimer);
|
||||
window.removeEventListener("resize", onWinResize);
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
ro?.disconnect();
|
||||
dataDisposable.dispose();
|
||||
try {
|
||||
|
|
@ -207,5 +311,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
};
|
||||
}, [wsUrl, title]);
|
||||
|
||||
return <div className="webcrt-term" ref={hostRef} />;
|
||||
return (
|
||||
<div
|
||||
className="webcrt-term"
|
||||
ref={hostRef}
|
||||
onMouseDown={() => {
|
||||
focusTerminal();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue