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); },