diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index cab5c652c1..361d28dae2 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -67,6 +67,7 @@ import { } from "@/lib/editorZoom"; import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/codemirrorTrimmedSelectionLayer"; +import { selectionMatchOccurrences } from "@/lib/codemirrorSelectionMatches"; import * as api from "@/lib/api"; import { areSqlSemanticDiagnosticsEqual, @@ -1487,6 +1488,7 @@ onMounted(async () => { foldGutter(), drawSelection(), trimmedSelectionLayer(), + selectionMatchOccurrences(), dropCursor(), EditorState.allowMultipleSelections.of(true), indentOnInput(), diff --git a/apps/desktop/src/lib/codemirrorSelectionMatches.ts b/apps/desktop/src/lib/codemirrorSelectionMatches.ts new file mode 100644 index 0000000000..feab1c84b7 --- /dev/null +++ b/apps/desktop/src/lib/codemirrorSelectionMatches.ts @@ -0,0 +1,141 @@ +import type { EditorState, Extension } from "@codemirror/state"; +import { highlightSelectionMatches, SearchCursor } from "@codemirror/search"; +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; + +const MIN_SELECTION_MATCH_LENGTH = 2; +const MAX_SELECTION_MATCH_LENGTH = 200; +const MAX_SCROLLBAR_MARKS = 1200; + +interface SelectionMatchMarker { + from: number; + to: number; + lineNumber: number; + selected: boolean; +} + +function selectedMatchQuery(state: EditorState): string | null { + const selection = state.selection.main; + if (selection.empty) return null; + if (selection.to - selection.from > MAX_SELECTION_MATCH_LENGTH) return null; + + const text = state.sliceDoc(selection.from, selection.to); + if (text.length < MIN_SELECTION_MATCH_LENGTH || !text.trim() || /[\r\n]/.test(text)) return null; + return text; +} + +function buildSelectionMatchMarkers(state: EditorState): SelectionMatchMarker[] { + const query = selectedMatchQuery(state); + if (!query) return []; + + const selection = state.selection.main; + const markers: SelectionMatchMarker[] = []; + const seenLines = new Set(); + const cursor = new SearchCursor(state.doc, query); + + for (let match = cursor.next(); !match.done; match = cursor.next()) { + const { from, to } = match.value; + if (from === to) continue; + + const line = state.doc.lineAt(from); + const selected = from === selection.from && to === selection.to; + const lineKey = selected ? -line.number : line.number; + if (seenLines.has(lineKey)) continue; + seenLines.add(lineKey); + + markers.push({ from, to, lineNumber: line.number, selected }); + if (markers.length >= MAX_SCROLLBAR_MARKS) break; + } + + return markers; +} + +class SelectionMatchScrollbar { + private readonly layer: HTMLElement; + private signature = ""; + + constructor(view: EditorView) { + this.layer = document.createElement("div"); + this.layer.className = "cm-selectionMatchScrollbarLayer"; + view.dom.appendChild(this.layer); + this.render(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet || update.geometryChanged) { + this.render(update.view); + } + } + + destroy() { + this.layer.remove(); + } + + private render(view: EditorView) { + const markers = buildSelectionMatchMarkers(view.state); + const lineCount = view.state.doc.lines; + const signature = markers + .map((marker) => `${marker.lineNumber}:${marker.from}:${marker.to}:${marker.selected}`) + .join("|"); + if (signature === this.signature) return; + this.signature = signature; + this.layer.replaceChildren( + ...markers.map((marker) => { + const el = document.createElement("div"); + el.className = marker.selected + ? "cm-selectionMatchScrollbarMark cm-selectionMatchScrollbarMark-selected" + : "cm-selectionMatchScrollbarMark"; + const ratio = lineCount <= 1 ? 0 : (marker.lineNumber - 1) / (lineCount - 1); + el.style.top = `${Math.max(0, Math.min(1, ratio)) * 100}%`; + return el; + }), + ); + } +} + +const selectionMatchScrollbar = ViewPlugin.fromClass(SelectionMatchScrollbar); + +export function selectionMatchOccurrences(): Extension { + return [ + highlightSelectionMatches({ + minSelectionLength: MIN_SELECTION_MATCH_LENGTH, + maxMatches: 500, + }), + selectionMatchScrollbar, + EditorView.baseTheme({ + "&": { + position: "relative", + }, + ".cm-selectionMatch": { + backgroundColor: "color-mix(in oklab, var(--foreground) 8%, transparent)", + outline: "1px solid color-mix(in oklab, var(--foreground) 10%, transparent)", + }, + ".cm-selectionMatch-main": { + backgroundColor: "color-mix(in oklab, var(--primary) 14%, transparent)", + outline: "1px solid color-mix(in oklab, var(--primary) 24%, transparent)", + }, + ".cm-selectionMatchScrollbarLayer": { + bottom: "2px", + pointerEvents: "none", + position: "absolute", + right: "2px", + top: "2px", + width: "5px", + zIndex: "40", + }, + ".cm-selectionMatchScrollbarMark": { + backgroundColor: "color-mix(in oklab, var(--primary) 48%, transparent)", + borderRadius: "999px", + height: "2px", + position: "absolute", + right: "0", + transform: "translateY(-50%)", + width: "3px", + }, + ".cm-selectionMatchScrollbarMark-selected": { + backgroundColor: "color-mix(in oklab, var(--primary) 72%, transparent)", + height: "3px", + width: "4px", + }, + }), + ]; +}