diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index c829676463..e9ebd962d6 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -3,7 +3,7 @@ import { computed, ref, nextTick, onBeforeUnmount, onMounted } from "vue"; import { useI18n } from "vue-i18n"; import { onClickOutside } from "@vueuse/core"; import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller"; -import { Copy, Eye, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue"; +import { Copy, Eye, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown, Search, Clock } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -19,6 +19,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redis/redisJsonHighlighter"; import { copyToClipboard } from "@/lib/common/clipboard"; import { formatTtl } from "@/lib/common/ttlFormat"; +import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh"; import { canRenderRedisValueFormat, canEditRedisMemberDetail, @@ -99,6 +100,62 @@ const memberValueView = ref(readPreferredRedisValueFormat()); const redisJsonView = ref<"raw" | "tree">("raw"); const redisJsonWordWrap = ref(readRedisJsonWordWrap()); const redisJsonHighlighter = ref(); + +// Auto-refresh +const autoRefreshEnabled = ref(false); +const countdownTtl = ref(0); +let autoRefreshTimer: ReturnType | null = null; + +function toggleAutoRefresh() { + autoRefreshEnabled.value = !autoRefreshEnabled.value; + if (autoRefreshEnabled.value) { + startAutoRefresh(); + } else { + stopAutoRefresh(); + } +} + +function startAutoRefresh() { + stopAutoRefresh(); + if (data.value && data.value.ttl > 0) { + countdownTtl.value = data.value.ttl; + } + autoRefreshTimer = setInterval(() => { + const action = computeAutoRefreshTick(autoRefreshEnabled.value, countdownTtl.value, loading.value); + if (action.type === "decrement") { + countdownTtl.value--; + return; + } + if (action.type === "refresh") { + load() + .then(() => { + if (!autoRefreshEnabled.value) return; + if (data.value && !shouldStopAutoRefresh(data.value.ttl)) { + countdownTtl.value = data.value.ttl; + } else { + // Key expired or has no TTL — stop auto-refresh to avoid infinite loop + stopAutoRefresh(); + autoRefreshEnabled.value = false; + } + }) + .catch(() => { + // Network / connection error — stop auto-refresh to avoid tight retry loop + if (autoRefreshEnabled.value) { + stopAutoRefresh(); + autoRefreshEnabled.value = false; + } + }); + } + }, 1000); +} + +function stopAutoRefresh() { + if (autoRefreshTimer !== null) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = null; + } +} + const hashSortBy = ref<"field" | "value" | null>(null); const hashSortDir = ref<"asc" | "desc">("asc"); const hashSearchQuery = ref(""); @@ -432,6 +489,10 @@ async function load(options: { selectDefaultMember?: boolean } = {}) { try { const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw); data.value = loadedValue; + // Reset auto-refresh countdown if active + if (autoRefreshEnabled.value) { + countdownTtl.value = shouldStopAutoRefresh(loadedValue.ttl) ? 0 : loadedValue.ttl; + } emit("loaded", loadedValue); scanCursor.value = redisValueCollectionScanCursor(loadedValue); collectionItems.value = redisValueCollectionItems(loadedValue); @@ -1064,6 +1125,7 @@ onMounted(() => { }); }); onBeforeUnmount(() => { + stopAutoRefresh(); stopResizeMemberSheet(); stopResizeHashColumns(); stopResizeZsetColumns(); @@ -1082,7 +1144,7 @@ onBeforeUnmount(() => {
{{ formatValue(data.key_display) }} - + @@ -1092,13 +1154,16 @@ onBeforeUnmount(() => { {{ data.redis_type }} {{ t("redis.columnSize") }}: {{ metadataSizeLabel }}
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b1eed51c6f..ba6cc766d1 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1942,6 +1942,7 @@ export default { loadedMembers: "{loaded} of {total} members loaded", entries: "{count} entries", noExpiry: "no expiry", + autoRefresh: "Auto-refresh countdown", ttlDay: "{count}d", columnType: "Type", columnKey: "Key", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b9cd17b0a5..98d8dea7fd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1874,6 +1874,7 @@ export default withEnglishFallback({ loadedMembers: "{loaded} de {total} miembros cargados", entries: "{count} entradas", noExpiry: "sin expiración", + autoRefresh: "Actualización automática", ttlDay: "{count}d", columnType: "Tipo", columnKey: "Clave", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ddafb0720f..958c5fa4df 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1872,6 +1872,7 @@ export default withEnglishFallback({ loadedMembers: "{loaded} di {total} membri caricati", entries: "{count} voci", noExpiry: "nessuna scadenza", + autoRefresh: "Aggiornamento automatico", ttlDay: "{count}g", columnType: "Tipo", columnKey: "Chiave", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index c4402fa1f1..531eebe7d5 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1872,6 +1872,7 @@ export default withEnglishFallback({ loadedMembers: "{loaded}/{total}メンバー読み込み完了", entries: "{count}エントリ", noExpiry: "期限なし", + autoRefresh: "自動更新(カウントダウン)", ttlDay: "{count}日", columnType: "型", columnKey: "キー", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 466850a35b..ad85bf443b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1873,6 +1873,7 @@ export default withEnglishFallback({ loadedMembers: "{loaded} de {total} membros carregados", entries: "{count} entradas", noExpiry: "sem expiração", + autoRefresh: "Atualização automática", ttlDay: "{count}d", columnType: "Tipo", columnKey: "Chave", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d35d0ea775..efbbda8da6 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1942,6 +1942,7 @@ export default withEnglishFallback({ loadedMembers: "已加载 {loaded} / 共 {total} 个成员", entries: "{count} 条记录", noExpiry: "永不过期", + autoRefresh: "自动刷新(倒计时)", ttlDay: "{count}天", columnType: "类型", columnKey: "键", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 76a63a2736..9e4b5d2f5f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1775,6 +1775,7 @@ export default withEnglishFallback({ loadedMembers: "已載入 {loaded} / 共 {total} 個成員", entries: "{count} 筆項目", noExpiry: "永不過期", + autoRefresh: "自動刷新(倒計時)", ttlDay: "{count}天", columnType: "類型", columnKey: "鍵", diff --git a/apps/desktop/src/lib/__tests__/redis/redisAutoRefresh.spec.ts b/apps/desktop/src/lib/__tests__/redis/redisAutoRefresh.spec.ts new file mode 100644 index 0000000000..628dce439c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/redis/redisAutoRefresh.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh"; + +describe("computeAutoRefreshTick", () => { + it("returns idle when auto-refresh is disabled", () => { + expect(computeAutoRefreshTick(false, 10, false)).toEqual({ type: "idle" }); + // countdown value and loading flag don't matter when disabled + expect(computeAutoRefreshTick(false, 0, false)).toEqual({ type: "idle" }); + expect(computeAutoRefreshTick(false, 5, true)).toEqual({ type: "idle" }); + }); + + it("returns decrement when countdown is above zero", () => { + expect(computeAutoRefreshTick(true, 10, false)).toEqual({ type: "decrement" }); + expect(computeAutoRefreshTick(true, 1, false)).toEqual({ type: "decrement" }); + // decrement even when loading — countdown should keep ticking + expect(computeAutoRefreshTick(true, 3, true)).toEqual({ type: "decrement" }); + }); + + it("returns refresh when countdown reaches zero and not loading", () => { + expect(computeAutoRefreshTick(true, 0, false)).toEqual({ type: "refresh" }); + expect(computeAutoRefreshTick(true, -1, false)).toEqual({ type: "refresh" }); + }); + + it("returns idle when countdown is zero but a load is already in flight", () => { + // This prevents concurrent load() calls + expect(computeAutoRefreshTick(true, 0, true)).toEqual({ type: "idle" }); + expect(computeAutoRefreshTick(true, -1, true)).toEqual({ type: "idle" }); + }); +}); + +describe("shouldStopAutoRefresh", () => { + it("returns true when TTL is zero (key expired)", () => { + expect(shouldStopAutoRefresh(0)).toBe(true); + }); + + it("returns true when TTL is negative (no expiry or key deleted)", () => { + expect(shouldStopAutoRefresh(-1)).toBe(true); + expect(shouldStopAutoRefresh(-2)).toBe(true); + }); + + it("returns false when TTL is positive (key still alive)", () => { + expect(shouldStopAutoRefresh(1)).toBe(false); + expect(shouldStopAutoRefresh(3600)).toBe(false); + }); +}); + +describe("computeDisplayTtl", () => { + it("returns server TTL when auto-refresh is disabled", () => { + expect(computeDisplayTtl(false, 3, 10)).toBe(10); + }); + + it("returns server TTL when countdown has not started (zero)", () => { + // countdownTtl starts at 0 before first tick; show server TTL + expect(computeDisplayTtl(true, 0, 10)).toBe(10); + }); + + it("returns live countdown when auto-refresh is active and counting", () => { + expect(computeDisplayTtl(true, 5, 10)).toBe(5); + expect(computeDisplayTtl(true, 1, 10)).toBe(1); + }); + + it("returns server TTL when countdown would go below zero", () => { + // Edge case: countdownTtl shouldn't be negative in practice, + // but if it is, fall back to server TTL + expect(computeDisplayTtl(true, -1, 10)).toBe(10); + }); +}); diff --git a/apps/desktop/src/lib/redis/redisAutoRefresh.ts b/apps/desktop/src/lib/redis/redisAutoRefresh.ts new file mode 100644 index 0000000000..683a19f201 --- /dev/null +++ b/apps/desktop/src/lib/redis/redisAutoRefresh.ts @@ -0,0 +1,47 @@ +/** + * Pure decision functions for Redis key auto-refresh countdown. + * + * Extracted from RedisValueViewer.vue to enable unit testing of the + * state-machine logic without mounting a Vue component. + */ + +/** Action computed after evaluating one auto-refresh tick. */ +export type AutoRefreshTickAction = { type: "idle" } | { type: "decrement" } | { type: "refresh" }; + +/** + * Evaluate one tick of the auto-refresh interval. + * + * @param enabled Whether auto-refresh is currently toggled on. + * @param countdownTtl Current countdown value in seconds. + * @param isLoading Whether a data load is already in flight. + * @returns The action the caller should take this tick. + */ +export function computeAutoRefreshTick(enabled: boolean, countdownTtl: number, isLoading: boolean): AutoRefreshTickAction { + if (!enabled) return { type: "idle" }; + if (countdownTtl > 0) return { type: "decrement" }; + if (countdownTtl <= 0 && !isLoading) return { type: "refresh" }; + return { type: "idle" }; +} + +/** + * Determine whether auto-refresh should be automatically disabled + * after a server load completes (e.g. key expired or deleted). + * + * @param ttl The TTL value returned by the server (seconds, -1 = no expiry). + */ +export function shouldStopAutoRefresh(ttl: number): boolean { + return ttl <= 0; +} + +/** + * Compute the TTL value that should be displayed in the badge. + * + * When auto-refresh is active and countdown is running, the live + * countdown value is shown. Otherwise the last known server TTL is used. + */ +export function computeDisplayTtl(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number { + if (autoRefreshEnabled && countdownTtl > 0) { + return countdownTtl; + } + return serverTtl; +}