mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:55:32 +00:00
fix(mongodb): keep collection results across tab switches
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, shallowRef, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { computed, ref, shallowRef, nextTick, watch, onMounted, onBeforeUnmount, toRaw } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff, Palette, Copy } from "@lucide/vue";
|
||||
@@ -69,7 +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 { restoreDocumentBrowserState, saveDocumentBrowserState, type DocumentBrowserDataSnapshot } from "@/lib/tabs/documentBrowserStateCache";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import {
|
||||
@@ -122,12 +122,13 @@ 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.
|
||||
// would silently drop the user's filter/sort conditions and re-query the
|
||||
// server. Page position and rows only survive 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 skipBasedDocumentStore = documentStoreProviderFor(props.databaseType).kind === "mongodb" || documentStoreProviderFor(props.databaseType).kind === "meilisearch";
|
||||
const restoresSkipBasedPage = !!restoredDocumentBrowserState && !settingsStore.editorSettings.infiniteScroll && skipBasedDocumentStore;
|
||||
|
||||
const documents = ref<JsonRecord[]>([]);
|
||||
const copyDocuments = ref<JsonRecord[]>([]);
|
||||
@@ -142,6 +143,11 @@ const mongoCopyDocumentsAvailable = ref(false);
|
||||
const lastGridColumns = ref<string[]>([]);
|
||||
const lastGridColumnTypes = ref<string[]>([]);
|
||||
const total = ref<number | undefined>(undefined);
|
||||
// Logical-result identity for DataGrid's tab-switch view snapshot. A data tab
|
||||
// gets this from queryStore.publishResultGeneration; document tabs have no
|
||||
// store-side result, so the browser mints one per completed load and inherits
|
||||
// it on an infinite-scroll append and on a cache restore.
|
||||
const documentViewGeneration = ref<string | undefined>(undefined);
|
||||
const totalIsExact = ref(true);
|
||||
const paginationTotal = ref<number | undefined>(undefined);
|
||||
const loading = ref(false);
|
||||
@@ -300,7 +306,46 @@ const documentFilterFieldSearch = ref<Record<string, string>>({});
|
||||
const documentFilterRules = ref<DocumentFilterRule[]>(restoredDocumentBrowserState?.documentFilterRules ?? []);
|
||||
const appliedDocumentFilter = ref<Record<string, unknown> | null>(restoredDocumentBrowserState?.appliedDocumentFilter ?? null);
|
||||
|
||||
function persistDocumentBrowserState() {
|
||||
// Identity + conditions the currently held rows were loaded under. Rows are
|
||||
// only worth replaying while this still matches the live inputs; a filter edit
|
||||
// with a load still in flight would otherwise pair new conditions with stale
|
||||
// rows on the next remount.
|
||||
function documentDataSignature(): string | undefined {
|
||||
try {
|
||||
return JSON.stringify([documentStoreProvider.value.kind, props.connectionId, props.database, props.collection, currentDocumentFilter() ?? null, currentDocumentSortJson(sortInput.value) ?? null, page.value, pageSize.value, settingsStore.editorSettings.infiniteScroll === true]);
|
||||
} catch {
|
||||
// Malformed filter/sort JSON: nothing stable to key rows against.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let loadedDocumentDataSignature: string | undefined;
|
||||
|
||||
function captureDocumentBrowserData(): DocumentBrowserDataSnapshot | undefined {
|
||||
// Cursor stores (DynamoDB/Elasticsearch) drop their cursor stacks on unmount
|
||||
// and cannot resume a page without them, so they keep restarting at page 0.
|
||||
if (!skipBasedDocumentStore) return undefined;
|
||||
// Never completed a load, mid-flight, or errored — let the remount retry.
|
||||
if (lastGridColumns.value.length === 0 || loading.value || error.value) return undefined;
|
||||
const signature = documentDataSignature();
|
||||
if (!signature || signature !== loadedDocumentDataSignature) return undefined;
|
||||
return {
|
||||
signature,
|
||||
viewGeneration: documentViewGeneration.value,
|
||||
// Unwrap the reactive proxies: this snapshot outlives the component.
|
||||
documents: toRaw(documents.value),
|
||||
copyDocuments: toRaw(copyDocuments.value),
|
||||
copyDocumentsAvailable: mongoCopyDocumentsAvailable.value,
|
||||
gridColumns: toRaw(lastGridColumns.value),
|
||||
gridColumnTypes: toRaw(lastGridColumnTypes.value),
|
||||
total: total.value,
|
||||
totalIsExact: totalIsExact.value,
|
||||
paginationTotal: paginationTotal.value,
|
||||
selectedIdx: selectedIdx.value,
|
||||
};
|
||||
}
|
||||
|
||||
function persistDocumentBrowserState(options: { includeData?: boolean } = {}) {
|
||||
if (!props.stateKey) return;
|
||||
saveDocumentBrowserState(props.stateKey, {
|
||||
filterInput: filterInput.value,
|
||||
@@ -308,10 +353,49 @@ function persistDocumentBrowserState() {
|
||||
appliedDocumentFilter: appliedDocumentFilter.value,
|
||||
documentFilterRules: documentFilterRules.value,
|
||||
page: page.value,
|
||||
// Any condition change drops the payload; only the unmount capture stores
|
||||
// rows, so a cached page can never outlive the conditions that produced it.
|
||||
data: options.includeData ? captureDocumentBrowserData() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
watch([filterInput, sortInput, appliedDocumentFilter, documentFilterRules, page], persistDocumentBrowserState, { deep: true });
|
||||
watch([filterInput, sortInput, appliedDocumentFilter, documentFilterRules, page], () => persistDocumentBrowserState(), { deep: true });
|
||||
|
||||
// Seed the grid from the cached page so a tab switch costs no round trip
|
||||
// (#8679). The signature guard rejects a snapshot whose identity or conditions
|
||||
// no longer match — a changed page-size setting, say — and falls through to a
|
||||
// normal load.
|
||||
const restoredDocumentData = skipBasedDocumentStore && restoredDocumentBrowserState?.data && restoredDocumentBrowserState.data.signature === documentDataSignature() ? restoredDocumentBrowserState.data : undefined;
|
||||
if (restoredDocumentData) {
|
||||
// Assign the columns before committing so a collection that loaded empty
|
||||
// stays distinguishable from one that never loaded: commitLoadedDocuments
|
||||
// reads a non-empty lastGridColumns as "a load has completed", which is what
|
||||
// drives the refresh toolbar for an empty collection.
|
||||
lastGridColumns.value = restoredDocumentData.gridColumns;
|
||||
lastGridColumnTypes.value = restoredDocumentData.gridColumnTypes;
|
||||
commitLoadedDocuments(restoredDocumentData.documents, restoredDocumentData.copyDocuments, restoredDocumentData.copyDocumentsAvailable, false, documentStoreProvider.value.kind);
|
||||
total.value = restoredDocumentData.total;
|
||||
totalIsExact.value = restoredDocumentData.totalIsExact;
|
||||
paginationTotal.value = restoredDocumentData.paginationTotal;
|
||||
loadedDocumentDataSignature = restoredDocumentData.signature;
|
||||
// Same rows as before the switch, so the grid may replay its viewport.
|
||||
documentViewGeneration.value = restoredDocumentData.viewGeneration;
|
||||
const restoredSelectedIdx = restoredDocumentData.selectedIdx;
|
||||
if (restoredSelectedIdx !== null && restoredSelectedIdx >= 0 && restoredSelectedIdx < documents.value.length) {
|
||||
selectedIdx.value = restoredSelectedIdx;
|
||||
editJson.value = stringifyDocumentStoreValue(documents.value[restoredSelectedIdx], documentStoreProvider.value.kind, 2);
|
||||
}
|
||||
// Keep the grid's "count total rows" action working without a preceding load.
|
||||
loadedDocumentQueryTotalCountRequest = {
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
collection: props.collection,
|
||||
filter: currentDocumentFilter(),
|
||||
generation: documentRequestGeneration,
|
||||
storeKind: documentStoreProvider.value.kind,
|
||||
};
|
||||
}
|
||||
|
||||
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
|
||||
function elasticsearchGridColumnTypesFor(columns: readonly string[]): string[] {
|
||||
const mappingTypes = elasticsearchFieldTypes.value;
|
||||
@@ -1459,6 +1543,12 @@ async function load(options: { page?: number; append?: boolean; offset?: number;
|
||||
// Commit page + rows together so stale rows never briefly show last-page indexes.
|
||||
if (options.page !== undefined) page.value = options.page;
|
||||
commitLoadedDocuments(nextDocuments, nextCopyDocuments, hasTypePreservingCopyDocuments, options.append === true, storeKind);
|
||||
// Mark which conditions these rows belong to, so the unmount capture can
|
||||
// tell a still-valid page from one the user has since edited away.
|
||||
loadedDocumentDataSignature = documentDataSignature();
|
||||
// A replacement dataset must not adopt the previous viewport; an
|
||||
// infinite-scroll append keeps rendering the same logical result.
|
||||
if (options.append !== true || !documentViewGeneration.value) documentViewGeneration.value = uuid();
|
||||
loadedDocumentQueryTotalCountRequest = countRequest;
|
||||
if (storeKind === "dynamodb") {
|
||||
const nextCursors = dynamodbPageCursors.value.slice(0, requestPage + 1);
|
||||
@@ -2139,7 +2229,9 @@ onMounted(async () => {
|
||||
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||
unsubscribeElasticsearchIndexCleared = subscribeElasticsearchIndexCleared(handleElasticsearchIndexCleared);
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
// A restored tab issues no query, so a blocking health probe here would be
|
||||
// the only round trip left on the switch.
|
||||
await connectionStore.ensureConnected(props.connectionId, restoredDocumentData ? { verifyHealth: false } : {});
|
||||
} catch (e) {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||||
}
|
||||
@@ -2147,11 +2239,14 @@ onMounted(async () => {
|
||||
// Mapping metadata enriches the filter builder, but it must not delay the
|
||||
// first page of documents when the mapping endpoint is slow.
|
||||
void loadElasticsearchMappingFields();
|
||||
void load();
|
||||
// A restored snapshot already holds the rows the last load produced, so a tab
|
||||
// switch must not re-issue the collection query (#8679). Refresh and every
|
||||
// mutation path still force a real load.
|
||||
if (!restoredDocumentData) void load();
|
||||
void nextTick(resizeDocumentQueryInputs);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
persistDocumentBrowserState();
|
||||
persistDocumentBrowserState({ includeData: true });
|
||||
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||
unsubscribeElasticsearchIndexCleared?.();
|
||||
unsubscribeElasticsearchIndexCleared = undefined;
|
||||
@@ -2421,6 +2516,8 @@ defineExpose({ focusSearch });
|
||||
:database="props.database"
|
||||
:table-meta="props.tableMeta"
|
||||
:column-layout-scope-key="documentColumnLayoutScopeKey"
|
||||
:view-state-key="props.stateKey"
|
||||
:view-generation="documentViewGeneration"
|
||||
context="results"
|
||||
page-size-preference="table-open"
|
||||
:database-type="props.databaseType"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Download, Filter, LoaderCircle, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
@@ -20,13 +20,23 @@ import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/do
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { restoreGridFsBrowserState, saveGridFsBrowserState } from "@/lib/tabs/documentBrowserStateCache";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
bucket: string;
|
||||
/** Tab id; query conditions and the file listing are cached per tab. */
|
||||
stateKey?: string;
|
||||
}>();
|
||||
|
||||
type GridFsFile = Awaited<ReturnType<typeof api.documentListGridFsFiles>>[number];
|
||||
|
||||
// ContentArea renders only the active tab, so this component is unmounted on
|
||||
// every tab switch. Without the per-tab cache, coming back re-lists the whole
|
||||
// bucket and drops the user's filter/sort (#8679).
|
||||
const restoredGridFsState = props.stateKey ? restoreGridFsBrowserState<GridFsFile>(props.stateKey) : undefined;
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -37,16 +47,16 @@ const uploading = ref(false);
|
||||
const downloading = ref(false);
|
||||
const deleting = ref(false);
|
||||
const error = ref("");
|
||||
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
|
||||
const selectedFileId = ref("");
|
||||
const files = ref<GridFsFile[]>([]);
|
||||
const selectedFileId = ref(restoredGridFsState?.selectedId ?? "");
|
||||
const checkedFileIds = ref<Set<string>>(new Set());
|
||||
const showDeleteConfirm = ref(false);
|
||||
const filterInput = ref("");
|
||||
const sortInput = ref("");
|
||||
const filterInput = ref(restoredGridFsState?.filterInput ?? "");
|
||||
const sortInput = ref(restoredGridFsState?.sortInput ?? "");
|
||||
const filterBuilderOpen = ref(false);
|
||||
const filterRules = ref<DocumentFilterRule[]>([createGridFsFileFilterRule(uuid())]);
|
||||
const appliedStructuredFilter = ref<Record<string, unknown> | null>(null);
|
||||
const page = ref(0);
|
||||
const filterRules = ref<DocumentFilterRule[]>(restoredGridFsState?.filterRules ?? [createGridFsFileFilterRule(uuid())]);
|
||||
const appliedStructuredFilter = ref<Record<string, unknown> | null>(restoredGridFsState?.appliedStructuredFilter ?? null);
|
||||
const page = ref(Math.max(0, Math.trunc(restoredGridFsState?.page ?? 0)));
|
||||
const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSize));
|
||||
|
||||
const totalBytes = computed(() => files.value.reduce((sum, file) => sum + (file.length || 0), 0));
|
||||
@@ -186,12 +196,48 @@ function updatePageSize(nextValue: unknown) {
|
||||
settingsStore.updateEditorSettings({ pageSize: normalized });
|
||||
}
|
||||
|
||||
function gridFsSignature(): string {
|
||||
return JSON.stringify([props.connectionId, props.database, props.bucket, filterInput.value, sortInput.value, appliedStructuredFilter.value ?? null]);
|
||||
}
|
||||
|
||||
// Rows are only replayed when the conditions that produced them still match, so
|
||||
// an edit made while a listing was in flight can never pair new conditions with
|
||||
// stale rows on the next remount.
|
||||
let loadedGridFsSignature: string | undefined;
|
||||
if (restoredGridFsState?.rows && restoredGridFsState.signature === gridFsSignature()) {
|
||||
files.value = restoredGridFsState.rows;
|
||||
loadedGridFsSignature = restoredGridFsState.signature;
|
||||
// Paging is client-side, so the page size setting may have moved while the
|
||||
// tab was inactive without invalidating the listing itself.
|
||||
ensurePageInRange();
|
||||
}
|
||||
const restoredGridFsFiles = loadedGridFsSignature !== undefined;
|
||||
|
||||
function persistGridFsState(options: { includeRows?: boolean } = {}) {
|
||||
if (!props.stateKey) return;
|
||||
const signature = gridFsSignature();
|
||||
const keepRows = options.includeRows === true && !loading.value && !error.value && loadedGridFsSignature === signature;
|
||||
saveGridFsBrowserState(props.stateKey, {
|
||||
filterInput: filterInput.value,
|
||||
sortInput: sortInput.value,
|
||||
appliedStructuredFilter: appliedStructuredFilter.value,
|
||||
filterRules: toRaw(filterRules.value),
|
||||
page: page.value,
|
||||
selectedId: selectedFileId.value,
|
||||
signature: keepRows ? signature : undefined,
|
||||
rows: keepRows ? toRaw(files.value) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
watch([filterInput, sortInput, appliedStructuredFilter, filterRules, page, selectedFileId], () => persistGridFsState(), { deep: true });
|
||||
|
||||
async function loadFiles() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket, currentFilesFilter(), currentDocumentSortJson(sortInput.value));
|
||||
files.value = nextFiles;
|
||||
loadedGridFsSignature = gridFsSignature();
|
||||
checkedFileIds.value = new Set(nextFiles.filter((file) => checkedFileIds.value.has(file.id)).map((file) => file.id));
|
||||
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
|
||||
selectedFileId.value = "";
|
||||
@@ -372,7 +418,12 @@ async function deleteSelectedFile() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFiles();
|
||||
// A restored listing means the tab switch costs no round trip; every explicit
|
||||
// refresh and mutation path still calls loadFiles().
|
||||
if (!restoredGridFsFiles) void loadFiles();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
persistGridFsState({ includeRows: true });
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Filter, FolderOpen, Plus, RefreshCcw, Trash2, X } from "@lucide/vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
@@ -10,6 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { currentGridFsBucketFilter, currentGridFsBucketSort, currentGridFsBucketSortDirection, gridFsBucketSortInputForColumn } from "@/lib/document/gridFsBrowser";
|
||||
import { restoreGridFsBrowserState, saveGridFsBrowserState } from "@/lib/tabs/documentBrowserStateCache";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -17,8 +18,17 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
/** Tab id; query conditions and the listing are cached per tab. */
|
||||
stateKey?: string;
|
||||
}>();
|
||||
|
||||
type GridFsBucket = Awaited<ReturnType<typeof api.documentListGridFsBuckets>>[number];
|
||||
|
||||
// ContentArea renders only the active tab, so this component is unmounted on
|
||||
// every tab switch. Without the per-tab cache, coming back re-lists every
|
||||
// bucket and drops the user's filter/sort (#8679).
|
||||
const restoredGridFsState = props.stateKey ? restoreGridFsBrowserState<GridFsBucket>(props.stateKey) : undefined;
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const queryStore = useQueryStore();
|
||||
@@ -28,13 +38,13 @@ const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const deleting = ref(false);
|
||||
const error = ref("");
|
||||
const buckets = ref<Awaited<ReturnType<typeof api.documentListGridFsBuckets>>>([]);
|
||||
const selectedBucketName = ref("");
|
||||
const buckets = ref<GridFsBucket[]>([]);
|
||||
const selectedBucketName = ref(restoredGridFsState?.selectedId ?? "");
|
||||
const showCreateDialog = ref(false);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const newBucketName = ref("");
|
||||
const filterInput = ref("");
|
||||
const sortInput = ref("");
|
||||
const filterInput = ref(restoredGridFsState?.filterInput ?? "");
|
||||
const sortInput = ref(restoredGridFsState?.sortInput ?? "");
|
||||
const filterBuilderOpen = ref(false);
|
||||
|
||||
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
|
||||
@@ -49,12 +59,42 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function gridFsSignature(): string {
|
||||
return JSON.stringify([props.connectionId, props.database, filterInput.value, sortInput.value]);
|
||||
}
|
||||
|
||||
// Rows are only replayed when the conditions that produced them still match, so
|
||||
// an edit made while a listing was in flight can never pair new conditions with
|
||||
// stale rows on the next remount.
|
||||
let loadedGridFsSignature: string | undefined;
|
||||
if (restoredGridFsState?.rows && restoredGridFsState.signature === gridFsSignature()) {
|
||||
buckets.value = restoredGridFsState.rows;
|
||||
loadedGridFsSignature = restoredGridFsState.signature;
|
||||
}
|
||||
const restoredGridFsBuckets = loadedGridFsSignature !== undefined;
|
||||
|
||||
function persistGridFsState(options: { includeRows?: boolean } = {}) {
|
||||
if (!props.stateKey) return;
|
||||
const signature = gridFsSignature();
|
||||
const keepRows = options.includeRows === true && !loading.value && !error.value && loadedGridFsSignature === signature;
|
||||
saveGridFsBrowserState(props.stateKey, {
|
||||
filterInput: filterInput.value,
|
||||
sortInput: sortInput.value,
|
||||
selectedId: selectedBucketName.value,
|
||||
signature: keepRows ? signature : undefined,
|
||||
rows: keepRows ? toRaw(buckets.value) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
watch([filterInput, sortInput, selectedBucketName], () => persistGridFsState());
|
||||
|
||||
async function loadBuckets() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const nextBuckets = await api.documentListGridFsBuckets(props.connectionId, props.database, currentGridFsBucketFilter(filterInput.value), currentGridFsBucketSort(sortInput.value));
|
||||
buckets.value = nextBuckets;
|
||||
loadedGridFsSignature = gridFsSignature();
|
||||
if (selectedBucketName.value && !nextBuckets.some((bucket) => bucket.name === selectedBucketName.value)) {
|
||||
selectedBucketName.value = "";
|
||||
}
|
||||
@@ -148,7 +188,12 @@ async function deleteBucket() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadBuckets();
|
||||
// A restored listing means the tab switch costs no round trip; every explicit
|
||||
// refresh and mutation path still calls loadBuckets().
|
||||
if (!restoredGridFsBuckets) void loadBuckets();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
persistGridFsState({ includeRows: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+159
-6
@@ -17,7 +17,12 @@ const backend = vi.hoisted(() => ({
|
||||
const dataGrid = vi.hoisted(() => ({
|
||||
paginate: undefined as ((offset: number, limit: number) => Promise<void>) | undefined,
|
||||
sort: undefined as ((column: string, columnIndex: number, direction: "asc" | "desc" | null) => void) | undefined,
|
||||
reload: undefined as (() => Promise<void>) | undefined,
|
||||
pageOffset: undefined as number | undefined,
|
||||
rowCount: undefined as number | undefined,
|
||||
columnCount: undefined as number | undefined,
|
||||
viewStateKey: undefined as string | undefined,
|
||||
viewGeneration: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
const settings = vi.hoisted(() => ({
|
||||
@@ -76,10 +81,13 @@ vi.mock("@/components/grid/DataGrid.vue", () => {
|
||||
pageOffset: { type: Number, required: false },
|
||||
pageLimit: { type: Number, required: false },
|
||||
pageSizePreference: { type: String, required: false },
|
||||
viewStateKey: { type: String, required: false },
|
||||
viewGeneration: { 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;
|
||||
dataGrid.reload = attrs.onReload as typeof dataGrid.reload;
|
||||
expose({
|
||||
visibleColumnCount: 2,
|
||||
displayableColumnCount: 2,
|
||||
@@ -102,6 +110,10 @@ vi.mock("@/components/grid/DataGrid.vue", () => {
|
||||
});
|
||||
return () => {
|
||||
dataGrid.pageOffset = props.pageOffset;
|
||||
dataGrid.viewStateKey = props.viewStateKey;
|
||||
dataGrid.viewGeneration = props.viewGeneration;
|
||||
dataGrid.rowCount = (props.result as { rows: unknown[] }).rows.length;
|
||||
dataGrid.columnCount = (props.result as { columns: unknown[] }).columns.length;
|
||||
return h("div", [
|
||||
// Rendering the real search-bar slot exposes the filter/sort inputs
|
||||
// the same way the actual grid toolbar does.
|
||||
@@ -151,6 +163,7 @@ vi.mock("@/components/ui/select", async () => {
|
||||
});
|
||||
|
||||
import DocumentBrowser from "@/components/document/DocumentBrowser.vue";
|
||||
import { resetBrowserStateCaches } from "@/lib/tabs/documentBrowserStateCache";
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
@@ -214,8 +227,15 @@ beforeEach(async () => {
|
||||
backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 2));
|
||||
dataGrid.paginate = undefined;
|
||||
dataGrid.sort = undefined;
|
||||
dataGrid.reload = undefined;
|
||||
dataGrid.pageOffset = undefined;
|
||||
dataGrid.rowCount = undefined;
|
||||
dataGrid.columnCount = undefined;
|
||||
dataGrid.viewStateKey = undefined;
|
||||
dataGrid.viewGeneration = undefined;
|
||||
settings.editorSettings.infiniteScroll = false;
|
||||
settings.editorSettings.tableOpenPageSize = 2;
|
||||
resetBrowserStateCaches();
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
@@ -253,14 +273,20 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => {
|
||||
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 }));
|
||||
// The remount replays the cached page instead of re-querying (#8679).
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3);
|
||||
|
||||
const [restoredFilter, restoredSort] = queryTextareas();
|
||||
expect(restoredFilter.value).toBe('{"name":"row_1"}');
|
||||
expect(restoredSort.value).toBe(JSON.stringify({ name: 1 }));
|
||||
|
||||
// The restored conditions are live, not merely cosmetic: the next query
|
||||
// built from them still carries the filter and the new sort direction.
|
||||
dataGrid.sort!("name", 1, "desc");
|
||||
await flushUi();
|
||||
const nextCall = backend.documentFindDocuments.mock.calls[3]!;
|
||||
expect(nextCall[5]).toBe(JSON.stringify({ name: "row_1" }));
|
||||
expect(nextCall[7]).toBe(JSON.stringify({ name: -1 }));
|
||||
});
|
||||
|
||||
it("restores the page position for skip-based stores", async () => {
|
||||
@@ -280,9 +306,15 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => {
|
||||
await flushUi();
|
||||
await mountBrowser({ stateKey: "tab-page" });
|
||||
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3);
|
||||
expect(backend.documentFindDocuments.mock.calls[2]![3]).toBe(2);
|
||||
// Restored from cache: the page position survives with no extra query.
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
|
||||
expect(dataGrid.pageOffset).toBe(2);
|
||||
|
||||
// Paging still works from the restored page.
|
||||
await dataGrid.paginate!(0, 2);
|
||||
await flushUi();
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3);
|
||||
expect(backend.documentFindDocuments.mock.calls[2]![3]).toBe(0);
|
||||
});
|
||||
|
||||
it("restarts from the first page for cursor-based stores (elasticsearch)", async () => {
|
||||
@@ -307,6 +339,127 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => {
|
||||
expect(restoredCall[10]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not re-query the collection when a tab switch remounts a mongodb browser", async () => {
|
||||
await mountBrowser({ stateKey: "tab-no-reload" });
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
|
||||
expect(dataGrid.rowCount).toBe(2);
|
||||
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ stateKey: "tab-no-reload" });
|
||||
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
|
||||
expect(dataGrid.rowCount).toBe(2);
|
||||
});
|
||||
|
||||
it("still forces a real reload from the refresh button after a restore", async () => {
|
||||
await mountBrowser({ stateKey: "tab-refresh" });
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ stateKey: "tab-refresh" });
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
|
||||
|
||||
await dataGrid.reload!();
|
||||
await flushUi();
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-queries when the page size changed while the tab was inactive", async () => {
|
||||
await mountBrowser({ stateKey: "tab-page-size" });
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
|
||||
settings.editorSettings.tableOpenPageSize = 10;
|
||||
await mountBrowser({ stateKey: "tab-page-size" });
|
||||
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-queries when the previous load failed", async () => {
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(documentResult(1, 2, 2));
|
||||
|
||||
await mountBrowser({ stateKey: "tab-error" });
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ stateKey: "tab-error" });
|
||||
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a loaded-but-empty collection distinguishable from a never-loaded one across a remount", async () => {
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments.mockResolvedValue({ documents: [], total: 0, total_is_exact: true });
|
||||
|
||||
await mountBrowser({ stateKey: "tab-empty" });
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
|
||||
const loadedColumnCount = dataGrid.columnCount;
|
||||
expect(loadedColumnCount).toBeGreaterThan(0);
|
||||
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ stateKey: "tab-empty" });
|
||||
|
||||
// No reload, and the grid still reads as "a load has completed" so the
|
||||
// refresh toolbar stays available (see the empty-collection behavior).
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
|
||||
expect(dataGrid.columnCount).toBe(loadedColumnCount);
|
||||
});
|
||||
|
||||
it("re-queries a cursor-based store on remount", async () => {
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 6));
|
||||
|
||||
await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es-reload" });
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
|
||||
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es-reload" });
|
||||
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("hands the grid a stable view identity so scroll and selection survive the switch", async () => {
|
||||
await mountBrowser({ stateKey: "tab-view-state" });
|
||||
expect(dataGrid.viewStateKey).toBe("tab-view-state");
|
||||
const generation = dataGrid.viewGeneration;
|
||||
expect(generation).toBeTruthy();
|
||||
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ stateKey: "tab-view-state" });
|
||||
|
||||
// Same logical result, so DataGrid may replay its cached viewport.
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
|
||||
expect(dataGrid.viewGeneration).toBe(generation);
|
||||
});
|
||||
|
||||
it("mints a new view identity when the data is actually reloaded", async () => {
|
||||
await mountBrowser({ stateKey: "tab-view-generation" });
|
||||
const generation = dataGrid.viewGeneration;
|
||||
|
||||
await dataGrid.reload!();
|
||||
await flushUi();
|
||||
|
||||
// A replacement dataset must not adopt the previous viewport.
|
||||
expect(dataGrid.viewGeneration).not.toBe(generation);
|
||||
});
|
||||
|
||||
it("keeps tab states isolated from each other", async () => {
|
||||
await mountBrowser({ stateKey: "tab-a" });
|
||||
dataGrid.sort!("name", 1, "asc");
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// @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(() => ({
|
||||
documentListGridFsBuckets: vi.fn(),
|
||||
documentListGridFsFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
const settings = vi.hoisted(() => ({
|
||||
editorSettings: {
|
||||
pageSize: 50,
|
||||
tableOpenPageSize: 50,
|
||||
infiniteScroll: false,
|
||||
tableFontFamily: "system-ui",
|
||||
tableFontSize: 12,
|
||||
},
|
||||
updateEditorSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
documentListGridFsBuckets: backend.documentListGridFsBuckets,
|
||||
documentListGridFsFiles: backend.documentListGridFsFiles,
|
||||
documentCreateGridFsBucket: vi.fn(),
|
||||
documentDeleteGridFsBucket: vi.fn(),
|
||||
documentUploadGridFsFile: vi.fn(),
|
||||
documentDownloadGridFsFile: vi.fn(),
|
||||
documentDeleteGridFsFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ getConfig: () => ({}), ensureConnected: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/queryStore", () => ({
|
||||
useQueryStore: () => ({ openMongoBucket: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
TABLE_FONT_SIZE_MIN: 8,
|
||||
TABLE_FONT_SIZE_MAX: 16,
|
||||
useSettingsStore: () => settings,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
function passthroughStub(name: string) {
|
||||
return defineComponent({
|
||||
name,
|
||||
inheritAttrs: false,
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: passthroughStub("PopoverStub"),
|
||||
PopoverTrigger: passthroughStub("PopoverTriggerStub"),
|
||||
PopoverContent: passthroughStub("PopoverContentStub"),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: passthroughStub("SelectStub"),
|
||||
SelectContent: passthroughStub("SelectContentStub"),
|
||||
SelectItem: passthroughStub("SelectItemStub"),
|
||||
SelectTrigger: passthroughStub("SelectTriggerStub"),
|
||||
SelectValue: passthroughStub("SelectValueStub"),
|
||||
}));
|
||||
|
||||
import MongoGridFsBrowser from "@/components/document/MongoGridFsBrowser.vue";
|
||||
import MongoBucketBrowser from "@/components/document/MongoBucketBrowser.vue";
|
||||
import { resetBrowserStateCaches } from "@/lib/tabs/documentBrowserStateCache";
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
async function mount(component: unknown, props: Record<string, unknown>) {
|
||||
app = createApp(component as Parameters<typeof createApp>[0], props);
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
}
|
||||
|
||||
async function remount(component: unknown, props: Record<string, unknown>) {
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mount(component, props);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined, removeItem: () => undefined });
|
||||
backend.documentListGridFsBuckets.mockReset();
|
||||
backend.documentListGridFsFiles.mockReset();
|
||||
backend.documentListGridFsBuckets.mockResolvedValue([{ name: "fs", fileCount: 2, totalBytes: 100 }]);
|
||||
backend.documentListGridFsFiles.mockResolvedValue([
|
||||
{ id: "1", filename: "a.txt", length: 10, chunkSize: 1, uploadDate: "2026-01-01T00:00:00Z", metadata: null },
|
||||
{ id: "2", filename: "b.txt", length: 20, chunkSize: 1, uploadDate: "2026-01-02T00:00:00Z", metadata: null },
|
||||
]);
|
||||
resetBrowserStateCaches();
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
app = null;
|
||||
root = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("MongoGridFsBrowser tab state", () => {
|
||||
const props = { connectionId: "conn-1", database: "test", stateKey: "gridfs-tab" };
|
||||
|
||||
it("does not re-list buckets when a tab switch remounts the browser", async () => {
|
||||
await mount(MongoGridFsBrowser, props);
|
||||
expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(1);
|
||||
|
||||
await remount(MongoGridFsBrowser, props);
|
||||
expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-lists when no state key ties the tab to a snapshot", async () => {
|
||||
await mount(MongoGridFsBrowser, { connectionId: "conn-1", database: "test" });
|
||||
await remount(MongoGridFsBrowser, { connectionId: "conn-1", database: "test" });
|
||||
|
||||
expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-lists when the previous load failed", async () => {
|
||||
backend.documentListGridFsBuckets.mockReset();
|
||||
backend.documentListGridFsBuckets.mockRejectedValueOnce(new Error("boom")).mockResolvedValue([]);
|
||||
|
||||
await mount(MongoGridFsBrowser, props);
|
||||
await remount(MongoGridFsBrowser, props);
|
||||
|
||||
expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MongoBucketBrowser tab state", () => {
|
||||
const props = { connectionId: "conn-1", database: "test", bucket: "fs", stateKey: "bucket-tab" };
|
||||
|
||||
it("does not re-list files when a tab switch remounts the browser", async () => {
|
||||
await mount(MongoBucketBrowser, props);
|
||||
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(1);
|
||||
|
||||
await remount(MongoBucketBrowser, props);
|
||||
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-lists when the bucket changed", async () => {
|
||||
await mount(MongoBucketBrowser, props);
|
||||
await remount(MongoBucketBrowser, { ...props, bucket: "other" });
|
||||
|
||||
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the filter condition across a remount and re-lists under it", async () => {
|
||||
await mount(MongoBucketBrowser, props);
|
||||
|
||||
const filterInput = root!.querySelector<HTMLInputElement>('input[placeholder="{}"]');
|
||||
expect(filterInput).not.toBeNull();
|
||||
filterInput!.value = '{"filename":"a.txt"}';
|
||||
filterInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
filterInput!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await flushUi();
|
||||
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2);
|
||||
|
||||
await remount(MongoBucketBrowser, props);
|
||||
|
||||
// The condition survives, and the cached listing means no third request.
|
||||
const restoredFilter = root!.querySelector<HTMLInputElement>('input[placeholder="{}"]');
|
||||
expect(restoredFilter?.value).toBe('{"filename":"a.txt"}');
|
||||
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -488,6 +488,13 @@ interface DataGridProps {
|
||||
cacheKey?: string;
|
||||
columnWidthCacheKey?: string;
|
||||
pendingStateKey?: string;
|
||||
/**
|
||||
* Owner key for the tab-switch view snapshot. Defaults to `cacheKey`, which
|
||||
* is what query and data tabs use. Document tabs (MongoDB collections) set it
|
||||
* on its own because `cacheKey` also scopes structured filters, column widths
|
||||
* and pending edits, and they must keep those scopes as they are (#8679).
|
||||
*/
|
||||
viewStateKey?: string;
|
||||
/**
|
||||
* Logical-result identity (`QueryTab.resultViewGeneration`) the grid is
|
||||
* currently rendering. The tab-switch view snapshot is captured with this
|
||||
@@ -4970,6 +4977,9 @@ function cancelViewSnapshotRestoreFrame() {
|
||||
}
|
||||
|
||||
/** Bounded integrity probe of the result the grid is currently rendering. */
|
||||
/** Snapshot owner; falls back to `cacheKey` for query and data tabs. */
|
||||
const viewSnapshotOwnerKey = computed(() => props.viewStateKey?.trim() || props.cacheKey?.trim() || undefined);
|
||||
|
||||
function currentViewProbe(): string {
|
||||
const rows = props.result.rows;
|
||||
return buildDataGridViewProbe({
|
||||
@@ -5096,13 +5106,14 @@ function expandRowRange(range: readonly number[], displayCount: number): number[
|
||||
*/
|
||||
function captureTabSwitchViewSnapshot() {
|
||||
if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;
|
||||
if (!props.cacheKey || !props.viewGeneration) return;
|
||||
const ownerKey = viewSnapshotOwnerKey.value;
|
||||
if (!ownerKey || !props.viewGeneration) return;
|
||||
if (showTranspose.value) return;
|
||||
const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement();
|
||||
if (!scroller) return;
|
||||
const { selection, droppedSelection } = captureViewSelection();
|
||||
saveDataGridViewSnapshot({
|
||||
ownerKey: props.cacheKey,
|
||||
ownerKey,
|
||||
viewGeneration: props.viewGeneration,
|
||||
probe: currentViewProbe(),
|
||||
renderer: useCanvasGridRows.value ? "canvas" : "dom",
|
||||
@@ -5122,7 +5133,7 @@ function captureTabSwitchViewSnapshot() {
|
||||
function restoreTabSwitchViewSnapshot() {
|
||||
if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;
|
||||
if (!structuredFilterHydrationReady.value) return;
|
||||
const ownerKey = props.cacheKey;
|
||||
const ownerKey = viewSnapshotOwnerKey.value;
|
||||
if (!ownerKey || !props.viewGeneration) return;
|
||||
if (showTranspose.value) return;
|
||||
const snapshot = peekDataGridViewSnapshot(ownerKey);
|
||||
|
||||
+10
-2
@@ -17,13 +17,21 @@ describe("DataGrid tab-switch view snapshots", () => {
|
||||
const restoreSource = functionSource("restoreTabSwitchViewSnapshot", "const multiRowCount = computed(");
|
||||
|
||||
it("captures only with an owner key and a generation, and skips transpose", () => {
|
||||
expect(captureFnSource).toContain("if (!props.cacheKey || !props.viewGeneration) return;");
|
||||
expect(captureFnSource).toContain("if (!ownerKey || !props.viewGeneration) return;");
|
||||
expect(captureFnSource).toContain("if (showTranspose.value) return;");
|
||||
expect(captureFnSource).toContain("ownerKey: props.cacheKey");
|
||||
expect(captureFnSource).toContain("ownerKey,");
|
||||
expect(captureFnSource).toContain("viewGeneration: props.viewGeneration");
|
||||
expect(captureFnSource).toContain("probe: currentViewProbe()");
|
||||
});
|
||||
|
||||
it("takes the snapshot owner from viewStateKey, falling back to cacheKey", () => {
|
||||
// Document tabs need their own owner key: cacheKey also scopes structured
|
||||
// filters, column widths and pending edits, which they leave untouched.
|
||||
expect(dataGridSource).toContain("const viewSnapshotOwnerKey = computed(() => props.viewStateKey?.trim() || props.cacheKey?.trim() || undefined);");
|
||||
expect(captureFnSource).toContain("const ownerKey = viewSnapshotOwnerKey.value;");
|
||||
expect(restoreSource).toContain("const ownerKey = viewSnapshotOwnerKey.value;");
|
||||
});
|
||||
|
||||
it("captures the selection in O(selection size), never through the refresh identity scan", () => {
|
||||
// Copies refs directly instead of captureCurrentSelectionForRefresh, which
|
||||
// builds an identity token for every row (O(row count) on a large result).
|
||||
|
||||
@@ -2427,13 +2427,13 @@ defineExpose({
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mongo-gridfs'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" />
|
||||
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :state-key="activeTab.id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mongo-bucket'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoBucketBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :bucket="activeTab.mongoBucket?.bucketName || activeTab.sql" />
|
||||
<MongoBucketBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :bucket="activeTab.mongoBucket?.bucketName || activeTab.sql" :state-key="activeTab.id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clearDocumentBrowserState, restoreDocumentBrowserState, saveDocumentBrowserState, type DocumentBrowserStateSnapshot } from "@/lib/tabs/documentBrowserStateCache";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginClosingBrowserState,
|
||||
clearDocumentBrowserState,
|
||||
clearGridFsBrowserState,
|
||||
MAX_DATA_ENTRIES,
|
||||
MAX_RESTORED_DOCUMENT_ROWS,
|
||||
resetBrowserStateCaches,
|
||||
restoreDocumentBrowserState,
|
||||
restoreGridFsBrowserState,
|
||||
saveDocumentBrowserState,
|
||||
saveGridFsBrowserState,
|
||||
type DocumentBrowserDataSnapshot,
|
||||
type DocumentBrowserStateSnapshot,
|
||||
} from "@/lib/tabs/documentBrowserStateCache";
|
||||
|
||||
function snapshot(page: number): DocumentBrowserStateSnapshot {
|
||||
return {
|
||||
@@ -11,6 +24,25 @@ function snapshot(page: number): DocumentBrowserStateSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function data(rowCount: number, signature = "sig"): DocumentBrowserDataSnapshot {
|
||||
return {
|
||||
signature,
|
||||
documents: Array.from({ length: rowCount }, (_, index) => ({ _id: String(index) })),
|
||||
copyDocuments: [],
|
||||
copyDocumentsAvailable: false,
|
||||
gridColumns: ["_id"],
|
||||
gridColumnTypes: ["objectId"],
|
||||
total: rowCount,
|
||||
totalIsExact: true,
|
||||
paginationTotal: rowCount,
|
||||
selectedIdx: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetBrowserStateCaches();
|
||||
});
|
||||
|
||||
describe("documentBrowserStateCache", () => {
|
||||
it("round-trips a snapshot for the same key", () => {
|
||||
saveDocumentBrowserState("round-trip", snapshot(3));
|
||||
@@ -56,4 +88,75 @@ describe("documentBrowserStateCache", () => {
|
||||
|
||||
expect(restoreDocumentBrowserState("clear-me")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips a row payload alongside the conditions", () => {
|
||||
saveDocumentBrowserState("with-data", { ...snapshot(1), data: data(2) });
|
||||
|
||||
expect(restoreDocumentBrowserState("with-data")?.data?.documents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops a stored payload on a conditions-only save", () => {
|
||||
saveDocumentBrowserState("drop-data", { ...snapshot(1), data: data(2) });
|
||||
saveDocumentBrowserState("drop-data", snapshot(1));
|
||||
|
||||
expect(restoreDocumentBrowserState("drop-data")?.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops a payload larger than the row bound but keeps the conditions", () => {
|
||||
saveDocumentBrowserState("too-big", { ...snapshot(2), data: data(MAX_RESTORED_DOCUMENT_ROWS + 1) });
|
||||
|
||||
const restored = restoreDocumentBrowserState("too-big");
|
||||
expect(restored?.data).toBeUndefined();
|
||||
expect(restored?.filterInput).toBe(snapshot(2).filterInput);
|
||||
});
|
||||
|
||||
it("keeps payloads only for the most recently used tabs", () => {
|
||||
for (let index = 0; index < MAX_DATA_ENTRIES + 2; index += 1) {
|
||||
saveDocumentBrowserState(`payload-${index}`, { ...snapshot(index), data: data(1) });
|
||||
}
|
||||
|
||||
expect(restoreDocumentBrowserState("payload-0")?.data).toBeUndefined();
|
||||
expect(restoreDocumentBrowserState("payload-0")?.filterInput).toBe(snapshot(0).filterInput);
|
||||
expect(restoreDocumentBrowserState(`payload-${MAX_DATA_ENTRIES + 1}`)?.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("blocks the unmount capture from resurrecting a closed tab", () => {
|
||||
saveDocumentBrowserState("closing", { ...snapshot(1), data: data(1) });
|
||||
beginClosingBrowserState("closing");
|
||||
// DocumentBrowser unmounts after the store removed the tab and writes again.
|
||||
saveDocumentBrowserState("closing", { ...snapshot(1), data: data(1) });
|
||||
|
||||
expect(restoreDocumentBrowserState("closing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gridFsBrowserStateCache", () => {
|
||||
it("round-trips conditions and rows", () => {
|
||||
saveGridFsBrowserState("gridfs-a", { filterInput: "{}", sortInput: "", signature: "sig", rows: [{ name: "fs" }] });
|
||||
|
||||
const restored = restoreGridFsBrowserState<{ name: string }>("gridfs-a");
|
||||
expect(restored?.rows).toEqual([{ name: "fs" }]);
|
||||
expect(restored?.signature).toBe("sig");
|
||||
});
|
||||
|
||||
it("drops an oversized listing but keeps the conditions", () => {
|
||||
const rows = Array.from({ length: MAX_RESTORED_DOCUMENT_ROWS + 1 }, (_, index) => ({ name: String(index) }));
|
||||
saveGridFsBrowserState("gridfs-big", { filterInput: "{}", sortInput: "", signature: "sig", rows });
|
||||
|
||||
const restored = restoreGridFsBrowserState<{ name: string }>("gridfs-big");
|
||||
expect(restored?.rows).toBeUndefined();
|
||||
expect(restored?.signature).toBeUndefined();
|
||||
expect(restored?.filterInput).toBe("{}");
|
||||
});
|
||||
|
||||
it("clears a key on demand and on close", () => {
|
||||
saveGridFsBrowserState("gridfs-clear", { filterInput: "", sortInput: "" });
|
||||
clearGridFsBrowserState("gridfs-clear");
|
||||
expect(restoreGridFsBrowserState("gridfs-clear")).toBeUndefined();
|
||||
|
||||
saveGridFsBrowserState("gridfs-closing", { filterInput: "", sortInput: "" });
|
||||
beginClosingBrowserState("gridfs-closing");
|
||||
saveGridFsBrowserState("gridfs-closing", { filterInput: "", sortInput: "" });
|
||||
expect(restoreGridFsBrowserState("gridfs-closing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
import type { DocumentFilterRule } from "@/lib/app/documentStoreProvider";
|
||||
|
||||
/**
|
||||
* Rows and totals a completed load produced, replayed on remount instead of
|
||||
* re-querying. `signature` pins the identity and conditions they were loaded
|
||||
* under (store kind, connection/database/collection, filter, sort, page, page
|
||||
* size, infinite-scroll mode); a restore that does not match it falls through
|
||||
* to a normal load.
|
||||
*/
|
||||
export interface DocumentBrowserDataSnapshot {
|
||||
signature: string;
|
||||
/** DataGrid's logical-result identity, so it may replay scroll and selection. */
|
||||
viewGeneration?: string;
|
||||
documents: Record<string, unknown>[];
|
||||
copyDocuments: Record<string, unknown>[];
|
||||
copyDocumentsAvailable: boolean;
|
||||
gridColumns: string[];
|
||||
gridColumnTypes: string[];
|
||||
total?: number;
|
||||
totalIsExact: boolean;
|
||||
paginationTotal?: number;
|
||||
selectedIdx: number | null;
|
||||
}
|
||||
|
||||
export interface DocumentBrowserStateSnapshot {
|
||||
/** Manual JSON filter text from the collection filter input. */
|
||||
filterInput: string;
|
||||
@@ -11,15 +33,55 @@ export interface DocumentBrowserStateSnapshot {
|
||||
documentFilterRules: DocumentFilterRule[];
|
||||
/** Zero-based page position; only meaningful for skip-based stores. */
|
||||
page: number;
|
||||
/**
|
||||
* Rows from the last completed load. Written only by the unmount capture; a
|
||||
* conditions-only save (the keystroke path) drops it, which is the whole
|
||||
* invalidation story — a dropped payload degrades to today's reload.
|
||||
*/
|
||||
data?: DocumentBrowserDataSnapshot;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// every tab switch and remounted when the user comes back. Query conditions and
|
||||
// the rows they produced survive that round trip through this session-scoped
|
||||
// cache keyed by tab id — the same role tab.whereInput/tab.orderByInput and
|
||||
// tab.result play for SQL data tabs, which have no equivalent here. The bounds
|
||||
// keep state for long tab sessions from accumulating forever.
|
||||
const MAX_ENTRIES = 32;
|
||||
/** Payloads larger than this are dropped; the tab reloads instead. */
|
||||
export const MAX_RESTORED_DOCUMENT_ROWS = 2_000;
|
||||
/** Only the most recently used tabs keep rows; older ones keep conditions. */
|
||||
export const MAX_DATA_ENTRIES = 8;
|
||||
|
||||
const cache = new Map<string, DocumentBrowserStateSnapshot>();
|
||||
const gridFsCache = new Map<string, GridFsBrowserStateSnapshot<unknown>>();
|
||||
const closingKeys = new Set<string>();
|
||||
|
||||
/**
|
||||
* Clear a tab's cached state and block new writes for a short window, so the
|
||||
* `onBeforeUnmount` capture cannot resurrect an entry after the tab is closed.
|
||||
* Mirrors `beginClosingDataGridViewSnapshotsForTab`.
|
||||
*/
|
||||
export function beginClosingBrowserState(stateKey: string): void {
|
||||
closingKeys.add(stateKey);
|
||||
// Some test environments stub `window` without timers, so a bare
|
||||
// `window.setTimeout` would throw while closing a tab.
|
||||
const schedule = typeof window !== "undefined" && typeof window.setTimeout === "function" ? window.setTimeout.bind(window) : setTimeout;
|
||||
schedule(() => closingKeys.delete(stateKey), 5000);
|
||||
cache.delete(stateKey);
|
||||
gridFsCache.delete(stateKey);
|
||||
}
|
||||
|
||||
function trimDataPayloads(): void {
|
||||
const keysNewestFirst = [...cache.keys()].reverse();
|
||||
let kept = 0;
|
||||
for (const key of keysNewestFirst) {
|
||||
const entry = cache.get(key);
|
||||
if (!entry?.data) continue;
|
||||
kept += 1;
|
||||
if (kept > MAX_DATA_ENTRIES) cache.set(key, { ...entry, data: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreDocumentBrowserState(stateKey: string): DocumentBrowserStateSnapshot | undefined {
|
||||
const snapshot = cache.get(stateKey);
|
||||
@@ -31,15 +93,67 @@ export function restoreDocumentBrowserState(stateKey: string): DocumentBrowserSt
|
||||
}
|
||||
|
||||
export function saveDocumentBrowserState(stateKey: string, snapshot: DocumentBrowserStateSnapshot): void {
|
||||
if (closingKeys.has(stateKey)) return;
|
||||
const bounded = snapshot.data && snapshot.data.documents.length > MAX_RESTORED_DOCUMENT_ROWS ? { ...snapshot, data: undefined } : snapshot;
|
||||
cache.delete(stateKey);
|
||||
cache.set(stateKey, snapshot);
|
||||
cache.set(stateKey, bounded);
|
||||
while (cache.size > MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
trimDataPayloads();
|
||||
}
|
||||
|
||||
export function clearDocumentBrowserState(stateKey: string): void {
|
||||
cache.delete(stateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* GridFS bucket and file listings reload on every remount for the same reason
|
||||
* DocumentBrowser does, and never got the per-tab condition cache at all. Their
|
||||
* payloads are small metadata rows, so one entry per tab carries both the query
|
||||
* conditions and the listing.
|
||||
*/
|
||||
export interface GridFsBrowserStateSnapshot<TRow> {
|
||||
filterInput: string;
|
||||
sortInput: string;
|
||||
appliedStructuredFilter?: Record<string, unknown> | null;
|
||||
filterRules?: DocumentFilterRule[];
|
||||
page?: number;
|
||||
selectedId?: string;
|
||||
/** Identity + conditions the rows were listed under. */
|
||||
signature?: string;
|
||||
rows?: TRow[];
|
||||
}
|
||||
|
||||
export function restoreGridFsBrowserState<TRow>(stateKey: string): GridFsBrowserStateSnapshot<TRow> | undefined {
|
||||
const snapshot = gridFsCache.get(stateKey);
|
||||
if (!snapshot) return undefined;
|
||||
gridFsCache.delete(stateKey);
|
||||
gridFsCache.set(stateKey, snapshot);
|
||||
return snapshot as GridFsBrowserStateSnapshot<TRow>;
|
||||
}
|
||||
|
||||
export function saveGridFsBrowserState<TRow>(stateKey: string, snapshot: GridFsBrowserStateSnapshot<TRow>): void {
|
||||
if (closingKeys.has(stateKey)) return;
|
||||
const bounded = snapshot.rows && snapshot.rows.length > MAX_RESTORED_DOCUMENT_ROWS ? { ...snapshot, rows: undefined, signature: undefined } : snapshot;
|
||||
gridFsCache.delete(stateKey);
|
||||
gridFsCache.set(stateKey, bounded as GridFsBrowserStateSnapshot<unknown>);
|
||||
while (gridFsCache.size > MAX_ENTRIES) {
|
||||
const oldest = gridFsCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
gridFsCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGridFsBrowserState(stateKey: string): void {
|
||||
gridFsCache.delete(stateKey);
|
||||
}
|
||||
|
||||
/** Test seam: drop everything. */
|
||||
export function resetBrowserStateCaches(): void {
|
||||
cache.clear();
|
||||
gridFsCache.clear();
|
||||
closingKeys.clear();
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import { classifySqlRisk } from "@/lib/sql/sqlRisk";
|
||||
import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen";
|
||||
import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
|
||||
import { beginClosingDataGridViewSnapshotsForTab, clearDataGridViewSnapshot, clearDataGridViewSnapshotsForTab } from "@/lib/dataGrid/dataGridViewStateCache";
|
||||
import { beginClosingBrowserState } from "@/lib/tabs/documentBrowserStateCache";
|
||||
import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence";
|
||||
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
@@ -3428,6 +3429,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||||
clearDataGridPendingSnapshotsForTab(id);
|
||||
beginClosingDataGridViewSnapshotsForTab(id);
|
||||
beginClosingBrowserState(id);
|
||||
clearDataGridStructuredFilterStatesForTab(id);
|
||||
clearDataGridSearchStatesForTab(id);
|
||||
if (tabs.value[idx].txnSessionId) void rollbackTransaction(id);
|
||||
@@ -3790,6 +3792,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
beginClosingDataGridViewSnapshotsForTab(tab.id);
|
||||
beginClosingBrowserState(tab.id);
|
||||
clearDataGridStructuredFilterStatesForTab(tab.id);
|
||||
clearDataGridSearchStatesForTab(tab.id);
|
||||
if (tab.txnSessionId) void rollbackTransaction(tab.id);
|
||||
@@ -4009,6 +4012,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
rollbackTabTransaction(tab, { resetAutoCommit: true });
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
beginClosingDataGridViewSnapshotsForTab(tab.id);
|
||||
beginClosingBrowserState(tab.id);
|
||||
clearDataGridStructuredFilterStatesForTab(tab.id);
|
||||
clearDataGridSearchStatesForTab(tab.id);
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
|
||||
Reference in New Issue
Block a user