feat(editor): search table structure hover with aligned offsets

This commit is contained in:
jearhe
2026-09-01 01:46:04 +08:00
committed by GitHub
parent 554be11aea
commit f9ff9245ce
11 changed files with 465 additions and 2 deletions
@@ -92,6 +92,7 @@ import {
} from "@/lib/sql/sqlNavigation";
import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, normalizeAlignedSqlWhitespace, quoteIdentifier, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql";
import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout";
import { createHoverSearch, type HoverSearchController } from "@/lib/editor/sqlHoverSearch";
import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange } from "@/lib/sql/sqlDiagnostics";
import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "@/lib/sql/mysqlRoutineSyntaxDiagnostics";
import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics";
@@ -2655,6 +2656,7 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows
let layoutController: ReturnType<typeof constrainSqlHoverLayout> | null = null;
let handleCopy: ((event: ClipboardEvent) => void) | null = null;
let searchController: HoverSearchController | null = null;
if (sqlContent) {
heading.className = "flex items-center justify-between gap-3 font-medium";
@@ -2699,7 +2701,18 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows
sqlContainer.textContent = sqlContent;
}
// 纯前端搜索:在已生成的 DDL 内容上做大小写不敏感匹配并高亮,不重新请求元数据/DDL。
// originalHtml 必须在挂到文档前、内容渲染后捕获,作为每次搜索的还原基线。
searchController = createHoverSearch({
target: sqlContainer,
originalHtml: sqlContainer.innerHTML,
placeholder: t("grid.hoverSearchPlaceholder"),
noResultLabel: t("grid.hoverSearchNoResult"),
});
dom.appendChild(searchController.element);
dom.appendChild(sqlContainer);
dom.appendChild(searchController.status);
// 返回 mount/destroy 给 CodeMirror TooltipView 生命周期钩子,
// 避免 MutationObserver 监听 body 全子树来兜底清理。
layoutController = constrainSqlHoverLayout(dom, sqlContainer);
@@ -2733,16 +2746,17 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows
return {
dom,
mount:
layoutController || handleCopy
layoutController || handleCopy || searchController
? () => {
layoutController?.mount();
if (handleCopy) document.addEventListener("copy", handleCopy);
}
: undefined,
destroy:
layoutController || handleCopy
layoutController || handleCopy || searchController
? () => {
layoutController?.destroy();
searchController?.destroy();
if (handleCopy) document.removeEventListener("copy", handleCopy);
}
: undefined,
+2
View File
@@ -1632,6 +1632,8 @@ export default {
mongoJsonPreviewEmpty: "Select a MongoDB document to preview.",
copyJson: "Copy JSON",
copyDdl: "Copy DDL",
hoverSearchPlaceholder: "Search columns…",
hoverSearchNoResult: "No matching columns",
copyCell: "Copy Cell",
copyRow: "Copy Entire Row (JSON Object)",
copyColumnJson: "Copy Column (JSON)",
+2
View File
@@ -1523,6 +1523,8 @@ export default withEnglishFallback({
mongoJsonPreviewEmpty: "Selecciona un documento de MongoDB para obtener una vista previa.",
copyJson: "Copiar JSON",
copyDdl: "Copiar DDL",
hoverSearchPlaceholder: "Buscar columnas…",
hoverSearchNoResult: "No hay columnas coincidentes",
copyCell: "Copiar celda",
copyRow: "Copiar fila completa (objeto JSON)",
copyColumnJson: "Copiar columna (JSON)",
+2
View File
@@ -1521,6 +1521,8 @@ export default withEnglishFallback({
mongoJsonPreviewEmpty: "Seleziona un documento MongoDB da visualizzare.",
copyJson: "Copia JSON",
copyDdl: "Copia DDL",
hoverSearchPlaceholder: "Cerca colonne…",
hoverSearchNoResult: "Nessuna colonna corrispondente",
copyCell: "Copia Cella",
copyRow: "Copia intera riga (oggetto JSON)",
copyColumnJson: "Copia Colonna (JSON)",
+2
View File
@@ -1535,6 +1535,8 @@ export default withEnglishFallback({
mongoJsonPreviewEmpty: "プレビューする MongoDB ドキュメントを選択してください。",
copyJson: "JSON をコピー",
copyDdl: "DDLをコピー",
hoverSearchPlaceholder: "列を検索…",
hoverSearchNoResult: "一致する列はありません",
copyCell: "セルをコピー",
copyRow: "行全体をコピー(JSON オブジェクト)",
copyColumnJson: "列をコピー (JSON)",
+2
View File
@@ -1513,6 +1513,8 @@ export default withEnglishFallback({
mongoJsonPreviewEmpty: "미리보려면 MongoDB 문서를 선택하세요.",
copyJson: "JSON 복사",
copyDdl: "DDL 복사",
hoverSearchPlaceholder: "열 검색…",
hoverSearchNoResult: "일치하는 열 없음",
copyCell: "셀 복사",
copyRow: "전체 행 복사(JSON 객체)",
copyColumnJson: "컬럼 복사 (JSON)",
+2
View File
@@ -1523,6 +1523,8 @@ export default withEnglishFallback({
mongoJsonPreviewEmpty: "Selecione um documento MongoDB para visualizar.",
copyJson: "Copiar JSON",
copyDdl: "Copiar DDL",
hoverSearchPlaceholder: "Pesquisar colunas…",
hoverSearchNoResult: "Nenhuma coluna correspondente",
copyCell: "Copiar Célula",
copyRow: "Copiar linha inteira (objeto JSON)",
copyColumnJson: "Copiar Coluna (JSON)",
+2
View File
@@ -1555,6 +1555,8 @@ export default withEnglishFallback({
mongoJsonPreviewEmpty: "选择一条 MongoDB 文档以预览。",
copyJson: "复制 JSON",
copyDdl: "复制 DDL",
hoverSearchPlaceholder: "搜索列…",
hoverSearchNoResult: "没有匹配的列",
copyCell: "复制单元格",
copyRow: "复制整行(JSON 对象)",
copyColumnJson: "复制列 (JSON)",
+2
View File
@@ -1521,6 +1521,8 @@ export default withEnglishFallback({
mongoJsonPreviewEmpty: "選擇一筆 MongoDB 文件以預覽。",
copyJson: "複製 JSON",
copyDdl: "複製 DDL",
hoverSearchPlaceholder: "搜尋欄位…",
hoverSearchNoResult: "沒有符合的欄位",
copyCell: "複製儲存格",
copyRow: "複製整筆(JSON 物件)",
copyColumnJson: "複製欄 (JSON)",
@@ -0,0 +1,215 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { applyHoverSearchHighlights, clearHoverSearchHighlights, createHoverSearch, findHoverSearchMatches } from "@/lib/editor/sqlHoverSearch";
const DDL = ["create table `orders` (", " `id` bigint not null,", " `customer_order_status` varchar(32) null,", " `USER_ID` bigint null,", " `amount` decimal(10,2) null", ");"].join("\n");
describe("findHoverSearchMatches", () => {
it("returns no matches for an empty or whitespace query", () => {
expect(findHoverSearchMatches(DDL, "")).toEqual([]);
expect(findHoverSearchMatches(DDL, " ")).toEqual([]);
});
it("matches a full column name", () => {
const matches = findHoverSearchMatches(DDL, "customer_order_status");
expect(matches).toHaveLength(1);
expect(DDL.slice(matches[0].start, matches[0].end)).toBe("customer_order_status");
});
it("matches a partial column name", () => {
const matches = findHoverSearchMatches(DDL, "order");
// Appears in table name `orders` and in `customer_order_status`.
expect(matches.length).toBeGreaterThanOrEqual(2);
});
it("is case-insensitive", () => {
expect(findHoverSearchMatches(DDL, "user_id")).toHaveLength(1);
expect(findHoverSearchMatches(DDL, "USER_ID")).toHaveLength(1);
expect(findHoverSearchMatches(DDL, "User_Id")).toHaveLength(1);
});
it("returns an empty array when nothing matches", () => {
expect(findHoverSearchMatches(DDL, "no_such_column")).toEqual([]);
});
it("treats regex metacharacters literally without throwing", () => {
// None of these should be interpreted as a pattern.
expect(() => findHoverSearchMatches(DDL, "(")).not.toThrow();
expect(() => findHoverSearchMatches(DDL, "[a-z")).not.toThrow();
expect(() => findHoverSearchMatches(DDL, "decimal(10,2)")).not.toThrow();
expect(findHoverSearchMatches(DDL, "(10,2)")).toHaveLength(1);
expect(findHoverSearchMatches(DDL, ".*")).toEqual([]);
});
it("finds non-overlapping repeated occurrences", () => {
const matches = findHoverSearchMatches("aaaa", "aa");
expect(matches).toEqual([
{ start: 0, end: 2 },
{ start: 2, end: 4 },
]);
});
});
describe("applyHoverSearchHighlights", () => {
it("wraps matches in <mark> without altering the visible text", () => {
const container = document.createElement("div");
container.textContent = DDL;
const matches = findHoverSearchMatches(DDL, "order");
const marks = applyHoverSearchHighlights(container, matches);
expect(marks.length).toBe(matches.length);
expect(container.querySelectorAll("mark").length).toBe(matches.length);
expect(container.textContent).toBe(DDL);
// First match is tagged active for scroll-into-view.
expect(marks[0].getAttribute("data-sql-hover-search-active")).toBe("true");
});
it("preserves surrounding highlight spans (only text nodes are split)", () => {
const container = document.createElement("div");
const span = document.createElement("span");
span.style.color = "red";
span.textContent = "customer_order_status";
container.append(document.createTextNode(" "), span, document.createTextNode(" varchar"));
const content = container.textContent!;
const matches = findHoverSearchMatches(content, "order");
applyHoverSearchHighlights(container, matches);
// The colored span is still present and now contains the mark.
const preservedSpan = container.querySelector("span");
expect(preservedSpan?.style.color).toBe("red");
expect(preservedSpan?.querySelector("mark")).not.toBeNull();
expect(container.textContent).toBe(content);
});
it("clears highlights and restores the original text nodes", () => {
const container = document.createElement("div");
container.textContent = DDL;
applyHoverSearchHighlights(container, findHoverSearchMatches(DDL, "id"));
expect(container.querySelector("mark")).not.toBeNull();
clearHoverSearchHighlights(container);
expect(container.querySelector("mark")).toBeNull();
expect(container.textContent).toBe(DDL);
});
});
describe("createHoverSearch", () => {
function setup() {
const target = document.createElement("div");
target.textContent = DDL;
const controller = createHoverSearch({
target,
originalHtml: target.innerHTML,
placeholder: "Search columns…",
noResultLabel: "No matching columns",
});
const input = controller.element.querySelector<HTMLInputElement>('[data-sql-hover-search-input="true"]')!;
return { target, controller, input };
}
it("highlights correctly when line breaks render as <br> elements", () => {
const target = document.createElement("div");
// Mirror the syntax highlighter's inline output: lines separated by <br>,
// so textContent contains no newline characters.
const lines = DDL.split("\n");
lines.forEach((line, index) => {
if (index > 0) target.appendChild(document.createElement("br"));
if (line) target.appendChild(document.createTextNode(line));
});
const controller = createHoverSearch({
target,
originalHtml: target.innerHTML,
placeholder: "Search columns…",
noResultLabel: "No matching columns",
});
const input = controller.element.querySelector<HTMLInputElement>('[data-sql-hover-search-input="true"]')!;
// `customer_order_status` sits on the third line — two `<br>`s in, exactly
// where newline-domain offsets used to drift into the wrong text.
input.value = "customer_order_status";
input.dispatchEvent(new Event("input"));
const marks = target.querySelectorAll("mark");
expect(marks.length).toBeGreaterThan(0);
for (const mark of marks) {
expect(mark.textContent?.toLowerCase()).toContain(input.value.toLowerCase());
}
expect(target.textContent?.toLowerCase()).toContain(input.value.toLowerCase());
});
it("creates a search input and a hidden no-result status", () => {
const { controller, input } = setup();
expect(input).not.toBeNull();
expect(input.placeholder).toBe("Search columns…");
expect(controller.status.hidden).toBe(true);
expect(controller.status.textContent).toBe("No matching columns");
});
it("highlights matches as the query changes", () => {
const { target, input } = setup();
input.value = "customer_order_status";
input.dispatchEvent(new Event("input"));
expect(target.querySelectorAll("mark").length).toBe(1);
});
it("shows the no-result status and no marks when nothing matches", () => {
const { target, controller, input } = setup();
input.value = "zzz_missing";
input.dispatchEvent(new Event("input"));
expect(controller.status.hidden).toBe(false);
expect(target.querySelector("mark")).toBeNull();
});
it("restores the full content when the query is cleared", () => {
const { target, controller, input } = setup();
input.value = "id";
input.dispatchEvent(new Event("input"));
expect(target.querySelector("mark")).not.toBeNull();
input.value = "";
input.dispatchEvent(new Event("input"));
expect(target.querySelector("mark")).toBeNull();
expect(controller.status.hidden).toBe(true);
expect(target.textContent).toBe(DDL);
});
it("stops keydown propagation so the editor keymap never sees typing", () => {
const { input } = setup();
let leaked = false;
document.addEventListener("keydown", () => (leaked = true));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(leaked).toBe(false);
});
it("clears the query on Escape but keeps propagation stopped (tooltip stays open)", () => {
const { target, input } = setup();
input.value = "id";
input.dispatchEvent(new Event("input"));
let leaked = false;
document.addEventListener("keydown", () => (leaked = true));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(input.value).toBe("");
expect(target.querySelector("mark")).toBeNull();
expect(leaked).toBe(false);
});
it("keeps pointerdown from bubbling to the editor (click focuses the input)", () => {
const { input } = setup();
let leaked = false;
document.addEventListener("pointerdown", () => (leaked = true));
input.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
expect(leaked).toBe(false);
});
it("removes its listeners on destroy", () => {
const { target, controller, input } = setup();
controller.destroy();
input.value = "id";
input.dispatchEvent(new Event("input"));
// No re-render after destroy.
expect(target.querySelector("mark")).toBeNull();
});
});
@@ -0,0 +1,218 @@
/**
* Client-side search for the SQL table-structure hover tooltip.
*
* The hover already renders the full table DDL (syntax-highlighted). When a
* table has many columns, finding one by eye is slow, so this module adds a
* compact search box that highlights matches inside the *already fetched* DDL
* text — no extra metadata or DDL request is made. Matching runs over the
* container's `textContent` — the same offset domain the highlight walker
* accumulates over its text nodes — never over the highlighted HTML, and uses
* substring search (not a `RegExp` built from user input) so special
* characters can never break matching. Keeping corpus and walker on the same
* source matters because the syntax highlighter renders line breaks as `<br>`
* elements: its `textContent` has no newline characters even though the raw
* DDL string does.
*/
const MATCH_ATTRIBUTE = "data-sql-hover-search-match";
const ACTIVE_ATTRIBUTE = "data-sql-hover-search-active";
export interface HoverSearchMatch {
/** Offset of the first matched character within the searched text. */
start: number;
/** Offset just past the last matched character within the searched text. */
end: number;
}
/**
* Find every case-insensitive, non-overlapping occurrence of `query` inside the
* searched text. A blank/whitespace-only query yields no matches (the caller
* treats this as "restore full content"). Uses `indexOf`, so arbitrary user
* input — including regex metacharacters like `()[]$.*` — is matched literally.
*/
export function findHoverSearchMatches(text: string, query: string): HoverSearchMatch[] {
const needle = query.trim().toLowerCase();
if (!needle) return [];
const haystack = text.toLowerCase();
const matches: HoverSearchMatch[] = [];
let from = 0;
for (;;) {
const index = haystack.indexOf(needle, from);
if (index === -1) break;
matches.push({ start: index, end: index + needle.length });
from = index + needle.length;
}
return matches;
}
/**
* Wrap each matched range in a `<mark>` by walking the container's text nodes,
* so the surrounding syntax-highlight spans are preserved untouched. A match
* that straddles two adjacent text nodes is highlighted per node segment.
* Match offsets are relative to the concatenated text-node content (the
* container's `textContent`); call {@link clearHoverSearchHighlights} (or
* restore the original HTML) before re-applying.
*
* Returns the created `<mark>` elements in document order; the first is tagged
* as the active match so callers can scroll it into view.
*/
export function applyHoverSearchHighlights(container: HTMLElement, matches: HoverSearchMatch[]): HTMLElement[] {
if (matches.length === 0) return [];
// Snapshot text nodes with their global offsets before mutating the DOM.
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
const textNodes: Array<{ node: Text; start: number; end: number }> = [];
let offset = 0;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node as Text;
const length = text.data.length;
textNodes.push({ node: text, start: offset, end: offset + length });
offset += length;
}
const marks: HTMLElement[] = [];
for (const { node, start, end } of textNodes) {
// Ranges (clamped to this node) that overlap the node, in node-local coords.
const segments = matches.filter((match) => match.start < end && match.end > start).map((match) => ({ from: Math.max(match.start, start) - start, to: Math.min(match.end, end) - start }));
if (segments.length === 0) continue;
const data = node.data;
const fragment = document.createDocumentFragment();
let cursor = 0;
for (const { from, to } of segments) {
if (from > cursor) fragment.appendChild(document.createTextNode(data.slice(cursor, from)));
const mark = document.createElement("mark");
mark.setAttribute(MATCH_ATTRIBUTE, "true");
mark.className = "rounded-[2px] bg-yellow-300/70 px-px text-inherit dark:bg-yellow-500/40";
mark.textContent = data.slice(from, to);
fragment.appendChild(mark);
marks.push(mark);
cursor = to;
}
if (cursor < data.length) fragment.appendChild(document.createTextNode(data.slice(cursor)));
node.replaceWith(fragment);
}
marks[0]?.setAttribute(ACTIVE_ATTRIBUTE, "true");
return marks;
}
/** Remove every highlight mark, merging its text back into the surrounding nodes. */
export function clearHoverSearchHighlights(container: HTMLElement): void {
const marks = container.querySelectorAll(`[${MATCH_ATTRIBUTE}]`);
for (const mark of marks) mark.replaceWith(...mark.childNodes);
container.normalize();
}
export interface HoverSearchController {
/** Row element to insert above the scrollable DDL content. */
element: HTMLElement;
/** No-result status element (inserted after the content by the caller). */
status: HTMLElement;
/** Focus the search input. */
focus(): void;
/** Remove listeners; safe to call once when the tooltip is destroyed. */
destroy(): void;
}
export interface HoverSearchOptions {
/** Scrollable element whose innerHTML holds the rendered DDL. */
target: HTMLElement;
/** The target's pristine innerHTML, restored before every re-highlight. */
originalHtml: string;
/** Input placeholder text. */
placeholder: string;
/** Message shown when a non-empty query has no matches. */
noResultLabel: string;
}
/**
* Build the compact search box + no-result status for the hover tooltip and
* wire live filtering. Interaction is isolated from the CodeMirror editor:
*
* - `pointerdown` stops propagation (but keeps default) so clicking the input
* focuses it without the editor treating it as "hover ended".
* - `keydown`/`keyup` stop propagation so editor keymap shortcuts and IME
* Enter/composition never fire or dismiss the tooltip while typing.
*/
export function createHoverSearch(options: HoverSearchOptions): HoverSearchController {
const { target, originalHtml, placeholder, noResultLabel } = options;
const element = document.createElement("div");
element.dataset.sqlHoverSearch = "true";
element.className = "mt-2 flex-none";
const input = document.createElement("input");
input.type = "text";
input.dataset.sqlHoverSearchInput = "true";
input.placeholder = placeholder;
input.setAttribute("aria-label", placeholder);
input.spellcheck = false;
input.autocomplete = "off";
input.className = "w-full rounded border border-border/60 bg-background px-2 py-1 text-[11px] leading-none text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
element.appendChild(input);
const status = document.createElement("div");
status.dataset.sqlHoverSearchEmpty = "true";
status.className = "mt-1.5 text-[11px] text-muted-foreground";
status.textContent = noResultLabel;
status.hidden = true;
const runSearch = () => {
// Restore the pristine highlighted DDL, then re-apply match highlights.
target.innerHTML = originalHtml;
const query = input.value;
// Search the rendered text, not the raw DDL string: the highlighter emits
// `<br>` for line breaks, so `textContent` differs from the raw DDL by the
// dropped newline characters — and the walker's offsets live in the
// `textContent` domain. Using any other corpus shifts every match after
// the first line.
const matches = findHoverSearchMatches(target.textContent ?? "", query);
if (!query.trim()) {
status.hidden = true;
return;
}
if (matches.length === 0) {
status.hidden = false;
return;
}
status.hidden = true;
const marks = applyHoverSearchHighlights(target, matches);
marks[0]?.scrollIntoView({ block: "nearest" });
};
const stopKeyboard = (event: KeyboardEvent) => {
// Keep editor keymap shortcuts and IME Enter/Escape from leaking out of the
// input; Escape clears the search but keeps the tooltip open.
event.stopPropagation();
if (event.key === "Escape" && input.value) {
event.preventDefault();
input.value = "";
runSearch();
}
};
const stopPointer = (event: Event) => {
event.stopPropagation();
};
input.addEventListener("input", runSearch);
input.addEventListener("keydown", stopKeyboard);
input.addEventListener("keyup", stopKeyboard);
input.addEventListener("pointerdown", stopPointer);
input.addEventListener("mousedown", stopPointer);
return {
element,
status,
focus() {
input.focus();
},
destroy() {
input.removeEventListener("input", runSearch);
input.removeEventListener("keydown", stopKeyboard);
input.removeEventListener("keyup", stopKeyboard);
input.removeEventListener("pointerdown", stopPointer);
input.removeEventListener("mousedown", stopPointer);
},
};
}