diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 739d64feb..3ac500762 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -19,7 +19,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit"; import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle"; import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals"; -import { canGoNextDocumentPage, resolveDocumentQueryTotals } from "@/lib/document/documentQueryTotals"; +import { canGoNextDocumentPage, isSameDocumentQueryTotalCountRequest, resolveDocumentQueryTotals, type DocumentQueryTotalCountRequest } from "@/lib/document/documentQueryTotals"; import { arrayObjectAncestorPathForDocumentField, buildDocumentFilterCondition, @@ -45,6 +45,7 @@ import { type DocumentFieldPathNode, type DocumentFilterMode, type DocumentFilterRule, + type DocumentStoreKind, type ElasticsearchBoolClause, type ElasticsearchQueryType, } from "@/lib/app/documentStoreProvider"; @@ -144,6 +145,9 @@ let elasticsearchExactTotal: number | undefined; let elasticsearchPaginationLowerBound: number | undefined; let elasticsearchCountExecutionId = ""; let elasticsearchCountGeneration = 0; +type LoadedDocumentQueryTotalCountRequest = DocumentQueryTotalCountRequest & { storeKind: DocumentStoreKind }; +let loadedDocumentQueryTotalCountRequest: LoadedDocumentQueryTotalCountRequest | undefined; +let documentRequestGeneration = 0; const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType)); const documentColumnLayoutScopeKey = computed(() => documentDataGridColumnLayoutScopeKey({ @@ -772,6 +776,13 @@ function elasticsearchCountFilterKey(filter: string | undefined): string { return JSON.stringify([props.connectionId, props.database, props.collection, filter ?? ""]); } +function isCurrentDocumentQueryTotalCountRequest(request: LoadedDocumentQueryTotalCountRequest): boolean { + if (request.generation !== documentRequestGeneration || request.connectionId !== props.connectionId || request.database !== props.database || request.collection !== props.collection || request.storeKind !== documentStoreProvider.value.kind) { + return false; + } + return loadedDocumentQueryTotalCountRequest !== undefined && isSameDocumentQueryTotalCountRequest(request, loadedDocumentQueryTotalCountRequest) && request.storeKind === loadedDocumentQueryTotalCountRequest.storeKind; +} + function cancelElasticsearchCount() { elasticsearchCountGeneration++; const executionId = elasticsearchCountExecutionId; @@ -790,13 +801,12 @@ function resetElasticsearchTotals(options: { preservePaginationTotal?: boolean } totalIsExact.value = nextTotals.totalIsExact; } -function clampPageToPaginationTotal(): boolean { +function clampPageToPaginationTotal(): number | undefined { const cap = paginationTotal.value; - if (cap === undefined) return false; + if (cap === undefined) return undefined; const nextPage = clampDocumentPage(page.value, pageSize.value, cap); - if (page.value === nextPage) return false; - page.value = nextPage; - return true; + if (page.value === nextPage) return undefined; + return nextPage; } function startElasticsearchExactCount(filter: string | undefined) { @@ -817,7 +827,8 @@ function startElasticsearchExactCount(filter: string | undefined) { total.value = totals.total; totalIsExact.value = totals.totalIsExact; paginationTotal.value = totals.paginationTotal; - if (clampPageToPaginationTotal()) void load(); + const clampedPage = clampPageToPaginationTotal(); + if (clampedPage !== undefined) void load({ page: clampedPage }); }) .catch(() => { // The lower-bound result remains truthful when a background count fails. @@ -862,27 +873,35 @@ function applyElasticsearchSearchTotal(searchTotal: number, isExact: boolean, fi startElasticsearchExactCount(filter); } -async function load() { +async function load(options: { page?: number } = {}) { if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value); + const requestGeneration = ++documentRequestGeneration; const executionId = uuid(); loading.value = true; documentLoadExecutionId.value = executionId; documentLoadCancelling.value = false; startDocumentLoadingTimer(); error.value = ""; + const requestPage = options.page ?? page.value; const previousSelectedIdx = selectedIdx.value; const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]); try { + const connectionId = props.connectionId; + const database = props.database; + const collection = props.collection; + const storeKind = documentStoreProvider.value.kind; const filter = currentDocumentFilter(); - if (documentStoreProvider.value.kind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) { + const countRequest: LoadedDocumentQueryTotalCountRequest = { connectionId, database, collection, filter, generation: requestGeneration, storeKind }; + if (storeKind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) { resetElasticsearchTotals(); } const sort = currentDocumentSortJson(sortInput.value); - const skip = page.value * pageSize.value; - const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId); + const skip = requestPage * pageSize.value; + const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId); if (documentLoadExecutionId.value !== executionId) return; + if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return; const nextDocuments = - documentStoreProvider.value.kind === "elasticsearch" && result.raw_documents?.length === result.documents.length + storeKind === "elasticsearch" && result.raw_documents?.length === result.documents.length ? result.raw_documents.map((raw, index) => { try { return asRecord(parseJsonPreservingLargeNumbers(raw)); @@ -893,9 +912,12 @@ async function load() { : result.documents.map(asRecord); const hasTypePreservingCopyDocuments = result.extended_documents?.length === nextDocuments.length; const nextCopyDocuments = hasTypePreservingCopyDocuments ? result.extended_documents!.map(asRecord) : nextDocuments; + // Commit page + rows together so stale rows never briefly show last-page indexes. + if (options.page !== undefined) page.value = options.page; documents.value = nextDocuments; copyDocuments.value = nextCopyDocuments; mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments; + loadedDocumentQueryTotalCountRequest = countRequest; if (nextDocuments.length > 0) { const keySet = new Set(); keySet.add("_id"); @@ -906,11 +928,15 @@ async function load() { } lastGridColumns.value = [...keySet]; } - if (documentStoreProvider.value.kind === "elasticsearch") { + if (storeKind === "elasticsearch") { applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter); } else { cancelElasticsearchCount(); - const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false); + const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false, { + page: requestPage, + pageSize: pageSize.value, + rowCount: nextDocuments.length, + }); total.value = totals.total; totalIsExact.value = totals.totalIsExact; paginationTotal.value = totals.paginationTotal; @@ -928,6 +954,34 @@ async function load() { } } +async function countExactDocumentTotal(): Promise { + const request = loadedDocumentQueryTotalCountRequest; + if (!request || !isCurrentDocumentQueryTotalCountRequest(request)) return undefined; + if (request.storeKind === "elasticsearch") { + const exactCount = await api.elasticsearchCountDocuments(request.connectionId, request.collection, request.filter); + if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined; + if (!Number.isFinite(exactCount) || exactCount < 0) { + throw new Error("invalid count"); + } + elasticsearchExactTotal = exactCount; + const totals = resolveElasticsearchDocumentTotals(elasticsearchPaginationLowerBound ?? exactCount, false, exactCount); + total.value = totals.total; + totalIsExact.value = totals.totalIsExact; + paginationTotal.value = totals.paginationTotal; + return exactCount; + } + const exactCount = await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate"); + if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined; + if (!Number.isFinite(exactCount) || exactCount < 0) { + throw new Error("invalid count"); + } + const totals = resolveDocumentQueryTotals(exactCount, true); + total.value = totals.total; + totalIsExact.value = totals.totalIsExact; + paginationTotal.value = totals.paginationTotal; + return exactCount; +} + async function refreshDocuments() { if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true }); await load(); @@ -959,8 +1013,8 @@ function paginate(offset: number, limit: number) { const normalizedLimit = normalizeResultPageSize(limit, pageSize.value); pageSize.value = normalizedLimit; const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit); - page.value = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value); - void load(); + const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value); + void load({ page: nextPage }); } function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) { @@ -1475,6 +1529,8 @@ onMounted(async () => { onBeforeUnmount(() => { window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true); if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value); + documentRequestGeneration++; + loadedDocumentQueryTotalCountRequest = undefined; cancelElasticsearchCount(); stopDocumentLoadingTimer(); endTableSearchSplitResize(); @@ -1616,7 +1672,9 @@ defineExpose({ focusSearch }); :page-limit="pageSize" :total-row-count="total" :total-row-count-is-exact="totalIsExact" + :inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'" :pagination-total-row-count="pageTotal" + :count-total-rows="countExactDocumentTotal" @sort="onSort" @reload="refreshDocuments" @paginate="(offset: number, limit: number) => paginate(offset, limit)" diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2e0477456..6beda9360 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -135,7 +135,7 @@ import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOpti import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; -import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "@/lib/dataGrid/dataGridPagination"; +import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; @@ -184,7 +184,7 @@ import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFro import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, extractorUnavailableForDatabase, type DataGridCopyExtractorId } from "@/lib/dataGrid/dataGridCopyExtractor"; import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy"; -import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; +import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState"; import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout"; import { useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime"; @@ -245,6 +245,7 @@ const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize); +const rowNumberWidth = ref(DATA_GRID_ROW_NUM_WIDTH); const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMultiRowTranspose); const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns); const { isDark, themePalette } = useTheme(); @@ -300,9 +301,12 @@ interface DataGridProps { countSql?: string; totalRowCount?: number; totalRowCountIsExact?: boolean; + inexactTotalRowCountMode?: DataGridInexactTotalRowCountMode; paginationTotalRowCount?: number; paginationEnabled?: boolean; totalRowCountLoading?: boolean; + /** Document stores (e.g. MongoDB) count exactly on demand without SQL tableMeta/countSql. */ + countTotalRows?: () => Promise; loading?: boolean; cacheKey?: string; exportSql?: string; @@ -322,6 +326,7 @@ const props = withDefaults(defineProps(), { // Vue casts absent Boolean props to false unless a default is explicit. // Regular grids have exact totals; document stores opt into lower-bound totals. totalRowCountIsExact: true, + inexactTotalRowCountMode: "at-least", paginationEnabled: true, // Omitted row-action limits must keep normal table-data editing. allowInsertRows: undefined, @@ -1898,6 +1903,7 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to columnStructureSignature, measureHeaderText: measureColumnHeaderText, headerMeasurementKey: columnHeaderMeasurementKey, + rowNumberWidth, }); const gridStyle = computed(() => ({ ...columnVars.value, @@ -1932,7 +1938,7 @@ const { renderedColumnWidths, scrollLeft: gridHorizontalScrollLeft, viewportWidth: gridViewportWidth, - rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH, + rowNumberWidth, headerRef, orderedColumnIndexes: orderedDisplayableColumnIndexes, hiddenColumnIndexes, @@ -2453,9 +2459,10 @@ const inferredBackendTotalRowCount = computed(() => { if (affected <= props.result.rows.length) return undefined; return affected; }); -const serverKnownTotalRowCount = computed(() => props.totalRowCount ?? manualTotalRowCount.value); +const serverKnownTotalRowCount = computed(() => (typeof manualTotalRowCount.value === "number" ? manualTotalRowCount.value : props.totalRowCount)); const displayedTotalRowCount = computed(() => serverKnownTotalRowCount.value ?? inferredBackendTotalRowCount.value); -const totalRowCountIsExact = computed(() => props.totalRowCountIsExact !== false); +const totalRowCountIsExact = computed(() => typeof manualTotalRowCount.value === "number" || props.totalRowCountIsExact !== false); +const totalRowCountLabelKey = computed(() => dataGridTotalRowCountLabelKey(totalRowCountIsExact.value, props.inexactTotalRowCountMode)); // A backend can expose an exact display total while deliberately restricting // offset pagination to a smaller safe range. const paginationTotalRowCount = computed(() => @@ -2506,9 +2513,36 @@ const canFetchNextInfiniteScrollSegment = computed(() => allRowsLoaded: allRowsLoaded.value, }), ); -const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql)); +const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql || !!props.countTotalRows)); const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value); -const canCalculateTotalRowCount = computed(() => !!props.connectionId && (!!props.tableMeta || !!props.countSql)); +/** Full-grid busy state: query loading or on-demand COUNT (e.g. jump to last page). */ +const gridSurfaceBusy = computed(() => props.loading === true || totalRowCountBusy.value); +const canCalculateTotalRowCount = computed(() => !!props.countTotalRows || (!!props.connectionId && (!!props.tableMeta || !!props.countSql))); +const showExactTotalCountAction = computed(() => canCalculateTotalRowCount.value && (totalRowCountIsExact.value === false || typeof displayedTotalRowCount.value !== "number")); +watch( + [ + () => + resolveDataGridMaxRowNumber({ + infiniteScroll: infiniteScrollEnabled.value, + allRowsLoaded: allRowsLoaded.value, + currentPage: currentPage.value, + pageSize: pageSize.value, + rowCount: props.result.rows.length, + }), + tableFontSize, + tableFontFamily, + ], + ([maxRowNumber, fontSize, fontFamily]) => { + rowNumberWidth.value = dataGridRowNumberColumnWidth(maxRowNumber, fontSize, (text) => { + if (typeof document === "undefined") return undefined; + if (columnHeaderMeasureContext === undefined) columnHeaderMeasureContext = document.createElement("canvas").getContext("2d"); + if (!columnHeaderMeasureContext) return undefined; + columnHeaderMeasureContext.font = `400 ${fontSize}px ${fontFamily}`; + return Math.ceil(columnHeaderMeasureContext.measureText(text).width); + }); + }, + { immediate: true }, +); // When a refresh/rollback completes and the current page exceeds the last // available page (e.g. data was deleted while viewing), auto-navigate to the // last available page instead of showing an empty page. @@ -2582,7 +2616,7 @@ function currentOrderBy(): string | undefined { } watch( - () => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? "", props.result], + () => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? ""], () => { manualTotalRowCount.value = undefined; // Reset infinite-scroll allLoaded when query context changes @@ -2684,42 +2718,73 @@ function applyCustomPageSize() { changePageSize(normalizeResultPageSize(customPageSizeInput.value, pageSize.value)); } +function jumpToCountedLastPage(total: number) { + if (total <= 0) return; + const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value)); + if (lastPageNum <= currentPage.value) return; + // Do not bump currentPage before the new page loads — otherwise stale rows + // briefly render with last-page indexes (e.g. 12001-13000) and flash a fake full page. + resetGridVerticalScroll(true); + emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy()); +} + +async function beginManualTotalRowCount(): Promise { + if (manualTotalRowCountLoading.value) return false; + manualTotalRowCountLoading.value = true; + // Flush busy UI (overlay / spinner) before the slow COUNT starts. + await nextTick(); + return true; +} + async function lastPage() { if (infiniteScrollEnabled.value) return; - if (hasKnownPaginationTotalRowCount.value) { - const total = paginationTotalRowCount.value ?? 0; - if (total <= 0) return; - const lastPageNum = Math.ceil(total / pageSize.value); - if (lastPageNum <= currentPage.value) return; - currentPage.value = lastPageNum; - resetGridVerticalScroll(true); - emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy()); - return; - } if (allRowsLoaded.value) { const total = props.result.rows.length; if (total <= 0) return; - const lastPageNum = Math.ceil(total / pageSize.value); + const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value)); if (lastPageNum <= currentPage.value) return; currentPage.value = lastPageNum; resetGridVerticalScroll(true); return; } - if (!props.connectionId) return; - const countTarget = await buildCurrentCountTarget(); - const sql = countTarget?.sql; - if (!sql) return; - try { - const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId))); - const total = Number(result.rows?.[0]?.[0] ?? 0); - if (total <= 0) return; - const lastPageNum = Math.ceil(total / pageSize.value); - if (lastPageNum <= currentPage.value) return; - currentPage.value = lastPageNum; - resetGridVerticalScroll(true); - emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy()); - } catch { - // COUNT query failed — ignore silently + // Navicat-style: always re-COUNT when jumping to the last page. + if (props.countTotalRows) { + if (!(await beginManualTotalRowCount())) return; + try { + const total = await props.countTotalRows(); + if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return; + manualTotalRowCount.value = total; + jumpToCountedLastPage(total); + // Keep the busy overlay until the parent query loading flag can take over. + await nextTick(); + } catch (e: any) { + toast(t("grid.calculateTotalRowsFailed", { message: e?.message || String(e) }), 5000); + } finally { + manualTotalRowCountLoading.value = false; + } + return; + } + if (props.connectionId && (props.countSql || props.tableMeta)) { + if (!(await beginManualTotalRowCount())) return; + try { + const countTarget = await buildCurrentCountTarget(); + const sql = countTarget?.sql; + if (!sql) return; + const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId))); + const total = Number(result.rows?.[0]?.[0] ?? 0); + if (!Number.isFinite(total) || total < 0) return; + manualTotalRowCount.value = total; + jumpToCountedLastPage(total); + await nextTick(); + } catch { + // COUNT query failed — ignore silently + } finally { + manualTotalRowCountLoading.value = false; + } + return; + } + if (hasKnownPaginationTotalRowCount.value) { + jumpToCountedLastPage(paginationTotalRowCount.value ?? 0); } } @@ -2741,9 +2806,16 @@ async function buildCurrentCountTarget(): Promise<{ sql: string; schema?: string } async function calculateTotalRowCount() { - if (!props.connectionId || manualTotalRowCountLoading.value) return; - manualTotalRowCountLoading.value = true; + if (!(await beginManualTotalRowCount())) return; try { + if (props.countTotalRows) { + const total = await props.countTotalRows(); + if (typeof total === "number" && Number.isFinite(total) && total >= 0) { + manualTotalRowCount.value = total; + } + return; + } + if (!props.connectionId) return; const countTarget = await buildCurrentCountTarget(); if (!countTarget?.sql) return; const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", countTarget.sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId))); @@ -4385,10 +4457,16 @@ function formatCellCached(value: CellValue, columnIndex?: number): string { return rememberPrimitiveCellFormat(key, formatCell(value, columnIndex)); } +function rowNumberPageOffset(): number { + if (infiniteScrollEnabled.value) return 0; + if (typeof props.pageOffset === "number" && props.pageOffset >= 0) return props.pageOffset; + return (currentPage.value - 1) * pageSize.value; +} + function rowNumberText(item: RowItem | undefined): string { if (!item) return ""; if (item.isDraft) return "*"; - return String(infiniteScrollEnabled.value ? item.displayIndex + 1 : item.displayIndex + 1 + (currentPage.value - 1) * pageSize.value); + return String(item.displayIndex + 1 + rowNumberPageOffset()); } function draftCellPlaceholder(item: RowItem | undefined, columnIndex: number): string | null { @@ -4546,12 +4624,12 @@ function dataGridCellFromClientPoint(clientX: number, clientY: number): { rowInd const scroller = dataGridSelectionScroller(); if (!scroller) return null; const rect = scroller.getBoundingClientRect(); - const clampedX = Math.min(rect.right - 1, Math.max(rect.left + DATA_GRID_ROW_NUM_WIDTH + 1, clientX)); + const clampedX = Math.min(rect.right - 1, Math.max(rect.left + rowNumberWidth.value + 1, clientX)); const clampedY = Math.min(rect.bottom - 1, Math.max(rect.top + 1, clientY)); if (useCanvasGridRows.value) { const rowIndex = Math.floor((scroller.scrollTop + clampedY - rect.top) / CANVAS_DATA_GRID_ROW_HEIGHT); - const visibleColIdx = canvasColumnAt(scroller.scrollLeft + clampedX - rect.left - DATA_GRID_ROW_NUM_WIDTH); + const visibleColIdx = canvasColumnAt(scroller.scrollLeft + clampedX - rect.left - rowNumberWidth.value); if (rowIndex < 0 || rowIndex >= displayRowCount.value || visibleColIdx < 0) return null; const item = displayItemAt(rowIndex); return item ? { rowIndex: item.displayIndex, colIndex: visibleColIdx } : null; @@ -4578,7 +4656,7 @@ function dataGridRowFromClientPoint(_clientX: number, clientY: number): number | return item?.displayIndex ?? null; } - const target = document.elementFromPoint(rect.left + Math.min(DATA_GRID_ROW_NUM_WIDTH / 2, rect.width / 2), clampedY); + const target = document.elementFromPoint(rect.left + Math.min(rowNumberWidth.value / 2, rect.width / 2), clampedY); const row = target instanceof Element ? target.closest("[data-row-index]") : null; const rowIndex = Number(row?.dataset.rowIndex); return Number.isInteger(rowIndex) ? rowIndex : null; @@ -4681,14 +4759,14 @@ function canvasHitTest(event: MouseEvent): { rowIndex: number; visibleColIdx: nu const y = event.clientY - rect.top; const rowIndex = Math.floor((scroller.scrollTop + y) / CANVAS_DATA_GRID_ROW_HEIGHT); if (rowIndex < 0 || rowIndex >= displayRowCount.value) return null; - if (x < DATA_GRID_ROW_NUM_WIDTH) return { rowIndex, visibleColIdx: -1, rowNumber: true }; + if (x < rowNumberWidth.value) return { rowIndex, visibleColIdx: -1, rowNumber: true }; // 冻结列区域不受 scrollLeft 影响,需要特殊处理 const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0; let contentX: number; - if (frozenWidth > 0 && x - DATA_GRID_ROW_NUM_WIDTH < frozenWidth) { - contentX = x - DATA_GRID_ROW_NUM_WIDTH; + if (frozenWidth > 0 && x - rowNumberWidth.value < frozenWidth) { + contentX = x - rowNumberWidth.value; } else { - contentX = scroller.scrollLeft + x - DATA_GRID_ROW_NUM_WIDTH; + contentX = scroller.scrollLeft + x - rowNumberWidth.value; } const visibleColIdx = canvasColumnAt(contentX); if (visibleColIdx < 0) return null; @@ -4908,7 +4986,7 @@ function canvasCellViewportRect(rowIndex: number, visibleColIdx: number) { if (colWidth === undefined) return null; // 冻结列不受 scrollLeft 影响 const isFrozen = visibleColIdx < frozenColumnCount.value; - const left = DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - (isFrozen ? 0 : gridHorizontalScrollLeft.value); + const left = rowNumberWidth.value + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - (isFrozen ? 0 : gridHorizontalScrollLeft.value); return { left, top: rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - canvasScrollTop.value, @@ -4931,7 +5009,7 @@ function canvasEditingCellIsVisible() { if (!rect) return false; const viewportWidth = canvasEffectiveViewportWidth(); const viewportHeight = canvasEffectiveViewportHeight(); - const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left); + const clippedLeft = Math.max(rowNumberWidth.value, rect.left); const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width; return rect.top + rect.height > 0 && rect.top < viewportHeight && clippedRight - clippedLeft > 0; } @@ -4974,7 +5052,7 @@ const canvasEditingCellStyle = computed(() => { const cell = canvasEditingCell.value; if (!cell) return {}; const viewportWidth = canvasEffectiveViewportWidth(); - const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, cell.rect.left); + const clippedLeft = Math.max(rowNumberWidth.value, cell.rect.left); const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, cell.rect.left + cell.rect.width) : cell.rect.left + cell.rect.width; return { left: `${clippedLeft}px`, @@ -4996,7 +5074,7 @@ const canvasDetailButtonCell = computed(() => { if (!rect) return null; const viewportWidth = canvasEffectiveViewportWidth(); const viewportHeight = canvasEffectiveViewportHeight(); - const visibleLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left); + const visibleLeft = Math.max(rowNumberWidth.value, rect.left); const visibleRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width; const canQuickDownload = canQuickDownloadCellValue(target.rowIndex, target.col); const minWidth = canQuickDownload ? 46 : 24; @@ -5010,7 +5088,7 @@ const canvasDetailButtonStyle = computed(() => { const actionWidth = cell.canQuickDownload ? 44 : 22; const edgeGap = 6; return { - left: `${Math.max(DATA_GRID_ROW_NUM_WIDTH, cell.rect.left + cell.rect.width - actionWidth - edgeGap)}px`, + left: `${Math.max(rowNumberWidth.value, cell.rect.left + cell.rect.width - actionWidth - edgeGap)}px`, top: `${cell.rect.top + cell.rect.height / 2}px`, }; }); @@ -5045,7 +5123,7 @@ function drawCanvasGrid() { columnPreviewOffsets: columnHeaderPreviewOffsets.value, columnPreviewSourceVisibleIndex: columnHeaderPreviewSourceVisibleIndex.value, visibleColumnIndexes: visibleColumnIndexes.value, - rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH, + rowNumberWidth: rowNumberWidth.value, hoverCell: canvasHoverCell.value, isScrolling: isScrolling.value, editingCell: editingCell.value, @@ -5058,8 +5136,7 @@ function drawCanvasGrid() { cellIsSelected, cellCanHover: canEditCellItem, infiniteScrollEnabled: infiniteScrollEnabled.value, - pageSize: pageSize.value, - currentPage: currentPage.value, + pageOffset: rowNumberPageOffset(), frozenColumnCount: frozenColumnCount.value, columnAligns: columnAligns.value, rightAlignedActionCell: canvasRightAlignedActionCell.value, @@ -5924,11 +6001,11 @@ function scrollGridColumnIntoView(visibleColIdx: number) { const colLeft = columnContentOffsetLeft(visibleColIdx); const colRight = colLeft + (renderedColumnWidths.value[visibleColIdx] ?? 0); const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0; - const viewportLeft = scroller.scrollLeft + DATA_GRID_ROW_NUM_WIDTH + frozenWidth; + const viewportLeft = scroller.scrollLeft + rowNumberWidth.value + frozenWidth; const viewportRight = scroller.scrollLeft + scroller.clientWidth; if (colLeft < viewportLeft) { - scroller.scrollLeft = Math.max(0, colLeft - DATA_GRID_ROW_NUM_WIDTH - frozenWidth); + scroller.scrollLeft = Math.max(0, colLeft - rowNumberWidth.value - frozenWidth); } else if (colRight > viewportRight) { scroller.scrollLeft = Math.max(0, colRight - scroller.clientWidth); } @@ -7470,45 +7547,40 @@ function stopLoadingElapsedTimer() { function startLoadingElapsedTimer() { stopLoadingElapsedTimer(); - if (!dataGridIsActive || !props.loading) return; + if (!dataGridIsActive || !gridSurfaceBusy.value) return; _loadingStart = Date.now(); loadingElapsed.value = 0; const updateOnNextFrame = () => { - if (!dataGridIsActive || !props.loading) return; + if (!dataGridIsActive || !gridSurfaceBusy.value) return; loadingElapsed.value = Date.now() - _loadingStart; _loadingFrame = window.requestAnimationFrame(updateOnNextFrame); }; _loadingFrame = window.requestAnimationFrame(updateOnNextFrame); } -watch( - () => props.loading, - (isLoading) => { - stopLoadingElapsedTimer(); - if (isDebugLoggingEnabled()) { - logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", { - traceId: dataGridTraceId, - cacheKey: props.cacheKey, - elapsedSinceSetup: dataGridElapsed(), - }); - } - if (isLoading) { - startLoadingElapsedTimer(); - } else { - if (isDebugLoggingEnabled()) { - nextTick(() => { - requestAnimationFrame(() => { - logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", { - traceId: dataGridTraceId, - cacheKey: props.cacheKey, - elapsedSinceSetup: dataGridElapsed(), - }); - }); +watch(gridSurfaceBusy, (isLoading) => { + stopLoadingElapsedTimer(); + if (isDebugLoggingEnabled()) { + logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", { + traceId: dataGridTraceId, + cacheKey: props.cacheKey, + elapsedSinceSetup: dataGridElapsed(), + }); + } + if (isLoading) { + startLoadingElapsedTimer(); + } else if (isDebugLoggingEnabled()) { + nextTick(() => { + requestAnimationFrame(() => { + logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", { + traceId: dataGridTraceId, + cacheKey: props.cacheKey, + elapsedSinceSetup: dataGridElapsed(), }); - } - } - }, -); + }); + }); + } +}); onActivated(() => { startLoadingElapsedTimer(); @@ -8949,7 +9021,7 @@ const gridContextMenuItems = computed(() => { -
+
{{ t("grid.loadingMore") }}
@@ -8959,7 +9031,7 @@ const gridContextMenuItems = computed(() => {
-
+
{{ formatElapsedSeconds(loadingElapsed) }}s @@ -9349,11 +9421,11 @@ const gridContextMenuItems = computed(() => {
{{ t(showTruncationWarning ? "grid.loadedRows" : "grid.totalRows", { count: result.rows.length }) }} - {{ t(totalRowCountIsExact === false ? "grid.totalRowCountAtLeast" : "grid.totalRowCount", { count: displayedTotalRowCount }) }} - + {{ t(totalRowCountLabelKey, { count: displayedTotalRowCount }) }} + {{ t("grid.totalRowCountLoading") }} - @@ -9385,7 +9457,7 @@ const gridContextMenuItems = computed(() => { :pagination-enabled="paginationEnabled" :selection-summary="selectionSummary" :selection-summary-sum-text="selectionSummarySumText" - :loading="loading" + :loading="gridSurfaceBusy" :infinite-scroll-enabled="infiniteScrollEnabled" :infinite-scroll-all-loaded="infiniteScrollAllLoaded" :page-size="pageSize" diff --git a/apps/desktop/src/components/grid/DataGridPagination.vue b/apps/desktop/src/components/grid/DataGridPagination.vue index 6d906df6d..9f7bab43e 100644 --- a/apps/desktop/src/components/grid/DataGridPagination.vue +++ b/apps/desktop/src/components/grid/DataGridPagination.vue @@ -86,11 +86,11 @@ const emit = defineEmits<{
- - + + {{ currentPage }} - - + +
diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index 8c7eecfdf..dc7a38335 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -212,6 +212,10 @@ describe("DataGridPagination", () => { expect(previousPage).toHaveBeenCalledOnce(); expect(nextPage).toHaveBeenCalledOnce(); expect(lastPage).toHaveBeenCalledOnce(); + + await mounted.setProps({ loading: true }); + const busyNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0"); + expect(busyNavigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]); }); it("hides pagination controls when the data source does not support paging", () => { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridTotalRowCount.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridTotalRowCount.spec.ts index a0cdb42b3..49c6eecf2 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridTotalRowCount.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridTotalRowCount.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { dataGridTotalRowCountLabelKey } from "@/lib/dataGrid/dataGridPagination"; import DataGrid from "../DataGrid.vue"; type VuePropDefinition = { default?: unknown }; @@ -8,5 +9,12 @@ describe("DataGrid total row count exactness", () => { it("treats totals as exact unless a caller explicitly marks them as a lower bound", () => { const component = DataGrid as unknown as VueComponentWithProps; expect(component.props?.totalRowCountIsExact?.default).toBe(true); + expect(component.props?.inexactTotalRowCountMode?.default).toBe("at-least"); + }); + + it("keeps lower-bound and estimated total labels distinct", () => { + expect(dataGridTotalRowCountLabelKey(true, "estimated")).toBe("grid.totalRowCount"); + expect(dataGridTotalRowCountLabelKey(false, "at-least")).toBe("grid.totalRowCountAtLeast"); + expect(dataGridTotalRowCountLabelKey(false, "estimated")).toBe("grid.totalRowCountEstimated"); }); }); diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts index eec460965..146ee8b5a 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts @@ -4,7 +4,7 @@ import { computed, nextTick, ref } from "vue"; import { beforeEach, describe, expect, it } from "vitest"; import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth"; import { clearDataGridColumnWidthStates, createDataGridColumnMeasurementSignature, createDataGridColumnStructureSignature, DATA_GRID_COLUMN_WIDTH_STATE_LIMIT, dataGridColumnWidthStateCount, loadDataGridColumnWidthState, saveDataGridColumnWidthState } from "@/lib/dataGrid/dataGridColumnWidthState"; -import { DATA_GRID_ROW_NUM_WIDTH, resizeDataGridColumnWidth, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; +import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resizeDataGridColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; function createResizeState(options: { columns: string[]; rows: Array>; columnIndexes?: number[]; columnTypes?: string[]; cacheKey?: string; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean; headerTextWidth?: number }) { const compact = ref(options.compactColumnHeaderActions ?? true); @@ -301,3 +301,19 @@ describe("useDataGridColumnResize", () => { expect(comf.columnWidths.value[0]).toBeGreaterThanOrEqual(std.columnWidths.value[0]); }); }); + +describe("dataGridRowNumberColumnWidth", () => { + it("keeps the default width for small page indexes", () => { + expect(dataGridRowNumberColumnWidth(999)).toBe(DATA_GRID_ROW_NUM_WIDTH); + expect(dataGridRowNumberColumnWidth(9999)).toBe(DATA_GRID_ROW_NUM_WIDTH); + }); + + it("widens the gutter for multi-million row numbers", () => { + expect(dataGridRowNumberColumnWidth(4_215_101)).toBeGreaterThan(DATA_GRID_ROW_NUM_WIDTH); + expect(dataGridRowNumberColumnWidth(4_215_101)).toBe(dataGridRowNumberColumnWidth(9_999_999)); + }); + + it("prefers measured text width when provided", () => { + expect(dataGridRowNumberColumnWidth(99, 12, () => 40)).toBe(56); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridColumnLayout.ts b/apps/desktop/src/composables/useDataGridColumnLayout.ts index e48cb3ee3..7aa7e7df0 100644 --- a/apps/desktop/src/composables/useDataGridColumnLayout.ts +++ b/apps/desktop/src/composables/useDataGridColumnLayout.ts @@ -405,7 +405,7 @@ export function useDataGridColumnLayout(options: { renderedColumnWidths: MaybeRefOrGetter; scrollLeft: MaybeRefOrGetter; viewportWidth: MaybeRefOrGetter; - rowNumberWidth: number; + rowNumberWidth: MaybeRefOrGetter; bufferPx?: number; headerRef?: MaybeRefOrGetter; orderedColumnIndexes?: MaybeRefOrGetter; @@ -427,7 +427,7 @@ export function useDataGridColumnLayout(options: { columnCount: toValue(options.visibleColumnIndexes).length, scrollLeft: toValue(options.scrollLeft), viewportWidth: toValue(options.viewportWidth), - rowNumberWidth: options.rowNumberWidth, + rowNumberWidth: toValue(options.rowNumberWidth), bufferPx: options.bufferPx ?? 900, }), ); @@ -478,7 +478,7 @@ export function useDataGridColumnLayout(options: { } function columnContentOffsetLeft(visibleColIdx: number): number { - return options.rowNumberWidth + (renderedColumnOffsets.value[visibleColIdx] ?? 0); + return toValue(options.rowNumberWidth) + (renderedColumnOffsets.value[visibleColIdx] ?? 0); } const columnHeaderDragState = ref(null); diff --git a/apps/desktop/src/composables/useDataGridColumnResize.ts b/apps/desktop/src/composables/useDataGridColumnResize.ts index 219ffc6d4..df161c1fd 100644 --- a/apps/desktop/src/composables/useDataGridColumnResize.ts +++ b/apps/desktop/src/composables/useDataGridColumnResize.ts @@ -5,8 +5,27 @@ import type { ColumnWidthDensity } from "@/stores/settingsStore"; type CellValue = string | number | boolean | null; +/** Minimum row-number gutter; fits ~4 digits with px-2 padding. */ export const DATA_GRID_ROW_NUM_WIDTH = 48; +/** Largest absolute row number that may appear in the gutter for the current page/window. */ +export function resolveDataGridMaxRowNumber(options: { infiniteScroll: boolean; allRowsLoaded: boolean; currentPage: number; pageSize: number; rowCount: number }): number { + if (options.infiniteScroll || options.allRowsLoaded) { + return Math.max(1, options.rowCount); + } + const pageSize = Math.max(1, options.pageSize); + return Math.max(1, Math.max(0, options.currentPage - 1) * pageSize + Math.max(options.rowCount, 1)); +} + +/** Grow the sticky # column so multi-million row indexes are not clipped or spilled into data cells. */ +export function dataGridRowNumberColumnWidth(maxRowNumber: number, fontSize = 12, measureTextWidth?: (text: string) => number | undefined): number { + const text = String(Math.max(1, Math.floor(Math.max(0, maxRowNumber)))); + const measured = measureTextWidth?.(text); + const contentWidth = typeof measured === "number" && Number.isFinite(measured) && measured > 0 ? measured : text.length * Math.max(8, Math.ceil(fontSize * 0.65)); + // Keep ~4 digits in the default 48px gutter; measured text gets the same 16px padding as px-2. + return Math.max(DATA_GRID_ROW_NUM_WIDTH, Math.ceil(contentWidth) + 16); +} + export function resizeDataGridColumnWidth(startWidth: number, deltaX: number): number { return Math.max(DATA_GRID_COL_MIN_WIDTH, startWidth + deltaX); } @@ -21,6 +40,7 @@ export interface UseDataGridColumnResizeOptions { columnStructureSignature: ComputedRef; measureHeaderText?: (text: string) => number | undefined; headerMeasurementKey?: Ref; + rowNumberWidth?: Ref | ComputedRef; } export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) { @@ -143,14 +163,16 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) const renderedColumnWidths = computed(() => columnWidths.value.slice()); - const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + DATA_GRID_ROW_NUM_WIDTH); + const resolvedRowNumberWidth = computed(() => options.rowNumberWidth?.value ?? DATA_GRID_ROW_NUM_WIDTH); + + const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + resolvedRowNumberWidth.value); const columnVars = computed(() => { const vars: Record = {}; renderedColumnWidths.value.forEach((w, i) => { vars[`--col-w-${i}`] = `${w}px`; }); - vars["--row-num-w"] = `${DATA_GRID_ROW_NUM_WIDTH}px`; + vars["--row-num-w"] = `${resolvedRowNumberWidth.value}px`; vars["--total-w"] = `${totalWidth.value}px`; return vars; }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index c56992a2e..e1092590a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1018,6 +1018,7 @@ export default { loadedRows: "Loaded {count} rows", totalRowCount: "({count} total)", totalRowCountAtLeast: "(at least {count} total)", + totalRowCountEstimated: "(~{count})", totalRowCountLoading: "(counting...)", loadingMore: "Loading more data...", allLoaded: "all loaded", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c2de43960..a3fde70f8 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -966,6 +966,7 @@ export default withEnglishFallback({ loadedRows: "{count} filas cargadas", totalRowCount: "({count} en total)", totalRowCountAtLeast: "(al menos {count} en total)", + totalRowCountEstimated: "(aprox. {count})", totalRowCountLoading: "(contando...)", loadingMore: "Cargando más datos...", allLoaded: "todo cargado", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index dac3a288d..38e577759 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -964,6 +964,7 @@ export default withEnglishFallback({ loadedRows: "{count} righe caricate", totalRowCount: "({count} in totale)", totalRowCountAtLeast: "(almeno {count} in totale)", + totalRowCountEstimated: "(circa {count})", totalRowCountLoading: "(conteggio...)", loadingMore: "Caricamento altri dati...", allLoaded: "tutto caricato", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 81e08eb83..992ccc902 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -965,6 +965,7 @@ export default withEnglishFallback({ loadedRows: "{count}件読み込み済み", totalRowCount: "(全{count}件)", totalRowCountAtLeast: "(少なくとも{count}件)", + totalRowCountEstimated: "(約 {count}件)", totalRowCountLoading: "(カウント中...)", calculateTotalRows: "総行数をカウント", calculateTotalRowsInline: "(総行数をカウント)", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 6e9c90451..53407c792 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1006,6 +1006,7 @@ export default withEnglishFallback({ loadedRows: "{count}행 로드됨", totalRowCount: "(전체 {count})", totalRowCountAtLeast: "(최소 {count})", + totalRowCountEstimated: "(약 {count})", totalRowCountLoading: "(집계 중...)", loadingMore: "데이터를 더 불러오는 중...", allLoaded: "모두 로드됨", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1b2aedeab..983c23d23 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -966,6 +966,7 @@ export default withEnglishFallback({ loadedRows: "{count} linhas carregadas", totalRowCount: "({count} no total)", totalRowCountAtLeast: "(pelo menos {count} no total)", + totalRowCountEstimated: "(aprox. {count})", totalRowCountLoading: "(contando...)", loadingMore: "Carregando mais dados...", allLoaded: "tudo carregado", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a60ec4a29..027ccac21 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1019,6 +1019,7 @@ export default withEnglishFallback({ loadedRows: "已加载 {count} 行", totalRowCount: "(总计 {count} 行)", totalRowCountAtLeast: "(至少 {count} 行)", + totalRowCountEstimated: "(约 {count} 行)", totalRowCountLoading: "(统计中...)", loadingMore: "加载更多数据...", allLoaded: "已全部加载", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index dd930d02b..4aec5bff9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -965,6 +965,7 @@ export default withEnglishFallback({ loadedRows: "已載入 {count} 筆", totalRowCount: "(總計 {count} 筆)", totalRowCountAtLeast: "(至少 {count} 筆)", + totalRowCountEstimated: "(約 {count} 筆)", totalRowCountLoading: "(統計中...)", loadingMore: "載入更多資料...", allLoaded: "已全部載入", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts index 7c490d6b3..57416b60b 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts @@ -63,8 +63,7 @@ function createBaseOptions(overrides: Partial = {}): cellIsSelected: () => false, cellCanHover: () => true, infiniteScrollEnabled: false, - pageSize: 100, - currentPage: 1, + pageOffset: 0, ...overrides, }; } diff --git a/apps/desktop/src/lib/__tests__/document/documentQueryTotals.spec.ts b/apps/desktop/src/lib/__tests__/document/documentQueryTotals.spec.ts index 834c663de..9d036b9f5 100644 --- a/apps/desktop/src/lib/__tests__/document/documentQueryTotals.spec.ts +++ b/apps/desktop/src/lib/__tests__/document/documentQueryTotals.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { canGoNextDocumentPage, resolveDocumentQueryTotals } from "@/lib/document/documentQueryTotals"; +import { canGoNextDocumentPage, exactTotalFromIncompleteDocumentPage, isSameDocumentQueryTotalCountRequest, resolveDocumentQueryTotals, type DocumentQueryTotalCountRequest } from "@/lib/document/documentQueryTotals"; describe("document query totals", () => { it("uses exact totals as the pagination bound", () => { @@ -21,4 +21,49 @@ describe("document query totals", () => { expect(canGoNextDocumentPage({ page: 999_999, pageSize: 10, rowCount: 10 })).toBe(true); expect(canGoNextDocumentPage({ page: 1_000_000, pageSize: 10, rowCount: 3 })).toBe(false); }); + + it("treats a short page as an exact total even when the backend estimate is inexact", () => { + expect(exactTotalFromIncompleteDocumentPage({ page: 0, pageSize: 500, rowCount: 0 })).toBe(0); + expect(exactTotalFromIncompleteDocumentPage({ page: 0, pageSize: 500, rowCount: 1 })).toBe(1); + expect(resolveDocumentQueryTotals(1, false, { page: 0, pageSize: 500, rowCount: 1 })).toEqual({ + total: 1, + totalIsExact: true, + paginationTotal: 1, + }); + expect(resolveDocumentQueryTotals(50, false, { page: 1, pageSize: 500, rowCount: 12 })).toEqual({ + total: 512, + totalIsExact: true, + paginationTotal: 512, + }); + expect(resolveDocumentQueryTotals(658_320, false, { page: 0, pageSize: 500, rowCount: 500 })).toEqual({ + total: 658_320, + totalIsExact: false, + paginationTotal: undefined, + }); + }); + + it("does not infer an exact total from an empty page after the first page", () => { + expect(exactTotalFromIncompleteDocumentPage({ page: 1, pageSize: 500, rowCount: 0 })).toBeUndefined(); + expect(resolveDocumentQueryTotals(658_320, false, { page: 1, pageSize: 500, rowCount: 0 })).toEqual({ + total: 658_320, + totalIsExact: false, + paginationTotal: undefined, + }); + }); + + it("matches exact-count requests across identity, filter, and generation", () => { + const request: DocumentQueryTotalCountRequest = { + connectionId: "connection-a", + database: "database-a", + collection: "collection-a", + filter: '{"active":true}', + generation: 3, + }; + expect(isSameDocumentQueryTotalCountRequest(request, { ...request })).toBe(true); + expect(isSameDocumentQueryTotalCountRequest(request, { ...request, connectionId: "connection-b" })).toBe(false); + expect(isSameDocumentQueryTotalCountRequest(request, { ...request, database: "database-b" })).toBe(false); + expect(isSameDocumentQueryTotalCountRequest(request, { ...request, collection: "collection-b" })).toBe(false); + expect(isSameDocumentQueryTotalCountRequest(request, { ...request, filter: undefined })).toBe(false); + expect(isSameDocumentQueryTotalCountRequest(request, { ...request, generation: 4 })).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts index d43cd9345..ee5e3a812 100644 --- a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts +++ b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts @@ -70,8 +70,7 @@ export interface DrawCanvasDataGridOptions { cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean; cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean; infiniteScrollEnabled: boolean; - pageSize: number; - currentPage: number; + pageOffset: number; frozenColumnCount?: number; columnAligns?: readonly ("left" | "right")[]; rightAlignedActionCell?: CanvasRightAlignedActionCell | null; @@ -267,8 +266,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { cellIsSelected, cellCanHover, infiniteScrollEnabled, - pageSize, - currentPage, + pageOffset, frozenColumnCount = 0, columnAligns, rightAlignedActionCell, @@ -362,13 +360,18 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont; ctx.textAlign = "center"; const textY = alignCanvasPixel(y + rowTextOffsetY, dpr); + ctx.save(); + ctx.beginPath(); + ctx.rect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT); + ctx.clip(); if (item.isDraft) { ctx.fillText("*", rowNumberTextX, textY); } else if (infiniteScrollEnabled) { ctx.fillText(String(item.displayIndex + 1), rowNumberTextX, textY); } else { - ctx.fillText(String(item.displayIndex + 1 + pageSize * (currentPage - 1)), rowNumberTextX, textY); + ctx.fillText(String(item.displayIndex + 1 + pageOffset), rowNumberTextX, textY); } + ctx.restore(); ctx.font = normalFont; ctx.strokeStyle = theme.border; diff --git a/apps/desktop/src/lib/dataGrid/dataGridPagination.ts b/apps/desktop/src/lib/dataGrid/dataGridPagination.ts index 099be5ecd..6337e3165 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridPagination.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridPagination.ts @@ -29,6 +29,13 @@ export interface CanFetchNextDataGridSegmentOptions { allRowsLoaded?: boolean; } +export type DataGridInexactTotalRowCountMode = "at-least" | "estimated"; + +export function dataGridTotalRowCountLabelKey(totalRowCountIsExact: boolean, inexactMode: DataGridInexactTotalRowCountMode): "grid.totalRowCount" | "grid.totalRowCountAtLeast" | "grid.totalRowCountEstimated" { + if (totalRowCountIsExact) return "grid.totalRowCount"; + return inexactMode === "estimated" ? "grid.totalRowCountEstimated" : "grid.totalRowCountAtLeast"; +} + export function resolveDataGridPaginationTotal(options: { paginationTotalRowCount?: number; serverKnownTotalRowCount?: number; totalRowCountIsExact: boolean }): number | undefined { if (options.paginationTotalRowCount !== undefined) return options.paginationTotalRowCount; return options.totalRowCountIsExact ? options.serverKnownTotalRowCount : undefined; diff --git a/apps/desktop/src/lib/document/documentQueryTotals.ts b/apps/desktop/src/lib/document/documentQueryTotals.ts index 214e4a266..9ea0dce99 100644 --- a/apps/desktop/src/lib/document/documentQueryTotals.ts +++ b/apps/desktop/src/lib/document/documentQueryTotals.ts @@ -4,7 +4,46 @@ export interface DocumentQueryTotals { paginationTotal: number | undefined; } -export function resolveDocumentQueryTotals(total: number, totalIsExact: boolean): DocumentQueryTotals { +export interface DocumentQueryTotalsPageOptions { + page?: number; + pageSize?: number; + rowCount?: number; +} + +export interface DocumentQueryTotalCountRequest { + connectionId: string; + database: string; + collection: string; + filter: string | undefined; + generation: number; +} + +export function isSameDocumentQueryTotalCountRequest(left: DocumentQueryTotalCountRequest, right: DocumentQueryTotalCountRequest): boolean { + return left.connectionId === right.connectionId && left.database === right.database && left.collection === right.collection && left.filter === right.filter && left.generation === right.generation; +} + +/** When the current page is short, the true total is exactly offset + rowCount. */ +export function exactTotalFromIncompleteDocumentPage(options: DocumentQueryTotalsPageOptions): number | undefined { + const page = options.page ?? 0; + const pageSize = options.pageSize; + const rowCount = options.rowCount; + if (!Number.isInteger(page) || page < 0 || typeof pageSize !== "number" || !Number.isInteger(pageSize) || pageSize <= 0 || typeof rowCount !== "number" || !Number.isInteger(rowCount) || rowCount < 0 || rowCount >= pageSize || (page > 0 && rowCount === 0)) { + return undefined; + } + return page * pageSize + rowCount; +} + +export function resolveDocumentQueryTotals(total: number, totalIsExact: boolean, pageOptions?: DocumentQueryTotalsPageOptions): DocumentQueryTotals { + if (!totalIsExact) { + const exactFromPage = exactTotalFromIncompleteDocumentPage(pageOptions ?? {}); + if (typeof exactFromPage === "number") { + return { + total: exactFromPage, + totalIsExact: true, + paginationTotal: exactFromPage, + }; + } + } return { total, totalIsExact, diff --git a/packages/app-tests/dataGridPagination.test.ts b/packages/app-tests/dataGridPagination.test.ts index 04feefbbf..a66b611cb 100644 --- a/packages/app-tests/dataGridPagination.test.ts +++ b/packages/app-tests/dataGridPagination.test.ts @@ -1,4 +1,5 @@ import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; import { test } from "vitest"; import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts"; @@ -162,3 +163,40 @@ test("auto-redirect: total is undefined — guard prevents redirect attempt", () const total = undefined; assert.equal(!total || (total as any) <= 0, true, "guard should prevent redirect when total is unknown"); }); + +test("last-page COUNT shows grid busy overlay before executeQuery", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + assert.match(source, /const gridSurfaceBusy = computed\(\(\) => props\.loading === true \|\| totalRowCountBusy\.value\)/); + assert.match(source, /v-if="gridSurfaceBusy"/); + assert.match(source, /async function beginManualTotalRowCount/); + assert.match(source, /await nextTick\(\);/); + const lastPageFn = source.match(/async function lastPage\(\) \{[\s\S]*?\n\}/)?.[0] ?? ""; + assert.match(lastPageFn, /beginManualTotalRowCount\(\)/); + assert.match(lastPageFn, /buildCurrentCountTarget\(\)/); + assert.ok(lastPageFn.indexOf("beginManualTotalRowCount") < lastPageFn.indexOf("buildCurrentCountTarget"), "busy UI must start before COUNT SQL is built"); +}); + +test("last page always re-counts when a count path is available", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + const lastPageFn = source.match(/async function lastPage\(\) \{[\s\S]*?\n\}/)?.[0] ?? ""; + const knownTotalIdx = lastPageFn.indexOf("hasKnownPaginationTotalRowCount"); + const countCallbackIdx = lastPageFn.indexOf("props.countTotalRows"); + const countSqlIdx = lastPageFn.indexOf("buildCurrentCountTarget"); + assert.ok(countCallbackIdx >= 0 && countSqlIdx >= 0, "last page must keep count paths"); + assert.ok(knownTotalIdx < 0 || knownTotalIdx > countSqlIdx, "known totals are only a fallback after re-COUNT"); +}); + +test("jumping to last page does not rewrite indexes before the new page loads", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + const jumpFn = source.match(/function jumpToCountedLastPage\(total: number\) \{[\s\S]*?\n\}/)?.[0] ?? ""; + assert.match(jumpFn, /emit\("paginate"/); + assert.doesNotMatch(jumpFn, /currentPage\.value\s*=/); + assert.match(source, /function rowNumberPageOffset/); +}); + +test("row number gutter width tracks the largest visible row index", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + assert.match(source, /dataGridRowNumberColumnWidth/); + assert.match(source, /resolveDataGridMaxRowNumber/); + assert.match(source, /rowNumberWidth,/); +}); diff --git a/packages/app-tests/documentBrowser.test.ts b/packages/app-tests/documentBrowser.test.ts index 79f092c9d..4a492012a 100644 --- a/packages/app-tests/documentBrowser.test.ts +++ b/packages/app-tests/documentBrowser.test.ts @@ -63,6 +63,25 @@ test("document save uses shared identity plan and write helpers", () => { assert.doesNotMatch(source, /async function replaceDocumentStoreDocument/); }); +test("document table wires on-demand exact total counting for estimated mongo totals", () => { + const source = documentBrowserSource(); + assert.match(source, /:count-total-rows="countExactDocumentTotal"/); + assert.match(source, /:inexact-total-row-count-mode="documentStoreProvider\.kind === 'mongodb' \? 'estimated' : 'at-least'"/); + assert.match(source, /async function countExactDocumentTotal/); + assert.match(source, /const request = loadedDocumentQueryTotalCountRequest/); + assert.match(source, /if \(!request \|\| !isCurrentDocumentQueryTotalCountRequest\(request\)\) return undefined;/); + assert.match(source, /api\.mongoCountDocuments\(request\.connectionId, request\.database, request\.collection, request\.filter, "accurate"\)/); + assert.equal(source.match(/if \(!isCurrentDocumentQueryTotalCountRequest\(request\)\) return undefined;/g)?.length, 2); + assert.match(source, /"accurate"/); +}); + +test("document pagination commits page index with fetched rows", () => { + const source = documentBrowserSource(); + assert.match(source, /async function load\(options: \{ page\?: number \} = \{\}\)/); + assert.match(source, /void load\(\{ page: nextPage \}\)/); + assert.match(source, /if \(options\.page !== undefined\) page\.value = options\.page;/); +}); + test("document query inputs apply on Enter and reserve Shift+Enter for newlines", () => { const source = documentBrowserSource(); assert.equal(source.match(/@keydown\.enter\.exact\.prevent="applyFilter"/g)?.length, 2);