diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 7de48712bc..6e5415afd1 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -2,7 +2,7 @@ import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue"; import { uuid } from "@/lib/common/utils"; import { useI18n } from "vue-i18n"; -import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue"; +import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; @@ -82,7 +82,6 @@ import { } from "@/lib/mongo/mongoDocumentValues"; import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; -import { findDocumentTextMatches, renderDocumentJsonHtml } from "@/lib/document/documentJsonSearch"; import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage"; import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore"; @@ -146,12 +145,7 @@ const sortInputRef = ref(); const dataGridRef = ref>(); const viewOptionsOpen = ref(false); const mongoUpdateTarget = computed(() => (props.databaseType === "mongodb" && mongoCopyDocumentsAvailable.value ? { collection: props.collection, idColumn: "_id" as const } : undefined)); -const documentViewerRef = ref(); -const documentSearchInputRef = ref(); -const documentSearchOpen = ref(false); -const documentSearchQuery = ref(""); -const documentSearchMatchIndex = ref(0); -const documentSearchHasNavigated = ref(false); +const documentJsonEditorRef = ref<{ openSearch: () => boolean }>(); const documentViewerSearchActive = ref(false); function openDataGridExtractorConfiguration() { @@ -285,16 +279,6 @@ const selectedDocumentIdLabel = computed(() => { return typeof id === "object" ? stringifyDocumentStoreValue(id, documentStoreProvider.value.kind) : String(id); }); const selectedDocumentIdWidth = computed(() => `${Math.min(Math.max(Array.from(selectedDocumentIdLabel.value).length + 2, 5), 52)}ch`); -const documentSearchText = computed(() => editJson.value); -const documentSearchMatches = computed(() => findDocumentTextMatches(documentSearchText.value, documentSearchQuery.value)); -const documentSearchActiveIndex = computed(() => { - if (documentSearchMatches.value.length === 0) return 0; - return Math.min(documentSearchMatchIndex.value, documentSearchMatches.value.length - 1); -}); -const documentSearchStatus = computed(() => { - const count = documentSearchMatches.value.length; - return count > 0 ? `${documentSearchActiveIndex.value + 1}/${count}` : "0/0"; -}); const editKeyWidth = computed(() => { const longest = editFields.value.reduce((max, field) => { @@ -1591,15 +1575,12 @@ function docPreview(doc: JsonRecord): string { return `${id} - ${preview}`; } -function highlightedJson(json: string): string { - return renderDocumentJsonHtml(json, documentSearchOpen.value ? documentSearchQuery.value : "", documentSearchActiveIndex.value); -} - function handleDocumentViewerDoubleClick(event: MouseEvent) { const target = event.target; if (!(target instanceof Element)) return; - const jsonViewer = target.closest(".json-viewer"); - if (jsonViewer && target !== jsonViewer) return; + // CodeMirror uses .cm-line for rendered document content. Preserve the + // existing shortcut only for whitespace around the source, not text itself. + if (target.closest(".cm-line")) return; const selection = window.getSelection(); if (selection && !selection.isCollapsed && selection.toString()) return; startEdit(); @@ -1614,55 +1595,12 @@ function focusSearch(): boolean { if (viewMode.value !== "document" || !documentViewerSearchActive.value) return false; if (isEditing.value) return false; if (!isNew.value && selectedIdx.value === null) return false; - documentSearchOpen.value = true; - documentSearchHasNavigated.value = false; - void nextTick(() => { - documentSearchInputRef.value?.focus(); - documentSearchInputRef.value?.select(); - }); - return true; + return documentJsonEditorRef.value?.openSearch() ?? false; } -function closeDocumentSearch() { - documentSearchOpen.value = false; - void nextTick(() => { - documentViewerRef.value?.focus(); - }); -} - -function moveDocumentSearchMatch(delta: -1 | 1) { - const count = documentSearchMatches.value.length; - if (count === 0) return; - documentSearchMatchIndex.value = (documentSearchActiveIndex.value + delta + count) % count; - documentSearchHasNavigated.value = true; - void scrollDocumentSearchMatchIntoView(); -} - -function activateDocumentSearchMatch(delta: -1 | 1) { - if (documentSearchMatches.value.length === 0) return; - if (!documentSearchHasNavigated.value) { - documentSearchHasNavigated.value = true; - void scrollDocumentSearchMatchIntoView(); - return; - } - moveDocumentSearchMatch(delta); -} - -async function scrollDocumentSearchMatchIntoView() { - await nextTick(); - documentViewerRef.value?.querySelector('[data-document-search-active="true"]')?.scrollIntoView({ block: "center", inline: "nearest" }); -} - -watch([documentSearchQuery, documentSearchText], () => { - documentSearchMatchIndex.value = 0; - documentSearchHasNavigated.value = false; - void scrollDocumentSearchMatchIntoView(); -}); - watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => { if (mode === "document" && !editing && index !== null) return; documentViewerSearchActive.value = false; - documentSearchOpen.value = false; }); onMounted(async () => { @@ -2255,23 +2193,6 @@ defineExpose({ focusSearch }); -
-
- - -
- {{ documentSearchStatus }} - - - -
-
{{ t("mongo.jsonReplaceHint") }}
@@ -2293,8 +2214,8 @@ defineExpose({ focusSearch }); -
-
+            
+
@@ -2319,15 +2240,6 @@ defineExpose({ focusSearch }); white-space: pre-wrap; } -.json-viewer { - font-family: var(--dbx-editor-font-family); - font-size: var(--dbx-editor-font-size); - line-height: 1.6; - tab-size: 2; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - .json-edit { font-family: var(--dbx-editor-font-family); font-size: var(--dbx-editor-font-size); diff --git a/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts b/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts index 4521f3ef6a..9269868331 100644 --- a/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts +++ b/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts @@ -11,6 +11,10 @@ const backend = vi.hoisted(() => ({ documentDeleteDocument: vi.fn(), })); +const documentJsonEditor = vi.hoisted(() => ({ + openSearch: vi.fn().mockReturnValue(true), +})); + const settings = vi.hoisted(() => ({ editorSettings: { pageSize: 100, @@ -106,6 +110,24 @@ vi.mock("@/components/grid/DataGrid.vue", async () => { }; }); +vi.mock("@/components/redis/RedisJsonEditor.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + props: { + modelValue: { type: String, required: true }, + readOnly: { type: Boolean, default: false }, + lineNumbers: { type: Boolean, default: true }, + presentation: { type: String, default: "editor" }, + }, + setup(props, { expose }) { + expose({ openSearch: documentJsonEditor.openSearch }); + return () => h("div", { "data-redis-json-editor-stub": "", "data-read-only": String(props.readOnly), "data-line-numbers": String(props.lineNumbers), "data-presentation": props.presentation }, [h("div", { class: "cm-line" }, h("span", { class: "json-string" }, props.modelValue))]); + }, + }), + }; +}); + vi.mock("@/components/ui/popover", async () => { const { computed, defineComponent, h, inject, provide } = await import("vue"); type PopoverContext = { @@ -236,6 +258,7 @@ beforeEach(async () => { backend.cancelQuery.mockReset(); backend.ensureConnected.mockReset(); backend.documentDeleteDocument.mockReset(); + documentJsonEditor.openSearch.mockClear(); backend.documentDeleteDocument.mockResolvedValue(undefined); settings.editorSettings.mongoViewMode = "table"; settings.editorSettings.columnWidthDensity = "standard"; @@ -532,7 +555,7 @@ describe("DocumentBrowser MongoDB filter value types", () => { collection: "typed_ids", databaseType: "mongodb", }); - app.mount(root!); + const documentBrowser = app.mount(root!) as unknown as { focusSearch: () => boolean }; await flushUi(); const documentRow = [...root!.querySelectorAll(".group")].find((element) => element.textContent?.includes("document-1"))!; @@ -540,7 +563,7 @@ describe("DocumentBrowser MongoDB filter value types", () => { await flushUi(); const viewer = root!.querySelector("[data-document-json-viewer]")!; - const jsonText = viewer.querySelector(".json-string")!; + const jsonText = viewer.querySelector(".cm-line .json-string")!; const documentId = root!.querySelector('input[aria-label^="_id:"]')!; expect(root!.firstElementChild?.classList.contains("select-none")).toBe(true); expect(documentId.readOnly).toBe(true); @@ -550,7 +573,9 @@ describe("DocumentBrowser MongoDB filter value types", () => { expect(documentIdBadge).not.toBeNull(); expect(documentIdBadge?.classList.contains("rounded")).toBe(true); expect(documentIdBadge?.classList.contains("rounded-4xl")).toBe(false); - expect(viewer.querySelector(".json-viewer")?.classList.contains("select-text")).toBe(true); + expect(viewer.querySelector("[data-redis-json-editor-stub]")?.dataset.readOnly).toBe("true"); + expect(viewer.querySelector("[data-redis-json-editor-stub]")?.dataset.lineNumbers).toBe("false"); + expect(viewer.querySelector("[data-redis-json-editor-stub]")?.dataset.presentation).toBe("viewer"); documentId.setSelectionRange(0, documentId.value.length); expect(documentId.selectionStart).toBe(0); @@ -561,6 +586,10 @@ describe("DocumentBrowser MongoDB filter value types", () => { await flushUi(); expect(buttonWithText("mongo.edit")).toBeDefined(); + viewer.dispatchEvent(new Event("pointerdown", { bubbles: true })); + expect(documentBrowser.focusSearch()).toBe(true); + expect(documentJsonEditor.openSearch).toHaveBeenCalledOnce(); + viewer.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); await flushUi(); expect(buttonWithText("grid.save")).toBeDefined(); diff --git a/apps/desktop/src/components/redis/RedisJsonEditor.vue b/apps/desktop/src/components/redis/RedisJsonEditor.vue index 04885124e3..d95e55e5e6 100644 --- a/apps/desktop/src/components/redis/RedisJsonEditor.vue +++ b/apps/desktop/src/components/redis/RedisJsonEditor.vue @@ -14,6 +14,10 @@ const props = withDefaults( saveDisabled?: boolean; readOnly?: boolean; wordWrap?: boolean; + /** Document previews keep folding controls but do not need source line numbers. */ + lineNumbers?: boolean; + /** A lighter reading surface for read-only JSON previews. */ + presentation?: "editor" | "viewer"; /** * When false, Mod+F is left to a parent find surface (RedisValueViewer). * Default true so DocumentBrowser and other callers keep CodeMirror find. @@ -24,6 +28,8 @@ const props = withDefaults( saveDisabled: false, readOnly: false, wordWrap: false, + lineNumbers: true, + presentation: "editor", enableBuiltinFind: true, }, ); @@ -39,7 +45,7 @@ const { isDark, themePalette } = useTheme(); const editor = useCellDetailEditor({ language: "json", - lineNumbers: true, + lineNumbers: props.lineNumbers, folding: true, lineWrapping: () => props.wordWrap, readOnly: () => props.readOnly, @@ -85,5 +91,39 @@ defineExpose({ openSearch, selectRange }); + + diff --git a/apps/desktop/src/composables/__tests__/useCellDetailEditor.folding.spec.ts b/apps/desktop/src/composables/__tests__/useCellDetailEditor.folding.spec.ts index 8b5a4eacf8..ca77e039c5 100644 --- a/apps/desktop/src/composables/__tests__/useCellDetailEditor.folding.spec.ts +++ b/apps/desktop/src/composables/__tests__/useCellDetailEditor.folding.spec.ts @@ -10,6 +10,7 @@ const cellDetailEditorSource = readFileSync(new URL("../useCellDetailEditor.ts", describe("Redis JSON editor folding", () => { it("opts the Redis JSON editor into the shared folding controls", () => { expect(redisJsonEditorSource).toMatch(/language:\s*"json"[\s\S]*folding:\s*true/); + expect(redisJsonEditorSource).toMatch(/lineNumbers:\s*props\.lineNumbers/); expect(cellDetailEditorSource).toContain("...(options.folding ? [foldGutter()] : []),"); expect(cellDetailEditorSource).toContain("...(options.folding ? foldKeymap : []),"); }); diff --git a/apps/desktop/src/lib/document/documentJsonSearch.ts b/apps/desktop/src/lib/document/documentJsonSearch.ts deleted file mode 100644 index 0d685095f1..0000000000 --- a/apps/desktop/src/lib/document/documentJsonSearch.ts +++ /dev/null @@ -1,98 +0,0 @@ -export interface DocumentTextMatch { - start: number; - end: number; -} - -type JsonTokenRange = DocumentTextMatch & { className: string }; - -const JSON_TOKEN_PATTERN = /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g; - -export function findDocumentTextMatches(text: string, query: string): DocumentTextMatch[] { - if (!query) return []; - const haystack = text.toLowerCase(); - const needle = query.toLowerCase(); - const matches: DocumentTextMatch[] = []; - let start = 0; - - while (start <= haystack.length - needle.length) { - const index = haystack.indexOf(needle, start); - if (index < 0) break; - matches.push({ start: index, end: index + needle.length }); - start = index + Math.max(needle.length, 1); - } - - return matches; -} - -export function renderDocumentJsonHtml(json: string, query = "", activeMatchIndex = 0): string { - const tokenRanges = jsonTokenRanges(json); - const searchRanges = findDocumentTextMatches(json, query); - const boundaries = new Set([0, json.length]); - - for (const range of tokenRanges) { - boundaries.add(range.start); - boundaries.add(range.end); - } - for (const range of searchRanges) { - boundaries.add(range.start); - boundaries.add(range.end); - } - - const points = [...boundaries].sort((left, right) => left - right); - let tokenIndex = 0; - let searchIndex = 0; - let html = ""; - - for (let index = 0; index < points.length - 1; index += 1) { - const start = points[index]; - const end = points[index + 1]; - if (end <= start) continue; - - while (tokenRanges[tokenIndex]?.end <= start) tokenIndex += 1; - while (searchRanges[searchIndex]?.end <= start) searchIndex += 1; - - const token = tokenRanges[tokenIndex]; - const search = searchRanges[searchIndex]; - let segment = escapeHtml(json.slice(start, end)); - - if (token && token.start <= start && token.end >= end) { - segment = `${segment}`; - } - if (search && search.start <= start && search.end >= end) { - const activeClass = searchIndex === activeMatchIndex ? " document-search-match-active" : ""; - const activeAttribute = searchIndex === activeMatchIndex ? ' data-document-search-active="true"' : ""; - segment = `${segment}`; - } - - html += segment; - } - - return html; -} - -function jsonTokenRanges(json: string): JsonTokenRange[] { - const ranges: JsonTokenRange[] = []; - const pattern = new RegExp(JSON_TOKEN_PATTERN.source, "g"); - let match: RegExpExecArray | null; - - while ((match = pattern.exec(json))) { - ranges.push({ - start: match.index, - end: match.index + match[0].length, - className: jsonTokenClass(match[0]), - }); - } - - return ranges; -} - -function jsonTokenClass(token: string): string { - if (token.startsWith('"')) return token.endsWith(":") ? "json-key" : "json-string"; - if (token === "true" || token === "false") return "json-boolean"; - if (token === "null") return "json-null"; - return "json-number"; -} - -function escapeHtml(text: string): string { - return text.replace(/&/g, "&").replace(//g, ">"); -} diff --git a/packages/app-tests/documentJsonSearch.test.ts b/packages/app-tests/documentJsonSearch.test.ts deleted file mode 100644 index 826e6b0e2d..0000000000 --- a/packages/app-tests/documentJsonSearch.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; - -import { findDocumentTextMatches, renderDocumentJsonHtml } from "../../apps/desktop/src/lib/document/documentJsonSearch.ts"; - -test("findDocumentTextMatches finds case-insensitive document matches", () => { - assert.deepEqual(findDocumentTextMatches('"City": "Shanghai", "backupCity": "shanghai"', "SHANGHAI"), [ - { start: 9, end: 17 }, - { start: 35, end: 43 }, - ]); -}); - -test("renderDocumentJsonHtml keeps syntax highlighting and marks the active match", () => { - const html = renderDocumentJsonHtml('{"profile":{"city":"Shanghai"}}', "city", 0); - - assert.match(html, /json-key/); - assert.match(html, /document-search-match-active/); - assert.match(html, /data-document-search-active="true"/); - assert.match(html, />city { - const html = renderDocumentJsonHtml('{"value":"