diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index e0e33a13d6..40b1893195 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -69,6 +69,7 @@ import { documentStoreValueForGrid, } from "@/lib/app/documentJsonValues"; import { applyDocumentStoreIdentityPlan, formatMeilisearchDocumentOperationPreview, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave"; +import { restoreDocumentBrowserState, saveDocumentBrowserState } from "@/lib/tabs/documentBrowserStateCache"; import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue"; import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; import { @@ -110,12 +111,23 @@ const props = defineProps<{ collection: string; databaseType?: DatabaseType; tableMeta?: NonNullable; + /** Tab id; query conditions are cached per tab and restored on remount. */ + stateKey?: string; }>(); type JsonRecord = Record; type ViewMode = "document" | "table"; const DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT = 10_000; +// This component is keyed by tab in ContentArea and unmounted on every tab +// switch; without restoring from the per-tab cache, coming back to the tab +// would silently drop the user's filter/sort conditions. Page position only +// survives for skip-based paging: cursor stores (DynamoDB/Elasticsearch) +// cannot resume a page without their cursor stacks, and infinite scroll +// always restarts from the first segment. +const restoredDocumentBrowserState = props.stateKey ? restoreDocumentBrowserState(props.stateKey) : undefined; +const restoresSkipBasedPage = !!restoredDocumentBrowserState && !settingsStore.editorSettings.infiniteScroll && (documentStoreProviderFor(props.databaseType).kind === "mongodb" || documentStoreProviderFor(props.databaseType).kind === "meilisearch"); + const documents = ref([]); const copyDocuments = ref([]); const gridRows = shallowRef([]); @@ -135,7 +147,7 @@ const loading = ref(false); const documentLoadExecutionId = ref(""); const documentLoadCancelling = ref(false); const documentLoadingElapsedSeconds = ref("0.0"); -const page = ref(0); +const page = ref(restoresSkipBasedPage ? Math.max(0, Math.trunc(restoredDocumentBrowserState!.page)) : 0); const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.tableOpenPageSize)); const selectedIdx = ref(null); const editJson = ref(""); @@ -155,8 +167,8 @@ const viewMode = computed({ get: () => settingsStore.editorSettings.mongoViewMode, set: (value) => settingsStore.updateEditorSettings({ mongoViewMode: value }), }); -const filterInput = ref(""); -const sortInput = ref(""); +const filterInput = ref(restoredDocumentBrowserState?.filterInput ?? ""); +const sortInput = ref(restoredDocumentBrowserState?.sortInput ?? ""); const filterInputRef = ref(); const sortInputRef = ref(); const dataGridRef = ref>(); @@ -284,8 +296,21 @@ type DocumentGridChanges = { const documentFilterBuilderOpen = ref(false); const documentFilterFieldPopoverOpen = ref>({}); const documentFilterFieldSearch = ref>({}); -const documentFilterRules = ref([]); -const appliedDocumentFilter = ref | null>(null); +const documentFilterRules = ref(restoredDocumentBrowserState?.documentFilterRules ?? []); +const appliedDocumentFilter = ref | null>(restoredDocumentBrowserState?.appliedDocumentFilter ?? null); + +function persistDocumentBrowserState() { + if (!props.stateKey) return; + saveDocumentBrowserState(props.stateKey, { + filterInput: filterInput.value, + sortInput: sortInput.value, + appliedDocumentFilter: appliedDocumentFilter.value, + documentFilterRules: documentFilterRules.value, + page: page.value, + }); +} + +watch([filterInput, sortInput, appliedDocumentFilter, documentFilterRules, page], persistDocumentBrowserState, { deep: true }); const elasticsearchMappingFields = ref([]); function elasticsearchGridColumnTypesFor(columns: readonly string[]): string[] { const mappingTypes = elasticsearchFieldTypes.value; @@ -2120,6 +2145,7 @@ onMounted(async () => { void nextTick(resizeDocumentQueryInputs); }); onBeforeUnmount(() => { + persistDocumentBrowserState(); window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true); unsubscribeElasticsearchIndexCleared?.(); unsubscribeElasticsearchIndexCleared = undefined; diff --git a/apps/desktop/src/components/document/__tests__/DocumentBrowserTabState.spec.ts b/apps/desktop/src/components/document/__tests__/DocumentBrowserTabState.spec.ts new file mode 100644 index 0000000000..9d8ef13b77 --- /dev/null +++ b/apps/desktop/src/components/document/__tests__/DocumentBrowserTabState.spec.ts @@ -0,0 +1,322 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const backend = vi.hoisted(() => ({ + documentFindDocuments: vi.fn(), + documentCountDocuments: vi.fn(), + cancelQuery: vi.fn(), + ensureConnected: vi.fn(), + documentInsertDocument: vi.fn(), + documentUpdateDocument: vi.fn(), + documentDeleteDocument: vi.fn(), + documentSaveMeilisearchBatch: vi.fn(), +})); + +const dataGrid = vi.hoisted(() => ({ + paginate: undefined as ((offset: number, limit: number) => Promise) | undefined, + sort: undefined as ((column: string, columnIndex: number, direction: "asc" | "desc" | null) => void) | undefined, + pageOffset: undefined as number | undefined, +})); + +const settings = vi.hoisted(() => ({ + editorSettings: { + pageSize: 2, + tableOpenPageSize: 2, + infiniteScroll: false, + mongoViewMode: "table" as "document" | "table", + columnWidthDensity: "standard" as "compact" | "standard" | "comfortable", + dataGridRenderMode: "canvas" as "canvas" | "dom", + tableFontFamily: "system-ui", + tableFontSize: 12, + numericColumnRightAlign: true, + confirmDangerousSqlExecution: true, + exportBatchSize: 2, + exportRowLimitEnabled: false, + exportRowLimit: 100_000, + }, + updateEditorSettings: vi.fn(), +})); + +vi.mock("vue-i18n", async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/lib/backend/api", () => ({ + getColumns: vi.fn(), + documentFindDocuments: backend.documentFindDocuments, + documentCountDocuments: backend.documentCountDocuments, + dynamodbDescribeTable: vi.fn(), + cancelQuery: backend.cancelQuery, + documentInsertDocument: backend.documentInsertDocument, + documentUpdateDocument: backend.documentUpdateDocument, + documentDeleteDocument: backend.documentDeleteDocument, + documentSaveMeilisearchBatch: backend.documentSaveMeilisearchBatch, +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ ensureConnected: backend.ensureConnected }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + TABLE_FONT_SIZE_MIN: 8, + TABLE_FONT_SIZE_MAX: 16, + useSettingsStore: () => settings, +})); + +vi.mock("@/components/grid/DataGrid.vue", () => { + return { + default: defineComponent({ + name: "DataGridStub", + inheritAttrs: false, + props: { + result: { type: Object, required: true }, + pageOffset: { type: Number, required: false }, + pageLimit: { type: Number, required: false }, + pageSizePreference: { type: String, required: false }, + }, + setup(props, { attrs, expose, slots }) { + dataGrid.paginate = attrs.onPaginate as typeof dataGrid.paginate; + dataGrid.sort = attrs.onSort as typeof dataGrid.sort; + expose({ + visibleColumnCount: 2, + displayableColumnCount: 2, + hiddenColumnCount: 0, + orderedColumnLayoutOptions: [], + filteredColumnLayoutOptions: () => [], + toggleColumnVisibility: vi.fn(), + showAllColumns: vi.fn(), + invertColumnVisibility: vi.fn(), + hasCustomColumnOrder: false, + moveDisplayableColumn: vi.fn(), + resetColumnOrder: vi.fn(), + nullColumnsHidden: false, + canToggleAllNullColumns: false, + allNullColumnCount: 0, + toggleAllNullColumns: vi.fn(), + multiRowTranspose: false, + setMultiRowTranspose: vi.fn(), + resetInfiniteScrollState: vi.fn(), + }); + return () => { + dataGrid.pageOffset = props.pageOffset; + return h("div", [ + // Rendering the real search-bar slot exposes the filter/sort inputs + // the same way the actual grid toolbar does. + h("div", slots["search-bar"]?.({ localFilterCount: 0, hasLocalColumnFilters: false, localFilterSummaries: [], clearLocalFilter: () => undefined })), + h("div", { "data-testid": "data-grid" }, (props.result as { rows: unknown[] }).rows.length), + ]); + }; + }, + }), + }; +}); + +vi.mock("@/components/redis/RedisJsonEditor.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + props: { modelValue: { type: String, required: true } }, + setup(props) { + return () => h("div", {}, props.modelValue); + }, + }), + }; +}); + +vi.mock("@/components/ui/popover", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = (name: string) => + defineComponent({ + name, + setup(_, { slots }) { + return () => h("div", slots.default?.()); + }, + }); + return { Popover: passthrough("PopoverStub"), PopoverTrigger: passthrough("PopoverTriggerStub"), PopoverContent: passthrough("PopoverContentStub") }; +}); + +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = (name: string) => + defineComponent({ + name, + setup(_, { slots }) { + return () => h("div", slots.default?.()); + }, + }); + return { Select: passthrough("SelectStub"), SelectContent: passthrough("SelectContentStub"), SelectItem: passthrough("SelectItemStub"), SelectTrigger: passthrough("SelectTriggerStub"), SelectValue: passthrough("SelectValueStub") }; +}); + +import DocumentBrowser from "@/components/document/DocumentBrowser.vue"; + +let app: App | null = null; +let root: HTMLDivElement | null = null; + +async function flushUi() { + for (let index = 0; index < 4; index++) { + await Promise.resolve(); + await nextTick(); + } +} + +async function mountBrowser(options: { databaseType?: "mongodb" | "elasticsearch"; stateKey?: string } = {}) { + app = createApp(DocumentBrowser, { + connectionId: "conn-1", + database: "test", + collection: "docs", + databaseType: options.databaseType ?? "mongodb", + stateKey: options.stateKey, + }); + app.mount(root!); + await flushUi(); +} + +function documentResult(start: number, count: number, total: number, extra: Record = {}) { + return { + documents: Array.from({ length: count }, (_, index) => { + const id = start + index; + return { _id: String(id), name: `row_${id}` }; + }), + total, + total_is_exact: true, + ...extra, + }; +} + +function queryTextareas(): HTMLTextAreaElement[] { + return [...root!.querySelectorAll("textarea.document-query-input")]; +} + +function typeInTextarea(textarea: HTMLTextAreaElement, value: string) { + textarea.value = value; + textarea.dispatchEvent(new Event("input", { bubbles: true })); +} + +function pressEnter(textarea: HTMLTextAreaElement) { + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); +} + +beforeEach(async () => { + vi.stubGlobal("localStorage", { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + }); + backend.documentFindDocuments.mockReset(); + backend.documentCountDocuments.mockReset(); + backend.cancelQuery.mockReset(); + backend.ensureConnected.mockReset(); + backend.ensureConnected.mockResolvedValue(undefined); + backend.documentCountDocuments.mockResolvedValue(0); + backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 2)); + dataGrid.paginate = undefined; + dataGrid.sort = undefined; + dataGrid.pageOffset = undefined; + settings.editorSettings.infiniteScroll = false; + + root = document.createElement("div"); + document.body.appendChild(root); +}); + +afterEach(() => { + app?.unmount(); + root?.remove(); + app = null; + root = null; + vi.unstubAllGlobals(); +}); + +describe("DocumentBrowser tab state (tab switch persistence)", () => { + it("restores filter and sort conditions after unmount/remount", async () => { + await mountBrowser({ stateKey: "tab-filter-sort" }); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1); + + const [filterTextarea] = queryTextareas(); + typeInTextarea(filterTextarea, '{"name":"row_1"}'); + await flushUi(); + pressEnter(filterTextarea); + await flushUi(); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2); + expect(backend.documentFindDocuments.mock.calls[1]![5]).toBe(JSON.stringify({ name: "row_1" })); + + dataGrid.sort!("name", 1, "asc"); + await flushUi(); + expect(backend.documentFindDocuments.mock.calls[2]![7]).toBe(JSON.stringify({ name: 1 })); + + // Switch away (unmount) and back (remount) — what ContentArea does per tab. + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ stateKey: "tab-filter-sort" }); + + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(4); + const restoredCall = backend.documentFindDocuments.mock.calls[3]!; + expect(restoredCall[5]).toBe(JSON.stringify({ name: "row_1" })); + expect(restoredCall[7]).toBe(JSON.stringify({ name: 1 })); + + const [restoredFilter, restoredSort] = queryTextareas(); + expect(restoredFilter.value).toBe('{"name":"row_1"}'); + expect(restoredSort.value).toBe(JSON.stringify({ name: 1 })); + }); + + it("restores the page position for skip-based stores", async () => { + backend.documentFindDocuments.mockReset(); + backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 6)); + + await mountBrowser({ stateKey: "tab-page" }); + expect(backend.documentFindDocuments.mock.calls[0]![3]).toBe(0); + + await dataGrid.paginate!(2, 2); + await flushUi(); + expect(backend.documentFindDocuments.mock.calls[1]![3]).toBe(2); + + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ stateKey: "tab-page" }); + + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3); + expect(backend.documentFindDocuments.mock.calls[2]![3]).toBe(2); + expect(dataGrid.pageOffset).toBe(2); + }); + + it("restarts from the first page for cursor-based stores (elasticsearch)", async () => { + backend.documentFindDocuments.mockReset(); + backend.documentFindDocuments.mockResolvedValueOnce(documentResult(1, 2, 6, { next_cursor: "cursor-1" })).mockResolvedValue(documentResult(3, 2, 6)); + + await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es" }); + + await dataGrid.paginate!(2, 2); + await flushUi(); + expect(backend.documentFindDocuments.mock.calls[1]![3]).toBe(0); + expect(backend.documentFindDocuments.mock.calls[1]![10]).toBe("cursor-1"); + + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es" }); + + const restoredCall = backend.documentFindDocuments.mock.calls.at(-1)!; + expect(restoredCall[3]).toBe(0); + expect(restoredCall[10]).toBeUndefined(); + }); + + it("keeps tab states isolated from each other", async () => { + await mountBrowser({ stateKey: "tab-a" }); + dataGrid.sort!("name", 1, "asc"); + await flushUi(); + app!.unmount(); + app = null; + root!.replaceChildren(); + + await mountBrowser({ stateKey: "tab-b" }); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3); + expect(backend.documentFindDocuments.mock.calls[2]![7]).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 1bf288f0c5..574d169093 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -2402,7 +2402,7 @@ defineExpose({ diff --git a/apps/desktop/src/lib/__tests__/tabs/documentBrowserStateCache.spec.ts b/apps/desktop/src/lib/__tests__/tabs/documentBrowserStateCache.spec.ts new file mode 100644 index 0000000000..32429dacdc --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tabs/documentBrowserStateCache.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { clearDocumentBrowserState, restoreDocumentBrowserState, saveDocumentBrowserState, type DocumentBrowserStateSnapshot } from "@/lib/tabs/documentBrowserStateCache"; + +function snapshot(page: number): DocumentBrowserStateSnapshot { + return { + filterInput: `{"page":${page}}`, + sortInput: `{"f":${page}}`, + appliedDocumentFilter: page % 2 === 0 ? { status: "active" } : null, + documentFilterRules: page % 2 === 0 ? [{ id: `rule-${page}`, fieldName: "status", mode: "equals", rawValue: "active", conjunction: "AND" }] : [], + page, + }; +} + +describe("documentBrowserStateCache", () => { + it("round-trips a snapshot for the same key", () => { + saveDocumentBrowserState("round-trip", snapshot(3)); + + expect(restoreDocumentBrowserState("round-trip")).toEqual(snapshot(3)); + }); + + it("returns undefined for an unknown key", () => { + expect(restoreDocumentBrowserState("missing")).toBeUndefined(); + }); + + it("keeps the most recent write for a key", () => { + saveDocumentBrowserState("overwrite", snapshot(1)); + saveDocumentBrowserState("overwrite", snapshot(2)); + + expect(restoreDocumentBrowserState("overwrite")).toEqual(snapshot(2)); + }); + + it("evicts the oldest entry beyond 32 keys", () => { + for (let index = 0; index < 33; index += 1) { + saveDocumentBrowserState(`evict-${index}`, snapshot(index)); + } + + expect(restoreDocumentBrowserState("evict-0")).toBeUndefined(); + expect(restoreDocumentBrowserState("evict-1")).toBeDefined(); + expect(restoreDocumentBrowserState("evict-32")).toBeDefined(); + }); + + it("treats a restore as recency so a touched entry survives eviction", () => { + for (let index = 0; index < 32; index += 1) { + saveDocumentBrowserState(`touch-${index}`, snapshot(index)); + } + expect(restoreDocumentBrowserState("touch-0")).toEqual(snapshot(0)); + saveDocumentBrowserState("touch-new", snapshot(99)); + + expect(restoreDocumentBrowserState("touch-0")).toBeDefined(); + expect(restoreDocumentBrowserState("touch-1")).toBeUndefined(); + }); + + it("clears a key on demand", () => { + saveDocumentBrowserState("clear-me", snapshot(4)); + clearDocumentBrowserState("clear-me"); + + expect(restoreDocumentBrowserState("clear-me")).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/tabs/documentBrowserStateCache.ts b/apps/desktop/src/lib/tabs/documentBrowserStateCache.ts new file mode 100644 index 0000000000..d6bc98c9a2 --- /dev/null +++ b/apps/desktop/src/lib/tabs/documentBrowserStateCache.ts @@ -0,0 +1,45 @@ +import type { DocumentFilterRule } from "@/lib/app/documentStoreProvider"; + +export interface DocumentBrowserStateSnapshot { + /** Manual JSON filter text from the collection filter input. */ + filterInput: string; + /** Manual JSON sort text from the collection sort input. */ + sortInput: string; + /** Structured-filter contribution applied on top of the manual filter. */ + appliedDocumentFilter: Record | null; + /** Rules backing the structured filter builder. */ + documentFilterRules: DocumentFilterRule[]; + /** Zero-based page position; only meaningful for skip-based stores. */ + page: number; +} + +// ContentArea renders only the active tab, so DocumentBrowser is unmounted on +// every tab switch and remounted when the user comes back. Query conditions +// survive that round trip through this session-scoped cache keyed by tab id — +// the same role tab.whereInput/tab.orderByInput play for SQL data tabs. The +// bound keeps state for long tab sessions from accumulating forever. +const MAX_ENTRIES = 32; +const cache = new Map(); + +export function restoreDocumentBrowserState(stateKey: string): DocumentBrowserStateSnapshot | undefined { + const snapshot = cache.get(stateKey); + if (!snapshot) return undefined; + // Refresh LRU recency. + cache.delete(stateKey); + cache.set(stateKey, snapshot); + return snapshot; +} + +export function saveDocumentBrowserState(stateKey: string, snapshot: DocumentBrowserStateSnapshot): void { + cache.delete(stateKey); + cache.set(stateKey, snapshot); + while (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } +} + +export function clearDocumentBrowserState(stateKey: string): void { + cache.delete(stateKey); +}