diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index 2e8570b9c..637a00c7a 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -170,6 +170,12 @@ const stringValueView = ref(readPreferredRedisValueFormat()); const memberValueView = ref(readPreferredRedisValueFormat()); const redisJsonWordWrap = ref(readRedisJsonWordWrap()); const redisJsonHighlighter = ref(); +const showHashFieldTtlDialog = ref(false); +const editingHashField = ref(null); +const savingHashFieldTtl = ref(false); +const hashFieldTtlMode = ref("none"); +const hashFieldTtlInput = ref(""); +const hashFieldExpireAt = shallowRef(null); // Decompressed view state. Decompression yields to the event loop so the // loading state paints before the synchronous bounded inflate runs; the result @@ -497,11 +503,15 @@ const hasRetainedMemberDraft = computed(() => { } return memberEditValue.value !== original; }); -const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null); +const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value); const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0); const collectionTotal = computed(() => (data.value ? redisValueCollectionTotal(data.value) : null)); +const hashFieldTtlSupported = computed(() => { + if (redisKind.value !== "hash") return false; + return (collectionItems.value as RedisHashItem[]).some((item) => item.field_ttl !== undefined); +}); const hashGridStyle = computed(() => ({ - gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`, + gridTemplateColumns: hashFieldTtlSupported.value ? `${hashFieldWidth.value}px minmax(12rem, 1fr) minmax(7rem, 9rem) 84px` : `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`, })); const zsetGridStyle = computed(() => ({ gridTemplateColumns: `60px ${zsetScoreWidth.value}px minmax(0, 1fr) 104px`, @@ -643,7 +653,7 @@ let zsetResizeStartWidth = 0; function shouldPauseAutoValueRefresh(): boolean { const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0; const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize; - return showMemberDetail.value || editingZsetMemberKey.value !== null || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage; + return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage; } type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string }; @@ -1463,13 +1473,20 @@ function generateInsertStatements(): string | null { break; } case "hash": { - const pairs = (collectionItems.value as RedisHashItem[]).map((item) => { + const hashItems = collectionItems.value as RedisHashItem[]; + const pairs = hashItems.map((item) => { const field = blobWriteText(item.field); const value = blobWriteText(item.value); return field == null || value == null ? null : `${escapeRedisArg(field)} ${escapeRedisArg(value)}`; }); if (pairs.some((item) => item == null)) return null; commands.push(`HSET ${escapeRedisArg(key)} ${(pairs as string[]).join(" ")}`); + for (const item of hashItems) { + const field = blobWriteText(item.field); + if (field != null && item.field_ttl !== undefined && item.field_ttl > 0) { + commands.push(`HEXPIRE ${escapeRedisArg(key)} ${item.field_ttl} FIELDS 1 ${escapeRedisArg(field)}`); + } + } break; } case "stream": { @@ -1992,6 +2009,90 @@ function cancelEditTtl() { } // Hash +function hashFieldItem(field: string | null): RedisHashItem | null { + if (!field || redisKind.value !== "hash") return null; + return (collectionItems.value as RedisHashItem[]).find((item) => redisBlobText(item.field) === field) ?? null; +} + +function hashFieldTtlLabel(item: RedisHashItem): string { + if (item.field_ttl === undefined) return "-"; + if (item.field_ttl === -1) return t("redis.noExpiry"); + return formatTtl(item.field_ttl, t) ?? "-"; +} + +function currentHashFieldTtl(): number { + return hashFieldItem(editingHashField.value)?.field_ttl ?? -1; +} + +function startEditHashFieldTtl(field: string | null) { + if (!field || savingHashFieldTtl.value || hashFieldItem(field)?.field_ttl === undefined) return; + const ttl = hashFieldItem(field)?.field_ttl ?? -1; + editingHashField.value = field; + hashFieldTtlMode.value = redisExpiryModeForTtl(ttl); + hashFieldTtlInput.value = ttl > 0 ? String(ttl) : ""; + hashFieldExpireAt.value = null; + showHashFieldTtlDialog.value = true; +} + +watch(hashFieldTtlMode, (mode, previousMode) => { + const ttl = currentHashFieldTtl(); + if (mode === "at" && previousMode !== "at" && ttl > 0) { + hashFieldExpireAt.value = unixSecondsToCalendarDateTime(Math.ceil(Date.now() / 1_000) + ttl); + } +}); + +function cancelEditHashFieldTtl(force = false) { + if (savingHashFieldTtl.value && !force) return; + showHashFieldTtlDialog.value = false; + editingHashField.value = null; + hashFieldTtlInput.value = ""; + hashFieldExpireAt.value = null; +} + +function handleHashFieldTtlOpenChange(open: boolean) { + if (open) { + showHashFieldTtlDialog.value = true; + } else { + cancelEditHashFieldTtl(); + } +} + +async function reloadHashPreservingSearch() { + const query = activeHashSearchQuery.value || hashSearchQuery.value.trim(); + await load({ selectDefaultMember: false }); + if (query) { + hashSearchQuery.value = query; + await onHashSearch(); + } +} + +async function saveHashFieldTtl() { + if (savingHashFieldTtl.value || !editingHashField.value) return; + const validation = validateRedisExpiry(hashFieldTtlMode.value, hashFieldTtlInput.value, hashFieldExpireAt.value); + if (!validation.valid) { + toast(expiryValidationMessage(validation.reason), 3000); + return; + } + + savingHashFieldTtl.value = true; + const field = editingHashField.value; + try { + if (validation.policy.mode === "none") { + await api.redisHashFieldSetTtl(props.connectionId, props.db, props.keyRaw, field, -1); + } else if (validation.policy.mode === "ttl") { + await api.redisHashFieldSetTtl(props.connectionId, props.db, props.keyRaw, field, validation.policy.ttl); + } else { + await api.redisHashFieldSetExpireAt(props.connectionId, props.db, props.keyRaw, field, validation.policy.expireAt); + } + cancelEditHashFieldTtl(true); + await reloadHashPreservingSearch(); + } catch (error) { + toast(errorMessage(error), 3000); + } finally { + savingHashFieldTtl.value = false; + } +} + async function hashSet() { if (!newField.value.trim()) { toast(t("redis.fieldRequired"), 3000); @@ -2584,6 +2685,7 @@ defineExpose({ focusSearch }); +
{{ t("redis.columnTTL") }}
@@ -2597,6 +2699,19 @@ defineExpose({ focusSearch }); >
{{ formatValue(row.value.field) }}
{{ formatValue(row.value.value) }}
+
+ + - +
+ + + + +