perf(table-info): mark DDL search hits on the rendered DOM
This commit is contained in:
6 files changed
+172
-15
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user