agentleFS
Sign inSign up

zedis

vicanso/zedis/CLAUDE.md

Zedis is a native, GPU-accelerated Redis GUI client built in Rust with GPUI (the Zed UI framework) and gpui-component, both taken from crates.io through gpui-kit: GPUI is the gpui-pre-* snapshot family gpui-kit pins (renamed back to gpui / gpuiplatform / gpuimacros in [workspace.dependencies], so source keeps gpui::…), and the component library and its icon assets are reached as gpuikit::component::… / gpuikit::assets::Assets. Bump them together — gpui, gpuiplatform, gpuimacros, gpui-kit, and gpui_web (the browser backend, which zedis-web names directly to choose…

CLAUDE.md2.1k starsChanged 3 days ago
  • Reads credentials
  • Installs packages
  • Commits and pushes

What's in it

  1. CLAUDE.md
  2. Commands
  3. Build-time locale parity gate (bites immediately)
  4. Workspace layout
  5. Desktop first: one codebase, two targets
  6. Nothing in the GUI crate speaks Redis
  7. Architecture
  8. GPUI gotchas (learned the hard way)
  9. Conventions
  10. Rust dependencies: check the current version before planning
  11. Agent skills
  12. GPUI / gpui-kit
  13. Issue tracker
  14. Domain docs
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Zedis is a native, GPU-accelerated Redis GUI client built in Rust with [GPUI](https://www.gpui.rs/) (the Zed UI framework) and `gpui-component`, both taken from crates.io through `gpui-kit`: GPUI is the `gpui-pre-*` snapshot family gpui-kit pins (renamed back to `gpui` / `gpui_platform` / `gpui_macros` in `[workspace.dependencies]`, so source keeps `gpui::…`), and the component library and its icon assets are reached as `gpui_kit::component::…` / `gpui_kit::assets::Assets`. Bump them together — `gpui`, `gpui_platform`, `gpui_macros`, `gpui-kit`, and `gpui_web` (the browser backend, which `zedis-web` names directly to choose its font fallback) — and confirm one `gpui-pre` in `Cargo.lock`.

## Commands

- Build / typecheck: `cargo check`
- Lint: **run `make lint` once as the final step before completing any work** (and after every change) — it is the required gate and runs `cargo fmt --check` + `typos` + `cargo clippy --all-targets --all -- --deny=warnings`, the same three things CI's lint job runs and in the same order. The formatting check is part of it because without it the gate was not the gate: a test file landed unformatted, `make lint` stayed green, and CI went red on a diff nobody had seen. Never report work as done until `make lint` passes clean. `cargo clippy --tests -- -D warnings` alone is *not* enough: it skips `typos`, so a misspelled word in code/comments passes locally but fails `make lint`/CI.
- Format: **run `make fmt` (`cargo fmt`) after every code change**, before the final `make lint`.
- Publish to crates.io: `make publish-check` (package + build-verify every crate, no upload) then, from a clean tree at the release tag with a crates.io token, `make publish` — `scripts/publish.sh` runs `cargo publish --workspace --locked`, which orders crates/* before zedis-gui and skips versions already on crates.io. The in-tree crates are workspace dependencies with `path` **and** `version` (what the published manifests keep); `scripts/bump-version.sh` moves those with the workspace version. `zedis-cmd-builder` is `publish = false`.
- Release mirror: the `gitee` job in `publish.yml` copies every **tagged** release to [gitee.com/vicanso/zedis](https://gitee.com/vicanso/zedis) asset for asset — GitHub's release CDN is slow to unreachable from mainland China, and an installer nobody can download is not a release. The in-app updater falls back to it for the check, the manifest, the notes and the download (`helpers/updater.rs`, `GITEE_API`); because the mirror carries the *same files*, the SHA-256 in `latest.json` still vouches for the bytes whichever host served them — a mirror is a second address, never a second build. The `GITEE_TOKEN` secret (projects scope) is required once the mirror is part of the release: unlike the Docker Hub and SignPath switches, which gate a separate artifact, a missing token here fails the job. Three things the Gitee API does not share with GitHub's, each of which cost a debugging round: a release that does not exist answers `200` with a body of literally `null` (so the body decides, not the status, and the id is checked for being a number before it reaches a URL), `target_commitish` is required on create (read the mirror's own default branch — a wrong one 404s as if the repo were missing), and there is no `--clobber`, so a re-upload has to delete the old attachment first or the page offers two files with one name. And the upload path from a GitHub runner to Gitee is erratic — the same 17 MB installer has taken ten seconds and twelve minutes, and v0.12.0's first one stalled for seventeen before `curl: (52) Empty reply from server` failed the job — so each attachment is retried (five tries, a transfer under 10 KB/s for two minutes is cut and restarted, a lost reply is checked against the listing), one already there at the same size is skipped so a `mirror_tag` re-run finishes a partial mirror instead of redoing it, SHA256SUMS and latest.json go up only after every installer has, and the closing check compares sizes, not just names. Also: `/releases/latest/download/<name>` is **not** a mirror path — on Gitee it resolves to a repository archive — so anything fetched by name needs the tag first. The nightly is deliberately not mirrored. A tag pushed with `git push --tags` leaves the release a *draft*, which the job refuses to mirror (publishing a draft's binaries there would beat GitHub to it); publish it, then re-run with the `mirror_tag` dispatch input, which builds nothing and only copies — because `prepare_vars`' gate sets `should_build=false` for it; without that line a dispatch on main is a full nightly, which sweeps and re-tags the nightly release first. Re-running the failed job instead replays the tag's old workflow file.
- Nightly by hand: `make nightly` (everything, ~30 min, macOS signing included) / `make nightly-docker` (only `vicanso/zedis-web:nightly`) — `scripts/nightly.sh` dispatches `publish.yml` on main, and `ARGS="--watch --yes"` passes flags through. It builds **origin/main, never the working tree** (the script lists what is local only), refuses a second run while one is in progress (they race on the one `nightly` release), and refuses a `docker` run against a main whose `publish.yml` lacks the sweep guard: `prepare_vars` deletes and re-creates the nightly release, and on an image-only run that left it **empty** and re-tagged HEAD, so the next scheduled run saw "main unchanged" and skipped — the two release steps therefore carry `inputs.targets != 'docker'`; keep that condition on anything new that touches the release.
- Dependency gate: `make deny` (`cargo deny check`, config in `deny.toml`) — advisories with *unmaintained* scoped to direct deps, a license allow list, duplicate-version warnings, crates.io-only sources. `audit.yml` runs it on every PR / push that touches the Cargo files and a daily `rustsec/audit-check` on top; the latter reads `.cargo/audit.toml`, so an advisory ignored for a real vulnerability goes into **both** files with the same reason. Dependabot (`.github/dependabot.yml`) bumps weekly and moves the gpui family in one grouped PR.
- Compatibility gate: `tests/command_floors.rs` (part of `make test`) reads `assets/commands.json` for each command `zedis-connection` sends and fails on anything newer than the oldest server the matrix runs (Redis 6.2) that is not named in its `GATED` list with the gate that guards it — a `floors::` constant, a probed `ServerCommand`, or *best-effort* (sent once at connect, error logged, feature simply absent). It exists because four compatibility defects landed in one week, all the same shape: something that works on the newest Redis and not on one in the matrix (`HPERSIST` sent on every hash-field write that asked for no TTL was the plainest). Adding a gated command adds a line there, which is also where a reviewer sees the gate without hunting. It cannot see *options* inside a command (`commands.json` carries no per-argument `since`), reply *fields* that arrive in a later version, or a command that exists but misbehaves — those need a server, which is what the matrix is for.
- Tests: `make test` (`cargo test --workspace`) — the sub-crates (zedis-core / zedis-connection / zedis-db) have their own suites, so a bare `cargo test` misses them. Run a subset by substring filter, e.g. `cargo test -p zedis-core fuzzy`, `cargo test config::`.
- Benchmarks: `make bench` — criterion suites in `crates/zedis-core/benches/hot_paths.rs` for the pure hot paths (fuzzy scan, RDB parse, JSONPath, key segmentation). No CI baseline: run before/after touching those paths and compare the reports; `make lint` keeps the bench targets compiling (clippy `--all-targets`). The key-tree build shows the shape to copy when a hot path is trapped in the bin crate: `new_key_tree_items` stays in `views/key_tree/build.rs` because its rows are gpui view models, but the per-key work it repeats moved to `zedis_core::key_segments` (`split_key_segments` / `folder_prefixes` / `single_child_expanded_set` — the second full pass over the keyspace, 2.5 ms per rebuild at 10k keys — reached through `helpers`), where it is both benched and unit-tested. Split that way rather than dragging `SharedString` into a lib crate.
- Live integration tests (`crates/zedis-connection/tests/live.rs`, all `#[ignore]`): `make it-up` starts a standalone + TLS + mTLS + sentinel + cluster topology plus a primary/replica pair and an unprivileged sshd from `scripts/it/up.sh` (local `redis-server`, or `REDIS_IMAGE=redis:7.2 make it-up` for docker), `make it` runs them with the `ZEDIS_IT_*` env it wrote, `make it-down` stops it. CI runs the matrix (Redis 6.2 / 7.2 / 8.0, Valkey 8.0 / 9.0 — pinned to the first release of each line, where a floor is wrong if it is wrong — redis-stack, valkey-bundle) in `.github/workflows/integration.yml` — the Valkey 9 lane is what covers the Valkey-only paths (`COMMANDLOG` size logs; atomic slot migration is one job in two dialects — Valkey 9's `MIGRATESLOTS`, told to the source, and Redis 8.4's `CLUSTER MIGRATION IMPORT`, told to the target, `SlotMigrationDialect` in `manager/slot_migration.rs` with `cluster_ops` picking the node — and the `redis-8.10` lane is the Redis side of that and of every other Redis floor above 8.0), and the `valkey-bundle` lane the Valkey modules: valkey-json lists itself as `json` (RedisJSON: `ReJSON`), valkey-search as `search` like RediSearch but with a smaller `FT.*` surface (no `TAGVALS` / `SPELLCHECK` / `EXPLAIN` / `PROFILE` / `DROPINDEX DD`, a KNN query is nearest-first by itself — `aim_sort_at_the_distance` adds no `SORTBY` on Valkey — and its `FT.INFO` spells the backfill `backfill_in_progress` / `backfill_complete_percent`, which `parse_info` reads into the same `indexing` / `percent_indexed` as RediSearch's), valkey-bloom as `bf` with `BF.*` alone, and there is no TimeSeries. The module tests ask `has_command` per family and skip what is absent rather than assuming the stack image's set; a stack-only assertion (index sizes from `FT.INFO`) is gated on `valkey_lane()`. A new connection-layer behavior that depends on the *server* (ACL, version, modules, topology) belongs here, not in a mocked unit test. **A green local run is one server version, and that is the version least likely to find anything**: every compatibility defect so far passed locally on the newest Redis and failed in the matrix. Before claiming a change is verified, either run the matrix (`REDIS_IMAGE=redis:6.2 make it-up` needs docker) or build the version in question — `curl -fsSL https://download.redis.io/releases/redis-8.0.0.tar.gz | tar xz && make -C redis-8.0.0 -j8 MALLOC=libc` takes about two minutes, and `SERVER_BIN=…/src/redis-server IT_SCENARIOS=standalone scripts/it/up.sh` points the whole suite at it. That is how the `CLIENT NO-TOUCH` crash was found and fixed; guessing from release notes alone would not have found it.
  - **Credentials are part of the topology, not an afterthought.** Sentinel and cluster are password-protected (`ZEDIS_IT_PASSWORD`), and the sentinel's own password (`ZEDIS_IT_SENTINEL_PASSWORD`) is deliberately a *different* one — that is the shape `sentinel_username` / `sentinel_password` and `sentinel_login()` exist for. Register those entries with the `protected_server` / `sentinel_server` / `sentinel_declared_server` helpers, never bare `server()`, or the test fails with `NOAUTH`. Standalone / TLS / busy / replication stay open so the no-credentials path keeps its coverage.
  - The `mtls` scenario is a second TLS port with `--tls-auth-clients yes` off the same CA (its own port, because that setting rejects exactly what the `tls` tests are), and it ships both a plain and a passphrase-encrypted client key. The `ssh` scenario runs sshd as the current user, public-key only, and is skipped — loudly — on a host without one. SSH sessions are cached by `user@addr`, so a tunnel test that means to *authenticate* has to run before any session to the same `user@addr` exists — which is why that sshd listens on a **second port** serving the same keys: it is how the RSA case gets a handshake of its own instead of case 2's cached session. It offers an ed25519 key (plain and passphrase-encrypted) **and** an RSA one, with `PubkeyAcceptedAlgorithms` pinned to `rsa-sha2-*`: RSA is the only algorithm whose *signature* is negotiated, an ed25519-only fixture cannot see that path at all, and without the pin an sshd older than 8.8 would accept the legacy SHA-1 signature and pass anyway.
- Link resilience: the 2s heartbeat (`stat.rs`, one per workspace tab; the pace itself lives in `helpers/pacing.rs`, which is where the browser build slows it down — never hard-code an interval next to a timer; `refresh_redis_info` lets one attempt through at a time, backs off 2s → 4s → … → 60s while probes fail and polls every 30s in a background tab — ADR 5) drives `ConnectionHealth`. **A beat is one command where it can be**: `RedisClient::heartbeat_probe` sends the `INFO` itself on the client's own connection when there is one master (standalone, Sentinel) and the caller times it — that is the status bar's latency — while a cluster still probes with `PING` and fans the `INFO` out. The fan-out's replies are folded by `aggregate_redis_info` (`stat.rs`), which starts from the first master's INFO and adds the *others* to it — a loop over every reply counted the first master twice in each sum (7 keys in the database switcher for a db holding 6, and the same for clients, memory and throughput); the unit test beside it pins the fold. The probe must stay on `self.connection`: the fan-out dials each master through the per-node cache and proves nothing about the link user commands travel on, so never "simplify" the heartbeat into the fan-out alone. A cluster pays per master, so `pacing::heartbeat_interval(masters)` stretches its beat in whole ticks to stay at or under two `INFO`s a second (≤4 masters untouched, 5–8 → 4s, … capped at 30s). And an app nobody is looking at beats like a background tab: `pacing::unattended()` is set by the main window's activation after `WINDOW_IDLE_AFTER` (2 min — `root/desktop.rs`; coming back clears it and `resume_heartbeat` beats at once) and by the browser page's `visibilitychange`; `is_background()` ORs it in. The reason is not this machine's CPU (an idle connected window measures ~0.1% of a core) but the server: a Redis billed per command was sent ~86k commands a day by one forgotten tab. `ZedisServerState::note_link_error` turns dropped-link / `LOADING` / `BUSY` / `READONLY` / `MASTERDOWN` / `CLUSTERDOWN` task errors into one throttled localized notice, drops the stale pooled client (so the next call or heartbeat rebuilds it — a Sentinel/Cluster failover re-runs discovery), and `on_link_restored` reloads the selected key when the heartbeat recovers. A `select` that the link itself failed — refused, timed out, `LOADING` (`heals_with_the_link`) — is owed a retry: the heartbeat runs it again (`retry_failed_load`) on the first beat that answers with `loading:0`, at most `MAX_LOAD_RETRIES` times in a row, because a server that was down when the app started otherwise came back "Connected" over an empty tree with no version, databases or node count (`INFO` answers while the dataset loads and `DBSIZE` does not, so the beat alone is not the signal). An unreachable seed is a connection *error*, never a "standalone" fallback (`get_redis_nodes`). A user who may not run `INFO` (`@dangerous`, outside `+@read`) is still a connection: the connect reads a denied `INFO cluster` as standalone and a denied `INFO server` as an unknown version (every floor then answers "not supported") and marks the client `info_unavailable`, after which `heartbeat_probe` is a `PING` and `master_infos` answers nothing — the beat records no metrics, and the status bar's memory and clients chips read "--" with the reason in their tooltips. It used to fail the connect and every beat, and show a user who could read every key an empty tree under "Offline". While the link is why a workspace has nothing to show, the editor draws `ZedisServerState::link_problem()` — the reason, the raw error, Reconnect / Edit connection / Diagnose — in place of its "no key selected" screen, whose "New key" buttons promise what an unreachable server cannot do; the error is kept across the retries of the same target (`link_failure`) so the panel does not flicker, and a refused connect is `Network`, never the TLS hint (`is_connection_dropped` excludes a refusal, which redis-rs 1.x counts as "dropped"). `BUSY` (a runaway script) is the one reason reconnecting cannot fix: the status bar then shows a Kill script button — `kill_running` in `script_kill.rs`, over fresh db-0 connections a busy server still answers (`AUTH` only; never the pooled client or anything that `SELECT`s). New server-state reply codes go into `ConnectionErrorKind` (+ a `conn_reason_*` key in every locale).
- Replication: the Topology page's standalone mode is the replication view (ADR 6) — the pair from the heartbeat's `INFO replication` (`RedisInfo::replication`, parsed by `zedis_core::replication`) plus `REPLICAOF host port` / `REPLICAOF NO ONE` / `FAILOVER` (`states/server/replication.rs`, `Capability::ReplicationWrite`, `floors::FAILOVER`, always with `FAILOVER_TIMEOUT_MS`). Sentinel / Cluster entries never render it: their failover is their own body's. The terminal treats `REPLICAOF` / `SLAVEOF` / `FAILOVER` as destructive.
- Timestamps: every user-facing date / time goes through `helpers/datetime.rs` (`format_unix_secs`, `format_clock`, `now_datetime`, `format_unix_millis_with`), which applies the Settings time-zone (local / UTC) and date-layout preference from a process-wide slot (`set_datetime_prefs`, mirrored at startup and on change like the proxy). Never call `Local::now().format(…)` / `Local.timestamp_opt(…)` in a view for display — file-name stamps and diagnostics are the only fixed-format exceptions.
- Keybindings: user-configurable shortcuts are the `HOT_KEYS` table in `helpers/action.rs` (id + default + overlay group + binder); `new_hot_keys()` and the ⌘/ `shortcut_reference()` are both derived from it, with `<config_dir>/keybindings.toml` overrides (`helpers/keybindings.rs`, loaded once before `bind_keys`; restart to apply). A new user-visible shortcut is one table row, never a second hand-written list. The native menu bar is `app_menus()` in `lib.rs` (Zedis / File / Edit / View / Window / Help): every item is an action that already has a handler, and its shortcut is drawn from the keymap — a command worth a shortcut is worth a menu item there, and Settings… is ⌘, like everywhere else on a Mac. Titles are English, as they always were. ⌘W closes a secondary window (Settings, About) and only it — `SecondaryWindow` answers `MemuAction::Close` ahead of the app-wide handler, which hides the app on macOS.
- Single instance + links: `claim_instance` (before `init_database`) forwards a second launch — and its `redis://` / `rediss://` arguments — to the running process over a loopback socket + token (`helpers/single_instance.rs`); OS-delivered links (`Application::on_open_urls`) and hand-offs queue in the same inbox that `launch` drains into `open_redis_urls` (`startup.rs` → `ZedisAppState::open_server_from_uri`). The schemes are declared in `[package.metadata.bundle]`, both `.desktop` files and `wix/main.wxs` — keep the three in step.
- Local-data backup: `zedis_db::backup` (`export_local_data` / `import_local_data`) is the one JSON document for tags, favorites, script viewers, Lua scripts and protos; a new authored table joins it (with `#[serde(default)]`), a cache / history does not.
- Diagnostics bundle: title-bar menu → Export Diagnostics (`DiagnosticsAction::Export`, handled on the `Zedis` root) writes `zedis-diagnostics-<stamp>.zip` to Downloads via `helpers/diagnostics.rs` (summary + redacted `zedis.toml` / `redis-servers.toml` + newest logs + crash reports) using the in-crate `helpers/zip.rs` writer — no `zip` dependency. Add new secrets to `ZedisAppState::redacted_toml` / `servers_toml_redacted`.
- Windows code signing: the `windows` job in `publish.yml` signs `zedis.exe` + `zedis.msi` through SignPath (`.signpath/artifact-configuration.xml` is the reviewed copy of the artifact configuration, `.signpath/README.md` the runbook). The repository variable `SIGNPATH_ORGANIZATION_ID` is the on-switch (unset = unsigned build with a `::warning::`, so releases work before onboarding), `SIGNPATH_API_TOKEN` the secret; a tag push uses `release-signing` (an approver clicks Approve on app.signpath.io within 30 min, once per arch), `workflow_dispatch` with `signpath_policy=test-signing` dry-runs the pipeline on the test certificate. The verify step prints the exe's path inside the MSI — the path the artifact configuration must name. The privacy statement in the README's *Code signing policy* (ADR 7) lists every unprompted network path; a feature that adds one changes README / README_zh / SECURITY.md in the same PR.
- Smoke mode: `ZEDIS_SMOKE_TEST=1` exits 0 on the first painted frame (macOS / Windows gates); `ZEDIS_SMOKE_GATE=window` accepts "window created + 5s alive" instead — the hard Linux gate in `smoke.yml`, because Xvfb + llvmpipe never delivers the frame signal.
- Run dev: `make dev` (`bacon run`); with logs: `make debug` (`RUST_LOG=DEBUG`).
- Release: `make release` (`cargo build --release --features mimalloc`).
- Web build (ADR 9): `make check-web` compiles every crate for `wasm32-unknown-unknown` (nightly, from `zedis-web/`) — run it beside `make lint`, which is native-only and cannot see a wasm break, and know that a wasm *runtime* failure (`std::time::Instant::now()` panics there) passes both: portable files import `Instant` / `SystemTime` from `web_time`. `make web-bundle` is the iteration bundle (`[profile.web]`, name section kept), `make web-release` the shipped form (`[profile.web-release]` = the desktop release profile at `opt-level = "s"`, stripped, then `wasm-opt -Oz`; needs binaryen), `make web-serve` runs `zedis-bridge` serving the bundle and the API under `RUST_ENV=dev`, i.e. in `<config_dir>/dev`, never the installed app's server list. `make web-dist` is the deployment package: the bridge in release form with the release bundle compiled into it (rust-embed, `static_files::WebBuild`; raw, not compressed, so the module is served zero-copy; a debug bridge reads `www/` from disk instead, and `zedis-bridge/build.rs` refuses a release build whose `www/wasm` is missing) in one tarball under `web-dist/` in cargo's target directory (`scripts/web-dist.sh`, which rebuilds the bundle first; it asks cargo for the target directory because `~/.cargo/config.toml` can move it). CJK text in the browser is drawn by the browser itself: the web text system only has the fonts it is handed (IBM Plex Sans, JetBrains Mono), so `zedis-web` constructs `WebPlatform` directly with `CanvasFontFallback::EmojiAndCjk` instead of bundling a 10 MB font — do not swap it back for `single_threaded_web()`, which takes the default (emoji only). Preferences in the browser: `zedis.toml` is a `localStorage` entry there (`zedis:zedis.toml`) — `fs_web` still refuses every real path and writes only a path from `browser_store_path(name)`, which the app uses for the state file on wasm; the AI key and a credentialed proxy are stripped from what is stored (`save_app_state`), and the first-run language follows `navigator.languages` (`sys-locale`'s `js` feature, wasm only). Shared authored data (tags, favorites, scripts) is *not* this: it stays on the `mem_store` seam until the bridge stores it. Shortcuts in the browser: GPUI resolves `secondary` at *parse* time with `cfg!(target_os = "macos")`, which is false in wasm on every machine, so the browser build binds a `cmd-…` twin beside each `secondary-…` keystroke (`web_twin` in `helpers/action.rs`) — twin first, so a gpui-component menu, which draws the last keystroke bound and would say "Win+K", says Ctrl+K — and draws labels from `uses_command_key()` — run-time in the browser (the page passes `run()` an `apple_keyboard` flag), compile-time on the desktop. Never branch a shortcut label on `#[cfg(target_os)]` again; and ⌘N/⌘T/⌘W/⌘Q (Ctrl+N/T/W elsewhere) are the browser's own and never reach a page. A text input's own shortcuts are not ours to twin: gpui-base binds them per platform at compile time, so in the browser every input answers the Windows set (Ctrl+F, Ctrl+Z) — on an Apple keyboard `apple_input_keys` binds the Mac set (⌘F, ⌘Z, ⌘A, ⌥←…) beside it, and only there, since ⌥← is the browser's Back on Windows. Typing CJK needs the capture-phase `keydown` guard in `zedis-web/www/index.html` (it stops `keyCode` 229 / `isComposing` keys before gpui-pre-web sees them; without it every composition leaks its first letter, `n你`) — keep it until upstream checks 229. The bridge's server list carries each entry's settings but never its secrets (`RedisServer::SECRET_FIELDS` / `take_secrets`, shared with encryption at rest and the diagnostics redaction): `GET /v1/servers` adds `secrets_set`, `PUT /v1/servers/{id}` takes `keep_secrets` and dials the edit before keeping it (rolls back on failure), and the browser shows a stored secret as the `STORED_SECRET` placeholder that its store turns back into a name. A new secret field goes into `SECRET_FIELDS` **and** `secret_mut`. The web build ships as an image too: `./Dockerfile` is self-contained (`docker build .` on a fresh clone — it installs both toolchains, `wasm-pack`, a current binaryen from GitHub, `brotli`, libclang for gpui's bindgen, then runs `scripts/web-bundle.sh --release` and builds the bridge onto distroless `cc`, user `nonroot`, volume `/data` = `ZEDIS_CONFIG_DIR`, listening on `0.0.0.0:7379`); `publish.yml`'s `docker` job builds it natively on amd64 and arm64 and `docker_manifest` joins them as `vicanso/zedis-web:<version>` + `:latest` (tags) or `:nightly` (main), gated on the `DOCKER_HUB_USERNAME` / `DOCKER_HUB_ACCESS_TOKEN` secrets — unset means built but not pushed, never a failed release. A tool the bundle script starts needing has to be added to the Dockerfile as well. The bridge stores and serves the module **compressed only** (`.gz` always, `.br` from `make web-release`; `static_files::negotiate` picks by `Accept-Encoding` and inflates for a caller that accepts neither — the raw `.wasm` is a build intermediate the embed excludes, and `build.rs` guards on the `.gz`), with an `ETag` so a reload is a `304`; `scripts/web-bundle.sh` deletes stale `.br`/`.gz` first because a leftover `.br` would beat a fresh `.gz`. The kit's fetched icons trigger no repaint when they land, so `zedis-web/src/assets.rs` watches for them (`repaint_when_icons_land`). Logins are on disk (`bridge-logins.json`: hashed ids, a salted password tag per record so a password change revokes, 8 h or 30 days idle) so a bridge restart signs nobody out — do not move the password into `localStorage` to get that effect. Bridge auth is `auth::Accounts` and nothing else: accounts are **required** — `ZEDIS_BRIDGE_USERS=user@password,…` or `--users-file` / `ZEDIS_BRIDGE_USERS_FILE` (a TOML file of `[[users]]` tables), exactly one of the two, and missing / empty / malformed / *both at once* stops the bridge (the old generated bearer token is gone). HTTP Basic for scripts, a username + password form on the page, and `authorize()` returns the account *name*. An account may be **read-only** (`alice:ro@secret`, or `read_only = true`) — the role rides on the name because a password may contain `:` and a name may not. It is a permission, not a confirmation, so it is checked before the danger classifier and no `confirm` token gets past it: `policy::Verdict::Deny` → `403`, and every mutating route goes through `authorize_write` rather than `authorize` plus a flag test, so a new route either asks that function or changes nothing. The read test is `zedis_connection::is_read_only_command` — an **allowlist**, the opposite shape from `danger.rs`, because a denylist of writes cannot carry a permission: `EVAL` runs any script, `BITFIELD` writes under a name that reads, `GETDEL`/`GETEX` are writes spelled like gets, and every module command is unknown to the core table. `KEYSPACE_READS` is generated from Redis's own `READONLY` command flag (8.10.2); the server-read and module-read tables beside it are the judgement calls (`CONFIG GET` yes, `CONFIG SET` no). An unknown command is refused, so forgetting a *read* costs a panel that says it is unavailable and forgetting a *write* costs nothing — and `crates/zedis-connection/tests/read_only_commands.rs` turns the first of those into a build failure for the person who added the command: it scans `cmd("…")` the way `tests/command_floors.rs` does and fails unless every command this crate sends is either allowed or named in its `WRITES` list. It found `MODULE LIST` and `ROLE` missing the first time it ran. A `[[users]]` table may also carry `servers = ["prod-*:ro", "staging", "id:…"]` (`auth::ServerRule`, ADR 13): which *shared* entries the account sees — by name glob or id; its own it always sees — and where it is read-only (`:ro`; where rules disagree about an entry, write wins). `visible_to(accounts, server, account)` and `Accounts::is_read_only_on(account, server)` are the two predicates: every route that names a server asks the first, every path that writes to one asks the second (`refuse_read_only_on` for entry edits, `policy::check` for commands). `set_account_read_only_on` (`manager/pool.rs`) is how that reaches the UI: the bridge marks `account_read_only` per `/v1/servers` entry (every entry for a read-only account, the `:ro` ones otherwise), `zedis-web`'s transport hands the marked ids over before anything connects, and `get_client` then makes those connections `AccessMode::StrictReadOnly` — the one `toggle_readonly` refuses to switch off — instead of the entry-level `SafeMode`, which the user can. A **write-locked** entry (`RedisServer::write_locked`: its `write_lock`, else its Prod tag, and never when `readonly` — ADR 14; the form shows the pair as one *Writes* radio on the Safety tab, `writes_index` / `writes_from_form_value`) connects in `SafeMode` and leaves it for `WRITE_UNLOCK_SECS` at a time: `ZedisServerState::unlock_writes` / `lock_writes` / the timer's `expire_unlock`; the status bar's lock button asks `DangerKind::WriteLocked` (typed name on Prod) instead of toggling; the bridge keeps the same window per account (`policy::Unlocks`, `POST` / `DELETE /v1/servers/{id}/unlock`, audit `unlocked` / `locked`) and answers a write outside it with `Confirm { WriteLocked }`, which a script may answer per command; the page opens the window through the `unlock_writes` / `lock_writes` seam pair in `server_ops.rs` (a no-op on the desktop). The desktop's dialogs confirm *before* an operation runs, so what they collected reaches the bridge on the operation's `ServerDb`: an operation that only runs behind a dialog runs on `at_confirmed()` (`ServerDb::confirmed(name)` — flush, folder / multi-key delete, `CONFIG SET`, a confirmed terminal line), `ServerDb::connection()` / `client()` / `dedicated_connection()` put the token on the bridge connection and every request it makes carries it. *Confirm Writes* (`require_confirm_writes`) is the terminal's rule on the desktop and stays so on the bridge: `policy::check`'s `typed` is `session.is_some()`, so an editor's write is never asked a question the page could not answer. The lock and read-only are the terminal's as well: `execute_command` asks `ZedisServerState::write_gate()` before anything is sent — `ReadOnly` puts the line and the reason in the transcript and sends nothing, `Locked` asks the lock's own question (`ask_to_unlock`) and then runs the input again from the top, so a destructive line still gets its own dialog — because a typed write has no button for `can()` to grey out, and until then `SET` went straight past a lock the status bar showed closed (ADR 14, amended 2026-10-05). A new place that sends what the user *typed* asks `write_gate()` too. The one question the bridge asks and the desktop does not: a script (`EVAL` / `EVALSHA` / `FCALL`, not the `_RO` forms) to a write-locked or production entry is `Confirm { Script }` every time, inside an unlock window too and by name on production (`danger::classify_guarded_script`), because a script's FLUSHALL is invisible to the classifier — and since a bridge refusal is an error rather than a dialog, the page asks first through the `bridge_danger` seam (`views/danger_confirm.rs`, `None` on the desktop) from the terminal and the function editor, the two places it sends a script. A new place that sends one asks it too. It is deliberately **not** `cfg`-gated even though only the browser sets it: a wasm-only export is invisible to `make lint`, which builds `zedis-web` for the host. The role is in `credential_tag`, so demoting an account ends the logins it already has. `--audit-log <file>` (`ZEDIS_BRIDGE_AUDIT_LOG`) turns on `audit::Audit`, one JSON line per event appended after the outcome is known (ADR 11): logins and failed logins, logouts, refusals by role, server entries added / edited (settings from → to, secret *names* only, private→shared called out) / deleted, every command that administers the server (`zedis_connection::is_administration_command` — `ACL`, `MODULE`, `FUNCTION`, `REPLICAOF`, the `CLUSTER` writes, `CONFIG SET`, `CLIENT KILL`, `SHUTDOWN`, `FLUSH*`…) and every command a person had to confirm, so `require_confirm_writes` on an entry logs every write typed into its terminal; `--audit-writes` adds plain data writes, reads are never logged. `policy::Verdict::Confirmed` exists for this alone (forwarded like `Allow`, logged unlike it). A new mutating route calls `authorize_write` with an action name, a command that carries a credential in an argument is added to `zedis_connection::redact_secrets` in the change that sends it, and a line that cannot be written is an error in the process log, never a refused request. `--trusted-header <name>` + `--trusted-proxy <cidr,…>` (`auth::TrustedProxy`, ADR 12) take the identity from the header a reverse proxy writes, believed only when the socket peer is in the listed networks (never `X-Forwarded-For`), and only for a name that is an account — an unknown one is a `403` and an audit `no_account` line, never a new user; roles stay in the users file, whose `password` may be omitted for such accounts (and is then refused at startup without the proxy settings). Both settings or neither; with neither the header is never read. `authorize` takes `&mut Origin` so the audit line says `auth: proxy`. `GET /v1/servers` sets `readonly: true` on every entry for such an account, which is the signal the page already understands — the refusal never depends on it. None of this replaces a Redis ACL user with `-@write`: that is enforced by the server for every client, this is enforced by the bridge for the only client a browser has. The MCP entry point (`zedis-bridge/src/mcp.rs`, ADR 15): `POST /v1/mcp` is hand-rolled JSON-RPC over one POST — `initialize` / `ping` / `tools/list` / `tools/call`, a notification is a `202`, GET and DELETE are `405`, no batches, no stream, no `rmcp` — behind the same `authorize` and then `Accounts::is_read_only`: a full account is a `403` and an audit `refused mcp` line, whatever it asked. Six tools (`list_servers`, `scan_keys`, `inspect_key`, `server_info`, `slowlog`, `read_command`); every command they send goes through `policy::check` with `read_only` fixed to `true` and out through `api::forward_values` (the exec route's body, split so the tools get values and the page keeps its frames), after `policy::holds_connection` refuses what the allowlist calls a read but would change or hold the pooled connection every caller of that server shares (`SELECT`, `AUTH`, `HELLO`, `RESET`, `MULTI` / `EXEC` / `WATCH`, the blocking pops and `XREAD BLOCK`, `CLIENT SETNAME` / `TRACKING` / `REPLY` / …, `SUBSCRIBE`, `MONITOR`, `WAIT`; `CLIENT LIST` stays). The same check guards the exec route: `forward_values` refuses those without a session (and always for a fan-out, whose per-node connections are shared too), and it checks a pipeline's `offset` / `count` against `PipelineSpec::for_pipeline` rather than trusting them — on a multiplexed connection the count is how replies are dealt out, so a wrong one hands one account's replies to another. Every call is one `Event::Tool` line with `via: mcp`, reads included, a `read_command`'s command redacted through `redact_secrets`; results are cut (`MAX_STRING_CHARS` / `MAX_ITEMS` / `MAX_TEXT_BYTES`), `scan_keys` carries a cursor per master (`host:port=cursor;…`) and shares `COUNT` out over them, and `mcp::Limiter` allows `CALLS_PER_MINUTE` per account. A new tool is one entry in `TOOLS` and `tool_list()` and one arm in `run_tool`, and it sends through `run` or not at all. Verified against the reference client (`npx @modelcontextprotocol/inspector --cli … --method tools/list`), which is also the quickest conformance check after a protocol change. Entries are owned: `RedisServer::owner` is `None` for shared (every entry written before owners existed) or an account name for private, `visible_to` is the one predicate every route that names a server goes through (someone else's entry answers like a missing one — never confirm it exists), and the bridge assigns the owner from who is signed in, never from the request (`assign_owner`). The wire has **three** states: `OWNER_SELF` (the page's unticked *Shared* box, wasm-only in `views/servers.rs`), `OWNER_SHARED` (ticked — spelled out, never stored), and *nothing*, which is what an import, a `redis://` link or a script sends and which the bridge settles as private-if-new / unchanged-if-edited. Never read an empty owner as "shared": that published an imported entry, password included, to every account. The desktop never sets `owner` and must never drop it — `upsert_server_then` carries it over. The bridge can live under a path (`--base-path /zedis` / `ZEDIS_BRIDGE_BASE_PATH`, for a host name shared with other applications): `api::router` registers every route at `{base_path}/v1/…` and the asset fallback strips the prefix itself (`mounted`) — not `Router::nest`, which in axum 0.8 hands the inner router `/zedis` but not `/zedis/`, the one address the page lives at — nothing outside the prefix answers, `/zedis` redirects to `/zedis/`, and `auth::CookiePolicy` scopes the login cookie's `Path` to it so the neighbours never receive it. The page is never told its prefix: `index.html` derives `root` from its own address and hands it to `run()`, which already builds the API and icon URLs on that base. So **nothing in `zedis-web/www` or the wasm may address a URL from `/`** — a root-absolute path works on a root deployment and silently hits another application's paths under a prefix (the page declares its own tab icon for that reason: an undeclared one is fetched from `/favicon.ico`).
- Toolchain: Rust **1.98.1**, edition 2024 — pinned in `rust-toolchain.toml` (rustup applies it to every local build; the workflows' `RUST_TOOLCHAIN` env mirrors it and lint CI fails on drift, so bump them together). The published MSRV is separate: `rust-version` in `Cargo.toml` (what `cargo install` users are promised), compile-checked by the `msrv` job in `lint.yml`.

Clippy `unwrap_used = "deny"` is set crate-wide **including tests** — use `.expect("…")` or proper matching in test code, never `.unwrap()`.

**Avoid `#[allow(clippy::…)]` and `#[allow(dead_code)]`.** Prefer fixing the underlying smell so the lint is clean: e.g. `too_many_arguments` → group parameters into a struct (as with status-bar `MetricChip` and migration's `ExportSpec`); needless `mut` → drop it. Dead code in particular: **delete it or wire it up — never park it behind `#[allow(dead_code)]`** "for future callers" (that speculation rotted three times in `search.rs`: an unused `as_str`, an unused `IndexInfo.name`, and an aggregate `total` that pagination needed but nobody remembered was there). Reach for `allow` only as a last resort when the lint is a genuine false positive or an unavoidable external-API constraint, and keep the attribute as narrow as possible with a one-line comment explaining why.

## Build-time locale parity gate (bites immediately)

`build.rs` enforces that **every** `locales/<lang>.toml` has the exact same key set as `locales/en.toml`. The 8 locales are `en, zh, de, es, fr, ja, pt, ru`. Adding or removing any UI string means editing **all 8 files** or `cargo check` panics. Run `make check-locales` (`tests/locale_keys.rs`, also part of `make test`) to verify directly — it re-checks parity even when build.rs's `rerun-if-changed` misses an in-place edit (the old workaround was `touch locales/en.toml`), **and** flags orphan keys: every en key must be reachable from source (quoted full key, quoted last segment, or a quoted base + a known dynamic suffix `_title`/`_body`/`_desc` — a new composed-key family must be taught to that test). Translate natively where a section is already translated in that locale (most are); English fallback only where the surrounding section is itself untranslated.

## Workspace layout

Cargo workspace: the root crate `zedis-gui` is a **library** (`zedis_gui`, `src/lib.rs` — everything) plus a 19-line bin (`zedis`, `src/main.rs`, which only calls `zedis_gui::run()`), with `members = ["crates/*", "zedis-bridge", "zedis-cmd-builder", "zedis-web"]` and `default-members = ["."]` — a bare `cargo build` builds the desktop app only. Shared dependency versions live in the root `[workspace.dependencies]`; member crates reference them with `{ workspace = true }`.

- `crates/zedis-core` — GUI-free pure logic: the `Capability` permission matrix, fuzzy match, hex/csv/diff, JSONPath, key segmentation (`key_segments`, the key-tree's per-key splitting), TTL helpers, `env::is_development`. No gpui, no i18n.
- `crates/zedis-connection` — the Redis layer: pooled clients (`manager/{client,pool,slots}.rs`), `RedisServer` config, SSH tunnels, plus the shared `error.rs` and the `fs`/`string`/`time` helpers. No gpui (strings are `String`, converted at UI boundaries) and no i18n (`danger.rs` returns `i18n_key()`s for the UI to translate). The embedded `commands.json` is injected at startup via `init_commands_json` — this crate has no access to the app's assets.
- `crates/zedis-db` — the local storage layer: redb-backed managers (tags, favorites, history, trash, scripts) and proto descriptors, with its own `error.rs` (redb + prost/protox variants live here, not in the app). New redb managers go in this crate. The four lists that are one `HistoryManager` over a table each (favorites, search history, command history, recent keys) are instances at the bottom of `history_manager.rs`, not a file apiece; the favorites table is spelled `"favority"` on disk and stays so — the Rust constant is `FAVORITES_TABLE`, the string is every existing user's data. Two rules hold for anything stored here, because this file is the *only* copy of the user's tags, favorites and script library: every value struct carries container-level `#[serde(default)]` (adding a field must never make an existing row unreadable — same contract as `ZedisAppState`), and a loader **skips** a row it cannot read, never deletes it. A new `TableDefinition` must also be opened in `ensure_schema` — read paths use a read transaction, which cannot create a missing table (`every_table_this_crate_defines_exists_after_an_open` guards this).
- `crates/zedis-ui` — reusable widgets (`ZedisCard`, `ZedisDialog`, `ZedisForm`, `ZedisTextTable`, ...). **Separate crate**: it cannot use `crate::helpers::*` from the app. Platform-specific values (e.g. monospace font family) and localized strings (column titles, toasts) must be passed in by the caller. `ZedisTextTable` is the one `TableDelegate` for read-only text grids — columns + rows of strings, sort (optionally by a payload cell holding the raw value), keyword filter + a row predicate, hover copy, and hooks for a per-cell colour / icon, a per-cell action button and a whole custom cell. The hover buttons are drawn *over* the end of the cell (absolute, on the hovered row's colour), not beside the label: reserving their width cost every cell ~32px of text whether or not the pointer was there, so size a column for its content and paddings only. MONITOR, keyspace events, clients, slow log, INFO, pub/sub and the memory-analysis tables all build rows for it; a row may carry payload cells beyond the columns (raw numbers, ids) that are never drawn. A new observability panel does the same instead of writing its own delegate; only a grid whose cells are editable controls (stream entries, proto bindings, script library) keeps a hand-written one.
- `zedis-bridge` — the web build's HTTP server (axum): serves the page and forwards RESP frames (ADR 9). Depends on zedis-connection + zedis-core **only** — never the GUI crate, which links a window system.
- `zedis-web` — the browser entry point: the GUI crate compiled to wasm, plus the bridge transport and the fetched-icon asset source. Three files; built from its own directory (nightly toolchain).
- `zedis-cmd-builder` — offline helper tool (`make build-cmd`).

The app re-exports the sub-crates through thin shims, so call sites keep their old paths: `crate::connection::*` (→ zedis-connection), `crate::db::*` (→ zedis-db), `crate::error::*` (the app-level `Error` — a thin wrapper that transparently passes through `zedis_connection::error::Error` and `zedis_db::error::Error`), `crate::helpers::*` (mixes app-only helpers with re-exports from the sub-crates). Add new pure logic to zedis-core, new Redis operations to zedis-connection (the app crate builds no commands at all — see *Nothing in the GUI crate speaks Redis*), new local-storage managers to zedis-db — not to the app crate. UI strings never move into the sub-crates (rust-i18n is per-crate; translations live only in the app).

## Desktop first: one codebase, two targets

The desktop app is the product and the reference implementation; the web build (ADR 9) is the same GUI crate compiled to wasm with parts taken out. Every rule here serves one goal: **a change made for the browser must not be able to change what the desktop does.** Counted 2026-09-28; the numbers below are that count's, so a later one can tell whether it drifted.

**Dependency direction — one way, no exceptions.** `zedis-core` ← `zedis-connection` / `zedis-db` / `zedis-ui` ← `zedis-gui` ← `zedis-web`, and `zedis-bridge` → `zedis-connection` + `zedis-core`. Nothing under `crates/` or in the GUI crate may name `zedis-web` or `zedis-bridge`; the bridge never depends on the GUI crate; `zedis-web` is the only crate that depends on it, and it stays thin (entry, transport, assets — 3 files). It enters the GUI crate through named entry points only — `launch`, `init_caches`, `init_embedded_commands`, `assets::Assets`, `db::init_database`, `helpers::set_web_command_key`, `states::{GlobalEvent, ZedisAppState, ZedisGlobalStore}`; when the browser needs more, add an entry point, do not reach into `views`.

**Which crates may know about the target at all.** `zedis-ui`: never (0 gates — widgets are target-agnostic). `zedis-core`: only its fs seam. `zedis-connection` / `zedis-db`: at their seams (below). The GUI crate: gates allowed, under the rules that follow. `zedis-bridge`: never — it is a native server.

**Default code is desktop code; the web subtracts.** In the GUI crate there are 226 `#[cfg(not(target_family = "wasm"))]` (a desktop feature taken out of the browser) against 71 `#[cfg(target_family = "wasm")]`. Keep that shape:

- `#[cfg(not(target_family = "wasm"))]` removes a desktop feature from the browser. Always fine.
- `#[cfg(target_family = "wasm")]` adds browser-only code. It belongs in `zedis-web`, or behind a **named seam** — `fs_web` / `browser_store_path` (files → `localStorage`), `mem_store` (redb → memory), `bridge.rs` + `RedisAsyncConn::Bridge` (socket → HTTP), the wasm `reach` in `pool.rs`, `web_twin` / `uses_command_key` (shortcuts), `save_servers` / `set_servers_cache` (server list → bridge), `helpers/pacing.rs` (how often the app polls on its own — in the browser a beat is HTTP round trips through a shared bridge, so the heartbeat is 10s instead of 2s, `DBSIZE` and the slow-log sample 300s instead of 60s, a background tab 60s instead of 30s, the Latency tab 15s instead of 5s, the Server Load and Hot Keys panels 10s instead of 3s / 2s (their loop is `views/panel_poll.rs`, which sleeps on "the interval or a refresh", not in slices), the root's housekeeping tick hourly instead of every 30s; only the *unprompted* polls are paced, a refresh after a user action still goes out at once, and a native test pins the desktop values. The same file holds `unattended()` — the browser page's `visibilitychange` (`zedis-web`'s `set_page_visible`, wired in `index.html`) and the desktop window's activation (see *Link resilience*): `ZedisServerState::is_background()` ORs it in, so an app nobody is looking at polls like a background workspace tab and the panels that already check `is_background()` stop with it. **A new timer takes its interval from this file, and a timer that can sleep on a channel does** — the fetched-icon watcher in `zedis-web/src/assets.rs` used to wake every 500ms for the life of the page and now waits for `load` to list a path). A new browser behaviour gets a seam with a name; it does not get statements sprinkled through a view's `render` or a state method.
- **Never edit the desktop arm to make the browser arm fit.** If one function needs two behaviours, write two functions with the same signature, each under its gate, desktop first (the `save_servers` pair in `config.rs`) — not one body with interleaved gated statements, where a later "simplification" of a gate silently changes the desktop.
- Attribute form only. `cfg!(target_family = "wasm")` compiles *both* arms on both targets, so it drags browser types into the desktop build; the one existing use (`uses_command_key`, a `bool`) is the limit of what it is for.

**Gate at the widest level that works: module > item > statement — one gate per feature.** `views.rs` does it right: eleven desktop-only panels are one gated `mod` + `pub use` each. Three more modules have that shape and are the ones to copy: `root/desktop.rs` (the updater, the crash report, window placement and multi-db search — one `DesktopOnly` struct behind one gated field of `Zedis`, its methods in the gated module, the three pending prompts behind one `open_desktop_prompts` call), and in `zedis-connection` `dump_restore/file.rs` (the `.zdis` file format) and `config/tls.rs` (TLS material). A desktop-only feature with state on a shared struct goes into its own struct (one gated field) in its own gated module — never a gate per field. A struct field is never gated without every one of its initialisers being gated too; the sub-struct makes that one place.

**`BridgeQuery` / `BridgePipeline` stay inside `zedis-connection`.** redis's `aio` (which carries `Cmd::query_async`) cannot build for wasm32, so the browser reaches the same call sites through these two traits: one import line per `zedis-connection` module that sends a command, `BridgeQuery` alone where it only queries. No file under `src` runs a command (*Nothing in the GUI crate speaks Redis*), so none imports them; one that does serves a command from the wrong crate.

**A panel the browser does not have is answered by the mechanism that already exists:** its `ServerView` arm in `content.rs` renders `ZedisUnsupportedPanel` under `#[cfg(target_family = "wasm")]`, the same panel a missing server command gets. No second "not available" surface. Left out of the browser today: Monitor, Pub/Sub, keyspace notifications, Topology (with the Sentinel dialogs), the Lua script library, the proto editor, multi-db key search, migration (file import / export), compare, connection diagnostics, plus the updater, crash reports, tray and window placement. Also gone there, because local storage in the browser is the page's memory (`mem_store`) and a reload empties it: the **recycle bin** (`ZedisAppState::soft_delete` answers `false` on wasm, so no `MEMORY USAGE` + `DUMP` + `PTTL` is sent and no value is shipped to a page that would lose it; its Settings switch and Tools entry are not rendered) and the **Metrics history** (`states/server/stat/persist.rs` is desktop-only, `MetricsRange::offered` leaves only `Live`). Tags, notes, favorites and saved scripts still work there for the life of the page, and the browser's first-connect toast (`hints.first_connect_web`) says so once — a feature that promises persistence must not be offered on `mem_store` without saying it. **Syntax highlighting and code folding are absent in the browser** and are not a bug to chase: gpui-component gates its whole highlighter on a `tree-sitter` feature, and without it `input_highlighter_factory` hands the editor `Rc::new(|_| None)` — no highlighter, so nothing colours and there are no fold ranges. The feature is asked for by `zedis-gui` alone under `cfg(not(target_family = "wasm"))`, because enabling it anywhere in the workspace fails a wasm build of any member with 37 errors (ADR 9); every gpui-component from 0.6 through 0.7 ships the same `wasm_stub`, so a version bump will not change it. What would: teaching tree-sitter to build for wasm32 upstream, or a hand-written tokenizer on the browser side — which would be browser-only colour the desktop does not have, and needs a reason better than a missing one. **An entry point goes with its feature**: when a panel or action is gated out, so is every button, menu item and shortcut row that leads to it (the empty state and the title bar still offered multi-db search after its handler was gone), and the ⌘/ reference drops what cannot work in a browser — `listed_in_reference` in `helpers/action.rs` filters the absent feature and the chords the browser keeps (`browser_keeps`: bare ⌘/Ctrl + N, T, W, Q). `docs/WEB.md` / `docs/WEB_zh.md` (*What the web version leaves out*) list this for users — change them together with `views.rs`.

**Dependencies follow the same split.** A crate in plain `[dependencies]` must build for `wasm32-unknown-unknown`; a native-only one goes under `[target.'cfg(not(target_family = "wasm"))'.dependencies]`, a browser-only one under `[target.'cfg(target_family = "wasm")'.dependencies]` — every crate that has the split already has both tables.

**Checking.** `make lint` + `make test` are the desktop's gate and see nothing of wasm; `make check-web` is the browser's and sees nothing of the desktop. A change that touches any file with a target gate, or anything under `crates/` (all of it compiles for both), runs **both** — and a wasm-only fix that made `make lint` or a native test change its result has broken the first rule of this section. `lint.yml`'s `wasm` job runs `make check-web` on every push and PR — it used to be local-only, and a desktop refactor could take the whole web build with it until the nightly *image* failed after the merge. Run it locally anyway: the job is the backstop, not the feedback loop.

## Nothing in the GUI crate speaks Redis

ADR 10: **no file under `src` builds a command (`cmd("`), runs one (`.query_async` / `.exec_async` / `pipe()`), takes a connection (`get_connection_manager()`, any `open_*_connection` dialer, the Pub/Sub getters) or names `redis::`, `RedisAsyncConn` or the `BridgeQuery` / `BridgePipeline` traits** — and `zedis-gui`'s manifest does not list `redis` at all, on either target. `src/views` draws and asks, `src/states` holds the truth and decides, `zedis-connection` knows what a command looks like.

`tests/view_layering.rs` (`make check-layering`, also part of `make test`) counts all of those per file under `src` against `tests/view_layering.baseline`, which is **empty**. A failure means the new code belongs in `zedis-connection`, not that the baseline wants rewriting.

Where an operation goes, and what it looks like:

- One module per Redis feature, typed, returning a struct the view can draw — `HllInfo`, `BitmapInfo`, `StreamInfo` — never a `redis::Value`, and with the helper methods that describe the data, since a view cannot `impl` a foreign type. By key type: `string_ops.rs` (String + RedisJSON, read and written whole), `hash_fields.rs`, `list_ops.rs`, `set_ops.rs`, `zset_ops.rs`, `stream_ops.rs`, `key_ops.rs` (the key menu's one-shot operations). By feature: `hyperloglog.rs`, `bitmap.rs`, `geo.rs`, `vector_set.rs`, `probabilistic.rs`, `module_ops.rs`, `search.rs`, `acl.rs`, `functions.rs`, `lua_script.rs`. By scope: `keyspace.rs` (a key as the tree and the header see it — scanned, typed, sized, renamed, expired, created, deleted, snapshotted for the bin), `server_ops.rs` (the server as a whole — what a connect learns, what a heartbeat asks, `BGSAVE` / `FLUSHDB` / `REPLICAOF` / `FAILOVER`, and `forget_client`), `server_config.rs`, `clients.rs`, `latency.rs`, `panel_ops.rs` (panels whose operation already existed as a `RedisClient` method and only needed a door).
- It takes a **`ServerDb`** — which database of which configured server — not a connection. `ServerDb::connection()` / `client()` / `dedicated_connection()` are `pub(crate)` on purpose: a caller holding a connection is a caller that builds commands. A state method makes one with `self.at()` and hands it over; a view with `ServerDb::new(server_id, db)`.
- **A cluster command is not a `ServerDb` operation.** `CLUSTER REPLICATE`, `FAILOVER`, `ADDSLOTS` and `SETSLOT` are not gossiped: they do what they say on the node they reach, and reaching the wrong one is a different outcome, not a slower one. Those take a **`ClusterNode`** (`cluster_ops.rs`) — which node of which configured cluster — while `MEET` and `FORGET` take a `ServerDb` because they are told to every master (`FORGET` because gossip re-adds a node that only some masters dropped). `migrate_slot` is the whole pre-Valkey-9 hand reshard of one slot; the progress loop around it stays in the state layer.
- **What the server pushes on a held connection is a type, not a connection.** The view keeps the *loop* (the cancellable `Task`, the channel to its drainer) and asks for the next item: `ChannelSubscription::open(at, SubscribeKind::{Patterns, Sharded, KeyspaceEvents}, channels)` → `next_message()` (a `ChannelMessage`: channel + undecoded bytes; Pub/Sub and keyspace notifications — `KeyspaceEvents` subscribes on every master, since a node publishes only its own keys' events, and an SSH entry subscribes on a RESP3 push connection through its tunnel), `open_monitor_feeds(at)` → one `MonitorFeed` per master plus the nodes that could not be opened (`next_line()`; an SSH entry's feed is read through its tunnel by `open_ssh_tunnel_monitor`, by hand, because redis-rs builds its `Monitor` only on a socket it dialed and MONITOR's `+…` lines are not pushes a multiplexed connection could carry), `StreamTail::open(at, key)` → `next_batch(block_ms, count)` (the `XREAD BLOCK` cursor lives in it). Dropping the value closes the connection — that is the unsubscribe. The first two are native-only like their panels; the tail works over a bridge session.
- **The terminal is the one place a reply stays raw**, because showing whatever the server said, again in another format, is its job: `TerminalSession::run(at, cmd, args)` answers a `TerminalReply` — opaque, asked `is_ok()` / `is_nil()` / `is_queued()` / `exec_replies()` / `selected_db()` and drawn with `render(format)`. The session owns the dedicated connection (opened by the first line, shared by clones, forgotten on a link error — `TerminalSession::drops_link(kind)`; a fresh `default()` is how the terminal forgets a `SELECT` or an open `MULTI` on a server switch). A test builds a reply from its RESP encoding (`TerminalReply::from_resp(cmd, args, b"+OK\r\n")`), never from a `redis::Value`.
- **Types the connection crate owns, the UI converts.** `zedis-connection` has no gpui, so its structs are `String` where the views want `SharedString`; the conversion is one function on the way in (`project_info` / `project_group` in `states/server/stream.rs`, `project_geo` in `views/geo_map.rs`) and not scattered. Vocabulary that is *spelled on the wire* belongs to the connection crate whatever draws it — `StreamTrim`, `StreamRefPolicy`, `ProbKind`, `ExpireCondition` — and is re-exported from `states` so call sites keep their path.
- **It gets a live test in the same change.** Every command that sat in a view or a state method had none — a command inside `src` is not reachable from `tests/live.rs` — so the move is what makes one possible, and an operation without one has only been relocated.

What this does **not** do is take `redis` out of the wasm bundle, and ADR 10 says why not to: it is 105 KiB of a 19.65 MiB code section (0.5%), it opens nothing in the browser, and removing it means a feature-level bridge — ~190 endpoints, a protocol that grows with every panel, a bridge released in step with the page — for which ADR 10 also lists the three signals that would justify it (per-command authorization or audit, a second front end, RESP2/3 debugged on both sides). Every operation being `fn(at: &ServerDb, …) -> Result<T>` is what keeps that decision reversible: the RPC boundary would go exactly there.

## Architecture

**State (`src/states/`)** — the source of truth, GPUI entities.
- `ZedisGlobalStore` / `ZedisAppState` (`app.rs`): app-wide config + selected server + view prefs. Persisted to `zedis.toml` via `update_app_state_and_save(cx, "action", |state, _| …)` (async, debounced). Add a field + getter/setter here to persist a new preference.
- `ZedisServerState` (`server.rs` + `server/*.rs`): per-connection state — loaded keys, selected value, and all type-specific ops (`string/hash/list/set/zset/stream/json`). One-shot Redis ops go through `self.spawn(ServerTask::…, op, on_done, cx)` or `exec_stream_op`. `reset()` clears it on server switch; `clear_if_removed()` drops it when the active server is deleted.
- Events: `GlobalEvent` (notifications, `ServerSelected`, `ServerListUpdated`, `RouteChanged`) and `ServerEvent` (`ValueUpdated`, `KeySelected`, …) drive view updates via `cx.subscribe`. `ServerTask` is the async-task identity enum (add a variant + string mapping in `server/event.rs` for a new task).
- i18n: `t!("section.key")` (rust-i18n). Use the `i18n_<section>(cx, key)` helpers in `states/i18n.rs`, each **individually** re-exported from `states.rs` (add both the fn and the `pub use` line for a new section).

**Connection (`crates/zedis-connection`, re-exported as `crate::connection`)** — `config.rs` holds `RedisServer` (server list persisted to `redis-servers.toml`, secrets encrypted; read through the in-memory `SERVER_CONFIG_MAP` ArcSwap cache via `get_servers()` / `get_server()`). `manager/pool.rs` hands out pooled multiplexed clients and probes the three-state `AccessMode` (ReadWrite / SafeMode / StrictReadOnly — the source for the `Capability` matrix); `manager/client.rs` is the `RedisClient` ops; `manager/slots.rs` is cluster parsing + reshard planning. **Blocking commands and server pushes** (`MONITOR`, `XREAD BLOCK`, Pub/Sub) run on a dedicated connection so they never starve the shared pool — `open_single_connection(&server, db, /*use_cache=*/false)` inside this crate, and from the app only through the types that own one (`StreamTail`, `ChannelSubscription`, `open_monitor_feeds`, `TerminalSession`; see *Nothing in the GUI crate speaks Redis*). `SENTINEL …` administration lives in `sentinel.rs`: it dials the sentinels (the entry's seeds plus the `SENTINEL SENTINELS` peers) and answers per sentinel — the pooled client of a Sentinel entry is the *data master*, where `SENTINEL` is an unknown command, so never send those through `query_async_masters`. **Inside this crate, a reply walked as a `redis::Value` is read with `reply`** (`text` / `text_lossy` / `int` / `uint` / `float` / `string_array` / `pairs` / `display` / `display_pairs` / `is_unsupported`). Six connection files and four editors each used to carry their own copies of these, and the copies had drifted by accident — one accepted a `SimpleString` its twin refused, one knew `VerbatimString`, the four `is_unsupported` lists each missed a different wording — so a new RESP3 shape is taught in that one file and nowhere else. The one real choice is `text` (strict UTF-8: names of fields, anything about to be parsed) against `text_lossy` (what the *user* named — a key in a hot-keys report — where dropping the row is worse than showing U+FFFD). No file outside this crate sees a `Value` at all; if `reply` lacks a shape an operation needs, add it there.

**Server feature matrix (proxies / managed clouds / Redis-compatible servers).** Availability is *probed*, never hardcoded per brand. `zedis-core/src/features.rs` holds the pure side (`ServerCommand`, `CommandStatus`, `ServerFeatures`, reply classification); `zedis-connection/src/probe.rs` runs it — once per server, in the background right after connect (`ZedisServerState::probe_features`), on a dedicated connection, read-only probes only (mutating commands go through `COMMAND INFO` + `ACL DRYRUN`), cached per server id (not per db). Two gating axes: `ServerView::required_commands()` (hard deps → `content.rs` renders `ZedisUnsupportedPanel` instead of the panel) and `Capability::required_commands()` (buttons → `ZedisServerState::can()` now covers read/write *and* command availability; `blocked_by()` gives the reason for a `views::unavailable_chip`). Runtime `NOPERM` / `unknown command` replies feed back through `note_command_error` (one localized notice, then the UI degrades and repeats stay quiet; views with their own tasks call it too). When a new panel/button depends on a new command: add a `ServerCommand` variant, its probe in `probe_cmd` (read-only!) or `dryrun_args`, and map it in `required_commands()`.

**Version gates decide both flavors — Redis *and* Valkey — every time.** Valkey forked from Redis 7.2.4 and numbers its releases (7.2 → 8.0 → 8.1 → 9.0) on a track that runs *ahead* of Redis's, so a bare `>= x.y.z` written for a Redis feature silently passes on a Valkey that never shipped it (`INFO keysizes`, HOTKEYS, XACKDEL — the Valkey 8 CI run failed exactly this way) or shipped it at another version (`SET IFEQ`: Redis 8.4 vs Valkey 8.1; hash field TTL: Redis 7.4 vs Valkey 9.0). The only version primitive is therefore `crates/zedis-connection/src/floors.rs`: every gated feature is a `Floor { redis, valkey: Option<…> }` constant there (`since_fork` = every Valkey release has it, `both(redis, valkey)` = different floors, `redis_only` = Valkey never shipped it, `valkey_only` = Redis never did — `COMMANDLOG`), checked through `RedisClient::supports(floor)` / `ZedisServerState::supports(floor)`; the named `supports_*` helpers are one-line wrappers over those. Adding a gate means adding a `Floor` with the Valkey side researched and written down (release notes, not guesswork — `None` is a claim too), and a live test gates with the same constant (`supports(&id, floors::X)`), never a raw version string. There is no `is_at_least_version` to reach for; don't reintroduce one. The one thing a `Floor` cannot express is a **regression window** — a version range where a feature that exists is *broken* — and `floors::no_touch_is_safe` is the single such predicate: Redis 8.0.0–8.2.6 **and Valkey 8.0.x** segfault in `lookupKey()` when a client with `CLIENT NO-TOUCH` set unblocks another client (Redis: upstream #14415, fixed in 8.2.7, never backported — 8.0.6 is still affected; Valkey: the same stack as valkey-io/valkey#2110, crashes 8.0.11 and is clean at 8.1.10 / 9.x — found by the stream-tail live test the moment a lane was pinned to `valkey/valkey:8.0`, which is why that lane is pinned; the version handed to the predicate is `valkey_version`, never Valkey's `redis_version:7.2.4` compatibility line, which is what had hidden the window), so `configure_client_connection` asks `INFO server` before setting that flag and withholds it where it would crash the server. A server that will not say its version does not get the flag: the cost is a little accuracy in the memory analyzer's heat column, and the cost of guessing wrong is the user's server. Don't fold it into a `Floor` — it is not an adoption point — and don't add a second range predicate without the same write-up. The database count is the other thing a version changes without a `Floor`: a Valkey 9 cluster keeps `databases` at its standalone value (16) and sizes `SELECT` by `cluster-databases` (default 1, immutable at run time), where Redis and Valkey 8 force `databases` to 1 in cluster mode — so `get_databases` (`manager/pool.rs`) asks a cluster `cluster-databases` first and takes the empty list an older server answers as db 0 alone — never `databases`, which Redis 6.2 leaves at 16 in cluster mode (newer servers force it to 1), and `cluster_database_count_is_the_one_select_accepts` in `tests/live.rs` holds that on every cluster lane (found by hand on a default Valkey 9.0 cluster: the switcher offered fifteen databases that answered `DB index is out of range`). Three Valkey 8.1 observability additions are aligned rather than gated: `BGSAVE CANCEL` is a `floors::BGSAVE_CANCEL` button on the persistence card (Redis cannot cancel one); the status bar's pause chip reads `paused_actions` / `paused_timeout_milliseconds` / `paused_reason` from `INFO clients` where the server reports them (`PausedActions::Unreported` is the tell) and otherwise shows the `CLIENT PAUSE` this app sent (`ZedisServerState::note_client_pause`, `stat::client_pause_now` — the server's word wins, even a `none`); and `LatencyEvent::avg_ms` is the server's sum / count where `LATENCY LATEST` has six fields, else the mean of `LATENCY HISTORY` (`avg_exact` says which). `CLUSTER NODES` flags carry `NodeHealth` (`fail?` / `fail`) beside the role, and the topology page counts both from the flags on either flavor — not from Valkey 9's `cluster_nodes_pfail`. `DUMP` payloads are the third: `RESTORE` refuses a payload above its own RDB version and the flavors number theirs apart (Redis 7.4 = 12, 8.10 = 15; Valkey 8 = 11, Valkey 9 = 80 — only Valkey 8 → Redis restores), so `logical_copy.rs` (ADR 16) re-creates a refused key by type from the source (`restore_or_recreate_chunk`, answering `RestoreStatus::Recreated`; string / hash / list / set / zset / stream with ids / JSON, TTL kept; module types, consumer groups and field TTLs are named as lost). `copy_key` and the migration's server-to-server path go through it; the `.zdis` import has no source and says to use JSON instead. `ZEDIS_IT_FOREIGN=host:port` runs `standalone_copy_lands_on_a_server_of_the_other_flavor` against a server of the other flavor by hand — the matrix has one flavor per lane. Command *availability* on proxies / managed clouds is the other axis and stays probed (above).

**Views (`src/views/`)** — one GPUI view per route/panel. `content.rs` is the route switcher: server tool panels live in a `HashMap<ServerView, AnyView>` (one `tool_view()` match arm per panel — add new tool pages there), created on first visit and **dropped on route change** (`clear_views`), so in-view-only state does not survive navigation (persist via `ZedisAppState` if it must); only the editor suite (key tree / value editor / terminal) survives within a server session. A view that outgrows one file becomes a directory of `impl` blocks — `use super::*;`, methods `pub(super)`, one file per mode or tab: `topology/{sentinel,replication,cluster_nodes,cluster_slots,cluster_reshard,cluster_load}.rs`, `slowlog_editor/{latency,commandlog}.rs`, `servers/{form,transfer}.rs` (the form's Sentinel settings — master name, sentinel credentials — sit on the Advanced tab under the server type and are offered for Auto and Sentinel: Auto discovers a Sentinel and uses the name, so hiding them there would drop a saved name on the next edit), `key_tree/`, and on the state side `states/app/{route,servers}.rs`. Move methods whole; it is a relocation, not a rewrite. `root.rs`'s `Zedis` root holds sidebar + workspace tabs (`Vec<ContentTab>`, one `ZedisContent` per tab; only the active tab reacts to global route/server broadcasts) + title bar, and registers global `.on_action` handlers. `lib.rs` is the entry point (`run`, `init_caches`, `launch` — `main.rs` only calls `run()`, because the browser build enters through `zedis-web` instead); `dialogs.rs` holds the app-level dialogs (crash, welcome, SSH host key, update), `window_setup.rs` window placement + theme application, `startup.rs` the CLI flags, smoke gates and the database recovery window.

**Long-running background loops** (live tail, MONITOR): mirror the Monitor pattern — a cancellable `gpui::Task` stored on the *view*, a `cx.background_spawn` loop that asks the connection layer's stream type for the next item (`MonitorFeed::next_line`, `ChannelSubscription::next_message`, `StreamTail::next_batch` — the view never holds the connection), a `smol::channel` ferrying batches to a foreground drainer that updates state. Dropping the `Task` (stop toggle / key or server switch / view teardown) cancels the loop and its connection. Always key-guard appends so a stale batch after a key switch is discarded. A panel that *samples* on an interval while it is open (Server Load, Hot Keys) does not write that loop again: `views/panel_poll.rs`'s `PanelPoll::start(cx, server_state, interval, fetch, apply)` owns it — rounds skipped while `is_background()`, `refresh_now()` for the Refresh button — and sleeps on "the interval or a refresh" instead of waking in slices to check a flag. The same goes for any background *build* that a newer one can overtake: the key tree keeps its build as `build_task` and every rebuild replaces it, because a detached build that finishes late puts an older tree (a folder collapsed again, the previous filter's rows) over the newer one — and what it builds from is `server_state.keys().clone()`: the loaded keys are a persistent B-tree (`LoadedKeys`, `imbl::OrdMap`), so that clone is a pointer copy on the UI thread and already in key order, and a scan page that lands while a build holds it copies only the paths it touches. Never turn it back into a hash map copied per rebuild (11–28ms of UI thread per rebuild at a million keys, plus a sort per build), and write it with single-walk `insert` / `remove` rather than `entry`, which walks the tree twice.

**Actions / keybindings**: gpui `#[derive(Action)]` enums in `helpers/action.rs` and `states/app.rs`; bound in `new_hot_keys()` (`helpers/action.rs`); dispatched via `.on_action(cx.listener(…))`, mostly on the `Zedis` root in `root.rs`.

## GPUI gotchas (learned the hard way)

The general GPUI / gpui-component rules live in the `gpui-kit` and `gpui-kit-design-guides` skills (see *Agent skills*); this list is only what this codebase hit on top of them.

- **rustls has two crypto backends in this binary — pick one before any TLS.** The Redis stack (redis, tokio-rustls, ureq, russh) is built on `ring`; gpui-kit's HTTP client (`gpui-pre-http-client-tls`) enables rustls's default `aws-lc-rs`. With both present rustls refuses to choose and `ClientConfig::builder()` panics in whichever thread first dials TLS (a `rediss://` server, the update check). `run` (`lib.rs`) calls `install_crypto_provider()` (zedis-connection, ring, idempotent) before anything can connect, the TLS builder calls it too, and `rustls_has_a_process_level_crypto_provider` in `lib.rs` fails if the install is ever dropped — it lives in the app crate because that is the only crate where the two backends meet (the sub-crates' own test binaries only see `ring`, so the live TLS test never caught it).
- `gpui_kit::component::list::ListItem` **now** impls `InteractiveElement` + `StatefulInteractiveElement` (it did not before gpui-component 0.5.2, which is why several call sites still wrap one in a stateful `div().id(...)`/`div().group(...)` — that wrapping is still fine). Two caveats upstream spells out: listeners registered through those traits are **not** gated by `disabled`/`separator`, and the hover style is managed internally — use `.on_hover(...)`, not `.hover(...)`.
- **Text input is three types, not one.** gpui-component splits the editing engine by kind, and the element has to match the state: `InputState`/`Input` (single line — `masked`, `pattern`, `validate`, `prefix`/`suffix`, and the only one that is `Sizable`), `TextareaState`/`Textarea` (`auto_grow`, `rows`), `EditorState`/`Editor` (`language`, `line_number`, `folding`, `indent_guides`). `soft_wrap` / `searchable` / `tab_size` are shared by the two multi-line kinds. The **LSP lives on the editor only**, so a completion provider (`state.lsp_mut().completion_provider`) forces an `EditorState` even for a one-line bar — file it back down with `.line_number(false).indent_guides(false).folding(false).searchable(false).submit_on_enter(true)` and a pinned `.h(...)` (the JSONPath bar in `bytes_editor.rs`) — `folding` **defaults to true** and reserves the fold-icon hitbox on the left even with line numbers off, so forgetting it leaves the cursor floating a gutter's width from the field's edge. `Editor` already applies the theme's mono font, its own line height, and `focus_bordered(false)`; don't re-set them. Since gpui-kit 0.6.1 every `EditorState` also defaults `auto_close` (bracket / quote pairing) and `smart_indent` (Enter) to **on**; the rules come per language from `set_language_config` (`gpui_kit::component::input`) — gpui-component only ships text / json / python, `helpers/syntax.rs` registers Lua's next to its grammar — and a field that is not code opts out with `.auto_close(false)` (the JSONPath bar, the terminal's batch editor).
- **A large text is neither soft-wrapped nor highlighted.** gpui-kit 0.7's editor finds each visual row's break by *shaping* candidate prefixes of the line (`measured_wrap_boundaries`, about eight `layout_line` calls a row), for the whole text, on the UI thread, every shaped prefix kept in the frame's layout cache: about two seconds and 100 MB per megabyte of distinct text, so a 22 MB string of 300,000 lines froze the window for 48 s and took the process to 2.4 GB. `ZedisBytesEditor` therefore wraps only up to `SOFT_WRAP_MAX_BYTES` (512 kB; the status bar's Soft Wrap button is disabled with the reason above it) and switches the wrap off *before* a large text is set — the wrap is worked out at the next paint from the text then held — and back on only after a small one has replaced it. Past `MAX_INLINE_VALUE_SIZE` the text is also shown as `text`, not `json`: every value is highlighted as JSON, and the parser's error-recovery tree for 22 MB of anything else was 2.3 million nodes. Measure such a thing with text whose lines differ — identical lines hit the layout cache and show nothing — and in a release build; memory that does not come back after the value is left is the allocator's (the heap is freed), not a leak.
- **A line-numbered `Editor` reserves its own gutter.** Since gpui-kit 0.7 the line numbers take three to seven digits' width whatever the line count (`line_number_len` in gpui-base), so switching between a 20-line and a 900-line value no longer moves the text. Do not pad the editor's left edge to steady it — that is what the removed `stable_gutter_padding` did for 0.6, and on top of the built-in reserve it indents short values twice.
- **`Scrollable` + `max_h` silently clips instead of scrolling.** `overflow_y_scrollbar()` copies the caller's size styles onto its wrapper but the inner content keeps a copy too; the forced `h_auto` overrides a fixed `h` yet nothing resets `max_h`, so the content itself is clamped and the scroll range is zero. Give the viewport a definite `h(...)` (branching on content length for short-content inline rendering) — never `max_h` (fixed in the update dialog / trash dialog / migration preview; grep before copying those patterns).
- **Charts are gpui-kit's, built in `views/charts.rs`, and each needs its own id.** `make_line_chart` / `make_series_chart` (several series, or several lines — `LineChart` draws one) / `make_bar_chart` take a `ChartParams` whose `id` must be unique among the charts a view draws: a chart's default id is its construction site, every chart is constructed in that one file, and hover state and the path cache hang off the id, so two charts sharing one trade them. Samples are placed by index (`Sample`), so a repeated time label stays two points and two bars. A new chart goes through these three, not a `canvas` of plot primitives. A chart keeps ten pixels above its domain and labels them, so a percentage on a 0–100 axis reads "105%" at the top: `make_bounded_line_chart` ends the axis at `y_max`. On the Metrics page the x labels are thinned at render to what a chart is wide enough for (`tick_margin_for`), and a series that never left zero is drawn on 0–1 rather than on an axis of five zeros (`chart_params`). Since gpui-kit 0.7.1 a chart draws its data in the first time it is painted. That is once per id: a live chart (Metrics, which redraws every beat) paints later samples in place, and scrolling one out of view and back does not replay it — both checked on the Metrics page — so the default is kept; `.appear(false)` is for a chart drawn per row of a virtual list, which this app does not have.
- **A panel that closes takes the focus with it.** When the focused input leaves the tree nothing is focused, and an action bound to a key has no dispatch path to the handler on the content view: ⌘J did nothing after closing the kv-table entry panel until something was clicked. Hand focus to a handle that stays (`ZedisKvTable::focus_handle`, taken on the open → closed transition) — and only when the closing panel held it (`contains_focused`), or a panel that closes on a key switch pulls the caret out of the key filter.
- `TabBar::child(impl Into<Tab>)` only accepts `Tab`, so a context menu (`.context_menu(...)` returns `ContextMenu<Self>`), drag & drop, or middle-click can't be attached to its tabs. The workspace tab strip in `root.rs` is hand-rolled pills for exactly this reason.
- Clippy's `should_implement_trait` fires on `pub fn from_str` in **lib crates** (it stays silent in the bin crate) — name such constructors `from_name` (existing convention: `ServerView::from_name`, `TtlFilter::from_name`, `TagColor::from_name`).
- `InputState` placeholder / default-value strings must **not** contain `\n`. The single-line wrapped-lines cache sizes on the placeholder but renders the actual text, causing a byte-index panic. Put multi-line guidance in an adjacent `Label`, keep the input string single-line.
- **A dialog body that holds an `Input` must be a view entity, not inline elements** — `.child(move || some_view.clone())`, the shape `key_tag_dialog` / `copy_key_dialog` / `connection_diagnostics` use. `InputState` is itself a view (`impl Render for InputState`; `Input` mounts it via `.child(self.state.clone())`), and `ZedisDialog::child` is an `Fn() -> AnyElement` the dialog re-invokes every frame. Hanging the input off that rebuilt element tree is only *latently* broken: it survives a plain body, but add a `flex_wrap()` container as a sibling and the extra taffy measure pass corrupts every input in that body — text stops rendering (an `auto_grow` box then mis-sizes to `max_rows`), and a click resets the field to its initial value. All three conditions are needed, which is what makes it so hard to spot: an inline body *without* `flex_wrap` is fine (servers export, kv bulk-add), and `flex_wrap` in a plain view is fine (`vector_set_editor`, `topology`). Cost a long hunt in the ACL editor; don't rebuild it.
- `ZedisDialog` builds its own footer: a non-alert dialog renders **no buttons at all** unless `.ok_text(…)` (or `.footer_child(…)`) is set on the *`ZedisDialog`* — passing ok/cancel labels only to `.button_props(…)` silently yields a footerless dialog, because gpui_component's `Dialog::render` emits a footer solely via `when_some(self.footer, …)`. A `debug_assert` in `ZedisDialog::open` now catches an `on_ok` that nothing can reach. An alert (`new_alert` / `.alert()`) always shows Cancel beside OK: gpui-component's alert is OK alone unless `show_cancel(true)`, which left every confirm in the app with one button, the one that goes ahead. A confirm that destroys something adds `.danger()` with `.ok_text(…)` and `.cancel_text(…)`: OK is the danger variant and **Return answers Cancel** — the dialog draws those two buttons itself, because the stock ones send Return and a click through one `Confirm` action and cannot tell them apart. Delete-key and every `DangerKind::is_destructive()` confirm use it; an alert that deletes and does not yet is a candidate, not an exception. The typed-name confirm on production is the other shape (`open_typed_confirm` in `views/danger_confirm.rs`): its body and its footer are two view entities — the body because it holds an `Input` (next item), the footer because its button follows what is typed and a footer builder has no `cx`.
- `IconName` (gpui-component) is the default icon subset; since gpui-kit 0.6.1 the full Lucide catalog is `gpui_kit::assets::IconName` (with `icon_assets!` to embed only chosen ones) — verify a variant exists before using it. Custom SVGs still live in `crate::assets::CustomIconName`; every one of them now exists upstream, so that enum is a migration candidate, not a place to add to. Theme-derived colors must be read before a `move` render closure (can't borrow `cx` inside).
- **`Input`/`NumberInput` balloon inside grid cards — pin their height.** gpui-component's `Input` paints with `.size_full()`, `InputState` carries `.flex_1()`, and `NumberInput`'s root is itself an `h_flex().flex_1()`. In a normal form row those resolve against a bounded parent; inside a CSS-grid card (config editor) they resolve against the stretched track/scroll viewport and inflate the card into a huge empty frame. Fix: give text inputs an explicit `.h(px(32.))` (= `Size::Medium`'s `h_8`) plus a height-pinned `overflow_hidden` wrapper — and don't use `NumberInput` there at all (its root `flex_1` grows even when the wrapper is pinned; the settings page only gets away with it inside a fixed-width column of a horizontal row). Checkbox/dropdown keep natural height — pinning + `overflow_hidden` would clip them.
- **`Button.loading(true)` shows no spinner without an icon.** The loading spinner renders by replacing the button's *icon* (`when_some(self.icon, …)` in gpui-component's `Button::render`), so a label-only button just greys out with zero feedback. Any button that uses `.loading(…)` must also set `.icon(…)`.
- **A bare `div()` is `display: Block` — flex sizing on its children silently dies there.** gpui's `Style::default()` is Block; only `h_flex()`/`v_flex()`/`.flex()` make a flex container. A `flex_1()`/`h_full()` child under a plain `div()` wrapper falls back to content height (flex-grow needs a flex parent, percent needs a definite one), so a `uniform_list` — whose default `ListSizingBehavior::Auto` has **no intrinsic size** — collapses to zero rows: the value-search hit list rendered blank while its counter said 164. Any wrapper on the path from a sized ancestor down to a `uniform_list`/`h_full` consumer must be a flex container (`v_flex().flex_1().min_h_0()`, the value-search/geo-map chain) or give the list an explicit `.h(...)` (multi-search).
- **`Label` ignores a parent's `.text_color()`.** gpui-component's `Label::render` unconditionally sets `.text_color(cx.theme().foreground)` on its own div before refining with the label's style, so text color set on an ancestor container never cascades into it (unlike raw gpui text). Setting a container-level color and expecting muted labels silently renders full-foreground text — set `.text_color(...)` on each `Label` itself (container-level color still reaches `Icon`s, e.g. a ghost button's glyph).
- **Bold needs a concrete font family.** The default UI font (gpui-component's `Root` cascades `theme.font_family` = `.SystemUIFont`, i.e. `.AppleSystemUIFont` on macOS) resolves heavy weights poorly, and GPUI does **not** synthesize (fake) bold — so `.font_weight(FontWeight::BOLD)` alone often looks unchanged. Render the text in a real family via `.font_family(get_mono_font_family())` on the element (or on an ancestor — font-family cascades, which is why the key-tree badge looks bold: its `ListItem` sets the family for the whole row). The app bundles **JetBrains Mono** (Regular + Bold, registered in `lib.rs`) and `get_mono_font_family()` returns it, so the mono face is deterministic across platforms. Mind a family's actual faces: JetBrains Mono ships only Regular/Bold here, so `EXTRA_BOLD`/`BLACK` collapse to `Bold`.
- **Two things about windows are set on purpose.** Notifications stack from the bottom right, above the status bar (`place_notifications`, on the theme global, once at startup — it survives a theme change): the library's top right is where this app keeps New / Import / Export and the editor's key bar. And on macOS a secondary window draws its own title strip in the app theme's colours (`own_title_strip` in `views/secondary_window.rs`; the native bar is transparent, the window grows by the strip): the system's bar follows the *system* appearance, so Light-on-a-Dark-system opened Settings with a dark bar over a light page. The main window's minimum is `main_window_min_size()` (960×600 — the sidebar and the key tree do not shrink), and a saved size below it is grown on restore.
- **Every theme application resets the fonts.** `Theme::change` / `apply_config` rebuild `font_family` / `mono_font_family` from stock typography unless the config names a family, so `apply_fonts` (`helpers/font.rs`) writes the families into the light / dark slots as well as the live theme, `reapply_fonts` follows `apply_named_theme` / `restore_default_themes`, and `launch` (`lib.rs`) applies fonts *before* its theme application — keep that order. That application is `apply_startup_theme` (`window_setup.rs`): the saved named theme, else `restore_default_themes` + `Theme::change`. The restore is not optional there — it is what gives the default themes the brand primary, and without it a pinned Light or Dark started with the registry's neutral one (black buttons in light) until the theme menu was used. Since gpui-kit 0.6.1 `Theme::change` also probes every installed font (~100ms on macOS) when the mono family is still the platform default; that happens once inside `gpui_kit::component::init` (cached per process) and cannot be moved from the app.
- **Never size a form or an editor from the window minus the chrome bars.** The kv-table entry panel used to reserve its editor height as `viewport − title bar − status bar − key bar − footer − 60 per field`, and every constant in that sum drifted (fonts, an extra tab strip, a preview block) — the form ended short of or past the panel, and measuring-then-rebuilding on the next frame flickered. The reason it could not simply flex: `ZedisForm` lays its fields out in a grid inside `overflow_y_scrollbar`, and a scroll container gives a `flex_1` child no height. `ZedisFormOptions::fill_height()` + `ZedisFormField::fill()` is the answer for an inline form whose parent has a definite height: the form drops the scroll container, lays the fields out as a flex column (every other field `flex_none`), and the `fill` field takes what is left — the kv-table entry form with its flexible value column. A form is a twelve-column grid: a field takes the row unless it asks for a share with `.span(n)` (host 9 + port 3), and a radio group of phrases lays out one per line with `.stacked()`. It is for a form with *one* field that should grow; a form of many fixed-height fields (a stream entry, one editor per field) keeps the default scrolling grid, or it would overflow with no scrollbar. Dialogs are the exception where a computed height is right: a dialog body has to be definite to scroll, so `dialog_max_height(...)` / an `h(...)` derived from the viewport belongs there (the server form, the report dialog).

## Conventions

- UI components: **prefer `gpui-component`'s built-in components first** — the `gpui-kit` skill's component catalog is the list to check. Only when `gpui-component` has no suitable component, use the shared widgets in `crates/zedis-ui` (`ZedisCard`, `ZedisDialog`, `ZedisForm`, ...). Hand-rolling a one-off widget is a last resort.
- A menu item or a button that opens a dialog, a file panel or a confirmation ends in `…` (the character, never three periods); one that acts at once does not. The primary variant is for a form's or a dialog's submit — a tool page's own command (Start Analysis, Search, Run) is `outline`, or every page has a filled button and none of them means "default". The selected row of a list is marked by its fill, not by a bar down its edge; the key tree's left bar is the key's *tag* colour.
- **Density is a value someone chose.** A list of records is rows, not cards: a key tree row has 6px of vertical padding (`py_1p5`, ~31px a row — 8px showed 19 rows of a 100,000-key database in a default window, 4px showed 24 and read as cramped), the kv tables are `DataTable … .small()` (30px rows and 14px text — the densest size, 26px rows with 12px text, read as cramped too), a config parameter is a 28px row (`CONFIG_ROW_HEIGHT`), a connection is a three-row `ZedisCard`: the name, the address, then when it was used and the note (one detail line for all three read as cramped and cut every cloud host name; the time stamp is what keeps the last row from being blank). In a kv table the columns that identify a row are fixed and wide enough to be read whole (stream entry id 196, zset score 196, hash field 200, the row number for six digits) and the value takes the rest — those widths are measured at the table's text size, so changing the table's size means measuring them again (at 14px the 12px widths cut the last digits of every stream id). A toolbar that does not fit wraps (`flex_wrap` + `min_h`, each group `flex_none` — the memory-analysis and slow-log headers) rather than clipping its last buttons.
- What sits in shared chrome answers for the page on screen: the status bar's Soft Wrap / format / Viewer controls show only on the editor route with a string open (`show_editor_controls`), its counts name themselves when the bar has room (`shows_units`), and a trigger's tooltip is held back while its own list is open (`db_menu_open`). A row marks the exception, not the default — a key has a TTL chip only if it expires. First-visit guidance can be closed and stays closed (`dismiss_hint` + a `HINT_*` id, as the editor's guide card). A value loaded past the size gate shows its size and a Cancel, not a blank pane (`RedisValue::large_load_size`; `cancel_large_load` puts back what the load replaced and drops the reply still on its way). The Settings window lists its sections down the left and shows one: a new row goes after its `.section(…)` in `render`'s chain (`SectionRows`), a new section is a `SettingsSection` variant. Another view opens the connection form through the route (`ZedisAppState::edit_server` / `new_server`, read by `form_asked_for`), never by reaching into `ZedisServers`.
- Maintain README parity: `README.md` and `README_zh.md` are both kept in sync when features change. The README is the landing page — why, highlights, screenshots, install, a short table — and stays short: it had grown to 375 lines, 150 of them a deployment manual, and read as one. Detail goes to `docs/`, each page with its `_zh` twin: a feature to `FEATURES.md` (whose first table is the whole matrix), anything about running the web version to `WEB.md`, a Redis / Valkey difference to `REDIS_VALKEY.md`. Three headings are linked from outside and keep their text: *Code signing policy* (SignPath's registered URL, `SECURITY.md`, the site), *Web version (self-hosted)* and *Installation*.
- Destructive Redis ops (`FLUSHALL`, `XGROUP DESTROY`, key/server delete, …) route through a confirm dialog (`ZedisDialog::new_alert` + `dialog_button_props`); production-tagged servers escalate the wording. Two of them are shared functions in `views/danger_confirm.rs`, not dialogs to rebuild: `confirm_dangerous_command(server, db, kind, …)` — the title names the server (and for `FLUSHDB` the database), the button names the action (`danger.<kind>_confirm`, one per `DangerKind`: "Flush all", "Unlock", "Run" — a new kind adds its `_title` / `_body` / `_confirm` in every locale), and where `confirm_strictness` says `TypeName` (something destructive, or an unlock, on production) the answer is the server's name typed into the dialog, the button disabled until it matches and Return going ahead only then — and `confirm_delete_key`, the one-key delete of the editor and the key tree, whose body says whether the key can come back (the recycle bin keeps values up to 1 MB for 24 hours; it does not promise more than that).
- A colour that carries meaning is chosen per theme and measured: `KeyType::color(dark)` has a light and a dark value per type, each at least 4.5:1 on the key tree's selected row (a test holds a new or retuned one to that), and the status bar's healthy green is `healthy_color(dark)`. One fixed value for both themes is how the type badges came to be 2.3–3.0:1 in light. The environment chips (`resolve_tag_chip`) are held the same way, per tier and theme, and so is the format chip in a value cell (the foreground at 70%, not the muted colour, which was 3.7:1 on its tint).
- Keep the dependency surface lean (e.g. fuzzy matching is hand-rolled, Lua highlighting registers tree-sitter manually) — prefer a small in-crate implementation over a new dependency for self-contained needs.
- Imports: bring items into scope with `use` declarations at the top of the file and refer to them by their short name. Do **not** write fully-qualified paths inline (e.g. `crate::views::ZedisEditor::new(...)`, `crate::states::ZedisGlobalStore`); add `use crate::views::ZedisEditor;` and call `ZedisEditor::new(...)`. The only acceptable inline-path exceptions are disambiguating two same-named types or a single use inside a macro where a `use` would be awkward.

## Rust dependencies: check the current version before planning

- Whenever a plan or design involves introducing a crate, run `cargo info <crate>` (or `cargo search <crate>`) to find its current latest version on crates.io **before** designing concrete usage.
- Never assume a version number from memory — remembered versions are very likely stale (e.g. assuming `rmcp` is 0.x when the actual latest may be 3.x).
- Plan API usage, feature selection, and code style against the version you just looked up — major versions can differ drastically in API.
- If the latest version differs substantially from the usage you're familiar with, consult that version's docs/examples before writing code — don't apply old-version idioms to a new major.
- When actually adding the dependency, use `cargo add <crate>` so the version is written by cargo, not by hand.

## Agent skills

### GPUI / gpui-kit

Two upstream skills are vendored in `.claude/skills/` (from `longbridge/gpui-kit`'s `skills/`, the set `npx skills add longbridge/gpui-kit` installs):

- `gpui-kit` — load before any GPUI or gpui-component work. Its `references/coding-guides.md` is the normative coding guide (read *Architecture at a glance* and *Rules for coding agents* first, then the section for the change); `SKILL.md` holds the component catalog with import paths, and `references/gpui/*.md` the mechanics (actions, async, contexts, custom elements, entities, events, focus, globals, layout, `ElementId`, tests).
- `gpui-kit-design-guides` — load before changing anything with a visible surface: layout, spacing, hierarchy, color, density, component choice, interaction states, overlays, motion, data-heavy views, interface copy.

How the skills' code maps onto this repo — the only places they differ:

- The skills write `use gpui_kit::*;` for GPUI. Here GPUI stays `gpui::…` (the `gpui-pre` rename in `[workspace.dependencies]`, so `gpui::Context`, `gpui::test`, `gpui::Task` are the spellings in use — see the intro at the top). Component and asset paths are the same as in the skills: `gpui_kit::component::…`, `gpui_kit::assets::Assets`.
- `gpui_kit::init(cx)` in the skills is `gpui_kit::component::init(cx)` in `lib.rs` — the same function (kit's `init` calls it), with our theme loading after it.
- Their "one dependency" rule is deliberately not followed to the letter: the `gpui`, `gpui_platform` and `gpui_macros` renames sit next to `gpui-kit` so the import paths did not have to change; do not "fix" that, and keep them all (with `gpui_web`, five) on the same `gpui-pre` version.
- `#[gpui_kit::test]` in the skills is `#[gpui::test]` here (`TestAppContext` tests in `src/`).

Updating: `npx skills update` refreshes `gpui-kit-design-guides` (tracked in `skills-lock.json`). `gpui-kit` is copied by hand and is not in `skills-lock.json`: it was first vendored when the skills CLI rejected its unquoted `description:` (upstream quotes it since 0.6.1) — when bumping gpui-kit, re-copy `skills/gpui-kit/` from the upstream tag (a sparse clone of `longbridge/gpui-kit` at `v<version>`) over `.claude/skills/gpui-kit/`, deleting files upstream removed.

### Issue tracker

Issues live in this repo's GitHub Issues (`vicanso/zedis`), accessed via the `gh` CLI. See `docs/agents/issue-tracker.md`.

### Domain docs

Single-context: `CONTEXT.md` (the domain vocabulary) + `docs/adr/` (numbered decisions, e.g. version floors, the no-op read-only probe, `~/.ssh/config`, dedicated connections) at the repo root. Add an ADR when a choice is made that the code alone would not explain. See `docs/agents/domain.md`.

More agent context in vicanso/zedis

2 other files this repository gives its agents.

Skill

Discussion

Did it work?

Say what you used it for and what you changed. People and their agents can both post here.

Reports can't be read right now.

Posts are public. Sign in to say whether it worked for you.Sign in to post

Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.