fix(mongo): stabilize estimated-total pagination

This commit is contained in:
Freedom 2026-07-31 14:51:25 +08:00 committed by GitHub
parent 96038d2b8a
commit 8f3fd0ebfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 462 additions and 124 deletions

View File

@ -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<string>();
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<number | undefined> {
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)"

View File

@ -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<number | undefined>;
loading?: boolean;
cacheKey?: string;
exportSql?: string;
@ -322,6 +326,7 @@ const props = withDefaults(defineProps<DataGridProps>(), {
// 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<boolean> {
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<HTMLElement>("[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<ContextMenuItem[]>(() => {
</template>
</RecycleScroller>
<!-- Infinite scroll loading indicator for RecycleScroller -->
<div v-if="infiniteScrollEnabled && infiniteScrollLoading && !loading" class="flex items-center justify-center py-2 text-xs text-muted-foreground">
<div v-if="infiniteScrollEnabled && infiniteScrollLoading && !gridSurfaceBusy" class="flex items-center justify-center py-2 text-xs text-muted-foreground">
<Loader2 class="w-3 h-3 animate-spin mr-1" />
{{ t("grid.loadingMore") }}
</div>
@ -8959,7 +9031,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div v-if="hasGridVerticalOverflow" ref="gridVerticalScrollbarTrackRef" class="data-grid-vertical-scrollbar" @pointerdown="startGridVerticalScrollbarDrag">
<div ref="gridVerticalScrollbarThumbRef" class="data-grid-vertical-scrollbar__thumb" />
</div>
<div v-if="loading" class="absolute inset-0 z-20 bg-background/50 flex items-center justify-center">
<div v-if="gridSurfaceBusy" class="absolute inset-0 z-20 bg-background/50 flex items-center justify-center">
<div class="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>{{ formatElapsedSeconds(loadingElapsed) }}s</span>
@ -9349,11 +9421,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<span v-if="hasData" class="shrink-0">
{{ t(showTruncationWarning ? "grid.loadedRows" : "grid.totalRows", { count: result.rows.length }) }}
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountIsExact === false ? "grid.totalRowCountAtLeast" : "grid.totalRowCount", { count: displayedTotalRowCount }) }}</span>
<span v-else-if="totalRowCountBusy" class="text-muted-foreground/70">
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountLabelKey, { count: displayedTotalRowCount }) }}</span>
<span v-if="totalRowCountBusy" class="text-muted-foreground/70">
{{ t("grid.totalRowCountLoading") }}
</span>
<button v-else-if="canCalculateTotalRowCount" type="button" class="text-muted-foreground/70 hover:text-foreground hover:underline underline-offset-2 disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
<button v-else-if="showExactTotalCountAction" type="button" class="text-muted-foreground/70 hover:text-foreground hover:underline underline-offset-2 disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
{{ t("grid.calculateTotalRowsInline") }}
</button>
</span>
@ -9385,7 +9457,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
: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"

View File

@ -86,11 +86,11 @@ const emit = defineEmits<{
</Tooltip>
</div>
</LightDropdown>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
<span class="shrink-0 tabular-nums">{{ currentPage }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="!canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="!canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
</template>
<DataGridExportMenu :items="exportMenuItems" :label="t('grid.export')" :on-select="(value) => emit('selectExport', value)" />
</div>

View File

@ -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", () => {

View File

@ -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");
});
});

View File

@ -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<Array<string | number | boolean | null>>; 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);
});
});

View File

@ -405,7 +405,7 @@ export function useDataGridColumnLayout(options: {
renderedColumnWidths: MaybeRefOrGetter<readonly number[]>;
scrollLeft: MaybeRefOrGetter<number>;
viewportWidth: MaybeRefOrGetter<number>;
rowNumberWidth: number;
rowNumberWidth: MaybeRefOrGetter<number>;
bufferPx?: number;
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
@ -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<ColumnHeaderDragState | null>(null);

View File

@ -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<string>;
measureHeaderText?: (text: string) => number | undefined;
headerMeasurementKey?: Ref<unknown>;
rowNumberWidth?: Ref<number> | ComputedRef<number>;
}
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<string, string> = {};
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;
});

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -965,6 +965,7 @@ export default withEnglishFallback({
loadedRows: "{count}件読み込み済み",
totalRowCount: "(全{count}件)",
totalRowCountAtLeast: "(少なくとも{count}件)",
totalRowCountEstimated: "(約 {count}件)",
totalRowCountLoading: "(カウント中...",
calculateTotalRows: "総行数をカウント",
calculateTotalRowsInline: "(総行数をカウント)",

View File

@ -1006,6 +1006,7 @@ export default withEnglishFallback({
loadedRows: "{count}행 로드됨",
totalRowCount: "(전체 {count})",
totalRowCountAtLeast: "(최소 {count})",
totalRowCountEstimated: "(약 {count})",
totalRowCountLoading: "(집계 중...)",
loadingMore: "데이터를 더 불러오는 중...",
allLoaded: "모두 로드됨",

View File

@ -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",

View File

@ -1019,6 +1019,7 @@ export default withEnglishFallback({
loadedRows: "已加载 {count} 行",
totalRowCount: "(总计 {count} 行)",
totalRowCountAtLeast: "(至少 {count} 行)",
totalRowCountEstimated: "(约 {count} 行)",
totalRowCountLoading: "(统计中...",
loadingMore: "加载更多数据...",
allLoaded: "已全部加载",

View File

@ -965,6 +965,7 @@ export default withEnglishFallback({
loadedRows: "已載入 {count} 筆",
totalRowCount: "(總計 {count} 筆)",
totalRowCountAtLeast: "(至少 {count} 筆)",
totalRowCountEstimated: "(約 {count} 筆)",
totalRowCountLoading: "(統計中...",
loadingMore: "載入更多資料...",
allLoaded: "已全部載入",

View File

@ -63,8 +63,7 @@ function createBaseOptions(overrides: Partial<DrawCanvasDataGridOptions> = {}):
cellIsSelected: () => false,
cellCanHover: () => true,
infiniteScrollEnabled: false,
pageSize: 100,
currentPage: 1,
pageOffset: 0,
...overrides,
};
}

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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,/);
});

View File

@ -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);