diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 9f67027704..64afd838d2 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -4336,7 +4336,7 @@ onUnmounted(() => { @format-error="toast(t('toolbar.formatSqlFailed'))" @save-sql="(tabId: string) => void openSaveSqlDialog(tabId)" @reload="(tabId: string, sql: any, searchText: any, whereInput: any, orderBy: any, limit: any, offset: any, intent: any) => onReloadData(tabId, sql, searchText, whereInput, orderBy, limit, offset, intent)" - @paginate="(tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string) => onPaginate(tabId, offset, limit, whereInput, orderBy)" + @paginate="(tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => onPaginate(tabId, offset, limit, whereInput, orderBy, appendResult)" @sort="(tabId: string, column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => onSort(tabId, column, columnIndex, direction, whereInput, mode)" @execute-sql="(tabId: string, sql: string) => onExecuteSql(tabId, sql)" @click-table="(_tabId: string, target: SqlObjectNavigationTarget) => onClickTable(target)" diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 574cb647ec..6af08563e6 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -2957,6 +2957,7 @@ defineExpose({ focusSearch }); :total-row-count-is-exact="totalIsExact" :inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'" :pagination-total-row-count="pageTotal" + :load-all-rows-enabled="false" :count-total-rows="countExactDocumentTotal" :full-export-result="documentStoreProvider.kind === 'mongodb' || documentStoreProvider.kind === 'dynamodb' || documentStoreProvider.kind === 'elasticsearch' ? exportAllDocumentStoreDocuments : undefined" @sort="onSort" diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 09f03b6a85..e55d4b9992 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -208,6 +208,7 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid import { canFetchNextDataGridSegment, canGoNextDataGridPage, + dataGridLoadAllSegment, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS, @@ -493,6 +494,7 @@ interface DataGridProps { inexactTotalRowCountMode?: DataGridInexactTotalRowCountMode; paginationTotalRowCount?: number; paginationEnabled?: boolean; + loadAllRowsEnabled?: boolean; totalRowCountLoading?: boolean; pageJumpProgress?: QueryPageJumpProgress; /** Document stores (e.g. MongoDB) count exactly on demand without SQL tableMeta/countSql. */ @@ -556,6 +558,7 @@ const props = withDefaults(defineProps(), { totalRowCountIsExact: true, inexactTotalRowCountMode: "at-least", paginationEnabled: true, + loadAllRowsEnabled: true, // Omitted row-action limits must keep normal table-data editing. allowInsertRows: undefined, allowDeleteRows: undefined, @@ -588,7 +591,7 @@ function logDataGridTiming(message: string, payload?: Record) { const emit = defineEmits<{ reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent]; - paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string]; + paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean]; sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode]; "update:whereInput": [value: string]; "update:orderByInput": [value: string]; @@ -3009,6 +3012,8 @@ let infiniteScrollCheckScheduled = false; let infiniteScrollAllLoaded = false; let infiniteScrollRequestedOffset: number | undefined; let infiniteScrollRequestedLimit: number | undefined; +let infiniteScrollLoadAllPending = false; +const loadAllRowsActive = ref(false); // Tracks whether the current loading cycle was triggered by a refresh/rollback // (as opposed to a normal paginate). Used to decide whether to auto-redirect // when the current page no longer exists after data was deleted. @@ -3046,11 +3051,14 @@ watch( }, { immediate: true }, ); -// Clear infinite-scroll loading when the parent finishes loading new data +// Complete an append only after both the loading state and appended result have +// propagated. Large results can update those props in separate render cycles. watch( - () => props.loading, - (loading, prevLoading) => { - if (prevLoading && !loading && infiniteScrollLoading.value) { + () => [props.loading, props.result.rows.length, props.result.appended_from_row_count] as const, + ([loading]) => { + if (!loading && infiniteScrollLoading.value) { + const shouldSelectLastRow = infiniteScrollLoadAllPending; + infiniteScrollLoadAllPending = false; infiniteScrollLoading.value = false; isInfiniteScrollPaginating.value = false; const requestedOffset = infiniteScrollRequestedOffset; @@ -3062,19 +3070,31 @@ watch( // optimistic page marker so a later scroll can retry the same segment. currentPage.value = Math.max(1, currentPage.value - 1); lastInfiniteScrollPage = Math.max(0, currentPage.value - 1); + loadAllRowsActive.value = false; return; } const appendedRows = props.result.rows.length - requestedOffset; if (props.result.rows.length >= infiniteScrollMaxRows.value || appendedRows < (requestedLimit ?? pageSize.value)) { infiniteScrollAllLoaded = true; } + if (shouldSelectLastRow) selectAndRevealLastLoadedRow(); } }, + { flush: "post" }, ); const manualTotalRowCount = ref(undefined); const manualTotalRowCountLoading = ref(false); const esDeepPageJumpConfirmOpen = ref(false); const pendingEsDeepPageJump = ref<{ targetPage: number; requestCount: number; updateCurrentPage: boolean }>(); +// One "load all" click fetches the whole remaining segment in a single request; +// with the result-row cap disabled that segment is effectively unbounded, so a +// large shot needs an explicit confirmation the way ES deep page jumps do. +const LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD = 100_000; +const loadAllRowsConfirmOpen = ref(false); +const pendingLoadAllRows = ref<{ remaining: number }>(); +watch(loadAllRowsConfirmOpen, (open) => { + if (!open) pendingLoadAllRows.value = undefined; +}); watch(esDeepPageJumpConfirmOpen, (open) => { if (!open) { pendingEsDeepPageJump.value = undefined; @@ -3318,8 +3338,6 @@ watch( (values, previousValues) => { if (!didDataGridInfiniteScrollContextChange(values, previousValues)) return; manualTotalRowCount.value = undefined; - // Reset infinite-scroll allLoaded when query context changes - infiniteScrollAllLoaded = false; }, ); @@ -3402,6 +3420,79 @@ function infiniteScrollNextPage() { // row identities, which would invalidate pending edits while the user scrolls. emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy()); } + +function selectAndRevealLastLoadedRow() { + nextTick(() => { + let rowIndex = displayRowRefs.value.length - 1; + while (rowIndex >= 0 && !("sourceIndex" in displayRowRefs.value[rowIndex])) rowIndex--; + if (rowIndex < 0) return; + selectRow(rowIndex); + if (showTranspose.value) { + transposeRowIndex.value = rowIndex; + nextTick(() => scrollTransposeRecordIntoView(rowIndex, "nearest")); + return; + } + + const rowHeight = useCanvasGridRows.value ? CANVAS_DATA_GRID_ROW_HEIGHT : DOM_DATA_GRID_ROW_HEIGHT; + const expectedRowBottom = (rowIndex + 1) * rowHeight; + let remainingFrames = 12; + const revealWhenReady = () => { + const scroller = gridScrollerElement(); + if (scroller) { + if (useCanvasGridRows.value) scrollCanvasRowIntoView(rowIndex, "end"); + else scrollDomRowIntoView(rowIndex, "end"); + const expectedScrollTop = Math.max(0, expectedRowBottom - scroller.clientHeight); + if (scroller.scrollTop >= expectedScrollTop - 1) return; + } + remainingFrames--; + if (remainingFrames > 0) requestAnimationFrame(revealWhenReady); + }; + requestAnimationFrame(revealWhenReady); + }); +} + +function loadAllRowsAndGoToLast() { + if (!props.loadAllRowsEnabled || gridSurfaceBusy.value || infiniteScrollLoading.value || props.result.rows.length === 0) return; + // search_after cursor paging fetches page by page; a single giant append + // against those cursors is untested, so ES/Easysearch grids keep the + // reveal-only shortcut instead of loading everything. + if (isResultsContext.value && (resolvedDatabaseType.value === "elasticsearch" || resolvedDatabaseType.value === "easysearch")) return; + const segment = dataGridLoadAllSegment(props.result.rows.length, infiniteScrollMaxRows.value, !infiniteScrollAllLoaded && canFetchNextInfiniteScrollSegment.value); + if (!segment) { + loadAllRowsActive.value = true; + infiniteScrollAllLoaded = true; + selectAndRevealLastLoadedRow(); + return; + } + const knownTotal = displayedTotalRowCount.value; + const remaining = typeof knownTotal === "number" && Number.isFinite(knownTotal) && knownTotal >= props.result.rows.length ? knownTotal - props.result.rows.length : segment.limit; + if (remaining > LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD) { + pendingLoadAllRows.value = { remaining }; + loadAllRowsConfirmOpen.value = true; + return; + } + startLoadAllRows(segment); +} + +function startLoadAllRows(segment: { offset: number; limit: number }) { + loadAllRowsActive.value = true; + infiniteScrollLoadAllPending = true; + infiniteScrollLoading.value = true; + isInfiniteScrollPaginating.value = true; + infiniteScrollRequestedOffset = segment.offset; + infiniteScrollRequestedLimit = segment.limit; + currentPage.value++; + emit("paginate", segment.offset, segment.limit, currentWhereInput(), currentOrderBy(), true); +} + +function confirmLoadAllRows() { + const pending = pendingLoadAllRows.value; + if (!pending) return; + pendingLoadAllRows.value = undefined; + loadAllRowsConfirmOpen.value = false; + const segment = dataGridLoadAllSegment(props.result.rows.length, infiniteScrollMaxRows.value, !infiniteScrollAllLoaded && canFetchNextInfiniteScrollSegment.value); + if (segment) startLoadAllRows(segment); +} function checkInfiniteScroll(scroller: HTMLElement) { if (!infiniteScrollEnabled.value || infiniteScrollLoading.value || props.loading) return; if (infiniteScrollAllLoaded) return; @@ -3423,6 +3514,7 @@ function changePageSize(size: number) { currentPage.value = 1; lastInfiniteScrollPage = 0; infiniteScrollAllLoaded = false; + loadAllRowsActive.value = false; infiniteScrollPositions = new WeakMap(); resetGridVerticalScroll(true); emit("paginate", 0, normalizedSize, currentWhereInput(), currentOrderBy()); @@ -4199,13 +4291,15 @@ function resetInfiniteScrollState() { infiniteScrollRequestedLimit = undefined; isInfiniteScrollPaginating.value = false; infiniteScrollLoading.value = false; + infiniteScrollLoadAllPending = false; + loadAllRowsActive.value = false; infiniteScrollPositions = new WeakMap(); resetGridVerticalScroll(true); } function prepareFullReload() { const viewportAnchor = captureViewportAnchorForRefresh(); - if (infiniteScrollEnabled.value) { + if (infiniteScrollEnabled.value || loadAllRowsActive.value) { resetInfiniteScrollState(); } const selection = captureCurrentSelectionForRefresh(); @@ -6114,7 +6208,7 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" | toast(t("grid.largeValueLocalSortUnavailable"), 5000); return; } - if (mode === "database" && infiniteScrollEnabled.value) { + if (mode === "database" && (infiniteScrollEnabled.value || loadAllRowsActive.value)) { resetInfiniteScrollState(); } else { currentPage.value = 1; @@ -9983,8 +10077,21 @@ watch( infiniteScrollLoading.value = false; isInfiniteScrollPaginating.value = false; } + // The append completion above already reset `infiniteScrollLoading`, so the + // post-flush loading watcher cannot observe this append; a "load all" run + // must still reveal its last row from here. + if (infiniteScrollLoadAllPending) { + infiniteScrollLoadAllPending = false; + selectAndRevealLastLoadedRow(); + } return; } + // A non-append result replaces the whole data set, so a running "load all" is over. + loadAllRowsActive.value = false; + // The replacement also invalidates the all-loaded marker: a filter change or + // page jump swaps in a fresh first page whose remaining segments must be + // re-derived instead of being silently skipped. + infiniteScrollAllLoaded = false; if (getResetScrollAfterResult()) { clearResetScrollAfterResult(); resetGridVerticalScroll(); @@ -13872,9 +13979,12 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val :selection-summary="selectionSummary" :selection-summary-sum-text="selectionSummarySumText" :selection-summary-average-text="selectionSummaryAverageText" - :loading="gridPaginationBusy" + :loading="gridPaginationBusy || infiniteScrollLoading" :infinite-scroll-enabled="infiniteScrollEnabled" :infinite-scroll-all-loaded="infiniteScrollAllLoaded" + :load-all-rows-active="loadAllRowsActive" + :load-all-rows-enabled="loadAllRowsEnabled" + :can-load-all-rows="result.rows.length > 0" :page-size="pageSize" :default-page-size="defaultPageSize" :page-size-menu-items="pageSizeMenuItems" @@ -13891,6 +14001,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @next-page="nextPage" @jump-page="jumpPage" @last-page="lastPage" + @load-all-rows="loadAllRowsAndGoToLast" @select-export="selectExportMenuItem" /> @@ -13999,6 +14110,24 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val + + + + + + {{ t("grid.loadAllRowsConfirmTitle") }} + + +

+ {{ t("grid.loadAllRowsConfirmMessage", { count: pendingLoadAllRows?.remaining ?? 0 }) }} +

+ + + + +
+
+ diff --git a/apps/desktop/src/components/grid/DataGridPagination.vue b/apps/desktop/src/components/grid/DataGridPagination.vue index 871eecb60c..9e8ab85612 100644 --- a/apps/desktop/src/components/grid/DataGridPagination.vue +++ b/apps/desktop/src/components/grid/DataGridPagination.vue @@ -1,5 +1,5 @@