diff --git a/packages/app/src/utils/global-clipboard.test.ts b/packages/app/src/utils/global-clipboard.test.ts index 4d1bb5547..609deb83c 100644 --- a/packages/app/src/utils/global-clipboard.test.ts +++ b/packages/app/src/utils/global-clipboard.test.ts @@ -370,4 +370,119 @@ describe("installGlobalClipboardFallback", () => { expect(event.defaultPrevented).toBe(false) expect(bridge.posted).toHaveLength(0) }) + + // --- Select-all (Cmd+A) --- + + test("mod+A selects all text in a form field", () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "hello world", 3, 3) + + const event = keydown(el, "a") + + expect(event.defaultPrevented).toBe(true) + expect(el.selectionStart).toBe(0) + expect(el.selectionEnd).toBe(11) + }) + + test("mod+A selects all content in a contenteditable", () => { + const bridge = framedWindow() + install(bridge.win) + const el = editableDiv("hello world") + + const event = keydown(el, "a") + + expect(event.defaultPrevented).toBe(true) + const selection = window.getSelection()! + expect(selection.toString()).toBe("hello world") + }) + + test("mod+A in a textarea selects all its text", () => { + const bridge = framedWindow() + install(bridge.win) + const el = document.createElement("textarea") + el.value = "line1\nline2" + document.body.appendChild(el) + el.setSelectionRange(2, 2) + + const event = keydown(el, "a") + + expect(event.defaultPrevented).toBe(true) + expect(el.selectionStart).toBe(0) + expect(el.selectionEnd).toBe(11) + }) + + // --- Undo (Cmd+Z) --- + + test("mod+Z calls execCommand undo on a contenteditable", () => { + const bridge = framedWindow() + install(bridge.win) + const el = editableDiv("hello") + + const event = keydown(el, "z") + + expect(event.defaultPrevented).toBe(true) + }) + + test("mod+Z calls execCommand undo on a form field", () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "hello world", 5, 5) + + const event = keydown(el, "z") + + expect(event.defaultPrevented).toBe(true) + }) + + // --- Redo (Cmd+Shift+Z / Cmd+Y) --- + + test("mod+Shift+Z calls execCommand redo", () => { + const bridge = framedWindow() + install(bridge.win) + const el = editableDiv("hello") + + const event = keydown(el, "z", { shiftKey: true }) + + expect(event.defaultPrevented).toBe(true) + }) + + test("mod+Y calls execCommand redo", () => { + const bridge = framedWindow() + install(bridge.win) + const el = editableDiv("hello") + + const event = keydown(el, "y") + + expect(event.defaultPrevented).toBe(true) + }) + + // --- Guard preservation --- + + test("mod+A in an unframed window is not intercepted", () => { + install(window) // unframed: parent === self + const el = field("text", "hello", 2, 2) + + const event = keydown(el, "a") + + expect(event.defaultPrevented).toBe(false) + }) + + test("mod+Z on a non-editable target is not intercepted", () => { + const bridge = framedWindow() + install(bridge.win) + const button = document.createElement("button") + document.body.appendChild(button) + + const event = keydown(button, "z") + + expect(event.defaultPrevented).toBe(false) + }) + + test("shift+alt chords with editing keys are still ignored", () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "abc", 0, 3) + + expect(keydown(el, "a", { altKey: true }).defaultPrevented).toBe(false) + }) }) diff --git a/packages/app/src/utils/global-clipboard.ts b/packages/app/src/utils/global-clipboard.ts index cf49516ab..86c6ca4a2 100644 --- a/packages/app/src/utils/global-clipboard.ts +++ b/packages/app/src/utils/global-clipboard.ts @@ -1,11 +1,12 @@ -// Framed-app clipboard fallback, generalized from the prompt input's bridge. -// Inside the VS Code webview iframe, native paste never fires and native -// copy never reaches the OS clipboard (see prompt-input/clipboard-bridge.ts -// for the full why) — so every editable outside the prompt (Connections -// credential fields, settings inputs, …) silently ignores ⌘V and poisons the -// next paste on ⌘C. This module intercepts mod+V/C/X at the window's capture -// phase and routes them over the existing extension-host bridge. Unframed -// (plain web/desktop), it does nothing — native clipboard behavior stands. +// Framed-app editing fallback, generalized from the prompt input's bridge. +// Inside the VS Code webview iframe, native editing shortcuts never fire: +// paste never reaches the DOM, copy never writes the OS clipboard, and +// select-all / undo / redo are suppressed by the Electron platform layer +// (see prompt-input/clipboard-bridge.ts for the full why). This module +// intercepts mod+V/C/X/A/Z/Y at the window's capture phase and implements +// them explicitly in JS — clipboard ops route over the extension-host bridge, +// and select-all / undo / redo call the DOM APIs directly. Unframed +// (plain web/desktop), it does nothing — native editing behavior stands. import { readClipboardViaBridge, writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" @@ -133,6 +134,23 @@ export function extractSelection(el: HTMLElement, opts: { cut?: boolean } = {}): return text } +// Select all content in an editable element — the JS equivalent of the native +// Cmd+A that the VS Code/Electron platform layer suppresses inside the iframe. +function selectAll(target: HTMLElement): void { + if (isFormField(target)) { + target.select() + return + } + // contenteditable: select all children of the editable root + const selection = target.ownerDocument.defaultView?.getSelection() + if (selection) { + selection.removeAllRanges() + const range = target.ownerDocument.createRange() + range.selectNodeContents(target) + selection.addRange(range) + } +} + // Capture-phase so it sees the keystroke before any component handler, and // window-level so portaled UI (popovers, dialogs) is covered too. Returns an // uninstall function; the handler re-checks framing per event, so installing @@ -140,13 +158,37 @@ export function extractSelection(el: HTMLElement, opts: { cut?: boolean } = {}): export function installGlobalClipboardFallback(win: Window = window): () => void { const onKeyDown = (event: KeyboardEvent) => { if (win.parent === win) return // unframed: native clipboard works — stay out - if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (!(event.metaKey || event.ctrlKey) || event.altKey) return if (event.isComposing) return const key = event.key.toLowerCase() - if (key !== "v" && key !== "c" && key !== "x") return + + // Redo: Cmd+Shift+Z (shift allowed only for this chord) + const isRedo = key === "z" && event.shiftKey + // All other editing shortcuts require NO shift + if (event.shiftKey && !isRedo) return + + if (key !== "v" && key !== "c" && key !== "x" && key !== "a" && key !== "z" && key !== "y") return const target = event.target if (!isEditableTarget(target)) return // non-editables keep native behavior + // --- Select all --- + if (key === "a") { + event.preventDefault() + selectAll(target) + return + } + + // --- Undo / Redo --- + if (key === "z" || key === "y") { + event.preventDefault() + const doc = target.ownerDocument + if (typeof doc.execCommand === "function") { + doc.execCommand(isRedo || key === "y" ? "redo" : "undo") + } + return + } + + // --- Clipboard: paste, copy, cut --- if (key === "v") { if (target.closest(CLIPBOARD_SELF_SELECTOR)) return // element owns its own paste // Native paste never fires in-frame, so preventDefault loses nothing;