From ec356293aaf50eb45247d6afce110648444daa86 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Fri, 18 Sep 2026 17:33:28 +0800 Subject: [PATCH] feat(redis): limit the sidebar database list --- .../editor/EditorSettingsDialog.vue | 30 +++++ ...log.redisDatabaseDisplayLimitClamp.spec.ts | 25 +++++ apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/tr.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../redis/redisDatabaseAlias.spec.ts | 20 +++- .../src/lib/redis/redisDatabaseAlias.ts | 15 +++ .../src/lib/settings/editorSettingsDraft.ts | 1 + .../src/lib/settings/settingsTransfer.ts | 1 + ...ionStore.redisDatabaseDisplayLimit.spec.ts | 106 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 53 +++++---- apps/desktop/src/stores/settingsStore.ts | 9 ++ 18 files changed, 265 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.redisDatabaseDisplayLimitClamp.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.redisDatabaseDisplayLimit.spec.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index f785c89ff3..05b9f03af4 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -178,6 +178,7 @@ import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { COLUMN_NAME_COPY_SEPARATOR_LABELS, COLUMN_NAME_COPY_SEPARATOR_OPTIONS, isColumnNameCopySeparator, type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates"; +import { REDIS_DATABASE_DISPLAY_LIMIT_MIN, REDIS_DATABASE_DISPLAY_LIMIT_MAX, REDIS_DATABASE_DISPLAY_LIMIT_OPTIONS } from "@/lib/redis/redisDatabaseAlias"; import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame"; import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer"; import { buildQueryEditorLineNumbersExtension } from "@/lib/editor/queryEditorLineNumbers"; @@ -693,6 +694,7 @@ const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarH const editSidebarCopyTableNameSeparator = ref(settingsStore.editorSettings.sidebarCopyTableNameSeparator); const editSidebarCopyTableNameIncludeSchema = ref(settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema); const editRedisKeyTemplates = ref(normalizeRedisKeyTemplates(settingsStore.editorSettings.redisKeyTemplates).join("\n")); +const editRedisDatabaseDisplayLimit = ref(settingsStore.editorSettings.redisDatabaseDisplayLimit); const editSidebarObjectInfoMode = ref(settingsStore.editorSettings.sidebarObjectInfoMode); const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll); const editSidebarShowTooltips = ref(settingsStore.editorSettings.sidebarShowTooltips); @@ -929,6 +931,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { sidebarCopyTableNameSeparator: editSidebarCopyTableNameSeparator.value, sidebarCopyTableNameIncludeSchema: editSidebarCopyTableNameIncludeSchema.value, redisKeyTemplates: normalizeRedisKeyTemplates(editRedisKeyTemplates.value), + redisDatabaseDisplayLimit: editRedisDatabaseDisplayLimit.value, exportBatchSize: editExportBatchSize.value, csvQuoteMode: editCsvQuoteMode.value, globalDateTimeDisplayFormat: editGlobalDateTimeDisplayFormat.value, @@ -1558,6 +1561,7 @@ function syncEditorSettingsDraftFromStore() { editSidebarCopyTableNameSeparator.value = settingsStore.editorSettings.sidebarCopyTableNameSeparator; editSidebarCopyTableNameIncludeSchema.value = settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema; editRedisKeyTemplates.value = normalizeRedisKeyTemplates(settingsStore.editorSettings.redisKeyTemplates).join("\n"); + editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit; editSidebarObjectInfoMode.value = settingsStore.editorSettings.sidebarObjectInfoMode; editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll; editSidebarShowTooltips.value = settingsStore.editorSettings.sidebarShowTooltips; @@ -1683,6 +1687,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = { sidebarCopyTableNameSeparator: editSidebarCopyTableNameSeparator, sidebarCopyTableNameIncludeSchema: editSidebarCopyTableNameIncludeSchema, redisKeyTemplates: editRedisKeyTemplates, + redisDatabaseDisplayLimit: editRedisDatabaseDisplayLimit, exportBatchSize: editExportBatchSize, csvQuoteMode: editCsvQuoteMode, exportRowLimitEnabled: editExportRowLimitEnabled, @@ -1951,6 +1956,10 @@ async function persistSettings() { settingsStore.updateEditorSettings(editorSettingsPatch); await settingsStore.persistEditorSettings(); editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings); + // updateEditorSettings clamps out-of-range values; reflect the clamped + // result back into the input so an out-of-range draft doesn't keep + // reporting unsaved changes after a successful apply. + editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit; } if (pendingBackgroundImageCleanup) { const cleanupPath = pendingBackgroundImageCleanup; @@ -2142,6 +2151,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editDuckDbWorkerMaxProcesses.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_max_processes; editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields); editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n"); + editRedisDatabaseDisplayLimit.value = DEFAULT_EDITOR_SETTINGS.redisDatabaseDisplayLimit; editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize; editCsvQuoteMode.value = DEFAULT_EDITOR_SETTINGS.csvQuoteMode; editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat; @@ -2267,6 +2277,7 @@ function resetAllDefaults() { editSidebarCopyTableNameSeparator.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameSeparator; editSidebarCopyTableNameIncludeSchema.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameIncludeSchema; editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n"); + editRedisDatabaseDisplayLimit.value = DEFAULT_EDITOR_SETTINGS.redisDatabaseDisplayLimit; editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize; editCsvQuoteMode.value = DEFAULT_EDITOR_SETTINGS.csvQuoteMode; editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat; @@ -7716,6 +7727,25 @@ onUnmounted(() => { {{ t("settings.redisKeyTemplatesDescription") }}

+
+ +
+ + + + {{ t("settings.redisDatabaseDisplayLimitDescription") }} +
+
diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.redisDatabaseDisplayLimitClamp.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.redisDatabaseDisplayLimitClamp.spec.ts new file mode 100644 index 0000000000..c3c90097f4 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.redisDatabaseDisplayLimitClamp.spec.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8"); + +// Regression found while verifying issue #1236 (Redis sidebar database list +// limit): updateEditorSettings() silently clamps an out-of-range +// redisDatabaseDisplayLimit to [MIN, MAX], but persistSettings() only +// re-synced the dirty-check baseline (editEditorSettingsBase), not the input's +// own v-model ref. Typing a below-minimum value and clicking Apply left the +// draft (the unclamped value) permanently out of sync with the saved +// (clamped) value, so the dialog kept reporting unsaved changes forever after +// a successful apply. +describe("EditorSettingsDialog Redis database display limit clamp resync", () => { + it("reflects the clamped value back into the input after a successful apply", () => { + const block = dialogSource.slice(dialogSource.indexOf("async function persistSettings()"), dialogSource.indexOf("function applySettingsErrorToast")); + expect(block).toContain("editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);"); + expect(block).toContain("editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit;"); + // The resync must happen after the clamp-and-persist step, inside the same + // "there is a pending editor-settings patch" branch, not unconditionally. + const clampResyncIndex = block.indexOf("editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit;"); + const persistIndex = block.indexOf("await settingsStore.persistEditorSettings();"); + expect(clampResyncIndex).toBeGreaterThan(persistIndex); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 243ea5a731..f16ff7c902 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7608,6 +7608,9 @@ export default { redisScanPageSize: "Redis scan count", redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.", redisScanPageSizeOption: "{count} keys", + redisDatabaseDisplayLimit: "Sidebar database list limit", + redisDatabaseDisplayLimitDescription: 'Maximum number of databases shown at once in the sidebar; the rest can be revealed with "Load more".', + redisDatabaseDisplayLimitOption: "{count} databases", exportBatchSize: "Export batch size", dateTimeSection: "Date and time", globalDateTimeDisplayFormat: "Global display format", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 140d828d05..32b242b65a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7120,6 +7120,9 @@ export default withEnglishFallback({ redisScanPageSize: "Cantidad de escaneo Redis", redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.", redisScanPageSizeOption: "{count} claves", + redisDatabaseDisplayLimit: "Límite de bases de datos en la barra lateral", + redisDatabaseDisplayLimitDescription: 'Número máximo de bases de datos que se muestran a la vez en la barra lateral; el resto se puede ver con "Cargar más".', + redisDatabaseDisplayLimitOption: "{count} bases de datos", exportBatchSize: "Tamaño de lote de exportación", exportBatchSizeDescription: "Filas obtenidas por lote al exportar datos de tabla (100-100000).", exportRowLimitEnabled: "Limitar filas exportadas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a8eaa8020f..dc3f7c62a4 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7120,6 +7120,9 @@ export default withEnglishFallback({ redisScanPageSize: "Conteggio scansione Redis", redisScanPageSizeDescription: "Chiavi richieste per pagina SCAN di Redis durante la navigazione delle chiavi.", redisScanPageSizeOption: "{count} chiavi", + redisDatabaseDisplayLimit: "Limite elenco database nella barra laterale", + redisDatabaseDisplayLimitDescription: 'Numero massimo di database mostrati contemporaneamente nella barra laterale; il resto può essere visualizzato con "Carica altro".', + redisDatabaseDisplayLimitOption: "{count} database", exportBatchSize: "Dimensione del lotto di esportazione", exportBatchSizeDescription: "Righe recuperate per lotto durante l'esportazione dei dati della tabella (100-100000).", exportRowLimitEnabled: "Limita righe esportate", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 9b17995b5e..18311ab7bf 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7133,6 +7133,9 @@ export default withEnglishFallback({ redisScanPageSize: "Redisスキャンカウント", redisScanPageSizeDescription: "キー参照時のRedis SCANページあたりの要求キー数。", redisScanPageSizeOption: "{count}キー", + redisDatabaseDisplayLimit: "サイドバーのデータベース一覧の上限", + redisDatabaseDisplayLimitDescription: "サイドバーに一度に表示するデータベースの最大数。残りは「もっと読み込む」で表示できます。", + redisDatabaseDisplayLimitOption: "{count}個のデータベース", exportBatchSize: "エクスポートバッチサイズ", exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。", exportSection: "エクスポート", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index a8aa9243bc..f5a384d0c1 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6886,6 +6886,9 @@ export default withEnglishFallback({ redisScanPageSize: "Redis 스캔 개수", redisScanPageSizeDescription: "키를 탐색할 때 Redis SCAN 페이지당 요청하는 키 수입니다.", redisScanPageSizeOption: "키 {count}개", + redisDatabaseDisplayLimit: "사이드바 데이터베이스 목록 제한", + redisDatabaseDisplayLimitDescription: '사이드바에 한 번에 표시할 최대 데이터베이스 수입니다. 나머지는 "더 불러오기"로 볼 수 있습니다.', + redisDatabaseDisplayLimitOption: "데이터베이스 {count}개", exportBatchSize: "내보내기 배치 크기", dateTimeSection: "날짜 및 시간", globalDateTimeDisplayFormat: "전역 표시 형식", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e3cea923d1..c4396b365f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7122,6 +7122,9 @@ export default withEnglishFallback({ redisScanPageSize: "Contagem de scan do Redis", redisScanPageSizeDescription: "Chaves solicitadas por página do Redis SCAN ao navegar pelas chaves.", redisScanPageSizeOption: "{count} chaves", + redisDatabaseDisplayLimit: "Limite da lista de bancos de dados na barra lateral", + redisDatabaseDisplayLimitDescription: 'Número máximo de bancos de dados exibidos de uma vez na barra lateral; o restante pode ser visto com "Carregar mais".', + redisDatabaseDisplayLimitOption: "{count} bancos de dados", exportBatchSize: "Tamanho do lote de exportação", exportBatchSizeDescription: "Linhas buscadas por lote ao exportar dados da tabela (100-100000).", exportRowLimitEnabled: "Limitar linhas exportadas", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index dd1ff908e1..ed7561422c 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7263,6 +7263,9 @@ export default withEnglishFallback({ redisScanPageSize: "Redis tarama sayısı", redisScanPageSizeDescription: "Anahtarlara gözatılırken her Redis SCAN sayfasında istenen anahtar sayısı.", redisScanPageSizeOption: "{count} anahtar", + redisDatabaseDisplayLimit: "Kenar çubuğu veritabanı listesi sınırı", + redisDatabaseDisplayLimitDescription: 'Kenar çubuğunda bir seferde gösterilecek maksimum veritabanı sayısı; kalanlar "Daha fazla yükle" ile görüntülenebilir.', + redisDatabaseDisplayLimitOption: "{count} veritabanı", exportBatchSize: "Dışa aktarma toplu iş boyutu", dateTimeSection: "Tarih ve saat", globalDateTimeDisplayFormat: "Genel görüntüleme biçimi", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index eff280e438..194d53cb63 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7580,6 +7580,9 @@ export default withEnglishFallback({ redisScanPageSize: "Redis 扫描数量", redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。", redisScanPageSizeOption: "{count} 个 Key", + redisDatabaseDisplayLimit: "侧边栏数据库列表上限", + redisDatabaseDisplayLimitDescription: "侧边栏一次最多展示的数据库数量,超出部分可通过“加载更多”查看。", + redisDatabaseDisplayLimitOption: "{count} 个数据库", exportBatchSize: "导出批次大小", dateTimeSection: "日期与时间", globalDateTimeDisplayFormat: "全局显示格式", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index be6321755d..5637f123e2 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6445,6 +6445,9 @@ export default withEnglishFallback({ redisScanPageSize: "Redis 掃描數量", redisScanPageSizeDescription: "瀏覽 Redis Key 時每次 SCAN 請求的 Key 數量。", redisScanPageSizeOption: "{count} 個 Key", + redisDatabaseDisplayLimit: "側邊欄資料庫清單上限", + redisDatabaseDisplayLimitDescription: "側邊欄一次最多顯示的資料庫數量,超出部分可透過「載入更多」查看。", + redisDatabaseDisplayLimitOption: "{count} 個資料庫", shortcutExecuteSql: "執行 SQL", shortcutExecuteSqlInNewResultTab: "在新結果分頁中執行 SQL", shortcutFind: "尋找", diff --git a/apps/desktop/src/lib/__tests__/redis/redisDatabaseAlias.spec.ts b/apps/desktop/src/lib/__tests__/redis/redisDatabaseAlias.spec.ts index 8775abe96b..7e827df41a 100644 --- a/apps/desktop/src/lib/__tests__/redis/redisDatabaseAlias.spec.ts +++ b/apps/desktop/src/lib/__tests__/redis/redisDatabaseAlias.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; +import { limitRedisDatabaseList, normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel, REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT } from "@/lib/redis/redisDatabaseAlias"; describe("redisDatabaseAlias", () => { it("normalizes numeric database keys and trims aliases", () => { @@ -32,4 +32,22 @@ describe("redisDatabaseAlias", () => { expect(redisDatabaseLabel(0, undefined, 0)).toBe("db0 (0)"); expect(redisDatabaseLabel(2, { "2": "cache" })).toBe("db2 · cache"); }); + + it("leaves the list untouched when it fits within the limit", () => { + const items = [1, 2, 3]; + expect(limitRedisDatabaseList(items, 10)).toEqual({ visible: items, hasMore: false }); + }); + + it("truncates and reports more items are available", () => { + const items = [1, 2, 3, 4, 5]; + expect(limitRedisDatabaseList(items, 2)).toEqual({ visible: [1, 2], hasMore: true }); + }); + + it("falls back to the default limit for missing or invalid values (#1236)", () => { + const items = Array.from({ length: REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT + 1 }, (_, i) => i); + expect(limitRedisDatabaseList(items, undefined).hasMore).toBe(true); + expect(limitRedisDatabaseList(items, 0).hasMore).toBe(true); + expect(limitRedisDatabaseList(items, -5).hasMore).toBe(true); + expect(limitRedisDatabaseList(items.slice(0, REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT), undefined).hasMore).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/redis/redisDatabaseAlias.ts b/apps/desktop/src/lib/redis/redisDatabaseAlias.ts index 757c062b50..a6c69954e8 100644 --- a/apps/desktop/src/lib/redis/redisDatabaseAlias.ts +++ b/apps/desktop/src/lib/redis/redisDatabaseAlias.ts @@ -28,3 +28,18 @@ export function redisDatabaseLabel(database: string | number, aliases?: RedisDat const name = alias ? `db${key} · ${alias}` : `db${key}`; return totalKeyCount == null ? name : `${name} (${totalKeyCount})`; } + +// Default kept large enough that connections with a typical (or even a few +// hundred) configured databases render exactly as before #1236 — only a +// connection with an unusually large `databases` count hits the cap and gets +// a "load more" node instead of a wall of sidebar rows. +export const REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT = 1000; +export const REDIS_DATABASE_DISPLAY_LIMIT_MIN = 10; +export const REDIS_DATABASE_DISPLAY_LIMIT_MAX = 100_000; +export const REDIS_DATABASE_DISPLAY_LIMIT_OPTIONS = [50, 100, 200, 500, 1000, 2000, 5000] as const; + +export function limitRedisDatabaseList(items: T[], limit: number | undefined): { visible: T[]; hasMore: boolean } { + const effectiveLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT; + if (items.length <= effectiveLimit) return { visible: items, hasMore: false }; + return { visible: items.slice(0, effectiveLimit), hasMore: true }; +} diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 0e921edc40..dbb84b8d5b 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -101,6 +101,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "sidebarCopyTableNameSeparator", "sidebarCopyTableNameIncludeSchema", "redisKeyTemplates", + "redisDatabaseDisplayLimit", "exportBatchSize", "csvQuoteMode", "exportRowLimitEnabled", diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index 7d831124e2..b7ee9e6fbe 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -164,6 +164,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function redisConnection(): ConnectionConfig { + return { + id: "redis-1", + name: "Redis", + db_type: "redis", + host: "127.0.0.1", + port: 6379, + username: "", + password: "", + database: "0", + }; +} + +function manyRedisDatabases(count: number) { + return Array.from({ length: count }, (_, db) => ({ db, keys: db === 0 ? 5 : 0 })); +} + +describe("connectionStore Redis database display limit (#1236)", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("caps the sidebar list at the configured limit and appends a load-more node", async () => { + const redisListDatabases = vi.fn().mockResolvedValue(manyRedisDatabases(30)); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + redisListDatabases, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + useSettingsStore().updateEditorSettings({ redisDatabaseDisplayLimit: 10 }); + + const store = useConnectionStore(); + const connection = redisConnection(); + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [] }]; + + await store.loadRedisDatabases(connection.id); + + const children = store.treeNodes[0].children ?? []; + expect(children.filter((node) => node.type === "redis-db")).toHaveLength(10); + const loadMore = children.find((node) => node.type === "load-more"); + expect(loadMore).toBeDefined(); + expect(loadMore?.loadMore).toEqual({ parentId: connection.id, offset: 10, pageSize: 20 }); + + await store.loadMoreObjectGroupChildren(loadMore!); + + const expandedChildren = store.treeNodes[0].children ?? []; + expect(expandedChildren.filter((node) => node.type === "redis-db")).toHaveLength(30); + expect(expandedChildren.some((node) => node.type === "load-more")).toBe(false); + expect(redisListDatabases).toHaveBeenCalledTimes(2); + }); + + it("does not truncate when the database count is within the configured limit", async () => { + const redisListDatabases = vi.fn().mockResolvedValue(manyRedisDatabases(16)); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + redisListDatabases, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + // Default limit (1000) — the historical "show everything" behavior must be unchanged. + expect(useSettingsStore().editorSettings.redisDatabaseDisplayLimit).toBe(1000); + + const store = useConnectionStore(); + const connection = redisConnection(); + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [] }]; + + await store.loadRedisDatabases(connection.id); + + const children = store.treeNodes[0].children ?? []; + expect(children.filter((node) => node.type === "redis-db")).toHaveLength(16); + expect(children.some((node) => node.type === "load-more")).toBe(false); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 186d769c99..eeba9aa0fb 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -145,7 +145,7 @@ import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metad import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation"; import { etcdPermissionsAllowKey } from "@/lib/etcd/keyPermissions"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; -import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; +import { limitRedisDatabaseList, normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates"; import { appendAgentDriverUpdateHint, connectionUsesSsh, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; import { appendConnectionErrorHints, isMysqlMissingPasswordFailure, isSqliteMissingEncryptionPasswordFailure } from "@/lib/connection/connectionErrorHints"; @@ -4841,7 +4841,7 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function loadRedisDatabases(connectionId: string) { + async function loadRedisDatabases(connectionId: string, options?: { showAll?: boolean }) { const node = findConnectionNode(connectionId); return runConnectionTreeMetadataLoad(connectionId, node, async (load) => { const dbs = await withMetadataLoadTimeout(connectionId, api.redisListDatabases(connectionId), "Redis databases"); @@ -4857,26 +4857,25 @@ export const useConnectionStore = defineStore("connection", () => { connectionId, dbs.map((db) => String(db.db)), ); - setChildren( - targetNode, - withSavedSqlRoot( + const databaseNodes: TreeNode[] = dbs + .filter((db) => visibleNameSet.has(String(db.db))) + .map((db) => ({ + id: `${connectionId}:db${db.db}`, + label: redisDatabaseLabel(db.db, config?.redis_database_aliases, db.keys), + type: "redis-db" as const, connectionId, - dbs - .filter((db) => visibleNameSet.has(String(db.db))) - .map((db) => ({ - id: `${connectionId}:db${db.db}`, - label: redisDatabaseLabel(db.db, config?.redis_database_aliases, db.keys), - type: "redis-db" as const, - connectionId, - database: String(db.db), - loadedKeyCount: 0, - totalKeyCount: db.keys, - isExpanded: false, - children: [], - })), - targetNode, - ), - ); + database: String(db.db), + loadedKeyCount: 0, + totalKeyCount: db.keys, + isExpanded: false, + children: [], + })); + // #1236: a `databases` count in the low hundreds is rare but not unheard + // of, and dumping every one of them into the sidebar at once is the + // reported pain point. Cap the initial render and let a "load more" node + // reveal the rest on demand, unless the user explicitly asked to see all. + const { visible, hasMore } = options?.showAll ? { visible: databaseNodes, hasMore: false } : limitRedisDatabaseList(databaseNodes, useSettingsStore().editorSettings.redisDatabaseDisplayLimit); + setChildren(targetNode, withSavedSqlRoot(connectionId, hasMore ? [...visible, buildLoadMoreNode(targetNode, visible.length, databaseNodes.length - visible.length)] : visible, targetNode)); targetNode.isExpanded = true; }); } @@ -6127,6 +6126,18 @@ export const useConnectionStore = defineStore("connection", () => { if (node.type !== "load-more" || !node.loadMore) return; const loadMore = node.loadMore; const parent = findNode(treeNodes.value, node.loadMore.parentId); + if (parent?.type === "connection" && parent.connectionId && getConfig(parent.connectionId)?.db_type === "redis") { + // The full database list is already fetched in one cheap call, so "load + // more" here just re-renders it without the display-limit truncation + // instead of paging through another backend round trip. + node.isLoading = true; + try { + await loadRedisDatabases(parent.connectionId, { showAll: true }); + } finally { + node.isLoading = false; + } + return; + } if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return; const parentConnectionId = parent.connectionId; const configForScope = getConfig(parentConnectionId); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index acc9b23436..7a377fd9b3 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -19,6 +19,7 @@ import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTar import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort"; import { type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy"; import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates"; +import { REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT, REDIS_DATABASE_DISPLAY_LIMIT_MIN, REDIS_DATABASE_DISPLAY_LIMIT_MAX } from "@/lib/redis/redisDatabaseAlias"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; import { normalizeSidebarCopyTableNameSeparator } from "@/lib/sidebar/sidebarTableNameCopy"; import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick"; @@ -889,6 +890,8 @@ export interface EditorSettings { csvQuoteMode: CsvQuoteMode; /** Global Redis key-search templates; overridden by non-empty connection templates. */ redisKeyTemplates: string[]; + /** Sidebar database-list cap for Redis connections; the rest are revealed via "load more". */ + redisDatabaseDisplayLimit: number; exportRowLimitEnabled: boolean; exportRowLimit: number; queryExportKeysetOptimizationEnabled: boolean; @@ -1135,6 +1138,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { exportBatchSize: 2000, csvQuoteMode: DEFAULT_CSV_QUOTE_MODE, redisKeyTemplates: [], + redisDatabaseDisplayLimit: REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT, exportRowLimitEnabled: false, exportRowLimit: 100000, queryExportKeysetOptimizationEnabled: true, @@ -1669,6 +1673,10 @@ export function normalizeEditorSettings(settings: Partial, exist exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize, csvQuoteMode: normalizeCsvQuoteMode(settings.csvQuoteMode), redisKeyTemplates: normalizeRedisKeyTemplates(settings.redisKeyTemplates), + redisDatabaseDisplayLimit: + typeof settings.redisDatabaseDisplayLimit === "number" && settings.redisDatabaseDisplayLimit >= REDIS_DATABASE_DISPLAY_LIMIT_MIN && settings.redisDatabaseDisplayLimit <= REDIS_DATABASE_DISPLAY_LIMIT_MAX + ? Math.round(settings.redisDatabaseDisplayLimit) + : DEFAULT_EDITOR_SETTINGS.redisDatabaseDisplayLimit, exportRowLimitEnabled: typeof settings.exportRowLimitEnabled === "boolean" ? settings.exportRowLimitEnabled : DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled, exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit, queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled, @@ -2398,6 +2406,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.exportBatchSize !== undefined) editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize))); if (partial.csvQuoteMode !== undefined) editorSettings.value.csvQuoteMode = normalizeCsvQuoteMode(partial.csvQuoteMode); if (partial.redisKeyTemplates !== undefined) editorSettings.value.redisKeyTemplates = normalizeRedisKeyTemplates(partial.redisKeyTemplates); + if (partial.redisDatabaseDisplayLimit !== undefined) editorSettings.value.redisDatabaseDisplayLimit = Math.min(REDIS_DATABASE_DISPLAY_LIMIT_MAX, Math.max(REDIS_DATABASE_DISPLAY_LIMIT_MIN, Math.round(partial.redisDatabaseDisplayLimit))); if (partial.exportRowLimitEnabled !== undefined) editorSettings.value.exportRowLimitEnabled = partial.exportRowLimitEnabled; if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit))); if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled;