perf(table-info): mark DDL search hits on the rendered DOM

This commit is contained in:
zipg authored and GitHub committed 2026-09-22 14:18:54 +08:00
1 parent 98101d20af
commit 70c6ce4c28
6 files changed
+172 -15

No files matched your search

+19 -14
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage";
import { applyDdlSearchMarks } from "@/lib/sql/ddlSearchMarks";
import DdlStorageToggle from "@/components/objects/DdlStorageToggle.vue";
import { useUpdateBlocker } from "@/lib/app/updatePreparation";
@@ -10492,6 +10493,7 @@ watch([activeTableInfoTab, ddlLoading], ([tab, loading]) => {
}
void nextTick(() => {
ddlPreRef.value?.focus();
applyDdlSearchMarks(ddlPreRef.value, searchQuery.value);
syncDdlSearchMatches(true);
});
});
@@ -11471,24 +11473,27 @@ const filteredConstraints = computed(() => {
return base.filter((c) => c.name.toLowerCase().includes(q) || c.constraint_type.toLowerCase().includes(q) || c.columns.some((col) => col.toLowerCase().includes(q)) || c.definition.toLowerCase().includes(q));
});
const filteredDdlContent = computed(() => {
if (!ddlContent.value) return "";
const html = highlight(ddlContent.value);
if (!searchQuery.value) return html;
const escaped = searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escaped})`, "gi");
// Match only text between > and < (text nodes), then replace the search term within those spans
return html.replace(/>([^<]*)</g, (_, text) => {
return `>${text.replace(regex, '<mark class="ddl-search-match">$1</mark>')}<`;
});
});
// The highlighted DDL only changes when the statement itself changes: the
// search marks are applied to the rendered DOM instead, so typing in the DDL
// search box no longer re-highlights (or re-parses) the whole document (#9212).
const highlightedDdlContent = computed(() => (ddlContent.value ? highlight(ddlContent.value) : ""));
watch(
[filteredDdlContent, searchQuery],
highlightedDdlContent,
async () => {
await nextTick();
applyDdlSearchMarks(ddlPreRef.value, searchQuery.value);
syncDdlSearchMatches(true);
},
{ flush: "post" },
);
watch(
searchQuery,
async () => {
ddlSearchMatchIndex.value = 0;
await nextTick();
applyDdlSearchMarks(ddlPreRef.value, searchQuery.value);
syncDdlSearchMatches(true);
},
{ flush: "post" },
@@ -13616,7 +13621,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
tabindex="0"
class="flex-1 min-w-0 text-xs font-mono p-3 overflow-auto ddl-code leading-5 select-text outline-none"
:class="settingsStore.editorSettings.tableDdlWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
v-html="filteredDdlContent"
v-html="highlightedDdlContent"
@keydown="onDdlKeydown"
></pre>
</template>
@@ -6,7 +6,18 @@ const metadataLoaderSource = readFileSync(new URL("../../../composables/useDataG
describe("DataGrid DDL search navigation", () => {
it("resets navigation when the raw search query changes", () => {
expect(dataGridSource).toMatch(/watch\(\s*\[filteredDdlContent, searchQuery\],/);
expect(dataGridSource).toMatch(/watch\(\s*searchQuery,\s*async \(\) => \{\s*ddlSearchMatchIndex\.value = 0;/);
});
it("highlights the DDL once and marks the rendered DOM for the search", () => {
// #9212: re-highlighting (and re-parsing) the whole DDL on every keystroke
// froze the window, so the highlighted HTML only depends on the DDL itself
// and the search marks are applied to the already-rendered <pre>.
expect(dataGridSource).toMatch(/const highlightedDdlContent = computed\(\(\) => \(ddlContent\.value \? highlight\(ddlContent\.value\) : ""\)\);/);
expect(dataGridSource).toContain('v-html="highlightedDdlContent"');
expect(dataGridSource).toContain('import { applyDdlSearchMarks } from "@/lib/sql/ddlSearchMarks"');
expect(dataGridSource).not.toContain("filteredDdlContent");
expect(dataGridSource.match(/applyDdlSearchMarks\(ddlPreRef\.value, searchQuery\.value\)/g) ?? []).toHaveLength(3);
});
it("uses the shared DDL cache policy and exposes refresh controls", () => {
@@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { applyDdlSearchMarks, clearDdlSearchMarks, DDL_SEARCH_MARK_CLASS } from "@/lib/sql/ddlSearchMarks";
let pre: HTMLElement;
function marks(): HTMLElement[] {
return Array.from(pre.querySelectorAll<HTMLElement>(`mark.${DDL_SEARCH_MARK_CLASS}`));
}
beforeEach(() => {
pre = document.createElement("pre");
document.body.replaceChildren(pre);
});
describe("applyDdlSearchMarks", () => {
it("marks every occurrence, ignoring case", () => {
pre.innerHTML = "<span>child wo</span><span>CHILD</span>";
applyDdlSearchMarks(pre, "chiLD");
expect(marks().map((mark) => mark.textContent)).toEqual(["child", "CHILD"]);
});
it("keeps the underlying DDL text unchanged", () => {
pre.innerHTML = "<span>CREATE TABLE [dbo].[Orders] (</span>";
const before = pre.textContent;
applyDdlSearchMarks(pre, "table");
expect(pre.textContent).toBe(before);
expect(marks()).toHaveLength(1);
});
it("treats the query literally instead of as a regex", () => {
pre.innerHTML = "<span>SUM(a+b) AND (b+c)</span>";
applyDdlSearchMarks(pre, "(b+c)");
expect(marks().map((mark) => mark.textContent)).toEqual(["(b+c)"]);
applyDdlSearchMarks(pre, "a+b");
expect(marks().map((mark) => mark.textContent)).toEqual(["a+b"]);
});
it("marks matches inside nested elements and next to each other", () => {
pre.innerHTML = '<span class="hl"><span>name_name</span></span>';
applyDdlSearchMarks(pre, "name");
expect(marks().map((mark) => mark.textContent)).toEqual(["name", "name"]);
expect(pre.textContent).toBe("name_name");
});
it("clears the previous marks when the query changes or empties", () => {
pre.innerHTML = "<span>alpha beta</span>";
applyDdlSearchMarks(pre, "alpha");
applyDdlSearchMarks(pre, "beta");
expect(marks().map((mark) => mark.textContent)).toEqual(["beta"]);
applyDdlSearchMarks(pre, "");
expect(marks()).toHaveLength(0);
expect(pre.textContent).toBe("alpha beta");
});
it("does not match across element boundaries", () => {
pre.innerHTML = "<span>child</span><span>worker</span>";
applyDdlSearchMarks(pre, "childworker");
expect(marks()).toHaveLength(0);
});
it("ignores missing elements and treats the query literally", () => {
expect(() => applyDdlSearchMarks(null, "x")).not.toThrow();
expect(() => clearDdlSearchMarks(undefined)).not.toThrow();
pre.innerHTML = "<span>[Id] int NOT NULL,</span>";
applyDdlSearchMarks(pre, "[Id] ");
expect(marks().map((mark) => mark.textContent)).toEqual(["[Id] "]);
});
});
@@ -0,0 +1,69 @@
/**
* Search highlighting for the table-info DDL panel (#9212).
*
* The DDL is syntax-highlighted and rendered once; the search marks are then
* applied straight to that rendered DOM. Rebuilding the highlighted HTML for
* every keystroke used to re-parse the whole document (these DDLs are often
* tens to hundreds of KB) and re-run the SQL highlighter, which made typing in
* the DDL search box look like a freeze.
*/
export const DDL_SEARCH_MARK_CLASS = "ddl-search-match";
/** Removes every search mark, keeping the underlying DDL text untouched. */
export function clearDdlSearchMarks(pre: HTMLElement | null | undefined): void {
if (!pre) return;
const marks = Array.from(pre.querySelectorAll(`mark.${DDL_SEARCH_MARK_CLASS}`));
for (const mark of marks) {
const parent = mark.parentNode;
if (!parent) continue;
parent.replaceChild(pre.ownerDocument.createTextNode(mark.textContent ?? ""), mark);
parent.normalize();
}
}
/**
* Rebuilds the search marks for `query` inside the rendered DDL.
*
* Only text nodes are searched, so a match never spans element boundaries —
* the same limit the previous HTML-rewriting implementation had. Matching is
* case-insensitive and literal (no regex), which keeps `(`, `%` and friends
* from being interpreted as patterns.
*/
export function applyDdlSearchMarks(pre: HTMLElement | null | undefined, query: string): void {
if (!pre) return;
clearDdlSearchMarks(pre);
const needle = query.toLowerCase();
if (!needle) return;
const textNodes: Text[] = [];
const walker = pre.ownerDocument.createTreeWalker(pre, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
if (walker.currentNode.nodeType === Node.TEXT_NODE) textNodes.push(walker.currentNode as Text);
}
for (const node of textNodes) {
const text = node.nodeValue ?? "";
if (!text) continue;
const lowered = text.toLowerCase();
// Some characters change length when lower-cased; fall back to a
// case-sensitive scan so the match offsets stay aligned with `text`.
const haystack = lowered.length === text.length ? lowered : text;
const search = lowered.length === text.length ? needle : query;
let index = haystack.indexOf(search);
if (index === -1) continue;
const fragment = pre.ownerDocument.createDocumentFragment();
let cursor = 0;
while (index !== -1) {
if (index > cursor) fragment.appendChild(pre.ownerDocument.createTextNode(text.slice(cursor, index)));
const mark = pre.ownerDocument.createElement("mark");
mark.className = DDL_SEARCH_MARK_CLASS;
mark.textContent = text.slice(index, index + search.length);
fragment.appendChild(mark);
cursor = index + search.length;
index = haystack.indexOf(search, cursor);
}
if (cursor < text.length) fragment.appendChild(pre.ownerDocument.createTextNode(text.slice(cursor)));
node.parentNode?.replaceChild(fragment, node);
}
}