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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 17:53:31 +08:00
1 джерело 932ac379bc
коміт d837cb1acb
14 files changed
+506 -13

No files matched your search

+203
Переглянути файл
@@ -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();
});
});
+9 -1
Переглянути файл
@@ -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 (
<div className={styles.detail} tabIndex={0} role="document" aria-label={tx("Build message")}>
<div
className={styles.detail}
tabIndex={0}
onKeyDown={copySelectionOnKeyDown}
role="document"
aria-label={tx("Build message")}
>
{location && (
<button type="button" className={styles.detailLocation} onClick={() => navigateToMessage(message, true)}>
{location}
+16 -3
Переглянути файл
@@ -1,5 +1,7 @@
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { registerActions } from "@/platform/actions";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Grid } from "./Grid";
vi.mock("@tanstack/react-virtual", () => ({
@@ -11,7 +13,17 @@ vi.mock("@tanstack/react-virtual", () => ({
}));
describe("database grid", () => {
it("drags a rectangle and copies it as TSV", () => {
const disposers: Array<() => void> = [];
afterEach(() => {
disposers
.splice(0)
.reverse()
.forEach((dispose) => dispose());
});
it("drags a rectangle and copies it as TSV through the Windows dispatcher", () => {
useKeymap.setState({ preset: "intellij-windows", overrides: {} });
const globalCopy = vi.fn();
disposers.push(registerActions([{ id: "edit.copy", title: "Copy", run: globalCopy }]), installKeyboardDispatcher());
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
render(
@@ -33,8 +45,9 @@ describe("database grid", () => {
fireEvent.mouseDown(cells[0]!, { button: 0 });
fireEvent.mouseEnter(cells[3]!);
fireEvent.mouseUp(window);
fireEvent.keyDown(screen.getByRole("grid"), { key: "c", metaKey: true });
fireEvent.keyDown(screen.getByRole("grid"), { key: "c", code: "KeyC", ctrlKey: true });
expect(writeText).toHaveBeenCalledWith("one\ttwo\nthree\tfour");
expect(globalCopy).not.toHaveBeenCalled();
});
it("pastes a rectangle as pending edits and skips deleted rows", () => {
const onEdit = vi.fn();
+9 -1
Переглянути файл
@@ -3,6 +3,7 @@
* executed statement with its outcome, and a `Result N` tab per result set —
* a toolbar (re-run, export / copy as CSV, JSON, SQL…), the grid and a floating row count.
*/
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import type { DbPlanNode } from "@/platform/ipc";
@@ -45,7 +46,14 @@ function Output() {
<span className={styles.spacer} />
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
</div>
<div ref={ref} className={styles.output} role="log" aria-label={tx("Database output")}>
<div
ref={ref}
className={styles.output}
tabIndex={0}
onKeyDown={copySelectionOnKeyDown}
role="log"
aria-label={tx("Database output")}
>
{lines.map((l: OutputLine) => (
<div key={l.id} className={clsx(styles.outputLine, l.kind === "error" && styles.outputError)}>
<span className={styles.outputTime}>[{time(l.time)}]</span>{" "}
+2
Переглянути файл
@@ -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}
>
+5 -2
Переглянути файл
@@ -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<HTMLDivElement>) => {
if (copySelectionOnKeyDown(e)) return;
if (!input || e.nativeEvent.isComposing) return;
if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
+9 -1
Переглянути файл
@@ -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: {
<div
ref={scroller}
className={clsx(styles.log, wrap && styles.wrap)}
tabIndex={0}
role="log"
onKeyDown={copySelectionOnKeyDown}
aria-label={tx("Container log")}
onScroll={(e) => {
const el = e.currentTarget;
@@ -391,7 +394,12 @@ function InspectView({ conn, object, id }: { conn: string; object: DockerObject;
}, [conn, object, id]);
return (
<div className={styles.detailBody}>
<pre className={clsx(styles.log, styles.json)} aria-label={tx("Inspect")}>
<pre
tabIndex={0}
onKeyDown={copySelectionOnKeyDown}
className={clsx(styles.log, styles.json)}
aria-label={tx("Inspect")}
>
{error ?? text ?? tx("Loading…")}
</pre>
</div>
+66
Переглянути файл
@@ -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();
}
});
});
+103
Переглянути файл
@@ -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();
}
});
+8
Переглянути файл
@@ -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);
+13 -3
Переглянути файл
@@ -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<rea
}
export function installTerminalKeys(target: Window = window): () => 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);
};
}
+21 -1
Переглянути файл
@@ -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" });
+3
Переглянути файл
@@ -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() {
<div
ref={ref}
className={styles.console}
tabIndex={0}
role="log"
onKeyDown={copySelectionOnKeyDown}
aria-label={tx("Git console")}
onScroll={(e) => {
const el = e.currentTarget;
+39 -1
Переглянути файл
@@ -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<void> {
}
document.execCommand("copy");
}
/** Copy a DOM output selection locally, leaving editable children and other views alone. */
export function copySelectionOnKeyDown(
e: Pick<
KeyboardEvent<HTMLElement>,
| "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;
}