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; +}