diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 03bf01c212..ddcb248147 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -80,7 +80,7 @@ import { sqlObjectNavigationTypeFromCompletionObjectType, type SqlObjectNavigationTarget, } from "@/lib/sql/sqlNavigation"; -import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, quoteIdentifier, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; +import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, normalizeAlignedSqlWhitespace, quoteIdentifier, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout"; import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange } from "@/lib/sql/sqlDiagnostics"; import { @@ -2331,6 +2331,7 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows dom.appendChild(detailNode); let layoutController: ReturnType | null = null; + let handleCopy: ((event: ClipboardEvent) => void) | null = null; if (sqlContent) { const separator = document.createElement("div"); @@ -2351,6 +2352,24 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows // 返回 mount/destroy 给 CodeMirror TooltipView 生命周期钩子, // 避免 MutationObserver 监听 body 全子树来兜底清理。 layoutController = constrainSqlHoverLayout(dom, sqlContainer); + + // The tooltip pads column names/types with literal spaces so they line up + // visually (see alignColumnRows). Selecting that text and copying it via + // the native OS/browser copy carries those spaces verbatim, which shows + // up as long literal space runs when pasted into a plain-text editor. + // Normalize just the clipboard payload so the on-screen alignment is + // untouched but paste targets get clean single-spaced SQL. + handleCopy = (event: ClipboardEvent) => { + const selection = document.getSelection(); + if (!selection || selection.isCollapsed) return; + if (!dom.contains(selection.anchorNode) && !dom.contains(selection.focusNode)) return; + const text = selection.toString(); + if (text !== sqlContent) return; + const normalized = normalizeAlignedSqlWhitespace(text); + if (normalized === text) return; + event.clipboardData?.setData("text/plain", normalized); + event.preventDefault(); + }; } for (const row of rows) { @@ -2362,8 +2381,20 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows return { dom, - mount: layoutController ? () => layoutController?.mount() : undefined, - destroy: layoutController ? () => layoutController?.destroy() : undefined, + mount: + layoutController || handleCopy + ? () => { + layoutController?.mount(); + if (handleCopy) document.addEventListener("copy", handleCopy); + } + : undefined, + destroy: + layoutController || handleCopy + ? () => { + layoutController?.destroy(); + if (handleCopy) document.removeEventListener("copy", handleCopy); + } + : undefined, }; } diff --git a/apps/desktop/src/lib/editor/__tests__/hoverTableSql.spec.ts b/apps/desktop/src/lib/editor/__tests__/hoverTableSql.spec.ts index 7eb2834c31..cbc709d6c8 100644 --- a/apps/desktop/src/lib/editor/__tests__/hoverTableSql.spec.ts +++ b/apps/desktop/src/lib/editor/__tests__/hoverTableSql.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, reformatHoverDdl, sanitizeHoverDdl, scopeHoverTables } from "@/lib/editor/hoverTableSql"; +import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, normalizeAlignedSqlWhitespace, reformatHoverDdl, sanitizeHoverDdl, scopeHoverTables } from "@/lib/editor/hoverTableSql"; import type { ColumnInfo, IndexInfo } from "@/types/database"; type ColumnOverride = Partial & { name: string; data_type: string }; @@ -327,3 +327,35 @@ describe("hover table scope", () => { expect(hoverTableMatchesScope({ name: "orders" }, { database: "sales", schema: "public" })).toBe(false); }); }); + +describe("normalizeAlignedSqlWhitespace", () => { + it("collapses column-alignment padding to single spaces", () => { + const aligned = ' "id" integer default nextval(\'x\'::regclass) not null,\n "username" character varying(255) not null,'; + expect(normalizeAlignedSqlWhitespace(aligned)).toBe(' "id" integer default nextval(\'x\'::regclass) not null,\n "username" character varying(255) not null,'); + }); + + it("preserves each line's leading indentation", () => { + expect(normalizeAlignedSqlWhitespace(' primary key ("id")')).toBe(' primary key ("id")'); + }); + + it("leaves single-spaced text unchanged", () => { + const text = "create table t (a int);"; + expect(normalizeAlignedSqlWhitespace(text)).toBe(text); + }); + + it("preserves semantic whitespace in literals, identifiers, and comments", () => { + const aligned = `create table t ( + "display name" varchar(20) default 'a b' not null, -- keep this + [comment text] varchar(20) comment 'first second', + escaped varchar(20) default 'it\\'s spaced', + body text default $$x y$$ /* keep that */ +);`; + + expect(normalizeAlignedSqlWhitespace(aligned)).toBe(`create table t ( + "display name" varchar(20) default 'a b' not null, -- keep this + [comment text] varchar(20) comment 'first second', + escaped varchar(20) default 'it\\'s spaced', + body text default $$x y$$ /* keep that */ +);`); + }); +}); diff --git a/apps/desktop/src/lib/editor/hoverTableSql.ts b/apps/desktop/src/lib/editor/hoverTableSql.ts index 8cf92153e9..b051488902 100644 --- a/apps/desktop/src/lib/editor/hoverTableSql.ts +++ b/apps/desktop/src/lib/editor/hoverTableSql.ts @@ -155,6 +155,135 @@ function alignColumnRows(rows: ColumnFieldParts[]): string[] { }); } +/** + * Collapse column-alignment padding outside SQL literals and comments, keeping + * each line's leading indentation intact. The hover tooltip renders padding for + * readability, but copied SQL must preserve semantic whitespace inside values. + */ +export function normalizeAlignedSqlWhitespace(text: string): string { + let result = ""; + let index = 0; + let lineStart = true; + let blockComment = false; + let dollarQuoteEnd = ""; + + while (index < text.length) { + if (blockComment) { + const end = text.indexOf("*/", index); + if (end === -1) return result + text.slice(index); + result += text.slice(index, end + 2); + lineStart = text[end + 1] === "\n"; + index = end + 2; + blockComment = false; + continue; + } + + if (dollarQuoteEnd) { + const end = text.indexOf(dollarQuoteEnd, index); + if (end === -1) return result + text.slice(index); + result += text.slice(index, end + dollarQuoteEnd.length); + lineStart = false; + index = end + dollarQuoteEnd.length; + dollarQuoteEnd = ""; + continue; + } + + const char = text[index]; + const next = text[index + 1]; + + if (char === "\n" || char === "\r") { + result += char; + index += 1; + lineStart = true; + continue; + } + + if (lineStart && (char === " " || char === "\t")) { + result += char; + index += 1; + continue; + } + lineStart = false; + + if (char === "-" && next === "-") { + const end = text.indexOf("\n", index); + if (end === -1) return result + text.slice(index); + result += text.slice(index, end); + index = end; + continue; + } + + if (char === "/" && next === "*") { + result += "/*"; + index += 2; + blockComment = true; + continue; + } + + if (char === "$" && (index === 0 || !/[\w$]/.test(text[index - 1]))) { + const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(text.slice(index)); + if (match) { + result += match[0]; + index += match[0].length; + dollarQuoteEnd = match[0]; + continue; + } + } + + if (char === "'" || char === '"' || char === "`") { + const quote = char; + const start = index; + index += 1; + while (index < text.length) { + if (text[index] === quote) { + if (text[index + 1] === quote) index += 2; + else { + index += 1; + break; + } + } else if (text[index] === "\\" && index + 1 < text.length) { + index += 2; + } else { + index += 1; + } + } + result += text.slice(start, index); + continue; + } + + if (char === "[") { + const start = index; + index += 1; + while (index < text.length) { + if (text[index] === "]") { + if (text[index + 1] === "]") index += 2; + else { + index += 1; + break; + } + } else { + index += 1; + } + } + result += text.slice(start, index); + continue; + } + + if (char === " ") { + let end = index + 1; + while (text[end] === " ") end += 1; + result += " "; + index = end; + continue; + } + + result += char; + index += 1; + } + + return result; +} + /** * Quote a SQL identifier by wrapping it in double quotes with proper escaping. */