fix(editor): preserve semantic whitespace in hover DDL copies

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-13 02:12:09 +08:00
1 parent d4d6501af6
commit de5c2cb26b
3 files changed
+196 -4

No files matched your search

@@ -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<typeof constrainSqlHoverLayout> | 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,
};
}
+33 -1
View File
@@ -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<ColumnInfo> & { 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 */
);`);
});
});
@@ -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.
*/