fix(editor): preserve semantic whitespace in hover DDL copies
This commit is contained in:
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
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user