mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(grid): make ctrl-c copy from the sql preview panel
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user