mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 18:30:47 +08:00
Improve WebCRT connect UX and terminal input handling.
Open shells asynchronously with clearer prompt settling, fix Telnet CRLF expansion, and add right-click paste with safer focus behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1463652dbb
commit
3b45d6abe3
8 changed files with 108 additions and 71 deletions
|
|
@ -89,10 +89,8 @@ def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str:
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
ret = channel_return(conn)
|
ret = channel_return(conn)
|
||||||
if ret == "\r":
|
# Replace once: chained replacements expand the CR in Telnet CRLF again.
|
||||||
return text
|
return re.sub(r"\r\n|\r|\n", lambda _match: ret, text)
|
||||||
# Prefer replacing CRLF first so Telnet RETURN \\r\\n does not double-expand.
|
|
||||||
return text.replace("\r\n", ret).replace("\r", ret)
|
|
||||||
|
|
||||||
|
|
||||||
def _drain_channel(conn: ConnectHandler, *, rounds: int = 6, wait: float = 0.06) -> str:
|
def _drain_channel(conn: ConnectHandler, *, rounds: int = 6, wait: float = 0.06) -> str:
|
||||||
|
|
|
||||||
|
|
@ -208,9 +208,8 @@ def api_quick_connect(
|
||||||
user_override = str(body.username or "").strip() or None
|
user_override = str(body.username or "").strip() or None
|
||||||
if str(body.password or "").strip():
|
if str(body.password or "").strip():
|
||||||
pwd_override = str(body.password)
|
pwd_override = str(body.password)
|
||||||
# SSH with password: wait for auth so wrong credentials can re-prompt (SecureCRT-like).
|
# Open the terminal immediately; WS reports auth progress and retryable failures.
|
||||||
wait_for_auth = proto == "ssh" and bool(pwd_override)
|
async_connect = bool(body.async_connect)
|
||||||
async_connect = bool(body.async_connect) and not wait_for_auth
|
|
||||||
try:
|
try:
|
||||||
session = create_session(
|
session = create_session(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -453,8 +453,12 @@ def _finish_connect(
|
||||||
text = _decode_bytes(chunk, sess.encoding)
|
text = _decode_bytes(chunk, sess.encoding)
|
||||||
except Exception:
|
except Exception:
|
||||||
text = ""
|
text = ""
|
||||||
if _is_prompt_only_echo(text, prompt_hint):
|
# A slow VM may emit its FIRST prompt after the reader starts.
|
||||||
|
# Only drop duplicates when a prompt was already delivered during login.
|
||||||
|
if _looks_like_cli_prompt(final_view) and _is_prompt_only_echo(text, prompt_hint):
|
||||||
continue
|
continue
|
||||||
|
if _looks_like_cli_prompt(text):
|
||||||
|
sess.needs_live_prompt = False
|
||||||
# Non-prompt data: put back and stop settling.
|
# Non-prompt data: put back and stop settling.
|
||||||
sess.out_queue.put(chunk)
|
sess.out_queue.put(chunk)
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,10 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
telnet = _FakeConn()
|
telnet = _FakeConn()
|
||||||
telnet.RETURN = "\r\n"
|
telnet.RETURN = "\r\n"
|
||||||
self.assertEqual(svc.map_network_cli_enter("\r", telnet), "\r\n") # type: ignore[arg-type]
|
self.assertEqual(svc.map_network_cli_enter("\r", telnet), "\r\n") # type: ignore[arg-type]
|
||||||
|
self.assertEqual(svc.map_network_cli_enter("show clock\r\n\r\n", telnet), "show clock\r\n\r\n")
|
||||||
|
self.assertEqual(svc.map_network_cli_enter("a\nb\rc\r\n", telnet), "a\r\nb\r\nc\r\n")
|
||||||
|
telnet.RETURN = "\r"
|
||||||
|
self.assertEqual(svc.map_network_cli_enter("a\r\nb\n", telnet), "a\rb\r")
|
||||||
self.assertEqual(svc.normalize_cli_transcript("R2#R2#\nR2#"), "R2#")
|
self.assertEqual(svc.normalize_cli_transcript("R2#R2#\nR2#"), "R2#")
|
||||||
self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
|
self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
|
||||||
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\nR2#")
|
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\nR2#")
|
||||||
|
|
@ -154,6 +158,47 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
self.assertIn("R2#", text)
|
self.assertIn("R2#", text)
|
||||||
self.assertNotIn("MagicMock", text)
|
self.assertNotIn("MagicMock", text)
|
||||||
|
|
||||||
|
@patch.object(reg, "_audit")
|
||||||
|
@patch.object(reg, "get_cli_hop_guard", return_value=None)
|
||||||
|
@patch.object(reg, "_capture_raw_channel", return_value="")
|
||||||
|
@patch.object(reg, "_prime_interactive_channel", return_value="")
|
||||||
|
@patch.object(reg, "open_netmiko_connection")
|
||||||
|
def test_first_prompt_arriving_after_reader_start_is_preserved(self, mock_open, *_mocks) -> None:
|
||||||
|
sess = svc.WebcrtSession(
|
||||||
|
session_id="late-first-prompt", ne_id="lab", ne_name="lab",
|
||||||
|
ne_ip="192.0.2.1", protocol="telnet", cols=80, rows=24,
|
||||||
|
)
|
||||||
|
mock_open.return_value = _FakeConn()
|
||||||
|
# Auth banner has been streamed, but the VM has not sent its first prompt yet.
|
||||||
|
sess.push_connect_echo("Login successful\r\n")
|
||||||
|
with patch.object(sess, "start_reader", side_effect=lambda: sess.out_queue.put(b"<r1>")), \
|
||||||
|
patch.object(sess, "open_session_log"), patch.object(sess, "append_session_log"):
|
||||||
|
reg._finish_connect(sess, creds={}, device={}, connect_timeout=30, client="test")
|
||||||
|
self.assertEqual(sess.state, "ready")
|
||||||
|
self.assertEqual(sess.out_queue.get_nowait(), b"<r1>")
|
||||||
|
self.assertFalse(sess.needs_live_prompt)
|
||||||
|
|
||||||
|
def test_quick_connect_honors_async_authentication(self) -> None:
|
||||||
|
from netx_api.webcrt_router import WebcrtQuickConnectBody, api_quick_connect
|
||||||
|
|
||||||
|
ne = MagicMock()
|
||||||
|
ne.id = "lab"
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.user.id = "user"
|
||||||
|
ctx.user.username = "operator"
|
||||||
|
with patch("netx_api.ne_service.upsert_webcrt_session_host", return_value=(ne, "reused")), \
|
||||||
|
patch("netx_api.webcrt_router.create_session", return_value={"session_id": "pending"}) as create, \
|
||||||
|
patch("netx_api.webcrt_router._client_label", return_value="test"):
|
||||||
|
for async_connect in (True, False):
|
||||||
|
body = WebcrtQuickConnectBody(
|
||||||
|
ip_address="192.0.2.1", protocol="ssh", username="operator",
|
||||||
|
password="one-shot", save_password=False, async_connect=async_connect,
|
||||||
|
)
|
||||||
|
result = api_quick_connect(body, request=MagicMock(), db=MagicMock(), ctx=ctx)
|
||||||
|
self.assertEqual(result["session_id"], "pending")
|
||||||
|
self.assertEqual(create.call_args.kwargs["async_connect"], async_connect)
|
||||||
|
self.assertEqual(create.call_args.kwargs["password_override"], "one-shot")
|
||||||
|
|
||||||
@patch.object(reg, "_audit")
|
@patch.object(reg, "_audit")
|
||||||
@patch.object(reg, "open_netmiko_connection")
|
@patch.object(reg, "open_netmiko_connection")
|
||||||
@patch("netx_api.cli_resolve.resolve_cli_target")
|
@patch("netx_api.cli_resolve.resolve_cli_target")
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export type WebTerminalHandle = {
|
||||||
copySelection: () => Promise<string>;
|
copySelection: () => Promise<string>;
|
||||||
getText: () => string;
|
getText: () => string;
|
||||||
fit: () => void;
|
fit: () => void;
|
||||||
focus: () => void;
|
focus: (opts?: { force?: boolean }) => void;
|
||||||
sendBreak: () => void;
|
sendBreak: () => void;
|
||||||
sendText: (data: string, opts?: { throttle?: boolean }) => void;
|
sendText: (data: string, opts?: { throttle?: boolean }) => void;
|
||||||
findNext: (term: string) => void;
|
findNext: (term: string) => void;
|
||||||
|
|
@ -74,6 +74,7 @@ type Props = {
|
||||||
themeName?: "dark" | "light";
|
themeName?: "dark" | "light";
|
||||||
pasteDelayMs?: number;
|
pasteDelayMs?: number;
|
||||||
copyOnSelect?: boolean;
|
copyOnSelect?: boolean;
|
||||||
|
rightClickPaste?: boolean;
|
||||||
keywordHighlight?: KeywordHighlightConfig;
|
keywordHighlight?: KeywordHighlightConfig;
|
||||||
onStatus?: (
|
onStatus?: (
|
||||||
state: string,
|
state: string,
|
||||||
|
|
@ -147,7 +148,7 @@ export function isAuditableCommandLine(line: string): boolean {
|
||||||
|
|
||||||
function currentCommandLine(term: Terminal): string {
|
function currentCommandLine(term: Terminal): string {
|
||||||
const buf = term.buffer.active;
|
const buf = term.buffer.active;
|
||||||
const y = buf.cursorY;
|
const y = buf.baseY + buf.cursorY;
|
||||||
const curText = normalizeAuditLine(buf.getLine(y)?.translateToString(true) ?? "");
|
const curText = normalizeAuditLine(buf.getLine(y)?.translateToString(true) ?? "");
|
||||||
|
|
||||||
// Sitting on a bare prompt / blank row after output — never walk up into the
|
// Sitting on a bare prompt / blank row after output — never walk up into the
|
||||||
|
|
@ -267,7 +268,7 @@ function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } {
|
||||||
const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
|
const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
|
||||||
return {
|
return {
|
||||||
copyOnSelect: j.copyOnSelect !== false,
|
copyOnSelect: j.copyOnSelect !== false,
|
||||||
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs) || 40)),
|
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs ?? 40) || 0)),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { copyOnSelect: true, pasteDelayMs: 40 };
|
return { copyOnSelect: true, pasteDelayMs: 40 };
|
||||||
|
|
@ -287,6 +288,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
themeName = "dark",
|
themeName = "dark",
|
||||||
pasteDelayMs,
|
pasteDelayMs,
|
||||||
copyOnSelect,
|
copyOnSelect,
|
||||||
|
rightClickPaste = true,
|
||||||
keywordHighlight,
|
keywordHighlight,
|
||||||
onStatus,
|
onStatus,
|
||||||
onReady,
|
onReady,
|
||||||
|
|
@ -316,6 +318,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
const termColorsRef = useRef<TermColors>(resolvedColors);
|
const termColorsRef = useRef<TermColors>(resolvedColors);
|
||||||
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
|
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
|
||||||
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
|
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
|
||||||
|
const rightClickPasteRef = useRef(rightClickPaste);
|
||||||
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
|
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
/** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */
|
/** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */
|
||||||
const lastStdoutAtRef = useRef(0);
|
const lastStdoutAtRef = useRef(0);
|
||||||
|
|
@ -332,10 +335,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
const pasteBridgeOpenRef = useRef(false);
|
const pasteBridgeOpenRef = useRef(false);
|
||||||
pasteBridgeOpenRef.current = pasteBridgeOpen;
|
pasteBridgeOpenRef.current = pasteBridgeOpen;
|
||||||
const findInputRef = useRef<HTMLInputElement | null>(null);
|
const findInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
/** Suppress duplicate Enter frames (keydown + xterm onData both fire). */
|
|
||||||
const enterHandledAtRef = useRef(0);
|
|
||||||
/** Last Tab keystroke — Enter soon after must wait for device completion redraw. */
|
|
||||||
const lastTabAtRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStatusRef.current = onStatus;
|
onStatusRef.current = onStatus;
|
||||||
|
|
@ -378,9 +377,16 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
|
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
|
||||||
}, [copyOnSelect]);
|
}, [copyOnSelect]);
|
||||||
|
|
||||||
|
rightClickPasteRef.current = rightClickPaste;
|
||||||
|
|
||||||
keywordHighlightRef.current = keywordHighlight;
|
keywordHighlightRef.current = keywordHighlight;
|
||||||
|
|
||||||
const focusTerminal = () => {
|
const focusTerminal = (force = false) => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!autoFocusRef.current || !host || host.closest("[hidden]")) return;
|
||||||
|
if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return;
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!force && active && active !== document.body && !host.contains(active)) return;
|
||||||
try {
|
try {
|
||||||
termRef.current?.focus();
|
termRef.current?.focus();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -405,27 +411,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
sendJson(payload);
|
sendJson(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendEnterStdin = (term: Terminal, explicitAuditLine?: string) => {
|
|
||||||
const recentTab = performance.now() - lastTabAtRef.current < 450;
|
|
||||||
if (recentTab) {
|
|
||||||
// Re-read xterm after device Tab redraw; ignore any stale snapshot.
|
|
||||||
// Mark Enter handled now so onData duplicate frame is suppressed while we wait.
|
|
||||||
enterHandledAtRef.current = performance.now();
|
|
||||||
window.setTimeout(() => {
|
|
||||||
enterHandledAtRef.current = performance.now();
|
|
||||||
sendStdinWithAudit("\r", auditLineForEnter(term));
|
|
||||||
}, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
enterHandledAtRef.current = performance.now();
|
|
||||||
sendStdinWithAudit("\r", explicitAuditLine ?? auditLineForEnter(term));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDuplicateEnterFrame = (data: string): boolean => {
|
|
||||||
if (!/^[\r\n]+$/.test(data)) return false;
|
|
||||||
return performance.now() - enterHandledAtRef.current < 120;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendStdinImmediate = (data: string) => {
|
const sendStdinImmediate = (data: string) => {
|
||||||
sendStdinWithAudit(data);
|
sendStdinWithAudit(data);
|
||||||
};
|
};
|
||||||
|
|
@ -508,7 +493,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
focus: focusTerminal,
|
focus: (opts) => focusTerminal(opts?.force !== false),
|
||||||
sendBreak: () => sendJson({ type: "break" }),
|
sendBreak: () => sendJson({ type: "break" }),
|
||||||
sendText: (data: string, opts?: { throttle?: boolean }) => {
|
sendText: (data: string, opts?: { throttle?: boolean }) => {
|
||||||
if (opts?.throttle) sendStdinThrottled(data);
|
if (opts?.throttle) sendStdinThrottled(data);
|
||||||
|
|
@ -546,8 +531,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
disableStdin: !live,
|
disableStdin: !live,
|
||||||
fontSize: fontSizeRef.current,
|
fontSize: fontSizeRef.current,
|
||||||
fontFamily: 'Consolas, "Courier New", monospace',
|
fontFamily: 'Consolas, "Courier New", monospace',
|
||||||
// Lower than CRT-style 10k: remounts + multi-tab stay lighter; server replays log tail on attach.
|
scrollback: 10000,
|
||||||
scrollback: 4000,
|
cursorStyle: "block",
|
||||||
|
scrollOnUserInput: true,
|
||||||
theme: xtermThemeFromColors(termColorsRef.current),
|
theme: xtermThemeFromColors(termColorsRef.current),
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
});
|
});
|
||||||
|
|
@ -656,14 +642,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
if (!isActiveSocket()) return;
|
if (!isActiveSocket()) return;
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
||||||
maybeFocus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
||||||
void ev.data.arrayBuffer().then((buf) => {
|
void ev.data.arrayBuffer().then((buf) => {
|
||||||
if (!isActiveSocket()) return;
|
if (!isActiveSocket()) return;
|
||||||
writeStdout(decodeBytes(buf, encodingRef.current));
|
writeStdout(decodeBytes(buf, encodingRef.current));
|
||||||
maybeFocus();
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -679,7 +663,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
};
|
};
|
||||||
if (msg.type === "stdout" && typeof msg.data === "string") {
|
if (msg.type === "stdout" && typeof msg.data === "string") {
|
||||||
writeStdout(msg.data);
|
writeStdout(msg.data);
|
||||||
maybeFocus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === "status") {
|
if (msg.type === "status") {
|
||||||
|
|
@ -793,23 +776,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
|
|
||||||
const dataDisposable = term.onData((data) => {
|
const dataDisposable = term.onData((data) => {
|
||||||
const normalized = data.replace(/\x7f/g, "\x08");
|
const normalized = data.replace(/\x7f/g, "\x08");
|
||||||
if (normalized.includes("\t")) {
|
|
||||||
lastTabAtRef.current = performance.now();
|
|
||||||
}
|
|
||||||
if (isDuplicateEnterFrame(normalized)) return;
|
|
||||||
|
|
||||||
const isEnter = normalized.includes("\r") || normalized.includes("\n");
|
const isEnter = normalized.includes("\r") || normalized.includes("\n");
|
||||||
if (isEnter) {
|
|
||||||
enterHandledAtRef.current = performance.now();
|
|
||||||
const recentTab = performance.now() - lastTabAtRef.current < 450;
|
|
||||||
// After Tab, wait briefly so Huawei/ZTE redraw lands in xterm before we snapshot.
|
|
||||||
if (recentTab && /^[\r\n]+$/.test(normalized)) {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
sendStdinWithAudit("\r", auditLineForEnter(term));
|
|
||||||
}, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const auditLine = isEnter ? auditLineForEnter(term) : undefined;
|
const auditLine = isEnter ? auditLineForEnter(term) : undefined;
|
||||||
// Large pastes from xterm arrive as one onData blob.
|
// Large pastes from xterm arrive as one onData blob.
|
||||||
|
|
@ -830,6 +797,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
if (!autoFocusRef.current) return;
|
if (!autoFocusRef.current) return;
|
||||||
if (host.closest("[hidden]")) return;
|
if (host.closest("[hidden]")) return;
|
||||||
if (isSidebarSearchTarget(e.target)) return;
|
if (isSidebarSearchTarget(e.target)) return;
|
||||||
|
if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return;
|
||||||
|
if (!host.contains(e.target as Node) && e.target !== document.body) return;
|
||||||
|
if (e.isComposing) return;
|
||||||
|
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
|
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -869,12 +839,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === "Backspace") {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isXtermTextarea(e.target)) {
|
if (isXtermTextarea(e.target)) {
|
||||||
maybeFocus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -885,7 +850,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
maybeFocus();
|
maybeFocus();
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
sendEnterStdin(term);
|
sendStdinWithAudit("\r", auditLineForEnter(term));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendStdinWithAudit(data);
|
sendStdinWithAudit(data);
|
||||||
|
|
@ -894,6 +859,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
|
|
||||||
const onContextMenu = (e: MouseEvent) => {
|
const onContextMenu = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (live && rightClickPasteRef.current && !e.shiftKey) {
|
||||||
|
void pasteFromClipboard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
menuSelectionRef.current = term.getSelection() || "";
|
menuSelectionRef.current = term.getSelection() || "";
|
||||||
setCtxMenu({ x: e.clientX, y: e.clientY });
|
setCtxMenu({ x: e.clientX, y: e.clientY });
|
||||||
};
|
};
|
||||||
|
|
@ -905,6 +874,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
if (host.closest("[hidden]")) return;
|
if (host.closest("[hidden]")) return;
|
||||||
if (isSidebarSearchTarget(e.target)) return;
|
if (isSidebarSearchTarget(e.target)) return;
|
||||||
const text = e.clipboardData?.getData("text") || "";
|
const text = e.clipboardData?.getData("text") || "";
|
||||||
|
if (!host.contains(e.target as Node)) return;
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
// Intercept paste so we always apply delay (even if focus is odd).
|
// Intercept paste so we always apply delay (even if focus is odd).
|
||||||
if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) {
|
if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) {
|
||||||
|
|
|
||||||
|
|
@ -2388,6 +2388,8 @@ const en = {
|
||||||
custom: "Custom",
|
custom: "Custom",
|
||||||
},
|
},
|
||||||
copyOnSelect: "Copy on select",
|
copyOnSelect: "Copy on select",
|
||||||
|
rightClickPaste: "Paste on right click",
|
||||||
|
rightClickPasteHint: "Shift + right click opens the menu. Turn off to always show the menu.",
|
||||||
pasteDelay: "Paste max line wait",
|
pasteDelay: "Paste max line wait",
|
||||||
pasteDelayOff: "No delay (bulk send)",
|
pasteDelayOff: "No delay (bulk send)",
|
||||||
keepalive: "SSH keepalive",
|
keepalive: "SSH keepalive",
|
||||||
|
|
|
||||||
|
|
@ -2370,6 +2370,8 @@ const zh = {
|
||||||
custom: "自定义",
|
custom: "自定义",
|
||||||
},
|
},
|
||||||
copyOnSelect: "选中即复制",
|
copyOnSelect: "选中即复制",
|
||||||
|
rightClickPaste: "右键直接粘贴",
|
||||||
|
rightClickPasteHint: "Shift + 右键打开菜单;关闭此项后,右键始终打开菜单。",
|
||||||
pasteDelay: "粘贴最大行间隔",
|
pasteDelay: "粘贴最大行间隔",
|
||||||
pasteDelayOff: "无延迟(整块发送)",
|
pasteDelayOff: "无延迟(整块发送)",
|
||||||
keepalive: "SSH Keepalive",
|
keepalive: "SSH Keepalive",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ type SessionOptions = {
|
||||||
background: string;
|
background: string;
|
||||||
foreground: string;
|
foreground: string;
|
||||||
copyOnSelect: boolean;
|
copyOnSelect: boolean;
|
||||||
|
rightClickPaste: boolean;
|
||||||
pasteDelayMs: number;
|
pasteDelayMs: number;
|
||||||
keepaliveSec: number;
|
keepaliveSec: number;
|
||||||
};
|
};
|
||||||
|
|
@ -186,6 +187,7 @@ function defaultSessionOptions(): SessionOptions {
|
||||||
background: COLOR_SCHEMES.dark.background,
|
background: COLOR_SCHEMES.dark.background,
|
||||||
foreground: COLOR_SCHEMES.dark.foreground,
|
foreground: COLOR_SCHEMES.dark.foreground,
|
||||||
copyOnSelect: true,
|
copyOnSelect: true,
|
||||||
|
rightClickPaste: true,
|
||||||
pasteDelayMs: 40,
|
pasteDelayMs: 40,
|
||||||
keepaliveSec: 0,
|
keepaliveSec: 0,
|
||||||
};
|
};
|
||||||
|
|
@ -199,7 +201,7 @@ function loadSessionOptions(): SessionOptions {
|
||||||
const legacy = JSON.parse(legacyRaw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
|
const legacy = JSON.parse(legacyRaw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
|
||||||
if (legacy.copyOnSelect === false) base.copyOnSelect = false;
|
if (legacy.copyOnSelect === false) base.copyOnSelect = false;
|
||||||
if (legacy.pasteDelayMs != null) {
|
if (legacy.pasteDelayMs != null) {
|
||||||
base.pasteDelayMs = Math.max(0, Math.min(200, Number(legacy.pasteDelayMs) || 40));
|
base.pasteDelayMs = Math.max(0, Math.min(200, Number(legacy.pasteDelayMs) || 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const raw = localStorage.getItem(SESSION_OPTS_KEY);
|
const raw = localStorage.getItem(SESSION_OPTS_KEY);
|
||||||
|
|
@ -234,7 +236,8 @@ function loadSessionOptions(): SessionOptions {
|
||||||
background,
|
background,
|
||||||
foreground,
|
foreground,
|
||||||
copyOnSelect: j.copyOnSelect !== false,
|
copyOnSelect: j.copyOnSelect !== false,
|
||||||
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs ?? base.pasteDelayMs) || 40)),
|
rightClickPaste: j.rightClickPaste !== false,
|
||||||
|
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs ?? base.pasteDelayMs) || 0)),
|
||||||
keepaliveSec: Math.max(0, Math.min(600, Number(j.keepaliveSec ?? base.keepaliveSec) || 0)),
|
keepaliveSec: Math.max(0, Math.min(600, Number(j.keepaliveSec ?? base.keepaliveSec) || 0)),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -263,6 +266,7 @@ function normalizeSessionOptions(draft: SessionOptions): SessionOptions {
|
||||||
background: preset.background,
|
background: preset.background,
|
||||||
foreground: preset.foreground,
|
foreground: preset.foreground,
|
||||||
copyOnSelect: draft.copyOnSelect !== false,
|
copyOnSelect: draft.copyOnSelect !== false,
|
||||||
|
rightClickPaste: draft.rightClickPaste !== false,
|
||||||
pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)),
|
pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)),
|
||||||
keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)),
|
keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)),
|
||||||
};
|
};
|
||||||
|
|
@ -274,6 +278,7 @@ function normalizeSessionOptions(draft: SessionOptions): SessionOptions {
|
||||||
background,
|
background,
|
||||||
foreground,
|
foreground,
|
||||||
copyOnSelect: draft.copyOnSelect !== false,
|
copyOnSelect: draft.copyOnSelect !== false,
|
||||||
|
rightClickPaste: draft.rightClickPaste !== false,
|
||||||
pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)),
|
pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)),
|
||||||
keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)),
|
keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)),
|
||||||
};
|
};
|
||||||
|
|
@ -1245,7 +1250,7 @@ export function WebcrtPage() {
|
||||||
rows: dims.rows,
|
rows: dims.rows,
|
||||||
encoding: dims.encoding,
|
encoding: dims.encoding,
|
||||||
keepalive_sec: dims.keepaliveSec,
|
keepalive_sec: dims.keepaliveSec,
|
||||||
async_connect: false,
|
async_connect: true,
|
||||||
username,
|
username,
|
||||||
password: authForm.password,
|
password: authForm.password,
|
||||||
});
|
});
|
||||||
|
|
@ -1279,7 +1284,7 @@ export function WebcrtPage() {
|
||||||
rows: dims.rows,
|
rows: dims.rows,
|
||||||
encoding: dims.encoding,
|
encoding: dims.encoding,
|
||||||
keepalive_sec: dims.keepaliveSec,
|
keepalive_sec: dims.keepaliveSec,
|
||||||
async_connect: false,
|
async_connect: true,
|
||||||
});
|
});
|
||||||
const target: CliTargetItem = {
|
const target: CliTargetItem = {
|
||||||
source: "webcrt",
|
source: "webcrt",
|
||||||
|
|
@ -2420,9 +2425,10 @@ export function WebcrtPage() {
|
||||||
foreground: sessionOpts.foreground,
|
foreground: sessionOpts.foreground,
|
||||||
}}
|
}}
|
||||||
copyOnSelect={sessionOpts.copyOnSelect}
|
copyOnSelect={sessionOpts.copyOnSelect}
|
||||||
|
rightClickPaste={sessionOpts.rightClickPaste}
|
||||||
pasteDelayMs={sessionOpts.pasteDelayMs}
|
pasteDelayMs={sessionOpts.pasteDelayMs}
|
||||||
keywordHighlight={keywordHl}
|
keywordHighlight={keywordHl}
|
||||||
autoFocus={tab.status === "connected"}
|
autoFocus={isActive && (tab.status === "connected" || tab.status === "connecting")}
|
||||||
onStdout={(chunk) => {
|
onStdout={(chunk) => {
|
||||||
if (!chunk) return;
|
if (!chunk) return;
|
||||||
let parts = logBuffersRef.current.get(tab.key);
|
let parts = logBuffersRef.current.get(tab.key);
|
||||||
|
|
@ -2452,7 +2458,7 @@ export function WebcrtPage() {
|
||||||
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
|
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
|
||||||
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
|
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
|
||||||
});
|
});
|
||||||
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
window.setTimeout(() => termRefs.current.get(tab.key)?.focus({ force: false }), 40);
|
||||||
} else if (state === "open" || state === "connecting") {
|
} else if (state === "open" || state === "connecting") {
|
||||||
const nextPhase: ConnectPhase =
|
const nextPhase: ConnectPhase =
|
||||||
phase === "waiting_prompt" || message === "waiting_prompt"
|
phase === "waiting_prompt" || message === "waiting_prompt"
|
||||||
|
|
@ -3257,6 +3263,17 @@ export function WebcrtPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="webcrt-session-opts__field webcrt-session-opts__field--check" title={t("webcrt.rightClickPasteHint")}>
|
||||||
|
<span>{t("webcrt.rightClickPaste")}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sessionOptsDraft.rightClickPaste}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSessionOptsDraft((prev) => ({ ...prev, rightClickPaste: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="form-hint">{t("webcrt.rightClickPasteHint")}</p>
|
||||||
<label className="webcrt-session-opts__field">
|
<label className="webcrt-session-opts__field">
|
||||||
<span>{t("webcrt.pasteDelay")}</span>
|
<span>{t("webcrt.pasteDelay")}</span>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue