diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index c0d6ac5f09..a875213805 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -2515,6 +2515,34 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows let handleCopy: ((event: ClipboardEvent) => void) | null = null; if (sqlContent) { + heading.className = "flex items-center justify-between gap-3 font-medium"; + + const copyButton = document.createElement("button"); + copyButton.type = "button"; + copyButton.className = "rounded border border-border/60 px-1.5 py-0.5 text-[11px] leading-none text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; + copyButton.textContent = t("grid.copyDdl"); + copyButton.title = t("grid.copyDdl"); + copyButton.setAttribute("aria-label", t("grid.copyDdl")); + copyButton.addEventListener("pointerdown", (event) => { + // Keep CodeMirror's editor gestures from dismissing the tooltip before + // the click can reach the copy action. + event.preventDefault(); + event.stopPropagation(); + }); + copyButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + void (async () => { + try { + await copyToClipboard(normalizeAlignedSqlWhitespace(sqlContent)); + toast(t("contextMenu.ddlCopied"), 2000); + } catch (error: any) { + toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000); + } + })(); + }); + heading.appendChild(copyButton); + const separator = document.createElement("div"); separator.className = "mt-2 border-t border-border/60"; dom.appendChild(separator); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorHoverDdlCopy.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorHoverDdlCopy.spec.ts new file mode 100644 index 0000000000..71b516cb88 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorHoverDdlCopy.spec.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +describe("QueryEditor hover DDL copy", () => { + it("adds an accessible copy button that preserves SQL whitespace semantics", () => { + expect(queryEditorSource).toContain('copyButton.textContent = t("grid.copyDdl");'); + expect(queryEditorSource).toContain('copyButton.setAttribute("aria-label", t("grid.copyDdl"));'); + expect(queryEditorSource).toContain('copyButton.addEventListener("pointerdown"'); + expect(queryEditorSource).toContain("event.stopPropagation();"); + expect(queryEditorSource).toContain("await copyToClipboard(normalizeAlignedSqlWhitespace(sqlContent));"); + expect(queryEditorSource).toContain('toast(t("contextMenu.ddlCopied"), 2000);'); + expect(queryEditorSource).toContain('toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);'); + }); +});