From 9ded32c4c16b748b6d73d0437b588b247b8f313a Mon Sep 17 00:00:00 2001
From: t8y2 <1156263951@qq.com>
Date: Thu, 17 Sep 2026 21:02:41 +0800
Subject: [PATCH] fix(editor): navigate SQL hover search matches
Closes #9442
---
.../editor/__tests__/sqlHoverSearch.spec.ts | 224 +++++++++++++++++-
apps/desktop/src/lib/editor/sqlHoverSearch.ts | 84 +++++--
2 files changed, 278 insertions(+), 30 deletions(-)
diff --git a/apps/desktop/src/lib/editor/__tests__/sqlHoverSearch.spec.ts b/apps/desktop/src/lib/editor/__tests__/sqlHoverSearch.spec.ts
index f80b3efa5a..ef61f43985 100644
--- a/apps/desktop/src/lib/editor/__tests__/sqlHoverSearch.spec.ts
+++ b/apps/desktop/src/lib/editor/__tests__/sqlHoverSearch.spec.ts
@@ -1,10 +1,15 @@
// @vitest-environment happy-dom
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } 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");
+afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.replaceChildren();
+});
+
describe("findHoverSearchMatches", () => {
it("returns no matches for an empty or whitespace query", () => {
expect(findHoverSearchMatches(DDL, "")).toEqual([]);
@@ -93,12 +98,25 @@ describe("applyHoverSearchHighlights", () => {
expect(container.querySelector("mark")).toBeNull();
expect(container.textContent).toBe(DDL);
});
+
+ it("groups fragments of one logical match across syntax-highlight spans", () => {
+ const container = document.createElement("div");
+ container.innerHTML = "order_id + order_id";
+ const originalText = container.textContent!;
+ const marks = applyHoverSearchHighlights(container, findHoverSearchMatches(originalText, "order_id"));
+
+ expect(marks.map((mark) => mark.getAttribute("data-sql-hover-search-match-index"))).toEqual(["0", "0", "1"]);
+ expect(marks.map((mark) => mark.hasAttribute("data-sql-hover-search-active"))).toEqual([true, true, false]);
+ expect(container.querySelectorAll("span")).toHaveLength(3);
+ expect(container.textContent).toBe(originalText);
+ });
});
describe("createHoverSearch", () => {
- function setup() {
+ function setup(html?: string) {
const target = document.createElement("div");
- target.textContent = DDL;
+ if (html === undefined) target.textContent = DDL;
+ else target.innerHTML = html;
const controller = createHoverSearch({
target,
originalHtml: target.innerHTML,
@@ -106,7 +124,19 @@ describe("createHoverSearch", () => {
noResultLabel: "No matching columns",
});
const input = controller.element.querySelector('[data-sql-hover-search-input="true"]')!;
- return { target, controller, input };
+ const count = controller.element.querySelector('[data-sql-hover-search-count="true"]')!;
+ return { target, controller, input, count };
+ }
+
+ function pressKey(input: HTMLInputElement, key: string, options: KeyboardEventInit = {}) {
+ const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options });
+ input.dispatchEvent(event);
+ input.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true, ...options }));
+ return event;
+ }
+
+ function activeMarks(target: HTMLElement) {
+ return [...target.querySelectorAll('[data-sql-hover-search-active="true"]')];
}
it("highlights correctly when line breaks render as
elements", () => {
@@ -176,19 +206,19 @@ describe("createHoverSearch", () => {
});
it("stops keydown propagation so the editor keymap never sees typing", () => {
- const { input } = setup();
+ const { controller, input } = setup();
let leaked = false;
- document.addEventListener("keydown", () => (leaked = true));
+ controller.element.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();
+ const { target, controller, input } = setup();
input.value = "id";
input.dispatchEvent(new Event("input"));
let leaked = false;
- document.addEventListener("keydown", () => (leaked = true));
+ controller.element.addEventListener("keydown", () => (leaked = true));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(input.value).toBe("");
@@ -197,9 +227,9 @@ describe("createHoverSearch", () => {
});
it("keeps pointerdown from bubbling to the editor (click focuses the input)", () => {
- const { input } = setup();
+ const { controller, input } = setup();
let leaked = false;
- document.addEventListener("pointerdown", () => (leaked = true));
+ controller.element.addEventListener("pointerdown", () => (leaked = true));
input.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
expect(leaked).toBe(false);
});
@@ -212,4 +242,178 @@ describe("createHoverSearch", () => {
// No re-render after destroy.
expect(target.querySelector("mark")).toBeNull();
});
+
+ it("shows the current match count and cycles forward on Enter without rerendering", () => {
+ const { target, controller, input, count } = setup("insurance_class + insurance_class AS insurance_class");
+ const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
+ document.body.append(controller.element, target);
+ input.focus();
+ input.value = "insurance_class";
+ input.dispatchEvent(new Event("input"));
+ const marks = [...target.querySelectorAll("mark")];
+
+ expect(count.hidden).toBe(false);
+ expect(count.textContent).toBe("1 / 3");
+ expect(count.getAttribute("role")).toBe("status");
+ expect(count.getAttribute("aria-live")).toBe("polite");
+ expect(activeMarks(target)).toEqual([marks[0]]);
+ expect(scroll).toHaveBeenCalledTimes(1);
+
+ for (const index of [1, 2, 0]) {
+ expect(pressKey(input, "Enter").defaultPrevented).toBe(true);
+ expect(count.textContent).toBe(`${index + 1} / 3`);
+ expect(activeMarks(target)).toEqual([marks[index]]);
+ expect(scroll.mock.instances.at(-1)).toBe(marks[index]);
+ }
+
+ expect(scroll).toHaveBeenCalledTimes(4);
+ expect(target.querySelector("mark")).toBe(marks[0]);
+ expect(document.activeElement).toBe(input);
+ });
+
+ it("cycles backward on Shift+Enter and handles one match", () => {
+ const { target, input, count } = setup("id + id + id");
+ input.value = "id";
+ input.dispatchEvent(new Event("input"));
+
+ pressKey(input, "Enter", { shiftKey: true });
+ expect(count.textContent).toBe("3 / 3");
+ pressKey(input, "Enter", { shiftKey: true });
+ expect(count.textContent).toBe("2 / 3");
+
+ input.value = "id + id + id";
+ input.dispatchEvent(new Event("input"));
+ pressKey(input, "Enter");
+ pressKey(input, "Enter", { shiftKey: true });
+ expect(count.textContent).toBe("1 / 1");
+ expect(activeMarks(target)).toHaveLength(1);
+ });
+
+ it("counts cross-span matches once and navigates every fragment together", () => {
+ const { target, input, count } = setup("order_id
order_id");
+ input.value = "order_id";
+ input.dispatchEvent(new Event("input"));
+ const marks = [...target.querySelectorAll("mark")];
+
+ expect(marks).toHaveLength(3);
+ expect(count.textContent).toBe("1 / 2");
+ expect(activeMarks(target)).toEqual([marks[0], marks[1]]);
+ pressKey(input, "Enter");
+ expect(count.textContent).toBe("2 / 2");
+ expect(activeMarks(target)).toEqual([marks[2]]);
+ pressKey(input, "Enter");
+ expect(activeMarks(target)).toEqual([marks[0], marks[1]]);
+ expect(target.querySelectorAll("span")).toHaveLength(3);
+ expect(target.querySelectorAll("br")).toHaveLength(1);
+ });
+
+ it("resets the active match when the query changes and hides the count on Escape", () => {
+ const { target, controller, input, count } = setup();
+ expect(count.hidden).toBe(true);
+ input.value = "id";
+ input.dispatchEvent(new Event("input"));
+ pressKey(input, "Enter");
+ expect(count.textContent).toBe("2 / 2");
+
+ input.value = "order";
+ input.dispatchEvent(new Event("input"));
+ expect(count.textContent).toBe("1 / 2");
+ expect(activeMarks(target)[0]).toBe(target.querySelector("mark"));
+ pressKey(input, "Escape");
+ expect(input.value).toBe("");
+ expect(count.hidden).toBe(true);
+ expect(count.textContent).toBe("");
+ expect(controller.status.hidden).toBe(true);
+ expect(target.textContent).toBe(DDL);
+ expect(target.querySelector("mark")).toBeNull();
+ });
+
+ it("shows zero matches without scrolling and ignores navigation for blank queries", () => {
+ const { target, controller, input, count } = setup();
+ const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
+ input.value = "missing";
+ input.dispatchEvent(new Event("input"));
+ pressKey(input, "Enter");
+ pressKey(input, "Enter", { shiftKey: true });
+ expect(count.hidden).toBe(false);
+ expect(count.textContent).toBe("0 / 0");
+ expect(controller.status.hidden).toBe(false);
+ expect(activeMarks(target)).toHaveLength(0);
+
+ input.value = " ";
+ input.dispatchEvent(new Event("input"));
+ pressKey(input, "Enter");
+ expect(count.hidden).toBe(true);
+ expect(controller.status.hidden).toBe(true);
+ expect(scroll).not.toHaveBeenCalled();
+ });
+
+ it.each(["Enter", "Escape"])("preserves %s while the keyboard event is composing", (key) => {
+ const { target, input } = setup("id + id");
+ input.value = "id";
+ input.dispatchEvent(new Event("input"));
+ const firstMatch = activeMarks(target)[0];
+
+ const event = pressKey(input, key, { isComposing: true });
+ expect(event.defaultPrevented).toBe(false);
+ expect(input.value).toBe("id");
+ expect(activeMarks(target)).toEqual([firstMatch]);
+ });
+
+ it("uses composition lifecycle events when a key event omits isComposing", () => {
+ const { target, input, count } = setup("id + id");
+ input.value = "id";
+ input.dispatchEvent(new Event("input"));
+ const firstMatch = activeMarks(target)[0];
+ input.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
+
+ expect(pressKey(input, "Enter").defaultPrevented).toBe(false);
+ expect(pressKey(input, "Escape").defaultPrevented).toBe(false);
+ expect(input.value).toBe("id");
+ expect(activeMarks(target)).toEqual([firstMatch]);
+
+ input.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
+ pressKey(input, "Enter");
+ expect(count.textContent).toBe("2 / 2");
+ });
+
+ it("ignores the legacy IME key code and does not clear on Escape keyup", () => {
+ const { target, input } = setup("id + id");
+ input.value = "id";
+ input.dispatchEvent(new Event("input"));
+ const firstMatch = activeMarks(target)[0];
+
+ expect(pressKey(input, "Enter", { keyCode: 229 }).defaultPrevented).toBe(false);
+ input.dispatchEvent(new KeyboardEvent("keyup", { key: "Escape", bubbles: true }));
+ expect(input.value).toBe("id");
+ expect(activeMarks(target)).toEqual([firstMatch]);
+ });
+
+ it.each([{ ctrlKey: true }, { metaKey: true }, { altKey: true }])("does not navigate or leak modified Enter with %j", (modifiers) => {
+ const { target, controller, input } = setup("id + id");
+ const keydown = vi.fn();
+ const keyup = vi.fn();
+ controller.element.addEventListener("keydown", keydown);
+ controller.element.addEventListener("keyup", keyup);
+ input.value = "id";
+ input.dispatchEvent(new Event("input"));
+ const firstMatch = activeMarks(target)[0];
+
+ pressKey(input, "Enter", modifiers);
+ expect(activeMarks(target)).toEqual([firstMatch]);
+ expect(keydown).not.toHaveBeenCalled();
+ expect(keyup).not.toHaveBeenCalled();
+ });
+
+ it("stops navigation after destroy", () => {
+ const { target, controller, input, count } = setup("id + id");
+ input.value = "id";
+ input.dispatchEvent(new Event("input"));
+ const firstMatch = activeMarks(target)[0];
+ controller.destroy();
+
+ expect(pressKey(input, "Enter").defaultPrevented).toBe(false);
+ expect(count.textContent).toBe("1 / 2");
+ expect(activeMarks(target)).toEqual([firstMatch]);
+ });
});
diff --git a/apps/desktop/src/lib/editor/sqlHoverSearch.ts b/apps/desktop/src/lib/editor/sqlHoverSearch.ts
index 590ad6a043..96bb26974e 100644
--- a/apps/desktop/src/lib/editor/sqlHoverSearch.ts
+++ b/apps/desktop/src/lib/editor/sqlHoverSearch.ts
@@ -15,6 +15,7 @@
*/
const MATCH_ATTRIBUTE = "data-sql-hover-search-match";
+const MATCH_INDEX_ATTRIBUTE = "data-sql-hover-search-match-index";
const ACTIVE_ATTRIBUTE = "data-sql-hover-search-active";
export interface HoverSearchMatch {
@@ -53,8 +54,8 @@ export function findHoverSearchMatches(text: string, query: string): HoverSearch
* container's `textContent`); call {@link clearHoverSearchHighlights} (or
* restore the original HTML) before re-applying.
*
- * Returns the created `` elements in document order; the first is tagged
- * as the active match so callers can scroll it into view.
+ * Returns the created `` elements in document order, grouped by match
+ * index. Every fragment of the first match is tagged active.
*/
export function applyHoverSearchHighlights(container: HTMLElement, matches: HoverSearchMatch[]): HTMLElement[] {
if (matches.length === 0) return [];
@@ -71,19 +72,26 @@ export function applyHoverSearchHighlights(container: HTMLElement, matches: Hove
}
const marks: HTMLElement[] = [];
+ let firstMatchIndex = 0;
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;
+ while (firstMatchIndex < matches.length && matches[firstMatchIndex].end <= start) firstMatchIndex++;
+ if (firstMatchIndex === matches.length) break;
+ if (start === end || matches[firstMatchIndex].start >= end) continue;
const data = node.data;
const fragment = document.createDocumentFragment();
let cursor = 0;
- for (const { from, to } of segments) {
+ for (let matchIndex = firstMatchIndex; matchIndex < matches.length && matches[matchIndex].start < end; matchIndex++) {
+ const match = matches[matchIndex];
+ const from = Math.max(match.start, start) - start;
+ const to = Math.min(match.end, end) - start;
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.setAttribute(MATCH_INDEX_ATTRIBUTE, String(matchIndex));
+ if (matchIndex === 0) mark.setAttribute(ACTIVE_ATTRIBUTE, "true");
+ mark.className =
+ "rounded-[2px] bg-yellow-300/70 px-px text-inherit dark:bg-yellow-500/40 data-[sql-hover-search-active=true]:bg-yellow-400/90 data-[sql-hover-search-active=true]:outline data-[sql-hover-search-active=true]:outline-1 data-[sql-hover-search-active=true]:outline-yellow-600 dark:data-[sql-hover-search-active=true]:bg-yellow-500/70 dark:data-[sql-hover-search-active=true]:outline-yellow-400";
mark.textContent = data.slice(from, to);
fragment.appendChild(mark);
marks.push(mark);
@@ -93,7 +101,6 @@ export function applyHoverSearchHighlights(container: HTMLElement, matches: Hove
node.replaceWith(fragment);
}
- marks[0]?.setAttribute(ACTIVE_ATTRIBUTE, "true");
return marks;
}
@@ -140,7 +147,7 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
const element = document.createElement("div");
element.dataset.sqlHoverSearch = "true";
- element.className = "mt-2 flex-none";
+ element.className = "mt-2 flex flex-none items-center gap-2";
const input = document.createElement("input");
input.type = "text";
@@ -149,18 +156,41 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
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";
+ input.className = "min-w-0 flex-1 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 matchCount = document.createElement("span");
+ matchCount.dataset.sqlHoverSearchCount = "true";
+ matchCount.className = "shrink-0 text-[11px] tabular-nums text-muted-foreground";
+ matchCount.setAttribute("role", "status");
+ matchCount.setAttribute("aria-live", "polite");
+ matchCount.setAttribute("aria-atomic", "true");
+ matchCount.hidden = true;
+ element.appendChild(matchCount);
+
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;
+ let matchGroups: HTMLElement[][] = [];
+ let activeMatchIndex = -1;
+ let isComposing = false;
+
+ const activateMatch = (index: number) => {
+ for (const mark of matchGroups[activeMatchIndex] ?? []) mark.removeAttribute(ACTIVE_ATTRIBUTE);
+ activeMatchIndex = index;
+ for (const mark of matchGroups[index]) mark.setAttribute(ACTIVE_ATTRIBUTE, "true");
+ matchCount.textContent = `${index + 1} / ${matchGroups.length}`;
+ matchGroups[index][0]?.scrollIntoView({ block: "nearest" });
+ };
+
const runSearch = () => {
// Restore the pristine highlighted DDL, then re-apply match highlights.
target.innerHTML = originalHtml;
+ matchGroups = [];
+ activeMatchIndex = -1;
const query = input.value;
// Search the rendered text, not the raw DDL string: the highlighter emits
// `
` for line breaks, so `textContent` differs from the raw DDL by the
@@ -168,23 +198,27 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
// `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 hasQuery = query.trim().length > 0;
+ matchCount.hidden = !hasQuery;
+ matchCount.textContent = hasQuery ? "0 / 0" : "";
+ status.hidden = !hasQuery || matches.length > 0;
+ if (matches.length === 0) return;
+
const marks = applyHoverSearchHighlights(target, matches);
- marks[0]?.scrollIntoView({ block: "nearest" });
+ matchGroups = Array.from({ length: matches.length }, () => []);
+ for (const mark of marks) matchGroups[Number(mark.getAttribute(MATCH_INDEX_ATTRIBUTE))].push(mark);
+ activateMatch(0);
};
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.type !== "keydown" || isComposing || event.isComposing || event.keyCode === 229) return;
+ if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ if (matchGroups.length > 0) activateMatch((activeMatchIndex + (event.shiftKey ? -1 : 1) + matchGroups.length) % matchGroups.length);
+ }
if (event.key === "Escape" && input.value) {
event.preventDefault();
input.value = "";
@@ -194,10 +228,18 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
const stopPointer = (event: Event) => {
event.stopPropagation();
};
+ const startComposition = () => {
+ isComposing = true;
+ };
+ const endComposition = () => {
+ isComposing = false;
+ };
input.addEventListener("input", runSearch);
input.addEventListener("keydown", stopKeyboard);
input.addEventListener("keyup", stopKeyboard);
+ input.addEventListener("compositionstart", startComposition);
+ input.addEventListener("compositionend", endComposition);
input.addEventListener("pointerdown", stopPointer);
input.addEventListener("mousedown", stopPointer);
@@ -211,6 +253,8 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
input.removeEventListener("input", runSearch);
input.removeEventListener("keydown", stopKeyboard);
input.removeEventListener("keyup", stopKeyboard);
+ input.removeEventListener("compositionstart", startComposition);
+ input.removeEventListener("compositionend", endComposition);
input.removeEventListener("pointerdown", stopPointer);
input.removeEventListener("mousedown", stopPointer);
},