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;