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