From 1991bf118be62b866c3f0f5b853552422ce11b73 Mon Sep 17 00:00:00 2001 From: Libon Date: Mon, 15 Jun 2026 23:05:35 +0800 Subject: [PATCH] feat(infinite-scroll): implement infinite scroll feature with customizable max rows --- .../editor/EditorSettingsDialog.vue | 40 ++++ apps/desktop/src/components/grid/DataGrid.vue | 215 ++++++++++++++---- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + apps/desktop/src/stores/settingsStore.ts | 31 ++- 9 files changed, 260 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index e0d324f2bf..7db07ccdc1 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -106,6 +106,8 @@ const debugLogDownloaded = ref(false); const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader); const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader); const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions); +const editInfiniteScroll = ref(settingsStore.editorSettings.infiniteScroll); +const editInfiniteScrollMaxRows = ref(settingsStore.editorSettings.infiniteScrollMaxRows); const editRedisScanPageSize = ref(settingsStore.editorSettings.redisScanPageSize); const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts)); const editSqlFormatter = ref(normalizeSqlFormatterSettings(settingsStore.editorSettings.sqlFormatter)); @@ -347,6 +349,8 @@ watch( editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader; editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader; editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions; + editInfiniteScroll.value = settingsStore.editorSettings.infiniteScroll; + editInfiniteScrollMaxRows.value = settingsStore.editorSettings.infiniteScrollMaxRows; editRedisScanPageSize.value = settingsStore.editorSettings.redisScanPageSize; editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); editSqlFormatter.value = normalizeSqlFormatterSettings(settingsStore.editorSettings.sqlFormatter); @@ -401,6 +405,8 @@ function hasChanges(): boolean { editShowColumnCommentsInHeader.value !== settingsStore.editorSettings.showColumnCommentsInHeader || editShowColumnTypesInHeader.value !== settingsStore.editorSettings.showColumnTypesInHeader || editCompactColumnHeaderActions.value !== settingsStore.editorSettings.compactColumnHeaderActions || + editInfiniteScroll.value !== settingsStore.editorSettings.infiniteScroll || + editInfiniteScrollMaxRows.value !== settingsStore.editorSettings.infiniteScrollMaxRows || editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize || JSON.stringify(editShortcuts.value) !== JSON.stringify(settingsStore.editorSettings.shortcuts) || JSON.stringify(editSqlFormatter.value) !== JSON.stringify(normalizeSqlFormatterSettings(settingsStore.editorSettings.sqlFormatter)) || @@ -436,6 +442,8 @@ async function persistSettings() { showColumnCommentsInHeader: editShowColumnCommentsInHeader.value, showColumnTypesInHeader: editShowColumnTypesInHeader.value, compactColumnHeaderActions: editCompactColumnHeaderActions.value, + infiniteScroll: editInfiniteScroll.value, + infiniteScrollMaxRows: editInfiniteScrollMaxRows.value, redisScanPageSize: editRedisScanPageSize.value, shortcuts: editShortcuts.value, sqlFormatter: normalizeSqlFormatterSettings(editSqlFormatter.value), @@ -488,6 +496,8 @@ function resetDefaults() { editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader; editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader; editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions; + editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll; + editInfiniteScrollMaxRows.value = DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows; editRedisScanPageSize.value = DEFAULT_EDITOR_SETTINGS.redisScanPageSize; editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts); editSqlFormatter.value = normalizeSqlFormatterSettings(DEFAULT_EDITOR_SETTINGS.sqlFormatter); @@ -1801,6 +1811,36 @@ watch( +
+
+ +

+ {{ t("settings.infiniteScrollDescription") }} +

+
+ +
+
+
+ +

+ {{ t("settings.infiniteScrollMaxRowsDescription") }} +

+
+ +
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2a41d578fe..93bfb0f938 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -277,6 +277,8 @@ const showColumnCommentsInHeader = computed(() => settingsStore.editorSettings.s const showColumnTypesInHeader = computed(() => settingsStore.editorSettings.showColumnTypesInHeader); const compactColumnHeaderActions = computed(() => settingsStore.editorSettings.compactColumnHeaderActions); const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode); +const infiniteScrollEnabled = computed(() => settingsStore.editorSettings.infiniteScroll); +const infiniteScrollMaxRows = computed(() => settingsStore.editorSettings.infiniteScrollMaxRows); function headerColumnComment(column: string): string { if (!showColumnCommentsInHeader.value) return ""; @@ -1883,7 +1885,10 @@ function markGridScrolling() { function onScrollerScroll(e: Event) { syncHeaderScroll(e); const target = e.target; - recordScrollPosition(target instanceof HTMLElement ? { top: target.scrollTop, left: target.scrollLeft } : undefined); + if (target instanceof HTMLElement) { + recordScrollPosition({ top: target.scrollTop, left: target.scrollLeft }); + checkInfiniteScroll(target); + } markGridScrolling(); } @@ -1931,6 +1936,11 @@ const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSi const currentPage = ref(1); const pageSizeOptions = computed(() => resultPageSizeMenuOptions(pageSize.value)); const customPageSizeInput = ref(String(pageSize.value)); +const infiniteScrollLoading = ref(false); +const isInfiniteScrollPaginating = ref(false); +let lastInfiniteScrollPage = 0; +let infiniteScrollCheckScheduled = false; +let infiniteScrollAllLoaded = false; watch(pageSize, (value) => { customPageSizeInput.value = String(value); }); @@ -1940,16 +1950,43 @@ watch( pageSize.value = normalizeResultPageSize(value, pageSize.value); }, ); +watch( + () => infiniteScrollEnabled.value, + (enabled, prevEnabled) => { + // Switched between paginated and infinite scroll: reset to first page + if (enabled !== prevEnabled) { + resetInfiniteScrollState(); + emit("paginate", 0, pageSize.value, currentWhereInput(), currentOrderBy()); + } + }, +); watch( () => [props.pageOffset, props.pageLimit], ([offset, limit]) => { if (typeof offset !== "number" || typeof limit !== "number" || limit <= 0) return; + // Skip resetting pagination state during infinite scroll pagination + if (isInfiniteScrollPaginating.value) return; const normalizedLimit = normalizeResultPageSize(limit); pageSize.value = normalizedLimit; currentPage.value = Math.floor(offset / normalizedLimit) + 1; }, { immediate: true }, ); +// Clear infinite-scroll loading when the parent finishes loading new data +watch( + () => props.loading, + (loading, prevLoading) => { + if (prevLoading && !loading && infiniteScrollLoading.value) { + infiniteScrollLoading.value = false; + isInfiniteScrollPaginating.value = false; + // Detect if the backend returned no new data for this page + const expectedRows = currentPage.value * pageSize.value; + if (props.result.rows.length < expectedRows) { + infiniteScrollAllLoaded = true; + } + } + }, +); const manualTotalRowCount = ref(undefined); const manualTotalRowCountLoading = ref(false); const showTruncationWarning = computed(() => props.result.truncated === true && typeof props.pageLimit !== "number" && props.result.has_more !== true); @@ -2042,6 +2079,8 @@ watch( () => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? "", props.result], () => { manualTotalRowCount.value = undefined; + // Reset infinite-scroll allLoaded when query context changes + infiniteScrollAllLoaded = false; }, ); @@ -2071,6 +2110,7 @@ watch( function firstPage() { if (currentPage.value <= 1) return; currentPage.value = 1; + lastInfiniteScrollPage = 0; resetGridVerticalScroll(true); emit("paginate", 0, pageSize.value, currentWhereInput(), currentOrderBy()); } @@ -2086,11 +2126,50 @@ function nextPage() { resetGridVerticalScroll(true); emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy()); } + +function infiniteScrollNextPage() { + if (infiniteScrollLoading.value || props.loading) return; + const nextPageNum = currentPage.value + 1; + const cumulativeLimit = nextPageNum * pageSize.value; + if (cumulativeLimit > infiniteScrollMaxRows.value) return; + // Stop if we already know all data is loaded + if (infiniteScrollAllLoaded) return; + // Skip if we already have this many rows loaded (e.g. cached data) + if (props.result.rows.length >= cumulativeLimit) { + currentPage.value = nextPageNum; + return; + } + infiniteScrollLoading.value = true; + isInfiniteScrollPaginating.value = true; + currentPage.value = nextPageNum; + // Load cumulative data (all rows up to current page) to append instead of replace + emit("paginate", 0, cumulativeLimit, currentWhereInput(), currentOrderBy()); +} +function checkInfiniteScroll(scroller: HTMLElement) { + if (!infiniteScrollEnabled.value || infiniteScrollLoading.value || props.loading) return; + if (infiniteScrollAllLoaded) return; + if (infiniteScrollCheckScheduled) return; + infiniteScrollCheckScheduled = true; + requestAnimationFrame(() => { + infiniteScrollCheckScheduled = false; + const { scrollTop, scrollHeight, clientHeight } = scroller; + const threshold = 100; + const distanceToBottom = scrollHeight - scrollTop - clientHeight; + // Only trigger when near bottom AND page has changed since last trigger + if (distanceToBottom < threshold && currentPage.value !== lastInfiniteScrollPage) { + lastInfiniteScrollPage = currentPage.value; + infiniteScrollNextPage(); + } + }); +} + function changePageSize(size: number) { const normalizedSize = normalizeResultPageSize(size); pageSize.value = normalizedSize; settingsStore.updateEditorSettings({ pageSize: normalizedSize }); currentPage.value = 1; + lastInfiniteScrollPage = 0; + infiniteScrollAllLoaded = false; resetGridVerticalScroll(true); emit("paginate", 0, normalizedSize, currentWhereInput(), currentOrderBy()); } @@ -2100,6 +2179,7 @@ function applyCustomPageSize() { } async function lastPage() { + if (infiniteScrollEnabled.value) return; if (hasKnownTotalRowCount.value) { const total = displayedTotalRowCount.value ?? 0; if (total <= 0) return; @@ -2394,12 +2474,25 @@ function canDeleteRowItem(item: RowItem | undefined): boolean { return !!props.editable && !!item && !item.isDeleted && (item.isNew || canEditExistingRows.value); } +function resetInfiniteScrollState() { + currentPage.value = 1; + lastInfiniteScrollPage = 0; + infiniteScrollAllLoaded = false; + isInfiniteScrollPaginating.value = false; + infiniteScrollLoading.value = false; + resetGridVerticalScroll(true); +} + async function onToolbarRefresh() { if (transactionActive.value) { discardChanges(); } + // Reset infinite scroll state on refresh + if (infiniteScrollEnabled.value) { + resetInfiniteScrollState(); + } preserveTransposeOnNextResult.value = showTranspose.value; - emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value); + emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, 0); } async function onToolbarCommit() { @@ -2409,7 +2502,11 @@ async function onToolbarCommit() { function onToolbarRollback() { preserveTransposeOnNextResult.value = showTranspose.value; discardChanges(); - emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value); + // Reset infinite scroll state on rollback + if (infiniteScrollEnabled.value) { + resetInfiniteScrollState(); + } + emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, 0); } function addRow() { @@ -3682,6 +3779,7 @@ function onCanvasScroll(event: Event) { recordScrollPosition({ top: scrollTop, left: scrollLeft }); markGridScrolling(); scheduleCanvasDraw(); + checkInfiniteScroll(scroller); } function canvasWheelDeltaToPixels(delta: number, deltaMode: number, pageSize: number): number { @@ -6819,7 +6917,7 @@ const gridContextMenuItems = computed(() => { -
+
(() => {
+ +
+ + {{ t("grid.loadingMore") }} +
@@ -6997,6 +7100,11 @@ const gridContextMenuItems = computed(() => { + +
+ + {{ t("grid.loadingMore") }} +
@@ -7415,52 +7523,59 @@ const gridContextMenuItems = computed(() => {
- -
-
{{ t("grid.customRowsPerPage") }}
-
- - - - - - {{ t("grid.applyPageSize") }} - -
- - - - {{ currentPage }} - - + + , exist }; }); } - return [ - ...(settings.customThemeColors - ? [ - { - id: "migrated", - name: "Migrated", - colors: { ...DEFAULT_CUSTOM_THEME_COLORS, ...settings.customThemeColors }, - ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS }, - }, - ] - : []), - ]; + return settings.customThemeColors + ? [ + { + id: "migrated", + name: "Migrated", + colors: { ...DEFAULT_CUSTOM_THEME_COLORS, ...settings.customThemeColors }, + ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS }, + }, + ] + : []; })(), activeCustomThemeId: settings.activeCustomThemeId ?? "default", executeMode: settings.executeMode ?? DEFAULT_EDITOR_SETTINGS.executeMode, @@ -538,6 +540,8 @@ export function normalizeEditorSettings(settings: Partial, exist compactTabTitle: settings.compactTabTitle ?? DEFAULT_EDITOR_SETTINGS.compactTabTitle, appLayout: settings.appLayout ?? DEFAULT_EDITOR_SETTINGS.appLayout, pageSize: normalizeResultPageSize(settings.pageSize), + infiniteScroll: settings.infiniteScroll ?? DEFAULT_EDITOR_SETTINGS.infiniteScroll, + infiniteScrollMaxRows: typeof settings.infiniteScrollMaxRows === "number" && settings.infiniteScrollMaxRows >= 1000 && settings.infiniteScrollMaxRows <= 50000 ? Math.round(settings.infiniteScrollMaxRows) : DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows, redisScanPageSize: settings.redisScanPageSize ?? DEFAULT_EDITOR_SETTINGS.redisScanPageSize, mongoViewMode: settings.mongoViewMode === "table" ? "table" : DEFAULT_EDITOR_SETTINGS.mongoViewMode, showColumnCommentsInHeader: settings.showColumnCommentsInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader, @@ -693,6 +697,9 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.compactTabTitle !== undefined) editorSettings.value.compactTabTitle = partial.compactTabTitle; if (partial.appLayout !== undefined) editorSettings.value.appLayout = partial.appLayout; if (partial.pageSize !== undefined) editorSettings.value.pageSize = normalizeResultPageSize(partial.pageSize); + if (partial.infiniteScroll !== undefined) editorSettings.value.infiniteScroll = partial.infiniteScroll; + if (partial.infiniteScrollMaxRows !== undefined) + editorSettings.value.infiniteScrollMaxRows = typeof partial.infiniteScrollMaxRows === "number" && partial.infiniteScrollMaxRows >= 1000 && partial.infiniteScrollMaxRows <= 50000 ? Math.round(partial.infiniteScrollMaxRows) : DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows; if (partial.redisScanPageSize !== undefined) editorSettings.value.redisScanPageSize = partial.redisScanPageSize; if (partial.mongoViewMode !== undefined) editorSettings.value.mongoViewMode = partial.mongoViewMode; if (partial.showColumnCommentsInHeader !== undefined) editorSettings.value.showColumnCommentsInHeader = partial.showColumnCommentsInHeader;