From 35040ef0c95555ee5501a1343a01cd2b52ff420a Mon Sep 17 00:00:00 2001 From: Senior Frontend Engineer Date: Tue, 31 Mar 2026 08:47:14 +0000 Subject: [PATCH] feat(desktop): add mutation review guardrails Co-Authored-By: Paperclip --- README.md | 14 +- apps/desktop/src/App.tsx | 824 +++++++++++++++--- .../src/lib/mutation-guardrails.test.ts | 93 ++ apps/desktop/src/lib/mutation-guardrails.ts | 178 ++++ docs/frontend-workspace-baseline.md | 19 +- docs/qa/acceptance-matrix.md | 44 +- ...6-03-31-hac-31-safe-mutation-guardrails.md | 37 + 7 files changed, 1084 insertions(+), 125 deletions(-) create mode 100644 apps/desktop/src/lib/mutation-guardrails.test.ts create mode 100644 apps/desktop/src/lib/mutation-guardrails.ts create mode 100644 plans/2026-03-31-hac-31-safe-mutation-guardrails.md diff --git a/README.md b/README.md index 98b9296..929ff11 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ pnpm run desktop:dev pnpm run desktop:build pnpm run desktop:tauri:dev pnpm run desktop:tauri:build +pnpm run desktop:tauri:build:all ``` 说明: @@ -45,9 +46,10 @@ pnpm run desktop:tauri:build - 仓库通过 `.npmrc` 固定安装 devDependencies,避免宿主机的 `NODE_ENV=production` 破坏桌面构建链路。 - `cargo test -p redis-core` 默认验证共享后端核心,包括 mock Redis 握手与命令执行测试,避免在缺少桌面系统库时阻断后端迭代。 - `pnpm --filter @redis-gui/desktop test` 运行桌面前端的最小单测基线;当前覆盖共享错误映射层,避免后续功能迭代把后端错误码重新退回成零散文案拼接。 -- `pnpm run desktop:dev` 启动浏览器态工作台壳层,用于检查布局、交互和 demo fallback;此模式下没有 Tauri 命令桥。 +- `pnpm run desktop:dev` 启动浏览器态工作台壳层,用于检查布局、交互和 demo fallback;此模式下没有 Tauri 命令桥,但会在当前会话内保留 mock string 保存和 TTL 调整结果,便于做壳层级交互验收。 - `pnpm run desktop:tauri:dev` 启动带 Tauri 后端桥的桌面工作台;连接测试和命令执行会调用现有 `test_redis_connection` 与 `execute_redis_command` 命令,因此需要完整的 Tauri Linux 运行依赖。 -- `pnpm run desktop:tauri:build` 会先执行桌面前端 build,再进入 Tauri/Rust 的真实打包流程;当前 Linux `.deb` 与 `.rpm` 产物位于仓库根 `target/release/bundle/`。 +- `pnpm run desktop:tauri:build` 是当前推荐的稳定 Linux 打包入口,只生成已验证可出的 `.deb` 与 `.rpm` 产物;产物位于仓库根 `target/release/bundle/`。 +- `pnpm run desktop:tauri:build:all` 保留完整 bundle 调查路径;当前会在 AppImage 的 `linuxdeploy` 阶段失败,因此不应作为默认 QA 打包命令。 - Linux 上进入 Tauri 打包前,可先用 `pkg-config --modversion gtk+-3.0 webkit2gtk-4.1 libsoup-3.0` 做原生依赖预检查;任一缺失都会阻断桌面包产物生成。 ## Local Redis Smoke Fixture @@ -79,9 +81,15 @@ pnpm run desktop:tauri:dev ## Current Frontend Scope - 首版可见界面现在已接入真实的桌面 browse / inspect / string save / TTL update 路径,但仍保持危险操作和完整产品化流程在后续阶段演进。 -- 当前前端消费 `backend_bootstrap`、`test_redis_connection`、`browse_redis_keys`、`read_redis_value`、`write_redis_value` 和 `execute_redis_command`;浏览器态仍保持明确的 demo fallback。 +- 当前前端消费 `backend_bootstrap`、`test_redis_connection`、`browse_redis_keys`、`read_redis_value`、`create_redis_value`、`write_redis_value`、`update_redis_key_ttl` 和 `execute_redis_command`;浏览器态仍保持明确的 demo fallback。 +- 当前仓库还包含一个 string-only 的 `Add key` 可见入口,走 `create_redis_value` 合约;该能力已在仓库中实现,但阶段验收仍需按 `docs/redis-gui-v1-product-requirements.md` 与 `docs/qa/acceptance-matrix.md` 单独判断,不应自动算入当前基线签收。 - 当前前端还通过共享错误映射层统一消费 `redis-core` 的稳定错误码,使连接测试、浏览、读取、保存、TTL 和命令执行失败都落到一致的操作提示上。 - `inspect_redis_key` 与 `update_redis_key_ttl` 已在桌面桥可用;当前 UI 已暴露 TTL 设置与移除控制,但删除执行仍保持未开放状态。 +- 当前 phase 的 string 保存、TTL 设置和 TTL 移除在执行前都会进入 scoped review,重复展示 connection / DB / key 语境与 current-vs-next 变化,并阻止 no-op 提交直接触达 live bridge。 - inspector 现在对 `hash/list/set/zset/stream` 提供结构化只读面板,不再要求操作者从原始 JSON 文本中手动解析 typed value。 +- inspector 现在还会明确显示当前 inspect 状态、value surface 语义、下一步操作路径和 key facts,降低 string / structured / missing / demo fallback 场景下的误读成本。 +- DB switcher 现在默认覆盖 `db0` 到 `db15`,并会在当前作用域落到更高 DB 时保留对应选项,避免 UI 退回到四个固定 DB 的弱验证面。 - 连接测试、key browse、value inspector 和命令托盘在 Tauri 桌面运行时走真实后端桥;浏览器态 `pnpm run desktop:dev` 会明确回退到 demo 数据,不伪装成真实 Redis 行为。 +- 浏览器态现在还会在单次会话内同步 mock string 保存、TTL 设置和 TTL 移除的可见状态,保证壳层演示与 UI 反馈一致,但这仍不构成任何 live Redis 证据。 +- utility rail 现在额外提供本地 smoke assist 面板,可直接切换到 `127.0.0.1:6380 / db0` 的 fixture 路径、聚焦 `smoke:*` 键,并预填安全 smoke 命令。 - 具体 UI/UX 边界与 QA 验收入口见 `docs/frontend-workspace-baseline.md` 和 `docs/qa/acceptance-matrix.md`。 diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2cdaf63..4ee6544 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -16,6 +16,7 @@ import { Database, KeyRound, PanelLeft, + Plus, PlugZap, Search, ShieldAlert, @@ -57,6 +58,10 @@ import { buildStructuredInspectorSections, type StructuredInspectorSection, } from "./lib/structured-inspector"; +import { + buildInspectorReadabilitySummary, + type InspectorFact, +} from "./lib/inspector-readability"; import { appendWorkspaceKeyRecords, filterWorkspaceKeys, @@ -76,6 +81,22 @@ import { updateDemoKeyTtl, updateDemoStringValue, } from "./lib/demo-workspace"; +import { buildDatabaseSwitcherLabels } from "./lib/database-switcher"; +import { + createDemoKeyRecord, + findDuplicateKeyName, + parseOptionalTtlMillis, + validateAddKeyDraft, + type AddKeyDraft, +} from "./lib/add-key-draft"; +import { + buildMutationGuardDialogContent, + canRemoveTtl, + hasMeaningfulStringChange, + hasMeaningfulTtlChange, + type ComparableTtlState, + type MutationGuardRuntimeMode, +} from "./lib/mutation-guardrails"; import { cn } from "./lib/utils"; declare const __APP_VERSION__: string; @@ -256,6 +277,56 @@ type RedisKeyTtlUpdateResult = { metadata: RedisKeyMetadata; }; +type RedisValueCreateRequest = { + connection: RedisConnectionRequest; + key: string; + value: { + kind: "string"; + value: string; + }; + ttl_millis: number | null; +}; + +type RedisValueCreateResult = { + record: RedisValueRecord; +}; + +type PendingMutationGuard = + | { + kind: "value_write"; + runtimeMode: MutationGuardRuntimeMode; + connection: RedisConnectionRequest; + connectionName: string; + databaseLabel: string; + keyName: string; + currentValue: string; + nextValue: string; + } + | { + kind: "ttl_update"; + runtimeMode: MutationGuardRuntimeMode; + connection: RedisConnectionRequest; + connectionName: string; + databaseLabel: string; + keyName: string; + currentTtl: string; + nextTtl: string; + nextTtlMillis: number; + } + | { + kind: "ttl_remove"; + runtimeMode: MutationGuardRuntimeMode; + connection: RedisConnectionRequest; + connectionName: string; + databaseLabel: string; + keyName: string; + currentTtl: string; + }; + +type PendingStringWrite = Extract; +type PendingTtlUpdate = Extract; +type PendingTtlRemoval = Extract; + type RedisResponse = | { kind: "null" } | { kind: "integer"; value: number } @@ -363,8 +434,6 @@ const initialDemoKeys: KeyRecord[] = [ }, ]; -const databases = ["db0", "db1", "db2", "db5"]; - const fallbackBootstrap: BackendBootstrap = { app_name: "Redis GUI Foundation", surface: "desktop", @@ -398,11 +467,17 @@ const initialConsoleHistory: ConsoleEntry[] = [ }, ]; +const initialAddKeyDraft: AddKeyDraft = { + name: "", + value: "", + ttlMillis: "", +}; + function App() { const [connections, setConnections] = useState(initialConnections); const [demoKeys, setDemoKeys] = useState(initialDemoKeys); const [activeConnectionId, setActiveConnectionId] = useState(initialConnections[0].id); - const [activeDb, setActiveDb] = useState(databases[0]); + const [activeDb, setActiveDb] = useState("db0"); const [search, setSearch] = useState(""); const [selectedKeyId, setSelectedKeyId] = useState(initialDemoKeys[1].id); const [draftValue, setDraftValue] = useState(initialDemoKeys[1].value); @@ -415,12 +490,15 @@ function App() { ), ); const [connectionDialogOpen, setConnectionDialogOpen] = useState(false); + const [addKeyDialogOpen, setAddKeyDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [pendingMutation, setPendingMutation] = useState(null); const [isConnectionTestRunning, setIsConnectionTestRunning] = useState(false); const [isCommandRunning, setIsCommandRunning] = useState(false); const [isBrowseLoading, setIsBrowseLoading] = useState(false); const [isInspectorLoading, setIsInspectorLoading] = useState(false); const [isSaveRunning, setIsSaveRunning] = useState(false); + const [isCreateKeyRunning, setIsCreateKeyRunning] = useState(false); const [isTtlUpdateRunning, setIsTtlUpdateRunning] = useState(false); const [copyFeedback, setCopyFeedback] = useState<"idle" | "copied" | "unavailable">("idle"); const [liveKeys, setLiveKeys] = useState([]); @@ -436,6 +514,7 @@ function App() { user: "readonly", password: "", }); + const [addKeyDraft, setAddKeyDraft] = useState(initialAddKeyDraft); const deferredSearch = useDeferredValue(search); const tauriAvailable = isTauriRuntimeAvailable(); const appVersion = __APP_VERSION__; @@ -449,13 +528,16 @@ function App() { const activeConnection = connections.find((connection) => connection.id === activeConnectionId) ?? connections[0]; - + const databaseOptions = buildDatabaseSwitcherLabels(parseDatabaseLabel(activeDb)); const searchState = resolveKeyBrowserSearchState(search); const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch); + const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearchState.trimmedQuery); const filteredKeys = tauriAvailable ? liveKeys : filteredDemoKeys; const draftErrors = validateConnectionDraft(connectionDraft, connections); const draftErrorList = Object.values(draftErrors); + const addKeyDraftErrors = validateAddKeyDraft(addKeyDraft); + const addKeyDraftErrorList = Object.values(addKeyDraftErrors); const selectedKey = resolveSelectedWorkspaceKey(filteredKeys, selectedKeyId); @@ -475,30 +557,63 @@ function App() { !isInspectorLoading && structuredInspectorSections.length > 0 && !isEditable; - - const supportNote = tauriAvailable - ? buildLiveSupportNote(selectedValueRecord, selectedKey) - : (selectedKey?.summary ?? - `Search currently returns zero keys in ${activeDb}. This is an intentional empty state, not a backend failure.`); const ttlWriteSupported = tauriAvailable ? selectedValueRecord?.ttl_write_supported === true : selectedKey !== null; + const selectedStringValue = + selectedKey === null + ? null + : tauriAvailable + ? selectedValueRecord?.data.kind === "string" + ? formatRedisValueContent(selectedValueRecord.data.value) + : null + : selectedKey.value; + const currentTtlState: ComparableTtlState | null = + selectedKey === null + ? null + : tauriAvailable + ? (selectedValueRecord?.metadata.ttl ?? null) + : { kind: "display", value: selectedKey.ttl }; + const inspectorReadability = buildInspectorReadabilitySummary({ + databaseLabel: activeDb, + runtimeMode: tauriAvailable ? "live" : "demo", + selectedKey: + selectedKey === null + ? null + : { + name: selectedKey.name, + type: selectedKey.type, + ttl: selectedKey.ttl, + }, + record: selectedValueRecord, + isLoading: isInspectorLoading, + isEditable, + ttlWriteSupported, + }); const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection); const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText; const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null; + const nextTtlMillis = + ttlDraft.trim() !== "" && ttlDraftError === null ? Number.parseInt(ttlDraft, 10) : null; + const nextTtlLabel = nextTtlMillis === null ? null : formatDurationFromMillis(nextTtlMillis); + const hasPendingStringChanges = + selectedStringValue !== null && hasMeaningfulStringChange(selectedStringValue, draftValue); + const hasPendingTtlChanges = + currentTtlState !== null && + nextTtlMillis !== null && + hasMeaningfulTtlChange(currentTtlState, nextTtlMillis); const canSubmitTtlUpdate = selectedKey !== null && ttlWriteSupported && - ttlDraft.trim() !== "" && - ttlDraftError === null && - !isTtlUpdateRunning; + nextTtlMillis !== null && + !isTtlUpdateRunning && + hasPendingTtlChanges; const canPersistTtl = selectedKey !== null && ttlWriteSupported && !isTtlUpdateRunning && - (tauriAvailable - ? selectedValueRecord?.metadata.ttl.kind !== "persistent" - : selectedKey?.ttl !== "persistent"); + currentTtlState !== null && + canRemoveTtl(currentTtlState); const latestConsoleEntry = consoleHistory[0] ?? null; const keyBrowserStatusMessage = buildKeyBrowserStatusMessage({ databaseLabel: activeDb, @@ -516,6 +631,15 @@ function App() { activeDb, deferredSearchState, ); + const pendingMutationDialog = pendingMutation + ? buildMutationGuardDialogContent(pendingMutation) + : null; + const pendingMutationRunning = + pendingMutation === null + ? false + : pendingMutation.kind === "value_write" + ? isSaveRunning + : isTtlUpdateRunning; const qaSnapshot = buildQaSnapshot({ appName: bootstrap.app_name, appVersion, @@ -731,6 +855,17 @@ function App() { })); }; + const handleAddKeyDraftChange = + (field: keyof AddKeyDraft) => + (event: ChangeEvent) => { + const value = event.target.value; + + setAddKeyDraft((current) => ({ + ...current, + [field]: value, + })); + }; + const handleSelectConnection = (connection: ConnectionProfile) => { startTransition(() => { setActiveConnectionId(connection.id); @@ -855,17 +990,126 @@ function App() { }); }; - const handleSaveString = async () => { - if (!selectedKey) { - showNeutralBanner(`Select a key in ${activeDb} before attempting a shell-level save.`); + const handleCreateKey = async () => { + const nextKeyName = addKeyDraft.name.trim(); + const createContext: BackendErrorContext = { + operation: "key_create", + connectionName: activeConnection.name, + databaseLabel: activeDb, + keyName: nextKeyName || "the requested key", + }; + + if (addKeyDraftErrorList.length > 0) { + showBackendErrorBanner( + { + code: "invalid_connection_config", + message: addKeyDraftErrorList[0], + detail: null, + }, + createContext, + ); return; } + const ttlMillis = parseOptionalTtlMillis(addKeyDraft.ttlMillis); + if (!tauriAvailable) { + if (findDuplicateKeyName(demoKeys, nextKeyName)) { + showBackendErrorBanner( + { + code: "already_exists", + message: "Redis key creation does not overwrite an existing key.", + detail: `key \`${nextKeyName}\` already exists`, + }, + createContext, + ); + return; + } + + const nextRecord = createDemoKeyRecord({ + ...addKeyDraft, + name: nextKeyName, + }); + startTransition(() => { - setDemoKeys((current) => updateDemoStringValue(current, selectedKey.name, draftValue)); + setDemoKeys((current) => [nextRecord, ...current]); + setSearch(nextRecord.name); + setSelectedKeyId(nextRecord.id); + setDraftValue(nextRecord.value); + setTtlDraft(ttlMillis === null ? "" : String(ttlMillis)); + setAddKeyDraft(initialAddKeyDraft); + setAddKeyDialogOpen(false); + showAccentBanner( + `Created session-local demo key ${nextRecord.name} in ${activeDb} and focused the browser on the new string entry.`, + ); + }); + return; + } + + startTransition(() => { + setIsCreateKeyRunning(true); + }); + + try { + const result = await invoke("create_redis_value", { + request: { + connection: toRedisConnectionRequest(activeConnection, activeDb), + key: nextKeyName, + value: { + kind: "string", + value: addKeyDraft.value, + }, + ttl_millis: ttlMillis, + } satisfies RedisValueCreateRequest, + }); + + const nextRecord = toLiveKeyRecordFromValue(result.record); + startTransition(() => { + setSelectedValueRecord(result.record); + setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord)); + setSearch(nextRecord.name); + setSelectedKeyId(nextRecord.id); + setDraftValue( + result.record.data.kind === "string" + ? formatRedisValueContent(result.record.data.value) + : formatRedisValueData(result.record.data), + ); + setTtlDraft( + result.record.metadata.ttl.kind === "expires_in_millis" + ? String(result.record.metadata.ttl.value) + : "", + ); + setAddKeyDraft(initialAddKeyDraft); + setAddKeyDialogOpen(false); + setIsCreateKeyRunning(false); + showAccentBanner( + `Created live string key ${nextRecord.name} in ${activeDb} and focused the browser on the new record.`, + ); + }); + } catch (error) { + startTransition(() => { + setIsCreateKeyRunning(false); + + if (isMissingTauriCommand(error, "create_redis_value")) { + showNeutralBanner( + "The checked-out desktop bridge does not expose create_redis_value yet. Form state was preserved so this flow can be retried after the backend slice lands.", + ); + return; + } + + showBackendErrorBanner(error, createContext); + }); + } + }; + + const executeStringSave = async (mutation: PendingStringWrite) => { + if (mutation.runtimeMode === "demo") { + startTransition(() => { + setDemoKeys((current) => + updateDemoStringValue(current, mutation.keyName, mutation.nextValue), + ); showNeutralBanner( - `Saved session-local mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is still unavailable in browser demo mode.`, + `Saved session-local mock string value for ${mutation.keyName} in ${mutation.databaseLabel}. Backend persistence is still unavailable in browser demo mode.`, ); }); return; @@ -878,11 +1122,11 @@ function App() { try { const result = await invoke("write_redis_value", { request: { - connection: toRedisConnectionRequest(activeConnection, activeDb), - key: selectedKey.name, + connection: mutation.connection, + key: mutation.keyName, value: { kind: "string", - value: draftValue, + value: mutation.nextValue, }, } satisfies RedisValueWriteRequest, }); @@ -898,7 +1142,7 @@ function App() { ); setIsSaveRunning(false); showAccentBanner( - `Saved live string value for ${selectedKey.name} in ${activeDb} and preserved the current TTL boundary.`, + `Saved live string value for ${mutation.keyName} in ${mutation.databaseLabel} and preserved the current TTL boundary.`, ); }); } catch (error) { @@ -906,14 +1150,48 @@ function App() { setIsSaveRunning(false); showBackendErrorBanner(error, { operation: "value_write", - connectionName: activeConnection.name, - databaseLabel: activeDb, - keyName: selectedKey.name, + connectionName: mutation.connectionName, + databaseLabel: mutation.databaseLabel, + keyName: mutation.keyName, }); }); } }; + const handleRequestSaveString = () => { + if (!selectedKey) { + showNeutralBanner(`Select a key in ${activeDb} before attempting a shell-level save.`); + return; + } + + if (selectedStringValue === null || !isEditable) { + showNeutralBanner( + `The selected key in ${activeDb} is not currently on an editable string path.`, + ); + return; + } + + if (!hasPendingStringChanges) { + showNeutralBanner( + `No string changes are pending for ${selectedKey.name} in ${activeDb}; the draft already matches the current value.`, + ); + return; + } + + startTransition(() => { + setPendingMutation({ + kind: "value_write", + runtimeMode: tauriAvailable ? "live" : "demo", + connection: toRedisConnectionRequest(activeConnection, activeDb), + connectionName: activeConnection.name, + databaseLabel: activeDb, + keyName: selectedKey.name, + currentValue: selectedStringValue, + nextValue: draftValue, + }); + }); + }; + const handleDeleteKey = () => { if (!selectedKey) { return; @@ -925,25 +1203,14 @@ function App() { ); }; - const handleApplyTtl = async () => { - if (!selectedKey) { - showNeutralBanner(`Select a key in ${activeDb} before attempting a TTL update.`); - return; - } - - const validationError = validateTtlDraft(ttlDraft); - if (validationError) { - showNeutralBanner(`TTL update is invalid: ${validationError}`); - return; - } - - if (!tauriAvailable) { + const executeTtlUpdate = async (mutation: PendingTtlUpdate) => { + if (mutation.runtimeMode === "demo") { startTransition(() => { setDemoKeys((current) => - updateDemoKeyTtl(current, selectedKey.name, Number.parseInt(ttlDraft, 10)), + updateDemoKeyTtl(current, mutation.keyName, mutation.nextTtlMillis), ); showNeutralBanner( - `Updated session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`, + `Updated session-local mock TTL for ${mutation.keyName} in ${mutation.databaseLabel}. Use desktop runtime for a live TTL change.`, ); }); return; @@ -956,11 +1223,11 @@ function App() { try { const result = await invoke("update_redis_key_ttl", { request: { - connection: toRedisConnectionRequest(activeConnection, activeDb), - key: selectedKey.name, + connection: mutation.connection, + key: mutation.keyName, operation: { kind: "expires_in_millis", - value: Number.parseInt(ttlDraft, 10), + value: mutation.nextTtlMillis, }, } satisfies RedisKeyTtlUpdateRequest, }); @@ -974,7 +1241,7 @@ function App() { ); setIsTtlUpdateRunning(false); showAccentBanner( - `Updated live TTL for ${selectedKey.name} to ${formatRedisKeyTtl(result.metadata.ttl)} in ${activeDb}.`, + `Updated live TTL for ${mutation.keyName} to ${formatRedisKeyTtl(result.metadata.ttl)} in ${mutation.databaseLabel}.`, ); }); } catch (error) { @@ -982,26 +1249,62 @@ function App() { setIsTtlUpdateRunning(false); showBackendErrorBanner(error, { operation: "ttl_update", - connectionName: activeConnection.name, - databaseLabel: activeDb, - keyName: selectedKey.name, + connectionName: mutation.connectionName, + databaseLabel: mutation.databaseLabel, + keyName: mutation.keyName, }); }); } }; - const handlePersistTtl = async () => { + const handleRequestTtlUpdate = () => { if (!selectedKey) { - showNeutralBanner(`Select a key in ${activeDb} before attempting to remove TTL.`); + showNeutralBanner(`Select a key in ${activeDb} before attempting a TTL update.`); return; } - if (!tauriAvailable) { + const validationError = validateTtlDraft(ttlDraft); + if (validationError) { + showNeutralBanner(`TTL update is invalid: ${validationError}`); + return; + } + + if (currentTtlState === null || nextTtlMillis === null || nextTtlLabel === null) { + showNeutralBanner( + `TTL state is not ready for ${selectedKey.name} in ${activeDb}. Refresh the inspector before retrying.`, + ); + return; + } + + if (!hasPendingTtlChanges) { + showNeutralBanner( + `TTL for ${selectedKey.name} in ${activeDb} already resolves to ${nextTtlLabel}; no update was sent.`, + ); + return; + } + + startTransition(() => { + setPendingMutation({ + kind: "ttl_update", + runtimeMode: tauriAvailable ? "live" : "demo", + connection: toRedisConnectionRequest(activeConnection, activeDb), + connectionName: activeConnection.name, + databaseLabel: activeDb, + keyName: selectedKey.name, + currentTtl: selectedKey.ttl, + nextTtl: nextTtlLabel, + nextTtlMillis, + }); + }); + }; + + const executeTtlRemoval = async (mutation: PendingTtlRemoval) => { + if (mutation.runtimeMode === "demo") { startTransition(() => { - setDemoKeys((current) => persistDemoKeyTtl(current, selectedKey.name)); + setDemoKeys((current) => persistDemoKeyTtl(current, mutation.keyName)); setTtlDraft(""); showNeutralBanner( - `Removed session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`, + `Removed session-local mock TTL for ${mutation.keyName} in ${mutation.databaseLabel}. Use desktop runtime for a live TTL change.`, ); }); return; @@ -1014,8 +1317,8 @@ function App() { try { const result = await invoke("update_redis_key_ttl", { request: { - connection: toRedisConnectionRequest(activeConnection, activeDb), - key: selectedKey.name, + connection: mutation.connection, + key: mutation.keyName, operation: { kind: "persist", }, @@ -1027,7 +1330,7 @@ function App() { setTtlDraft(""); setIsTtlUpdateRunning(false); showAccentBanner( - `Removed TTL for ${selectedKey.name}; the key is now ${formatRedisKeyTtl(result.metadata.ttl)}.`, + `Removed TTL for ${mutation.keyName}; the key is now ${formatRedisKeyTtl(result.metadata.ttl)}.`, ); }); } catch (error) { @@ -1035,14 +1338,70 @@ function App() { setIsTtlUpdateRunning(false); showBackendErrorBanner(error, { operation: "ttl_remove", - connectionName: activeConnection.name, - databaseLabel: activeDb, - keyName: selectedKey.name, + connectionName: mutation.connectionName, + databaseLabel: mutation.databaseLabel, + keyName: mutation.keyName, }); }); } }; + const handleRequestPersistTtl = () => { + if (!selectedKey) { + showNeutralBanner(`Select a key in ${activeDb} before attempting to remove TTL.`); + return; + } + + if (currentTtlState === null) { + showNeutralBanner( + `TTL state is not ready for ${selectedKey.name} in ${activeDb}. Refresh the inspector before retrying.`, + ); + return; + } + + if (!canRemoveTtl(currentTtlState)) { + showNeutralBanner( + `${selectedKey.name} in ${activeDb} is already persistent; no TTL removal was sent.`, + ); + return; + } + + startTransition(() => { + setPendingMutation({ + kind: "ttl_remove", + runtimeMode: tauriAvailable ? "live" : "demo", + connection: toRedisConnectionRequest(activeConnection, activeDb), + connectionName: activeConnection.name, + databaseLabel: activeDb, + keyName: selectedKey.name, + currentTtl: selectedKey.ttl, + }); + }); + }; + + const handleConfirmPendingMutation = async () => { + if (!pendingMutation) { + return; + } + + const mutation = pendingMutation; + setPendingMutation(null); + + switch (mutation.kind) { + case "value_write": + await executeStringSave(mutation); + return; + case "ttl_update": + await executeTtlUpdate(mutation); + return; + case "ttl_remove": + await executeTtlRemoval(mutation); + return; + default: + return; + } + }; + const handleRefreshInspector = async () => { if (!selectedKey) { showNeutralBanner( @@ -1607,7 +1966,7 @@ function App() {
} label="Database switcher" />
- {databases.map((database) => { + {databaseOptions.map((database) => { const active = database === activeDb; return ( @@ -1741,11 +2100,99 @@ function App() { } label="Key browser" />

Incremental browse with fast pattern search

- - {tauriAvailable - ? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}` - : `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`} - +
+ + {tauriAvailable + ? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}` + : `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`} + + + + + + + +

+ Add key +

+ Create one bounded string key + + The create flow stays scoped to {activeConnection.name} / {activeDb}, only supports string values, and never overwrites an existing key name. + +
+ +
+ {activeConnection.name} + {activeDb} + string only +
+ +
+
+ + +
+ +
+ +