fix(grid): make ctrl-c copy from the sql preview panel

This commit is contained in:
弈秋
2026-10-15 20:27:16 +08:00
committed by GitHub
parent 3ef9639dbf
commit 4e618f3454
2 changed files with 204 additions and 8 deletions
@@ -60,11 +60,41 @@ function selectPreviewContent() {
return true;
}
/** Text the user actually highlighted inside the preview, if any. */
function selectedPreviewText(): string {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return "";
const el = contentRef.value;
if (!el) return "";
// Only a selection that lives inside this preview counts; one leaked from the
// surrounding grid must not be copied as if it were the preview text.
for (let index = 0; index < selection.rangeCount; index += 1) {
const range = selection.getRangeAt(index);
if (!el.contains(range.commonAncestorContainer)) return "";
}
return selection.toString();
}
async function copySelectionOrAll(event: KeyboardEvent) {
const text = selectedPreviewText() || displaySql.value;
if (!text.trim()) return;
event.preventDefault();
event.stopPropagation();
await copyPreviewText(text);
}
function onContentKeydown(event: KeyboardEvent) {
if (!isPlainClipboardShortcut(event, "a")) return;
// Claim the shortcut before it reaches the surrounding data grid, then select
// the preview text instead of the grid cells.
if (selectPreviewContent()) event.preventDefault();
if (isPlainClipboardShortcut(event, "a")) {
// Claim the shortcut before it reaches the surrounding data grid, then select
// the preview text instead of the grid cells.
if (selectPreviewContent()) event.preventDefault();
return;
}
// A programmatically built selection (Ctrl+A above) is not guaranteed to reach
// the system clipboard through the webview's native copy, so copy it through the
// app clipboard instead: the toast already claimed success that never happened
// (#12167).
if (isPlainClipboardShortcut(event, "c")) void copySelectionOrAll(event);
}
async function initHighlighter() {
@@ -114,6 +144,10 @@ async function toggleFormat() {
async function handleCopy() {
const text = displaySql.value;
if (!text.trim()) return;
await copyPreviewText(text);
}
async function copyPreviewText(text: string) {
try {
await copyToClipboard(text);
toast(t("grid.copied"));
@@ -5,16 +5,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
highlight: vi.fn<(content: string) => string>(() => ""),
copyToClipboard: vi.fn<(_text: string) => Promise<void>>(async () => {}),
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { sqlFormatter: {} } }) }));
vi.mock("@/lib/sql/sqlHighlighter", () => ({
createShikiSqlHighlighter: async () => (content: string) => mocks.highlight(content),
}));
vi.mock("@/lib/common/clipboard", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/common/clipboard")>();
return { ...actual, copyToClipboard: mocks.copyToClipboard };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
@@ -74,8 +81,30 @@ function dispatchSelectAll(target: HTMLElement, modifiers: KeyboardEventInit = {
return event;
}
function dispatchCopy(target: HTMLElement, modifiers: KeyboardEventInit = { ctrlKey: true }) {
const event = new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true, ...modifiers });
target.dispatchEvent(event);
return event;
}
/** Builds a selection the way a mouse drag would: a sub-range of the preview text. */
function dragSelect(pre: HTMLElement, start: number, end: number) {
const textNode = pre.firstChild;
if (!textNode) throw new Error("preview text node not found");
const range = document.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, end);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return selection;
}
beforeEach(() => {
mocks.highlight.mockImplementation(() => "");
mocks.copyToClipboard.mockClear();
mocks.copyToClipboard.mockImplementation(async () => {});
mocks.toast.mockClear();
});
afterEach(() => {
@@ -85,6 +114,8 @@ afterEach(() => {
host?.remove();
host = null;
mocks.highlight.mockClear();
mocks.copyToClipboard.mockClear();
mocks.toast.mockClear();
});
describe("SqlPreviewPanel select all", () => {
@@ -127,9 +158,6 @@ describe("SqlPreviewPanel select all", () => {
expect(dispatchSelectAll(pre, { ctrlKey: true, shiftKey: true }).defaultPrevented).toBe(false);
expect(dispatchSelectAll(pre, { ctrlKey: true, altKey: true }).defaultPrevented).toBe(false);
expect(dispatchSelectAll(pre, { ctrlKey: false }).defaultPrevented).toBe(false);
const other = new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true, ctrlKey: true });
pre.dispatchEvent(other);
expect(other.defaultPrevented).toBe(false);
expect(window.getSelection()?.rangeCount ?? 0).toBe(0);
});
@@ -144,3 +172,137 @@ describe("SqlPreviewPanel select all", () => {
expect(bubbled).toHaveBeenCalledOnce();
});
});
describe("SqlPreviewPanel copy", () => {
it("copies a Ctrl+A selection through the app clipboard instead of the unreliable native path", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
pre.focus();
expect(dispatchSelectAll(pre).defaultPrevented).toBe(true);
const copied = dispatchCopy(pre);
await flushUi();
// The webview never lands a programmatically built selection on the system
// clipboard, which is why Ctrl+A followed by Ctrl+C used to toast success and
// copy nothing (#12167).
expect(copied.defaultPrevented).toBe(true);
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
expect(mocks.copyToClipboard).toHaveBeenCalledWith(SQL);
expect(mocks.toast).toHaveBeenCalledWith("grid.copied");
});
it("copies the whole preview when Ctrl+C arrives without a selection", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
const copied = dispatchCopy(pre);
await flushUi();
expect(copied.defaultPrevented).toBe(true);
expect(mocks.copyToClipboard).toHaveBeenCalledWith(SQL);
expect(mocks.toast).toHaveBeenCalledWith("grid.copied");
});
it("copies only the text the user actually highlighted", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
dragSelect(pre, 0, 7);
const copied = dispatchCopy(pre);
await flushUi();
expect(copied.defaultPrevented).toBe(true);
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
expect(mocks.copyToClipboard).toHaveBeenCalledWith(SQL.slice(0, 7));
});
it("copies only the highlighted SQL when the formatted view is active", async () => {
mocks.highlight.mockImplementation((content) => `<span class="tok">${content}</span>`);
const mounted = await mountPanel();
const pre = previewText(mounted);
expect(pre.innerHTML).toContain('class="tok"');
dragSelect(pre.querySelector("span") ?? pre, 0, 7);
const copied = dispatchCopy(pre);
await flushUi();
expect(copied.defaultPrevented).toBe(true);
expect(mocks.copyToClipboard).toHaveBeenCalledWith(SQL.slice(0, 7));
});
it("supports Cmd+C on macOS", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
const copied = dispatchCopy(pre, { metaKey: true });
await flushUi();
expect(copied.defaultPrevented).toBe(true);
expect(mocks.copyToClipboard).toHaveBeenCalledWith(SQL);
});
it("falls back to the whole preview when the selection leaked outside it", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
const outside = document.createElement("div");
outside.textContent = "grid cell";
mounted.append(outside);
const outsideNode = outside.firstChild;
const previewNode = pre.firstChild;
if (!outsideNode || !previewNode) throw new Error("selection anchors not found");
const range = document.createRange();
range.setStart(previewNode, 0);
range.setEnd(outsideNode, 5);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const copied = dispatchCopy(pre);
await flushUi();
outside.remove();
expect(copied.defaultPrevented).toBe(true);
expect(mocks.copyToClipboard).toHaveBeenCalledWith(SQL);
});
it("keeps the shortcut untouched for other modifiers", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
expect(dispatchCopy(pre, { ctrlKey: true, shiftKey: true }).defaultPrevented).toBe(false);
expect(dispatchCopy(pre, { ctrlKey: true, altKey: true }).defaultPrevented).toBe(false);
expect(dispatchCopy(pre, { ctrlKey: false }).defaultPrevented).toBe(false);
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
expect(mocks.toast).not.toHaveBeenCalled();
});
it("stops the copy shortcut from reaching the surrounding data grid", async () => {
const mounted = await mountPanel();
const pre = previewText(mounted);
const bubbled = vi.fn();
mounted.addEventListener("keydown", bubbled);
const copied = dispatchCopy(pre);
await flushUi();
expect(copied.defaultPrevented).toBe(true);
expect(bubbled).not.toHaveBeenCalled();
expect(mocks.copyToClipboard).toHaveBeenCalledTimes(1);
});
it("surfaces a failure toast when the app clipboard rejects", async () => {
mocks.copyToClipboard.mockImplementationOnce(async () => {
throw new Error("clipboard unavailable");
});
const mounted = await mountPanel();
const pre = previewText(mounted);
dispatchCopy(pre);
await flushUi();
expect(mocks.toast.mock.calls[0]?.[0]).toBe("grid.copyFailed");
});
});