diff --git a/apps/desktop/src/components/editor/SqlPreviewPanel.vue b/apps/desktop/src/components/editor/SqlPreviewPanel.vue index 6aff7d003d..6c06aa299c 100644 --- a/apps/desktop/src/components/editor/SqlPreviewPanel.vue +++ b/apps/desktop/src/components/editor/SqlPreviewPanel.vue @@ -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")); diff --git a/apps/desktop/src/components/editor/__tests__/SqlPreviewPanel.spec.ts b/apps/desktop/src/components/editor/__tests__/SqlPreviewPanel.spec.ts index 14921a5de1..840d89f35f 100644 --- a/apps/desktop/src/components/editor/__tests__/SqlPreviewPanel.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/SqlPreviewPanel.spec.ts @@ -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>(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(); + 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) => `${content}`); + 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"); + }); +});