Compare commits

...
8 changed files with 191 additions and 12 deletions

No files matched your search

@@ -200,7 +200,22 @@ export const useTerminalContextActions = ({
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
broadcastPasswordBypass: () => broadcastPasswordBypassRef?.current === true,
onClipboardImageUploadResult,
readClipboardText: () => navigator.clipboard.readText(),
// Prefer the main-process clipboard read: the renderer's clipboard
// view can still be empty right after Windows clipboard history
// (Win+V) writes the picked item while the window was unfocused
// (#3582), while the bridge always reads the live system clipboard.
readClipboardText: async () => {
try {
const bridged = await bridge?.readClipboardText?.();
// Treat any successful bridge read as authoritative — including an
// empty string, which means the live clipboard is genuinely empty
// and must not fall back to possibly-stale renderer clipboard text.
if (typeof bridged === "string") return bridged;
} catch (err) {
logger.warn("Bridge clipboard read failed; falling back to navigator", err);
}
return navigator.clipboard.readText();
},
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData: broadcastUserPasteData,
sessionId: sessionRef.current,
@@ -67,10 +67,20 @@ export function useTerminalFilePaste({
// remote keyboard pastes; otherwise xterm's default handler would send
// the lines without the safety dialog when neither image upload nor
// local file handling applies.
//
// Windows clipboard history (Win+V) can deliver the picked item with an
// empty paste event: the picker writes the system clipboard while the
// window is unfocused and Chromium still serves its stale (empty)
// clipboard view on the synthetic paste (#3582). Intercept these events
// and re-read through the main-process bridge, which sees the live
// system clipboard.
const eventText = event.clipboardData?.getData("text/plain") ?? "";
const emptyPasteEventRecovery = !eventText && !!bridge?.readClipboardText;
const shouldInterceptPaste =
wantsImageUpload
|| canHandleLocalPaste
|| !!multilinePasteConfirmRef?.current?.enabled;
|| !!multilinePasteConfirmRef?.current?.enabled
|| emptyPasteEventRecovery;
if (!shouldInterceptPaste) return;
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
@@ -79,12 +89,6 @@ export function useTerminalFilePaste({
event.preventDefault();
event.stopPropagation();
// Capture the pasted text synchronously: navigator.clipboard.readText()
// can be rejected (permissions / platform quirks) even though the event
// already carries the text, so prefer it and only fall back to the
// async clipboard API when the event has no text data.
const eventText = event.clipboardData?.getData("text/plain") ?? "";
void (async () => {
try {
const term = termRef.current;
@@ -110,6 +114,17 @@ export function useTerminalFilePaste({
onClipboardImageUploadResult,
readClipboardText: async () => {
if (eventText) return eventText;
// Prefer the main-process bridge: the renderer's clipboard view
// can lag behind a Windows clipboard history write (#3582).
// Any successful string result is authoritative — including ""
// (e.g. the clipboard was genuinely cleared) — so treat only a
// missing bridge or thrown read as a fallback condition.
try {
const bridged = await bridge?.readClipboardText?.();
if (typeof bridged === "string") return bridged;
} catch (err) {
logger.warn("Bridge clipboard read failed; falling back to navigator", err);
}
return navigator.clipboard.readText();
},
scrollOnPaste: scrollOnPasteRef?.current ?? false,
@@ -1113,3 +1113,26 @@ test("Shift+Enter send-text fallback snapshots prompt sensitivity before the loc
/broadcastKittyInput\(\s*\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\},\s*false,\s*undefined,\s*sourceSensitivePrompt \? \{ sourceSensitive: true \} : undefined\)/,
);
});
test("plain Ctrl+V keydowns that escape the menu accelerator route through the shared paste path (#3582)", async () => {
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// Windows clipboard history (Win+V) injects a plain Ctrl+V keydown that the
// Edit > Paste menu accelerator does not consume. Unclaimed, xterm encodes
// it as bare \x16 and the shell shows ^V instead of pasting. The claim must
// share the context-menu paste path so local image-only clipboards keep
// their Ctrl+V forward and remote auto-upload stays gated.
const claimIdx = source.indexOf("platform === \"win32\" && matchesPlainCtrlVChord(e)");
assert.ok(claimIdx >= 0, "plain Ctrl+V claim must exist in the keydown handler");
const claim = source.slice(claimIdx, claimIdx + 600);
assert.match(claim, /platform === "win32" && matchesPlainCtrlVChord\(e\)/);
// Auto-repeat Ctrl+V (held key) must be consumed without re-pasting, or
// each repeat reaches xterm and is encoded as bare \x16.
assert.match(claim, /if \(!e\.repeat\) void ctx\.terminalContextActionsRef\?\.current\?\.onPaste\?\.()/u);
assert.match(claim, /return false;/);
// The claim sits after the configured shortcut bindings so a re-mapped
// paste chord keeps precedence.
const bindingsIdx = source.indexOf("const currentBindings = ctx.keyBindingsRef.current;");
assert.ok(bindingsIdx >= 0 && bindingsIdx < claimIdx);
});
@@ -172,6 +172,7 @@ import {
type TerminalOutputHistoryPreview,
} from "./terminalOutputHistory";
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
import { matchesPlainCtrlVChord } from "./win32ClipboardHistoryPaste";
import {
isMacCommandPeriodInterruptChord,
shouldUseUrgentTerminalInterrupt,
@@ -2497,6 +2498,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
}
// Windows clipboard history (Win+V) delivers the picked item as a plain
// Ctrl+V keydown that escapes the Edit > Paste menu accelerator (#3582).
// Left to xterm's default handling it is encoded as bare \x16, so the
// shell shows ^V instead of pasting. Claim the chord on Windows only —
// on Linux plain Ctrl+V is terminal input (e.g. Readline's quoted-insert);
// the PC paste binding there is Ctrl+Shift+V. Route the claimed chord
// through the shared clipboard paste path, which reads the live system
// clipboard via the main-process bridge.
if (platform === "win32" && matchesPlainCtrlVChord(e)) {
e.preventDefault();
e.stopPropagation();
// Auto-repeat keydowns of a held Ctrl+V must be consumed too: left
// unclaimed, each repeat reaches xterm and is encoded as bare \x16
// (^V in the shell). Paste only on the initial keydown.
if (!e.repeat) void ctx.terminalContextActionsRef?.current?.onPaste?.();
return false;
}
// Sogou/macOS CJK punctuation: keydown still reports ASCII "," while the
// full-width "," arrives on insertText. Returning false (without
// preventDefault) stops xterm/Kitty from sending the half-width key so the
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isPlainCtrlVPasteChord,
matchesPlainCtrlVChord,
} from "./win32ClipboardHistoryPaste";
const keyboardEvent = (
overrides: Partial<KeyboardEvent> = {},
): KeyboardEvent => ({
key: "v",
code: "KeyV",
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
repeat: false,
...overrides,
}) as KeyboardEvent;
test("plain Ctrl+V from the Windows clipboard history picker is claimed", () => {
assert.equal(isPlainCtrlVPasteChord(keyboardEvent()), true);
});
test("plain Ctrl+V follows the physical V key on non-Latin layouts", () => {
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ key: "м" })), true);
});
test("Ctrl+Shift+V stays the configured paste binding", () => {
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ key: "V", shiftKey: true })), false);
});
test("chords with extra modifiers are not clipboard history pastes", () => {
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ altKey: true })), false);
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ metaKey: true })), false);
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ ctrlKey: false })), false);
});
test("auto-repeat keydowns are not claimed to avoid paste spam", () => {
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ repeat: true })), false);
});
test("auto-repeat keydowns still match the chord so the keydown claim can consume them", () => {
assert.equal(matchesPlainCtrlVChord(keyboardEvent({ repeat: true })), true);
assert.equal(matchesPlainCtrlVChord(keyboardEvent({ shiftKey: true, repeat: true })), false);
});
test("other Ctrl chords are untouched", () => {
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ key: "c", code: "KeyC" })), false);
});
@@ -0,0 +1,35 @@
type PasteChordKeyEvent = Pick<
KeyboardEvent,
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey" | "repeat"
>;
function isPhysicalVKey(e: PasteChordKeyEvent): boolean {
return e.key?.toLowerCase() === "v" || e.code === "KeyV";
}
/**
* Plain Ctrl+V as injected by the Windows clipboard history picker (Win+V).
*
* The picker delivers the chosen item by synthesizing a Ctrl+V keydown that
* must not rely on the Electron Edit > Paste menu accelerator (#3582): when
* the synthesized chord reaches the page unclaimed, xterm encodes it as bare
* \x16 and the shell shows ^V instead of pasting. Netcatty claims the chord
* and routes it through the shared clipboard paste path (which reads the live
* system clipboard via the main-process bridge) instead.
*/
/**
* Physical plain Ctrl+V chord, ignoring auto-repeat. Used by the keydown
* claim so held-key repeats of a clipboard-history paste are consumed instead
* of escaping to xterm (which would encode each repeat as bare \x16).
*/
export function matchesPlainCtrlVChord(e: PasteChordKeyEvent): boolean {
return Boolean(e.ctrlKey)
&& !e.shiftKey
&& !e.altKey
&& !e.metaKey
&& isPhysicalVKey(e);
}
export function isPlainCtrlVPasteChord(e: PasteChordKeyEvent): boolean {
return !e.repeat && matchesPlainCtrlVChord(e);
}
@@ -111,3 +111,22 @@ test("bypassed context paste at a password prompt is marked sourceSensitive (#34
{ options: { lineDelayMs: 250, sourceSensitive: true } },
]);
});
test("context paste prefers the main-process clipboard read (#3582)", async () => {
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./hooks/useTerminalContextActions.ts", import.meta.url), "utf8");
// Windows clipboard history (Win+V) writes the picked item while the window
// is unfocused, so the renderer's clipboard view can still be empty when
// the synthetic paste arrives. The shared onPaste path must read the live
// system clipboard through the main-process bridge first.
const onPasteIdx = source.indexOf("const onPaste = useCallback");
assert.ok(onPasteIdx >= 0);
const onPaste = source.slice(onPasteIdx, onPasteIdx + 5000);
assert.match(onPaste, /bridge\?\.readClipboardText\?\.\(\)/u);
assert.match(onPaste, /navigator\.clipboard\.readText\(\)/u);
assert.ok(
onPaste.indexOf("bridge?.readClipboardText?.()")
< onPaste.indexOf("navigator.clipboard.readText()"),
);
});
+6 -4
View File
@@ -897,11 +897,13 @@ function createBridgeRegistrar(context) {
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
ipcMain.handle("netcatty:clipboard:readText", async () => {
try {
return clipboard?.readText?.() || "";
} catch {
return "";
// Reject on failure so renderer callers can fall back to their own
// clipboard paths; only a successful read returns a string (possibly ""
// for a genuinely empty clipboard).
if (!clipboard || typeof clipboard.readText !== "function") {
throw new Error("Clipboard API unavailable");
}
return clipboard.readText();
});
ipcMain.handle("netcatty:clipboard:writeText", async (_event, text) => {