From 9788c20d3c82dd4f247ed609aca1e76b688bb978 Mon Sep 17 00:00:00 2001 From: onenewcode Date: Fri, 18 Sep 2026 20:05:26 +0800 Subject: [PATCH] feat(mongodb): complete fields in filter and sort bars --- .../components/document/DocumentBrowser.vue | 385 ++++++++- .../document/DocumentQueryCompletionMenu.vue | 81 ++ .../DocumentBrowserFieldSearch.spec.ts | 5 + .../DocumentBrowserQueryCompletion.spec.ts | 750 ++++++++++++++++++ .../mongoDocumentQueryCompletion.spec.ts | 147 ++++ apps/desktop/src/lib/mongo/mongoCompletion.ts | 164 +++- packages/app-tests/documentBrowser.test.ts | 8 +- 7 files changed, 1526 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/components/document/DocumentQueryCompletionMenu.vue create mode 100644 apps/desktop/src/components/document/__tests__/DocumentBrowserQueryCompletion.spec.ts create mode 100644 apps/desktop/src/lib/mongo/__tests__/mongoDocumentQueryCompletion.spec.ts diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index be32fb5172..765ab4c77c 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -20,6 +20,7 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue"; import * as api from "@/lib/backend/api"; import type { DynamoDbIndexInfo, DynamoDbTableDescription } from "@/lib/backend/api"; import { useConnectionStore } from "@/stores/connectionStore"; +import { getDataGridConditionSuggestionPosition, type DataGridConditionSuggestionPosition } from "@/lib/dataGrid/dataGridConditionSuggestionPosition"; import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit"; import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle"; import { ELASTICSEARCH_DEFAULT_MAX_RESULT_WINDOW, clampDocumentPage, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals"; @@ -85,6 +86,18 @@ import { serializeMongoDocumentId, type MongoInputValue, } from "@/lib/mongo/mongoDocumentValues"; +import { + buildMongoCompletionItemsFromContext, + getMongoDocumentQueryCompletionContext, + inferMongoCompletionFields, + mongoCompletionNeedsFields, + plainMongoCompletionInsertion, + readMongoPropertyPrefix, + shouldAutoOpenMongoDocumentQueryCompletion, + type MongoCompletionField, + type MongoCompletionItem, + type MongoDocumentQueryKind, +} from "@/lib/mongo/mongoCompletion"; import { mongoDocumentsToQueryResult } from "@/lib/mongo/mongoShellCommand"; import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; @@ -95,6 +108,7 @@ import { matchesElasticsearchIndexPattern, subscribeElasticsearchIndexCleared, t import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import { copyToClipboard } from "@/lib/common/clipboard"; +import DocumentQueryCompletionMenu from "./DocumentQueryCompletionMenu.vue"; import JsonEditNode from "./JsonEditNode.vue"; import type { EditNode } from "@/types/editor"; @@ -964,6 +978,8 @@ function resizeDocumentQueryInput(el: HTMLTextAreaElement | undefined) { function resizeDocumentQueryInputs() { resizeDocumentQueryInput(filterInputRef.value); resizeDocumentQueryInput(sortInputRef.value); + // A bar that just grew a line moved the menu's anchor with it. + repositionOpenDocumentQueryCompletions(); } function formatFilterInput() { @@ -990,6 +1006,330 @@ watch([filterInput, sortInput], () => { void nextTick(resizeDocumentQueryInputs); }); +/* ---------------------------------------------------------------- * + * Filter / sort bar completion (MongoDB) + * + * The bars hold a bare query document, so they reuse the same field + * and operator tables as the query editor's `find({ … })` completion + * — a collection's field names are exactly what is too long to + * remember and retype here. Only MongoDB opts in: the other document + * stores put their own dialects in these inputs. + * ---------------------------------------------------------------- */ + +type DocumentQueryCompletionTarget = "filter" | "sort"; + +const DOCUMENT_QUERY_COMPLETION_MENU_LIMIT = 50; + +const documentQueryCompletionTarget = ref(null); +const documentQueryCompletionItems = ref([]); +const documentQueryCompletionIndex = ref(0); +const documentQueryCompletionPosition = ref({ left: 0, top: 0, width: 0 }); +const documentQueryCompletionListboxId = `document-query-completions-${uuid()}`; +const documentQueryCompletionEnabled = computed(() => documentStoreProvider.value.kind === "mongodb"); +const documentQueryCompletionOpen = computed(() => documentQueryCompletionTarget.value !== null && documentQueryCompletionItems.value.length > 0); +const documentQueryCompletionActiveDescendant = computed(() => (documentQueryCompletionOpen.value ? `${documentQueryCompletionListboxId}-option-${documentQueryCompletionIndex.value}` : undefined)); +// Guards the field lookup: a keystroke that lands while a previous refresh is +// still awaiting fields must win, and a dismiss must cancel both. +let documentQueryCompletionRequestId = 0; + +function documentQueryCompletionKind(target: DocumentQueryCompletionTarget): MongoDocumentQueryKind { + return target === "filter" ? "filter" : "sortKeys"; +} + +function documentQueryCompletionInputEl(target: DocumentQueryCompletionTarget): HTMLTextAreaElement | undefined { + return target === "filter" ? filterInputRef.value : sortInputRef.value; +} + +function documentQueryCompletionText(target: DocumentQueryCompletionTarget): string { + return target === "filter" ? filterInput.value : sortInput.value; +} + +// Walking every loaded document is too much to redo on each keystroke — under +// infinite scroll `documents` holds every page fetched so far — so the page's +// fields are derived once per load and reused until the rows change. +const documentQueryCompletionLocalFields = computed(() => (documentQueryCompletionEnabled.value ? inferMongoCompletionFields(documents.value) : [])); + +/** + * Fields the collection is known to have: those visible in the loaded page, + * which carry the types the grid already inferred, plus the store's cached + * server-side sample, which also covers fields the current page happens not to + * contain. + */ +async function documentQueryCompletionFields(): Promise { + const byName = new Map(documentQueryCompletionLocalFields.value.map((field) => [field.name, field])); + let sampled: MongoCompletionField[] = []; + try { + sampled = await connectionStore.listMongoCompletionFields(props.connectionId, props.database, props.collection); + } catch { + sampled = []; + } + for (const field of sampled) if (!byName.has(field.name)) byName.set(field.name, field); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +function dismissDocumentQueryCompletions() { + documentQueryCompletionRequestId++; + documentQueryCompletionTarget.value = null; + documentQueryCompletionItems.value = []; + documentQueryCompletionIndex.value = 0; +} + +async function refreshDocumentQueryCompletions(target: DocumentQueryCompletionTarget, options: { force?: boolean } = {}) { + if (!documentQueryCompletionEnabled.value) return; + const text = documentQueryCompletionText(target); + const cursor = documentQueryCompletionInputEl(target)?.selectionStart ?? text.length; + const kind = documentQueryCompletionKind(target); + + // Without `force` (Ctrl/Cmd+Space, or a just-accepted item that opens a new + // position) the menu only appears for characters that start something. + if (!options.force && !shouldAutoOpenMongoDocumentQueryCompletion(text, cursor, kind)) { + dismissDocumentQueryCompletions(); + return; + } + + const context = getMongoDocumentQueryCompletionContext(text, cursor, kind); + if (context.mode === "none") { + dismissDocumentQueryCompletions(); + return; + } + + const requestId = ++documentQueryCompletionRequestId; + const fields = mongoCompletionNeedsFields(context.mode) ? await documentQueryCompletionFields() : []; + if (requestId !== documentQueryCompletionRequestId) return; + + const items = buildMongoCompletionItemsFromContext(context, { fields }).slice(0, DOCUMENT_QUERY_COMPLETION_MENU_LIMIT); + if (items.length === 0) { + dismissDocumentQueryCompletions(); + return; + } + documentQueryCompletionTarget.value = target; + documentQueryCompletionItems.value = items; + documentQueryCompletionIndex.value = 0; + updateDocumentQueryCompletionPosition(target); +} + +/** + * Anchors the menu to its input in viewport coordinates, which is what a + * teleported menu needs: the grid's toolbar clips both axes, so the menu cannot + * live next to the input in the DOM. + */ +function updateDocumentQueryCompletionPosition(target: DocumentQueryCompletionTarget) { + const input = documentQueryCompletionInputEl(target); + if (!input) return; + documentQueryCompletionPosition.value = getDataGridConditionSuggestionPosition(input.getBoundingClientRect(), { + viewportWidth: window.innerWidth, + minWidth: 240, + maxWidth: 460, + }); +} + +// Teleported out of the input's box, the menu cannot follow it on its own: the +// toolbar scrolls horizontally and the window resizes without the input ever +// being touched. +function repositionOpenDocumentQueryCompletions() { + const target = documentQueryCompletionTarget.value; + if (target) updateDocumentQueryCompletionPosition(target); +} + +watch(documentQueryCompletionOpen, (open) => { + if (open) { + window.addEventListener("scroll", repositionOpenDocumentQueryCompletions, true); + window.addEventListener("resize", repositionOpenDocumentQueryCompletions); + } else { + window.removeEventListener("scroll", repositionOpenDocumentQueryCompletions, true); + window.removeEventListener("resize", repositionOpenDocumentQueryCompletions); + } +}); + +function selectDocumentQueryCompletion(index: number) { + if (index < 0 || index >= documentQueryCompletionItems.value.length) return; + documentQueryCompletionIndex.value = index; + void nextTick(() => { + const listbox = document.getElementById(documentQueryCompletionListboxId); + const option = document.getElementById(`${documentQueryCompletionListboxId}-option-${index}`); + if (!listbox || !option) return; + const listboxRect = listbox.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + if (optionRect.top < listboxRect.top) listbox.scrollTop -= listboxRect.top - optionRect.top; + else if (optionRect.bottom > listboxRect.bottom) listbox.scrollTop += optionRect.bottom - listboxRect.bottom; + }); +} + +function moveDocumentQueryCompletionSelection(direction: 1 | -1): boolean { + if (!documentQueryCompletionOpen.value) return false; + const count = documentQueryCompletionItems.value.length; + const next = Math.min(Math.max(documentQueryCompletionIndex.value + direction, 0), count - 1); + if (next !== documentQueryCompletionIndex.value) selectDocumentQueryCompletion(next); + return true; +} + +function documentQueryCompletionInsertion(index = documentQueryCompletionIndex.value) { + const target = documentQueryCompletionTarget.value; + const item = documentQueryCompletionItems.value[index]; + if (!target || !item) return null; + + const input = documentQueryCompletionInputEl(target); + const text = documentQueryCompletionText(target); + const cursor = input?.selectionStart ?? text.length; + const context = getMongoDocumentQueryCompletionContext(text, cursor, documentQueryCompletionKind(target)); + if (context.mode === "none") return null; + + // A quoted key completion writes both of its quotes, so the closing quote the + // input already holds has to go with the prefix it belongs to. + const to = Math.min((input?.selectionEnd ?? text.length) + (item.replaceClosingQuote ? 1 : 0), text.length); + const insertion = plainMongoCompletionInsertion(item.apply ?? item.label, text.slice(to)); + return { target, text, from: context.from, to, insertion }; +} + +function acceptDocumentQueryCompletion(index = documentQueryCompletionIndex.value): boolean { + const completion = documentQueryCompletionInsertion(index); + if (!completion) return false; + + const { target, text, from, to, insertion } = completion; + const next = `${text.slice(0, from)}${insertion.text}${text.slice(to)}`; + if (target === "filter") filterInput.value = next; + else sortInput.value = next; + dismissDocumentQueryCompletions(); + + void nextTick(() => { + const input = documentQueryCompletionInputEl(target); + if (!input) return; + input.focus(); + input.setSelectionRange(from + insertion.selectionStart, from + insertion.selectionEnd); + // A field completion ends at `field: `, an operator at its value — both are + // fresh positions with their own suggestions, so open the menu again. + void refreshDocumentQueryCompletions(target, { force: true }); + }); + return true; +} + +/** True when accepting would not change the text, so Enter should run the query instead. */ +function documentQueryCompletionMatchesInput(): boolean { + const completion = documentQueryCompletionInsertion(); + if (!completion) return false; + return completion.text.slice(completion.from, completion.to) === completion.insertion.text; +} + +/** + * Opens the document around the first character typed into an empty bar, so + * `d` becomes `{d}` with the caret left between the braces. + * + * The bars hold a bare document and a field name only reads as a key once its + * braces exist, so typing straight into an empty bar used to land in a position + * that classifies as nothing and suggested nothing — the very case #9427 + * reports. Nothing is lost by writing the braces: an unbraced bar never parses + * as a filter either, so that text was a dead query, not a shorter spelling of + * one. + * + * Only a lone character that could start a key qualifies. A paste arrives whole + * and usually brings its own braces, and a typed `{` is the user opening the + * document themselves — which already suggests. + * + * The edit has to be an insertion. Backspacing `ab` down to `a` leaves exactly + * the same one character and caret as typing `a` into an empty bar, and writing + * braces around text the user is in the middle of deleting would hand them two + * more characters to delete. + */ +function openDocumentQueryDocument(event: InputEvent, target: DocumentQueryCompletionTarget): boolean { + if (!documentQueryCompletionEnabled.value) return false; + if (!event.inputType?.startsWith("insert")) return false; + const text = documentQueryCompletionText(target); + const prefix = readMongoPropertyPrefix(text, text.length); + if ([...text].length !== 1 || !/^[$_"'\p{L}\p{N}]$/u.test(text) || prefix.from !== 0 || prefix.prefix !== text) return false; + if (documentQueryCompletionInputEl(target)?.selectionStart !== text.length) return false; + + if (target === "filter") filterInput.value = `{${text}}`; + else sortInput.value = `{${text}}`; + + // Rewriting the model moves the caret to the end, past the `}` we just added, + // where there is nothing to complete. Put it back inside before asking. + void nextTick(() => { + documentQueryCompletionInputEl(target)?.setSelectionRange(text.length + 1, text.length + 1); + void refreshDocumentQueryCompletions(target); + }); + return true; +} + +function onDocumentQueryInput(event: Event, target: DocumentQueryCompletionTarget) { + // `v-model` holds off on the model until the composition is confirmed, so a + // mid-composition refresh would suggest against the text as it was before the + // IME opened. Vue re-dispatches `input` once it commits, which is when the + // suggestions are worth computing. + if ((event as InputEvent).isComposing) return; + if (openDocumentQueryDocument(event as InputEvent, target)) return; + void refreshDocumentQueryCompletions(target); +} + +/** + * Dismissing also cancels whatever refresh is in flight, which is the point: + * the target is only set once `documentQueryCompletionFields` has resolved, so + * a bar blurred during that first (uncached) backend round trip would otherwise + * open its menu afterwards, over a bar that no longer has focus. Nothing else + * would take it down — the menu is teleported to `body` and there is no + * outside-click handler. + */ +function onDocumentQueryBlur(target: DocumentQueryCompletionTarget) { + if (documentQueryCompletionTarget.value === null || documentQueryCompletionTarget.value === target) dismissDocumentQueryCompletions(); +} + +/** + * The suggestions describe the position the caret was in when they were built, + * so a caret moved without an edit leaves them describing somewhere else: + * accepting one then splices a stale item at a freshly computed offset and + * garbles the text. Moving the caret closes the menu instead. + */ +function onDocumentQueryCaretMove(target: DocumentQueryCompletionTarget) { + if (documentQueryCompletionTarget.value === target) dismissDocumentQueryCompletions(); +} + +function onDocumentQueryKeydown(event: KeyboardEvent, target: DocumentQueryCompletionTarget) { + if (event.isComposing) return; + + if (documentQueryCompletionEnabled.value) { + if ((event.ctrlKey || event.metaKey) && event.code === "Space") { + event.preventDefault(); + void refreshDocumentQueryCompletions(target, { force: true }); + return; + } + if (event.key === "Escape" && documentQueryCompletionOpen.value) { + event.preventDefault(); + dismissDocumentQueryCompletions(); + return; + } + // These move the caret rather than the selection, so they leave the open + // suggestions describing a position the caret has left. The key still does + // its normal job — only the menu goes. + if (event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "Home" || event.key === "End") { + onDocumentQueryCaretMove(target); + return; + } + if (event.key === "ArrowDown" && moveDocumentQueryCompletionSelection(1)) { + event.preventDefault(); + return; + } + if (event.key === "ArrowUp" && moveDocumentQueryCompletionSelection(-1)) { + event.preventDefault(); + return; + } + if (event.key === "Tab" && !event.shiftKey && documentQueryCompletionOpen.value && acceptDocumentQueryCompletion()) { + event.preventDefault(); + return; + } + // Enter takes the highlighted suggestion first; a second Enter runs the query. + if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && documentQueryCompletionOpen.value && !documentQueryCompletionMatchesInput() && acceptDocumentQueryCompletion()) { + event.preventDefault(); + return; + } + } + + if (event.key !== "Enter") return; + const plainEnter = !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey; + if (!plainEnter && !((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey)) return; + event.preventDefault(); + dismissDocumentQueryCompletions(); + applyFilter(); +} + const documentQueryPreview = computed(() => { let filter = "{}"; try { @@ -2288,6 +2628,9 @@ onMounted(async () => { }); onBeforeUnmount(() => { persistDocumentBrowserState({ includeData: true }); + // The open/close watcher cannot run on unmount, so drop the menu's listeners here. + window.removeEventListener("scroll", repositionOpenDocumentQueryCompletions, true); + window.removeEventListener("resize", repositionOpenDocumentQueryCompletions); window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true); unsubscribeElasticsearchIndexCleared?.(); unsubscribeElasticsearchIndexCleared = undefined; @@ -2799,9 +3142,24 @@ defineExpose({ focusSearch }); rows="1" class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" - @keydown.enter.exact.prevent="applyFilter" - @keydown.ctrl.enter.prevent="applyFilter" - @keydown.meta.enter.prevent="applyFilter" + :aria-autocomplete="documentQueryCompletionEnabled ? 'list' : undefined" + :aria-controls="documentQueryCompletionTarget === 'filter' ? documentQueryCompletionListboxId : undefined" + :aria-activedescendant="documentQueryCompletionTarget === 'filter' ? documentQueryCompletionActiveDescendant : undefined" + :aria-expanded="documentQueryCompletionEnabled ? documentQueryCompletionTarget === 'filter' : undefined" + @blur="onDocumentQueryBlur('filter')" + @click="onDocumentQueryCaretMove('filter')" + @input="onDocumentQueryInput($event, 'filter')" + @keydown="onDocumentQueryKeydown($event, 'filter')" + /> +