дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
932ac379bc
коміт
d837cb1acb
14 files changed
+506
-13
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>{" "}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача