From d837cb1acb71f9e8f446364b9982e6c044161831 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:53:31 +0800 Subject: [PATCH] fix(console): Ctrl+C copies selected output in consoles and terminals (#381) On Windows and Linux, Ctrl+C in the Run/Debug consoles and other DOM output views did nothing because no handler copied the selection. Each output view (Run and Debug consoles, Build output and message detail, Git console, database output, Docker logs) now copies its own selection on Ctrl+C / Ctrl+Insert (Cmd+C on macOS) through a shared helper, leaving views with their own copy handling (database grid, Problems) untouched. In terminals, Ctrl+C copies a selection on Windows and Linux and otherwise interrupts; on macOS it always interrupts. The console menu shows the host's copy shortcut. Not verified on a real Windows machine. Fixes #372 --- .../app/__tests__/consoleCopy.test.ts | 203 ++++++++++++++++++ src/features/build/BuildView.tsx | 10 +- src/features/database/Grid.test.tsx | 19 +- src/features/database/ResultsView.tsx | 10 +- src/features/debugger/ui/DebugToolWindow.tsx | 2 + src/features/run/ConsoleView.tsx | 7 +- src/features/services/docker.tsx | 10 +- src/features/terminal/copyKeys.test.ts | 66 ++++++ src/features/terminal/embedded.test.ts | 103 +++++++++ src/features/terminal/embedded.ts | 8 + src/features/terminal/keys.ts | 16 +- src/features/terminal/terminal.test.ts | 22 +- src/features/vcs/GitLog.tsx | 3 + src/ui/selectableText.ts | 40 +++- 14 files changed, 506 insertions(+), 13 deletions(-) create mode 100644 src/features/app/__tests__/consoleCopy.test.ts create mode 100644 src/features/terminal/copyKeys.test.ts create mode 100644 src/features/terminal/embedded.test.ts diff --git a/src/features/app/__tests__/consoleCopy.test.ts b/src/features/app/__tests__/consoleCopy.test.ts new file mode 100644 index 000000000..3f449702f --- /dev/null +++ b/src/features/app/__tests__/consoleCopy.test.ts @@ -0,0 +1,203 @@ +import { createElement } from "react"; +import { cleanup as cleanupViews, render, screen } from "@testing-library/react"; +import { BuildView } from "@/features/build/BuildView"; +import { useBuildRun } from "@/features/build/store"; +import { ConsoleModel } from "@/features/run/console"; +import { ConsoleView } from "@/features/run/ConsoleView"; +import { ResultsView } from "@/features/database/ResultsView"; +import { useDatabase } from "@/features/database/store"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { appActions } from "../appActions"; +import { registerActions } from "@/platform/actions"; +import { installKeyboardDispatcher, PRESETS, useKeymap } from "@/platform/keymap"; +import { copySelectionOnKeyDown } from "@/ui/selectableText"; +import { installTerminalKeys } from "@/features/terminal/keys"; + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 20, + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 20, key: index })), + scrollToIndex: () => {}, + measureElement: () => {}, + measure: () => {}, + }), +})); + +const writeText = vi.fn().mockResolvedValue(undefined); +const exec = vi.fn(); +let cleanup: Array<() => void> = []; + +beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + document.execCommand = exec; + useKeymap.setState({ preset: "intellij-windows", overrides: {} }); + cleanup = [registerActions(appActions()), installTerminalKeys(), installKeyboardDispatcher()]; +}); + +afterEach(() => { + cleanupViews(); + cleanup.reverse().forEach((dispose) => dispose()); + window.getSelection()?.removeAllRanges(); + document.body.replaceChildren(); + vi.clearAllMocks(); +}); + +function selectOutput(name: string, role = "log") { + const output = document.createElement("div"); + output.role = role; + output.tabIndex = 0; + output.setAttribute("aria-label", name); + output.textContent = "prefix selected application output suffix"; + document.body.append(output); + output.addEventListener("keydown", (event) => + copySelectionOnKeyDown({ + key: event.key, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + nativeEvent: event, + target: event.target!, + currentTarget: output, + defaultPrevented: event.defaultPrevented, + preventDefault: () => event.preventDefault(), + stopPropagation: () => event.stopPropagation(), + }), + ); + output.focus(); + const range = document.createRange(); + range.setStart(output.firstChild!, 7); + range.setEnd(output.firstChild!, 34); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + return output; +} + +function copy(target: HTMLElement, init: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { + key: "c", + code: "KeyC", + ctrlKey: true, + bubbles: true, + cancelable: true, + ...init, + }); + target.dispatchEvent(event); + return event; +} + +describe("view-local console copy with the Windows keymap (#372)", () => { + it.each(["c", "Insert"])("copies selected Run console output with Ctrl+%s", (key) => { + const model = new ConsoleModel(); + model.append({ kind: "stdout", text: "selected application output" }); + render( + createElement(ConsoleView, { + model, + version: 1, + softWrap: false, + follow: false, + onFollowChange: () => {}, + cwd: "/", + }), + ); + const output = screen.getByRole("log"); + const range = document.createRange(); + range.selectNodeContents(screen.getByText("selected application output")); + window.getSelection()!.addRange(range); + expect(copy(output, { key, code: key === "c" ? "KeyC" : "Insert" }).defaultPrevented).toBe(true); + expect(writeText).toHaveBeenCalledWith("selected application output"); + }); + + it.each([false, true])("copies build output and selected message detail (detail=%s)", (detail) => { + useBuildRun.setState({ + status: "failed", + lines: [{ kind: "err", text: "Compiler output" }], + messages: [{ severity: "error", code: null, text: "Compiler detail", path: null, range: null }], + selected: detail ? "build:msg:0" : null, + }); + render(createElement(BuildView)); + const text = screen.getByText(detail ? "Compiler detail" : "Compiler output", { selector: "div" }); + const range = document.createRange(); + range.selectNodeContents(text); + window.getSelection()!.addRange(range); + expect(copy(screen.getByRole(detail ? "document" : "log")).defaultPrevented).toBe(true); + expect(writeText).toHaveBeenCalledWith(detail ? "Compiler detail" : "Compiler output"); + }); + + it("copies selected database results output", () => { + useDatabase.setState({ results: [], output: [{ id: 1, time: 0, kind: "info", text: "Query completed" }] }); + render(createElement(ResultsView)); + const range = document.createRange(); + range.selectNodeContents(screen.getByText("Query completed")); + window.getSelection()!.addRange(range); + expect(copy(screen.getByRole("log")).defaultPrevented).toBe(true); + expect(writeText).toHaveBeenCalledWith("Query completed"); + }); + + it.each(["intellij-windows", "eclipse-windows", "vscode-windows"])("copies locally in %s", (preset) => { + useKeymap.setState({ preset }); + copy(selectOutput("Build message", "document")); + expect(writeText).toHaveBeenCalledWith("selected application output"); + }); + + it("leaves editable selections to their native copy handler", () => { + const output = selectOutput("old output"); + const input = document.createElement("input"); + output.append(input); + input.focus(); + copy(input); + expect(writeText).not.toHaveBeenCalled(); + expect(exec).not.toHaveBeenCalled(); + }); + + it("does not copy a selection belonging to another output view", () => { + const output = selectOutput("first"); + selectOutput("second"); + expect(copy(output).defaultPrevented).toBe(false); + expect(writeText).not.toHaveBeenCalled(); + }); + + it.each([{ altKey: true }, { shiftKey: true }, { isComposing: true }])( + "leaves modified and composing keys alone (%j)", + (init) => { + expect(copy(selectOutput("Run"), init).defaultPrevented).toBe(false); + expect(writeText).not.toHaveBeenCalled(); + }, + ); + + it("does not write an empty selection", () => { + const output = selectOutput("Run"); + window.getSelection()!.removeAllRanges(); + copy(output); + expect(writeText).not.toHaveBeenCalled(); + }); + + it.each([false, true])("leaves xterm Ctrl+C to terminal handling (Shift=%s)", (shiftKey) => { + const terminal = document.createElement("div"); + terminal.className = "xterm"; + const input = document.createElement("textarea"); + terminal.append(input); + document.body.append(terminal); + input.focus(); + expect(copy(input, { shiftKey }).defaultPrevented).toBe(false); + expect(exec).not.toHaveBeenCalled(); + expect(writeText).not.toHaveBeenCalled(); + }); + + it("supports Ctrl+Insert", () => { + expect(copy(selectOutput("Run"), { key: "Insert", code: "Insert" }).defaultPrevented).toBe(true); + expect(writeText).toHaveBeenCalledWith("selected application output"); + }); + + it.each(Object.keys(PRESETS))("%s does not pre-empt per-view Ctrl+C", (preset) => { + useKeymap.setState({ preset }); + expect(PRESETS[preset]?.["edit.copy"]).toBeUndefined(); + const view = document.createElement("div"); + document.body.append(view); + const handler = vi.fn(); + view.addEventListener("keydown", handler); + expect(copy(view).defaultPrevented).toBe(false); + expect(handler).toHaveBeenCalledOnce(); + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/build/BuildView.tsx b/src/features/build/BuildView.tsx index cec14c620..3d726c8a5 100644 --- a/src/features/build/BuildView.tsx +++ b/src/features/build/BuildView.tsx @@ -4,6 +4,7 @@ * shows its full text on the right instead; Enter / double-click / F4 opens * its source (as IDEA: selection shows, navigation jumps). */ +import { copySelectionOnKeyDown } from "@/ui/selectableText"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -219,6 +220,7 @@ function BuildConsole() { className={styles.console} tabIndex={0} role="log" + onKeyDown={copySelectionOnKeyDown} aria-label={tx("Build output")} onScroll={(e) => { const el = e.currentTarget; @@ -253,7 +255,13 @@ function MessageDetail({ message }: { message: BuildMessage }) { }` : null; return ( -
+
{location && (
-
+
{lines.map((l: OutputLine) => (
[{time(l.time)}]{" "} diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 681c3ea43..75c668a41 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -1,4 +1,5 @@ /** Debug tool window: toolbar, Threads & Variables (frames + variables), Console. */ +import { copySelectionOnKeyDown } from "@/ui/selectableText"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { executeAction, getAction } from "@/platform/actions"; @@ -621,6 +622,7 @@ function ConsoleView({ session }: { session: DebugSession }) { showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]); }} role="log" + onKeyDown={copySelectionOnKeyDown} aria-label={tx("Debug console")} tabIndex={0} > diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 2cfc832ba..752f1ed5b 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -1,3 +1,4 @@ +import { copySelectionOnKeyDown } from "@/ui/selectableText"; import { memo, useEffect, @@ -20,6 +21,7 @@ import { findOutputLinks, type OutputLink } from "./links"; import { openTarget, openUrl } from "./navigate"; import { segmentLine } from "./segments"; import styles from "./Run.module.css"; +import { hostBinding, formatBinding } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; const ROW = 20; @@ -203,7 +205,7 @@ export function ConsoleView({ { label: "Copy", icon: "copy", - shortcut: "⌘C", + shortcut: formatBinding(hostBinding("Meta+C")), disabled: !selection, run: () => void navigator.clipboard?.writeText(selection).catch(() => undefined), }, @@ -216,7 +218,8 @@ export function ConsoleView({ ]); }; - const onKeyDown = (e: KeyboardEvent) => { + const onKeyDown = (e: KeyboardEvent) => { + if (copySelectionOnKeyDown(e)) return; if (!input || e.nativeEvent.isComposing) return; if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); diff --git a/src/features/services/docker.tsx b/src/features/services/docker.tsx index 0b21841fc..3fc5255cc 100644 --- a/src/features/services/docker.tsx +++ b/src/features/services/docker.tsx @@ -3,6 +3,7 @@ * grouped by Compose project, images, volumes, networks) and one per registry; * log / inspect of the selection in the detail pane. */ +import { copySelectionOnKeyDown } from "@/ui/selectableText"; import { useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { Channel } from "@tauri-apps/api/core"; @@ -355,7 +356,9 @@ function LogStream(props: {
{ const el = e.currentTarget; @@ -391,7 +394,12 @@ function InspectView({ conn, object, id }: { conn: string; object: DockerObject; }, [conn, object, id]); return (
-
+      
         {error ?? text ?? tx("Loading…")}
       
diff --git a/src/features/terminal/copyKeys.test.ts b/src/features/terminal/copyKeys.test.ts new file mode 100644 index 000000000..3a21b1116 --- /dev/null +++ b/src/features/terminal/copyKeys.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { installTerminalKeys } from "./keys"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import { registerAction } from "@/platform/actions"; +import { usePlatform } from "@/platform/platformInfo"; + +const mock = vi.hoisted(() => ({ + session: { hasSelection: true, send: vi.fn(), copySelection: vi.fn() }, + settings: { terminal: { overrideIdeShortcuts: true } }, +})); +vi.mock("./loader", () => ({ terminalRuntime: () => ({ sessionFromElement: () => mock.session }) })); +vi.mock("@/platform/workflowSettings", () => ({ workflowSettings: () => mock.settings })); + +beforeEach(() => usePlatform.setState({ os: "windows" })); +afterEach(() => usePlatform.setState({ os: "macos" })); +afterEach(() => vi.clearAllMocks()); + +describe("terminal copy capture", () => { + it.each([true, false])("copies before xterm and IDE dispatch with override shortcuts %s", (override) => { + mock.settings.terminal.overrideIdeShortcuts = override; + mock.session.hasSelection = true; + useKeymap.setState({ preset: "intellij-windows", overrides: {} }); + const globalCopy = vi.fn(); + const removeAction = registerAction({ id: "edit.copy", title: "Copy", run: globalCopy }); + const removeKeys = installTerminalKeys(); + const removeDispatcher = installKeyboardDispatcher(); + const downstream = vi.fn(); + window.addEventListener("keydown", downstream); + try { + for (const shiftKey of [false, true]) { + const event = new KeyboardEvent("keydown", { + key: "c", + code: "KeyC", + ctrlKey: true, + shiftKey, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } + expect(mock.session.copySelection).toHaveBeenCalledTimes(2); + expect(mock.session.send).not.toHaveBeenCalled(); + expect(globalCopy).not.toHaveBeenCalled(); + expect(downstream).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("keydown", downstream); + removeDispatcher(); + removeKeys(); + removeAction(); + } + }); + + it("sends SIGINT when there is no selection", () => { + mock.settings.terminal.overrideIdeShortcuts = true; + mock.session.hasSelection = false; + const dispose = installTerminalKeys(); + try { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", code: "KeyC", ctrlKey: true })); + expect(mock.session.send).toHaveBeenCalledWith("\x03"); + expect(mock.session.copySelection).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }); +}); diff --git a/src/features/terminal/embedded.test.ts b/src/features/terminal/embedded.test.ts new file mode 100644 index 000000000..fb8446fbc --- /dev/null +++ b/src/features/terminal/embedded.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createEmbeddedTerminal } from "./embedded"; +import { installTerminalKeys } from "./keys"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import { registerAction } from "@/platform/actions"; +import { usePlatform } from "@/platform/platformInfo"; + +const terminal = vi.hoisted(() => ({ + selection: "selected run output", + handler: (_e: KeyboardEvent): boolean => true, +})); +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + cols = 80; + rows = 24; + options = {}; + hasSelection() { + return !!terminal.selection; + } + getSelection() { + return terminal.selection; + } + attachCustomKeyEventHandler(handler: typeof terminal.handler) { + terminal.handler = handler; + } + loadAddon() {} + open() {} + dispose() {} + onData() { + return { dispose() {} }; + } + onBinary() { + return { dispose() {} }; + } + onResize() { + return { dispose() {} }; + } + registerLinkProvider() { + return { dispose() {} }; + } + }, +})); +vi.mock("@xterm/addon-fit", () => ({ FitAddon: class {} })); +vi.mock("./session", () => ({ fontFor: () => ({}), LINK_HANDLER: {}, openExternal: vi.fn(), openFileLink: vi.fn() })); +vi.mock("./theme", () => ({ readTerminalTheme: () => ({}), remeasureWhenFontsLoad: vi.fn(), MIN_CONTRAST_RATIO: 1 })); +vi.mock("./fit", () => ({ + fitScheduler: () => ({ schedule() {}, cancel() {} }), + settledResize: () => ({ resized() {}, cancel() {} }), +})); + +beforeEach(() => usePlatform.setState({ os: "windows" })); +afterEach(() => usePlatform.setState({ os: "macos" })); +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +it("copies embedded Run terminal selections before xterm can send Ctrl+C", () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + const host = document.createElement("div"); + host.className = "xterm"; + document.body.append(host); + const term = createEmbeddedTerminal(host, { onData: vi.fn(), onResize: vi.fn() }); + const globalCopy = vi.fn(); + const disposeAction = registerAction({ id: "edit.copy", title: "Copy", run: globalCopy }); + useKeymap.setState({ preset: "intellij-windows", overrides: {} }); + const disposeKeys = installTerminalKeys(); + const disposeDispatcher = installKeyboardDispatcher(); + const shell = vi.fn(); + host.addEventListener("keydown", (event) => { + if (terminal.handler(event)) shell(); + }); + try { + terminal.selection = "selected run output"; + for (const modifiers of [{ ctrlKey: true }, { ctrlKey: true, shiftKey: true }, { metaKey: true }]) { + host.dispatchEvent( + new KeyboardEvent("keydown", { + key: "c", + code: "KeyC", + bubbles: true, + cancelable: true, + ...modifiers, + }), + ); + } + expect(writeText).toHaveBeenCalledTimes(3); + expect(writeText).toHaveBeenCalledWith("selected run output"); + expect(shell).not.toHaveBeenCalled(); + expect(globalCopy).not.toHaveBeenCalled(); + terminal.handler(new KeyboardEvent("keyup", { key: "c", code: "KeyC", ctrlKey: true })); + expect(writeText).toHaveBeenCalledTimes(3); + terminal.selection = ""; + host.dispatchEvent(new KeyboardEvent("keydown", { key: "c", code: "KeyC", ctrlKey: true, bubbles: true })); + expect(shell).toHaveBeenCalledOnce(); + expect(globalCopy).not.toHaveBeenCalled(); + } finally { + disposeDispatcher(); + disposeKeys(); + disposeAction(); + term.dispose(); + } +}); diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index 12c6673ec..4a1b5cad4 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -8,6 +8,8 @@ import { Terminal, type IDisposable } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import type { EmbeddedTerminal, EmbeddedTerminalOptions } from "@/workbench/contributions"; +import { isImeKey } from "@/platform/ime"; +import { terminalKeyAction } from "./keys"; import { useProject } from "@/platform/project"; import { workflowSettings } from "@/platform/workflowSettings"; import { fitScheduler, settledResize } from "./fit"; @@ -47,6 +49,12 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal convertEol: false, theme: readTerminalTheme(), }); + term.attachCustomKeyEventHandler((e) => { + if (isImeKey(e) || terminalKeyAction(e, term.hasSelection()).kind !== "copy") return true; + if (e.type === "keydown") void navigator.clipboard?.writeText(term.getSelection()).catch(() => undefined); + e.preventDefault(); + return false; + }); const fit = new FitAddon(); term.loadAddon(fit); live.add(term); diff --git a/src/features/terminal/keys.ts b/src/features/terminal/keys.ts index 39ec66dc6..3a6a0dbe3 100644 --- a/src/features/terminal/keys.ts +++ b/src/features/terminal/keys.ts @@ -3,7 +3,7 @@ * (IDEA's "Override IDE shortcuts"): Ctrl+letter goes to the shell (Ctrl+R * reverse search, Ctrl+D EOF…), plus the macOS conveniences ⌘C / ⌘V / ⌘K / * ⌘W, ⌘F (find) and ⌘/⌥ arrow navigation, and Ctrl+Shift+C / V (copy / paste - * on Windows and Linux, where Ctrl+C must reach the shell). + * on Windows and Linux, where Ctrl+C also copies a selection and otherwise interrupts the shell). * * With "Override IDE shortcuts" on, a Ctrl or Escape stroke still reaches the IDE when the * keymap binds it to one of IDEA's terminal-allowed actions (Find Action, Go to @@ -15,7 +15,7 @@ * there is no terminal, so there is nothing to handle. */ import { executeAction } from "@/platform/actions"; -import { effectiveBindings, letterOf, strokeOf } from "@/platform/keymap"; +import { effectiveBindings, isMacHost, letterOf, registerKeyClaim, strokeOf } from "@/platform/keymap"; import { workflowSettings } from "@/platform/workflowSettings"; import { terminalRuntime } from "./loader"; import { isImeKey } from "@/platform/ime"; @@ -54,6 +54,7 @@ export function terminalKeyAction(e: KeyLike, hasSelection: boolean): TerminalKe return { kind: "pass" }; } const letter = letterOf(e); + if (ctrl && !meta && !alt && letter === "C" && hasSelection && !isMacHost()) return { kind: "copy" }; if (ctrl && shift && !meta && !alt) { if (letter === "C") return hasSelection ? { kind: "copy" } : { kind: "pass" }; if (letter === "V") return { kind: "paste" }; @@ -127,6 +128,12 @@ export function isAllowedIdeShortcut(stroke: string, bindings: ReadonlyArray void { + // Embedded terminals and sessions with shortcut overrides disabled still own copy/interrupt. + const releaseCopyClaim = registerKeyClaim( + (e, stroke) => + !!(e.target as Element | null)?.closest?.(".xterm") && + ["Ctrl+C", "Ctrl+Insert", "Ctrl+Shift+C", "Meta+C"].includes(stroke), + ); const onKeyDown = (e: KeyboardEvent) => { const rt = terminalRuntime(); if (isImeKey(e) || !rt) return; @@ -183,5 +190,8 @@ export function installTerminalKeys(target: Window = window): () => void { e.stopImmediatePropagation(); }; target.addEventListener("keydown", onKeyDown, true); - return () => target.removeEventListener("keydown", onKeyDown, true); + return () => { + releaseCopyClaim(); + target.removeEventListener("keydown", onKeyDown, true); + }; } diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index fa16c59eb..59f2297f9 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -15,6 +15,7 @@ import { type TerminalTab, } from "./store"; import type { SessionSnapshot } from "./session"; +import { usePlatform } from "@/platform/platformInfo"; import { buildTheme, mix, parseColor, terminalFont, toHex } from "./theme"; describe("theme", () => { @@ -110,7 +111,7 @@ describe("terminal keys", () => { kind: "send", data: "\x04", }); - expect(terminalKeyAction(key({ key: "c", code: "KeyC", ctrlKey: true }), true)).toEqual({ + expect(terminalKeyAction(key({ key: "c", code: "KeyC", ctrlKey: true }), false)).toEqual({ kind: "send", data: "\x03", }); @@ -149,6 +150,25 @@ describe("terminal keys", () => { }); }); + it.each([ + { key: "c", code: "KeyC", ctrlKey: true }, + { key: "c", code: "KeyI", ctrlKey: true }, + { key: "C", code: "KeyC", ctrlKey: true, shiftKey: true }, + { key: "c", code: "KeyC", metaKey: true }, + ])("copies terminal selections with %o", (stroke) => { + usePlatform.setState({ os: "windows" }); + try { + expect(terminalKeyAction(key(stroke), true)).toEqual({ kind: "copy" }); + } finally { + usePlatform.setState({ os: "macos" }); + } + }); + + it("keeps Ctrl+C as interrupt on macOS even with a selection", () => { + usePlatform.setState({ os: "macos" }); + expect(terminalKeyAction(key({ key: "c", code: "KeyC", ctrlKey: true }), true)).not.toEqual({ kind: "copy" }); + }); + it("implements macOS conveniences", () => { expect(terminalKeyAction(key({ key: "c", metaKey: true }), true)).toEqual({ kind: "copy" }); expect(terminalKeyAction(key({ key: "c", metaKey: true }), false)).toEqual({ kind: "pass" }); diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 79fb170dd..9fac5b34f 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -1,3 +1,4 @@ +import { copySelectionOnKeyDown } from "@/ui/selectableText"; import { branchesByRepo, commitKey, commitRepo, graphCommit, type RepoCommit } from "./multiRoot"; /** Git tool window: Log (graph, filters, commit details) and Console tabs. */ import { IssueText } from "./issueLinks"; @@ -1486,7 +1487,9 @@ function ConsoleView() {
{ const el = e.currentTarget; diff --git a/src/ui/selectableText.ts b/src/ui/selectableText.ts index 3d8c53ad3..d285d3bfd 100644 --- a/src/ui/selectableText.ts +++ b/src/ui/selectableText.ts @@ -4,7 +4,8 @@ * (chrome); content opts back in with `selectableText`. A right-click on a * selection, or anywhere in such content, offers Copy (`copyMenuFor`). */ -import { formatBinding } from "@/platform/keymap"; +import type { KeyboardEvent } from "react"; +import { hasPrimaryModifier, formatBinding } from "@/platform/keymap"; import type { MenuItem } from "./Menu"; import styles from "./SelectableText.module.css"; @@ -52,3 +53,40 @@ export async function copyText(text: string): Promise { } document.execCommand("copy"); } + +/** Copy a DOM output selection locally, leaving editable children and other views alone. */ +export function copySelectionOnKeyDown( + e: Pick< + KeyboardEvent, + | "key" + | "ctrlKey" + | "metaKey" + | "altKey" + | "shiftKey" + | "defaultPrevented" + | "nativeEvent" + | "target" + | "currentTarget" + | "preventDefault" + | "stopPropagation" + >, +): boolean { + if (e.defaultPrevented || e.nativeEvent.isComposing || e.altKey || e.shiftKey) return false; + const copy = + (e.key.toLowerCase() === "c" && (e.ctrlKey || hasPrimaryModifier(e))) || + (e.key === "Insert" && e.ctrlKey && !e.metaKey); + if (!copy || (e.target instanceof Element && e.target.closest(EDITABLE))) return false; + const selection = window.getSelection(); + if ( + !selection || + selection.isCollapsed || + !selection.toString() || + !e.currentTarget.contains(selection.anchorNode) || + !e.currentTarget.contains(selection.focusNode) + ) + return false; + e.preventDefault(); + e.stopPropagation(); + void copyText(selection.toString()); + return true; +}