mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(redis): limit the sidebar database list
This commit is contained in:
@@ -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<ColumnNameCopySeparator>(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<SidebarObjectInfoMode>(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") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="redis-database-display-limit-input">{{ t("settings.redisDatabaseDisplayLimit") }}</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Input
|
||||
id="redis-database-display-limit-input"
|
||||
type="number"
|
||||
list="redis-database-display-limits"
|
||||
:min="REDIS_DATABASE_DISPLAY_LIMIT_MIN"
|
||||
:max="REDIS_DATABASE_DISPLAY_LIMIT_MAX"
|
||||
step="10"
|
||||
v-model.number="editRedisDatabaseDisplayLimit"
|
||||
class="settings-export-number-input h-9 w-28 [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<datalist id="redis-database-display-limits">
|
||||
<option v-for="size in REDIS_DATABASE_DISPLAY_LIMIT_OPTIONS" :key="size" :value="size" />
|
||||
</datalist>
|
||||
<span class="text-xs text-muted-foreground">{{ t("settings.redisDatabaseDisplayLimitDescription") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
+25
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7133,6 +7133,9 @@ export default withEnglishFallback({
|
||||
redisScanPageSize: "Redisスキャンカウント",
|
||||
redisScanPageSizeDescription: "キー参照時のRedis SCANページあたりの要求キー数。",
|
||||
redisScanPageSizeOption: "{count}キー",
|
||||
redisDatabaseDisplayLimit: "サイドバーのデータベース一覧の上限",
|
||||
redisDatabaseDisplayLimitDescription: "サイドバーに一度に表示するデータベースの最大数。残りは「もっと読み込む」で表示できます。",
|
||||
redisDatabaseDisplayLimitOption: "{count}個のデータベース",
|
||||
exportBatchSize: "エクスポートバッチサイズ",
|
||||
exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。",
|
||||
exportSection: "エクスポート",
|
||||
|
||||
@@ -6886,6 +6886,9 @@ export default withEnglishFallback({
|
||||
redisScanPageSize: "Redis 스캔 개수",
|
||||
redisScanPageSizeDescription: "키를 탐색할 때 Redis SCAN 페이지당 요청하는 키 수입니다.",
|
||||
redisScanPageSizeOption: "키 {count}개",
|
||||
redisDatabaseDisplayLimit: "사이드바 데이터베이스 목록 제한",
|
||||
redisDatabaseDisplayLimitDescription: '사이드바에 한 번에 표시할 최대 데이터베이스 수입니다. 나머지는 "더 불러오기"로 볼 수 있습니다.',
|
||||
redisDatabaseDisplayLimitOption: "데이터베이스 {count}개",
|
||||
exportBatchSize: "내보내기 배치 크기",
|
||||
dateTimeSection: "날짜 및 시간",
|
||||
globalDateTimeDisplayFormat: "전역 표시 형식",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7580,6 +7580,9 @@ export default withEnglishFallback({
|
||||
redisScanPageSize: "Redis 扫描数量",
|
||||
redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。",
|
||||
redisScanPageSizeOption: "{count} 个 Key",
|
||||
redisDatabaseDisplayLimit: "侧边栏数据库列表上限",
|
||||
redisDatabaseDisplayLimitDescription: "侧边栏一次最多展示的数据库数量,超出部分可通过“加载更多”查看。",
|
||||
redisDatabaseDisplayLimitOption: "{count} 个数据库",
|
||||
exportBatchSize: "导出批次大小",
|
||||
dateTimeSection: "日期与时间",
|
||||
globalDateTimeDisplayFormat: "全局显示格式",
|
||||
|
||||
@@ -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: "尋找",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>(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 };
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
||||
"sidebarCopyTableNameSeparator",
|
||||
"sidebarCopyTableNameIncludeSchema",
|
||||
"redisKeyTemplates",
|
||||
"redisDatabaseDisplayLimit",
|
||||
"exportBatchSize",
|
||||
"csvQuoteMode",
|
||||
"exportRowLimitEnabled",
|
||||
|
||||
@@ -164,6 +164,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
|
||||
"autoCalculateTotalRows",
|
||||
"tableColumnTemplateFields",
|
||||
"redisKeyTemplates",
|
||||
"redisDatabaseDisplayLimit",
|
||||
"exportBatchSize",
|
||||
"csvQuoteMode",
|
||||
"exportRowLimitEnabled",
|
||||
|
||||
+106
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user