diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 42a12c627f..45b324c948 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -55,6 +55,7 @@ import { createConnectionNoteVisibilityDraft, persistConnectionNoteVisibilityDra import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern"; import { normalizeRedisKeyTemplates, redisKeyTemplatesToTextarea } from "@/lib/redis/redisKeyTemplates"; import { normalizeRedisDatabaseValue } from "@/lib/redis/redisDatabaseIndex"; +import { REDIS_VALUE_PREVIEW_LIMIT_KIB_MAX, REDIS_VALUE_PREVIEW_LIMIT_KIB_MIN, normalizeRedisValuePreviewLimitKiB } from "@/lib/redis/redisValuePreviewLimit"; import { normalizeGlobalConnectTimeoutSecs, normalizeGlobalQueryTimeoutSecs, useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; @@ -295,6 +296,7 @@ const showConnectionNotesInSidebar = computed({ }); const editGlobalConnectTimeoutSecs = ref(settingsStore.editorSettings.globalConnectTimeoutSecs); const editGlobalQueryTimeoutSecs = ref(settingsStore.editorSettings.globalQueryTimeoutSecs); +const editRedisValuePreviewLimitKiB = ref(settingsStore.editorSettings.redisValuePreviewLimitKiB); const open = defineModel("open", { default: false }); const isDesktop = isTauriRuntime(); @@ -3085,6 +3087,7 @@ watch( resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes); editGlobalConnectTimeoutSecs.value = settingsStore.editorSettings.globalConnectTimeoutSecs; editGlobalQueryTimeoutSecs.value = settingsStore.editorSettings.globalQueryTimeoutSecs; + editRedisValuePreviewLimitKiB.value = settingsStore.editorSettings.redisValuePreviewLimitKiB; if (syncAction === "hydrate" && config) { appliedConnectionUpdate = connectionUpdate; if (connectionUpdate) applyingConnectionUpdate = true; @@ -6176,6 +6179,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) { resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes); editGlobalConnectTimeoutSecs.value = settingsStore.editorSettings.globalConnectTimeoutSecs; editGlobalQueryTimeoutSecs.value = settingsStore.editorSettings.globalQueryTimeoutSecs; + editRedisValuePreviewLimitKiB.value = settingsStore.editorSettings.redisValuePreviewLimitKiB; selectedTransportLayerId.value = null; draggedTransportLayerId.value = null; selectedType.value = "mysql"; @@ -6623,6 +6627,13 @@ async function persistGlobalTimeoutDrafts() { }); } +async function persistGlobalRedisValuePreviewLimit() { + const next = normalizeRedisValuePreviewLimitKiB(editRedisValuePreviewLimitKiB.value); + editRedisValuePreviewLimitKiB.value = next; + if (next === settingsStore.editorSettings.redisValuePreviewLimitKiB) return; + await settingsStore.updateEditorSettingsAndPersist({ redisValuePreviewLimitKiB: next }); +} + async function persistConnectionNoteVisibilityDraft() { await persistConnectionNoteVisibilityDraftState(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes, (value) => settingsStore.updateEditorSettingsAndPersist({ sidebarShowConnectionNotes: value })); } @@ -6674,6 +6685,7 @@ async function save(options: SaveConnectionOptions = {}): Promise { await ensureRequiredGaussdbMJdbcRuntime(updated); validateConnectionUpdateTarget(); await persistGlobalTimeoutDrafts(); + await persistGlobalRedisValuePreviewLimit(); await store.updateConnection(updated); savedConfig = updated; connectionSaved = true; @@ -6683,6 +6695,7 @@ async function save(options: SaveConnectionOptions = {}): Promise { await ensureRequiredAgentDriverInstalled(config); await ensureRequiredGaussdbMJdbcRuntime(config); await persistGlobalTimeoutDrafts(); + await persistGlobalRedisValuePreviewLimit(); await store.addConnection(config, selectedConnectionGroupId.value); savedConfig = config; connectionSaved = true; @@ -10420,6 +10433,16 @@ function openExternalUrl(url: string) {

{{ t("settings.redisScanPageSizeDescription") }}

+
+ +
+
+ + KiB +
+

{{ t("settings.redisValuePreviewLimitDescription") }}

+
+
diff --git a/apps/desktop/src/components/connection/__tests__/ConnectionDialog.deepLinkEdit.spec.ts b/apps/desktop/src/components/connection/__tests__/ConnectionDialog.deepLinkEdit.spec.ts index 469ecb0d92..1ecc0265c3 100644 --- a/apps/desktop/src/components/connection/__tests__/ConnectionDialog.deepLinkEdit.spec.ts +++ b/apps/desktop/src/components/connection/__tests__/ConnectionDialog.deepLinkEdit.spec.ts @@ -171,7 +171,7 @@ async function settle() { } async function mountDialog(config: ConnectionConfig, url: string, initiallyOpen = true) { - const props = reactive({ open: initiallyOpen, editConfig: config, updatePrefill: parseConnectionDeepLinkUpdate(url) }); + const props = reactive({ open: initiallyOpen, editConfig: config, updatePrefill: url ? parseConnectionDeepLinkUpdate(url) : null }); store.getConfig.mockReturnValue(config); const container = document.createElement("div"); document.body.append(container); @@ -209,6 +209,25 @@ afterEach(() => { }); describe("ConnectionDialog deep-link edit confirmation", () => { + it("shows the global Redis preview limit in advanced settings and persists it", async () => { + visibleTab.value = "advanced"; + settings.editorSettings.redisValuePreviewLimitKiB = 64; + const redis = savedConnection({ id: "redis-connection", name: "Redis", db_type: "redis", driver_profile: "redis", driver_label: "Redis", host: "127.0.0.1", port: 6379, database: "0" }); + await mountDialog(redis, ""); + const input = document.querySelector("#redis-value-preview-limit-input"); + expect(input?.value).toBe("64"); + expect(input?.min).toBe("1"); + expect(input?.max).toBe("65536"); + input!.value = "256"; + input!.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save"); + save!.click(); + await settle(); + expect(settings.updateEditorSettingsAndPersist).toHaveBeenCalledWith({ redisValuePreviewLimitKiB: 256 }); + expect(store.updateConnection).toHaveBeenCalled(); + }); + it("changes the advanced badge picker and persists it while retaining production protection", async () => { visibleTab.value = "advanced"; await mountDialog(savedConnection({ production_badge_scope: "connection" }), "dbx://connection/new?id=saved-production"); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 8bcc8d9d97..787faeffaf 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -1347,7 +1347,7 @@ describe("RedisKeyBrowser expiry creation", () => { await submitCreate(); expectWriterBefore(mocks.redisSetString, mocks.redisSetTtl); - expect(mocks.redisGetValue).toHaveBeenCalledWith("connection", 0, KEY_RAW); + expect(mocks.redisGetValue).toHaveBeenCalledWith("connection", 0, KEY_RAW, 65536); expect(mocks.redisDeleteKey).not.toHaveBeenCalled(); expect(mocks.redisDeleteKeys).not.toHaveBeenCalled(); expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index b53646f806..90b678b73c 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch"; import DateTimePicker from "@/components/ui/date-time-picker/DateTimePicker.vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; +import { redisValuePreviewLimitBytes } from "@/lib/redis/redisValuePreviewLimit"; import RedisValueViewer from "./RedisValueViewer.vue"; import RedisPubSubPanel from "./RedisPubSubPanel.vue"; import RedisSlowlogPanel from "./RedisSlowlogPanel.vue"; @@ -2327,7 +2328,7 @@ function isRedisMissingKeyError(error: unknown): boolean { } async function syncWrittenKey(keyRaw: string): Promise { - const created = await api.redisGetValue(props.connectionId, props.db, keyRaw); + const created = await api.redisGetValue(props.connectionId, props.db, keyRaw, redisValuePreviewLimitBytes(settingsStore.editorSettings.redisValuePreviewLimitKiB)); if (created.redis_type === "none") { removeKnownKey(keyRaw); return false; @@ -3737,6 +3738,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); :key-display="selectedKey.key_display" :key-raw="selectedKey.key_raw" :metadata="selectedKey" + :preview-limit-ki-b="settingsStore.editorSettings.redisValuePreviewLimitKiB" @deleted="onKeyDeleted" @renamed="onKeyRenamed" @loaded="onKeyLoaded" diff --git a/apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts b/apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts index 1a515c1392..ade2bc42c0 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts @@ -179,7 +179,7 @@ const testI18nMessages = { }, }; -function mountViewer(onDeleted: (keyRaw: string) => void, onLoaded = vi.fn()) { +function mountViewer(onDeleted: (keyRaw: string) => void, onLoaded = vi.fn(), previewLimitKiB = ref(64)) { const host = document.createElement("div"); document.body.append(host); const app = createApp( @@ -193,6 +193,7 @@ function mountViewer(onDeleted: (keyRaw: string) => void, onLoaded = vi.fn()) { keyRaw: "key", onDeleted, onLoaded, + previewLimitKiB: previewLimitKiB.value, }); }, }), @@ -288,6 +289,31 @@ async function setStringDraft(value: string) { } describe("RedisValueViewer expiry saving", () => { + it("loads the configured preview size and refreshes when the limit changes", async () => { + const limit = ref(256); + mocks.redisGetValue.mockResolvedValue(stringValue()); + mountViewer(vi.fn(), vi.fn(), limit); + await settle(); + expect(mocks.redisGetValue).toHaveBeenLastCalledWith("connection", 0, "key", 256 * 1024); + limit.value = 1024; + await settle(); + expect(mocks.redisGetValue).toHaveBeenLastCalledWith("connection", 0, "key", 1024 * 1024); + expect(document.querySelector("textarea")?.readOnly).toBe(false); + }); + + it("preserves unsaved edits when the preview limit changes", async () => { + const limit = ref(64); + mocks.redisGetValue.mockResolvedValue(stringValue()); + mountViewer(vi.fn(), vi.fn(), limit); + await settle(); + await setStringDraft("unsaved draft"); + const calls = mocks.redisGetValue.mock.calls.length; + limit.value = 256; + await settle(); + expect(mocks.redisGetValue).toHaveBeenCalledTimes(calls); + expect(document.querySelector("textarea")?.value).toBe("unsaved draft"); + }); + it("renders a large String as a bounded read-only preview", async () => { mocks.redisGetValue.mockResolvedValueOnce(largeStringValue()); diff --git a/apps/desktop/src/components/redis/RedisValueViewer.streamMonitoring.spec.ts b/apps/desktop/src/components/redis/RedisValueViewer.streamMonitoring.spec.ts index 51470c724f..8f70505f16 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.streamMonitoring.spec.ts +++ b/apps/desktop/src/components/redis/RedisValueViewer.streamMonitoring.spec.ts @@ -231,7 +231,7 @@ describe("RedisValueViewer stream monitoring", () => { const host = mountViewer(); await settle(); - expect(mocks.redisGetValue).toHaveBeenCalledWith("redis-1", 2, "b3JkZXJz"); + expect(mocks.redisGetValue).toHaveBeenCalledWith("redis-1", 2, "b3JkZXJz", 65536); expect(mocks.redisGetStreamGroups).not.toHaveBeenCalled(); openGroups(host); diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index b345c77695..c2bd9497cc 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -73,6 +73,7 @@ import RedisHorizontalScrollbar from "@/components/redis/RedisHorizontalScrollba import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/lib/common/safeJsonFormat"; import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker"; import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry"; +import { redisValuePreviewLimitBytes } from "@/lib/redis/redisValuePreviewLimit"; import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession"; import { formatBytes } from "@/lib/database/serverMetrics"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; @@ -89,6 +90,7 @@ const props = defineProps<{ keyDisplay: string; keyRaw: string; metadata?: RedisKeyInfo | null; + previewLimitKiB?: number; }>(); const redisExpiryTransport = { @@ -120,6 +122,7 @@ const renameKeyError = ref(""); const renameKeyInitialName = ref(""); const renameKeyInitialRaw = ref(""); let loadRequestId = 0; +let loadedPreviewLimitBytes: number | undefined; const streamTab = ref<"entries" | "groups">("entries"); const streamEntries = ref([]); const streamEntriesCursor = ref(); @@ -1771,10 +1774,10 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre const shouldSelectDefaultMember = options.selectDefaultMember ?? true; const requestId = ++loadRequestId; if (!background) stopCollectionSearch(); - const loadScope = { connectionId: props.connectionId, db: props.db, keyRaw: props.keyRaw }; + const loadScope = { connectionId: props.connectionId, db: props.db, keyRaw: props.keyRaw, previewLimitBytes: redisValuePreviewLimitBytes(props.previewLimitKiB) }; if (!background) loading.value = true; try { - let loadedValue = await api.redisGetValue(loadScope.connectionId, loadScope.db, loadScope.keyRaw); + let loadedValue = await api.redisGetValue(loadScope.connectionId, loadScope.db, loadScope.keyRaw, loadScope.previewLimitBytes); if (requestId !== loadRequestId || !redisValueViewerIsActive || (options.shouldApply && !options.shouldApply())) return false; if (loadedValue.data.kind === "zset" && zsetSortDir.value === "desc") { const sortedPage = await collectionSearchController.readPage({ ...loadScope, kind: "zset", sortDirection: "desc" }, 0, "", () => requestId === loadRequestId && (!options.shouldApply || options.shouldApply())); @@ -1828,6 +1831,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre searchLoading.value = false; resetValueSearch(); data.value = loadedValue; + loadedPreviewLimitBytes = loadScope.previewLimitBytes; syncCountdownTtl(loadedValue.ttl); if (notifyParent) emit("loaded", loadedValue); scanCursor.value = redisValueCollectionScanCursor(loadedValue); @@ -2920,6 +2924,14 @@ watch( }, ); +watch( + () => props.previewLimitKiB, + () => { + // Apply the new limit immediately, but never replace an edited value. + if (redisValueViewerIsActive && !hasUnsavedRedisDraft.value) void load(); + }, +); + watch(streamTab, (tab) => { if (tab === "groups" && redisKind.value === "stream") void loadStreamGroups(); }); @@ -2962,7 +2974,7 @@ onMounted(() => { onActivated(() => { redisValueViewerIsActive = true; collectionSearchController.setActive(true); - if (!data.value && !loading.value) void load(); + if (!loading.value && (!data.value || (loadedPreviewLimitBytes !== redisValuePreviewLimitBytes(props.previewLimitKiB) && !hasUnsavedRedisDraft.value))) void load(); startRefreshTimers(); }); onDeactivated(() => { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d6561c4a61..8595408893 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -9418,6 +9418,8 @@ export default { redisScanPageSize: "Redis scan count", redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.", redisScanPageSizeOption: "{count} keys", + redisValuePreviewLimit: "Value preview limit", + redisValuePreviewLimitDescription: "Maximum String and Bitmap value size loaded in the editor. Default: 64 KiB; range: 1–65536 KiB (64 MiB). Larger values can be downloaded in full. Changing this refreshes open details without unsaved edits.", 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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index cfabf8c1fb..2b43f4210f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -8484,6 +8484,8 @@ export default withEnglishFallback({ redisScanPageSize: "Cantidad de escaneo Redis", redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.", redisScanPageSizeOption: "{count} claves", + redisValuePreviewLimit: "Límite de vista previa de valores", + redisValuePreviewLimitDescription: "Tamaño máximo de valores String y Bitmap cargados en el editor. Predeterminado: 64 KiB; rango: 1–65536 KiB (64 MiB). Los valores mayores se pueden descargar completos. Al cambiarlo se actualizan los detalles abiertos sin cambios pendientes.", 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", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index bac57c1b8a..e74f154551 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -8848,6 +8848,8 @@ export default withEnglishFallback({ redisScanPageSize: "Jumlah scan Redis", redisScanPageSizeDescription: "Key yang diminta per halaman SCAN Redis saat menjelajahi key.", redisScanPageSizeOption: "{count} key", + redisValuePreviewLimit: "Batas pratinjau nilai", + redisValuePreviewLimitDescription: "Ukuran maksimum nilai String dan Bitmap yang dimuat di editor. Default: 64 KiB; rentang: 1–65536 KiB (64 MiB). Nilai yang lebih besar dapat diunduh lengkap. Perubahan menyegarkan detail terbuka tanpa edit yang belum disimpan.", redisDatabaseDisplayLimit: "Batas daftar database bilah sisi", redisDatabaseDisplayLimitDescription: 'Jumlah maksimum database yang ditampilkan sekaligus di bilah sisi; sisanya dapat ditampilkan dengan "Muat lebih banyak".', redisDatabaseDisplayLimitOption: "{count} database", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index fd090eda79..bbc0160cad 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -8247,6 +8247,8 @@ export default withEnglishFallback({ redisScanPageSize: "Conteggio scansione Redis", redisScanPageSizeDescription: "Chiavi richieste per pagina SCAN di Redis durante la navigazione delle chiavi.", redisScanPageSizeOption: "{count} chiavi", + redisValuePreviewLimit: "Limite di anteprima dei valori", + redisValuePreviewLimitDescription: "Dimensione massima dei valori String e Bitmap caricati nell’editor. Predefinito: 64 KiB; intervallo: 1–65536 KiB (64 MiB). I valori più grandi possono essere scaricati per intero. La modifica aggiorna i dettagli aperti senza modifiche non salvate.", 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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b11ef42a63..535952ab6f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -8244,6 +8244,8 @@ export default withEnglishFallback({ redisScanPageSize: "Redisスキャンカウント", redisScanPageSizeDescription: "キー参照時のRedis SCANページあたりの要求キー数。", redisScanPageSizeOption: "{count}キー", + redisValuePreviewLimit: "値のプレビュー上限", + redisValuePreviewLimitDescription: "エディターに読み込む String と Bitmap 値の最大サイズ。既定値は 64 KiB、範囲は 1~65536 KiB(64 MiB)です。上限を超える値は全体をダウンロードできます。変更すると、未保存の編集がない詳細が更新されます。", redisDatabaseDisplayLimit: "サイドバーのデータベース一覧の上限", redisDatabaseDisplayLimitDescription: "サイドバーに一度に表示するデータベースの最大数。残りは「もっと読み込む」で表示できます。", redisDatabaseDisplayLimitOption: "{count}個のデータベース", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 29c3745cdd..4e0830b8cc 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -8125,6 +8125,8 @@ export default withEnglishFallback({ redisScanPageSize: "Redis 스캔 개수", redisScanPageSizeDescription: "키를 탐색할 때 Redis SCAN 페이지당 요청하는 키 수입니다.", redisScanPageSizeOption: "키 {count}개", + redisValuePreviewLimit: "값 미리보기 한도", + redisValuePreviewLimitDescription: "편집기에 불러오는 String 및 Bitmap 값의 최대 크기입니다. 기본값: 64 KiB, 범위: 1–65536 KiB(64 MiB). 더 큰 값은 전체를 다운로드할 수 있습니다. 변경하면 저장하지 않은 편집이 없는 열린 상세 정보가 새로 고쳐집니다.", redisDatabaseDisplayLimit: "사이드바 데이터베이스 목록 제한", redisDatabaseDisplayLimitDescription: '사이드바에 한 번에 표시할 최대 데이터베이스 수입니다. 나머지는 "더 불러오기"로 볼 수 있습니다.', redisDatabaseDisplayLimitOption: "데이터베이스 {count}개", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6b80491a48..8122512b81 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -8245,6 +8245,8 @@ 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", + redisValuePreviewLimit: "Limite de prévia de valores", + redisValuePreviewLimitDescription: "Tamanho máximo dos valores String e Bitmap carregados no editor. Padrão: 64 KiB; intervalo: 1–65536 KiB (64 MiB). Valores maiores podem ser baixados integralmente. A alteração atualiza os detalhes abertos sem edições não salvas.", 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", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 4e3544b368..2389bcff37 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -9600,6 +9600,8 @@ export default withEnglishFallback({ redisScanPageSize: "Количество ключей при сканировании Redis", redisScanPageSizeDescription: "Число ключей, запрашиваемых за одну страницу Redis SCAN при просмотре ключей.", redisScanPageSizeOption: "{count} ключей", + redisValuePreviewLimit: "Лимит предпросмотра значений", + redisValuePreviewLimitDescription: "Максимальный размер значений String и Bitmap, загружаемых в редактор. По умолчанию: 64 КиБ; диапазон: 1–65536 КиБ (64 МиБ). Большие значения можно скачать целиком. Изменение обновляет открытые сведения без несохранённых правок.", redisDatabaseDisplayLimit: "Лимит списка баз данных в боковой панели", redisDatabaseDisplayLimitDescription: "Максимальное число баз данных, показываемых сразу в боковой панели; остальные можно показать кнопкой «Загрузить ещё».", redisDatabaseDisplayLimitOption: "{count} баз данных", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 1aa7118371..d903da43bd 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7943,6 +7943,8 @@ 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", + redisValuePreviewLimit: "Değer önizleme sınırı", + redisValuePreviewLimitDescription: "Düzenleyicide yüklenen String ve Bitmap değerlerinin en büyük boyutu. Varsayılan: 64 KiB; aralık: 1–65536 KiB (64 MiB). Daha büyük değerler tamamen indirilebilir. Değişiklik, kaydedilmemiş düzenlemesi olmayan açık ayrıntıları yeniler.", 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ı", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index dbf15daa46..d897cae7b3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -9397,6 +9397,8 @@ export default withEnglishFallback({ redisScanPageSize: "Redis 扫描数量", redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。", redisScanPageSizeOption: "{count} 个 Key", + redisValuePreviewLimit: "值预览上限", + redisValuePreviewLimitDescription: "String 和 Bitmap 值在编辑器中最多加载的大小。默认 64 KiB,可设置为 1–65536 KiB(64 MiB)。超过上限时可下载完整值;修改后无未保存编辑的详情会自动刷新。", redisDatabaseDisplayLimit: "侧边栏数据库列表上限", redisDatabaseDisplayLimitDescription: "侧边栏一次最多展示的数据库数量,超出部分可通过“加载更多”查看。", redisDatabaseDisplayLimitOption: "{count} 个数据库", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 174fe53e01..7de9ab259b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -7587,6 +7587,8 @@ export default withEnglishFallback({ redisScanPageSize: "Redis 掃描數量", redisScanPageSizeDescription: "瀏覽 Redis Key 時每次 SCAN 請求的 Key 數量。", redisScanPageSizeOption: "{count} 個 Key", + redisValuePreviewLimit: "值預覽上限", + redisValuePreviewLimitDescription: "String 和 Bitmap 值在編輯器中最多載入的大小。預設 64 KiB,可設為 1–65536 KiB(64 MiB)。超過上限時可下載完整值;修改後沒有未儲存編輯的詳細資料會自動重新整理。", redisDatabaseDisplayLimit: "側邊欄資料庫清單上限", redisDatabaseDisplayLimitDescription: "側邊欄一次最多顯示的資料庫數量,超出部分可透過「載入更多」查看。", redisDatabaseDisplayLimitOption: "{count} 個資料庫", diff --git a/apps/desktop/src/lib/backend/__tests__/redisExpiryApi.spec.ts b/apps/desktop/src/lib/backend/__tests__/redisExpiryApi.spec.ts index 2bdc0de8c1..fe4eced171 100644 --- a/apps/desktop/src/lib/backend/__tests__/redisExpiryApi.spec.ts +++ b/apps/desktop/src/lib/backend/__tests__/redisExpiryApi.spec.ts @@ -18,6 +18,12 @@ describe("Redis expiry Tauri API", () => { vi.unstubAllGlobals(); }); + it("forwards the configured Redis value preview byte limit to Tauri", async () => { + const { redisGetValue } = await import("@/lib/backend/tauri"); + await redisGetValue("redis-1", 2, "a2V5", 262144); + expect(mocks.invoke).toHaveBeenCalledWith("redis_get_value", { connectionId: "redis-1", db: 2, keyRaw: "a2V5", previewLimitBytes: 262144 }); + }); + it("invokes EXPIREAT with the Unix timestamp", async () => { mocks.invoke.mockResolvedValue(undefined); const { redisSetExpireAt } = await import("@/lib/backend/tauri"); @@ -91,6 +97,15 @@ describe("Redis expiry HTTP API", () => { return { url, body: JSON.parse(String(init.body)) as Record }; } + it("posts the configured preview byte limit and allows older callers to omit it", async () => { + const fetch = stubFetch(); + const { redisGetValue } = await import("@/lib/backend/http"); + await redisGetValue("redis-1", 2, "a2V5", 262144); + expect(lastCall(fetch)).toEqual({ url: "/api/redis/get-value", body: { connectionId: "redis-1", db: 2, keyRaw: "a2V5", previewLimitBytes: 262144 } }); + await redisGetValue("redis-1", 2, "a2V5"); + expect(lastCall(fetch)).toEqual({ url: "/api/redis/get-value", body: { connectionId: "redis-1", db: 2, keyRaw: "a2V5" } }); + }); + it("posts relative TTL, persist, and absolute expiration requests", async () => { const fetchMock = stubFetch(); const { redisSetExpireAt, redisSetTtl } = await import("@/lib/backend/http"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 682b76e6dd..44466029ff 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -3805,8 +3805,8 @@ export async function redisScanValues(connectionId: string, db: number, cursor: }); } -export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise { - return post("/api/redis/get-value", { connectionId, db, keyRaw }); +export async function redisGetValue(connectionId: string, db: number, keyRaw: string, previewLimitBytes?: number): Promise { + return post("/api/redis/get-value", { connectionId, db, keyRaw, previewLimitBytes }); } export async function redisGetRawValue(connectionId: string, db: number, keyRaw: string): Promise { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 7cd03aa983..529f0a3c5c 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -3632,8 +3632,8 @@ export async function redisScanValues(connectionId: string, db: number, cursor: }); } -export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise { - return invoke("redis_get_value", { connectionId, db, keyRaw }); +export async function redisGetValue(connectionId: string, db: number, keyRaw: string, previewLimitBytes?: number): Promise { + return invoke("redis_get_value", { connectionId, db, keyRaw, previewLimitBytes }); } export async function redisGetRawValue(connectionId: string, db: number, keyRaw: string): Promise { diff --git a/apps/desktop/src/lib/redis/__tests__/redisValuePreviewLimit.spec.ts b/apps/desktop/src/lib/redis/__tests__/redisValuePreviewLimit.spec.ts new file mode 100644 index 0000000000..241e7b3fc9 --- /dev/null +++ b/apps/desktop/src/lib/redis/__tests__/redisValuePreviewLimit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRedisValuePreviewLimitKiB, redisValuePreviewLimitBytes } from "../redisValuePreviewLimit"; +import { DEFAULT_EDITOR_SETTINGS, normalizeEditorSettings } from "@/stores/settingsStore"; + +describe("Redis value preview setting", () => { + it("preserves the 64 KiB default for existing settings", () => { + expect(DEFAULT_EDITOR_SETTINGS.redisValuePreviewLimitKiB).toBe(64); + expect(normalizeEditorSettings({}).redisValuePreviewLimitKiB).toBe(64); + expect(redisValuePreviewLimitBytes(undefined)).toBe(65536); + }); + + it.each([undefined, "256", NaN, Infinity, -Infinity])("uses the default for invalid saved values %s", (value) => { + expect(normalizeRedisValuePreviewLimitKiB(value)).toBe(64); + }); + + it.each([ + [0, 1], + [-2, 1], + [256.4, 256], + [256.7, 257], + [65536, 65536], + [100000, 65536], + ])("normalizes %s KiB to %s", (value, expected) => { + expect(normalizeEditorSettings({ redisValuePreviewLimitKiB: value }).redisValuePreviewLimitKiB).toBe(expected); + expect(redisValuePreviewLimitBytes(value)).toBe(expected * 1024); + }); +}); diff --git a/apps/desktop/src/lib/redis/redisValuePreviewLimit.ts b/apps/desktop/src/lib/redis/redisValuePreviewLimit.ts new file mode 100644 index 0000000000..bc21e210e4 --- /dev/null +++ b/apps/desktop/src/lib/redis/redisValuePreviewLimit.ts @@ -0,0 +1,13 @@ +export const REDIS_VALUE_PREVIEW_LIMIT_KIB_DEFAULT = 64; +export const REDIS_VALUE_PREVIEW_LIMIT_KIB_MIN = 1; +export const REDIS_VALUE_PREVIEW_LIMIT_KIB_MAX = 65_536; +export const REDIS_VALUE_PREVIEW_LIMIT_KIB_OPTIONS = [64, 256, 1024, 4096, 16_384, 65_536] as const; + +export function normalizeRedisValuePreviewLimitKiB(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return REDIS_VALUE_PREVIEW_LIMIT_KIB_DEFAULT; + return Math.min(REDIS_VALUE_PREVIEW_LIMIT_KIB_MAX, Math.max(REDIS_VALUE_PREVIEW_LIMIT_KIB_MIN, Math.round(value))); +} + +export function redisValuePreviewLimitBytes(value: unknown): number { + return normalizeRedisValuePreviewLimitKiB(value) * 1024; +} diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 6d9587edbb..5aa0504b18 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -23,6 +23,7 @@ import { needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings, import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort"; import { type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy"; +import { normalizeRedisValuePreviewLimitKiB, REDIS_VALUE_PREVIEW_LIMIT_KIB_DEFAULT } from "@/lib/redis/redisValuePreviewLimit"; 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"; @@ -1185,6 +1186,8 @@ export interface EditorSettings { redisKeyTemplates: string[]; /** Sidebar database-list cap for Redis connections; the rest are revealed via "load more". */ redisDatabaseDisplayLimit: number; + /** Maximum Redis String/Bitmap preview size in KiB. */ + redisValuePreviewLimitKiB: number; exportRowLimitEnabled: boolean; exportRowLimit: number; queryExportKeysetOptimizationEnabled: boolean; @@ -1512,6 +1515,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { csvNullMode: DEFAULT_CSV_NULL_MODE, redisKeyTemplates: [], redisDatabaseDisplayLimit: REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT, + redisValuePreviewLimitKiB: REDIS_VALUE_PREVIEW_LIMIT_KIB_DEFAULT, exportRowLimitEnabled: false, exportRowLimit: 100000, queryExportKeysetOptimizationEnabled: true, @@ -2209,6 +2213,7 @@ export function normalizeEditorSettings(settings: Partial, exist csvQuoteMode: normalizeCsvQuoteMode(settings.csvQuoteMode), csvNullMode: normalizeCsvNullMode(settings.csvNullMode), redisKeyTemplates: normalizeRedisKeyTemplates(settings.redisKeyTemplates), + redisValuePreviewLimitKiB: normalizeRedisValuePreviewLimitKiB(settings.redisValuePreviewLimitKiB), redisDatabaseDisplayLimit: typeof settings.redisDatabaseDisplayLimit === "number" && settings.redisDatabaseDisplayLimit >= REDIS_DATABASE_DISPLAY_LIMIT_MIN && settings.redisDatabaseDisplayLimit <= REDIS_DATABASE_DISPLAY_LIMIT_MAX ? Math.round(settings.redisDatabaseDisplayLimit) @@ -3063,6 +3068,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.applyColumnFormattersOnExport !== undefined) editorSettings.value.applyColumnFormattersOnExport = partial.applyColumnFormattersOnExport === true; if (partial.csvQuoteMode !== undefined) editorSettings.value.csvQuoteMode = normalizeCsvQuoteMode(partial.csvQuoteMode); if (partial.csvNullMode !== undefined) editorSettings.value.csvNullMode = normalizeCsvNullMode(partial.csvNullMode); + if (partial.redisValuePreviewLimitKiB !== undefined) editorSettings.value.redisValuePreviewLimitKiB = normalizeRedisValuePreviewLimitKiB(partial.redisValuePreviewLimitKiB); 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; diff --git a/crates/dbx-core/src/query/redis_ops.rs b/crates/dbx-core/src/query/redis_ops.rs index bb3ef77797..8662769920 100644 --- a/crates/dbx-core/src/query/redis_ops.rs +++ b/crates/dbx-core/src/query/redis_ops.rs @@ -136,6 +136,18 @@ pub async fn redis_get_value_in_db_core( db: u32, key_raw: &str, ) -> Result { + redis_get_value_in_db_with_preview_limit_core(state, connection_id, db, key_raw, None).await +} + +pub async fn redis_get_value_in_db_with_preview_limit_core( + state: &AppState, + connection_id: &str, + db: u32, + key_raw: &str, + preview_limit_bytes: Option, +) -> Result { + let preview_limit_bytes = preview_limit_bytes.unwrap_or(redis_driver::STRING_PREVIEW_MAX_BYTES); + redis_driver::validate_value_preview_limit(preview_limit_bytes)?; ensure_redis_pool(state, connection_id).await?; let pool = state.pool_handle(connection_id).await.ok_or("Connection not found")?; match &pool { @@ -145,15 +157,22 @@ pub async fn redis_get_value_in_db_core( RedisConnection::Direct(con) => { let mut con = con.lock().await; redis_driver::select_db(&mut *con, db).await?; - redis_driver::get_value(&mut *con, &key).await + redis_driver::get_value_scoped_with_preview_limit( + &mut *con, + &key, + &redis_driver::RedisOperationScope { connection_id: connection_id.to_string(), db }, + preview_limit_bytes, + ) + .await } RedisConnection::Cluster(cluster) => { redis_driver::ensure_cluster_database(cluster, db).await?; let mut con = redis_driver::cluster_key_connection_in_db(cluster, &key, db).await?; - redis_driver::get_value_scoped( + redis_driver::get_value_scoped_with_preview_limit( &mut con, &key, &redis_driver::RedisOperationScope { connection_id: connection_id.to_string(), db }, + preview_limit_bytes, ) .await } diff --git a/crates/dbx-driver-redis/src/lib.rs b/crates/dbx-driver-redis/src/lib.rs index 4fd851ba7e..f16b839611 100644 --- a/crates/dbx-driver-redis/src/lib.rs +++ b/crates/dbx-driver-redis/src/lib.rs @@ -19,7 +19,15 @@ use tokio::sync::{Mutex, MutexGuard}; const STREAM_ENTRY_PAGE_SIZE: usize = 50; const STREAM_PENDING_PAGE_SIZE: usize = 100; const COLLECTION_PAGE_SIZE: usize = 200; -const STRING_PREVIEW_MAX_BYTES: usize = 64 * 1024; +pub const STRING_PREVIEW_MAX_BYTES: usize = 64 * 1024; +pub const VALUE_PREVIEW_MAX_BYTES: usize = 64 * 1024 * 1024; + +pub fn validate_value_preview_limit(limit: usize) -> Result<(), String> { + if !(1024..=VALUE_PREVIEW_MAX_BYTES).contains(&limit) { + return Err("Redis value preview limit must be between 1024 and 67108864 bytes".to_string()); + } + Ok(()) +} /// A collection page is capped by payload size as well as item count. /// /// `COLLECTION_PAGE_SIZE` alone bounds a page only when the members are small. A hash, list, set @@ -3239,6 +3247,19 @@ pub async fn get_value_scoped(con: &mut C, key: &[u8], scope: &RedisOperation where C: ConnectionLike + Send + Sync + Unpin, { + get_value_scoped_with_preview_limit(con, key, scope, STRING_PREVIEW_MAX_BYTES).await +} + +pub async fn get_value_scoped_with_preview_limit( + con: &mut C, + key: &[u8], + scope: &RedisOperationScope, + preview_limit_bytes: usize, +) -> Result +where + C: ConnectionLike + Send + Sync + Unpin, +{ + validate_value_preview_limit(preview_limit_bytes)?; let redis_type: String = redis::cmd("TYPE").arg(key).query_async(con).await.map_err(|e| e.to_string())?; // For Streams, fetch metadata and the initial page together after TYPE. @@ -3257,15 +3278,15 @@ where let v: RedisRawValue = redis::cmd("GETRANGE") .arg(key) .arg(0) - .arg(STRING_PREVIEW_MAX_BYTES as i64) + .arg(preview_limit_bytes as i64) .query_async(con) .await .map_err(|e| e.to_string())?; let mut bytes = redis_value_to_bytes(v).ok_or_else(|| "Redis string payload is not byte-addressable".to_string())?; - let truncated = bytes.len() > STRING_PREVIEW_MAX_BYTES; + let truncated = bytes.len() > preview_limit_bytes; if truncated { - bytes.truncate(STRING_PREVIEW_MAX_BYTES); + bytes.truncate(preview_limit_bytes); } let total_bytes = if truncated { redis::cmd("STRLEN").arg(key).query_async(con).await.ok() @@ -3319,9 +3340,9 @@ where // 且 GET 返回的字节与 Redis 位序一致,可直接按字符串预览。 let raw: RedisRawValue = redis::cmd("GET").arg(key).query_async(con).await.map_err(|e| e.to_string())?; let mut bytes = redis_value_to_bytes(raw).unwrap_or_default(); - let truncated = bytes.len() > STRING_PREVIEW_MAX_BYTES; + let truncated = bytes.len() > preview_limit_bytes; if truncated { - bytes.truncate(STRING_PREVIEW_MAX_BYTES); + bytes.truncate(preview_limit_bytes); } // 截断后拿不到准确长度(kvrocks 位图没有可用的 STRLEN),置空交给前端按未知处理 let total_bytes = (!truncated).then_some(bytes.len() as u64); @@ -3347,7 +3368,7 @@ where /// Read the complete raw payload for a Redis string-like key. /// -/// The normal value path deliberately caps previews at 64 KiB. Downloads must +/// The normal value path caps previews at the configured size. Downloads must /// bypass that cap while preserving the exact bytes, including non-UTF-8 data. pub async fn get_raw_value(con: &mut C, key: &[u8]) -> Result where @@ -7046,6 +7067,94 @@ mod tests { assert_eq!(con.command_count("STRLEN"), 1); } + #[tokio::test] + async fn value_preview_limit_controls_the_range_and_exact_boundary() { + for limit in [1024, 256 * 1024, super::VALUE_PREVIEW_MAX_BYTES] { + // Large limit uses a small payload to avoid allocating 64 MiB in a unit test. + let size = limit.min(256 * 1024); + let mut con = FakeRedisConnection::new(vec![ + bulk("string"), + RedisRawValue::Int(-1), + RedisRawValue::BulkString(vec![b'x'; size]), + ]); + let value = super::get_value_scoped_with_preview_limit( + &mut con, + b"large", + &super::RedisOperationScope::default(), + limit, + ) + .await + .unwrap(); + let RedisValueData::String { content, total_bytes, truncated } = value.data else { + panic!("expected string") + }; + assert!(!truncated); + assert_eq!(total_bytes, Some(size as u64)); + assert_eq!(content.raw_base64, base64::engine::general_purpose::STANDARD.encode(vec![b'x'; size])); + assert!(con.commands.iter().any(|command| command.contains(&format!("\r\n{limit}\r\n")))); + assert_eq!(con.command_count("GET"), 0); + } + let limit = 128 * 1024; + let mut con = FakeRedisConnection::new(vec![ + bulk("string"), + RedisRawValue::Int(-1), + RedisRawValue::BulkString(vec![0xff; limit + 1]), + RedisRawValue::Int(200_000), + ]); + let value = super::get_value_scoped_with_preview_limit( + &mut con, + b"binary", + &super::RedisOperationScope::default(), + limit, + ) + .await + .unwrap(); + let RedisValueData::String { content, total_bytes, truncated } = value.data else { panic!("expected string") }; + assert!(truncated); + assert_eq!(total_bytes, Some(200_000)); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(content.raw_base64).unwrap().len(), limit); + } + + #[tokio::test] + async fn value_preview_limit_rejects_invalid_sizes_before_redis_io() { + for limit in [0, 1023, super::VALUE_PREVIEW_MAX_BYTES + 1, usize::MAX] { + let mut con = FakeRedisConnection::new(vec![]); + assert!(super::get_value_scoped_with_preview_limit( + &mut con, + b"key", + &super::RedisOperationScope::default(), + limit, + ) + .await + .is_err()); + assert!(con.commands.is_empty()); + } + } + + #[tokio::test] + async fn bitmap_preview_uses_the_configured_limit() { + let mut con = FakeRedisConnection::new(vec![ + bulk("bitmap"), + RedisRawValue::Int(-1), + RedisRawValue::BulkString(vec![0x80; 4097]), + RedisRawValue::Int(4097), + ]); + let value = super::get_value_scoped_with_preview_limit( + &mut con, + b"bitmap", + &super::RedisOperationScope::default(), + 4096, + ) + .await + .unwrap(); + let RedisValueData::Bitmap { content, total_bytes, truncated, .. } = value.data else { + panic!("expected bitmap") + }; + assert!(truncated); + assert_eq!(total_bytes, None); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(content.raw_base64).unwrap().len(), 4096); + } + #[test] fn collection_pages_stop_at_the_byte_budget_without_truncating_a_member() { fn hash_items(count: usize, value_bytes: usize) -> Vec { diff --git a/crates/dbx-driver-redis/tests/value_preview_limit.rs b/crates/dbx-driver-redis/tests/value_preview_limit.rs new file mode 100644 index 0000000000..df751caff7 --- /dev/null +++ b/crates/dbx-driver-redis/tests/value_preview_limit.rs @@ -0,0 +1,49 @@ +use base64::Engine; +use dbx_driver_redis::{ + connect, get_raw_value, get_value, get_value_scoped_with_preview_limit, RedisOperationScope, RedisValueData, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[tokio::test] +#[ignore = "requires a disposable server via DBX_TEST_REDIS_URL"] +async fn configured_preview_limit_reads_full_large_values_and_preserves_binary_downloads() { + let url = std::env::var("DBX_TEST_REDIS_URL").expect("DBX_TEST_REDIS_URL"); + let mut con = connect(&url, Duration::from_secs(5)).await.unwrap(); + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let prefix = format!("dbx11326-{}-{nonce}", std::process::id()); + let key = prefix.as_bytes(); + let scope = RedisOperationScope::default(); + for (name, bytes) in [ + ("text", vec![b'x'; 96 * 1024]), + ("utf8", "中文🙂".repeat(10_000).into_bytes()), + ("binary", (0..128 * 1024).map(|i| (i % 256) as u8).collect()), + ] { + redis::cmd("SET").arg(key).arg(&bytes).arg("EX").arg(300).query_async::<()>(&mut con).await.unwrap(); + let default_value = get_value(&mut con, key).await.unwrap(); + let RedisValueData::String { content, truncated, total_bytes } = default_value.data else { + panic!("expected string") + }; + assert!(truncated); + assert_eq!(total_bytes, Some(bytes.len() as u64)); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(content.raw_base64).unwrap(), bytes[..65536]); + for limit in [256 * 1024, bytes.len()] { + let value = get_value_scoped_with_preview_limit(&mut con, key, &scope, limit).await.unwrap(); + let RedisValueData::String { content, truncated, total_bytes } = value.data else { + panic!("expected string") + }; + assert!(!truncated); + assert_eq!(total_bytes, Some(bytes.len() as u64)); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(content.raw_base64).unwrap(), bytes); + } + let small = get_value_scoped_with_preview_limit(&mut con, key, &scope, 1024).await.unwrap(); + let RedisValueData::String { truncated, .. } = small.data else { panic!("expected string") }; + assert!(truncated); + let raw = get_raw_value(&mut con, key).await.unwrap(); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(raw.raw_base64).unwrap(), bytes); + println!( + "PASS {name}: 64 KiB truncates, 256 KiB and exact boundary complete, full download preserves {} bytes", + bytes.len() + ); + } + redis::cmd("DEL").arg(key).query_async::<()>(&mut con).await.unwrap(); +} diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index f3341bbe3f..4612fd925e 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -77,6 +77,15 @@ pub struct RedisKeyRequest { pub key_raw: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisValueRequest { + pub connection_id: String, + pub db: u32, + pub key_raw: String, + pub preview_limit_bytes: Option, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RedisRenameKeyRequest { @@ -404,11 +413,17 @@ pub async fn scan_values( pub async fn get_value( State(state): State>, - Json(req): Json, + Json(req): Json, ) -> Result, AppError> { - let result = dbx_core::redis_ops::redis_get_value_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw) - .await - .map_err(AppError::from)?; + let result = dbx_core::redis_ops::redis_get_value_in_db_with_preview_limit_core( + &state.app, + &req.connection_id, + req.db, + &req.key_raw, + req.preview_limit_bytes, + ) + .await + .map_err(AppError::from)?; Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } diff --git a/docs/testing/assets/redis-value-preview-11326/persisted-full-value.png b/docs/testing/assets/redis-value-preview-11326/persisted-full-value.png new file mode 100644 index 0000000000..852f67f452 Binary files /dev/null and b/docs/testing/assets/redis-value-preview-11326/persisted-full-value.png differ diff --git a/docs/testing/assets/redis-value-preview-11326/setting-256-kib.png b/docs/testing/assets/redis-value-preview-11326/setting-256-kib.png new file mode 100644 index 0000000000..3b0827d989 Binary files /dev/null and b/docs/testing/assets/redis-value-preview-11326/setting-256-kib.png differ diff --git a/docs/testing/redis-value-preview-11326.md b/docs/testing/redis-value-preview-11326.md new file mode 100644 index 0000000000..2af0e54801 --- /dev/null +++ b/docs/testing/redis-value-preview-11326.md @@ -0,0 +1,38 @@ +# Configurable Redis value preview limit (#11326) + +Verified on macOS 26.3 with the local DBX Web build on 2026-10-14. + +The new setting is at the bottom of **Redis connection → Advanced settings**. It is stored globally and applies to every Redis connection. It defaults to 64 KiB and +accepts 1–65536 KiB. The selected value is passed to the Redis String/Bitmap +preview request; complete downloads still use the raw-value endpoint. + +![Redis preview setting set to 256 KiB](assets/redis-value-preview-11326/setting-256-kib.png) + +## Manual Web verification + +A local Redis 7.4.9 server was seeded with these values: + +- a 101,084 byte JSON document containing 850 items and a final `END-11326` marker; +- a 131,072 byte binary value containing every byte from 0 through 255 repeatedly; +- a 5 byte value; +- a 65,536 byte boundary value. + +With the default 64 KiB setting, the 101,084 byte JSON appeared as a read-only +preview with the message that only 64 KiB was loaded. After changing the setting +to 256 KiB and applying it, the open detail automatically refreshed: the full +JSON became editable, the truncation notice disappeared, and the final items +and `END-11326` marker were visible. Reloading the Web app preserved 256 KiB and +opening the same key again kept the full editable value. + +![Full value after reload with 256 KiB persisted](assets/redis-value-preview-11326/persisted-full-value.png) + +## Automated verification + +- 187 `dbx-driver-redis` unit tests passed. +- The ignored integration test against the local Redis server passed for text, + UTF-8 Chinese content, and binary data: 64 KiB truncation, 256 KiB complete + reads, exact-boundary reads, and complete raw downloads all preserved bytes. +- 470 frontend tests passed across 23 focused suites, including Redis viewer behavior, settings apply and reset, settings transfer validation, and HTTP/Tauri argument forwarding. +- Frontend formatting, lint, typecheck, and the Web backend build passed. +- Invalid preview sizes below 1 KiB and above 64 MiB were rejected before Redis + I/O. diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index ecead99083..935953b333 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -83,8 +83,16 @@ pub async fn redis_get_value( connection_id: String, db: u32, key_raw: String, + preview_limit_bytes: Option, ) -> Result { - dbx_core::redis_ops::redis_get_value_in_db_core(&state, &connection_id, db, &key_raw).await + dbx_core::redis_ops::redis_get_value_in_db_with_preview_limit_core( + &state, + &connection_id, + db, + &key_raw, + preview_limit_bytes, + ) + .await } #[tauri::command]