feat(redis): limit the sidebar database list

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-09-18 17:33:28 +08:00
1 parent 745c201f06
commit ec356293aa
18 files changed
+265 -22

No files matched your search

@@ -0,0 +1,106 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
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);
});
});
+32 -21
View File
@@ -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);
+9
View File
@@ -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<EditorSettings>, 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;