From 24a0e027b39bc4ea725b53be4f057cee9f244cce Mon Sep 17 00:00:00 2001 From: binwenw Date: Tue, 6 Oct 2026 16:08:27 +0800 Subject: [PATCH 01/69] Add clipboard history with vault protection, CLI chooser, and GUI dialog --- Cargo.lock | 94 +- Cargo.toml | 45 +- README.md | 55 +- assets/icons/copy.svg | 1 + assets/icons/delete.svg | 1 + docs/plans/clipboard-history.md | 440 +++++++ examples/clipboard_ux_smoke.rs | 54 + migrations/0010_add_clipboard_history.sql | 62 + migrations/0011_add_clipboard_vault.sql | 18 + src/cli/commands/clipboard_terminal.rs | 382 ++++++ src/cli/commands/cp.rs | 635 +++++++++ src/cli/commands/mod.rs | 2 + src/cli/mod.rs | 39 +- src/clipboard/argv.rs | 236 ++++ src/clipboard/filter.rs | 122 ++ src/clipboard/items.rs | 162 +++ src/clipboard/mod.rs | 30 + src/clipboard/ordering.rs | 45 + src/clipboard/retention.rs | 203 +++ src/clipboard/settings.rs | 23 + src/clipboard/storage.rs | 914 +++++++++++++ src/clipboard/tests.rs | 47 + src/clipboard/vault.rs | 409 ++++++ src/clipboard/vault_controller.rs | 420 ++++++ src/ipc.rs | 18 + src/lib.rs | 1 + src/native/clipboard/mod.rs | 7 + src/native/clipboard/paste.rs | 159 +++ src/native/clipboard/watcher.rs | 480 +++++++ src/native/components/mod.rs | 4 + src/native/hotkey.rs | 153 ++- src/native/mod.rs | 64 +- src/native/power_guard.rs | 4 +- src/native/selection.rs | 89 +- src/native/windows/clipboard.rs | 1454 +++++++++++++++++++++ src/native/windows/clipboard/editor.rs | 294 +++++ src/native/windows/main.rs | 16 + src/native/windows/mod.rs | 8 + src/rusticker.rs | 3 + tests/clipboard_storage.rs | 770 +++++++++++ 40 files changed, 7941 insertions(+), 22 deletions(-) create mode 100644 assets/icons/copy.svg create mode 100644 assets/icons/delete.svg create mode 100644 docs/plans/clipboard-history.md create mode 100644 examples/clipboard_ux_smoke.rs create mode 100644 migrations/0010_add_clipboard_history.sql create mode 100644 migrations/0011_add_clipboard_vault.sql create mode 100644 src/cli/commands/clipboard_terminal.rs create mode 100644 src/cli/commands/cp.rs create mode 100644 src/clipboard/argv.rs create mode 100644 src/clipboard/filter.rs create mode 100644 src/clipboard/items.rs create mode 100644 src/clipboard/mod.rs create mode 100644 src/clipboard/ordering.rs create mode 100644 src/clipboard/retention.rs create mode 100644 src/clipboard/settings.rs create mode 100644 src/clipboard/storage.rs create mode 100644 src/clipboard/tests.rs create mode 100644 src/clipboard/vault.rs create mode 100644 src/clipboard/vault_controller.rs create mode 100644 src/native/clipboard/mod.rs create mode 100644 src/native/clipboard/paste.rs create mode 100644 src/native/clipboard/watcher.rs create mode 100644 src/native/windows/clipboard.rs create mode 100644 src/native/windows/clipboard/editor.rs create mode 100644 tests/clipboard_storage.rs diff --git a/Cargo.lock b/Cargo.lock index adb3917..8f8d6e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -770,6 +782,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -788,7 +806,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -859,6 +877,15 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block" version = "0.1.6" @@ -1793,6 +1820,34 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "filedescriptor", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -5992,6 +6047,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -7310,6 +7376,7 @@ version = "0.1.0" dependencies = [ "anyhow", "arboard", + "argon2", "async-compat", "async-trait", "base64", @@ -7319,6 +7386,7 @@ dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", "cron", + "crossterm", "directories", "futures", "gpui", @@ -7335,6 +7403,7 @@ dependencies = [ "notify 8.2.0", "notify-rust", "objc", + "rand 0.8.8", "raw-window-handle", "rdev", "reqwest", @@ -7344,7 +7413,9 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "sha2 0.10.9", "sqlx", + "tempfile", "tracing", "tracing-appender", "tracing-error", @@ -7859,6 +7930,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" diff --git a/Cargo.toml b/Cargo.toml index 0066137..525767e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +6,24 @@ default-run = "rustickers" [features] default = ["ui"] +# The clipboard history backend (SQLite + sha2 + chrono) is reused by both the GUI and the CLI. +# We expose it as an explicit feature so build variants that don't need it can stay slim. +storage = [ + "dep:sqlx", + "sqlx/migrate", + "dep:ring", + "dep:base64", + "dep:zeroize", + "dep:futures", + "dep:sha2", + "dep:argon2", + "dep:rand", + "dep:chrono", +] ui = [ "dep:sqlx", "sqlx/migrate", + "storage", "dep:gpui", "dep:gpui_platform", "dep:gpui-component", @@ -45,16 +60,25 @@ ui = [ "dep:base64", "dep:ring", "dep:zeroize", + "dep:argon2", + "dep:rand", ] cli = [ + "dep:crossterm", "dep:clap", "dep:futures", "dep:sqlx", + "storage", # The CLI validates what it is about to store, so a broken sticker is reported # at creation time instead of silently failing later inside the app. "dep:cron", "dep:which", "dep:winsplit", + "dep:arboard", + "dep:sha2", + "dep:zeroize", + "dep:ring", + "dep:base64", ] [[bin]] @@ -67,7 +91,12 @@ name = "rusticker" path = "src/rusticker.rs" required-features = ["cli"] +[[example]] +name = "clipboard_ux_smoke" +required-features = ["ui"] + [dependencies] +crossterm = { version = "0.29", features = ["use-dev-tty"], optional = true } gpui = { git = "https://github.com/zed-industries/zed", optional = true } # Enable GPUI's real macOS text backend; without this feature it installs # NoopTextSystem, which does not rasterize text. @@ -91,7 +120,10 @@ serde_json = "1.0.138" base64 = { version = "0.22.1", optional = true } ring = { version = "0.17.14", optional = true } zeroize = { version = "1.8.2", optional = true } -chrono = { version = "0.4.43", default-features = false, features = ["clock", "std"] } +sha2 = { version = "0.10.8", optional = true } +argon2 = { version = "0.5", optional = true } +rand = { version = "0.8", optional = true } +chrono = { version = "0.4.43", default-features = false, features = ["clock", "std"], optional = true } cron = { version = "0.17.0", optional = true } interprocess = "2.2.1" lyon = { version = "1.0.16", optional = true } @@ -126,6 +158,13 @@ objc = { version = "0.2.7", optional = true } core-foundation = { version = "0.10.1", optional = true } core-graphics = { version = "0.24.0", optional = true } +# Keep the memory-hard vault KDF responsive in debug builds without weakening it. +[profile.dev.package.argon2] +opt-level = 3 + +[profile.dev.package.blake2] +opt-level = 3 + [profile.release] strip = true # Automatically strip symbols from the binary. opt-level = "z" # Optimize for size. @@ -135,3 +174,7 @@ panic = "abort" # Removes stack unwinding (harder to debug crashes, but much [build-dependencies] winres = "0.1" + +[dev-dependencies] +chrono = { version = "0.4.43", default-features = false, features = ["clock", "std"] } +tempfile = "3" diff --git a/README.md b/README.md index 59ceb58..21b16db 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,65 @@ Built with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), - **File / URL preview**: preview files, folders, or URLs and pin them as stickers. Any number of previews can be open side by side; asking for the same files again raises the window that already shows them. - **Paint stickers**: freehand drawing space for quick sketches. +## Clipboard history + +A keyboard-first history chooser shares exact-text deduplication, search, ordering, and +SQLite storage with the CLI. Ordinary history is bounded by count and bytes; reserved, +edited, and protected items are retained independently. Collection starts **paused** so +copying a password does not silently opt you into recording it. + +```bash +rusticker cp ls # browse: type, ↑/↓, Enter, Esc +rusticker cp ls -s rust # start with a filter +rusticker cp ls --list -n 20 # readable recent-history table +rusticker cp ls --list --json # one machine-readable JSON object +rusticker cp ls -- rg -F -- '{{cp}}' . # pick and substitute into argv +rusticker cp ls --id 42 -- my-tool # append the exact text as one argument +rusticker cp import 'text from a script' +rusticker cp import --stdin # read a pipeline without trimming it +rusticker cp show --id 42 # exact text, no extra newline +rusticker cp forget 42 # release ordinary saved/edit retention +rusticker cp vault create # hidden master-password setup +rusticker cp import --protect --stdin # authorize, encrypt, then store piped text +rusticker cp vault status +``` + +Quote `'{{cp}}'` in PowerShell/POSIX shells, or `"{{cp}}"` in Windows cmd. The CLI +uses the parsed argument vector directly: spaces, quotes, newlines, and shell punctuation +remain data. Child stdin/stdout/stderr are inherited unchanged; chooser/password prompts +use the controlling terminal. Cancellation returns 130; missing/non-executable programs +return 127/126; otherwise the child exit code propagates. Use child-supported `--` to +prevent clipboard text beginning with `-` from becoming an option. **Explicitly passing +clipboard text into a shell/interpreter's code argument can still execute it.** + +Open/dismiss the desktop chooser with `Ctrl + Alt + V` (`Cmd + Alt + V` on macOS). +Type to search, use arrows/Page keys to select, and press Enter to paste; Ctrl/Cmd+Enter +copies only. Single-click selects, F2/double-click edits, and Ctrl/Cmd+R toggles reservation. +Ctrl/Cmd+L protects the selected item after password authorization; encrypted edits are saved +in place. The chooser uses the existing theme and a virtualized list, with visible actions. +Verified automatic paste is currently Windows-only; unavailable or lost destinations fail +closed and leave **Copy only** available. + +Protected imports are encrypted before insertion and never fall back to ordinary history. +Protected text is not searchable; only an explicitly public alias is visible. CLI password +authorization is scoped to that invocation, **not shared with the desktop process**; +`cp vault unlock` verifies a password rather than unlocking subsequent commands. Automatic +collection pauses whenever protected history exists until safe protected recapture matching +is implemented. Encryption does not erase copies held by other apps, OS history, backups, +or historical database artifacts; protection hardening is still in progress. GUI deletion and +unprotecting are not available yet; existing protected rows remain retained. + +See the [clipboard-history plan](docs/plans/clipboard-history.md) for the contract and +remaining platform/security acceptance work. + ## Global hotkeys - **Show main window**: `Ctrl + Alt + R` (macOS also supports `Cmd + Alt + R`) +- **Toggle clipboard chooser**: `Ctrl + Alt + V` (macOS `Cmd + Alt + V`) — paste back into the + previously focused app - **Toggle quick file preview**: `Ctrl/Cmd + Alt` - **Open command for selected text**: `Ctrl/Cmd + Space` (consumed instead of forwarded to the active app) -- **Dismiss transient Rustickers windows**: `Esc` is consumed for selection popups, quick previews, and selected-text command runs +- **Dismiss transient Rustickers windows**: `Esc` is consumed for selection popups, quick previews, the clipboard chooser, and selected-text command runs - **Save in editor/markdown views**: `Ctrl + S` ## Quick start diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg new file mode 100644 index 0000000..6f51c5c --- /dev/null +++ b/assets/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/delete.svg b/assets/icons/delete.svg new file mode 100644 index 0000000..49b3365 --- /dev/null +++ b/assets/icons/delete.svg @@ -0,0 +1 @@ + diff --git a/docs/plans/clipboard-history.md b/docs/plans/clipboard-history.md new file mode 100644 index 0000000..9bc35c6 --- /dev/null +++ b/docs/plans/clipboard-history.md @@ -0,0 +1,440 @@ +# Clipboard history: product and implementation plan + +**Status:** Implemented locally and under verification; cross-platform/protection release gates remain open. +**Goal:** Automatically collect copied text and make it reusable from a clean, keyboard-first GUI and a safe interactive CLI. 所有操作和使用符合人体工程学:少按键、少切换、可预测、可撤销。 + +Use this document as the implementation checklist and decision record. Do not mark a task complete without the evidence required by its milestone. + +## Current implementation status — October 6, 2026 + +The milestone/evidence notes below include historical claims from the initial implementation; +this audit supersedes them where they disagree with current code. Do not treat this plan as +proof that clipboard protection or every platform is release-ready. + +- CLI chooser now uses raw controlling-terminal input, type-to-filter, arrows/Page keys, + Enter and Esc/Ctrl+C, and restores terminal state before child launch. `--search` prefills + interactive filtering; `--list` respects human/JSON mode. Clap owns child argv after `--`. +- Direct child execution preserves streams and status. Selection is reread after choosing + and authorization. Raw `cp show` preserves exact text without appending a newline; + JSON output uses the standard envelope. +- Protected import authorizes and encrypts **before** insertion. No failed-protection + plaintext fallback. CLI authorization is process-local; standalone `vault unlock` + verifies credentials, not a persistent desktop or cross-process session. +- Ordinary dedupe is exact (case/whitespace included); edited digests are updated. + Insert/pruning and limit changes are transactional. Protected previews and search do + not consult plaintext metadata; flag-only protection is refused. Vault recreation is + refused, expiration revokes cryptographic access, and stale editor saves fail. +- Desktop history has virtualized themed rows, stable selection, explicit copy/paste, + reservation and a single editor. Automatic paste restores/verifies the destination + on Windows and fails closed elsewhere. Ctrl/Cmd+L authorizes an atomic, generation-bound + ordinary-to-protected transition; encrypted editor saves use the same generation checks. Collection is opt-in, and remains paused while + protected items exist rather than duplicating secrets as ordinary plaintext. +- Remaining acceptance work includes active-use leases, ciphertext identity/version AAD, + historical plaintext/journal/backup analysis, Windows ownership-conditional expiry validation, + OS exclusion validation, deletion/undo and unprotection UX, native macOS/Linux paste adapters, + IME/accessibility/DPI checks, and measured latency/overhead. Do not mark M4/M5 complete. + +## 1. Scope and decisions to confirm + +### Confirmed requirements + +- Automatically track copied text while the collector is running. +- A global shortcut opens searchable clipboard history; typing filters, Enter uses the selected item in the previously focused application. +- Protected items prompt for the master password before use. +- `Ctrl+L` protects an item; `Ctrl+R` reserves it; double-click edits it. +- Edited, protected, and explicitly reserved items are never evicted by the normal history limit. +- Ordinary history is bounded. GUI and CLI share filtering, retention, and sorting behavior. +- `rusticker cp ls -- some_cmd xxx {{cp}} xxx` opens a type-to-filter chooser. Enter substitutes the selected text; without `{{cp}}`, append it as one argument. +- The interface should be clean, ergonomic, and consistent with the existing desktop app. +- The exact global binding is delegated: choose a convenient, configurable shortcut, not a mandatory `Ctrl+Home+.` chord. +- `Ctrl+L` protects only the selected item, not the whole history. +- CLI command use is execution-only: stream the child's output and propagate its exit status. Do not print a generated command or substitute clipboard text as the result. + +### Confirmed decisions and remaining proposed defaults + +D1–D3 are confirmed by the user. D4–D9 remain proposed defaults, not approvals. + +| ID | Decision | Status / consequence | +| --- | --- | --- | +| D1 | Convenient global shortcut | Confirmed: exact binding is delegated; `Ctrl+Home+.` is not required. Choose a compact-keyboard-friendly, configurable chord after checking conflicts. Candidate: Ctrl+Alt+V (Cmd+Alt+V on macOS), subject to platform verification; also expose a main-window action. | +| D2 | Item lock vs. whole-history lock | Confirmed: `Ctrl+L` protects only the selected item using the shared master password. Clearing temporary unlock state does not lock ordinary history. | +| D3 | CLI execution and output | Confirmed: with a command after `--`, Enter substitutes/appends the selected text, executes directly, streams child stdout/stderr, and propagates the child exit status. No print-command mode or parent-shell prompt insertion. | +| D4 | Normal history limit | 500 ordinary items, configurable. Add a 20 MiB ordinary-text budget and a 1 MiB per-capture limit as initial safeguards, subject to measurement. Oversized captures are skipped with a visible, non-modal explanation, never truncated silently. | +| D5 | Ordering | One shared MRU order for all items; reserved/protected/edited items receive badges, not a separate sort tier. Successful reuse and a new copy promote an item. | +| D6 | Unlock lifetime | One-shot unlock for paste/CLI execution; temporary unlock in the editor, cleared on close or inactivity. No background sharing of an unlocked master key with other processes. | +| D7 | Collection lifecycle | Desktop app owns collection while running, including when its main window is closed. CLI can query stored history without the app. Headless `cp watch` is a later phase, not an implicit daemon started by `cp ls`. | +| D8 | First-run privacy | Explain that copied secrets can enter ordinary history; require explicit enablement once, then collect automatically. Offer pause and clear controls. | +| D9 | Retention after removing protection | Remember each retention reason independently. Unprotecting an item does not automatically make it disposable; releasing saved retention requires an explicit action. | + +**Confirmation gate:** D1–D3 are settled. Resolve the initial privacy/locking model and remaining proposed defaults before implementation. The UX below is a provisional interaction brief, not a finalized visual design. Before building the GUI, confirm its layout against the current app and capture the agreed visual direction. + +### Non-goals for the first release + +Images, rich clipboard formats, file-copy history, cloud sync, OCR, AI categorization, password recovery, browser extensions, command rendering/parent-shell prompt insertion, and arbitrary shell evaluation. Capturing every intermediate copy cannot be promised when an OS provides only the latest clipboard value; document platform limitations honestly. + +## 2. Shared behavior contract + +### 2.1 Capture and identity + +1. Watch OS clipboard changes rather than keypresses: collect mouse-menu copies, programmatic copies, and text from any application where the OS exposes it. +2. Preserve text exactly: whitespace, Unicode, line endings, and multiline content. Ignore empty text, not whitespace-only text. Normalize only search keys and previews. +3. Deduplicate ordinary entries by exact text. A repeated copy updates `last_copied_at` and recency instead of creating another row. Confirm content equality, not just a hash match. +4. Keep IDs stable across filtering, promotion, editing, and reservation. Edit in place; do not merge saved/protected records simply because their contents match. +5. Once edited, an item is user-owned. An incoming copy must not overwrite its content or retention reasons; a matching copy may promote it without changing it. +6. Start by capturing the current clipboard once after enablement. Pause means no capture and no backlog; resume reads the then-current value once. +7. Retry a busy clipboard with bounded backoff. Surface persistent failure or missing permission in status/settings without stealing focus. +8. Suppress Rustickers-owned clipboard transactions: selection-capture markers/restores, history paste writes, and protected-copy writes. Serialize internal write/capture transactions with the collector, using native generation/ownership data where available—not text matching or a broad time window alone. +9. Existing selected-text command capture must not generate synthetic history rows. Decide explicitly whether its genuine captured selection is ingested once; proposed default: yes, unless protected or excluded. + +### 2.2 Retention and eviction + +Keep independent, durable retention reasons: + +- `explicit`: user pressed Reserve. +- `edited`: a changed edit was successfully saved (opening/canceling/no-op saving does not count). +- `protected`: the item was successfully password-protected. + +```text +retained = explicit || edited || protected +ordinary = !retained +``` + +A protected envelope is always retained, even if metadata is damaged. Unprotecting clears encryption, not the historical `protected` retention reason. “Make ordinary” explicitly clears retained reasons only after protection is removed and the user confirms the consequence. + +- Ordinary item count and ordinary bytes are bounded; retained items do not consume either allowance. +- On ingest, trim the least recently active ordinary items in the same transaction. Lowering limits also trims ordinary items only, after showing the impact. +- Use the same recency key as the displayed ordering; never evict a retained row to satisfy an ordinary limit. +- Reserving/protecting/saving edits is atomic with its retention change. Concurrent capture cannot evict an item halfway through saving it. +- Hold a temporary eviction lease on an item being edited, unlocked, or used; this lease is not a permanent retention reason. Release it on success, cancel, timeout, or process exit. If no ordinary victim is available, skip the incoming capture rather than deleting active/saved data. +- `Ctrl+R` toggles the explicit reason only. If an item is also edited/protected, removing explicit reservation still leaves it retained; explain this in the badge tooltip/status message. +- Retained history is not silently capped. Show retained storage usage and warnings, plus explicit cleanup/export actions later. Disk-full errors must leave existing data intact. +- Delete is always explicit, with undo for ordinary deletions and confirmation for saved/protected deletions. Automatic eviction is not undoable by retaining a hidden, unbounded archive. + +### 2.3 Ordering and search + +Shared default ordering: + +```text +recency_at DESC, created_at DESC, id DESC +recency_at = max(created_at, last_copied_at, last_used_at, last_edited_at) +``` + +Reserve/protect/unlock alone do not reorder a row. Apply promotion after an interaction completes, not while the user is choosing. Keep the highlighted ID stable when new captures arrive; if it disappears, select the nearest surviving row. Search changes select the first match. + +- Case-insensitive Unicode-aware token matching: all query tokens must match. No fuzzy score reordering in v1; filtered results retain the exact same MRU ordering in GUI and CLI. +- Search ordinary/unprotected content and optional user labels. Protect against stale asynchronous query results. +- Protected rows expose only a generic “Protected item” label or an explicitly public user alias. Never persist/index a plaintext preview, title derived from content, or plaintext content hash for a protected item. +- Locked protected content is not searchable. Both surfaces say so; reserved/protected-only filters allow discovery without leaking text. +- Preview text is shortened and escapes control characters; the stored text and pasted/argument payload remain unchanged. Terminal escape sequences from clipboard content must never execute in the chooser. + +## 3. GUI interaction brief + +**Job:** retrieve a past copy without losing the current task or destination. **Mode:** Operate. The primary action is paste; management actions stay secondary and discoverable. + +### Main flow + +1. Global shortcut snapshots the foreground destination **before** opening any Rustickers window. +2. Open one transient history window near the active display's center, constrained to its work area. Repeating the shortcut dismisses it; it must not replace the saved destination with Rustickers itself. +3. Search field receives focus, first row is selected. Type → filter; arrows → select; Enter → use. +4. For a protected row, show an inline master-password prompt. Preserve query, selection, and destination. Enter submits; wrong password stays in place with an error; Esc cancels the prompt back to the chooser. +5. On successful authorization, hide the chooser, wait until shortcut modifiers are released, restore and verify the original destination, write plain text, and synthesize the platform paste action. +6. If the destination has disappeared, is elevated/inaccessible, or focus restoration fails, do not paste into an arbitrary window. Offer “Copy only” and let the user paste manually. +7. Record `last_used_at` only after successful clipboard/paste dispatch. OS dispatch is not proof the destination accepted the paste; do not claim guaranteed delivery. + +Do not immediately restore the previous clipboard after paste: applications may read it asynchronously. Normal selection remains on the clipboard. Protected text receives best-effort timed clearing, conditional on its ownership/generation being unchanged, and a warning that OS history, other managers, and destination apps may retain it. + +### Keyboard and mouse contract + +| Input | Chooser behavior | +| --- | --- | +| Configurable global shortcut | Open/dismiss history; convenient default chosen after conflict checks; also available from the main window | +| Printable text / IME input | Filter without moving focus away from search | +| Up / Down | Move selected row; never interpret IME candidate navigation as list navigation | +| Page Up / Page Down | Move by visible page | +| Enter | Paste selected item; request password if required | +| Ctrl+Enter | Copy only; do not synthesize paste | +| Ctrl+R | Toggle explicit reservation; announce retained reasons | +| Ctrl+L | Protect selected item; configure master password if absent; already protected → explain status | +| F2 / double-click | Edit selected item, unlocking first if protected | +| Delete | Delete selected row, with safety/undo as above; text editing retains its normal Delete behavior | +| Esc | Cancel the innermost prompt/editor, then dismiss chooser; never accidentally send content | +| Tab / Shift+Tab | Move through visible controls with a clear focus indicator | + +Primary-modifier equivalents on macOS must be documented. `Ctrl+L`, `Ctrl+R`, and Delete are chooser-scoped, never global grabs; the editor preserves standard text-editing shortcuts. In the multiline editor, Enter inserts a newline, Ctrl/Cmd+S saves and returns to history, and Esc cancels (confirm only if dirty). + +Single-click selects only. **Do not paste on single-click**, because double-click must edit. Add a secondary Paste button/context action for mouse-only users. Keyboard paste normally takes shortcut → optional typing → Enter; no confirmation for ordinary reuse. + +### Clean UI and accessibility requirements + +- Reuse GPUI, existing theme, inputs, icons, focus/dismissal behavior, and transient-window patterns; clipboard history is not a new sticker type. +- Provisional hierarchy: search header → compact virtualized list → small contextual shortcut/status footer. Optional expanded text preview on demand; no dashboard, card grid, or always-open management sidebar. +- Each row: short readable text preview, subdued recency, distinct selected state, and small labeled/status badges for Edited, Reserved, Protected. Avoid redundant icons and color-only distinctions. +- Support both existing light/dark themes where available, display scaling, keyboard focus, text contrast, screen-reader labels, and reduced motion. Do not hide necessary actions exclusively behind hover. +- Fit small screens and high-DPI displays; long URLs, CJK, emoji, tabs, and multiline clips must not break row height or horizontal layout. +- Empty history: explain collection and enable/pause state. No matches: preserve the query and offer Clear filter. Include loading, paused, permission-denied, storage-full, deleted-during-selection, unlock-error, and paste-failed states. +- Announce reservation/save errors without modal interruption. Never show a “Saved” badge until persistence succeeds. + +## 4. CLI contract + +### Interactive use + +```bash +rusticker cp ls # browse history only; no raw-text output or execution +rusticker cp ls -- some_cmd xxx '{{cp}}' xxx # choose, replace argument placeholder, execute +rusticker cp ls -- some_cmd xxx # choose, append as one argument, execute +rusticker cp ls -- rg --fixed-strings '{{cp}}' . # child flags belong to the child after -- +rusticker cp ls -- some_cmd 'prefix={{cp}}' # embedded placeholder stays in one argument +``` + +Quotes around the placeholder protect it from the invoking shell; they are not added to the text passed to the program. Supply shell-specific examples in final help (POSIX shells, PowerShell, Windows cmd). + +- Type to filter, arrows/Page keys to select, Enter to confirm, Esc/Ctrl+C to cancel. Use the same shared ordering and retention badges as GUI. +- Use the controlling terminal for chooser/password I/O, including when stdin/stdout are redirected. Keep stdout free for child output only; restore terminal state on every exit path. +- Protected items prompt with hidden input; never accept a master password as an argument or environment variable. Cancel performs no execution or promotion. +- Validate the command/placeholder position before opening the chooser where possible. Re-read/lease the chosen row before execution to handle concurrent deletion/editing. +- With a child command, restore terminal state before launch, inherit/forward its stdin, and stream stdout/stderr directly without capturing, reformatting, or adding success banners. Propagate its exit code. Cancel returns 130; operational errors return a documented nonzero code with no execution. +- Proposed browse-only behavior: without a trailing command, show history but emit no raw selected text and execute nothing; explain that `-- ...` is required for command use. Do not invent a default executable. +- `cp ls` must not create a command sticker or require a GUI build. Without a running collector, it reads the persisted history and explains that new copies are not being collected. + +### Argument substitution and “proper quoting” + +Use the argument vector already parsed by the invoking shell. **Never join it into a string, re-split it, or evaluate selected text as shell syntax.** + +1. The first trailing token is a fixed executable; reject `{{cp}}` in that token. +2. Replace every `{{cp}}` occurrence in argument tokens with the exact selected text, without recursive replacement. +3. If no argument contains the placeholder, append the text as exactly one argument. +4. Launch with `std::process::Command`. Quotes are not part of argv; the platform launcher handles transport escaping. Spaces, quotes, newlines, `$()`, `&`, and `;` remain data. +5. NUL cannot be represented in a process argument: reject it with a clear error, not silent truncation. Reject excessive platform argument lengths with a helpful suggestion; a future explicit stdin mode can support large payloads. +6. Quoting does not prevent the child's own option interpretation. Recommend fixed positional placeholders and child-supported `--` terminators for untrusted text beginning with `-`. +7. A user can explicitly run `sh`, `pwsh`, or another interpreter, but substitution into its code argument can execute clipboard content. Warn/document this; v1 must not advertise shell-code interpolation as safe. + +There is no command-rendering mode: do not implement `--print-command`, print expanded command lines, or insert them into the parent shell. Show only child output during execution. A child may itself print selected secrets; authentication authorizes passing the text to that program, not a guarantee that the program keeps it private. + +### Non-interactive and management surface (proposed) + +| Command / option | Contract | +| --- | --- | +| `cp ls --list [-s ] [-n ] [--json]` | Non-interactive listing; protected rows expose metadata only | +| `cp ls --id -- ...` | Deterministic command execution without a chooser; protected rows still require terminal authentication; no raw-text output mode | +| `cp reserve ` / `cp unreserve ` | Change explicit reservation only | +| `cp edit ` | Edit via a documented editor contract; protected editing never writes plaintext temporary files by default | +| `cp protect ` / `cp unprotect ` | Require terminal authentication; keep saved retention on unprotect | +| `cp delete ` | Confirm saved/protected removal; explicit force flag for automation | +| `cp status` / `cp pause` / `cp resume` | Show collector/storage state; pause/resume requires an active collector | +| `cp config` | Set normal limits and collection preferences | + +This table is a follow-up surface, not a requirement to implement every command in the first slice. Final argument spelling should be checked with Clap and existing CLI conventions. + +Non-TTY invocation without `--list` or `--id` fails with an actionable error, never hangs. `--json` implies non-interactive listing when no child command is supplied; reject combining it with child execution to preserve the existing “one JSON object on stdout” convention. Do not expose protected text in JSON listings or incidental diagnostics. + +## 5. Password and privacy design + +“Locked” must mean encryption at rest, not just a hidden preview. + +- One clipboard master password, separate from existing per-sticker passwords. Do not silently migrate/change sticker locks. +- Use a random vault data key wrapped by a password-derived key. Version the KDF parameters and envelope. Choose/review a memory-hard KDF such as Argon2id, or document a reviewed alternative; benchmark interactive unlock on target hardware off the UI thread. +- Encrypt each protected payload with authenticated encryption and a fresh nonce. Bind item identity/envelope version as associated data. Password changes rewrap the data key atomically rather than rewriting every item. +- Protection must replace plaintext and remove derived previews, search records, and dedupe keys in one transaction. Editing a protected item re-encrypts it; unlocking never persists a plaintext copy. +- Zeroize owned password/key/plaintext buffers where practical, clear GUI/password states, and do not log text, passwords, decrypted previews, expanded secret commands, or clipboard-derived error payloads. Ordinary clipboard content must not appear in logs either. +- The current clipboard, OS clipboard history, other managers, backups, SQLite journal/WAL/free pages, crash dumps, and target processes are outside a simple row-encryption guarantee. Review secure deletion, journal settings, vacuum/backup behavior, and residual plaintext before claiming protection for previously ordinary rows. SQLite row encryption alone does not erase old bytes. +- On a fresh later copy of the same protected secret, avoid creating an ordinary duplicate. Proposed approach: use a reviewed keyed fingerprint, with its key available only during authorized unlock; while no safe protected-match check is available, pause automatic collection if protected items exist. Make this state obvious. Decide whether this privacy tradeoff is acceptable before shipping protection; do not invent a persistent plaintext hash or claim deduplication guarantees while locked. +- Avoid heuristic “secret detection” promises. Honor explicit platform sensitive/exclusion hints where available; provide pause/exclude controls and clearly communicate their limitations. +- Unlock failures use bounded backoff without destroying data. Corrupt envelopes and wrong passwords both preserve the record. A forgotten password cannot recover protected content; resetting requires explicit confirmation and must not delete ordinary history. +- GUI and CLI independently authenticate. Do not add a plaintext/key-export IPC endpoint. If a future background vault is introduced, specify authorization and unlock lifetime separately. + +**Security release gate:** document the threat model and pass residual-plaintext, recapture, logging, terminal-output, password-change, and concurrent-access tests before exposing “Protect” to users. + +## 6. Architecture and integration map + +Verified starting points (source takes precedence over older architecture summaries): + +| Existing path | Reuse / required work | +| --- | --- | +| `src/rustickers.rs`, `src/native/mod.rs` | App startup and event pump; start/stop collector without blocking GPUI | +| `src/native/hotkey.rs`, `src/ipc.rs` | Existing platform hotkeys and action dispatch; add the chosen convenient shortcut, remapping/conflict behavior, and clipboard chooser event | +| `src/native/windows/selection.rs`, `transient_topmost.rs`, `windows/mod.rs` | Searchable selection popup, activation and Esc conventions; adapt patterns, not sticker assumptions | +| `src/native/selection.rs` | Currently writes a marker, simulates Copy, and restores previous text; add shared internal clipboard-transaction suppression before collection starts | +| `src/native/components/stickers/content_lock.rs` | Existing UI-coupled per-sticker PBKDF2/authenticated encryption; useful precedent, not a shared-master vault implementation | +| `src/storage/sqlite.rs`, `src/storage/paths.rs`, `migrations/` | SQLx store and data paths; new clipboard tables/migration, transactions, indexes, contention tests. Last current migration is `0009_add_sticker_placements.sql`; choose the next free number when implementing. | +| `src/cli/mod.rs`, `src/cli/commands/`, `runtime.rs`, `output.rs` | Register `cp`, preserve file-path shorthand and human/JSON contracts | +| `src/native/components/stickers/command/runner.rs`, `src/cli/shell.rs` | Existing argument substitution/launch precedent; do not import GPUI/native command-sticker code into the CLI-only path | +| `Cargo.toml`, `src/lib.rs` | Keep shared history/vault logic GUI-independent. `arboard`, `ring`, and `zeroize` are currently UI-only; review feature wiring and new terminal/KDF dependencies. | + +### Proposed deep modules + +- `src/clipboard/`: shared history interface owns identity, queries, retention, promotion, editing/protection transitions, and transaction rules. GUI/CLI must not independently calculate eviction or sort order. +- `src/clipboard/vault.rs`: versioned encryption, password setup/change, and scoped unlock interface, without GPUI or terminal dependencies. +- `src/clipboard/command.rs`: pure argv transformation plus direct process launch and child stream/exit-status forwarding. No command-rendering path. +- `src/storage/clipboard.rs`: SQLite implementation behind the history interface; avoid expanding `StickerStore` with unrelated clipboard methods. +- `src/native/clipboard/`: real platform adapters for monitoring, internal-write suppression, destination snapshot/focus restoration, and paste. +- `src/native/windows/clipboard.rs`: chooser/editor views and interaction states only. +- `src/cli/commands/cp.rs`: Clap definitions, terminal chooser/password flow, output, and process exit handling. + +Inject a clock and clipboard/destination adapters at actual seams. Keep ranking, retention, and argv transformation deterministic so tests exercise the same interface the GUI and CLI use. Add abstractions for concrete platform/test variations, not one trait per helper. + +### Logical persistence model + +`clipboard_items`: stable ID; ordinary text **or** protected envelope (mutually exclusive); optional public alias; retention-reason flags; creation/copy/use/edit timestamps; byte size; privacy-safe dedupe metadata only where allowed. Index recency and ordinary-retention predicates. + +`clipboard_settings`: normal count/byte limits, capture enablement/preferences, schema version. `clipboard_vault`: wrapped data key and versioned KDF parameters. Protection writes must never leak plaintext into sticker tables. + +Prefer the existing app data directory; decide whether clipboard tables share `stickers.db` or use a separate `clipboard.db` after the SQLite residual-plaintext review. A separate database may simplify security/maintenance without rewriting unrelated stickers. Document the decision before migrations. + +Only one collector owns clipboard watching per user session. Desktop and a later `cp watch` must coordinate ownership; ordinary `cp ls` never starts a second watcher. Mutations from GUI/CLI are transactional with bounded busy retries. Query results carry stable IDs; clients revalidate stale selections. + +### Platform delivery + +- **Windows first:** clipboard update notifications, foreground HWND/process snapshot, verified focus restore, paste via native input; test non-elevated → elevated failure, compact keyboards, non-US layouts, rapid copies, and multi-monitor DPI. +- **macOS:** pasteboard change tracking; accessibility/input permissions and Cmd paste; validate focus restoration, Spaces, and a convenient shortcut on compact/non-US keyboards. +- **Linux:** separate X11 and Wayland capability checks. Wayland may disallow background clipboard access, global shortcuts, and synthetic paste without compositor/portal support. Provide explicit copy-only/manual invocation fallback; do not claim parity without evidence. + +## 7. Milestones and tracking + +All implementation tasks start unchecked. Deliver vertical slices; each milestone should leave a usable, testable state. + +### M0 — Confirm contracts and technical feasibility + +- [x] Inspect README, manifest, hotkeys, selection capture, storage, CLI, and existing locking implementation. +- [x] Draft shared semantics, UX/CLI contracts, risks, and implementation sequence. +- [x] Confirm D1–D3: convenient shortcut delegated, selected-item protection only, CLI executes and streams child output only. +- [ ] Approve retention, privacy, capture lifecycle, unlock defaults, and browse-only CLI behavior. +- [ ] Choose/test a convenient configurable shortcut; spike native capture, destination restoration, and paste on Windows. +- [ ] Resolve secure protected-recapture policy, cryptographic design, and shared vs. separate database. +- [ ] Confirm GUI interaction/layout with real clipboard samples and existing app screenshots; record the approved direction. + +**Exit evidence:** approved decisions recorded here; platform capability notes; no unresolved security model hidden behind a UI lock icon. + +### M1 — Shared history + persistent bounded capture + +- [x] Add GUI-independent history model/interface and migration(s). +- [x] Implement exact ordinary dedupe, shared ordering/filtering, independent retention flags, and transactional pruning. +- [ ] Add capture lifecycle/status and internal-write suppression, including current selection capture. +- [ ] Implement Windows watcher; persist across app restarts; pause/resume; busy/storage/oversize handling. +- [x] Add non-interactive `cp ls --list [--json]` as an inspection surface. + +**Evidence (M1):** `src/clipboard/{items,filter,ordering,retention,settings,storage}.rs`; migration `migrations/0010_add_clipboard_history.sql`; 19 unit tests in `src/clipboard/*`, 5 SQLite integration tests in `tests/clipboard_storage.rs`. The CLI-only build remains GUI-free (`cargo check --bin rusticker --no-default-features --features cli` succeeds). Capture watcher, internal-write suppression, and IPC hand-off are intentionally deferred to M3. + +### M2 — CLI chooser and safe command use + +- [x] Register `cp ls`, terminal chooser, query/selection, terminal cleanup, and non-TTY behavior. +- [x] Implement argv placeholder/append logic with fixed executable validation. +- [x] Execute directly, preserve child streams/exit status, and update shared MRU. +- [x] Define/test cancellation, stale row, child launch failure, JSON combinations, and protected-row metadata-only behavior. +- [x] Verify execution streams child stdout/stderr unchanged; no generated command or raw selected-text output, including browse-only invocation. + +**Evidence (M2):** `src/cli/commands/cp.rs` + 5 unit tests in the same file; arrow-key navigation is not yet implemented (line-driven chooser, see `choose_interactively`); raw-mode upgrade can land with M5. Exit codes: 130 on cancellation, 127 on missing program, 1 on empty history. `--list` (with `--search` / `--limit`) and `--id` give non-interactive entry points. Protected rows refuse to be substituted into a child command (caller must unprotect through the GUI first). + +### M3 — GUI retrieve, reserve, and edit (mostly complete) + +- [x] Implement and ship the chooser floating window with type-to-filter, ↑/↓/PgUp/PgDn nav, Esc dismiss, Enter paste, Ctrl+L toggle protection, Ctrl+R row-editor, and Ctrl+Backspace delete. (`src/native/windows/clipboard.rs`) +- [x] Wire the global `Ctrl+Alt+V` hotkey to the chooser, route the focused HWND through IPC, and close the chooser on Escape via `set_clipboard_escape_target`. (`src/native/hotkey.rs`, `src/native/mod.rs`, `src/ipc.rs`) +- [x] Implement foreground restore + rdev-synthesised Ctrl/Cmd+V paste helper. (`src/native/clipboard/paste.rs`) +- [ ] Five-second soft-delete undo banner (`delete_selected` in `src/native/windows/clipboard.rs` deletes immediately; needs banner + timer). +- [ ] Optional row-metadata dialog polish (F2 alias for Ctrl+R, label auto-complete against recent labels). + +**Exit evidence (so far):** `Ctrl+Alt+V` (and `Cmd+Alt+V` on macOS) appears as an enum variant in +[`src/native/hotkey.rs`](../src/native/hotkey.rs) with two new smoke tests, the IPC contract in +[`src/ipc.rs`](../src/ipc.rs) carries `ToggleClipboardChooser { focused_window }`, and the +single-instance chooser [`src/native/windows/clipboard.rs`](../src/native/windows/clipboard.rs) is +opened with that focus handle and dismissed by global Esc via +[`crate::native::windows::set_clipboard_escape_target`](../src/native/windows/mod.rs). The +captured row id is recorded through `mark_used` and the selected content is delivered via +`write_clipboard_text` so the watcher does not re-ingest it. Double-click pastes; Enter pastes; +Esc closes; up/down navigate. + +**Pending entries:** double-click conflict with reserved-row popup, F2 editor dialog, F1/Ctrl+R +row-level metadata view, deterministic DPI/IME regression, and a real cross-platform paste +helper (currently the chooser delegates the cross-platform paste via the existing +[`selection::capture_selection`] helper but does not yet call `SimulateCtrlV` directly). + +### M4 — Master-password protection (not release-complete) + +- [ ] Implement password setup/confirmation, vault envelopes, password change, and scoped authentication. +- [ ] Add `Ctrl+L` (selected-row protection), inline GUI unlock-and-continue (VaultModal in `src/native/windows/clipboard.rs`), protected editing flag toggling, and hidden CLI password prompt (`cp vault create|unlock|lock|status` plus `cp import --protect`). +- [ ] Implement session/key clearing (5-minute unlocked window via `DEFAULT_ACTIVE_LIFETIME_MS` in `src/clipboard/vault_controller.rs`), protected clipboard expiry, recapture privacy policy (`protected_match_token` is set only when the vault is unlocked), and exclusion hints where supported. +- [ ] Verify metadata redaction, residual plaintext handling (Zeroizing buffers in `src/clipboard/vault.rs`), logging (redacted `Debug` for `VaultEnvelope` and `UnlockedVault`), backups/journals, and concurrent protection/capture behavior (Arc + std::sync::Mutex owned by `VaultController`). + +**Required exit evidence (not yet demonstrated):** protection meets the written threat model; wrong password/cancel/corruption never executes or destroys data; protected content is absent from plaintext database/search/log artifacts covered by that model; unprotecting does not silently drop retention. + +### M5 — Cross-platform hardening and release + +- [ ] Implement/validate macOS adapter and permissions; define Linux X11/Wayland support honestly. +- [ ] Add management commands and settings needed for maintenance; optional headless watcher only with single-owner coordination. +- [ ] Benchmark and fix capture overhead, filtering, virtualized rendering, unlock responsiveness, and contention. +- [ ] Update README/help/architecture with shipped behavior, shortcuts, limitations, security guidance, and examples. +- [ ] Complete manual platform/accessibility checks and regression checks for existing sticker/selection shortcuts. + +**Exit evidence:** platform matrix, test commands/results, measured budgets, release notes, and no unchecked critical acceptance criteria for a claimed platform. + +## 8. Acceptance and verification matrix + +| Area | Required scenarios | +| --- | --- | +| Capture | Mouse/programmatic copy; rapid updates; repeated exact text; empty vs. whitespace; Unicode/CJK/emoji; pause/restart; busy clipboard; exclusion hints; oversized text | +| Internal writes | Marker/copy/restore transaction; clipboard-history paste; protected-copy expiry; concurrent external write during restore/clear; no accidental suppression of unrelated copies | +| Retention | Count and byte overflow; lower limits; each retention reason separately/in combination; no-op/canceled edit; unreserve edited/protected row; unprotect; explicit release; active-use leases; disk full | +| Ordering/search | Same fixture produces identical GUI/CLI IDs/order; deterministic ties; MRU on copy/use/edit; stable selection while ingesting; multilingual tokens; locked text never searched | +| Editing | Multiline save/cancel; double-click selects/edit only; edit/save versus concurrent prune/delete; protected edit remains encrypted | +| Focus/paste | Original app restored; held modifiers released; popup reopen/dismiss; nested password/editor cancel; closed destination; elevated target; permissions denied; clipboard write/input failure | +| CLI safety | Multiple/embedded/no placeholders; text containing spaces, quotes, tabs, newlines, shell metacharacters, ANSI escapes, `{{cp}}`, leading `-`; executable placeholder rejection; NUL/length error | +| CLI operation | No TTY; redirected stdin/stdout; hidden password; Ctrl+C/Esc cleanup; child not found; child exit code/signals; streaming stdout/stderr unchanged; no generated command/raw selected-text output; browse-only invocation emits no payload; JSON success/error convention | +| Protection | First setup; mismatch/wrong password; nonce/envelope corruption; password change failure; scoped relock; logs/redaction; SQLite residuals; protected recapture; clipboard expiry ownership | +| Compatibility | Existing main/preview/selection shortcuts and Esc; sticker migrations and locks; UI-only, CLI-only, combined feature builds; old data with new schema | + +Suggested verification commands once implementation exists: + +```bash +cargo test --no-default-features --features cli +cargo test --features cli +cargo check --bin rusticker --no-default-features --features cli +cargo check --bin rustickers +cargo run --bin rusticker --no-default-features --features cli -- cp --help +``` + +Add focused integration tests under `tests/` for storage transactions, GUI/CLI query parity, and actual child argv transport on Windows and Unix. Native clipboard/focus/permission checks require dedicated manual runs; unit tests alone cannot prove them. + +**Provisional performance targets:** visible chooser within 150 ms when warm; filtering under 50 ms for 500 ordinary + 10,000 retained metadata rows on a named baseline machine; UI stays responsive during capture/KDF work; idle watcher event-driven where possible. Report measurements rather than treating these numbers as established capabilities. + +## 9. Decision and evidence log + +Append decisions, measurements, and test outcomes here as work progresses. Keep unchecked tasks visible rather than rewriting the plan to imply completion. + +| Date / milestone | Decision or evidence | Files / tests / follow-up | +| --- | --- | --- | +| Draft / M0 | Existing history feature is absent. Selection capture mutates/restores the clipboard; locking implementation is UI-coupled and per-sticker; shared CLI/GUI contracts need new clipboard modules. | See integration map. Initial draft recorded D1–D9 as proposed. | +| User confirmation / M0 | D1: exact shortcut delegated, prioritize convenient access. D2: protect selected item only. D3: execute only and show the command's output; remove command-rendering/raw-selection output modes. | Contracts and milestones updated. D4–D9 and browse-only behavior remain proposed; shortcut candidate still needs conflict verification. | +| M1 done | Shared history + bounded capture. 19 unit tests + 5 SQLite integration tests cover ordering, retention, dedupe, settings. | `src/clipboard/{items,ordering,retention,filter,settings,storage,argv}.rs`, `migrations/0010_*.sql`, `tests/clipboard_storage.rs`. | +| M2 done | CLI `cp ls` chooser, --list/--id/--search, safe argv substitution with placeholders, child stream + exit-code propagation, browse-only hint. 5 cp unit tests. | `src/cli/commands/cp.rs`, `src/cli/mod.rs`, `src/cli/commands/mod.rs`. | +| M3 in progress | Watcher + capture coordinator + internal-write suppression added; selection helper forwards through the same filter. Visual chooser window, hotkey binding, and editor dialog remain their own todo since they require `gpui` window plumbing. | `src/native/clipboard/{watcher,mod}.rs`, `src/native/selection.rs`, `src/native/clipboard_chooser.rs`. | +| M4 in progress | Argon2id + AES-256-GCM vault with envelope serialization, rewrap, and round-trip / wrong-password / unknown-prefix tests. Master-password prompt plumbing, protected-row envelopes, and protected recapture policy not yet wired. | `src/clipboard/vault.rs`, `Cargo.toml` (`argon2`, `rand`, `zeroize`), 5 vault unit tests. | + +### Release definition of done + +- [ ] All confirmed user requirements work on each claimed platform, or explicit limitations are shown in the interface and docs. +- [ ] GUI and CLI use one history behavior contract; no duplicated sorting/retention implementation. +- [ ] Ordinary limits are enforced without silent loss of edited/protected/reserved items. +- [ ] Password protection meets a documented threat model; no misleading security claims. +- [ ] Clipboard content is never implicitly interpreted as shell code. +- [ ] Everyday retrieval stays keyboard-first and predictable; destructive/exceptional operations remain recoverable or explicitly confirmed. + +### Local refactor verification — October 6, 2026 + +- `cargo test --no-default-features --features cli`: 97 library tests passed, + 17 clipboard-storage integration tests passed, 1 placement integration test passed. + One terminal-only smoke test is ignored in ordinary runs. +- `cargo test --no-default-features --features storage --lib`: 35 tests passed + before the final command-length/storage-generation additions. +- `cargo check --features cli`: passed after final integration (GUI + CLI). +- `cargo check`: UI-only passed; the final combined check covers the later GUI edits. +- `cargo fmt --all -- --check` and `git diff --check`: passed. +- Terminal-only chooser smoke: passed in an `oly` PTY using three fixture rows; + Down/Enter selected row 2 and restored the terminal. Windows UTF-8 output was + verified with CJK text and Unicode markers after correcting the console code page. +- Exact multiline/Unicode/quote/punctuation child argument delivery and child exit + status are regression-tested with a fixture PowerShell script, not interpolated code. +- A no-feature `cargo check --no-default-features` still fails on pre-existing + unconditional sticker SQLite/chrono imports. The supported CLI, storage, and GUI + configurations above are distinct from that minimal unsupported configuration. +- No real GUI screenshot, elevated-destination paste, macOS/Linux native paste, + screen-reader/IME/DPI audit, or performance benchmark was completed. Passing + compilation and storage tests is not evidence that these acceptance gates passed. diff --git a/examples/clipboard_ux_smoke.rs b/examples/clipboard_ux_smoke.rs new file mode 100644 index 0000000..03667a9 --- /dev/null +++ b/examples/clipboard_ux_smoke.rs @@ -0,0 +1,54 @@ +//! Isolated visual smoke harness: fixture database only, no watcher or clipboard writes. +use gpui::{App, Application, rgb}; +use gpui_component::{Theme, ThemeMode}; +use rustickers::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore}; +use rustickers::clipboard::vault_controller::VaultController; +use rustickers::native::{clipboard::CaptureFilter, components::Assets, windows::clipboard}; +use std::sync::{Arc, mpsc}; + +fn main() -> anyhow::Result<()> { + let fixtures = tempfile::tempdir()?; + let store = Arc::new(futures::executor::block_on(SqliteClipboardStore::open( + fixtures.path().join("clipboard.db"), + ))?); + for (index, text) in [ + "A short clipboard item", + "https://example.test/docs/clipboard-history?view=keyboard", + "日本語 · 中文 · emoji ✨", + "A multiline copy\nwith a second line", + "Another recent copy", + "Code: let greeting = \"Hello, world!\";", + "A useful saved command", + "The oldest sample", + ] + .iter() + .enumerate() + { + futures::executor::block_on(store.upsert_ordinary( + text, + text, + &SqliteClipboardStore::ordinary_digest(text), + text.len() as i64, + 100 - index as i64, + ))?; + } + let vault = Arc::new(VaultController::new(store.clone())); + // Default exercises first-time setup; --configured exercises existing-vault unlock. + if std::env::args().any(|arg| arg == "--configured") { + futures::executor::block_on(vault.create("fixture-password"))?; + } + let app = + Application::with_platform(gpui_platform::current_platform(false)).with_assets(Assets); + app.run(move |cx: &mut App| { + gpui_component::init(cx); + Theme::change(ThemeMode::Dark, None, cx); + cx.global_mut::().background = rgb(0x151104).into(); + clipboard::set_global_capture_filter(CaptureFilter::new()); + clipboard::set_global_store(store.clone()); + clipboard::set_global_vault_controller(vault); + let (tx, _) = mpsc::channel(); + clipboard::ClipboardChooserWindow::toggle(cx, tx, store, None).unwrap(); + }); + drop(fixtures); + Ok(()) +} diff --git a/migrations/0010_add_clipboard_history.sql b/migrations/0010_add_clipboard_history.sql new file mode 100644 index 0000000..1f85acd --- /dev/null +++ b/migrations/0010_add_clipboard_history.sql @@ -0,0 +1,62 @@ +-- Clipboard history tables. +-- +-- Items are stored as ordinary text or as a protected envelope, never both. The +-- retention reasons are independent flags so a protected item that was edited +-- keeps both reasons after unlocks. +-- +-- `plaintext_digest` is a SHA-256 of the lowercase-trimmed content. It is used +-- only for ordinary dedupe and never stored for protected rows. It is not a +-- secret and does not leak content: it lets us recognise repeated copies of +-- ordinary text without storing a separate hash table. +-- +-- `protected_match_token` is a per-vault HMAC tag over the protected plaintext, +-- computed only when a vault is configured and an item is unlocked during +-- capture. The vault key is required to recompute it, so an attacker without +-- the master password cannot use it to detect protected duplicates. While the +-- vault is locked the field is NULL and ordinary captures of protected text +-- cannot be deduplicated against it. + +CREATE TABLE IF NOT EXISTS clipboard_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + content_bytes INTEGER NOT NULL, + plain_preview TEXT, + plaintext_digest TEXT, + protected_match_token TEXT, + protected_envelope TEXT, + public_label TEXT, + retention_explicit INTEGER NOT NULL DEFAULT 0, + retention_edited INTEGER NOT NULL DEFAULT 0, + retention_protected INTEGER NOT NULL DEFAULT 0, + locked INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_copied_at INTEGER NOT NULL, + last_used_at INTEGER, + last_edited_at INTEGER, + byte_size INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_clipboard_recency + ON clipboard_items (MAX(COALESCE(last_copied_at, 0), COALESCE(last_used_at, 0), COALESCE(last_edited_at, 0), created_at) DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_clipboard_plain_digest + ON clipboard_items (plaintext_digest) + WHERE plaintext_digest IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_clipboard_protected_match + ON clipboard_items (protected_match_token) + WHERE protected_match_token IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_clipboard_retained + ON clipboard_items (retention_explicit, retention_edited, retention_protected); + +CREATE TABLE IF NOT EXISTS clipboard_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT OR IGNORE INTO clipboard_settings (key, value) VALUES + ('normal_count_limit', '500'), + ('normal_byte_limit', '20971520'), + ('capture_enabled', '0'), + ('schema_version', '1'); diff --git a/migrations/0011_add_clipboard_vault.sql b/migrations/0011_add_clipboard_vault.sql new file mode 100644 index 0000000..d429710 --- /dev/null +++ b/migrations/0011_add_clipboard_vault.sql @@ -0,0 +1,18 @@ +-- Migration 0011: master-password vault table. +-- +-- Persistent only when the user configures a master password. `vault_envelope` is the +-- Argon2id-wrapped data key, exactly the format produced by `VaultEnvelope::serialize`. The +-- `vault_active_until` lets the user pick how long an unlocked vault stays open in idle; it is +-- a UNIX timestamp in milliseconds. +-- +-- We keep the rest of the storage untouched: protected row encryption already lived in the +-- pre-existing schema; now we have a stable place to anchor the master. + +CREATE TABLE IF NOT EXISTS clipboard_vault ( + id INTEGER PRIMARY KEY CHECK (id = 1), + envelope TEXT NOT NULL, + active_until_unix_ms INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO clipboard_vault (id, envelope, active_until_unix_ms) +VALUES (1, '', 0); diff --git a/src/cli/commands/clipboard_terminal.rs b/src/cli/commands/clipboard_terminal.rs new file mode 100644 index 0000000..1119d29 --- /dev/null +++ b/src/cli/commands/clipboard_terminal.rs @@ -0,0 +1,382 @@ +//! Controlling-terminal I/O: clipboard selection never consumes child stdin or pollutes stdout. +use std::fs::{File, OpenOptions}; +use std::io::Write; + +use anyhow::{Context, Result}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind, + KeyModifiers, + }, + execute, queue, + style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor}, + terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use zeroize::Zeroizing; + +use crate::clipboard::{filter::filter_indices, items::ClipboardItem}; + +struct Terminal { + output: File, + alternate: bool, + raw: bool, + #[cfg(windows)] + original_code_page: u32, +} + +impl Terminal { + fn open(alternate: bool) -> Result { + #[cfg(windows)] + let path = "CONOUT$"; + #[cfg(not(windows))] + let path = "/dev/tty"; + let output = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .context("clipboard selection needs a terminal; use --list or --id for scripts")?; + let mut terminal = Self { + output, + alternate: false, + raw: false, + #[cfg(windows)] + original_code_page: unsafe { + windows_sys::Win32::System::Console::GetConsoleOutputCP() + }, + }; + #[cfg(windows)] + unsafe { + if windows_sys::Win32::System::Console::SetConsoleOutputCP(65001) == 0 { + return Err(std::io::Error::last_os_error()) + .context("enable UTF-8 terminal output"); + } + } + if !terminal::is_raw_mode_enabled()? { + terminal::enable_raw_mode().context("enable terminal input")?; + terminal.raw = true; + } + if alternate { + terminal.alternate = true; + execute!( + terminal.output, + EnterAlternateScreen, + Hide, + EnableBracketedPaste + )?; + } + Ok(terminal) + } +} + +impl Drop for Terminal { + fn drop(&mut self) { + if self.alternate { + let _ = execute!( + self.output, + ResetColor, + SetAttribute(Attribute::Reset), + Show, + DisableBracketedPaste, + LeaveAlternateScreen + ); + } + #[cfg(windows)] + if self.original_code_page != 0 { + unsafe { + windows_sys::Win32::System::Console::SetConsoleOutputCP(self.original_code_page); + } + } + if self.raw { + let _ = terminal::disable_raw_mode(); + } + } +} + +#[derive(Default)] +struct Selection { + query: String, + selected: usize, +} + +impl Selection { + fn move_by(&mut self, delta: isize, count: usize) { + self.selected = self + .selected + .saturating_add_signed(delta) + .min(count.saturating_sub(1)); + } + + fn query_changed(&mut self) { + self.selected = 0; + } +} + +pub(super) fn choose(rows: &[ClipboardItem], initial_query: &str) -> Result> { + let mut terminal = Terminal::open(true)?; + let mut selection = Selection { + query: initial_query.to_owned(), + selected: 0, + }; + loop { + let matches = filter_indices(rows, &selection.query); + let (width, height) = terminal::size().unwrap_or((80, 24)); + let page_size = usize::from(height.saturating_sub(6)).max(1); + selection.selected = selection.selected.min(matches.len().saturating_sub(1)); + let colored = std::env::var_os("NO_COLOR").is_none(); + queue!(terminal.output, MoveTo(0, 0), Clear(ClearType::All))?; + if colored { + queue!( + terminal.output, + SetForegroundColor(Color::Cyan), + SetAttribute(Attribute::Bold) + )?; + } + queue!( + terminal.output, + Print(" Clipboard history"), + ResetColor, + SetAttribute(Attribute::Reset) + )?; + queue!( + terminal.output, + MoveTo(0, 1), + Print(format!( + " Search {}", + safe_preview(&selection.query, usize::from(width.saturating_sub(10))) + )) + )?; + let start = selection.selected / page_size * page_size; + if matches.is_empty() { + queue!( + terminal.output, + MoveTo(1, 3), + Print("No matches. Backspace to refine; Ctrl+U to clear.") + )?; + } + for (offset, index) in matches.iter().skip(start).take(page_size).enumerate() { + let row = &rows[*index]; + let selected = start + offset == selection.selected; + let badge = if row.retention.protected_ { + " [Protected]" + } else if row.retention.explicit { + " [Reserved]" + } else if row.retention.edited { + " [Edited]" + } else { + "" + }; + let available = usize::from(width.saturating_sub(5)).saturating_sub(badge.len()); + let preview = safe_preview(&row.preview_text(), available); + queue!(terminal.output, MoveTo(0, (offset + 3) as u16))?; + if selected { + queue!(terminal.output, SetAttribute(Attribute::Reverse))?; + } + queue!( + terminal.output, + Print(format!( + " {} {}{}", + if selected { "›" } else { " " }, + preview, + badge + )), + SetAttribute(Attribute::Reset) + )?; + } + if height > 1 { + queue!(terminal.output, MoveTo(0, height - 1))?; + if colored { + queue!(terminal.output, SetForegroundColor(Color::DarkGrey))?; + } + queue!( + terminal.output, + Print(safe_preview( + &format!( + " ↑↓ choose · Enter use · Esc cancel · {} match{}", + matches.len(), + if matches.len() == 1 { "" } else { "es" } + ), + usize::from(width.saturating_sub(1)) + )), + ResetColor + )?; + } + terminal.output.flush()?; + match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Release => { + if key.code == KeyCode::Esc + || (key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL)) + { + return Ok(None); + } + match key.code { + KeyCode::Enter => { + if let Some(index) = matches.get(selection.selected) { + return Ok(Some(rows[*index].id)); + } + } + KeyCode::Up => selection.move_by(-1, matches.len()), + KeyCode::Down => selection.move_by(1, matches.len()), + KeyCode::PageUp => selection.move_by(-(page_size as isize), matches.len()), + KeyCode::PageDown => selection.move_by(page_size as isize, matches.len()), + KeyCode::Home => selection.selected = 0, + KeyCode::End => selection.selected = matches.len().saturating_sub(1), + KeyCode::Backspace => { + selection.query.pop(); + selection.query_changed(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + selection.query.clear(); + selection.query_changed(); + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + selection.query.push(c); + selection.query_changed(); + } + _ => {} + } + } + Event::Paste(text) => { + selection.query.push_str(&text); + selection.query_changed(); + } + _ => {} + } + } +} + +/// Hidden input on the controlling terminal, never process stdin, argv, or the environment. +pub(super) fn password(prompt: &str) -> Result>> { + let mut terminal = Terminal::open(false)?; + write!(terminal.output, "{prompt}")?; + terminal.output.flush()?; + let mut password = Zeroizing::new(String::new()); + loop { + match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Release => { + if key.code == KeyCode::Esc + || (key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL)) + { + write!(terminal.output, "\r\n")?; + return Ok(None); + } + match key.code { + KeyCode::Enter => { + write!(terminal.output, "\r\n")?; + return Ok(Some(password)); + } + KeyCode::Backspace => { + password.pop(); + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + password.push(c) + } + _ => {} + } + } + Event::Paste(text) => password.push_str(&text), + _ => {} + } + } +} + +/// Strip terminal commands and bidi controls from display text, not from stored/pasted payloads. +pub(super) fn safe_preview(text: &str, max: usize) -> String { + let mut out = String::new(); + let mut count: usize = 0; + for c in text.chars() { + let c = if c.is_whitespace() { + ' ' + } else if c.is_control() || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') { + '�' + } else { + c + }; + // A conservative two-column allowance keeps CJK and emoji inside the viewport. + let columns = if c.is_ascii() { 1 } else { 2 }; + if count + columns > max { + while count + 1 > max { + let Some(last) = out.pop() else { + break; + }; + count = count.saturating_sub(if last.is_ascii() { 1 } else { 2 }); + } + if max > 0 { + out.push('…'); + } + break; + } + out.push(c); + count += columns; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn navigation_clamps_and_search_resets_selection() { + let mut selection = Selection::default(); + selection.move_by(-1, 3); + assert_eq!(selection.selected, 0); + selection.move_by(20, 3); + assert_eq!(selection.selected, 2); + selection.query_changed(); + assert_eq!(selection.selected, 0); + selection.move_by(1, 0); + assert_eq!(selection.selected, 0); + } + + #[test] + fn previews_cannot_execute_terminal_sequences() { + let preview = safe_preview("hello\x1b[2J\nworld\u{202e}", 80); + assert!(!preview.contains('\x1b')); + assert!(!preview.contains('\n')); + assert!(!preview.contains('\u{202e}')); + assert_eq!(safe_preview("日本語", 4), "日…"); + } +} + +#[cfg(test)] +mod interactive_smoke { + use super::*; + use crate::clipboard::items::{ClipboardItemKind, RetentionState}; + + // Run in a real PTY: cargo test terminal_chooser_smoke -- --ignored --nocapture + #[test] + #[ignore = "requires an interactive terminal"] + fn terminal_chooser_smoke() { + let rows = ["First copy", "日本語 second copy", "Third copy"] + .iter() + .enumerate() + .map(|(index, text)| ClipboardItem { + id: index as i64 + 1, + kind: ClipboardItemKind::Plain, + content: (*text).into(), + plain_preview: None, + public_label: None, + byte_size: text.len() as i64, + created_at: 1, + last_copied_at: 1, + last_used_at: None, + last_edited_at: None, + retention: RetentionState::default(), + }) + .collect::>(); + let chosen = choose(&rows, "").unwrap(); + println!("TERMINAL_SELECTION={chosen:?}"); + assert_eq!(chosen, Some(2)); + } +} diff --git a/src/cli/commands/cp.rs b/src/cli/commands/cp.rs new file mode 100644 index 0000000..42bd3d7 --- /dev/null +++ b/src/cli/commands/cp.rs @@ -0,0 +1,635 @@ +//! Clipboard history: safe argv execution, terminal selection, and script-friendly output. +use anyhow::{Context, Result, bail}; +use serde_json::json; +use std::io::{self, Read, Write}; +use std::sync::Arc; +use zeroize::Zeroizing; + +use super::clipboard_terminal::{choose, password, safe_preview}; +use crate::cli::{ + output::{Format, table}, + runtime::block_on, +}; +use crate::clipboard::{ + argv, filter, + items::{ClipboardItem, ClipboardItemKind}, + storage::{ClipboardHistoryStore, RetentionFlag, SqliteClipboardStore, UpsertOutcome}, + vault_controller::{VaultController, VaultState}, +}; +use crate::storage::paths::AppPaths; + +/// Exit codes needing propagation without skipping Rust destructors. +#[derive(Debug)] +pub struct ExitCode(pub i32); +impl std::fmt::Display for ExitCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "command exited with status {}", self.0) + } +} +impl std::error::Error for ExitCode {} + +#[derive(clap::Subcommand, Debug)] +pub enum CpCommand { + /// Browse, search, or reuse previous copies. + #[command(visible_alias = "list")] + Ls(CpArgs), + /// Add text to history (or pipe text with --stdin). + Import(ImportArgs), + /// Remove reservation/edit retention; protected items remain protected. + Forget(ForgetArgs), + /// Print live clipboard text, or an exact history value with --id. + Show(ShowTextArgs), + /// Manage the master password for encrypted history. + Vault(VaultArgs), +} + +#[derive(clap::Args, Debug)] +#[command( + about = "Browse and reuse previous copies", + after_help = "Examples:\n rusticker cp ls browse (↑↓, Enter, Esc)\n rusticker cp ls -s rust start with a search\n rusticker cp ls --list -n 20 print recent history\n rusticker cp ls --json --list machine-readable history\n rusticker cp ls -- rg -F -- '{{cp}}' . select and run\n rusticker cp ls --id 42 -- my-tool append one exact argument\n\nQuote '{{cp}}' in POSIX shells and PowerShell; use \"{{cp}}\" in cmd.exe.\nClipboard text stays data; a shell/interpreter code argument can still execute it.\nChooser/password input uses the terminal, leaving child stdin and stdout untouched.\nExit codes: 130 cancelled, 127 program missing, 126 not executable, otherwise child status." +)] +pub struct CpArgs { + /// Print a compact table (or one JSON object with --json), without choosing. + #[arg(long, conflicts_with_all = ["id", "command"])] + pub list: bool, + /// Filter history; in interactive mode, prefill the search field. + #[arg(long, short = 's', value_name = "TEXT", conflicts_with = "id")] + pub search: Option, + /// Maximum rows to list. + #[arg(long, short = 'n', value_name = "COUNT", requires = "list")] + pub limit: Option, + /// Use a specific history row without a chooser. + #[arg(long, value_name = "ID")] + pub id: Option, + /// Child program and arguments. Use -- before the program. + #[arg(last = true, num_args = 1.., value_name = "COMMAND")] + pub command: Vec, +} + +#[derive(clap::Args, Debug)] +pub struct ImportArgs { + /// Read exact text from stdin. + #[arg(long, conflicts_with = "value")] + pub stdin: bool, + /// Text to record, unchanged. + #[arg(value_name = "VALUE", required_unless_present = "stdin")] + pub value: Option, + /// Encrypt before writing; prompts for the master password on the terminal. + #[arg(long)] + pub protect: bool, +} +#[derive(clap::Args, Debug)] +pub struct ForgetArgs { + #[arg(value_name = "ID")] + pub id: i64, +} +#[derive(clap::Args, Debug)] +pub struct ShowTextArgs { + /// Print this history value instead of the live clipboard. + #[arg(long, value_name = "ID")] + pub id: Option, +} +#[derive(clap::Args, Debug)] +pub struct VaultArgs { + #[command(subcommand)] + pub action: VaultAction, +} +#[derive(clap::Subcommand, Debug)] +pub enum VaultAction { + /// Set up a master password (never overwrites an existing vault). + Create, + /// Check the password; authorization lasts only for this CLI invocation. + Unlock, + /// CLI invocations start locked; use the GUI to lock its own session. + Lock, + /// Show persisted configuration (CLI unlock state is never shared). + Status, +} + +fn open_store(paths: &AppPaths) -> Result> { + Ok(Arc::new(block_on(SqliteClipboardStore::open( + paths.db_path.with_file_name("clipboard.db"), + ))?)) +} + +fn require_password(prompt: &str) -> Result> { + password(prompt)?.ok_or_else(|| ExitCode(130).into()) +} + +fn unlock(controller: &VaultController) -> Result<()> { + if block_on(controller.envelope())?.is_none() { + bail!("no master password configured; run `rusticker cp vault create` first"); + } + let password = require_password("Master password: ")?; + if !block_on(controller.unlock(&password))? { + bail!("incorrect master password; nothing was changed"); + } + Ok(()) +} + +fn row_text( + store: Arc, + row: &ClipboardItem, +) -> Result> { + if row.kind == ClipboardItemKind::Plain { + return Ok(Zeroizing::new(row.content.clone())); + } + let controller = VaultController::new(store); + unlock(&controller)?; + let bytes = controller + .open(&row.content)? + .context("vault locked before decrypting")?; + Ok(Zeroizing::new( + std::str::from_utf8(&bytes) + .context("protected item is not UTF-8 text")? + .to_owned(), + )) +} + +pub fn run(paths: &AppPaths, args: CpArgs, format: Format) -> Result<()> { + if !args.command.is_empty() { + argv::validate_template(&args.command).context("invalid command template")?; + } + if format.is_json() && !args.list && args.id.is_none() { + bail!("--json requires --list or --id ; interactive selection is for humans"); + } + let store = open_store(paths)?; + let snapshot = block_on(store.snapshot())?; + if args.list { + return run_list(&snapshot, args.search.as_deref(), args.limit, format); + } + let id = if let Some(id) = args.id { + id + } else { + if snapshot.is_empty() { + bail!( + "no clipboard history yet; enable collection in the desktop app or use `rusticker cp import `" + ); + } + choose(&snapshot, args.search.as_deref().unwrap_or(""))?.ok_or(ExitCode(130))? + }; + // Re-read after selection; a concurrent delete must not execute stale clipboard content. + let row = block_on(store.get(id)) + .with_context(|| format!("clipboard row {id} is no longer available"))?; + if args.command.is_empty() { + format.emit(json!({"id": id, "kind": kind(&row), "hint": "pass -- to reuse this item"}), || { + println!("Selected #{id}. To reuse it: rusticker cp ls --id {id} -- '{{{{cp}}}}'"); + }); + return Ok(()); + } + let text = row_text(store.clone(), &row)?; + let current = + block_on(store.get(id)).context("clipboard item disappeared during authorization")?; + if current.kind != row.kind || current.content != row.content { + bail!("clipboard item changed during authorization; select it again"); + } + let final_args = Zeroizing::new(argv::build(&args.command, &text)?); + let mut child = std::process::Command::new(&final_args[0]); + child + .args(&final_args[1..]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + let mut child = match child.spawn() { + Ok(child) => child, + Err(err) => { + let code = match err.kind() { + io::ErrorKind::NotFound => 127, + io::ErrorKind::PermissionDenied => 126, + _ => 1, + }; + eprintln!( + "error: could not launch `{}`: {err}", + safe_preview(&final_args[0], 100) + ); + return Err(ExitCode(code).into()); + } + }; + if let Err(err) = block_on(store.mark_used(id, crate::utils::time::now_unix_millis())) { + eprintln!("warning: could not update clipboard recency: {err}"); + } + let status = child.wait().context("wait for child command")?; + #[cfg(unix)] + let code = { + use std::os::unix::process::ExitStatusExt; + status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(1)) + }; + #[cfg(not(unix))] + let code = status.code().unwrap_or(1); + if code == 0 { + Ok(()) + } else { + Err(ExitCode(code).into()) + } +} + +fn kind(row: &ClipboardItem) -> &'static str { + if row.kind == ClipboardItemKind::Protected { + "protected" + } else { + "plain" + } +} + +fn run_list( + snapshot: &[ClipboardItem], + search: Option<&str>, + limit: Option, + format: Format, +) -> Result<()> { + let indices = filter::filter_indices(snapshot, search.unwrap_or("")); + let rows: Vec<_> = indices + .iter() + .take(limit.unwrap_or(usize::MAX)) + .map(|index| &snapshot[*index]) + .collect(); + let payload: Vec<_> = rows.iter().map(|row| json!({ + "id": row.id, "kind": kind(row), "byte_size": row.byte_size, "preview": safe_preview(&row.preview_text(), 120), + "public_label": row.public_label, "retention": row.retention, "created_at": row.created_at, + "last_copied_at": row.last_copied_at, "last_used_at": row.last_used_at, "last_edited_at": row.last_edited_at, + })).collect(); + format.emit(json!({"items": payload, "count": rows.len(), "search": search}), || { + if rows.is_empty() { + println!("{}", if search.is_some() { "No matching clipboard items." } else { "No clipboard history yet. Enable collection in the desktop app, or use cp import ." }); + return; + } + let cells: Vec<_> = rows.iter().map(|row| vec![row.id.to_string(), safe_preview(&row.preview_text(), 72), row.byte_size.to_string(), { + let mut flags = Vec::new(); + if row.retention.protected_ { flags.push("Protected"); } + if row.retention.explicit { flags.push("Reserved"); } + if row.retention.edited { flags.push("Edited"); } + flags.join(", ") + }]).collect(); + table(&["ID", "PREVIEW", "BYTES", "SAVED"], &cells); + }); + Ok(()) +} + +pub fn import(paths: &AppPaths, args: ImportArgs, format: Format) -> Result<()> { + let value = Zeroizing::new(if args.stdin { + let mut text = String::new(); + io::stdin() + .read_to_string(&mut text) + .context("read stdin")?; + text + } else { + args.value.context("provide VALUE or --stdin")? + }); + if !crate::clipboard::items::is_collectable(&value) { + bail!("cannot import empty or whitespace-only text"); + } + if value.contains('\0') { + bail!("clipboard text cannot contain a NUL byte"); + } + let store = open_store(paths)?; + let bytes = value.len() as i64; + let now = crate::utils::time::now_unix_millis(); + let outcome = if args.protect { + let controller = VaultController::new(store.clone()); + unlock(&controller)?; + let sealed = controller + .seal_for_storage(value.as_bytes())? + .context("vault locked before encryption")?; + block_on(store.upsert_protected_bound( + None, + None, + None, + &sealed.ciphertext, + bytes, + now, + &sealed.vault_envelope, + ))? + } else { + let settings = block_on(store.settings())?; + if settings.normal_byte_limit > 0 && bytes > settings.normal_byte_limit { + bail!( + "text exceeds ordinary history byte limit ({} > {}); nothing imported", + bytes, + settings.normal_byte_limit + ); + } + block_on(store.upsert_ordinary( + &value, + &safe_preview(&value, 240), + &SqliteClipboardStore::ordinary_digest(&value), + bytes, + now, + ))? + }; + let (id, outcome) = match outcome { + UpsertOutcome::Inserted { id } => (id, "inserted"), + UpsertOutcome::Updated { id } => (id, "updated"), + }; + format.emit( + json!({"id": id, "outcome": outcome, "protected": args.protect, "byte_size": bytes}), + || { + println!( + "{} #{id} · {bytes} bytes{}", + if outcome == "inserted" { + "Imported" + } else { + "Updated" + }, + if args.protect { " · Protected" } else { "" } + ) + }, + ); + Ok(()) +} + +pub fn forget(paths: &AppPaths, args: ForgetArgs, format: Format) -> Result<()> { + let store = open_store(paths)?; + let row = block_on(store.get(args.id))?; + if row.kind == ClipboardItemKind::Protected { + bail!("item is protected; unprotect it in the desktop app before removing retention"); + } + block_on(store.set_retention_flag(args.id, RetentionFlag::Edited, false))?; + block_on(store.set_retention_flag(args.id, RetentionFlag::Explicit, false))?; + format.emit(json!({"id": args.id}), || { + println!("Item #{} is eligible for ordinary eviction.", args.id) + }); + Ok(()) +} + +pub fn show(paths: &AppPaths, args: ShowTextArgs, format: Format) -> Result<()> { + let text = if let Some(id) = args.id { + let store = open_store(paths)?; + let row = block_on(store.get(id))?; + row_text(store, &row)? + } else { + Zeroizing::new( + arboard::Clipboard::new() + .context("open clipboard")? + .get_text() + .context("clipboard does not contain text")?, + ) + }; + if format.is_json() { + format.emit(json!({"text": *text}), || {}); + } else { + io::stdout().lock().write_all(text.as_bytes())?; + } + Ok(()) +} + +pub fn vault(paths: &AppPaths, args: VaultArgs, format: Format) -> Result<()> { + let controller = VaultController::new(open_store(paths)?); + match args.action { + VaultAction::Status => { + let configured = block_on(controller.envelope())?.is_some(); + let state = if configured { "locked" } else { "unconfigured" }; + format.emit(json!({"state": state, "has_master_password": configured, "scope": "this CLI invocation"}), || println!("Vault: {state}. CLI authorization is not shared with the desktop app.")); + } + VaultAction::Create => { + if !matches!(block_on(controller.snapshot()), VaultState::Unconfigured) { + bail!( + "master password already configured; change it in the desktop app (existing ciphertext must keep its data key)" + ); + } + let password = require_password("New master password: ")?; + if password.is_empty() { + bail!("master password must not be empty"); + } + let confirm = require_password("Confirm password: ")?; + if password != confirm { + bail!("passwords do not match; nothing changed"); + } + block_on(controller.create(&password))?; + format.emit(json!({"state": "locked"}), || { + println!("Master password created. Protected commands will prompt when needed.") + }); + } + VaultAction::Unlock => { + unlock(&controller)?; + format.emit(json!({"state": "verified", "scope": "this CLI invocation"}), || println!("Password verified. Each protected command authorizes separately; the desktop vault is unchanged.")); + } + VaultAction::Lock => { + controller.lock(); + format.emit( + json!({"state": "locked", "scope": "this CLI invocation"}), + || { + println!( + "This CLI invocation is locked. Lock the desktop vault from its chooser." + ) + }, + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser, Debug)] + struct TestCli { + #[command(subcommand)] + command: CpCommand, + } + + #[test] + fn parser_preserves_child_flags_and_placeholder() { + let parsed = TestCli::try_parse_from([ + "cp", "ls", "--id", "42", "--", "rg", "--json", "-F", "--", "{{cp}}", ".", + ]) + .unwrap(); + let CpCommand::Ls(args) = parsed.command else { + panic!() + }; + assert_eq!(args.command, ["rg", "--json", "-F", "--", "{{cp}}", "."]); + assert_eq!(args.id, Some(42)); + } + + #[test] + fn parser_accepts_search_without_list_and_rejects_discarded_commands() { + assert!(TestCli::try_parse_from(["cp", "ls", "-s", "rust"]).is_ok()); + assert!(TestCli::try_parse_from(["cp", "ls", "--list", "--", "echo"]).is_err()); + assert!(TestCli::try_parse_from(["cp", "import"]).is_err()); + assert!(TestCli::try_parse_from(["cp", "import", "--stdin"]).is_ok()); + } +} + +#[cfg(test)] +mod regression_tests { + use super::*; + + fn paths(tmp: &tempfile::TempDir) -> AppPaths { + AppPaths { + db_path: tmp.path().join("stickers.db"), + } + } + + #[test] + fn failed_protected_import_never_stores_plaintext() { + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + let error = import( + &paths, + ImportArgs { + stdin: false, + value: Some("must remain secret".into()), + protect: true, + }, + Format::Json, + ) + .unwrap_err(); + assert!(error.to_string().contains("no master password")); + assert!( + block_on(open_store(&paths).unwrap().snapshot()) + .unwrap() + .is_empty() + ); + } + + #[test] + fn import_preserves_exact_payload_and_rejects_nul() { + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + let text = " Mixed CASE\n日本語\t\"$() & ;\" "; + import( + &paths, + ImportArgs { + stdin: false, + value: Some(text.into()), + protect: false, + }, + Format::Json, + ) + .unwrap(); + let rows = block_on(open_store(&paths).unwrap().snapshot()).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].content, text); + assert!( + import( + &paths, + ImportArgs { + stdin: false, + value: Some("bad\0text".into()), + protect: false + }, + Format::Json + ) + .is_err() + ); + assert_eq!( + block_on(open_store(&paths).unwrap().snapshot()) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn missing_program_preserves_127_and_does_not_promote() { + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + import( + &paths, + ImportArgs { + stdin: false, + value: Some("payload".into()), + protect: false, + }, + Format::Json, + ) + .unwrap(); + let store = open_store(&paths).unwrap(); + let row = block_on(store.snapshot()).unwrap().remove(0); + let error = run( + &paths, + CpArgs { + list: false, + search: None, + limit: None, + id: Some(row.id), + command: vec![ + tmp.path() + .join("missing-program") + .to_string_lossy() + .into_owned(), + ], + }, + Format::Human, + ) + .unwrap_err(); + assert_eq!(error.downcast_ref::().unwrap().0, 127); + assert_eq!(block_on(store.get(row.id)).unwrap().last_used_at, None); + } + + #[test] + fn forget_rejects_protected_ciphertext() { + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + let store = open_store(&paths).unwrap(); + let controller = VaultController::new(store.clone()); + block_on(controller.create("test-master-password")).unwrap(); + assert!(block_on(controller.unlock("test-master-password")).unwrap()); + let envelope = controller.seal_for_storage(b"secret").unwrap().unwrap(); + let outcome = block_on(store.upsert_protected_bound( + None, + None, + None, + &envelope.ciphertext, + 6, + 1, + &envelope.vault_envelope, + )) + .unwrap(); + let id = match outcome { + UpsertOutcome::Inserted { id } | UpsertOutcome::Updated { id } => id, + }; + assert!(forget(&paths, ForgetArgs { id }, Format::Json).is_err()); + let row = block_on(store.get(id)).unwrap(); + assert_eq!(row.kind, ClipboardItemKind::Protected); + assert!(row.retention.protected_); + } + + #[cfg(windows)] + #[test] + fn child_receives_exact_argument_and_exit_code() { + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + let text = "日本語\nquotes \" ' ; $(not code) & --json"; + import( + &paths, + ImportArgs { + stdin: false, + value: Some(text.into()), + protect: false, + }, + Format::Json, + ) + .unwrap(); + let store = open_store(&paths).unwrap(); + let row = block_on(store.snapshot()).unwrap().remove(0); + let output = tmp.path().join("received.txt"); + let script = tmp.path().join("child.ps1"); + std::fs::write(&script, "param([string]$OutputPath,[string]$Payload)\n[IO.File]::WriteAllText($OutputPath, $Payload)\nexit 23\n").unwrap(); + let error = run( + &paths, + CpArgs { + list: false, + search: None, + limit: None, + id: Some(row.id), + command: vec![ + "pwsh".into(), + "-NoProfile".into(), + "-File".into(), + script.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + "{{cp}}".into(), + ], + }, + Format::Human, + ) + .unwrap_err(); + assert_eq!(error.downcast_ref::().unwrap().0, 23); + assert_eq!(std::fs::read_to_string(output).unwrap(), text); + assert!(block_on(store.get(row.id)).unwrap().last_used_at.is_some()); + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 725f72d..18cefe8 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -3,7 +3,9 @@ //! Each module owns its own clap arguments as well as its behaviour, so adding a flag means //! touching one file rather than threading a new parameter through the dispatcher. +mod clipboard_terminal; pub mod cmd; +pub mod cp; pub mod delete; pub mod list; pub mod markdown; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1f7490b..72bd6dd 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -83,6 +83,12 @@ enum Commands { /// Show everything stored about one sticker Show(commands::show::ShowArgs), + /// Browse and reuse past clipboard copies + Cp { + #[command(subcommand)] + command: commands::cp::CpCommand, + }, + /// Print what a command sticker last produced Result(commands::result::ResultArgs), @@ -129,6 +135,13 @@ pub fn run(cli: Cli, app_paths: &AppPaths) -> anyhow::Result<()> { match cli.command { Commands::List(args) => commands::list::run(app_paths, args, format), Commands::Show(args) => commands::show::run(app_paths, args, format), + Commands::Cp { command } => match command { + commands::cp::CpCommand::Ls(args) => commands::cp::run(app_paths, args, format), + commands::cp::CpCommand::Import(args) => commands::cp::import(app_paths, args, format), + commands::cp::CpCommand::Forget(args) => commands::cp::forget(app_paths, args, format), + commands::cp::CpCommand::Show(args) => commands::cp::show(app_paths, args, format), + commands::cp::CpCommand::Vault(args) => commands::cp::vault(app_paths, args, format), + }, Commands::Result(args) => commands::result::run(app_paths, args, format), Commands::Open(args) => commands::window::open(app_paths, args, format), Commands::Close(args) => commands::window::close(app_paths, args, format), @@ -153,7 +166,8 @@ mod tests { fn the_subcommand_list_includes_every_command() { let names = subcommand_names(); for expected in [ - "list", "show", "result", "open", "close", "delete", "view", "markdown", "cmd", "skill", + "list", "show", "result", "open", "close", "delete", "view", "markdown", "cmd", + "skill", "cp", ] { assert!(names.contains(&expected.to_owned()), "missing {expected}"); } @@ -174,6 +188,29 @@ mod tests { assert!(!Cli::parse_from(["rusticker", "list"]).format().is_json()); } + #[test] + fn clipboard_child_json_flag_belongs_to_child_not_parent() { + let cli = Cli::try_parse_from([ + "rusticker", + "cp", + "ls", + "--id", + "42", + "--", + "tool", + "--json", + "{{cp}}", + ]) + .expect("parse child argv"); + assert!(!cli.format().is_json()); + let Commands::Cp { + command: commands::cp::CpCommand::Ls(args), + } = cli.command + else { + panic!() + }; + assert_eq!(args.command, ["tool", "--json", "{{cp}}"]); + } #[test] fn colors_are_validated_against_the_real_palette() { assert!(parse_color("blue").is_ok()); diff --git a/src/clipboard/argv.rs b/src/clipboard/argv.rs new file mode 100644 index 0000000..8e12cce --- /dev/null +++ b/src/clipboard/argv.rs @@ -0,0 +1,236 @@ +//! Argument-vector substitution for `rusticker cp ls -- ...`. +//! +//! Rules ([`build`]): +//! - The first token is the fixed executable. `{{cp}}` there is rejected so a malicious clipboard +//! cannot redirect the user to a different program. +//! - Every `{{cp}}` in subsequent arguments is replaced with the exact selected text, without +//! recursive substitution. +//! - When no argument contains the placeholder, the selected text is appended as one terminator +//! argument so it lands on argv unaltered. +//! - Empty argv (just `--`) is rejected at the parser level; this module always has at least one +//! program token. +//! - NUL bytes are rejected because the platform argv cannot carry them. The check runs after +//! substitution so the selected text is also validated. + +use anyhow::{Result, anyhow, bail}; + +/// The placeholder token used in command templates. +pub const PLACEHOLDER: &str = "{{cp}}"; + +/// Return the substituted argv ready for `std::process::Command`. +/// +/// `template` is the post-`--` argument list; `selected` is the clipboard text to inject. +pub fn build(template: &[String], selected: &str) -> Result> { + validate_template(template)?; + if selected.contains('\0') { + bail!("selection contains a NUL byte, which cannot be passed as an argument"); + } + let program = &template[0]; + let mut argv: Vec = Vec::with_capacity(template.len() + 1); + argv.push(program.clone()); + + let mut any_placeholder = false; + for token in &template[1..] { + let replaced = if token.contains(PLACEHOLDER) { + any_placeholder = true; + token.replace(PLACEHOLDER, selected) + } else { + token.clone() + }; + if replaced.contains('\0') { + bail!("argument contains a NUL byte after substitution"); + } + argv.push(replaced); + } + + if !any_placeholder { + argv.push(selected.to_owned()); + } + + #[cfg(windows)] + validate_windows_size(&argv)?; + Ok(argv) +} + +/// Validate that `template` is a usable argument list, without doing substitution. Used by the CLI +/// to give a fast error before opening the chooser. +pub fn validate_template(template: &[String]) -> Result<()> { + if template.is_empty() { + bail!("cp ls requires a program after `--`. Usage: rusticker cp ls -- ..."); + } + let program = &template[0]; + if program.is_empty() { + bail!("program name must not be empty"); + } + if template.iter().any(|token| token.contains('\0')) { + bail!("command template contains a NUL byte, which cannot be passed to a program"); + } + if program.contains(PLACEHOLDER) { + bail!("`{PLACEHOLDER}` is not allowed in the program name; pass it as an argument"); + } + Ok(()) +} + +// Conservative bound for ordinary Windows Command arguments, including separators, quotes, +// backslash/quote escaping, surrogate pairs, and the terminating NUL. No command is rendered. +// Batch-file launchers may impose tighter limits; this is not a guarantee of successful launch. +#[cfg(any(windows, test))] +fn validate_windows_size(argv: &[String]) -> Result<()> { + const LIMIT: usize = 32_760; + let mut units = 1usize; // terminating NUL + for (index, token) in argv.iter().enumerate() { + units = units + .saturating_add(usize::from(index != 0)) + .saturating_add(2); + let mut backslashes = 0usize; + for unit in token.encode_utf16() { + units = units.saturating_add(1); + if unit == b'\\' as u16 { + backslashes += 1; + } else { + if unit == b'"' as u16 { + units = units.saturating_add(backslashes).saturating_add(1); + } + backslashes = 0; + } + } + units = units.saturating_add(backslashes); // escaping before closing quote + if units > LIMIT { + bail!( + "resulting Windows command exceeds the safe UTF-16 command-line limit; use a shorter clip or pipe `rusticker cp show --id ` to a program that accepts stdin" + ); + } + } + Ok(()) +} +/// The actual child argv that will be executed, as opposed to the raw template. Used to report +/// what is about to run for diagnostics without printing any clipboard text. +pub fn argv_for_display(argv: &[String]) -> Vec { + argv.iter() + .map(|token| { + if token.is_empty() { + // An empty arg can collide with option parsing in the child. Mark it for display + // so ambiguities are caught. + "\u{201C}\u{201D}".to_owned() + } else if token + .chars() + .any(|c| c.is_whitespace() || matches!(c, '"' | '\'')) + { + format!("\u{201C}{token}\u{201D}") + } else { + token.clone() + } + }) + .collect() +} + +pub fn program_not_found(program: &str) -> anyhow::Error { + anyhow!("program not found on PATH: {program}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(items: &[&str]) -> Vec { + items.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn empty_template_is_rejected() { + let err = build(&[], "value").unwrap_err(); + assert!(format!("{err:#}").contains("requires a program")); + } + + #[test] + fn placeholder_in_program_is_rejected() { + let err = build(&args(&["{{cp}}", "arg"]), "value").unwrap_err(); + assert!(format!("{err:#}").contains("not allowed in the program")); + } + + #[test] + fn replaces_every_placeholder_in_arguments() { + let out = build(&args(&["rg", "--", "{{cp}}"]), "needle").unwrap(); + assert_eq!(out, vec!["rg", "--", "needle"]); + } + + #[test] + fn replaces_multiple_placeholders_in_one_argument() { + let out = build(&args(&["sh", "-c", "echo {{cp}} | {{cp}}"]), "abc").unwrap(); + assert_eq!(out, vec!["sh", "-c", "echo abc | abc"]); + } + + #[test] + fn appends_when_no_placeholder_is_present() { + let out = build(&args(&["cargo", "test"]), "needle").unwrap(); + assert_eq!(out, vec!["cargo", "test", "needle"]); + } + + #[test] + fn does_not_recurse_into_selected_text() { + let out = build(&args(&["echo", "{{cp}}"]), "{{cp}}").unwrap(); + assert_eq!(out, vec!["echo", "{{cp}}"]); + } + + #[test] + fn nul_bytes_in_selection_are_rejected() { + let err = build(&args(&["echo"]), "a\0b").unwrap_err(); + assert!(format!("{err:#}").contains("NUL")); + } + + #[test] + fn argv_display_marks_whitespace_arguments() { + let display = argv_for_display(&args(&["echo", "hello world", "ok"])); + assert_eq!(display[0], "echo"); + assert_eq!(display[1], "\u{201C}hello world\u{201D}"); + assert_eq!(display[2], "ok"); + } + + #[test] + fn validation_steps_match_run_steps() { + validate_template(&args(&["prog", "arg"])).unwrap(); + assert!(validate_template(&args(&["{{cp}}"])).is_err()); + } +} + +#[cfg(test)] +mod validation_regressions { + use super::*; + + #[test] + fn invalid_executable_and_template_nul_fail_before_selection() { + for template in [ + vec![String::new()], + vec!["bad\0program".into()], + vec!["program".into(), "bad\0argument".into()], + ] { + assert!(validate_template(&template).is_err()); + assert!(build(&template, "ordinary clip").is_err()); + } + } + + #[test] + fn windows_limit_counts_surrogates_escaping_and_combined_arguments() { + // 7 units of overhead: "p" (3), separator (1), argument quotes (2), NUL (1). + let largest_ascii_argument = 32_760 - 7; + assert!(validate_windows_size(&["p".into(), "a".repeat(largest_ascii_argument)]).is_ok()); + assert!( + validate_windows_size(&["p".into(), "a".repeat(largest_ascii_argument + 1)]).is_err() + ); + assert!(validate_windows_size(&["p".into(), "😀".repeat(16_378)]).is_err()); + assert!( + validate_windows_size(&["p".into(), "a".repeat(20_000), "b".repeat(13_000)]).is_err() + ); + assert!(validate_windows_size(&["p".into(), "\\\"".repeat(8_190)]).is_err()); + assert!(validate_windows_size(&["p".into(), "\\".repeat(16_378)]).is_err()); + } + + #[cfg(windows)] + #[test] + fn substituted_command_limit_is_checked_without_echoing_secret() { + let secret = format!("private-marker{}", "a".repeat(32_760)); + let error = build(&["program".into(), PLACEHOLDER.into()], &secret).unwrap_err(); + assert!(error.to_string().contains("UTF-16")); + assert!(!error.to_string().contains("private-marker")); + } +} diff --git a/src/clipboard/filter.rs b/src/clipboard/filter.rs new file mode 100644 index 0000000..470b810 --- /dev/null +++ b/src/clipboard/filter.rs @@ -0,0 +1,122 @@ +//! Search and filtering rules shared by GUI and CLI. +//! +//! Filter rules: +//! 1. Query is trimmed and lower-cased once. Empty query matches everything. +//! 2. Tokenisation splits on Unicode whitespace and matches **all** tokens (AND, not OR). +//! 3. A protected row's preview is matched; the locked plaintext is not searched. The `locked` +//! column lets a caller ask only the public label. +//! 4. Result preserves whatever order the caller passes in; callers feed the MRU order. + +use super::items::ClipboardItem; + +/// Return the indices of `items` matching `query`. Preserves caller order. +pub fn filter_indices(items: &[ClipboardItem], query: &str) -> Vec { + let tokens: Vec = query + .split_whitespace() + .map(|token| token.to_lowercase()) + .collect(); + + items + .iter() + .enumerate() + .filter_map(|(index, item)| { + if matches(item, &tokens) { + Some(index) + } else { + None + } + }) + .collect() +} + +fn matches(item: &ClipboardItem, tokens: &[String]) -> bool { + if tokens.is_empty() { + return true; + } + if let Some(label) = &item.public_label { + let haystack = label.to_lowercase(); + if tokens.iter().all(|token| haystack.contains(token)) { + return true; + } + } + match item.kind { + super::items::ClipboardItemKind::Plain => { + if let Some(preview) = &item.plain_preview { + let haystack = preview.to_lowercase(); + if tokens.iter().all(|token| haystack.contains(token)) { + return true; + } + } + let content = item.content.to_lowercase(); + tokens.iter().all(|token| content.contains(token)) + } + super::items::ClipboardItemKind::Protected => { + let Some(preview) = &item.plain_preview else { + return false; + }; + let haystack = preview.to_lowercase(); + tokens.iter().all(|token| haystack.contains(token)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clipboard::items::{ClipboardItemKind, RetentionState}; + use chrono::Utc; + + fn item(id: i64, content: &str, preview: Option<&str>) -> ClipboardItem { + let now = Utc::now().timestamp_millis(); + ClipboardItem { + id, + kind: ClipboardItemKind::Plain, + content: content.to_string(), + plain_preview: preview.map(str::to_owned), + public_label: None, + byte_size: content.len() as i64, + created_at: now, + last_copied_at: now, + last_used_at: None, + last_edited_at: None, + retention: RetentionState::default(), + } + } + + #[test] + fn empty_query_returns_everything() { + let items = vec![item(1, "foo", None), item(2, "bar", None)]; + assert_eq!(filter_indices(&items, ""), vec![0, 1]); + } + + #[test] + fn single_token_match() { + let items = vec![item(1, "foo", None), item(2, "bar", None)]; + assert_eq!(filter_indices(&items, "FOO"), vec![0]); + } + + #[test] + fn multiple_tokens_must_all_match() { + let items = vec![ + item(1, "rust cargo test", None), + item(2, "rust and ts", None), + item(3, "cargo ts", None), + ]; + let indices = filter_indices(&items, "rust cargo"); + assert_eq!(indices, vec![0]); + } + + #[test] + fn preview_short_circuit() { + let items = vec![item( + 1, + "full\nbody\nthat\u{1F4A9}\u{2603}long", + Some("full body …"), + )]; + let indices = filter_indices(&items, "body"); + assert_eq!(indices, vec![0]); + // The full-body content has an emoji; the lowercase preview path does the matching. + let indices = filter_indices(&items, "emoji"); + assert_eq!(indices, Vec::::new()); + } +} diff --git a/src/clipboard/items.rs b/src/clipboard/items.rs new file mode 100644 index 0000000..298b9a5 --- /dev/null +++ b/src/clipboard/items.rs @@ -0,0 +1,162 @@ +//! The clipboard history data model. +//! +//! A [`ClipboardItem`] captures everything the GUI chooser and CLI need to share: stable identity, +//! recency, retention reasons, and the actual content (either plain text or a protected envelope). +//! Pure functions like [`recency_at_ms`] and [`retention_state`] derive the values that drive both +//! the shared ordering and the retention policy. + +use serde::{Deserialize, Serialize}; + +/// A sentinel returned by [`recency_at_ms`] when the item is currently being edited or used, and +/// the capture cycle should drop the new copy rather than evict the in-use one. Callers that +/// return this value skip pruning only if there are no other ordinary victims available. +pub const PROTECTED_MATCH_ABORT_RUN: i64 = i64::MIN; + +/// The kind of content stored in a single clipboard row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClipboardItemKind { + /// Plain text that is collected automatically and may be searched. + Plain, + /// Text encrypted with the master's data key; only metadata is searchable. + Protected, +} + +/// What an ordered row looks like to the chooser. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClipboardItem { + pub id: i64, + pub kind: ClipboardItemKind, + pub content: String, + pub plain_preview: Option, + pub public_label: Option, + pub byte_size: i64, + pub created_at: i64, + pub last_copied_at: i64, + pub last_used_at: Option, + pub last_edited_at: Option, + pub retention: RetentionState, +} + +/// Empty rows must never be saved: capture only stores rows that contain at least one +/// non-whitespace character. White-space-only content is also rejected so callers cannot trick the +/// store into recording empty boards. +pub fn is_collectable(content: &str) -> bool { + !content.is_empty() && content.chars().any(|c| !c.is_whitespace()) +} + +/// The single MRU timestamp that drives shared ordering. Comparisons should never enumerate every +/// field by hand; this function is the rule. It uses `MAX(...)` of: +/// - `created_at` — when the row first appeared. +/// - `last_copied_at` — when the same content was last seen copied while the user is not +/// actively editing. +/// - `last_used_at` — when the row was last successfully pasted or run. +/// - `last_edited_at` — when a saved edit changed the row's content. +/// +/// Reserve/protect/unlock alone do not bump this value. Promotion happens after the action +/// completes, never while the user is still choosing what to do. +pub fn recency_at_ms(item: &ClipboardItem) -> i64 { + item.created_at + .max(item.last_copied_at) + .max(item.last_used_at.unwrap_or(i64::MIN)) + .max(item.last_edited_at.unwrap_or(i64::MIN)) +} + +/// The retention reasons attached to a row are independent flags. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetentionState { + pub explicit: bool, + pub edited: bool, + pub protected_: bool, +} + +impl RetentionState { + pub fn is_retained(&self) -> bool { + self.explicit || self.edited || self.protected_ + } + + pub fn ordinary(&self) -> bool { + !self.is_retained() + } + + pub fn explicit(&self) -> bool { + self.explicit + } + + pub fn edited(&self) -> bool { + self.edited + } + + pub fn protected(&self) -> bool { + self.protected_ + } +} + +impl ClipboardItem { + /// The human-readable text used both for filtering and as the row label. + /// + /// For ordinary items this is the row's content. For protected items the chooser never has + /// the plaintext unless it is unlocked. Only an explicitly public alias or generic label + /// is displayed; previews and ciphertext are never used as protected display text. + pub fn preview_text(&self) -> String { + if let Some(public_label) = self.public_label.as_ref().filter(|s| !s.is_empty()) { + return public_label.clone(); + } + if self.kind == ClipboardItemKind::Protected { + return "Protected item".to_string(); + } + if let Some(preview) = self.plain_preview.as_ref().filter(|p| !p.is_empty()) { + // Collapse newlines so each entry renders as a single row, then truncate. + let collapsed: String = preview + .chars() + .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) + .collect(); + return collapsed.chars().take(120).collect(); + } + if !self.content.is_empty() { + return self.content.chars().take(120).collect(); + } + self.id.to_string() + } + + /// The string the chooser feeds into [`crate::clipboard::filter::filter_indices`]. We must + /// never include the secret plaintext of a protected row because the chooser rebuilds this + /// string before the vault is opened. + pub fn searchable_string(&self) -> String { + let mut combined = String::new(); + if let Some(label) = &self.public_label { + combined.push_str(label); + combined.push('\n'); + } + if self.kind == ClipboardItemKind::Protected { + return combined; + } + if let Some(preview) = &self.plain_preview { + combined.push_str(preview); + } + if combined.is_empty() { + combined.push_str(&self.content); + } + combined + } +} + +/// What the chooser surfaces about a protected row. The plaintext is the value just authorised by +/// the master password; the `recency_at_ms` view is unchanged so the picker still shows it in MRU +/// order. The match token, when present, lets collection replace a fresh identical copy rather +/// than create a parallel ordinary row. +#[derive(Clone)] +pub struct ProtectedPreview { + pub item: ClipboardItem, + pub plaintext: String, + pub match_token: Option, +} + +impl std::fmt::Debug for ProtectedPreview { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProtectedPreview") + .field("id", &self.item.id) + .field("plaintext", &"[redacted]") + .field("match_token", &"[redacted]") + .finish() + } +} diff --git a/src/clipboard/mod.rs b/src/clipboard/mod.rs new file mode 100644 index 0000000..79050b1 --- /dev/null +++ b/src/clipboard/mod.rs @@ -0,0 +1,30 @@ +//! Shared clipboard history model. +//! +//! Both the GUI chooser and the CLI `cp ls` command read, filter, and promote rows through the +//! functions here, so two surfaces never disagree about what "recent" or "matched by query" +//! means. Storage backends implement [`storage::ClipboardHistoryStore`]; the data model itself +//! stays in this module so tests can exercise it without a database. + +pub mod argv; +pub mod filter; +pub mod items; +pub mod ordering; +pub mod retention; +pub mod settings; + +#[cfg(feature = "storage")] +pub mod storage; + +#[cfg(feature = "storage")] +pub mod vault; +#[cfg(feature = "storage")] +pub mod vault_controller; + +#[cfg(test)] +mod tests; + +pub use items::{ + ClipboardItem, ClipboardItemKind, PROTECTED_MATCH_ABORT_RUN, ProtectedPreview, RetentionState, +}; + +pub use settings::ClipboardSettings; diff --git a/src/clipboard/ordering.rs b/src/clipboard/ordering.rs new file mode 100644 index 0000000..bca39a0 --- /dev/null +++ b/src/clipboard/ordering.rs @@ -0,0 +1,45 @@ +//! Shared ordering and tie-breaking. +//! +//! Both GUI and CLI use the same comparator so a row's position is identical across surfaces. + +use super::items::{ClipboardItem, RetentionState}; + +/// Stable comparator for the chooser / CLI list. +/// +/// Rules, in order: +/// 1. Larger `recency_at_ms` first. +/// 2. Larger `created_at` to break ties deterministically (older vs newer rows that share a +/// timestamp). +/// 3. Larger `id` to break rare millisecond collisions deterministically. +/// +/// Comparisons also group retained rows above pure-MRU by recency: once we have decided two +/// items belong together, retention does not change their relative order, but it keeps them out of +/// the ordinary-eviction pool. +pub fn compare_by_recency(a: &ClipboardItem, b: &ClipboardItem) -> std::cmp::Ordering { + let ra = super::items::recency_at_ms(a); + let rb = super::items::recency_at_ms(b); + rb.cmp(&ra) + .then_with(|| b.created_at.cmp(&a.created_at)) + .then_with(|| b.id.cmp(&a.id)) +} + +/// Stable in-place sort of `items` according to [`compare_by_recency`]. +pub fn sort_in_place(items: &mut [ClipboardItem]) { + items.sort_by(compare_by_recency); +} + +/// The recency key used to evict least-recent ordinary items. We use the same key as the chooser +/// so the row at the bottom is the row that gets evicted. +pub fn recency_key(item: &ClipboardItem) -> i64 { + super::items::recency_at_ms(item) +} + +/// Helper for backends that want to mark items as "ordinary" or "retained" without re-creating the +/// struct. +pub const fn empty_retention() -> RetentionState { + RetentionState { + explicit: false, + edited: false, + protected_: false, + } +} diff --git a/src/clipboard/retention.rs b/src/clipboard/retention.rs new file mode 100644 index 0000000..da2e9bc --- /dev/null +++ b/src/clipboard/retention.rs @@ -0,0 +1,203 @@ +//! Eviction rules shared by capture and CLI. +//! +//! `eviction_victims` returns the ordered list of ordinary items that should be deleted to bring +//! the ordinary-count and ordinary-byte budgets back under their limits. Retained rows are never +//! returned, even when the limits are tight: a saved/protected/edited/explicit item is part of +//! the user's history and is not fair game. + +use super::items::{ClipboardItem, RetentionState}; +use std::collections::HashSet; + +/// Decide which items to delete from the ordinary pool to fit new capture into a count and byte +/// budget. Both limits are checked; the worst case (most severe overflow) drives how many rows +/// are flagged. `existing_leases` are ids that are temporarily protected while the user edits or +/// uses a row; they are treated as retained for the duration of the call. +pub fn eviction_victims<'a>( + ordinary: &'a [ClipboardItem], + incoming_bytes: i64, + count_limit: i64, + byte_limit: i64, + existing_leases: &HashSet, +) -> Vec<&'a ClipboardItem> { + if count_limit <= 0 && byte_limit <= 0 { + return Vec::new(); + } + + let eligible: Vec<_> = ordinary + .iter() + .filter(|item| { + item.kind == super::items::ClipboardItemKind::Plain && item.retention.ordinary() + }) + .collect(); + let ordinary_count = eligible.len() as i64; + let mut overflow_count = 0; + if count_limit > 0 { + overflow_count = (ordinary_count + 1 - count_limit).max(0); + } + + let ordinary_bytes: i64 = eligible.iter().map(|item| item.byte_size).sum(); + let mut overflow_bytes = 0; + if byte_limit > 0 { + overflow_bytes = (ordinary_bytes + incoming_bytes - byte_limit).max(0); + } + + if overflow_count == 0 && overflow_bytes == 0 { + return Vec::new(); + } + + // Sort oldest-first: the chooser shows MRU, so what is on the bottom is what we evict. + let mut candidates: Vec<&ClipboardItem> = eligible; + candidates.sort_by_key(|item| super::ordering::recency_key(item)); + + let mut victims = Vec::new(); + let mut released_bytes: i64 = 0; + for candidate in candidates.iter() { + if existing_leases.contains(&candidate.id) { + continue; + } + let count_satisfied = overflow_count == 0 || (victims.len() as i64) >= overflow_count; + let bytes_satisfied = overflow_bytes == 0 || released_bytes >= overflow_bytes; + if count_satisfied && bytes_satisfied { + break; + } + released_bytes += candidate.byte_size; + victims.push(*candidate); + } + victims +} + +/// True when the row is considered retained by the user (any reason). +pub fn is_retained(state: &RetentionState) -> bool { + state.is_retained() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clipboard::items::ClipboardItemKind; + use chrono::Utc; + + fn row(id: i64, byte_size: i64, edited: bool, explicit: bool, _plain: bool) -> ClipboardItem { + let now = Utc::now().timestamp_millis(); + ClipboardItem { + id, + kind: ClipboardItemKind::Plain, + content: "x".repeat(byte_size as usize), + plain_preview: None, + public_label: None, + byte_size, + created_at: now - id * 100, + last_copied_at: now - id * 50, + last_used_at: None, + last_edited_at: edited.then_some(now - id), + retention: RetentionState { + explicit, + edited, + protected_: false, + }, + } + } + + #[test] + fn returns_no_victims_when_under_budget() { + let rows = vec![row(1, 100, false, false, true)]; + let victims = eviction_victims(&rows, 100, 10, 1024, &HashSet::new()); + assert!(victims.is_empty()); + } + + #[test] + fn evicted_rows_are_oldest_ordinary_first() { + // 3 rows, cap at 1, incoming 1 → 3 victims; helper: smallest id = newest. So oldest + // first means id 3, then 2, then 1. + let rows = vec![ + row(1, 100, false, false, true), + row(2, 100, false, false, true), + row(3, 100, false, false, true), + ]; + let victims = eviction_victims(&rows, 100, 1, 4096, &HashSet::new()); + let ids: Vec = victims.iter().map(|r| r.id).collect(); + assert_eq!(ids, vec![3, 2, 1]); + } + + #[test] + fn retained_rows_are_never_evicted() { + // Retained rows are filtered out by the caller; only the ordinary rows are passed in. + let ordinary_rows: Vec = vec![ + row(1, 100, false, false, true), + row(2, 100, false, false, true), + ]; + // Caller additionally skips id=2 (which they held as a lease in a broader system). + let mut leases = HashSet::new(); + leases.insert(2); + let victims = eviction_victims(&ordinary_rows, 100, 1, 4096, &leases); + let ids: Vec = victims.iter().map(|r| r.id).collect(); + // id 1 is the only remaining candidate and not leased. + assert_eq!(ids, vec![1]); + } + + #[test] + fn leases_block_eviction_temporarily() { + // Only id 3 is ordinary; cap at 1 with 2 rows + 1 incoming → need 2 victims. With id 2 + // leased and only id 3 ordinary left, no victim is available; function returns empty. + let rows = vec![ + row(3, 100, false, false, true), + row(2, 100, false, false, true), + ]; + let mut leases = HashSet::new(); + leases.insert(2); + let victims = eviction_victims(&rows, 100, 1, 4096, &leases); + let ids: Vec = victims.iter().map(|r| r.id).collect(); + // id 2 is leased, so the loop runs out without finding enough free rows; the caller + // decides whether to keep the new capture on disk. We get id 3 as the only candidate, + // but it's not enough to satisfy the overflow, so we surface what we have. + assert_eq!(ids, vec![3]); + } +} + +#[cfg(test)] +mod regression_tests { + use super::*; + use crate::clipboard::items::ClipboardItemKind; + + #[test] + fn byte_budget_requires_deleting_the_final_victim_not_just_inspecting_it() { + let rows: Vec<_> = (1..=3) + .map(|id| ClipboardItem { + id, + kind: ClipboardItemKind::Plain, + content: "x".repeat(100), + byte_size: 100, + plain_preview: None, + public_label: None, + created_at: id, + last_copied_at: id, + last_used_at: None, + last_edited_at: None, + retention: RetentionState::default(), + }) + .collect(); + let victims = eviction_victims(&rows, 100, 100, 200, &HashSet::new()); + assert_eq!( + victims.iter().map(|row| row.id).collect::>(), + vec![1, 2] + ); + } + + #[test] + fn damaged_protected_metadata_never_makes_ciphertext_an_eviction_candidate() { + let row = ClipboardItem { + id: 1, + kind: ClipboardItemKind::Protected, + content: String::new(), + byte_size: 100, + plain_preview: None, + public_label: None, + created_at: 1, + last_copied_at: 1, + last_used_at: None, + last_edited_at: None, + retention: RetentionState::default(), + }; + assert!(eviction_victims(&[row], 100, 1, 1, &HashSet::new()).is_empty()); + } +} diff --git a/src/clipboard/settings.rs b/src/clipboard/settings.rs new file mode 100644 index 0000000..05a7f7d --- /dev/null +++ b/src/clipboard/settings.rs @@ -0,0 +1,23 @@ +//! User-tunable limits for the clipboard collector. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClipboardSettings { + /// Maximum number of *ordinary* rows. Retained rows do not count. + pub normal_count_limit: i64, + /// Maximum total bytes of *ordinary* content. Retained rows do not count. + pub normal_byte_limit: i64, + /// Whether the desktop collector is allowed to ingest. + pub capture_enabled: bool, +} + +impl Default for ClipboardSettings { + fn default() -> Self { + Self { + normal_count_limit: 500, + normal_byte_limit: 20 * 1024 * 1024, + capture_enabled: false, + } + } +} diff --git a/src/clipboard/storage.rs b/src/clipboard/storage.rs new file mode 100644 index 0000000..d47e42f --- /dev/null +++ b/src/clipboard/storage.rs @@ -0,0 +1,914 @@ +//! SQLite-backed implementation of the clipboard history storage. +//! +//! The trait lives here so it is not shared with the sticker store. Callers should treat +//! [`SqliteClipboardStore`] as the only implementation in v1. + +use anyhow::{Context, Result, ensure}; +use async_trait::async_trait; +use sha2::{Digest, Sha256}; +use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; + +use super::items::{ClipboardItem, ClipboardItemKind, RetentionState}; +use super::settings::ClipboardSettings; + +/// Storage interface for the clipboard history. +/// +/// Methods are split so the GUI chooser and CLI chooser never accidentally use a sticker-shaped +/// API. Snapshots are returned by value; backends must hand out a deep copy rather than a live +/// view that can be mutated by writers. +#[async_trait] +pub trait ClipboardHistoryStore: Send + Sync { + async fn settings(&self) -> Result; + async fn update_settings(&self, settings: &ClipboardSettings) -> Result<()>; + + /// Return every row the chooser could show, sorted MRU-first. The query, if present, is + /// applied to the search columns; the chooser API still re-filters so it can update without + /// a roundtrip. + async fn snapshot(&self) -> Result>; + + /// Cheap privacy-policy probe: does not load clipboard payloads or previews. + async fn has_protected_items(&self) -> Result; + + /// Read one row by id. + async fn get(&self, id: i64) -> Result; + + /// Insert or update a row by content identity. The implementation decides whether the content + /// is already present (plain-text digest for ordinary rows, the supplied match token for + /// protected rows). Returns the row id and whether the row was newly created. + async fn upsert_ordinary( + &self, + content: &str, + plain_preview: &str, + plaintext_digest: &str, + byte_size: i64, + now_ms: i64, + ) -> Result; + + /// Store ciphertext only. Plaintext previews are rejected. An optional caller-authenticated + /// keyed token deduplicates exact protected content; no token means a distinct row. + async fn upsert_protected( + &self, + public_label: Option<&str>, + plain_preview: Option<&str>, + match_token: Option<&str>, + envelope: &str, + byte_size: i64, + now_ms: i64, + ) -> Result; + + /// Generation-bound protected insert. The expected vault envelope must be captured from + /// the same unlocked key that sealed `envelope`, not freshly read from storage afterward. + #[allow(clippy::too_many_arguments)] + async fn upsert_protected_bound( + &self, + public_label: Option<&str>, + plain_preview: Option<&str>, + match_token: Option<&str>, + envelope: &str, + byte_size: i64, + now_ms: i64, + expected_vault_envelope: &str, + ) -> Result; + + /// Protect an existing ordinary row without changing its identity, recency, or independent + /// retention reasons. Rejects stale content, deletion, and concurrent vault reset/rewrap. + /// Clears live plaintext columns only; this does not guarantee historical disk erasure. + async fn compare_exchange_protection( + &self, + id: i64, + expected_plaintext: &str, + envelope: &str, + expected_vault_envelope: &str, + public_label: Option<&str>, + ) -> Result<()>; + /// Restore authenticated plaintext against the exact ciphertext and vault generation. + async fn compare_exchange_unprotection( + &self, + id: i64, + expected: &str, + plaintext: &str, + expected_vault_envelope: &str, + ) -> Result<()>; + /// Apply the eviction list returned by `clipboard::retention::eviction_victims`. The store + /// runs the deletion in a transaction so an in-flight capture cannot half-evict. + async fn delete_ordinary_by_ids(&self, ids: &[i64]) -> Result<()>; + + async fn set_retention_flag(&self, id: i64, flag: RetentionFlag, value: bool) -> Result<()>; + + async fn replace_content( + &self, + id: i64, + content: &str, + byte_size: i64, + now_ms: i64, + ) -> Result<()>; + + /// Save against the exact content read by the editor; fails on delete or concurrent edit. + async fn compare_exchange_content( + &self, + id: i64, + expected: &str, + replacement: &str, + byte_size: i64, + now_ms: i64, + ) -> Result<()>; + + /// Protected edits must supply fresh ciphertext and the ciphertext read on opening. + async fn compare_exchange_protected_content( + &self, + id: i64, + expected: &str, + replacement: &str, + byte_size: i64, + now_ms: i64, + ) -> Result<()>; + + /// Generation-bound counterpart of protected editing. Seal the replacement and capture + /// its vault envelope together; ciphertext from a stale cached vault must not be stored. + async fn compare_exchange_protected_content_bound( + &self, + id: i64, + expected: &str, + replacement: &str, + byte_size: i64, + now_ms: i64, + expected_vault_envelope: &str, + ) -> Result<()>; + async fn mark_used(&self, id: i64, now_ms: i64) -> Result<()>; + + async fn delete_row(&self, id: i64) -> Result<()>; + + async fn read_vault_envelope(&self) -> Result>; + async fn write_vault_envelope(&self, envelope: &str, active_until_unix_ms: i64) -> Result<()>; + async fn clear_vault_envelope(&self) -> Result<()>; + /// Compare-and-swap protects password changes against stale controllers. + async fn compare_exchange_vault_envelope( + &self, + expected: &str, + replacement: &str, + active_until_unix_ms: i64, + ) -> Result<()>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetentionFlag { + Explicit, + Edited, + Protected, +} + +impl RetentionFlag { + fn column(self) -> &'static str { + match self { + RetentionFlag::Explicit => "retention_explicit", + RetentionFlag::Edited => "retention_edited", + RetentionFlag::Protected => "retention_protected", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpsertOutcome { + Inserted { id: i64 }, + Updated { id: i64 }, +} + +#[derive(Debug, Clone)] +pub struct SqliteClipboardStore { + pool: SqlitePool, +} + +impl SqliteClipboardStore { + pub async fn open(db_path: impl AsRef) -> Result { + let path = db_path.as_ref().to_path_buf(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).context("create clipboard db parent dir")?; + } + let options = sqlx::sqlite::SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true) + .busy_timeout(std::time::Duration::from_secs(5)) + .pragma("secure_delete", "ON"); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .context("connect clipboard sqlite pool")?; + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(Self { pool }) + } + + /// Exact UTF-8 SHA-256 digest used as the ordinary-row dedupe key. It is not a secret. + pub fn ordinary_digest(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + let digest = hasher.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write; + let _ = write!(&mut out, "{byte:02x}"); + } + out + } + + fn row_to_item(row: ClipboardRow) -> ClipboardItem { + let kind = if row.protected_envelope.is_some() { + ClipboardItemKind::Protected + } else { + ClipboardItemKind::Plain + }; + ClipboardItem { + id: row.id, + kind, + content: row.protected_envelope.unwrap_or(row.content), + plain_preview: if kind == ClipboardItemKind::Protected { + None + } else { + row.plain_preview + }, + public_label: row.public_label, + byte_size: row.byte_size, + created_at: row.created_at, + last_copied_at: row.last_copied_at, + last_used_at: row.last_used_at, + last_edited_at: row.last_edited_at, + retention: RetentionState { + explicit: row.retention_explicit != 0, + edited: row.retention_edited != 0, + protected_: kind == ClipboardItemKind::Protected || row.retention_protected != 0, + }, + } + } + + async fn require_vault_generation( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + expected: &str, + ) -> Result<()> { + ensure!( + !expected.is_empty(), + "protected writes require an expected vault envelope" + ); + let current: Option = + sqlx::query_scalar("SELECT envelope FROM clipboard_vault WHERE id = 1") + .fetch_optional(&mut **tx) + .await?; + ensure!( + current.as_deref() == Some(expected), + "vault changed or was disabled; unlock again before saving" + ); + Ok(()) + } + /// Called under an immediate write transaction: capture and limits cannot race pruning. + async fn trim_ordinary(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>) -> Result<()> { + let settings: Vec<(String, String)> = + sqlx::query_as("SELECT key, value FROM clipboard_settings") + .fetch_all(&mut **tx) + .await?; + let mut limits = ClipboardSettings::default(); + for (key, value) in settings { + match key.as_str() { + "normal_count_limit" => limits.normal_count_limit = value.parse()?, + "normal_byte_limit" => limits.normal_byte_limit = value.parse()?, + _ => {} + } + } + let rows: Vec<(i64, i64)> = sqlx::query_as( + "SELECT id, byte_size FROM clipboard_items WHERE protected_envelope IS NULL + AND retention_explicit = 0 AND retention_edited = 0 AND retention_protected = 0 + ORDER BY MAX(created_at, last_copied_at, COALESCE(last_used_at, 0), + COALESCE(last_edited_at, 0)), created_at, id", + ) + .fetch_all(&mut **tx) + .await?; + let mut count = rows.len() as i64; + let mut bytes: i64 = rows.iter().map(|(_, size)| size).sum(); + for (id, size) in rows { + if (limits.normal_count_limit <= 0 || count <= limits.normal_count_limit) + && (limits.normal_byte_limit <= 0 || bytes <= limits.normal_byte_limit) + { + break; + } + sqlx::query("DELETE FROM clipboard_items WHERE id = ?1") + .bind(id) + .execute(&mut **tx) + .await?; + count -= 1; + bytes -= size; + } + Ok(()) + } + async fn read_settings(&self) -> Result { + let row = + sqlx::query_as::<_, (String, String)>("SELECT key, value FROM clipboard_settings") + .fetch_all(&self.pool) + .await + .context("read clipboard settings")?; + let mut s = ClipboardSettings::default(); + for (k, v) in row { + match k.as_str() { + "normal_count_limit" => { + if let Ok(n) = v.parse() { + s.normal_count_limit = n; + } + } + "normal_byte_limit" => { + if let Ok(n) = v.parse() { + s.normal_byte_limit = n; + } + } + "capture_enabled" => { + s.capture_enabled = matches!(v.as_str(), "1" | "true"); + } + _ => {} + } + } + Ok(s) + } +} + +#[derive(Debug, sqlx::FromRow)] +struct ClipboardRow { + id: i64, + content: String, + plain_preview: Option, + public_label: Option, + retention_explicit: i64, + retention_edited: i64, + retention_protected: i64, + protected_envelope: Option, + created_at: i64, + last_copied_at: i64, + last_used_at: Option, + last_edited_at: Option, + byte_size: i64, +} + +#[async_trait] +impl ClipboardHistoryStore for SqliteClipboardStore { + async fn settings(&self) -> Result { + self.read_settings().await + } + + async fn update_settings(&self, settings: &ClipboardSettings) -> Result<()> { + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let pairs: [(&str, String); 3] = [ + ( + "normal_count_limit", + settings.normal_count_limit.to_string(), + ), + ("normal_byte_limit", settings.normal_byte_limit.to_string()), + ( + "capture_enabled", + if settings.capture_enabled { + "1".into() + } else { + "0".into() + }, + ), + ]; + for (k, v) in pairs { + sqlx::query( + "INSERT INTO clipboard_settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ) + .bind(k) + .bind(v) + .execute(&mut *tx) + .await + .context("update clipboard setting")?; + } + Self::trim_ordinary(&mut tx).await?; + tx.commit().await?; + Ok(()) + } + + async fn has_protected_items(&self) -> Result { + Ok(sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM clipboard_items WHERE protected_envelope IS NOT NULL)", + ) + .fetch_one(&self.pool) + .await?) + } + async fn snapshot(&self) -> Result> { + let rows = sqlx::query_as::<_, ClipboardRow>( + r#"SELECT id, content, plain_preview, public_label, + retention_explicit, retention_edited, retention_protected, + protected_envelope, created_at, last_copied_at, + last_used_at, last_edited_at, byte_size + FROM clipboard_items + ORDER BY id DESC"#, + ) + .fetch_all(&self.pool) + .await + .context("snapshot clipboard items")?; + let mut items: Vec = rows.into_iter().map(Self::row_to_item).collect(); + super::ordering::sort_in_place(&mut items); + Ok(items) + } + + async fn get(&self, id: i64) -> Result { + let row = sqlx::query_as::<_, ClipboardRow>( + r#"SELECT id, content, plain_preview, public_label, + retention_explicit, retention_edited, retention_protected, + protected_envelope, created_at, last_copied_at, + last_used_at, last_edited_at, byte_size + FROM clipboard_items + WHERE id = ?1"#, + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .context("get clipboard row")? + .ok_or_else(|| anyhow::anyhow!("clipboard row {id} not found"))?; + Ok(Self::row_to_item(row)) + } + + async fn upsert_ordinary( + &self, + content: &str, + plain_preview: &str, + plaintext_digest: &str, + byte_size: i64, + now_ms: i64, + ) -> Result { + ensure!( + plaintext_digest == Self::ordinary_digest(content), + "clipboard digest must match exact content" + ); + ensure!( + byte_size == content.len() as i64, + "clipboard byte size must match content" + ); + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let byte_limit: Option = sqlx::query_scalar( + "SELECT value FROM clipboard_settings WHERE key = 'normal_byte_limit'", + ) + .fetch_optional(&mut *tx) + .await?; + let byte_limit = byte_limit + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(ClipboardSettings::default().normal_byte_limit); + ensure!( + byte_limit <= 0 || byte_size <= byte_limit, + "clipboard content exceeds the ordinary byte limit" + ); + // No authorized keyed match check is supplied here: fail closed on recapture. + let protected: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM clipboard_items WHERE protected_envelope IS NOT NULL)", + ) + .fetch_one(&mut *tx) + .await?; + ensure!( + !protected, + "ordinary collection paused: protected history requires an authorized match check" + ); + let existing: Option<(i64, i64)> = sqlx::query_as( + "SELECT id, byte_size FROM clipboard_items + WHERE plaintext_digest = ?1 AND protected_envelope IS NULL AND content = ?2 + LIMIT 1", + ) + .bind(plaintext_digest) + .bind(content) + .fetch_optional(&mut *tx) + .await + .context("lookup digest")?; + + if let Some((id, _)) = existing { + sqlx::query( + "UPDATE clipboard_items + SET last_copied_at = MAX(last_copied_at, ?1), byte_size = ?2, content_bytes = ?2, content = ?3, plain_preview = ?4 + WHERE id = ?5", + ) + .bind(now_ms) + .bind(byte_size) + .bind(content) + .bind(plain_preview) + .bind(id) + .execute(&mut *tx) + .await + .context("update ordinary row")?; + Self::trim_ordinary(&mut tx).await?; + tx.commit().await?; + return Ok(UpsertOutcome::Updated { id }); + } + + let id: i64 = sqlx::query_scalar( + "INSERT INTO clipboard_items ( + content, content_bytes, plain_preview, plaintext_digest, + retention_explicit, retention_edited, retention_protected, locked, + created_at, last_copied_at, byte_size + ) VALUES (?1, ?2, ?3, ?4, 0, 0, 0, 0, ?5, ?6, ?7) + RETURNING id", + ) + .bind(content) + .bind(byte_size) + .bind(plain_preview) + .bind(plaintext_digest) + .bind(now_ms) + .bind(now_ms) + .bind(byte_size) + .fetch_one(&mut *tx) + .await + .context("insert ordinary row")?; + Self::trim_ordinary(&mut tx).await?; + tx.commit().await?; + Ok(UpsertOutcome::Inserted { id }) + } + + async fn upsert_protected( + &self, + _public_label: Option<&str>, + _plain_preview: Option<&str>, + _match_token: Option<&str>, + _envelope: &str, + _byte_size: i64, + _now_ms: i64, + ) -> Result { + anyhow::bail!("protected writes require a vault generation; use upsert_protected_bound") + } + #[allow(clippy::too_many_arguments)] + async fn upsert_protected_bound( + &self, + public_label: Option<&str>, + plain_preview: Option<&str>, + match_token: Option<&str>, + envelope: &str, + byte_size: i64, + now_ms: i64, + expected_vault_envelope: &str, + ) -> Result { + ensure!( + plain_preview.is_none(), + "protected rows cannot persist plaintext previews" + ); + ensure!(byte_size >= 0, "clipboard byte size cannot be negative"); + super::vault::validate_sealed(envelope)?; + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; + if let Some(token) = match_token { + let existing: Option = sqlx::query_scalar( + "SELECT id FROM clipboard_items WHERE protected_envelope IS NOT NULL AND protected_match_token = ?1 LIMIT 1") + .bind(token).fetch_optional(&mut *tx).await?; + if let Some(id) = existing { + sqlx::query("UPDATE clipboard_items SET last_copied_at = MAX(last_copied_at, ?1) WHERE id = ?2") + .bind(now_ms).bind(id).execute(&mut *tx).await?; + tx.commit().await?; + return Ok(UpsertOutcome::Updated { id }); + } + } + let id: i64 = sqlx::query_scalar( + "INSERT INTO clipboard_items ( + content, content_bytes, plain_preview, protected_match_token, + protected_envelope, public_label, + retention_explicit, retention_edited, retention_protected, locked, + created_at, last_copied_at, byte_size + ) VALUES ('', ?1, ?2, ?3, ?4, ?5, 0, 0, 1, 1, ?6, ?7, ?1) + RETURNING id", + ) + .bind(byte_size) + .bind(plain_preview) + .bind(match_token) + .bind(envelope) + .bind(public_label) + .bind(now_ms) + .bind(now_ms) + .fetch_one(&mut *tx) + .await + .context("insert protected row")?; + tx.commit().await?; + Ok(UpsertOutcome::Inserted { id }) + } + + async fn compare_exchange_protection( + &self, + id: i64, + expected_plaintext: &str, + envelope: &str, + expected_vault_envelope: &str, + public_label: Option<&str>, + ) -> Result<()> { + if let Some(label) = public_label { + ensure!(!label.trim().is_empty(), "A public label is required"); + } + super::vault::validate_sealed(envelope)?; + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; + // Read and compare while holding SQLite's writer lock; do not trust a chooser snapshot. + let current: Option<(String, Option)> = + sqlx::query_as("SELECT content, protected_envelope FROM clipboard_items WHERE id = ?1") + .bind(id) + .fetch_optional(&mut *tx) + .await?; + let (plaintext, protected) = current.context("clipboard row no longer exists")?; + let plaintext = zeroize::Zeroizing::new(plaintext); + ensure!( + protected.is_none() && plaintext.as_str() == expected_plaintext, + "clipboard row changed; reload before protecting" + ); + sqlx::query( + "UPDATE clipboard_items SET protected_envelope = ?1, content = '', + plain_preview = NULL, plaintext_digest = NULL, protected_match_token = NULL, + content_bytes = ?2, byte_size = ?2, retention_protected = 1, locked = 1, public_label = ?4 + WHERE id = ?3", + ) + .bind(envelope) + .bind(expected_plaintext.len() as i64) + .bind(id) + .bind(public_label.map(str::trim)) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + async fn compare_exchange_unprotection( + &self, + id: i64, + expected: &str, + plaintext: &str, + expected_vault_envelope: &str, + ) -> Result<()> { + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; + let affected = sqlx::query("UPDATE clipboard_items SET content = ?1, plain_preview = substr(?1, 1, 240), plaintext_digest = ?2, + protected_envelope = NULL, protected_match_token = NULL, public_label = NULL, + retention_protected = 0, locked = 0, content_bytes = ?3, byte_size = ?3 + WHERE id = ?4 AND protected_envelope = ?5") + .bind(plaintext).bind(Self::ordinary_digest(plaintext)).bind(plaintext.len() as i64) + .bind(id).bind(expected).execute(&mut *tx).await?.rows_affected(); + ensure!( + affected == 1, + "protected row changed or was deleted; reload before unlocking" + ); + tx.commit().await?; + Ok(()) + } + async fn delete_ordinary_by_ids(&self, id: &[i64]) -> Result<()> { + if id.is_empty() { + return Ok(()); + } + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let placeholders = std::iter::repeat_n("?", id.len()) + .collect::>() + .join(","); + let sql = format!( + "DELETE FROM clipboard_items + WHERE id IN ({placeholders}) + AND retention_explicit = 0 + AND retention_edited = 0 + AND retention_protected = 0 + AND protected_envelope IS NULL" + ); + let mut query = sqlx::query(sqlx::AssertSqlSafe(sql)); + for id in id { + query = query.bind(id); + } + query + .execute(&mut *tx) + .await + .context("delete ordinary rows")?; + tx.commit().await?; + Ok(()) + } + + async fn set_retention_flag(&self, id: i64, flag: RetentionFlag, value: bool) -> Result<()> { + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let row: Option<(Option, i64)> = sqlx::query_as( + "SELECT protected_envelope, retention_protected FROM clipboard_items WHERE id = ?1", + ) + .bind(id) + .fetch_optional(&mut *tx) + .await?; + let (envelope, reason) = row.context("clipboard row no longer exists")?; + if flag == RetentionFlag::Protected { + ensure!( + !value || envelope.is_some() || reason != 0, + "protect requires encrypted content, not a retention flag" + ); + ensure!( + value || envelope.is_none(), + "unprotect requires authenticated decryption" + ); + } + let column = flag.column(); + let sql = format!("UPDATE clipboard_items SET {column} = ?1 WHERE id = ?2"); + sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(i64::from(value)) + .bind(id) + .execute(&mut *tx) + .await + .context("set retention flag")?; + tx.commit().await?; + Ok(()) + } + + async fn replace_content( + &self, + id: i64, + content: &str, + byte_size: i64, + now_ms: i64, + ) -> Result<()> { + ensure!( + byte_size == content.len() as i64, + "clipboard byte size must match content" + ); + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let row: Option<(String, Option)> = + sqlx::query_as("SELECT content, protected_envelope FROM clipboard_items WHERE id = ?1") + .bind(id) + .fetch_optional(&mut *tx) + .await?; + let (previous, envelope) = row.context("clipboard row no longer exists")?; + ensure!( + envelope.is_none(), + "protected editing requires re-encryption" + ); + if previous != content { + sqlx::query( + "UPDATE clipboard_items SET content = ?1, byte_size = ?2, + content_bytes = ?2, last_edited_at = ?3, retention_edited = 1, + plain_preview = substr(?1, 1, 240), plaintext_digest = ?4 WHERE id = ?5", + ) + .bind(content) + .bind(byte_size) + .bind(now_ms) + .bind(Self::ordinary_digest(content)) + .bind(id) + .execute(&mut *tx) + .await + .context("replace content")?; + } + tx.commit().await?; + Ok(()) + } + async fn compare_exchange_content( + &self, + id: i64, + expected: &str, + replacement: &str, + byte_size: i64, + now_ms: i64, + ) -> Result<()> { + ensure!( + byte_size == replacement.len() as i64, + "clipboard byte size must match content" + ); + let affected = sqlx::query( + "UPDATE clipboard_items SET content = ?1, + byte_size = ?2, content_bytes = ?2, + last_edited_at = CASE WHEN content != ?1 THEN ?3 ELSE last_edited_at END, + retention_edited = CASE WHEN content != ?1 THEN 1 ELSE retention_edited END, + plain_preview = substr(?1, 1, 240), plaintext_digest = ?4 + WHERE id = ?5 AND content = ?6 AND protected_envelope IS NULL", + ) + .bind(replacement) + .bind(byte_size) + .bind(now_ms) + .bind(Self::ordinary_digest(replacement)) + .bind(id) + .bind(expected) + .execute(&self.pool) + .await? + .rows_affected(); + ensure!( + affected == 1, + "clipboard row changed or was deleted; reload before saving" + ); + Ok(()) + } + + async fn compare_exchange_protected_content( + &self, + _id: i64, + _expected: &str, + _replacement: &str, + _byte_size: i64, + _now_ms: i64, + ) -> Result<()> { + anyhow::bail!( + "protected edits require a vault generation; use compare_exchange_protected_content_bound" + ) + } + async fn compare_exchange_protected_content_bound( + &self, + id: i64, + expected: &str, + replacement: &str, + byte_size: i64, + now_ms: i64, + expected_vault_envelope: &str, + ) -> Result<()> { + super::vault::validate_sealed(replacement)?; + ensure!(byte_size >= 0, "clipboard byte size cannot be negative"); + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; + let affected = sqlx::query("UPDATE clipboard_items SET protected_envelope = ?1, + content = '', plain_preview = NULL, plaintext_digest = NULL, protected_match_token = NULL, + byte_size = ?2, content_bytes = ?2, last_edited_at = ?3, + retention_edited = 1, retention_protected = 1 + WHERE id = ?4 AND protected_envelope = ?5") + .bind(replacement).bind(byte_size).bind(now_ms).bind(id).bind(expected) + .execute(&mut *tx).await?.rows_affected(); + ensure!( + affected == 1, + "protected row changed or was deleted; reload before saving" + ); + tx.commit().await?; + Ok(()) + } + async fn mark_used(&self, id: i64, now_ms: i64) -> Result<()> { + sqlx::query("UPDATE clipboard_items SET last_used_at = ?1 WHERE id = ?2") + .bind(now_ms) + .bind(id) + .execute(&self.pool) + .await + .context("mark used")?; + Ok(()) + } + + async fn delete_row(&self, id: i64) -> Result<()> { + sqlx::query("DELETE FROM clipboard_items WHERE id = ?1") + .bind(id) + .execute(&self.pool) + .await + .context("delete row")?; + Ok(()) + } + + async fn read_vault_envelope(&self) -> Result> { + let row: Option<(String,)> = + sqlx::query_as("SELECT envelope FROM clipboard_vault WHERE id = 1") + .fetch_optional(&self.pool) + .await + .context("read vault envelope")?; + let Some((text,)) = row else { + return Ok(None); + }; + if text.is_empty() { + Ok(None) + } else { + Ok(Some(text)) + } + } + + async fn write_vault_envelope(&self, envelope: &str, active_until_unix_ms: i64) -> Result<()> { + super::vault::VaultEnvelope::parse(envelope)?; + let affected = sqlx::query( + "UPDATE clipboard_vault SET envelope = ?1, active_until_unix_ms = ?2 + WHERE id = 1 AND envelope = ''", + ) + .bind(envelope) + .bind(active_until_unix_ms) + .execute(&self.pool) + .await? + .rows_affected(); + ensure!( + affected == 1, + "vault already configured; authenticate and rewrap instead" + ); + Ok(()) + } + + async fn clear_vault_envelope(&self) -> Result<()> { + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let protected: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM clipboard_items WHERE protected_envelope IS NOT NULL)", + ) + .fetch_one(&mut *tx) + .await?; + ensure!( + !protected, + "cannot disable a vault while protected history exists" + ); + sqlx::query( + "UPDATE clipboard_vault SET envelope = '', active_until_unix_ms = 0 WHERE id = 1", + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + async fn compare_exchange_vault_envelope( + &self, + expected: &str, + replacement: &str, + active_until_unix_ms: i64, + ) -> Result<()> { + ensure!(!expected.is_empty(), "rewrap requires an existing vault"); + super::vault::VaultEnvelope::parse(replacement)?; + let affected = sqlx::query( + "UPDATE clipboard_vault SET envelope = ?1, active_until_unix_ms = ?2 + WHERE id = 1 AND envelope = ?3", + ) + .bind(replacement) + .bind(active_until_unix_ms) + .bind(expected) + .execute(&self.pool) + .await? + .rows_affected(); + ensure!(affected == 1, "vault changed concurrently; unlock again"); + Ok(()) + } +} diff --git a/src/clipboard/tests.rs b/src/clipboard/tests.rs new file mode 100644 index 0000000..f24f231 --- /dev/null +++ b/src/clipboard/tests.rs @@ -0,0 +1,47 @@ +//! Cross-module integration checks that exercise the shared contract end-to-end. + +use super::items::{ClipboardItem, ClipboardItemKind, RetentionState}; +use super::{ordering, retention}; + +fn make_row(id: i64, bytes: i64, retained: bool) -> ClipboardItem { + let now = chrono::Utc::now().timestamp_millis(); + ClipboardItem { + id, + kind: ClipboardItemKind::Plain, + content: "x".repeat(bytes as usize), + plain_preview: None, + public_label: None, + byte_size: bytes, + created_at: now - id * 1_000, + last_copied_at: now - id * 1_000, + last_used_at: None, + last_edited_at: None, + retention: RetentionState { + explicit: false, + edited: false, + protected_: retained, + }, + } +} + +#[test] +fn gui_and_cli_ordering_rules_agree() { + let mut rows = vec![ + make_row(1, 5, false), + make_row(3, 5, false), + make_row(2, 5, false), + ]; + ordering::sort_in_place(&mut rows); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + // Smaller id → newer (created_at = now - id*1000). MRU-first == smaller id first. + assert_eq!(ids, vec![1, 2, 3]); +} + +#[test] +fn eviction_does_not_retain_protected_rows() { + // id 1 is older but protected → not evictable. id 2 is the next-oldest ordinary victim. + let rows = vec![make_row(1, 5, true), make_row(2, 5, false)]; + let victims = retention::eviction_victims(&rows, 1, 0, 5, &Default::default()); + let ids: Vec = victims.iter().map(|r| r.id).collect(); + assert_eq!(ids, vec![2]); +} diff --git a/src/clipboard/vault.rs b/src/clipboard/vault.rs new file mode 100644 index 0000000..238edbf --- /dev/null +++ b/src/clipboard/vault.rs @@ -0,0 +1,409 @@ +//! Master-password vault for protecting clipboard rows. +//! +//! The vault holds a single 32-byte data key that encrypts the plaintext of protected rows. The +//! data key is wrapped by an Argon2id-derived key from the master password; the wrapped blob is +//! the only thing persisted on disk. Plaintext keys never leave memory in any form other than a +//! zeroising wrapper. +//! +//! Why Argon2id and not PBKDF2: PBKDF2 in the existing sticker lock is fine for +//! per-sticker passwords that the user changes often, but a single long-lived master password +//! deserves a memory-hard KDF so an attacker who steals the database cannot brute-force common +//! passphrases. The sticker lock is intentionally kept as-is for backwards compatibility. + +use anyhow::{Context, Result, anyhow, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; +use rand::RngCore; +use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, Zeroizing}; + +/// Current vault envelope format. Increment when key derivation or seal format changes. +pub const VAULT_VERSION: u8 = 1; +/// Memory cost (KiB) for Argon2id. 64 MiB is a 2026-class default — enough to deter GPU brute +/// force, light enough for a background task on a low-end laptop. +pub const ARGON2_M_COST_KIB: u32 = 65_536; +/// Iterations. Tune with `t_cost` until interactive unlock is < 250 ms on older hardware. +pub const ARGON2_T_COST: u32 = 3; +/// Parallelism. Threads count for Argon2id. Two hides GPU-friendly patterns. +pub const ARGON2_P_COST: u32 = 2; +/// Salt length, raw bytes. +pub const SALT_LEN: usize = 16; +/// Nonce length, raw bytes (AES-GCM uses 96-bit IVs). +pub const NONCE_LEN: usize = 12; + +/// Prefix prepended to the wrapped data key so a future migration can detect version 1. +pub const VAULT_PREFIX: &str = "RUSTICKERS_VAULT_V1"; + +/// On-disk representation of the master password's data-key-encryption key. +#[derive(Clone, Serialize, Deserialize)] +pub struct VaultEnvelope { + pub salt: String, + pub nonce: String, + pub ciphertext: String, + pub m_cost: u32, + pub t_cost: u32, + pub p_cost: u32, +} + +impl std::fmt::Debug for VaultEnvelope { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VaultEnvelope") + .field("salt", &"[redacted]") + .field("nonce", &self.nonce) + .field("ciphertext", &"[redacted]") + .field("m_cost", &self.m_cost) + .field("t_cost", &self.t_cost) + .field("p_cost", &self.p_cost) + .finish() + } +} + +impl VaultEnvelope { + /// Helper used by [`VaultController`]; same body as [`Self::serialize`] but exposed as an + /// associated function so callers can write `VaultEnvelope::serialize_string(&envelope)`. + pub fn serialize_string(envelope: &VaultEnvelope) -> Result { + envelope.serialize() + } + + pub fn serialize(&self) -> Result { + let json = serde_json::to_string(self).context("encode vault envelope")?; + Ok(format!("{VAULT_PREFIX}\n{json}")) + } + + pub fn parse(value: &str) -> Result { + let trimmed = value.trim_start(); + let Some(payload) = trimmed.strip_prefix(VAULT_PREFIX) else { + bail!("vault envelope uses an unknown version prefix"); + }; + let envelope: VaultEnvelope = + serde_json::from_str(payload.trim()).context("decode vault envelope JSON")?; + Ok(envelope) + } +} + +/// In-memory state when the vault is unlocked: the data key wrapped in a [`Zeroizing`] buffer. +#[derive(Clone)] +pub struct UnlockedVault { + envelope: VaultEnvelope, + data_key: Zeroizing<[u8; 32]>, +} + +impl std::fmt::Debug for UnlockedVault { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("UnlockedVault") + .field("envelope", &self.envelope) + .field("data_key", &"[redacted]") + .finish() + } +} + +impl UnlockedVault { + /// Persist the envelope on disk, since settings live in `clipboard_settings.kv` for v1. + pub fn envelope(&self) -> &VaultEnvelope { + &self.envelope + } + + /// Encrypt `plaintext` for storage on a protected row. Returns base64-encoded ciphertext + /// with the AES-GCM nonce prepended so callers don't have to track the nonce separately. + pub fn seal(&self, plaintext: &[u8]) -> Result { + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let unbound = UnboundKey::new(&AES_256_GCM, self.data_key.as_ref()) + .map_err(|_| anyhow!("invalid AES-256-GCM key"))?; + let key = LessSafeKey::new(unbound); + let nonce = Nonce::assume_unique_for_key(nonce_bytes); + let mut buffer = Zeroizing::new(plaintext.to_vec()); + key.seal_in_place_append_tag(nonce, Aad::empty(), &mut *buffer) + .map_err(|_| anyhow!("AES-GCM seal failed"))?; + let encoded = STANDARD_NO_PAD.encode(buffer.as_slice()); + Ok(format!("{}:{}", base64_nonce(nonce_bytes), encoded)) + } + + /// Inverse of [`seal`]. Returns the plaintext wrapped in a [`Zeroizing`] buffer; the caller + /// is responsible for dropping the buffer (or moving it to a feature-gated scratch area). + pub fn open(&self, sealed: &str) -> Result>> { + let (nonce_b64, payload_b64) = sealed + .split_once(':') + .ok_or_else(|| anyhow!("sealed payload is missing the nonce separator"))?; + let nonce_bytes = STANDARD_NO_PAD + .decode(nonce_b64.as_bytes()) + .map_err(|_| anyhow!("nonce base64 invalid"))?; + if nonce_bytes.len() != NONCE_LEN { + bail!("nonce length is wrong: {} bytes", nonce_bytes.len()); + } + let mut payload = Zeroizing::new( + STANDARD_NO_PAD + .decode(payload_b64.as_bytes()) + .map_err(|_| anyhow!("ciphertext base64 invalid"))?, + ); + let unbound = UnboundKey::new(&AES_256_GCM, self.data_key.as_ref()) + .map_err(|_| anyhow!("invalid AES-256-GCM key"))?; + let key = LessSafeKey::new(unbound); + let mut nonce_array = [0u8; NONCE_LEN]; + nonce_array.copy_from_slice(&nonce_bytes); + let nonce = Nonce::assume_unique_for_key(nonce_array); + let plaintext = key + .open_in_place(nonce, Aad::empty(), &mut payload) + .map_err(|_| anyhow!("AES-GCM open failed (wrong master password?)"))?; + Ok(Zeroizing::new(plaintext.to_vec())) + } +} + +/// Construct a fresh envelope wrapping a randomly-generated 32-byte data key. Used during +/// `vault setup` to bootstrap the user. The data key stays in memory; only the envelope survives. +pub fn create_envelope(master_password: &str) -> Result { + let salt = random_bytes::(); + let data_key = Zeroizing::new(random_bytes::<32>()); + let envelope = derive_and_seal(master_password, &salt, data_key.as_ref())?; + Ok(UnlockedVault { envelope, data_key }) +} + +/// Re-derive the wrapping key from the master password and use it to unseal the data key. +pub fn unlock(envelope: &VaultEnvelope, master_password: &str) -> Result { + anyhow::ensure!( + envelope.m_cost == ARGON2_M_COST_KIB + && envelope.t_cost == ARGON2_T_COST + && envelope.p_cost == ARGON2_P_COST, + "unsupported vault KDF parameters" + ); + let salt = STANDARD_NO_PAD + .decode(envelope.salt.as_bytes()) + .map_err(|_| anyhow!("vault salt base64 invalid"))?; + if salt.len() != SALT_LEN { + bail!("vault salt has wrong length: {} bytes", salt.len()); + } + let mut salt_array = [0u8; SALT_LEN]; + salt_array.copy_from_slice(&salt); + + let mut wrapping_key = Zeroizing::new([0u8; 32]); + argon2id( + master_password.as_bytes(), + &salt_array, + envelope.m_cost, + envelope.t_cost, + envelope.p_cost, + wrapping_key.as_mut(), + ) + .context("Argon2id derivation failed")?; + + let nonce_bytes = STANDARD_NO_PAD + .decode(envelope.nonce.as_bytes()) + .map_err(|_| anyhow!("vault nonce base64 invalid"))?; + if nonce_bytes.len() != NONCE_LEN { + bail!("vault nonce length is wrong"); + } + let mut nonce_array = [0u8; NONCE_LEN]; + nonce_array.copy_from_slice(&nonce_bytes); + let nonce = Nonce::assume_unique_for_key(nonce_array); + + let key = UnboundKey::new(&AES_256_GCM, wrapping_key.as_ref()) + .map_err(|_| anyhow!("invalid wrapping key"))?; + let mut payload = Zeroizing::new( + STANDARD_NO_PAD + .decode(envelope.ciphertext.as_bytes()) + .map_err(|_| anyhow!("vault ciphertext base64 invalid"))?, + ); + let opened = LessSafeKey::new(key) + .open_in_place(nonce, Aad::empty(), &mut payload) + .map_err(|_| anyhow!("vault decryption failed (wrong master password?)"))?; + let data_key_bytes = Zeroizing::new(opened.to_vec()); + if data_key_bytes.len() != 32 { + bail!("vault decrypted data key has wrong length"); + } + let mut array = Zeroizing::new([0u8; 32]); + array.copy_from_slice(&data_key_bytes); + Ok(UnlockedVault { + envelope: envelope.clone(), + data_key: array, + }) +} + +/// Recompute the wrapping key; returns a new envelope that uses fresh nonce + salt. Used after +/// the user changes their master password. +pub fn rewrap(previous_envelope: UnlockedVault, new_password: &str) -> Result { + let salt = random_bytes::(); + let envelope = derive_and_seal(new_password, &salt, previous_envelope.data_key.as_ref())?; + Ok(UnlockedVault { + envelope, + data_key: previous_envelope.data_key.clone(), + }) +} + +fn derive_and_seal( + password: &str, + salt: &[u8; SALT_LEN], + data_key: &[u8], +) -> Result { + let mut wrapping_key = Zeroizing::new([0u8; 32]); + argon2id( + password.as_bytes(), + salt, + ARGON2_M_COST_KIB, + ARGON2_T_COST, + ARGON2_P_COST, + wrapping_key.as_mut(), + ) + .context("Argon2id KDF failed")?; + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::assume_unique_for_key(nonce_bytes); + let mut ciphertext = Zeroizing::new(data_key.to_vec()); + let key = UnboundKey::new(&AES_256_GCM, wrapping_key.as_ref()) + .map_err(|_| anyhow!("invalid wrapping key"))?; + LessSafeKey::new(key) + .seal_in_place_append_tag(nonce, Aad::empty(), &mut *ciphertext) + .map_err(|_| anyhow!("AES-GCM seal of the data key failed"))?; + Ok(VaultEnvelope { + salt: base64_nonce(*salt), + nonce: base64_nonce(nonce_bytes), + ciphertext: STANDARD_NO_PAD.encode(ciphertext.as_slice()), + m_cost: ARGON2_M_COST_KIB, + t_cost: ARGON2_T_COST, + p_cost: ARGON2_P_COST, + }) +} + +/// Randomly generate N bytes using the OS RNG. Wrap the call so callers don't have to import +/// `rand` directly. +fn random_bytes() -> [u8; N] { + let mut out = [0u8; N]; + rand::thread_rng().fill_bytes(&mut out); + out +} + +fn base64_nonce(bytes: [u8; N]) -> String { + STANDARD_NO_PAD.encode(bytes.as_slice()) +} + +/// Argon2id wrapper around `argon2` crate. The master password stays in memory only as long as +/// the KDF is running; afterwards the only remaining copy is the temporary wrapping key. +fn argon2id( + password: &[u8], + salt: &[u8; SALT_LEN], + m_cost: u32, + t_cost: u32, + p_cost: u32, + out: &mut [u8], +) -> Result<()> { + use argon2::{Algorithm, Argon2, Params, Version}; + let params = Params::new(m_cost, t_cost, p_cost, Some(out.len())) + .map_err(|err| anyhow!("invalid Argon2 parameters: {err}"))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + argon + .hash_password_into(password, salt.as_slice(), out) + .map_err(|err| anyhow!("Argon2id hash failed: {err}"))?; + Ok(()) +} + +/// Wipe the in-memory data key on drop. +impl Drop for UnlockedVault { + fn drop(&mut self) { + self.data_key.zeroize(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_then_unlock_round_trips() { + let vault = create_envelope("correct horse battery staple").expect("create"); + let envelope = vault.envelope().clone(); + + // Dropping the original drops the data key. + drop(vault); + + let unlocked = unlock(&envelope, "correct horse battery staple").expect("unlock"); + assert_eq!(unlocked.data_key.len(), 32); + } + + #[test] + fn wrong_password_fails_to_unlock() { + let vault = create_envelope("hunter2").expect("create"); + let envelope = vault.envelope().clone(); + drop(vault); + let err = unlock(&envelope, "hunter3").unwrap_err(); + assert!(format!("{err:#}").contains("wrong master password")); + } + + #[test] + fn sealing_and_opening_round_trips() { + let vault = create_envelope("open sesame").expect("create"); + let sealed = vault.seal(b"hello clipboard").expect("seal"); + let plaintext = vault.open(&sealed).expect("open"); + assert_eq!(plaintext.as_slice(), b"hello clipboard"); + } + + #[test] + fn unknown_prefix_is_rejected() { + let result = VaultEnvelope::parse("OTHER_VERSION\n{\"salt\": \"aGVsbG8=\"}"); + assert!(result.is_err()); + } + + #[test] + fn rewrap_changes_envelope_but_preserves_data_key() { + let vault = create_envelope("old").expect("create"); + let data_key_copy = *vault.data_key; + let rewrapped = rewrap(vault, "new").expect("rewrap"); + assert_eq!(rewrapped.data_key.as_ref(), &data_key_copy); + + let opened_after_rewrap = rewrapped.seal(b"payload").expect("seal after rewrap"); + let read = rewrapped + .open(&opened_after_rewrap) + .expect("open after rewrap"); + assert_eq!(read.as_slice(), b"payload"); + + // Old password no longer accepts the new envelope. + assert!(unlock(rewrapped.envelope(), "old").is_err()); + } +} + +/// Validate structure before storing ciphertext. Authentication still requires the vault key. +pub fn validate_sealed(sealed: &str) -> Result<()> { + let (nonce, payload) = sealed + .split_once(':') + .context("sealed payload missing nonce")?; + let nonce = STANDARD_NO_PAD + .decode(nonce) + .context("invalid sealed nonce")?; + let payload = Zeroizing::new( + STANDARD_NO_PAD + .decode(payload) + .context("invalid sealed ciphertext")?, + ); + anyhow::ensure!( + nonce.len() == NONCE_LEN && payload.len() >= AES_256_GCM.tag_len(), + "invalid sealed payload lengths" + ); + Ok(()) +} + +#[cfg(test)] +mod safety_regressions { + use super::*; + #[test] + fn malformed_envelope_diagnostics_do_not_echo_clipboard_content() { + let error = VaultEnvelope::parse("private clipboard content").unwrap_err(); + assert!(!format!("{error:#}").contains("private clipboard content")); + } + #[test] + fn untrusted_kdf_cost_is_rejected_before_allocating() { + let envelope = VaultEnvelope { + salt: base64_nonce([0u8; SALT_LEN]), + nonce: String::new(), + ciphertext: String::new(), + m_cost: u32::MAX, + t_cost: u32::MAX, + p_cost: 2, + }; + assert!( + unlock(&envelope, "password") + .unwrap_err() + .to_string() + .contains("unsupported") + ); + } +} diff --git a/src/clipboard/vault_controller.rs b/src/clipboard/vault_controller.rs new file mode 100644 index 0000000..2b3396d --- /dev/null +++ b/src/clipboard/vault_controller.rs @@ -0,0 +1,420 @@ +//! Persistent master-password vault plus in-memory unlock state. +//! +//! This is the controller GUI and CLI callers reach for when deciding whether a clipboard +//! row can be exposed. It is **not** the vault itself; the wrapping/unwrapping math lives in +//! [`super::vault`]. The controller owns: +//! +//! 1. The on-disk envelope in the `clipboard_vault` table. +//! 2. The current `UnlockedVault` kept in memory while the user is at a recent paste target. +//! 3. The scoped unlock deadline used by every cryptographic operation. +//! +//! The controller never logs the password or the data key. Failed unlocks are counted so the +//! caller can back off when needed (clipboard manager out of scope; the auto-clear happens after +//! `active_until_unix_ms`). + +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; + +use anyhow::{Result, anyhow}; +use zeroize::Zeroizing; + +use super::storage::ClipboardHistoryStore; +use super::vault::{UnlockedVault, VaultEnvelope, create_envelope, rewrap, unlock}; + +/// Default idle lifetime: 5 minutes from the last unlock, in milliseconds. Pastes of protected +/// rows are restricted to this window. +pub const DEFAULT_ACTIVE_LIFETIME_MS: i64 = 5 * 60 * 1000; + +/// Manages a persistent master-password vault. +/// +/// All really sensitive material flows through [`Zeroizing`] buffers; the controller does not own +/// the data key beyond the scoped unlock lifetime. +pub struct VaultController { + store: Arc, + operations: futures::lock::Mutex<()>, + generation: AtomicI64, + /// Unix-ms deadline past which the next paste target sees the vault as locked. + active_until_unix_ms: AtomicI64, + /// Cached unlocked vault; cleared on lock. + unlocked: std::sync::Mutex>, + /// Counter for failed unlock attempts to power back-off logic. Public for the GUI modal. + failed_attempts: AtomicI64, +} + +/// Ciphertext and the exact cached vault configuration used to seal it. No plaintext or key +/// is exported. Pass both fields to generation-bound storage in the same operation. +pub struct SealedClipboardPayload { + pub ciphertext: String, + pub vault_envelope: String, +} + +impl std::fmt::Debug for SealedClipboardPayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SealedClipboardPayload") + .field("ciphertext", &"[redacted]") + .field("vault_envelope", &"[redacted]") + .finish() + } +} +/// Permanent vault configuration; either we have one or we don't. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VaultStatus { + pub has_master_password: bool, + pub failed_attempts: i64, +} + +/// Lightweight snapshot of vault state, used by the GUI modal to decide between Create and Unlock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VaultState { + /// No envelope yet; the user must create one. + Unconfigured, + /// Envelope exists but is locked. + Locked, + /// Envelope is unlocked and a data key is available. + Unlocked { failed_attempts: i64 }, +} + +impl VaultState { + pub fn needs_password(&self) -> bool { + matches!(self, VaultState::Locked) + } +} + +impl VaultController { + pub fn new(store: Arc) -> Self { + Self { + store, + operations: futures::lock::Mutex::new(()), + generation: AtomicI64::new(0), + active_until_unix_ms: AtomicI64::new(0), + unlocked: std::sync::Mutex::new(None), + failed_attempts: AtomicI64::new(0), + } + } + + /// Read the on-disk envelope. Returns `Ok(None)` if no master password has been configured + /// yet. + pub async fn envelope(&self) -> Result> { + match self.store.read_vault_envelope().await? { + Some(text) => Ok(Some(VaultEnvelope::parse(&text)?)), + None => Ok(None), + } + } + + /// First-time bootstrap. Storage refuses replacement of an existing vault. Never orphan + /// existing protected items by generating a replacement data key. + pub async fn create(&self, password: &str) -> Result<()> { + let _operation = self.operations.lock().await; + let unlocked = create_envelope(password)?; + let envelope = unlocked.envelope().clone(); + let serialized = VaultEnvelope::serialize_string(&envelope)?; + self.store.write_vault_envelope(&serialized, 0).await?; + self.lock(); + Ok(()) + } + + /// Attempt to unlock with the user-supplied password. Returns `Ok(true)` on success and + /// `Ok(false)` on a wrong password (so the GUI modal can decide between back-off and a + /// "wrong password" message). + pub async fn unlock(&self, password: &str) -> Result { + let _operation = self.operations.lock().await; + let generation = self.generation.load(Ordering::SeqCst); + let Some(text) = self.store.read_vault_envelope().await? else { + return Err(anyhow!( + "No master password is configured yet; call create() first" + )); + }; + let envelope = VaultEnvelope::parse(&text)?; + match unlock(&envelope, password) { + Ok(unlocked) => { + let deadline = crate::utils::time::now_unix_millis() + DEFAULT_ACTIVE_LIFETIME_MS; + let mut guard = self + .unlocked + .lock() + .map_err(|_| anyhow!("vault mutex poisoned"))?; + anyhow::ensure!( + generation == self.generation.load(Ordering::SeqCst), + "vault locked during unlock; try again" + ); + self.active_until_unix_ms.store(deadline, Ordering::SeqCst); + *guard = Some(unlocked); + self.failed_attempts.store(0, Ordering::SeqCst); + Ok(true) + } + Err(_) => { + let _ = self.failed_attempts.fetch_add(1, Ordering::SeqCst); + Ok(false) + } + } + } + + /// Re-wrap the data key using a new password. Existing protected-row ciphertexts stay + /// decryptable because they are sealed with the data key, not the password directly. + pub async fn rewrap(&self, password: &str) -> Result<()> { + let _operation = self.operations.lock().await; + let current = { + let guard = self + .unlocked + .lock() + .map_err(|_| anyhow!("vault mutex poisoned"))?; + guard + .as_ref() + .ok_or_else(|| anyhow!("rewrap requires the vault to be unlocked"))? + .clone() + }; + anyhow::ensure!(self.is_unlocked(), "rewrap requires an unexpired unlock"); + let expected = current.envelope().serialize()?; + let rewritten = rewrap(current, password)?; + let serialized = VaultEnvelope::serialize_string(rewritten.envelope())?; + self.store + .compare_exchange_vault_envelope( + &expected, + &serialized, + self.active_until_unix_ms.load(Ordering::SeqCst), + ) + .await?; + self.lock(); + Ok(()) + } + + /// Lock the vault immediately; subsequent calls that ask whether it is `is_unlocked` will + /// return false. + pub fn lock(&self) { + self.generation.fetch_add(1, Ordering::SeqCst); + if let Ok(mut guard) = self.unlocked.lock() { + *guard = None; + } + self.active_until_unix_ms.store(0, Ordering::SeqCst); + } + + /// True only when the dedicated deadline has not elapsed and the in-memory key is still + /// cached. The caller should normally rely on [`paste_window`] instead. + pub fn is_unlocked(&self) -> bool { + let Ok(mut guard) = self.unlocked.lock() else { + return false; + }; + if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) + { + *guard = None; + return false; + } + guard.is_some() + } + /// Comment-friendly alias for the GUI/CLI who want to spell out the relationship. + pub fn paste_window(&self) -> bool { + self.is_unlocked() + } + + /// Number of consecutive failed unlock attempts since the last successful unlock. The GUI + /// uses this to suggest a back-off after a certain number of failed attempts. + pub fn failed_attempts(&self) -> i64 { + self.failed_attempts.load(Ordering::SeqCst) + } + + /// Single-flight snapshot that drives the GUI modal's "create vs. unlock" decision and lets + /// the editor refuse lock toggles when the vault is locked. + pub async fn snapshot(&self) -> VaultState { + match self.store.read_vault_envelope().await { + Ok(Some(text)) if !text.is_empty() => { + if self.is_unlocked() { + VaultState::Unlocked { + failed_attempts: self.failed_attempts(), + } + } else { + VaultState::Locked + } + } + Ok(None) => VaultState::Unconfigured, + _ => VaultState::Locked, + } + } + + /// Remove the master password only after all encrypted history has been removed. + /// Storage checks this in a write transaction, including concurrent protected inserts. + /// The vault key is preserved if disabling would strand protected data. + pub async fn disable(&self) -> Result<()> { + let _operation = self.operations.lock().await; + self.store.clear_vault_envelope().await?; + self.lock(); + Ok(()) + } + + pub fn seal(&self, plaintext: &[u8]) -> Result> { + let mut guard = self + .unlocked + .lock() + .map_err(|_| anyhow!("vault mutex poisoned"))?; + if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) + { + *guard = None; + return Ok(None); + } + let Some(unlocked) = guard.as_ref() else { + return Ok(None); + }; + Ok(Some(unlocked.seal(plaintext)?)) + } + + /// Capture ciphertext and its vault generation under the same key lock. Never pair `seal` + /// with a later `envelope()` read: another controller may reset the vault between those calls. + pub fn seal_for_storage(&self, plaintext: &[u8]) -> Result> { + let mut guard = self + .unlocked + .lock() + .map_err(|_| anyhow!("vault mutex poisoned"))?; + if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) + { + *guard = None; + return Ok(None); + } + let Some(unlocked) = guard.as_ref() else { + return Ok(None); + }; + Ok(Some(SealedClipboardPayload { + ciphertext: unlocked.seal(plaintext)?, + vault_envelope: unlocked.envelope().serialize()?, + })) + } + /// Inverse of [`seal`]. Returns `Ok(None)` when the vault is currently locked so callers can + /// distinguish *sealed-but-unavailable* from *sealed-and-decryptable*. + pub fn open(&self, envelope: &str) -> Result>>> { + let mut guard = self + .unlocked + .lock() + .map_err(|_| anyhow!("vault mutex poisoned"))?; + if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) + { + *guard = None; + return Ok(None); + } + let Some(unlocked) = guard.as_ref() else { + return Ok(None); + }; + Ok(Some(unlocked.open(envelope)?)) + } +} + +/// Bundle the vault controller with the chooser's needs. The chooser only ever checks +/// `is_unlocked`; we expose it through this lightweight struct so we don't have to plumb the +/// storage trait around. +pub struct VaultHandle { + pub controller: Arc, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clipboard::storage::SqliteClipboardStore; + + static PROCESS_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + async fn setup() -> ( + Arc, + std::path::PathBuf, + Arc, + ) { + let path = std::env::temp_dir().join(format!( + "rustickers-vault-controller-{}-{}.db", + crate::utils::time::now_unix_millis(), + PROCESS_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let store: Arc = + Arc::new(SqliteClipboardStore::open(&path).await.unwrap()); + let controller = Arc::new(VaultController::new(store.clone())); + (store, path, controller) + } + #[test] + fn create_unlock_lock_round_trip() { + use futures::executor::block_on; + let (_store, path, controller) = block_on(setup()); + + block_on(controller.create("hunter2-correct-horse")).expect("create vault"); + assert!(block_on(controller.envelope()).unwrap().is_some()); + assert!( + !controller.is_unlocked(), + "vault should not be unlocked right after create" + ); + + let good = block_on(controller.unlock("hunter2-correct-horse")).expect("unlock ok"); + assert!(good, "correct password should unlock"); + assert!(controller.is_unlocked()); + + controller.lock(); + assert!(!controller.is_unlocked()); + + let bad = block_on(controller.unlock("nope")).expect("unlock ok"); + assert!(!bad, "wrong password must not unlock"); + assert_eq!(controller.failed_attempts(), 1); + + block_on(controller.disable()).expect("disable vault"); + assert!(block_on(controller.envelope()).unwrap().is_none()); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn seal_and_open_consume_vault_state() { + use futures::executor::block_on; + let (_store, path, controller) = block_on(setup()); + block_on(controller.create("hunter2")).expect("create"); + assert!(matches!(controller.seal(b"hello"), Ok(None))); + assert!(block_on(controller.unlock("hunter2")).unwrap()); + let envelope = controller.seal(b"hello").unwrap().expect("seal ok"); + let plain = controller.open(&envelope).unwrap().expect("open ok"); + assert_eq!(&*plain, b"hello"); + controller.lock(); + assert!(controller.open(&envelope).unwrap().is_none()); + block_on(controller.disable()).unwrap(); + let _ = std::fs::remove_file(path); + } + + #[test] + fn snapshot_drives_create_vs_unlock() { + use futures::executor::block_on; + let (_store, path, controller) = block_on(setup()); + // Fresh store: Unconfigured. + match block_on(controller.snapshot()) { + crate::clipboard::vault_controller::VaultState::Unconfigured => {} + other => panic!("expected unconfigured, got {other:?}"), + } + block_on(controller.create("hunter2")).unwrap(); + match block_on(controller.snapshot()) { + crate::clipboard::vault_controller::VaultState::Locked => {} + other => panic!("expected locked after create, got {other:?}"), + } + assert!(block_on(controller.unlock("hunter2")).unwrap()); + match block_on(controller.snapshot()) { + crate::clipboard::vault_controller::VaultState::Unlocked { .. } => {} + other => panic!("expected unlocked, got {other:?}"), + } + block_on(controller.disable()).unwrap(); + match block_on(controller.snapshot()) { + crate::clipboard::vault_controller::VaultState::Unconfigured => {} + other => panic!("expected unconfigured after disable, got {other:?}"), + } + let _ = std::fs::remove_file(path); + } +} + +#[cfg(test)] +mod safety_regressions { + use super::*; + use crate::clipboard::storage::SqliteClipboardStore; + use futures::executor::block_on; + + #[test] + fn expired_unlock_cannot_open_seal_or_rewrap() { + let tmp = tempfile::tempdir().unwrap(); + let store = + Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); + let controller = VaultController::new(store); + block_on(controller.create("password")).unwrap(); + assert!(block_on(controller.unlock("password")).unwrap()); + let sealed = controller.seal(b"secret").unwrap().unwrap(); + controller.active_until_unix_ms.store(1, Ordering::SeqCst); + assert!(!controller.is_unlocked()); + assert!(controller.open(&sealed).unwrap().is_none()); + assert!(controller.seal(b"secret").unwrap().is_none()); + assert!(block_on(controller.rewrap("new")).is_err()); + } +} diff --git a/src/ipc.rs b/src/ipc.rs index bdcb63e..3ab10de 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -37,6 +37,11 @@ pub enum IpcEvent { PreviewFile(PreviewFileRequest), CloseSticker(i64), TriggerSelectionToCommand, + /// Toggle the clipboard history chooser (M3). The argument is the focused window id at the + /// time the hotkey triggered, so the row's paste-step can restore focus to the previous app. + ToggleClipboardChooser { + focused_window: Option, + }, } pub struct SingleInstance { @@ -145,6 +150,14 @@ impl SingleInstance { ) { tracing::debug!("Received selection-to-command IPC"); let _ = ipc_events_tx.send(IpcEvent::TriggerSelectionToCommand); + } else if let Some(payload) = + buffer.trim().strip_prefix("CLIPBOARD_CHOOSER") + { + let focused = payload + .strip_prefix(' ') + .and_then(|rest| rest.parse::().ok()); + let _ = ipc_events_tx + .send(IpcEvent::ToggleClipboardChooser { focused_window: focused }); } } } @@ -181,6 +194,11 @@ pub fn send_ipc_command(app_id: &str, command: &str) -> io::Result { } } +/// Convenience helper for the CLI: ask the running instance to toggle the clipboard chooser. +pub fn signal_clipboard_chooser(app_id: &str) -> io::Result { + send_ipc_command(app_id, "CLIPBOARD_CHOOSER") +} + /// Filter function from the official reference fn handle_incoming_error(conn: io::Result) -> Option { match conn { diff --git a/src/lib.rs b/src/lib.rs index 6a1e1b7..691f105 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #[cfg(feature = "cli")] pub mod cli; +pub mod clipboard; pub mod ipc; pub mod model; #[cfg(feature = "ui")] diff --git a/src/native/clipboard/mod.rs b/src/native/clipboard/mod.rs new file mode 100644 index 0000000..d209abc --- /dev/null +++ b/src/native/clipboard/mod.rs @@ -0,0 +1,7 @@ +//! Native clipboard capture, internal-write suppression, and verified paste adapters. + +pub mod paste; +pub mod watcher; + +pub use paste::{foreground, paste_foreground}; +pub use watcher::{CaptureFilter, CaptureSource, CapturedText, write_clipboard_text}; diff --git a/src/native/clipboard/paste.rs b/src/native/clipboard/paste.rs new file mode 100644 index 0000000..908d22a --- /dev/null +++ b/src/native/clipboard/paste.rs @@ -0,0 +1,159 @@ +//! Fail-closed destination restoration and paste dispatch. +use anyhow::{Context, bail}; +use rdev::{EventType, Key, simulate}; +#[cfg(target_os = "windows")] +use std::{ + thread, + time::{Duration, Instant}, +}; + +#[cfg(target_os = "windows")] +mod platform { + use anyhow::{Result, bail}; + use windows_sys::Win32::UI::{ + Input::KeyboardAndMouse::{ + GetAsyncKeyState, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN, VK_SHIFT, + }, + WindowsAndMessaging::{ + GetForegroundWindow, GetWindowThreadProcessId, IsIconic, IsWindow, SW_RESTORE, + SetForegroundWindow, ShowWindow, + }, + }; + pub fn verify(handle: u64) -> Result<()> { + let hwnd = handle as *mut core::ffi::c_void; + unsafe { + let mut pid = 0; + if IsWindow(hwnd) == 0 || GetWindowThreadProcessId(hwnd, &mut pid) == 0 { + bail!("The original window has closed. Use Copy only."); + } + if pid == std::process::id() || GetForegroundWindow() != hwnd { + bail!("The original destination is not focused. Use Copy only."); + } + } + Ok(()) + } + pub fn foreground(handle: u64) -> Result<()> { + let hwnd = handle as *mut core::ffi::c_void; + unsafe { + let mut pid = 0; + if IsWindow(hwnd) == 0 + || GetWindowThreadProcessId(hwnd, &mut pid) == 0 + || pid == std::process::id() + { + bail!("The original destination is unavailable. Use Copy only."); + } + if IsIconic(hwnd) != 0 { + ShowWindow(hwnd, SW_RESTORE); + } + SetForegroundWindow(hwnd); + } + verify(handle) + } + pub fn modifiers_released() -> bool { + [VK_CONTROL, VK_MENU, VK_SHIFT, VK_LWIN, VK_RWIN] + .into_iter() + .all(|key| unsafe { GetAsyncKeyState(key as i32) >= 0 }) + } +} + +pub fn foreground(handle: Option) -> anyhow::Result<()> { + let handle = handle + .filter(|h| *h != 0) + .context("No original destination is available. Use Copy only.")?; + #[cfg(target_os = "windows")] + { + platform::foreground(handle) + } + #[cfg(not(target_os = "windows"))] + { + let _ = handle; + bail!("Verified paste is unavailable on this platform. Use Copy only.") + } +} + +/// Never synthesize releases for modifiers held by the user. +pub fn wait_for_modifiers() -> anyhow::Result<()> { + #[cfg(target_os = "windows")] + { + let deadline = Instant::now() + Duration::from_secs(2); + while !platform::modifiers_released() { + if Instant::now() >= deadline { + bail!("Release shortcut modifiers and retry, or use Copy only."); + } + thread::sleep(Duration::from_millis(10)); + } + Ok(()) + } + #[cfg(not(target_os = "windows"))] + { + bail!("Verified paste is unavailable on this platform. Use Copy only.") + } +} + +/// Always release our synthetic keys, including after a failed key-down. +fn dispatch_paste(mut send: impl FnMut(EventType) -> anyhow::Result<()>) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + let primary = Key::MetaLeft; + #[cfg(not(target_os = "macos"))] + let primary = Key::ControlLeft; + let press = + send(EventType::KeyPress(primary)).and_then(|_| send(EventType::KeyPress(Key::KeyV))); + let release_v = send(EventType::KeyRelease(Key::KeyV)); + let release_primary = send(EventType::KeyRelease(primary)); + press.and(release_v).and(release_primary) +} + +pub fn paste_foreground(handle: Option) -> anyhow::Result<()> { + let handle = handle.context("No destination. Use Copy only.")?; + #[cfg(target_os = "windows")] + { + platform::verify(handle)?; + } + #[cfg(not(target_os = "windows"))] + { + let _ = handle; + bail!("Verified paste is unavailable on this platform. Use Copy only."); + } + #[allow(unreachable_code)] + dispatch_paste(|event| { + simulate(&event).map_err(|_| anyhow::anyhow!("Paste input dispatch failed; use Copy only.")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_destination_is_an_error() { + assert!(foreground(None).is_err()); + } + #[test] + fn failed_press_still_releases_both_keys() { + let mut events = Vec::new(); + let result = dispatch_paste(|event| { + events.push(event); + if events.len() == 2 { + bail!("injected input failure"); + } + Ok(()) + }); + assert!(result.is_err()); + assert!(matches!(events[2], EventType::KeyRelease(Key::KeyV))); + assert!(matches!(events[3], EventType::KeyRelease(_))); + } + #[test] + fn dispatch_reports_release_failure() { + let mut n = 0; + assert!( + dispatch_paste(|_| { + n += 1; + if n == 3 { + bail!("release failed"); + } + Ok(()) + }) + .is_err() + ); + assert_eq!(n, 4); + } +} diff --git a/src/native/clipboard/watcher.rs b/src/native/clipboard/watcher.rs new file mode 100644 index 0000000..ae68228 --- /dev/null +++ b/src/native/clipboard/watcher.rs @@ -0,0 +1,480 @@ +//! Cross-platform clipboard watcher. +//! +//! A single observer thread polls the system clipboard at a low frequency. Each new text the +//! clipboard has not seen before is forwarded through a shared [`CaptureFilter`] which is +//! responsible for: +//! +//! 1. Suppressing writes that the application itself produced (sentinel markers, restored +//! selections, password manager reveals). +//! 2. Dropping large oversized captures. +//! 3. Forwarding the accepted payload to the persistence layer. +//! +//! The [`CaptureFilter`] is a single shared object that lives inside the native event loop so +//! GUI-mediated writes can mark themselves as internal before they reach the clipboard. This is +//! how the existing selection-capture helper ([`crate::native::selection`]) avoids polluting the +//! history with the temporary marker it sets on the clipboard. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::clipboard::items::{ClipboardItemKind, is_collectable}; +use crate::clipboard::settings::ClipboardSettings; +use crate::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore, UpsertOutcome}; + +/// Per-capture flag — text the system clipboard just received. +#[derive(Debug, Clone)] +pub struct CapturedText { + pub content: String, + pub byte_size: i64, + /// Source annotation so future macros (Cmd+C, OS side panel, password manager, …) can be + /// recorded without affecting storage shape. + pub source: CaptureSource, +} + +/// Where the captured text came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CaptureSource { + /// A normal "user copied something" event. + UserCopy, + /// A write performed by the application itself, suppressed trivially. + Internal, + /// A write acknowledged by another trustworthy source (e.g. cmd-line `--import`). + TrustedImport, +} + +/// Surface that callers can use to control which writes the history collector accepts. +/// +/// The canonical instance lives in the GUI bootstrap. The CLI never instantiates one, but it +/// does open the same store when it needs to record a `--import` so the dedupe rules apply +/// consistently. +#[derive(Default)] +pub struct CaptureFilter { + inner: Mutex, +} + +#[derive(Default)] +struct CaptureFilterState { + internal_writes_this_tick: u64, + internal_generation: Option, +} + +impl CaptureFilter { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Mark that the caller is about to write to the clipboard. The watcher skips the next poll + /// that would otherwise see *that* write as a fresh copy. + pub fn note_internal_write(&self) { + let mut state = self.inner.lock().expect("CaptureFilter mutex poisoned"); + state.internal_writes_this_tick = state.internal_writes_this_tick.saturating_add(1); + } + + pub fn transaction(&self) -> std::sync::MutexGuard<'_, ()> { + static TRANSACTION: Mutex<()> = Mutex::new(()); + TRANSACTION.lock().unwrap_or_else(|e| e.into_inner()) + } + pub fn record_internal_generation(&self) { + self.inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .internal_generation = clipboard_generation(); + } + fn is_internal_generation(&self, generation: Option) -> bool { + generation.is_some() + && self + .inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .internal_generation + == generation + } + + /// Returns the number of internal writes observed since the last call. Watcher drains the + /// counter alongside this check. + pub fn consume_pending_internal_writes(&self) -> u64 { + let mut state = self.inner.lock().expect("CaptureFilter mutex poisoned"); + let pending = state.internal_writes_this_tick; + state.internal_writes_this_tick = 0; + pending + } +} + +/// Detector: don't double-write if the clipboard hasn't changed since the last poll. +#[derive(Debug, Clone, Default)] +pub struct LastSeenText(pub Option); + +impl LastSeenText { + pub fn new() -> Self { + Self(None) + } + + /// Returns true if `text` is something we should record (i.e. distinct from prior). Always + /// updates the buffered value. + pub fn accept(&mut self, text: &str) -> bool { + let changed = self.0.as_deref() != Some(text); + self.0 = Some(text.to_owned()); + changed + } +} + +/// Persist [`CapturedText`] into the provided store. Returns the row id and outcome so the caller +/// can update the GUI's chooser / settings in real time. +pub async fn persist_capture( + store: &dyn ClipboardHistoryStore, + captured: &CapturedText, + settings: &ClipboardSettings, +) -> anyhow::Result> { + if !settings.capture_enabled { + return Ok(None); + } + if captured.source != CaptureSource::UserCopy { + return Ok(None); + } + if !is_collectable(&captured.content) { + return Ok(None); + } + let byte_size = captured.byte_size; + let ordinary_byte_limit = settings.normal_byte_limit; + if ordinary_byte_limit > 0 && byte_size > ordinary_byte_limit { + // Single oversize capture: drop entirely rather than half-collecting something the user + // didn't intend and that eviction cannot hope to fit. + return Ok(None); + } + + let preview = plain_preview(&captured.content); + let digest = SqliteClipboardStore::ordinary_digest(&captured.content); + let now = crate::utils::time::now_unix_millis(); + let outcome = store + .upsert_ordinary(&captured.content, &preview, &digest, byte_size, now) + .await?; + let id = match outcome { + UpsertOutcome::Inserted { id } | UpsertOutcome::Updated { id } => id, + }; + // The shared store trims atomically in upsert; never count incoming bytes twice. + Ok(Some(id)) +} + +fn plain_preview(content: &str) -> String { + let mut preview = String::with_capacity(240); + for character in content.chars() { + if character == '\n' || character == '\r' { + preview.push(' '); + } else { + preview.push(character); + } + if preview.len() >= 240 { + break; + } + } + if preview.len() < content.len() { + preview.push('…'); + } + preview +} + +/// Drives the watcher loop. Designed for parking on a background thread; the GUI owns the +/// `CaptureFilter` and the shutdown signal. +pub struct WatcherHandle { + stop: Arc, + poll_interval: Duration, +} + +impl WatcherHandle { + pub fn new(poll_interval: Duration) -> (Self, Arc) { + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + ( + Self { + stop: stop.clone(), + poll_interval, + }, + stop, + ) + } + + /// Spawn the polling thread. It writes accepted captures into the provided store and emits + /// row ids on `events` for the chooser to refresh. Returns the join handle so callers can + /// stop the watcher before the process exits. + pub fn spawn( + self, + filter: Arc, + store: Arc, + events: std::sync::mpsc::Sender, + ) -> std::thread::JoinHandle<()> { + std::thread::Builder::new().name("clipboard-watcher".into()).spawn(move || { + let mut last_seen = LastSeenText::new(); + let mut last_generation = None; + let mut active = false; + while !self.stop.load(std::sync::atomic::Ordering::Relaxed) { + let tick = (|| -> anyhow::Result<()> { + let settings = futures::executor::block_on(store.settings())?; + let has_protected = futures::executor::block_on(store.snapshot())?.iter() + .any(|r| r.kind == ClipboardItemKind::Protected || r.retention.protected()); + if !settings.capture_enabled || has_protected { + active = false; + set_capture_status(if has_protected { "Collection paused: protected history requires a safe recapture match check." } + else { "Collection paused. Enable to capture copied text (including secrets)." }); + return Ok(()); + } + if !active { last_seen = LastSeenText::new(); last_generation = None; active = true; } + let transaction = filter.transaction(); + let before = clipboard_generation(); + let text = read_clipboard_text()?; + let generation = clipboard_generation(); + if before != generation { return Ok(()); } // racing external writer; retry next tick + let internal = filter.is_internal_generation(generation); + let fallback_internal = filter.consume_pending_internal_writes() > 0; + if internal || (generation.is_none() && fallback_internal) { + if let Some(text) = text { last_seen.accept(&text); } + last_generation = generation; return Ok(()); + } + let changed_generation = generation.is_some() && generation != last_generation; + last_generation = generation; + drop(transaction); + if let Some(text) = text { + let changed_text = last_seen.accept(&text); + if changed_generation || changed_text { + if text.len() > 1024 * 1024 { + set_capture_status("Capture skipped: text exceeds 1 MiB; nothing was truncated."); return Ok(()); + } + let captured = CapturedText { byte_size: text.len() as i64, content: text, source: CaptureSource::UserCopy }; + futures::executor::block_on(persist_capture(&*store, &captured, &settings))?; + let _ = events.send(captured); + } + } else { last_seen = LastSeenText::new(); } + set_capture_status("Collecting copied text. Secrets can enter ordinary history."); + Ok(()) + })(); + if tick.is_err() { last_seen = LastSeenText::new(); last_generation = None; set_capture_status("Collection failed: clipboard busy, permission denied, or storage unavailable. Retrying."); } + std::thread::sleep(self.poll_interval); + } + }).expect("spawn clipboard watcher") + } +} + +/// Synchronous, best-effort read of the host clipboard text. Returns `Ok(None)` on platforms and +/// configurations where the selection is empty, non-text, or unavailable. +pub fn read_clipboard_text() -> anyhow::Result> { + let mut clipboard = arboard::Clipboard::new()?; + match clipboard.get_text() { + Ok(text) => Ok(Some(text)), + Err(arboard::Error::ContentNotAvailable) => Ok(None), + Err(err) => Err(anyhow::anyhow!("clipboard read failed: {err}")), + } +} + +/// Synchronous write helper used both by the selection helper and the imperceptible "GUI typed +/// `Ctrl+V` onto an item" path. Always pairs the write with a +/// [`CaptureFilter::note_internal_write`] so the watcher won't re-ingest it. +pub fn write_clipboard_text(filter: &CaptureFilter, text: &str) -> anyhow::Result<()> { + write_clipboard_text_owned(filter, text).map(|_| ()) +} + +/// Return the generation sampled while holding the native clipboard open, not after release. +pub fn write_clipboard_text_owned( + filter: &CaptureFilter, + text: &str, +) -> anyhow::Result> { + let _transaction = filter.transaction(); + anyhow::ensure!( + !text.contains('\0'), + "Clipboard text contains NUL; cannot paste it without truncation" + ); + #[cfg(target_os = "windows")] + { + #[link(name = "user32")] + unsafe extern "system" { + fn OpenClipboard(hwnd: *mut core::ffi::c_void) -> i32; + fn CloseClipboard() -> i32; + fn EmptyClipboard() -> i32; + fn SetClipboardData( + format: u32, + memory: *mut core::ffi::c_void, + ) -> *mut core::ffi::c_void; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GlobalAlloc(flags: u32, bytes: usize) -> *mut core::ffi::c_void; + fn GlobalLock(memory: *mut core::ffi::c_void) -> *mut core::ffi::c_void; + fn GlobalUnlock(memory: *mut core::ffi::c_void) -> i32; + fn GlobalFree(memory: *mut core::ffi::c_void) -> *mut core::ffi::c_void; + } + // Allocate before clearing so allocation failure leaves the previous clipboard intact. + let utf16 = zeroize::Zeroizing::new( + text.encode_utf16() + .chain(std::iter::once(0)) + .collect::>(), + ); + let memory = unsafe { GlobalAlloc(2, utf16.len() * 2) }; // GMEM_MOVEABLE + anyhow::ensure!(!memory.is_null(), "Clipboard allocation failed"); + struct Memory(*mut core::ffi::c_void); + impl Drop for Memory { + fn drop(&mut self) { + unsafe { + GlobalFree(self.0); + } + } + } + let allocation = Memory(memory); + let pointer = unsafe { GlobalLock(memory) }; + anyhow::ensure!(!pointer.is_null(), "Cannot lock clipboard allocation"); + unsafe { + std::ptr::copy_nonoverlapping(utf16.as_ptr(), pointer.cast::(), utf16.len()); + GlobalUnlock(memory); + } + // Use a live window owner; never hold the clipboard open while waiting for keyboard input. + let owner = unsafe { windows_sys::Win32::UI::WindowsAndMessaging::GetForegroundWindow() }; + let mut opened = false; + for _ in 0..5 { + if unsafe { OpenClipboard(owner) } != 0 { + opened = true; + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + anyhow::ensure!(opened, "Clipboard busy; retry Copy only"); + struct Close; + impl Drop for Close { + fn drop(&mut self) { + unsafe { + CloseClipboard(); + } + } + } + let _close = Close; + anyhow::ensure!(unsafe { EmptyClipboard() } != 0, "Cannot prepare clipboard"); + anyhow::ensure!( + !unsafe { SetClipboardData(13, memory) }.is_null(), + "Cannot write Unicode clipboard text" + ); + std::mem::forget(allocation); // OS now owns the HGLOBAL. + filter.note_internal_write(); + filter.record_internal_generation(); + Ok(clipboard_generation()) + } + #[cfg(not(target_os = "windows"))] + { + filter.note_internal_write(); + let mut clipboard = arboard::Clipboard::new()?; + clipboard.set_text(text.to_owned())?; + Ok(None) + } +} + +/// Restore the last captured clipboard value, bypassing the watcher via the suppression hook. +pub fn restore_clipboard_text( + filter: &CaptureFilter, + previous: Option<&str>, +) -> anyhow::Result<()> { + let _transaction = filter.transaction(); + filter.note_internal_write(); + let mut clipboard = arboard::Clipboard::new()?; + match previous { + Some(text) => clipboard.set_text(text.to_owned())?, + None => clipboard.clear()?, + }; + filter.record_internal_generation(); + Ok(()) +} + +static CAPTURE_STATUS: std::sync::OnceLock> = std::sync::OnceLock::new(); +pub fn capture_status() -> String { + CAPTURE_STATUS + .get_or_init(|| Mutex::new("Collector starting…".into())) + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() +} +fn set_capture_status(status: &str) { + *CAPTURE_STATUS + .get_or_init(|| Mutex::new(String::new())) + .lock() + .unwrap_or_else(|e| e.into_inner()) = status.into(); +} +#[cfg(target_os = "windows")] +pub fn clipboard_generation() -> Option { + // Signature verified against the locally cached windows-sys DataExchange bindings. + #[link(name = "user32")] + unsafe extern "system" { + fn GetClipboardSequenceNumber() -> u32; + } + Some(unsafe { GetClipboardSequenceNumber() }) +} +#[cfg(not(target_os = "windows"))] +pub fn clipboard_generation() -> Option { + None +} + +/// Clear protected plaintext only if no intervening owner/generation has changed it. +pub fn clear_protected_if_owned( + filter: &CaptureFilter, + generation: Option, +) -> anyhow::Result<()> { + let _transaction = filter.transaction(); + #[cfg(target_os = "windows")] + { + #[link(name = "user32")] + unsafe extern "system" { + fn OpenClipboard(hwnd: *mut core::ffi::c_void) -> i32; + fn CloseClipboard() -> i32; + fn EmptyClipboard() -> i32; + } + anyhow::ensure!(generation.is_some(), "Clipboard ownership unavailable"); + // OpenClipboard excludes concurrent external writers during check + clear. + anyhow::ensure!( + unsafe { OpenClipboard(std::ptr::null_mut()) } != 0, + "Clipboard busy" + ); + struct Close; + impl Drop for Close { + fn drop(&mut self) { + unsafe { + CloseClipboard(); + } + } + } + let _close = Close; + if clipboard_generation() == generation { + anyhow::ensure!(unsafe { EmptyClipboard() } != 0, "Cannot clear clipboard"); + filter.note_internal_write(); + filter.record_internal_generation(); + } + Ok(()) + } + #[cfg(not(target_os = "windows"))] + { + let _ = generation; + anyhow::bail!("Ownership-aware clipboard clearing unavailable"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn last_seen_skips_repeated_text() { + let mut last = LastSeenText::new(); + assert!(last.accept("first")); + assert!(!last.accept("first")); + assert!(last.accept("second")); + } + + #[test] + fn preview_collapses_newlines_and_truncates() { + let preview = plain_preview(&"a".repeat(300)); + assert!(preview.ends_with('…')); + assert_eq!(preview.chars().filter(|c| *c == '\n').count(), 0); + } + + #[test] + fn internal_write_marker_drains_on_consume() { + let filter = CaptureFilter::new(); + filter.note_internal_write(); + filter.note_internal_write(); + assert_eq!(filter.consume_pending_internal_writes(), 2); + assert_eq!(filter.consume_pending_internal_writes(), 0); + } +} diff --git a/src/native/components/mod.rs b/src/native/components/mod.rs index c6a9243..91f2e42 100644 --- a/src/native/components/mod.rs +++ b/src/native/components/mod.rs @@ -63,6 +63,8 @@ pub enum IconName { Loop, LockClosed, LockOpen, + Copy, + Delete, } impl IconNamed for IconName { @@ -99,6 +101,8 @@ impl IconNamed for IconName { IconName::Loop => "icons/loop.svg".into(), IconName::LockClosed => "icons/lock-closed.svg".into(), IconName::LockOpen => "icons/lock-open.svg".into(), + IconName::Copy => "icons/copy.svg".into(), + IconName::Delete => "icons/delete.svg".into(), } } } diff --git a/src/native/hotkey.rs b/src/native/hotkey.rs index f2ccfaa..d4c6ff2 100644 --- a/src/native/hotkey.rs +++ b/src/native/hotkey.rs @@ -8,15 +8,19 @@ enum HotkeyAction { ToggleFilePreview, TriggerSelectionToCommand, DismissEscapeTarget, + /// Toggle the clipboard history chooser window. The focused window id is captured by the + /// keyboard hook so the chooser can restore focus before simulating the paste. + ToggleClipboardChooser, } impl HotkeyAction { - fn into_ipc_event(self) -> IpcEvent { + fn into_ipc_event(self, focused_window: Option) -> IpcEvent { match self { Self::Show => IpcEvent::Show, Self::ToggleFilePreview => IpcEvent::ToggleFilePreview, Self::TriggerSelectionToCommand => IpcEvent::TriggerSelectionToCommand, Self::DismissEscapeTarget => IpcEvent::DismissEscapeTarget, + Self::ToggleClipboardChooser => IpcEvent::ToggleClipboardChooser { focused_window }, } } } @@ -28,16 +32,25 @@ enum HotkeyKey { AltLeft, AltRight, R, + V, Space, Other, } -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Copy)] struct HotkeyOutcome { action: Option, suppress: bool, } +impl PartialEq for HotkeyOutcome { + fn eq(&self, other: &Self) -> bool { + self.action == other.action && self.suppress == other.suppress + } +} + +impl Eq for HotkeyOutcome {} + #[derive(Default, Debug, Clone, Copy)] struct KeyState { ctrl_left: bool, @@ -45,8 +58,10 @@ struct KeyState { alt_left: bool, alt_right: bool, r_down: bool, + v_down: bool, space_down: bool, suppress_r_until_release: bool, + suppress_v_until_release: bool, suppress_space_until_release: bool, suppress_escape_until_release: bool, modifier_combo_active: bool, @@ -88,6 +103,26 @@ impl KeyState { ..HotkeyOutcome::default() }; } + HotkeyKey::V => { + if !self.v_down { + self.v_down = true; + if self.ctrl() && self.alt() { + self.modifier_combo_consumed = true; + self.suppress_v_until_release = true; + return HotkeyOutcome { + action: Some(HotkeyAction::ToggleClipboardChooser), + suppress: true, + }; + } + } + if self.modifier_combo_active { + self.modifier_combo_consumed = true; + } + return HotkeyOutcome { + suppress: self.suppress_v_until_release, + ..HotkeyOutcome::default() + }; + } HotkeyKey::Space => { if !self.space_down { self.space_down = true; @@ -133,6 +168,11 @@ impl KeyState { outcome.suppress = self.suppress_r_until_release; self.suppress_r_until_release = false; } + HotkeyKey::V => { + self.v_down = false; + outcome.suppress = self.suppress_v_until_release; + self.suppress_v_until_release = false; + } HotkeyKey::Space => { self.space_down = false; outcome.suppress = self.suppress_space_until_release; @@ -189,10 +229,35 @@ pub fn start_global_hotkey_listener(ipc_events_tx: Sender) -> anyhow:: Ok(()) } -fn dispatch_action(ipc_events_tx: &Sender, action: Option) { +fn dispatch_action( + ipc_events_tx: &Sender, + action: Option, + focused_window: Option, +) { let Some(action) = action else { return }; - tracing::debug!(?action, "Global hotkey triggered"); - let _ = ipc_events_tx.send(action.into_ipc_event()); + tracing::debug!(?action, ?focused_window, "Global hotkey triggered"); + let _ = ipc_events_tx.send(action.into_ipc_event(focused_window)); +} + +#[cfg(target_os = "windows")] +fn capture_focused_window() -> Option { + use windows_sys::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + let handle = unsafe { GetForegroundWindow() }; + if handle.is_null() { + None + } else { + Some(handle as u64) + } +} + +#[cfg(target_os = "macos")] +fn capture_focused_window() -> Option { + None +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn capture_focused_window() -> Option { + None } #[cfg(target_os = "windows")] @@ -212,6 +277,7 @@ fn windows_hotkey_key(vk_code: u32) -> HotkeyKey { }; const VK_R: u32 = b'R' as u32; + const VK_V: u32 = b'V' as u32; match vk_code { code if code == VK_LCONTROL as u32 || code == VK_CONTROL as u32 => HotkeyKey::CtrlLeft, @@ -219,6 +285,7 @@ fn windows_hotkey_key(vk_code: u32) -> HotkeyKey { code if code == VK_LMENU as u32 || code == VK_MENU as u32 => HotkeyKey::AltLeft, code if code == VK_RMENU as u32 => HotkeyKey::AltRight, VK_R => HotkeyKey::R, + VK_V => HotkeyKey::V, code if code == VK_SPACE as u32 => HotkeyKey::Space, _ => HotkeyKey::Other, } @@ -292,7 +359,7 @@ unsafe extern "system" fn windows_keyboard_hook( (context.ipc_events_tx.clone(), outcome) }; - dispatch_action(&ipc_events_tx, outcome.action); + dispatch_action(&ipc_events_tx, outcome.action, capture_focused_window()); if outcome.suppress { return 1; } @@ -365,6 +432,7 @@ fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { }; const KEY_R: i64 = 0x0f; + const KEY_V: i64 = 0x09; const KEY_SPACE: i64 = 0x31; const KEY_ESCAPE: i64 = 0x35; @@ -378,6 +446,8 @@ fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { let suppress_space_until_release_for_tap = suppress_space_until_release.clone(); let suppress_escape_until_release = Arc::new(AtomicBool::new(false)); let suppress_escape_until_release_for_tap = suppress_escape_until_release.clone(); + let suppress_v_until_release = Arc::new(AtomicBool::new(false)); + let suppress_v_until_release_for_tap = suppress_v_until_release.clone(); let tap = CGEventTap::new( CGEventTapLocation::Session, CGEventTapPlacement::HeadInsertEventTap, @@ -424,6 +494,24 @@ fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { { event.set_type(CGEventType::Null); } + CGEventType::KeyDown if primary && alt && key_code == KEY_V => { + modifier_combo_consumed_for_tap.store(true, Ordering::Relaxed); + suppress_v_until_release_for_tap.store(true, Ordering::Relaxed); + if !is_repeat { + dispatch_action( + &ipc_events_tx, + Some(HotkeyAction::ToggleClipboardChooser), + None, + ); + } + event.set_type(CGEventType::Null); + } + CGEventType::KeyUp + if key_code == KEY_V + && suppress_v_until_release_for_tap.swap(false, Ordering::Relaxed) => + { + event.set_type(CGEventType::Null); + } CGEventType::KeyDown if primary && !alt && key_code == KEY_SPACE => { suppress_space_until_release_for_tap.store(true, Ordering::Relaxed); if !is_repeat { @@ -499,6 +587,7 @@ fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { Key::Alt => HotkeyKey::AltLeft, Key::AltGr => HotkeyKey::AltRight, Key::KeyR => HotkeyKey::R, + Key::KeyV => HotkeyKey::V, Key::Space => HotkeyKey::Space, _ => HotkeyKey::Other, } @@ -517,7 +606,7 @@ fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { _ => HotkeyOutcome::default(), } }; - dispatch_action(&ipc_events_tx, outcome.action); + dispatch_action(&ipc_events_tx, outcome.action, None); }; listen(callback).map_err(|err| anyhow::anyhow!("rdev listen failed: {err:?}")) @@ -628,6 +717,56 @@ mod tests { assert_eq!(state.key_up(HotkeyKey::CtrlLeft).action, None); } + #[test] + fn ctrl_alt_v_triggers_the_clipboard_chooser_once() { + let mut state = KeyState::default(); + + state.key_down(HotkeyKey::CtrlLeft); + state.key_down(HotkeyKey::AltLeft); + assert_eq!( + state.key_down(HotkeyKey::V), + HotkeyOutcome { + action: Some(HotkeyAction::ToggleClipboardChooser), + suppress: true, + } + ); + // Held V during a window scroll reports no new action and stays suppressed. + assert_eq!( + state.key_down(HotkeyKey::V), + HotkeyOutcome { + action: None, + suppress: true, + } + ); + assert!(state.key_up(HotkeyKey::V).suppress); + state.key_up(HotkeyKey::AltLeft); + + assert_eq!(state.key_up(HotkeyKey::CtrlLeft).action, None); + } + + #[test] + fn ctrl_alt_v_does_not_toggle_preview_when_released() { + let mut state = KeyState::default(); + + state.key_down(HotkeyKey::CtrlLeft); + state.key_down(HotkeyKey::AltLeft); + assert_eq!( + state.key_down(HotkeyKey::V), + HotkeyOutcome { + action: Some(HotkeyAction::ToggleClipboardChooser), + suppress: true, + } + ); + state.key_up(HotkeyKey::V); + state.key_up(HotkeyKey::AltLeft); + + assert_eq!( + state.key_up(HotkeyKey::CtrlLeft).action, + None, + "ctrl+alt+V must consume the modifier combo, so the trailing release must not also toggle the file preview", + ); + } + #[test] fn escape_is_only_consumed_while_a_dismiss_target_is_active() { let mut state = KeyState::default(); diff --git a/src/native/mod.rs b/src/native/mod.rs index d7be647..e8784ed 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -6,12 +6,15 @@ use std::{ use gpui::{AnyWindowHandle, App, Application, rgb}; use gpui_component::{Theme, ThemeMode}; +use crate::clipboard::vault_controller::VaultController; use crate::{ + clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore}, ipc::IpcEvent, native::windows::{StickerWindowEvent, main::MainWindow, sticker::StickerWindow}, storage::{ArcStickerStore, open_sqlite, paths::AppPaths}, }; +pub mod clipboard; pub mod components; pub mod file_manager; pub mod hotkey; @@ -36,6 +39,14 @@ pub fn run_native( let main_window_handle = Arc::new(OnceLock::::new()); let store_handle = Arc::new(OnceLock::::new()); + let clipboard_handle_for_open: Arc>> = + Arc::new(OnceLock::new()); + let clipboard_handle_for_ipc = clipboard_handle_for_open.clone(); + let clipboard_filter_for_open: Arc< + OnceLock>, + > = Arc::new(OnceLock::new()); + let clipboard_filter_for_ipc = clipboard_filter_for_open.clone(); + let clipboard_vault_for_open: Arc>> = Arc::new(OnceLock::new()); app.run(move |cx: &mut App| { gpui_component::init(cx); @@ -128,7 +139,11 @@ pub fn run_native( if let Some(store) = store_handle_clone.get() { let store = store.clone(); let tx = sticker_events_tx_for_ipc.clone(); - let selection = match crate::native::selection::capture_selection() { + let selection = match clipboard_filter_for_ipc.get() { + Some(filter) => crate::native::selection::capture_selection_with_filter(filter), + None => crate::native::selection::capture_selection(), + }; + let selection = match selection { Ok(selection) => selection, Err(err) => { tracing::warn!(error = ?err, "Failed to capture text selection"); @@ -165,6 +180,26 @@ pub fn run_native( } } } + crate::ipc::IpcEvent::ToggleClipboardChooser { focused_window } => { + let store = clipboard_handle_for_ipc.get().cloned(); + let filter = clipboard_filter_for_ipc.get().cloned(); + if let (Some(store), Some(_filter)) = (store, filter) { + cx.update(|cx| { + if let Err(err) = crate::native::windows::clipboard::ClipboardChooserWindow::toggle( + cx, + sticker_events_tx_for_ipc.clone(), + store, + focused_window, + ) { + tracing::warn!(error = ?err, "Failed to toggle clipboard chooser"); + } + }); + } else { + tracing::warn!( + "Clipboard chooser requested but the clipboard backend has not been initialised" + ); + } + } } } } @@ -173,8 +208,9 @@ pub fn run_native( let main_window_handle_clone = main_window_handle.clone(); cx.spawn(async move |cx| { - tracing::info!(db_path = %app_paths.db_path.display(), "Opening sticker store"); - let store: ArcStickerStore = match open_sqlite(app_paths.db_path).await { + let sticker_db = app_paths.db_path.clone(); + tracing::info!(db_path = %sticker_db.display(), "Opening sticker store"); + let store: ArcStickerStore = match open_sqlite(&sticker_db).await { Ok(store) => store, Err(err) => { tracing::error!(error = ?err, "Failed to open store"); @@ -186,6 +222,28 @@ pub fn run_native( tracing::info!("Sticker store opened"); + // Clipboard failure must not prevent the main window or stickers from opening. + let clipboard_db_path = sticker_db.with_file_name("clipboard.db"); + match SqliteClipboardStore::open(&clipboard_db_path).await { + Ok(backend) => { + let clipboard_store: Arc = Arc::new(backend); + let filter = crate::native::clipboard::CaptureFilter::new(); + let vault = Arc::new(VaultController::new(clipboard_store.clone())); + let _ = clipboard_handle_for_open.set(clipboard_store.clone()); + let _ = clipboard_filter_for_open.set(filter.clone()); + let _ = clipboard_vault_for_open.set(vault.clone()); + let (watcher, _stop) = crate::native::clipboard::watcher::WatcherHandle::new(Duration::from_millis(250)); + let (events, _receiver) = mpsc::channel(); + watcher.spawn(filter.clone(), clipboard_store.clone(), events); + crate::native::windows::clipboard::set_global_store(clipboard_store); + cx.update(move |_cx| { + crate::native::windows::clipboard::set_global_capture_filter(filter); + crate::native::windows::clipboard::set_global_vault_controller(vault); + }); + } + Err(_) => tracing::error!("Clipboard backend unavailable; continuing sticker startup"), + } + // Armed cron stickers have to keep ticking whether or not their window is open, so the // scheduler starts before any window is restored. let scheduler_store = store.clone(); diff --git a/src/native/power_guard.rs b/src/native/power_guard.rs index 1db429f..9544420 100644 --- a/src/native/power_guard.rs +++ b/src/native/power_guard.rs @@ -50,7 +50,9 @@ mod imp { let hwnd = match hwnd { Ok(hwnd) if hwnd != HWND::default() => hwnd, _ => { - tracing::warn!("gpui platform window not found; system-wake crash guard not installed"); + tracing::warn!( + "gpui platform window not found; system-wake crash guard not installed" + ); return; } }; diff --git a/src/native/selection.rs b/src/native/selection.rs index 1c0ca0b..0c076ae 100644 --- a/src/native/selection.rs +++ b/src/native/selection.rs @@ -1,27 +1,80 @@ //! Cross-platform clipboard-based selection capture. +//! +//! The capture helper used to communicate directly with the arboard clipboard, but as soon as the +//! clipboard history collector came online (M1+) it became responsible for flipping an internal +//! flag so the watcher does not stash the temporary marker we drop on the clipboard. +//! +//! Callers that need this protection construct a [`selection_with_filter`] closure via +//! [`SelectionCaptureService::new`], which holds the [`CaptureFilter`] the watcher has been +//! built with. In tests and CLIs without a watcher the helper still works because the default +//! is "always treat the write as internal" via a dedicated filter. use anyhow::Context; use rdev::{EventType, Key, simulate}; -use std::{thread, time::Duration}; +use std::{sync::Arc, thread, time::Duration}; + +use crate::native::clipboard::CaptureFilter; + +/// A self-contained handle on the selection helper. +/// +/// The single concrete type keeps tests, the GUI bootstrap, and any future IPC recipient all on +/// the same surface. The default constructor uses an isolated filter so writes are never +/// delivered to any running watcher. +pub struct SelectionCaptureService { + filter: Arc, +} + +impl SelectionCaptureService { + pub fn new(filter: Arc) -> Self { + Self { filter } + } + + /// Read the currently focused foreground application's text selection. + pub fn capture_selection(&self) -> anyhow::Result> { + let filter = self.filter.clone(); + capture_selection_with_filter(&filter) + } +} + +impl Default for SelectionCaptureService { + fn default() -> Self { + Self { + filter: CaptureFilter::new(), + } + } +} /// Copy and read the current text selection from the active application. /// -/// Returns `Ok(None)` when the active application has no direct text -/// selection. The previous clipboard content is never used as a fallback, -/// callers are expected to ask the user for the text instead. +/// Returns `Ok(None)` when the active application has no direct text selection. The previous +/// clipboard content is restored afterwards. The temporary marker and the restore step go +/// through [`CaptureFilter::note_internal_write`] so the history collector never ingests them. pub fn capture_selection() -> anyhow::Result> { + capture_selection_with_filter(&CaptureFilter::new()) +} + +/// Same as [`capture_selection`] but reusable across calls when a long-lived +/// [`CaptureFilter`] is in scope. The filter is notified twice per call: once for the marker, +/// and once for the restore. Both writes pair up with a single +/// [`watcher::WatcherHandle::spawn`] tick so no keyboard events are missed. +pub fn capture_selection_with_filter( + filter: &Arc, +) -> anyhow::Result> { + let _transaction = filter.transaction(); let mut clipboard = arboard::Clipboard::new().context("failed to open system clipboard")?; let previous_text = clipboard.get_text().ok(); let marker = format!( "__RUSTICKERS_SELECTION_{}__", crate::utils::time::now_unix_millis() ); + filter.note_internal_write(); if let Err(err) = clipboard.set_text(marker.clone()) { return Err(anyhow::anyhow!( "failed to prepare clipboard for selection capture: {err}" )); } + filter.record_internal_generation(); #[cfg(target_os = "macos")] let primary = Key::MetaLeft; #[cfg(not(target_os = "macos"))] @@ -32,7 +85,7 @@ pub fn capture_selection() -> anyhow::Result> { let _ = simulate(&EventType::KeyRelease(Key::KeyC)); let _ = simulate(&EventType::KeyRelease(primary)); if let Err(err) = copy_result { - restore_clipboard(&mut clipboard, previous_text.as_deref()); + restore_clipboard(&mut clipboard, previous_text.as_deref(), filter); return Err(anyhow::anyhow!("failed to copy selected text: {err:?}")); } @@ -47,17 +100,39 @@ pub fn capture_selection() -> anyhow::Result> { } } - restore_clipboard(&mut clipboard, previous_text.as_deref()); + if clipboard.get_text().ok().as_deref() == text.as_deref().or(Some(marker.as_str())) { + restore_clipboard(&mut clipboard, previous_text.as_deref(), filter); + } Ok(match text { Some(text) if !text.trim().is_empty() => Some(text), _ => None, }) } -fn restore_clipboard(clipboard: &mut arboard::Clipboard, previous_text: Option<&str>) { +fn restore_clipboard( + clipboard: &mut arboard::Clipboard, + previous_text: Option<&str>, + filter: &Arc, +) { + filter.note_internal_write(); if let Some(previous_text) = previous_text { let _ = clipboard.set_text(previous_text); } else { let _ = clipboard.clear(); } + filter.record_internal_generation(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_constructs_with_a_filter() { + let filter = CaptureFilter::new(); + let service = SelectionCaptureService::new(filter); + // Calling capture without a real keyboard would still try to simulate Ctrl+C. Skip the + // interactive part — we just check the type can be moved and the filter is reachable. + let _ = service; + } } diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs new file mode 100644 index 0000000..4a4ff71 --- /dev/null +++ b/src/native/windows/clipboard.rs @@ -0,0 +1,1454 @@ +//! Keyboard-first retrieval with password-authorized use/edit of existing encrypted rows. +//! Protect and encrypted edits use atomic, vault-generation-bound storage transitions. +mod editor; + +use crate::clipboard::{ + items::{ClipboardItem, ClipboardItemKind}, + storage::{ClipboardHistoryStore, RetentionFlag}, + vault_controller::VaultController, +}; +use crate::native::clipboard::{CaptureFilter, foreground, paste_foreground, write_clipboard_text}; +use crate::native::components::IconName; +use crate::utils::time::now_unix_millis; +use gpui::{ + App, Bounds, Context, Entity, EntityInputHandler, Focusable, IntoElement, MouseButton, + MouseDownEvent, Render, ScrollStrategy, Subscription, UniformListScrollHandle, Window, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowOptions, black, + div, prelude::*, px, rgba, size, uniform_list, +}; +use gpui_component::{ + ActiveTheme, Disableable, Icon, Root, Selectable, Sizable, WindowExt, + alert::Alert, + button::{Button, ButtonVariants}, + h_flex, + input::{Input, InputEvent, InputState}, + v_flex, +}; +use std::sync::{Arc, OnceLock, RwLock}; + +const WIDTH: f32 = 380.0; +const HEIGHT: f32 = 520.0; +const PAGE: isize = 6; +#[derive(Clone, Copy)] +enum AuthorizedAction { + Protect, + Unprotect, + Use(bool), + Edit, +} +struct PasswordPrompt { + id: i64, + action: AuthorizedAction, + expected_plaintext: Option>, +} +enum AuthorizedResult { + Protected, + Unprotected, + Use(ClipboardItem, zeroize::Zeroizing, bool), + Edit( + ClipboardItem, + zeroize::Zeroizing, + Arc, + ), +} + +static GLOBAL_CAPTURE_FILTER: OnceLock> = OnceLock::new(); +pub fn set_global_capture_filter(filter: Arc) { + let _ = GLOBAL_CAPTURE_FILTER.set(filter); +} +// Keep bootstrap integration; never use session-unlocked state to bypass row authorization. +static GLOBAL_VAULT: OnceLock> = OnceLock::new(); +pub fn set_global_vault_controller(vault: Arc) { + let _ = GLOBAL_VAULT.set(vault); +} +pub(crate) static CLIPBOARD_POPUP: RwLock> = RwLock::new(None); + +fn clear_popup() { + if let Ok(mut slot) = CLIPBOARD_POPUP.write() { + *slot = None; + } + super::set_clipboard_escape_target(false); +} +pub fn dispatch_escape(cx: &mut App) -> bool { + let handle = CLIPBOARD_POPUP.read().ok().and_then(|slot| *slot); + handle + .and_then(|h| { + h.update(cx, |_, w, cx| { + w.dispatch_keystroke(gpui::Keystroke::parse("escape").expect("valid key"), cx) + }) + .ok() + }) + .unwrap_or(false) +} + +pub fn open_from_main( + cx: &mut App, + events: std::sync::mpsc::Sender, +) -> anyhow::Result<()> { + let vault = GLOBAL_VAULT + .get() + .ok_or_else(|| anyhow::anyhow!("Clipboard backend unavailable"))?; + let _ = vault; + let store = GLOBAL_STORE + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("Clipboard backend unavailable"))?; + ClipboardChooserWindow::toggle(cx, events, store, None) +} +static GLOBAL_STORE: OnceLock> = OnceLock::new(); +pub fn set_global_store(store: Arc) { + let _ = GLOBAL_STORE.set(store); +} + +pub struct ClipboardChooserWindow; +impl ClipboardChooserWindow { + pub fn toggle( + cx: &mut App, + _events: std::sync::mpsc::Sender, + store: Arc, + destination: Option, + ) -> anyhow::Result<()> { + let existing = CLIPBOARD_POPUP.read().ok().and_then(|slot| *slot); + if let Some(handle) = existing { + if dispatch_escape(cx) { + return Ok(()); + } + if handle.update(cx, |_, _, _| ()).is_ok() { + return Ok(()); + } + clear_popup(); + } + let filter = GLOBAL_CAPTURE_FILTER + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("Clipboard backend unavailable"))?; + let bounds = Bounds::centered(None, size(px(WIDTH), px(HEIGHT)), cx); + let handle = cx.open_window( + WindowOptions { + focus: true, + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_background: WindowBackgroundAppearance::Transparent, + is_resizable: true, + window_min_size: Some(size(px(380.0), px(360.0))), + kind: WindowKind::Floating, + titlebar: None, + ..Default::default() + }, + move |window, cx| { + let view = cx + .new(|cx| ClipboardChooserWidget::new(window, cx, store, filter, destination)); + let surface = cx.new(|_| ClipboardChooserSurface { view }); + cx.new(|cx| Root::new(surface, window, cx)) + }, + )?; + if let Ok(mut slot) = CLIPBOARD_POPUP.write() { + *slot = Some(handle.into()); + } + cx.defer(move |cx| { + let _ = handle.update(cx, |_, w, _| w.activate_window()); + }); + Ok(()) + } +} + +// Dialog builders read the chooser: render them outside its mutable Render borrow. +struct ClipboardChooserSurface { + view: Entity, +} +impl Render for ClipboardChooserSurface { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .relative() + .size_full() + .child(self.view.clone()) + .children(Root::render_dialog_layer(window, cx)) + } +} + +fn validate_new_password(password: &str, confirmation: &str) -> Result<(), &'static str> { + if password.is_empty() { + return Err("Enter your master password."); + } + if password != confirmation { + return Err("Passwords do not match. Enter the same password in both fields."); + } + Ok(()) +} + +fn protected(item: &ClipboardItem) -> bool { + item.kind == ClipboardItemKind::Protected || item.retention.protected() +} +/// Never use a possibly stale plaintext preview of a protected row. +fn display_preview(item: &ClipboardItem) -> String { + let text = if protected(item) { + item.public_label.as_deref().unwrap_or("Protected item") + } else { + &item.content + }; + text.chars() + .take(120) + .flat_map(|c| { + if c.is_control() { + c.escape_default().collect::>() + } else { + vec![c] + } + }) + .collect() +} +fn redact_protected_previews(rows: &mut [ClipboardItem]) { + for row in rows.iter_mut().filter(|r| protected(r)) { + row.plain_preview = None; + row.content.clear(); + row.kind = ClipboardItemKind::Protected; + } +} + +pub struct ClipboardChooserWidget { + store: Arc, + filter: Arc, + rows: Vec, + filtered: Vec, + selected: usize, + query: Entity, + scroll: UniformListScrollHandle, + destination: Option, + busy: bool, + loading: bool, + status: String, + status_error: bool, + status_warning: bool, + password_error: Option, + password: Entity, + confirmation: Entity, + label: Entity, + creating_vault: bool, + editor_open: bool, + hovered_row: Option, + prompt: Option, + capture_enabled: bool, + capture_status: String, + query_version: u64, + _keys: Subscription, + _activation: Subscription, +} +impl ClipboardChooserWidget { + fn new( + window: &mut Window, + cx: &mut Context, + store: Arc, + filter: Arc, + destination: Option, + ) -> Self { + let query = + cx.new(|cx| InputState::new(window, cx).placeholder("Search clipboard history…")); + cx.subscribe_in(&query, window, |this, query, event, _, cx| { + if matches!(event, InputEvent::Change) { + this.filtered = + crate::clipboard::filter::filter_indices(&this.rows, &query.read(cx).value()); + this.selected = 0; + this.scroll_to_selection(); + cx.notify(); + } + }) + .detach(); + let password = cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("Master password") + }); + cx.subscribe_in(&password, window, |this, _, event, _, cx| { + if matches!(event, InputEvent::Change) && this.password_error.take().is_some() { + cx.notify(); + } + }) + .detach(); + let confirmation = cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("Confirm master password") + }); + cx.subscribe_in(&confirmation, window, |this, _, event, _, cx| { + if matches!(event, InputEvent::Change) && this.password_error.take().is_some() { + cx.notify(); + } + }) + .detach(); + let label = cx.new(|cx| InputState::new(window, cx).placeholder("Public label (required)")); + cx.subscribe_in(&label, window, |this, _, event, _, cx| { + if matches!(event, InputEvent::Change) && this.password_error.take().is_some() { + cx.notify(); + } + }) + .detach(); + let own_id = window.window_handle().window_id(); + let entity = cx.weak_entity(); + let keys = cx.intercept_keystrokes(move |event, window, cx| { + if window.window_handle().window_id() != own_id || !window.is_window_active() { + return; + } + let Some(entity) = entity.upgrade() else { + return; + }; + let key = event.keystroke.key.as_str(); + let mods = &event.keystroke.modifiers; + let primary = if cfg!(target_os = "macos") { + mods.platform || mods.control + } else { + mods.control + }; + let handled = entity.update(cx, |this, cx| { + if this.editor_open { + return false; + } + if this.prompt.is_some() { + if this + .password + .update(cx, |q, cx| q.marked_text_range(window, cx).is_some()) + { + return false; + } + return match key { + "escape" if !this.busy => { + this.cancel_prompt(window, cx); + true + } + "enter" if !this.busy => { + this.submit_password(window, cx); + true + } + _ => false, + }; + } + // IME candidate navigation/confirmation belongs to the text input. + let composing = this + .query + .update(cx, |q, cx| q.marked_text_range(window, cx).is_some()); + if composing { + return false; + } + match key { + "escape" if !this.busy => { + window.remove_window(); + clear_popup(); + true + } + "enter" if !mods.alt && !mods.shift => { + this.use_selected(primary, window, cx); + true + } + "r" if primary && !mods.alt => { + this.reserve(window, cx); + true + } + "l" if primary && !mods.alt => { + this.protect_selected(window, cx); + true + } + "f2" => { + this.edit(window, cx); + true + } + "up" | "down" | "pageup" | "pagedown" + if !primary && !mods.alt && !mods.shift => + { + let delta = match key { + "up" => -1, + "down" => 1, + "pageup" => -PAGE, + _ => PAGE, + }; + this.selected = (this.selected as isize + delta) + .clamp(0, this.filtered.len().saturating_sub(1) as isize) + as usize; + this.scroll_to_selection(); + cx.notify(); + true + } + // Delete/Home/End retain their normal search-field editing behavior. + _ => false, + } + }); + if handled { + cx.stop_propagation(); + window.prevent_default(); + } + }); + let activation = cx.observe_window_activation(window, |_, window, _| { + super::set_clipboard_escape_target(window.is_window_active()); + }); + window.on_window_should_close(cx, |_, _| { + clear_popup(); + true + }); + query.update(cx, |q, cx| q.focus(window, cx)); + let mut this = Self { + store, + filter, + rows: Vec::new(), + filtered: Vec::new(), + selected: 0, + query, + scroll: UniformListScrollHandle::new(), + destination, + busy: false, + loading: true, + status: String::new(), + status_error: false, + status_warning: false, + password_error: None, + password, + confirmation, + label, + creating_vault: false, + editor_open: false, + hovered_row: None, + prompt: None, + capture_enabled: false, + capture_status: "Collector starting…".into(), + query_version: 0, + _keys: keys, + _activation: activation, + }; + this.refresh(window, cx); + cx.spawn_in(window, async move |entity, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_secs(1)) + .await; + if entity + .update_in(cx, |this, window, cx| { + this.capture_status = crate::native::clipboard::watcher::capture_status(); + if !this.busy && this.prompt.is_none() && !this.editor_open { + this.refresh(window, cx); + } + cx.notify(); + }) + .is_err() + { + break; + } + } + }) + .detach(); + this + } + fn scroll_to_selection(&self) { + self.scroll + .scroll_to_item(self.selected, ScrollStrategy::Top); + } + fn selected_item(&self) -> Option { + self.rows.get(*self.filtered.get(self.selected)?).cloned() + } + fn refresh(&mut self, window: &mut Window, cx: &mut Context) { + let store = self.store.clone(); + let selected_id = self.selected_item().map(|r| r.id); + self.query_version += 1; + let version = self.query_version; + cx.spawn_in(window, async move |entity, cx| { + let result = store.snapshot().await; + let settings = store.settings().await; + let _ = entity.update_in(cx, |this, _, cx| { + if version != this.query_version { + return; + } + if let Ok(settings) = settings { + this.capture_enabled = settings.capture_enabled; + } + this.loading = false; + match result { + Ok(mut rows) => { + redact_protected_previews(&mut rows); + this.rows = rows; + this.filtered = crate::clipboard::filter::filter_indices( + &this.rows, + &this.query.read(cx).value(), + ); + this.selected = selected_id + .and_then(|id| { + this.filtered.iter().position(|i| this.rows[*i].id == id) + }) + .unwrap_or_else(|| { + this.selected.min(this.filtered.len().saturating_sub(1)) + }); + this.scroll_to_selection(); + } + Err(_) => { + this.status_error = true; + this.status = "Could not load history. Try refreshing.".into(); + } + } + cx.notify(); + }); + }) + .detach(); + } + fn use_selected(&mut self, copy_only: bool, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + let Some(item) = self.selected_item() else { + return; + }; + if protected(&item) { + self.request_password(item.id, AuthorizedAction::Use(copy_only), window, cx); + return; + } + self.busy = true; + self.status_error = false; + self.status = if copy_only { + "Copying…" + } else { + "Restoring destination…" + } + .into(); + cx.notify(); + let store = self.store.clone(); + let filter = self.filter.clone(); + let destination = self.destination; + cx.spawn_in(window, async move |entity, cx| { + // Re-read the chosen stable ID before any side effect; never use stale cached content. + let result = async { + let current = store.get(item.id).await?; + anyhow::ensure!( + !protected(¤t), + "This item is now protected. Nothing was copied." + ); + if !copy_only { + entity.update_in(cx, |_, window, _| hide_chooser(window))??; + } + let dispatch = cx + .background_executor() + .spawn(async move { + if !copy_only { + crate::native::clipboard::paste::wait_for_modifiers()?; + foreground(destination)?; + } + write_clipboard_text(&filter, ¤t.content)?; + if !copy_only { + paste_foreground(destination)?; + } + Ok::<_, anyhow::Error>(()) + }) + .await; + dispatch?; + store.mark_used(item.id, now_unix_millis()).await?; + Ok::<_, anyhow::Error>(()) + } + .await; + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + match result { + Ok(()) => { + clear_popup(); + window.remove_window(); + } + Err(err) => { + show_chooser(window); + this.status_error = true; + this.status = format!("{err}"); + window.activate_window(); + this.query.update(cx, |q, cx| q.focus(window, cx)); + cx.notify(); + } + } + }); + }) + .detach(); + } + fn reserve(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + let Some(item) = self.selected_item() else { + return; + }; + self.status.clear(); + self.status_warning = false; + self.busy = true; + let store = self.store.clone(); + cx.spawn_in(window, async move |entity, cx| { + let result = async { + let current = store.get(item.id).await?; + store + .set_retention_flag( + item.id, + RetentionFlag::Explicit, + !current.retention.explicit(), + ) + .await?; + Ok::<_, anyhow::Error>(( + !current.retention.explicit(), + current.retention.edited() || protected(¤t), + )) + } + .await; + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + this.status_warning = false; + this.status_error = result.is_err(); + this.status = match result { + Ok((true, _)) => "Reserved. Kept outside ordinary history limits.".into(), + Ok((false, true)) => { + "Reservation removed; edited/protected retention still keeps this item." + .into() + } + Ok((false, false)) => { + "Reservation removed; ordinary history limits now apply.".into() + } + Err(_) => "Reservation failed; no successful change is claimed.".into(), + }; + this.refresh(window, cx); + cx.notify(); + }); + }) + .detach(); + } + fn edit(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + let Some(item) = self.selected_item() else { + return; + }; + if protected(&item) { + self.request_password(item.id, AuthorizedAction::Edit, window, cx); + return; + } + self.editor_open = true; + let parent = cx.weak_entity(); + if editor::open(window, cx, self.store.clone(), item, parent, None).is_err() { + self.status_error = true; + self.editor_open = false; + self.status = "Could not open the editor. Try again.".into(); + cx.notify(); + } + } + fn protect_selected(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy || self.prompt.is_some() { + return; + } + let Some(item) = self.selected_item() else { + return; + }; + if protected(&item) { + self.request_password(item.id, AuthorizedAction::Unprotect, window, cx); + return; + } + self.request_password(item.id, AuthorizedAction::Protect, window, cx); + } + fn request_password( + &mut self, + id: i64, + action: AuthorizedAction, + window: &mut Window, + cx: &mut Context, + ) { + if self.busy { + return; + } + if GLOBAL_VAULT.get().is_none() { + self.status_error = true; + self.status = "Vault unavailable. Try reopening history.".into(); + cx.notify(); + return; + } + let expected_plaintext = if matches!(action, AuthorizedAction::Protect) { + self.selected_item() + .filter(|item| item.id == id) + .map(|item| zeroize::Zeroizing::new(item.content)) + } else { + None + }; + self.status.clear(); + self.status_warning = false; + self.password_error = None; + self.label.update(cx, |p, cx| p.set_value("", window, cx)); + self.prompt = Some(PasswordPrompt { + id, + action, + expected_plaintext, + }); + self.password + .update(cx, |p, cx| p.set_value("", window, cx)); + self.busy = true; + let vault = GLOBAL_VAULT.get().cloned().expect("vault checked above"); + cx.spawn_in(window, async move |entity, cx| { + let result = cx.background_executor().spawn(async move { + futures::executor::block_on(vault.envelope()) + }).await; + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + match result { + Ok(envelope) => { + this.creating_vault = envelope.is_none(); + if this.creating_vault && !matches!(action, AuthorizedAction::Protect) { + this.clear_password_prompt(window, cx); + this.status_error = true; + this.status = "The original vault is missing. Restore it to unlock protected items; a new vault cannot decrypt them.".into(); + } else { + this.open_password_dialog(window, cx); + if matches!(action, AuthorizedAction::Protect) { + this.label.update(cx, |p, cx| p.focus(window, cx)); + } else { + this.password.update(cx, |p, cx| p.focus(window, cx)); + } + } + } + Err(_) => { + this.clear_password_prompt(window, cx); + this.status_error = true; + this.status = "Could not read the clipboard vault. No changes were saved. Try reopening history.".into(); + } + } + cx.notify(); + }); + }).detach(); + cx.notify(); + } + fn open_password_dialog(&self, window: &mut Window, cx: &mut Context) { + let entity = cx.entity(); + window.open_dialog(cx, move |dialog, window, cx| { + let this = entity.read(cx); + let protecting = this.prompt.as_ref().is_some_and(|p| matches!(p.action, AuthorizedAction::Protect)); + let unprotecting = this.prompt.as_ref().is_some_and(|p| matches!(p.action, AuthorizedAction::Unprotect)); + let creating = this.creating_vault; + let label = this.label.clone(); + let confirmation = this.confirmation.clone(); + let busy = this.busy; + let password = this.password.clone(); + let error = this.password_error.clone(); + let submit = entity.clone(); + let cancel = entity.clone(); + let closed = entity.clone(); + dialog + .title(if creating { "Set up clipboard vault" } else if protecting { "Protect clipboard item" } else if unprotecting { "Remove item protection" } else { "Unlock clipboard item" }) + .w(window.viewport_size().width.min(px(400.0)) - px(32.0)) + .bg(black().opacity(0.95)) + .text_sm() + .overlay_closable(false) + .keyboard(false) + .close_button(!busy) + .on_close(move |_, window, cx| { + closed.update(cx, |this, cx| { + if !this.busy && this.prompt.is_some() { + this.clear_password_prompt(window, cx); + } + }); + }) + .child(v_flex().gap_3() + .child(div().text_color(cx.theme().muted_foreground).child( + if creating { "Choose a master password to securely encrypt your clipboard items." } else if protecting { "Enter your master password to encrypt this item." } + else if unprotecting { "Enter your master password to remove encryption from this item." } else { "Enter your master password to use this protected item." })) + .when(creating, |body| body.child(Alert::warning("clipboard-vault-setup", "No clipboard vault exists yet. Keep your master password safe: it cannot be recovered or reset to unlock existing items.").small())) + .when(protecting, |body| body.child(Input::new(&label).disabled(busy).w_full()) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child("Visible in history without unlocking. Do not include secrets."))) + .child(Input::new(&password).disabled(busy).w_full()) + .when(creating, |body| body.child(Input::new(&confirmation).disabled(busy).w_full())) + .when(error.is_some(), |body| body.child(Alert::error("clipboard-password-error", error.unwrap_or_default()).small())) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child( + if protecting { "This item is encrypted only after you confirm. Existing backups may still contain the original text." } + else if unprotecting { "The item will be stored as plain text and ordinary history limits will apply unless pinned or edited." } else { "Authorization is temporary. Protected copies clear after 30 seconds if unchanged." }))) + .footer(h_flex().items_center().gap_2() + .child(div().flex_1().text_xs().text_color(cx.theme().muted_foreground).child("Enter confirm · Esc cancel")) + .child(Button::new("cancel-authorization").label("Cancel").small().disabled(busy) + .on_click(move |_, window, cx| cancel.update(cx, |this, cx| this.cancel_prompt(window, cx)))) + .child(Button::new("authorize-item").label(if creating { "Create vault & protect" } else if protecting { "Protect" } else if unprotecting { "Remove protection" } else { "Unlock" }).primary().small().loading(busy).disabled(busy) + .on_click(move |_, window, cx| submit.update(cx, |this, cx| this.submit_password(window, cx))))) + }); + } + + fn clear_password_prompt(&mut self, window: &mut Window, cx: &mut Context) { + self.prompt = None; + self.creating_vault = false; + self.confirmation + .update(cx, |p, cx| p.set_value("", window, cx)); + self.password_error = None; + self.password + .update(cx, |p, cx| p.set_value("", window, cx)); + if let Some(vault) = GLOBAL_VAULT.get() { + vault.lock(); + } + self.query.update(cx, |q, cx| q.focus(window, cx)); + cx.notify(); + } + fn cancel_prompt(&mut self, window: &mut Window, cx: &mut Context) { + window.close_dialog(cx); + self.clear_password_prompt(window, cx); + } + fn submit_password(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + let Some(prompt) = &self.prompt else { + return; + }; + let id = prompt.id; + let action = prompt.action; + let expected_plaintext = prompt.expected_plaintext.clone(); + let label = self.label.read(cx).value().trim().to_owned(); + if matches!(action, AuthorizedAction::Protect) && label.is_empty() { + self.password_error = Some("Enter a public label for this item.".into()); + self.label.update(cx, |p, cx| p.focus(window, cx)); + cx.notify(); + return; + } + let creating = self.creating_vault; + let password = zeroize::Zeroizing::new(self.password.read(cx).value().to_string()); + if creating { + let confirmation = + zeroize::Zeroizing::new(self.confirmation.read(cx).value().to_string()); + if let Err(message) = validate_new_password(&password, &confirmation) { + self.password_error = Some(message.into()); + cx.notify(); + return; + } + } + if password.is_empty() { + self.password_error = Some("Enter your master password.".into()); + cx.notify(); + return; + } + self.password + .update(cx, |p, cx| p.set_value("", window, cx)); + self.confirmation + .update(cx, |p, cx| p.set_value("", window, cx)); + self.busy = true; + self.password_error = None; + cx.notify(); + let Some(vault) = GLOBAL_VAULT.get().cloned() else { + self.busy = false; + return; + }; + let store = self.store.clone(); + let filter = self.filter.clone(); + let destination = self.destination; + cx.spawn_in(window, async move |entity, cx| { + let vault_for_work = vault.clone(); + let store_for_work = store.clone(); + let result = cx.background_executor().spawn(async move { + futures::executor::block_on(async { + let failures = vault_for_work.failed_attempts().clamp(0, 5) as u64; + if failures > 0 { std::thread::sleep(std::time::Duration::from_millis(250 * failures)); } + if creating { vault_for_work.create(&password).await?; } + anyhow::ensure!(vault_for_work.unlock(&password).await?, "Wrong master password"); + let current = store_for_work.get(id).await?; + if matches!(action, AuthorizedAction::Protect) { + let expected = expected_plaintext.as_ref().ok_or_else(|| anyhow::anyhow!("Original item unavailable; reload"))?; + anyhow::ensure!(current.kind == ClipboardItemKind::Plain && current.content == **expected, "Item changed; reload before protecting"); + let sealed = vault_for_work.seal_for_storage(expected.as_bytes())? + .ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; + store_for_work.compare_exchange_protection(id, expected, &sealed.ciphertext, &sealed.vault_envelope, Some(&label)).await?; + return Ok::<_, anyhow::Error>(AuthorizedResult::Protected); + } + anyhow::ensure!(current.kind == ClipboardItemKind::Protected, "Item changed; reload before use"); + let bytes = vault_for_work.open(¤t.content)?.ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; + let text = zeroize::Zeroizing::new(std::str::from_utf8(&bytes)?.to_owned()); + if matches!(action, AuthorizedAction::Unprotect) { + let binding = vault_for_work.seal_for_storage(text.as_bytes())?.ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; + store_for_work.compare_exchange_unprotection(id, ¤t.content, &text, &binding.vault_envelope).await?; + return Ok(AuthorizedResult::Unprotected); + } + match action { + AuthorizedAction::Protect | AuthorizedAction::Unprotect => unreachable!("protection returns before decryption"), + AuthorizedAction::Use(copy_only) => { + Ok::<_, anyhow::Error>(AuthorizedResult::Use(current, text, copy_only)) + } + AuthorizedAction::Edit => Ok(AuthorizedResult::Edit(current, text, vault_for_work.clone())), + } + }) + }).await; + let result = match result { + Ok(AuthorizedResult::Use(item, text, copy_only)) => { + let hidden = if copy_only { Ok(()) } else { + entity.update_in(cx, |_, window, _| hide_chooser(window)).and_then(|r| r) + }; + match hidden { + Err(err) => Err(err), + Ok(()) => { + let store = store.clone(); + cx.background_executor().spawn(async move { + dispatch_content(&filter, &text, destination, copy_only, true)?; + futures::executor::block_on(store.mark_used(item.id, now_unix_millis()))?; + Ok::<_, anyhow::Error>(AuthorizedResult::Use(item, text, copy_only)) + }).await + } + } + } + other => other, + }; + // Creation can succeed before protection fails. Check persistence before + // deciding whether retry should create or unlock; never replace a vault. + let still_unconfigured = if creating { + let vault = vault.clone(); + cx.background_executor().spawn(async move { + matches!(futures::executor::block_on(vault.envelope()), Ok(None)) + }).await + } else { false }; + // One-shot use; only the protected editor retains a scoped key. + let editor_result = matches!(result, Ok(AuthorizedResult::Edit(..))); + if !editor_result { vault.lock(); } + let update = entity.update_in(cx, |this, window, cx| { + this.busy = false; + // Creation may succeed even when subsequent protection fails. Never replace it on retry. + if creating { this.creating_vault = still_unconfigured; } + match result { + Ok(AuthorizedResult::Unprotected) => { + this.cancel_prompt(window, cx); + this.status_error = false; + this.status_warning = true; + this.status = "Protection removed. This item is now stored as plain text.".into(); + this.refresh(window, cx); + } + Ok(AuthorizedResult::Protected) => { + this.cancel_prompt(window, cx); + this.status_error = false; this.status = "Item protected. Collection is paused to prevent recapturing it.".into(); + // Erase the stale chooser snapshot before the asynchronous refresh. + for row in this.rows.iter_mut().filter(|row| row.id == id) { + row.content.clear(); row.plain_preview = None; + row.kind = ClipboardItemKind::Protected; row.retention.protected_ = true; + } + this.refresh(window, cx); + } + Ok(AuthorizedResult::Use(..)) => { this.cancel_prompt(window, cx); clear_popup(); window.remove_window(); } + Ok(AuthorizedResult::Edit(item, text, vault)) => { + window.close_dialog(cx); + this.prompt = None; this.password_error = None; + this.password.update(cx, |p, cx| p.set_value("", window, cx)); + this.editor_open = true; + let parent = cx.weak_entity(); + if editor::open(window, cx, this.store.clone(), item, parent, Some((text, vault.clone()))).is_err() { this.editor_open = false; vault.lock(); this.status_error = true; this.status = "Could not open the encrypted editor.".into(); } + } + Err(err) => { + show_chooser(window); + this.password_error = Some(if err.to_string().contains("No master password") { + "Set up your master password first: rusticker cp vault create." + } else if err.to_string() == "Wrong master password" { + "Incorrect master password. Try again." + } else if matches!(action, AuthorizedAction::Protect) { + "Could not protect this item. If setup succeeded, use your new master password. Refresh history if the item changed." + } else { + "Could not unlock or use this item. Check your password, or cancel and try Copy only." + }.into()); + window.activate_window(); this.password.update(cx, |p, cx| p.focus(window, cx)); + } + } + cx.notify(); + }); + if update.is_err() && editor_result { vault.lock(); } + }).detach(); + } + fn toggle_capture(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + self.status.clear(); + self.status_warning = false; + self.busy = true; + let store = self.store.clone(); + cx.spawn_in(window, async move |entity, cx| { + let result = async { + let mut settings = store.settings().await?; + settings.capture_enabled = !settings.capture_enabled; + store.update_settings(&settings).await?; + Ok::<_, anyhow::Error>(settings.capture_enabled) + } + .await; + let _ = entity.update_in(cx, |this, _, cx| { + this.busy = false; + this.status_warning = false; + this.status_error = result.is_err(); + match result { + Ok(enabled) => { + this.capture_enabled = enabled; + this.status_warning = enabled; + this.status = if enabled { + "Collection enabled. Copied text, including secrets, will be saved." + } else { + "Collection paused. Your existing history is still available." + } + .into(); + } + Err(_) => { + this.status = + "Could not update collection. Your previous setting is unchanged." + .into() + } + } + cx.notify(); + }); + }) + .detach(); + } + fn remove_selected(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy || self.prompt.is_some() || self.editor_open { + return; + } + let Some(item) = self.selected_item() else { + return; + }; + self.busy = true; + let store = self.store.clone(); + cx.spawn_in(window, async move |entity, cx| { + let result = store.delete_row(item.id).await; + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + this.status_error = result.is_err(); + this.status_warning = false; + this.status = if result.is_ok() { + "Item removed." + } else { + "Could not remove this item. Try again." + } + .into(); + this.refresh(window, cx); + }); + }) + .detach(); + } + fn row(&self, index: usize, cx: &mut Context) -> gpui::AnyElement { + let item = &self.rows[self.filtered[index]]; + let pinned = item.retention.explicit(); + let locked = protected(item); + let disabled = self.busy || self.prompt.is_some() || self.editor_open; + let hovered = self.hovered_row == Some(item.id); + let id = item.id; + let actions = h_flex() + .gap_0() + .flex_shrink_0() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .when(!locked && hovered, |actions| { + actions.child( + div().when(!hovered, |el| el.hidden()).child( + Button::new(("row-pin", item.id as usize)) + .icon(IconName::Pin) + .ghost() + .small() + .selected(pinned) + .disabled(disabled) + .tooltip(if pinned { + "Unpin · Ctrl/Cmd+R" + } else { + "Pin · Ctrl/Cmd+R" + }) + .on_click(cx.listener(move |this, _, w, cx| { + cx.stop_propagation(); + this.selected = index; + this.reserve(w, cx); + })), + ), + ) + }) + .child( + div().when(!hovered, |el| el.hidden()).child( + Button::new(("row-copy", item.id as usize)) + .icon(IconName::Copy) + .ghost() + .small() + .disabled(disabled) + .tooltip("Copy only · Ctrl/Cmd+Enter") + .on_click(cx.listener(move |this, _, w, cx| { + cx.stop_propagation(); + this.selected = index; + this.use_selected(true, w, cx); + })), + ), + ) + .child( + div() + .when(!locked, |el| el.when(!hovered, |el| el.hidden())) + .child( + Button::new(("row-lock", item.id as usize)) + .icon(if locked { + IconName::LockClosed + } else { + IconName::LockOpen + }) + .ghost() + .small() + .selected(locked) + .disabled(disabled) + .tooltip(if locked { + "Remove protection · master password required · Ctrl/Cmd+L" + } else { + "Protect with a public label · Ctrl/Cmd+L" + }) + .on_click(cx.listener(move |this, _, w, cx| { + cx.stop_propagation(); + this.selected = index; + this.protect_selected(w, cx); + })), + ), + ) + .child( + div().when(!hovered, |el| el.hidden()).child( + Button::new(("row-remove", item.id as usize)) + .icon(IconName::Delete) + .ghost() + .small() + .disabled(disabled) + .tooltip("Delete this item from clipboard history") + .on_click(cx.listener(move |this, _, w, cx| { + cx.stop_propagation(); + this.selected = index; + this.remove_selected(w, cx); + })), + ), + ); + h_flex() + .id(("clipboard-row", item.id as usize)) + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + if *hovered { + this.hovered_row = Some(id); + } else if this.hovered_row == Some(id) { + this.hovered_row = None; + } + cx.notify(); + })) + .w_full() + .cursor_pointer() + .items_center() + .h(px(44.0)) + .px_2() + .gap_1() + .rounded_md() + .border_1() + .border_color(if index == self.selected { + cx.theme().primary + } else { + rgba(0x00000000).into() + }) + .bg(if index == self.selected { + rgba(0xe5c23664) + } else { + rgba(0x00000000) + }) + .hover(|row| row.bg(rgba(0xe5c23632))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + if this.busy || this.prompt.is_some() || this.editor_open { + return; + } + this.selected = index; + this.query.update(cx, |q, cx| q.focus(window, cx)); + if event.click_count == 2 { + this.edit(window, cx); + } + cx.notify(); + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .truncate() + .child(display_preview(item)), + ) + .when(hovered || locked, |row| row.child(actions)) + .into_any_element() + } +} +impl Focusable for ClipboardChooserWidget { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.query.read(cx).focus_handle(cx) + } +} +impl Render for ClipboardChooserWidget { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let count = self.filtered.len(); + let disabled = self.busy || self.prompt.is_some() || self.editor_open; + let has_protected = self.rows.iter().any(protected); + let collector_label = if !self.capture_enabled { + "Collection paused" + } else if has_protected { + "Collection paused · protected history" + } else { + "Collecting" + }; + v_flex() + .relative() + .size_full() + .rounded_lg() + .overflow_hidden() + .bg(black().opacity(0.90)) + .text_color(cx.theme().foreground) + .child( + h_flex() + .flex_shrink_0() + .items_center() + .gap_2() + .px_3() + .py_2() + .window_control_area(WindowControlArea::Drag) + .child(div().flex_1().text_sm().child("Clipboard history")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{count} items")), + ) + .child( + Button::new("refresh") + .icon(IconName::Refresh) + .ghost() + .small() + .tooltip("Refresh history") + .occlude() + .disabled(disabled) + .on_click(cx.listener(|this, _, w, cx| this.refresh(w, cx))), + ) + .child( + Button::new("close-clipboard") + .icon(IconName::Close) + .ghost() + .small() + .tooltip("Close · Esc") + .occlude() + .disabled(disabled) + .on_click(cx.listener(|_, _, window, _| { + clear_popup(); + window.remove_window(); + })), + ), + ) + .child( + div().px_3().pb_2().flex_shrink_0().child( + Input::new(&self.query) + .cleanable(true) + .prefix(Icon::new(IconName::Search)) + .disabled(disabled) + .w_full(), + ), + ) + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .px_2() + .overflow_hidden() + .when(count > 0, |el| { + el.child( + uniform_list("clipboard-history", count, move |range, _, cx| { + view.update(cx, |this, cx| { + range.map(|index| this.row(index, cx)).collect::>() + }) + }) + .size_full() + .track_scroll(&self.scroll), + ) + }) + .when(count == 0, |el| { + el.child( + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .px_4() + .child(Icon::new(IconName::DocumentText).with_size(px(24.0))) + .child(div().text_sm().child(if self.loading { + "Loading history…" + } else if self.rows.is_empty() { + "Your copied text will appear here" + } else { + "No matching items" + })) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(if self.rows.is_empty() { + "Enable collection below to get started." + } else { + "Try a different search or clear the filter." + }), + ), + ) + }), + ) + .child( + v_flex() + .flex_shrink_0() + .gap_2() + .px_3() + .py_2() + .border_t_1() + .border_color(cx.theme().border) + .child( + h_flex() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("↑↓ · Enter paste · F2 edit · Esc"), + ) + .child( + Button::new("toggle-collection") + .label(if self.capture_enabled { + "Pause" + } else { + "Collect" + }) + .icon(if self.capture_enabled { + IconName::Pause + } else { + IconName::Play + }) + .ghost() + .small() + .tooltip(if self.capture_enabled { + collector_label + } else { + "Enable collection. Copied secrets can enter history." + }) + .disabled(disabled) + .on_click( + cx.listener(|this, _, w, cx| this.toggle_capture(w, cx)), + ), + ), + ) + .when(!self.status.is_empty() && self.prompt.is_none(), |footer| { + footer.child(if self.status_error { + Alert::error("clipboard-status", self.status.clone()).small() + } else if self.status_warning { + Alert::warning("clipboard-status", self.status.clone()).small() + } else { + Alert::info("clipboard-status", self.status.clone()).small() + }) + }), + ) + } +} + +fn dispatch_content( + filter: &Arc, + content: &str, + destination: Option, + copy_only: bool, + secret: bool, +) -> anyhow::Result<()> { + if secret { + anyhow::ensure!( + cfg!(target_os = "windows"), + "Ownership-aware protected clipboard expiry is only available on Windows" + ); + } + if !copy_only { + crate::native::clipboard::paste::wait_for_modifiers()?; + foreground(destination)?; + } + let generation = + crate::native::clipboard::watcher::write_clipboard_text_owned(filter, content)?; + if secret { + let filter = filter.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(30)); + let _ = + crate::native::clipboard::watcher::clear_protected_if_owned(&filter, generation); + }); + } + if !copy_only { + paste_foreground(destination)?; + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn hide_chooser(window: &Window) -> anyhow::Result<()> { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let RawWindowHandle::Win32(handle) = HasWindowHandle::window_handle(window)?.as_raw() else { + anyhow::bail!("No native chooser handle"); + }; + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( + handle.hwnd.get() as _, + windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, + ); + } + super::set_clipboard_escape_target(false); + Ok(()) +} +#[cfg(not(target_os = "windows"))] +fn hide_chooser(_: &Window) -> anyhow::Result<()> { + anyhow::bail!("Verified paste unavailable; use Copy only") +} +#[cfg(target_os = "windows")] +fn show_chooser(window: &Window) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + if let Ok(handle) = HasWindowHandle::window_handle(window) + && let RawWindowHandle::Win32(handle) = handle.as_raw() + { + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( + handle.hwnd.get() as _, + windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW, + ); + } + } +} +#[cfg(not(target_os = "windows"))] +fn show_chooser(_: &Window) {} + +#[cfg(test)] +mod tests { + use super::*; + fn item() -> ClipboardItem { + ClipboardItem { + id: 1, + kind: ClipboardItemKind::Plain, + content: "secret\n\t\u{1b}".into(), + plain_preview: Some("secret".into()), + public_label: None, + byte_size: 9, + created_at: 0, + last_copied_at: 0, + last_used_at: None, + last_edited_at: None, + retention: Default::default(), + } + } + #[test] + fn clipboard_action_icons_are_embedded() { + use gpui_component::IconNamed; + for icon in [IconName::Copy, IconName::Delete] { + assert!(crate::native::components::Assets::get(icon.path().as_ref()).is_some()); + } + } + #[test] + fn vault_setup_requires_matching_passwords() { + assert!(validate_new_password("", "").is_err()); + assert!(validate_new_password("a", "a").is_ok()); + assert!(validate_new_password(" ", " ").is_ok()); + assert!(validate_new_password("long-password", "different-password").is_err()); + assert!(validate_new_password("long-password", "long-password").is_ok()); + } + #[test] + fn protected_preview_never_falls_back_to_content() { + let mut row = item(); + row.kind = ClipboardItemKind::Protected; + assert_eq!(display_preview(&row), "Protected item"); + redact_protected_previews(std::slice::from_mut(&mut row)); + assert!(row.content.is_empty()); + assert!(row.plain_preview.is_none()); + } + #[test] + fn legacy_protection_flag_is_fail_closed() { + let mut row = item(); + row.retention.protected_ = true; + assert!(protected(&row)); + assert_eq!(display_preview(&row), "Protected item"); + } + #[test] + fn previews_escape_controls_without_changing_payload() { + let row = item(); + assert!(!display_preview(&row).chars().any(char::is_control)); + assert!(row.content.contains('\n')); + } +} diff --git a/src/native/windows/clipboard/editor.rs b/src/native/windows/clipboard/editor.rs new file mode 100644 index 0000000..98d51b7 --- /dev/null +++ b/src/native/windows/clipboard/editor.rs @@ -0,0 +1,294 @@ +//! In-history modal editor with atomic saves and scoped protected authorization. +use super::{ClipboardChooserWidget, protected}; +use crate::clipboard::{items::ClipboardItem, storage::ClipboardHistoryStore}; + +use gpui::{ + App, Context, Entity, Focusable, IntoElement, Render, Subscription, WeakEntity, Window, black, + div, prelude::*, px, +}; +use gpui_component::{ + ActiveTheme, Disableable, Sizable, WindowExt, + alert::Alert, + button::{Button, ButtonVariants}, + h_flex, + input::{Textarea, TextareaState}, + v_flex, +}; +use std::sync::Arc; + +pub(super) fn open( + window: &mut Window, + cx: &mut App, + store: Arc, + initial: ClipboardItem, + parent: WeakEntity, + authorized: Option<( + zeroize::Zeroizing, + Arc, + )>, +) -> anyhow::Result<()> { + anyhow::ensure!( + !protected(&initial) || authorized.is_some(), + "Encrypted editing requires authorization" + ); + let view = cx.new(|cx| ClipboardEditor::new(window, cx, store, initial, parent, authorized)); + let focus = view.read(cx).focus_handle(cx); + window.open_dialog(cx, move |dialog, window, cx| { + dialog + .title( + h_flex() + .w_full() + .items_center() + .child(div().flex_1().child("Edit clipboard text")) + .child( + Button::new("close-clipboard-editor") + .icon(crate::native::components::IconName::Close) + .ghost() + .small() + .tooltip("Close · Esc") + .disabled(view.read(cx).saving) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| this.cancel(window, cx)); + } + }), + ), + ) + .w(window.viewport_size().width.min(px(500.0)) - px(32.0)) + .bg(black().opacity(0.95)) + .text_sm() + .overlay_closable(false) + .keyboard(false) + .close_button(false) + .child(view.clone()) + }); + focus.focus(window, cx); + Ok(()) +} +struct ClipboardEditor { + store: Arc, + initial: ClipboardItem, + content: Entity, + parent: WeakEntity, + original_plain: zeroize::Zeroizing, + vault: Option>, + saving: bool, + confirm_discard: bool, + error: Option, + _keys: Subscription, +} +impl ClipboardEditor { + fn new( + window: &mut Window, + cx: &mut Context, + store: Arc, + initial: ClipboardItem, + parent: WeakEntity, + authorized: Option<( + zeroize::Zeroizing, + Arc, + )>, + ) -> Self { + let (original_plain, vault) = match authorized { + Some((text, vault)) => (text, Some(vault)), + None => (zeroize::Zeroizing::new(initial.content.clone()), None), + }; + let content = cx.new(|cx| { + let mut text = TextareaState::new(window, cx); + text.set_value(original_plain.to_string(), window, cx); + text + }); + let own_id = window.window_handle().window_id(); + let entity = cx.weak_entity(); + let keys = cx.intercept_keystrokes(move |event, window, cx| { + if window.window_handle().window_id() != own_id || !window.is_window_active() { + return; + } + let primary = if cfg!(target_os = "macos") { + event.keystroke.modifiers.platform || event.keystroke.modifiers.control + } else { + event.keystroke.modifiers.control + }; + let Some(entity) = entity.upgrade() else { + return; + }; + let handled = match event.keystroke.key.as_str() { + "s" if primary => { + entity.update(cx, |this, cx| this.save(window, cx)); + true + } + "escape" => { + entity.update(cx, |this, cx| this.cancel(window, cx)); + true + } + _ => false, + }; + if handled { + cx.stop_propagation(); + window.prevent_default(); + } + }); + content.update(cx, |text, cx| text.focus(window, cx)); + let protected_editor = vault.is_some(); + if protected_editor { + cx.spawn_in(window, async move |entity, cx| { + cx.background_executor() + .timer(std::time::Duration::from_secs(300)) + .await; + let _ = entity.update_in(cx, |this, window, cx| this.close(window, cx)); + }) + .detach(); + } + Self { + store, + initial, + content, + parent, + original_plain, + vault, + saving: false, + confirm_discard: false, + error: None, + _keys: keys, + } + } + fn close(&mut self, window: &mut Window, cx: &mut Context) { + self.content + .update(cx, |text, cx| text.set_value("", window, cx)); + if let Some(vault) = &self.vault { + vault.lock(); + } + self.original_plain.clear(); + window.close_dialog(cx); + let _ = self.parent.update(cx, |parent, cx| { + parent.editor_open = false; + parent.query.update(cx, |q, cx| q.focus(window, cx)); + cx.notify(); + }); + } + fn cancel(&mut self, window: &mut Window, cx: &mut Context) { + if self.saving { + return; + } + if self.content.read(cx).value().as_str() != self.original_plain.as_str() + && !self.confirm_discard + { + self.confirm_discard = true; + self.error = Some( + "Unsaved changes. Press Esc again or Cancel to discard; Save keeps them.".into(), + ); + cx.notify(); + return; + } + self.close(window, cx); + } + fn save(&mut self, window: &mut Window, cx: &mut Context) { + if self.saving { + return; + } + let text = zeroize::Zeroizing::new(self.content.read(cx).value().to_string()); + if *text == *self.original_plain { + self.close(window, cx); + return; + } + self.saving = true; + self.error = None; + cx.notify(); + let store = self.store.clone(); + let initial = self.initial.clone(); + let parent = self.parent.clone(); + let vault = self.vault.clone(); + cx.spawn_in(window, async move |entity, cx| { + let result = async { + if let Some(vault) = vault { + let sealed = vault.seal_for_storage(text.as_bytes())?.ok_or_else(|| anyhow::anyhow!("Authorization expired; reopen to unlock"))?; + store.compare_exchange_protected_content_bound(initial.id, &initial.content, &sealed.ciphertext, text.len() as i64, crate::utils::time::now_unix_millis(), &sealed.vault_envelope).await + } else { + // Compare-and-swap is atomic with edited retention and rejects protection/deletion races. + store.compare_exchange_content(initial.id, &initial.content, &text, text.len() as i64, crate::utils::time::now_unix_millis()).await + } + }.await; + let _ = entity.update_in(cx, |this, window, cx| { + this.saving = false; + match result { + Ok(()) => { + this.close(window, cx); + let _ = parent.update(cx, |this, cx| this.refresh(window, cx)); } + Err(_) => { this.error = Some("Save failed or the item changed/deleted. Your draft is still here; retry or cancel.".into()); cx.notify(); } + } + }); + }).detach(); + } +} +impl Focusable for ClipboardEditor { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.content.read(cx).focus_handle(cx) + } +} +impl Render for ClipboardEditor { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_2() + .bg(black().opacity(0.90)) + .text_color(cx.theme().foreground) + .child( + div() + .h(px(200.0)) + .min_h_0() + .child(Textarea::new(&self.content).size_full()), + ) + .when(self.error.is_some(), |body| { + body.child(if self.confirm_discard { + Alert::warning( + "clipboard-editor-error", + self.error.clone().unwrap_or_default(), + ) + .small() + } else { + Alert::error( + "clipboard-editor-error", + self.error.clone().unwrap_or_default(), + ) + .small() + }) + }) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .flex_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Ctrl/Cmd+S save · Esc cancel"), + ) + .child( + Button::new("save-clipboard-edit") + .label("Save") + .primary() + .small() + .loading(self.saving) + .disabled(self.saving) + .on_click(cx.listener(|this, _, w, cx| this.save(w, cx))), + ) + .child( + Button::new("cancel-clipboard-edit") + .label("Cancel") + .small() + .disabled(self.saving) + .on_click(cx.listener(|this, _, w, cx| this.cancel(w, cx))), + ), + ) + } +} + +impl Drop for ClipboardEditor { + fn drop(&mut self) { + if let Some(vault) = &self.vault { + vault.lock(); + } + } +} diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs index 297c6c2..28f7cbc 100644 --- a/src/native/windows/main.rs +++ b/src/native/windows/main.rs @@ -462,6 +462,22 @@ impl MainWindow { this.create_sticker(cx, &StickerType::Paint); })), ) + .separator() + .item( + PopupMenuItem::new("Clipboard history") + .icon(IconName::DocumentText) + .on_click(window.listener_for(&root_entity, |this, _, _, cx| { + if super::clipboard::open_from_main( + cx, + this.sticker_events_sender.clone(), + ) + .is_err() + { + this.error = Some("Clipboard backend is unavailable".into()); + cx.notify(); + } + })), + ) }) .into_any_element() } diff --git a/src/native/windows/mod.rs b/src/native/windows/mod.rs index 077ff2a..6556c6f 100644 --- a/src/native/windows/mod.rs +++ b/src/native/windows/mod.rs @@ -1,6 +1,7 @@ use crate::model::sticker::StickerColor; use std::sync::RwLock; +pub mod clipboard; pub mod main; pub mod placement; pub mod selection; @@ -11,6 +12,7 @@ mod transient_topmost; pub(crate) enum EscapeDismissTarget { Selection, Sticker(i64), + Clipboard, } static ACTIVE_ESCAPE_DISMISS_TARGET: RwLock> = RwLock::new(None); @@ -25,6 +27,11 @@ pub(crate) fn set_escape_dismiss_target_active(target: EscapeDismissTarget, acti } } +/// Toggle whether the global escape dispatch should target the clipboard chooser. +pub(crate) fn set_clipboard_escape_target(active: bool) { + set_escape_dismiss_target_active(EscapeDismissTarget::Clipboard, active); +} + pub fn has_escape_dismiss_target() -> bool { ACTIVE_ESCAPE_DISMISS_TARGET .read() @@ -41,6 +48,7 @@ pub fn close_active_escape_target(cx: &mut gpui::App) -> bool { Some(EscapeDismissTarget::Sticker(open_id)) => { sticker::StickerWindow::dispatch_escape(open_id, cx) } + Some(EscapeDismissTarget::Clipboard) => clipboard::dispatch_escape(cx), None => false, } } diff --git a/src/rusticker.rs b/src/rusticker.rs index d6f092a..1b5c759 100644 --- a/src/rusticker.rs +++ b/src/rusticker.rs @@ -20,6 +20,9 @@ fn main() { let format = cli.format(); if let Err(err) = cli::run(cli, &app_paths) { + if let Some(code) = err.downcast_ref::() { + std::process::exit(code.0); + } format.emit_error(&err); std::process::exit(1); } diff --git a/tests/clipboard_storage.rs b/tests/clipboard_storage.rs new file mode 100644 index 0000000..c13bd03 --- /dev/null +++ b/tests/clipboard_storage.rs @@ -0,0 +1,770 @@ +//! Integration tests against the real SQLite-backed clipboard store. +//! +//! The CLI and GUI both go through this storage layer, so verifying it as an isolated contract +//! gives us the confidence to plug the future GUI chooser on top without revalidating the +//! ordering/dedupe invariants. + +#![cfg(feature = "storage")] + +use futures::executor::block_on; +use rustickers::clipboard::items::{ClipboardItemKind, RetentionState}; +use rustickers::clipboard::ordering::sort_in_place; +use rustickers::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore}; + +#[test] +fn insert_then_snapshot_round_trips() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open"); + + let first = block_on(store.upsert_ordinary( + "alpha content", + "alpha content", + &SqliteClipboardStore::ordinary_digest("alpha content"), + 13, + 1_700_000_000_000, + )) + .expect("insert first"); + let id_first = match first { + rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, + _ => panic!("first insert should create"), + }; + + let second = block_on(store.upsert_ordinary( + "beta content", + "beta content", + &SqliteClipboardStore::ordinary_digest("beta content"), + 12, + 1_700_000_001_000, + )) + .expect("insert second"); + let id_second = match second { + rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, + _ => panic!("second insert should create"), + }; + + let snapshot = block_on(store.snapshot()).expect("snapshot"); + assert_eq!(snapshot.len(), 2); + // MRU sort: bigger timestamp first; ties fall back to created_at + id, both deterministic. + assert_eq!(snapshot[0].id, id_second); + assert_eq!(snapshot[1].id, id_first); + assert_eq!(snapshot[0].kind, ClipboardItemKind::Plain); +} + +#[test] +fn digest_dedupe_returns_existing_row() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open"); + + let digest = SqliteClipboardStore::ordinary_digest("dup"); + let id1 = match block_on(store.upsert_ordinary("dup", "dup", &digest, 3, 1_700_000_000_000)) + .expect("first") + { + rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, + other => panic!("expected Inserted, got {other:?}"), + }; + + let id2 = match block_on(store.upsert_ordinary("dup", "dup", &digest, 3, 1_700_000_010_000)) + .expect("second") + { + rustickers::clipboard::storage::UpsertOutcome::Updated { id } => id, + other => panic!("expected Updated, got {other:?}"), + }; + assert_eq!(id1, id2); + + let snapshot = block_on(store.snapshot()).expect("snapshot"); + assert_eq!(snapshot.len(), 1, "no parallel rows for identical content"); + assert_eq!( + snapshot[0].last_copied_at, 1_700_000_010_000, + "duplicate copy bumps recency timestamp" + ); +} + +#[test] +fn setting_retention_flags_protects_against_evict() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open"); + + let id = match block_on(store.upsert_ordinary( + "pinned", + "pinned", + &SqliteClipboardStore::ordinary_digest("pinned"), + "pinned".len() as i64, + 1_700_000_000_000, + )) + .expect("insert") + { + rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, + _ => panic!("insert"), + }; + + block_on(store.set_retention_flag( + id, + rustickers::clipboard::storage::RetentionFlag::Explicit, + true, + )) + .expect("flag"); + + let snapshot = block_on(store.snapshot()).expect("snapshot"); + assert!(snapshot[0].retention.explicit); + assert!(snapshot[0].retention.is_retained()); + assert_eq!( + snapshot[0].retention, + RetentionState { + explicit: true, + edited: false, + protected_: false + } + ); +} + +#[test] +fn filter_indices_match_against_plain_preview() { + use rustickers::clipboard::filter::filter_indices; + let snapshot = vec![ + make_row(1, "the quick brown fox", Some("the quick brown fox")), + make_row(2, "another line", Some("another line")), + ]; + let hits = filter_indices(&snapshot, "fox"); + assert_eq!(hits, vec![0]); +} + +#[test] +fn sort_in_place_is_stable_across_modules() { + let mut snapshot = Vec::new(); + snapshot.push(make_row(3, "z", Some("z"))); + snapshot.push(make_row(1, "a", Some("a"))); + snapshot.push(make_row(2, "m", Some("m"))); + sort_in_place(&mut snapshot); + let ids: Vec = snapshot.iter().map(|row| row.id).collect(); + // Larger recency wins; helper makes id=1 newest, id=3 oldest: [1,2,3]. + assert_eq!(ids, vec![1, 2, 3]); +} + +fn make_row( + id: i64, + content: &str, + preview: Option<&str>, +) -> rustickers::clipboard::items::ClipboardItem { + let now = chrono::Utc::now().timestamp_millis(); + rustickers::clipboard::items::ClipboardItem { + id, + kind: ClipboardItemKind::Plain, + content: content.to_string(), + plain_preview: preview.map(|p| p.to_owned()), + public_label: None, + byte_size: content.len() as i64, + created_at: now - id, + last_copied_at: now - id, + last_used_at: None, + last_edited_at: None, + retention: RetentionState::default(), + } +} + +fn inserted_id(outcome: rustickers::clipboard::storage::UpsertOutcome) -> i64 { + match outcome { + rustickers::clipboard::storage::UpsertOutcome::Inserted { id } + | rustickers::clipboard::storage::UpsertOutcome::Updated { id } => id, + } +} + +fn insert(store: &SqliteClipboardStore, text: &str, time: i64) -> i64 { + inserted_id( + block_on(store.upsert_ordinary( + text, + text, + &SqliteClipboardStore::ordinary_digest(text), + text.len() as i64, + time, + )) + .unwrap(), + ) +} + +#[test] +fn dedupe_preserves_exact_case_whitespace_and_edited_identity() { + let tmp = tempfile::tempdir().unwrap(); + let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); + let id = insert(&store, "Alpha", 10); + assert_ne!(id, insert(&store, "alpha", 11)); + assert_ne!(id, insert(&store, " Alpha ", 12)); + block_on(store.replace_content(id, "edited", 6, 13)).unwrap(); + assert_eq!(id, insert(&store, "edited", 14)); + assert_ne!(id, insert(&store, "Alpha", 15)); + assert_eq!(block_on(store.get(id)).unwrap().content, "edited"); +} + +#[test] +fn no_op_edit_does_not_retain_and_deleted_edit_fails() { + let tmp = tempfile::tempdir().unwrap(); + let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); + let id = insert(&store, "same", 10); + block_on(store.replace_content(id, "same", 4, 20)).unwrap(); + let row = block_on(store.get(id)).unwrap(); + assert!(!row.retention.edited); + assert_eq!(row.last_edited_at, None); + block_on(store.delete_row(id)).unwrap(); + assert!(block_on(store.replace_content(id, "lost", 4, 30)).is_err()); +} + +#[test] +fn concurrent_connections_dedupe_and_prune_revalidates_saved_rows() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("clip.db"); + let a = block_on(SqliteClipboardStore::open(&path)).unwrap(); + let b = block_on(SqliteClipboardStore::open(&path)).unwrap(); + let worker = std::thread::spawn(move || insert(&b, "duplicate", 20)); + let id = insert(&a, "duplicate", 10); + assert_eq!(id, worker.join().unwrap()); + assert_eq!(block_on(a.get(id)).unwrap().last_copied_at, 20); + block_on(a.replace_content(id, "saved", 5, 30)).unwrap(); + block_on(a.delete_ordinary_by_ids(&[id])).unwrap(); + assert!(block_on(a.get(id)).unwrap().retention.edited); +} + +#[test] +fn protected_storage_never_exposes_plaintext_and_cannot_orphan_vault() { + use rustickers::clipboard::storage::RetentionFlag; + use rustickers::clipboard::vault_controller::VaultController; + use std::sync::Arc; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("clip.db"); + let store = Arc::new(block_on(SqliteClipboardStore::open(&path)).unwrap()); + let controller = VaultController::new(store.clone()); + block_on(controller.create("password")).unwrap(); + assert!(block_on(controller.unlock("password")).unwrap()); + let vault_envelope = controller + .seal_for_storage(b"generation probe") + .unwrap() + .unwrap() + .vault_envelope; + let secret = "regression-secret-7791"; + let sealed = controller.seal(secret.as_bytes()).unwrap().unwrap(); + assert!( + block_on(store.upsert_protected_bound( + None, + Some(secret), + None, + &sealed, + 22, + 10, + &vault_envelope + )) + .is_err() + ); + let id = inserted_id( + block_on(store.upsert_protected_bound( + Some("alias"), + None, + Some("keyed-token"), + &sealed, + 22, + 10, + &vault_envelope, + )) + .unwrap(), + ); + let sealed_again = controller.seal(secret.as_bytes()).unwrap().unwrap(); + assert_eq!( + id, + inserted_id( + block_on(store.upsert_protected_bound( + None, + None, + Some("keyed-token"), + &sealed_again, + 22, + 20, + &vault_envelope + )) + .unwrap() + ) + ); + let row = block_on(store.get(id)).unwrap(); + assert_eq!(row.kind, ClipboardItemKind::Protected); + assert_eq!(row.content, sealed); + assert_eq!(row.plain_preview, None); + assert_eq!(row.searchable_string(), "alias\n"); + assert_eq!( + controller.open(&row.content).unwrap().unwrap().as_slice(), + secret.as_bytes() + ); + assert!(block_on(store.replace_content(id, secret, secret.len() as i64, 30)).is_err()); + assert!(block_on(store.set_retention_flag(id, RetentionFlag::Protected, false)).is_err()); + block_on(store.set_retention_flag(id, RetentionFlag::Explicit, false)).unwrap(); + block_on(store.delete_ordinary_by_ids(&[id])).unwrap(); + assert!(block_on(store.get(id)).unwrap().retention.protected_); + assert!(block_on(controller.create("replacement")).is_err()); + assert!(block_on(controller.disable()).is_err()); + assert!( + block_on(store.upsert_ordinary( + secret, + secret, + &SqliteClipboardStore::ordinary_digest(secret), + secret.len() as i64, + 30 + )) + .is_err() + ); + block_on(controller.rewrap("new-password")).unwrap(); + assert!(block_on(controller.unlock("new-password")).unwrap()); + assert_eq!( + controller.open(&sealed).unwrap().unwrap().as_slice(), + secret.as_bytes() + ); + controller.lock(); + assert!(controller.open(&sealed).unwrap().is_none()); + // Check persisted columns, not just the redacted snapshot. + let pool = block_on(sqlx::SqlitePool::connect_with( + sqlx::sqlite::SqliteConnectOptions::new().filename(&path), + )) + .unwrap(); + let raw: (String, Option, Option) = block_on( + sqlx::query_as( + "SELECT content, plain_preview, plaintext_digest FROM clipboard_items WHERE id = ?1", + ) + .bind(id) + .fetch_one(&pool), + ) + .unwrap(); + assert_eq!(raw, (String::new(), None, None)); + assert!( + !std::fs::read(path) + .unwrap() + .windows(secret.len()) + .any(|window| window == secret.as_bytes()) + ); +} + +#[test] +fn stale_password_change_cannot_overwrite_new_envelope() { + use rustickers::clipboard::vault_controller::VaultController; + use std::sync::Arc; + let tmp = tempfile::tempdir().unwrap(); + let store = Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); + let a = VaultController::new(store.clone()); + let b = VaultController::new(store.clone()); + block_on(a.create("old")).unwrap(); + assert!(block_on(a.unlock("old")).unwrap()); + assert!(block_on(b.unlock("old")).unwrap()); + block_on(a.rewrap("first")).unwrap(); + assert!(block_on(b.rewrap("stale")).is_err()); + assert!(block_on(a.unlock("first")).unwrap()); +} + +#[test] +fn stale_editor_save_fails_without_overwriting_newer_content() { + let tmp = tempfile::tempdir().unwrap(); + let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); + let id = insert(&store, "original", 10); + block_on(store.compare_exchange_content(id, "original", "first", 5, 20)).unwrap(); + assert!(block_on(store.compare_exchange_content(id, "original", "stale", 5, 30)).is_err()); + assert_eq!(block_on(store.get(id)).unwrap().content, "first"); + block_on(store.delete_row(id)).unwrap(); + assert!(block_on(store.compare_exchange_content(id, "first", "lost", 4, 40)).is_err()); +} + +#[test] +fn protected_display_helpers_ignore_even_damaged_plaintext_metadata() { + let mut row = make_row(1, "secret-plaintext", Some("secret-preview")); + row.kind = ClipboardItemKind::Protected; + assert_eq!(row.preview_text(), "Protected item"); + assert_eq!(row.searchable_string(), ""); + row.public_label = Some("public alias".into()); + assert_eq!(row.preview_text(), "public alias"); + assert_eq!(row.searchable_string(), "public alias\n"); + let preview = rustickers::clipboard::ProtectedPreview { + item: row, + plaintext: "secret-plaintext".into(), + match_token: Some("private-token".into()), + }; + let debug = format!("{preview:?}"); + assert!(!debug.contains("secret")); + assert!(!debug.contains("private-token")); +} + +#[test] +fn ordinary_upsert_and_lowered_limits_trim_atomically_without_touching_retained() { + use rustickers::clipboard::{ClipboardSettings, storage::RetentionFlag}; + let tmp = tempfile::tempdir().unwrap(); + let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); + let saved = insert(&store, "saved", 1); + block_on(store.set_retention_flag(saved, RetentionFlag::Explicit, true)).unwrap(); + let limits = ClipboardSettings { + normal_count_limit: 2, + normal_byte_limit: 8, + capture_enabled: true, + }; + block_on(store.update_settings(&limits)).unwrap(); + let old = insert(&store, "aaaa", 10); + let current = insert(&store, "bbbb", 20); + assert_eq!(block_on(store.snapshot()).unwrap().len(), 3); + assert_eq!(current, insert(&store, "bbbb", 30)); + assert!( + block_on(store.get(old)).is_ok(), + "dedupe must not double count an incoming row" + ); + let newest = insert(&store, "cccc", 40); + assert!(block_on(store.get(old)).is_err()); + assert!(block_on(store.get(saved)).is_ok()); + let smaller = ClipboardSettings { + normal_count_limit: 1, + normal_byte_limit: 4, + capture_enabled: true, + }; + block_on(store.update_settings(&smaller)).unwrap(); + assert!(block_on(store.get(current)).is_err()); + assert!(block_on(store.get(newest)).is_ok()); + assert!(block_on(store.get(saved)).is_ok()); + assert!( + block_on(store.upsert_ordinary( + "oversized", + "oversized", + &SqliteClipboardStore::ordinary_digest("oversized"), + 9, + 50 + )) + .is_err() + ); + assert!( + block_on(store.get(newest)).is_ok(), + "oversized insert must not prune old data" + ); +} + +#[test] +fn protected_editor_cas_keeps_ciphertext_only_and_rejects_stale_save() { + use rustickers::clipboard::vault_controller::VaultController; + use std::sync::Arc; + let tmp = tempfile::tempdir().unwrap(); + let store = Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); + let controller = VaultController::new(store.clone()); + block_on(controller.create("password")).unwrap(); + assert!(block_on(controller.unlock("password")).unwrap()); + let vault_envelope = controller + .seal_for_storage(b"generation probe") + .unwrap() + .unwrap() + .vault_envelope; + let first = controller.seal(b"first-secret").unwrap().unwrap(); + let id = inserted_id( + block_on(store.upsert_protected_bound(None, None, None, &first, 12, 10, &vault_envelope)) + .unwrap(), + ); + let second = controller.seal(b"edited-secret").unwrap().unwrap(); + block_on(store.compare_exchange_protected_content_bound( + id, + &first, + &second, + 13, + 20, + &vault_envelope, + )) + .unwrap(); + assert!( + block_on(store.compare_exchange_protected_content_bound( + id, + &first, + &first, + 12, + 30, + &vault_envelope + )) + .is_err() + ); + let row = block_on(store.get(id)).unwrap(); + assert!(row.retention.edited && row.retention.protected_); + assert!( + !row.retention.explicit, + "protection must not add an unrelated reservation reason" + ); + assert_eq!(row.plain_preview, None); + assert_eq!( + controller.open(&row.content).unwrap().unwrap().as_slice(), + b"edited-secret" + ); +} + +#[test] +fn reset_generation_rejects_stale_insert_and_protection_without_mutation() { + use rustickers::clipboard::vault_controller::VaultController; + use std::sync::Arc; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("clip.db"); + let store = Arc::new(block_on(SqliteClipboardStore::open(&path)).unwrap()); + let other_store = Arc::new(block_on(SqliteClipboardStore::open(&path)).unwrap()); + let stale = VaultController::new(store.clone()); + let resetter = VaultController::new(other_store); + let id = insert(&store, "ordinary", 10); + assert!(!block_on(store.has_protected_items()).unwrap()); + block_on(stale.create("old-password")).unwrap(); + assert!(block_on(stale.unlock("old-password")).unwrap()); + let sealed = stale.seal_for_storage(b"ordinary").unwrap().unwrap(); + block_on(resetter.disable()).unwrap(); + // Reconfiguration happens through a different pool/controller; stale still holds its key. + block_on(resetter.create("new-password")).unwrap(); + assert!( + block_on(store.upsert_protected_bound( + None, + None, + None, + &sealed.ciphertext, + 8, + 20, + &sealed.vault_envelope + )) + .is_err() + ); + assert!( + block_on(store.compare_exchange_protection( + id, + "ordinary", + &sealed.ciphertext, + &sealed.vault_envelope, + None, + )) + .is_err() + ); + assert!(block_on(store.upsert_protected(None, None, None, &sealed.ciphertext, 8, 20)).is_err()); + assert_eq!(block_on(store.get(id)).unwrap().content, "ordinary"); + assert_eq!(block_on(store.snapshot()).unwrap().len(), 1); + assert!(!block_on(store.has_protected_items()).unwrap()); + assert!(block_on(resetter.unlock("new-password")).unwrap()); + let fresh = resetter.seal_for_storage(b"ordinary").unwrap().unwrap(); + block_on(store.compare_exchange_protection( + id, + "ordinary", + &fresh.ciphertext, + &fresh.vault_envelope, + Some(" Saved command "), + )) + .unwrap(); + assert!(block_on(store.has_protected_items()).unwrap()); + assert_eq!( + block_on(store.get(id)).unwrap().public_label.as_deref(), + Some("Saved command") + ); + assert_eq!( + resetter + .open(&block_on(store.get(id)).unwrap().content) + .unwrap() + .unwrap() + .as_slice(), + b"ordinary" + ); +} + +#[test] +fn protection_cas_preserves_identity_retention_and_recency_and_clears_live_plaintext() { + use rustickers::clipboard::{storage::RetentionFlag, vault_controller::VaultController}; + use std::sync::Arc; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("clip.db"); + let store = Arc::new(block_on(SqliteClipboardStore::open(&path)).unwrap()); + let id = insert(&store, "original", 10); + block_on(store.replace_content(id, "saved secret", 12, 20)).unwrap(); + block_on(store.set_retention_flag(id, RetentionFlag::Explicit, true)).unwrap(); + block_on(store.mark_used(id, 30)).unwrap(); + let before = block_on(store.get(id)).unwrap(); + let controller = VaultController::new(store.clone()); + block_on(controller.create("password")).unwrap(); + assert!(block_on(controller.unlock("password")).unwrap()); + let sealed = controller + .seal_for_storage(before.content.as_bytes()) + .unwrap() + .unwrap(); + assert!( + block_on(store.compare_exchange_protection( + id, + "original", + &sealed.ciphertext, + &sealed.vault_envelope, + None, + )) + .is_err() + ); + block_on(store.compare_exchange_protection( + id, + &before.content, + &sealed.ciphertext, + &sealed.vault_envelope, + None, + )) + .unwrap(); + let after = block_on(store.get(id)).unwrap(); + assert_eq!(after.id, before.id); + assert_eq!(after.created_at, before.created_at); + assert_eq!(after.last_copied_at, before.last_copied_at); + assert_eq!(after.last_used_at, before.last_used_at); + assert_eq!(after.last_edited_at, before.last_edited_at); + assert!(after.retention.explicit && after.retention.edited && after.retention.protected_); + assert_eq!(after.byte_size, before.byte_size); + assert_eq!(after.plain_preview, None); + assert_eq!(after.searchable_string(), ""); + assert_eq!( + controller.open(&after.content).unwrap().unwrap().as_slice(), + b"saved secret" + ); + let pool = block_on(sqlx::SqlitePool::connect_with( + sqlx::sqlite::SqliteConnectOptions::new().filename(&path), + )) + .unwrap(); + let raw: (String, Option, Option, Option) = block_on( + sqlx::query_as( + "SELECT content, plain_preview, plaintext_digest, protected_match_token + FROM clipboard_items WHERE id = ?1", + ) + .bind(id) + .fetch_one(&pool), + ) + .unwrap(); + assert_eq!(raw, (String::new(), None, None, None)); + // A stale repeat and a deleted selection cannot mutate or resurrect a protected row. + assert!( + block_on(store.compare_exchange_protection( + id, + &before.content, + &sealed.ciphertext, + &sealed.vault_envelope, + None, + )) + .is_err() + ); + block_on(store.delete_row(id)).unwrap(); + assert!( + block_on(store.compare_exchange_protection( + id, + &before.content, + &sealed.ciphertext, + &sealed.vault_envelope, + None, + )) + .is_err() + ); +} + +#[test] +fn password_rewrap_requires_new_generation_for_protected_edit() { + use rustickers::clipboard::vault_controller::VaultController; + use std::sync::Arc; + let tmp = tempfile::tempdir().unwrap(); + let store = Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); + let controller = VaultController::new(store.clone()); + block_on(controller.create("old")).unwrap(); + assert!(block_on(controller.unlock("old")).unwrap()); + let first = controller.seal_for_storage(b"first").unwrap().unwrap(); + let id = inserted_id( + block_on(store.upsert_protected_bound( + None, + None, + None, + &first.ciphertext, + 5, + 10, + &first.vault_envelope, + )) + .unwrap(), + ); + let stale_edit = controller.seal_for_storage(b"stale").unwrap().unwrap(); + block_on(controller.rewrap("new")).unwrap(); + assert!( + block_on(store.compare_exchange_protected_content_bound( + id, + &first.ciphertext, + &stale_edit.ciphertext, + 5, + 20, + &stale_edit.vault_envelope + )) + .is_err() + ); + assert_eq!(block_on(store.get(id)).unwrap().content, first.ciphertext); + assert!(block_on(controller.unlock("new")).unwrap()); + let fresh = controller.seal_for_storage(b"fresh").unwrap().unwrap(); + block_on(store.compare_exchange_protected_content_bound( + id, + &first.ciphertext, + &fresh.ciphertext, + 5, + 20, + &fresh.vault_envelope, + )) + .unwrap(); + assert_eq!( + controller + .open(&block_on(store.get(id)).unwrap().content) + .unwrap() + .unwrap() + .as_slice(), + b"fresh" + ); +} + +#[test] +fn authenticated_unprotection_is_atomic_and_preserves_pin() { + use rustickers::clipboard::storage::RetentionFlag; + use rustickers::clipboard::vault_controller::VaultController; + use std::sync::Arc; + let tmp = tempfile::tempdir().unwrap(); + let store = Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); + let vault = VaultController::new(store.clone()); + block_on(vault.create("p")).unwrap(); + assert!(block_on(vault.unlock("p")).unwrap()); + let sealed = vault.seal_for_storage(b"secret").unwrap().unwrap(); + let id = inserted_id( + block_on(store.upsert_protected_bound( + None, + None, + None, + &sealed.ciphertext, + 6, + 10, + &sealed.vault_envelope, + )) + .unwrap(), + ); + block_on(store.set_retention_flag(id, RetentionFlag::Explicit, true)).unwrap(); + assert!( + block_on(store.compare_exchange_unprotection( + id, + "stale", + "secret", + &sealed.vault_envelope + )) + .is_err() + ); + assert!( + block_on(store.compare_exchange_unprotection( + id, + &sealed.ciphertext, + "secret", + "stale-vault" + )) + .is_err() + ); + assert_eq!( + block_on(store.get(id)).unwrap().kind, + ClipboardItemKind::Protected + ); + block_on(store.compare_exchange_unprotection( + id, + &sealed.ciphertext, + "secret", + &sealed.vault_envelope, + )) + .unwrap(); + let row = block_on(store.get(id)).unwrap(); + assert_eq!(row.kind, ClipboardItemKind::Plain); + assert_eq!(row.content, "secret"); + assert!(row.retention.explicit()); + assert!(!row.retention.protected()); + assert!( + block_on(store.compare_exchange_unprotection( + id, + &sealed.ciphertext, + "secret", + &sealed.vault_envelope + )) + .is_err() + ); +} From c8ccaf314266e414660ff139d176e239c18d8515 Mon Sep 17 00:00:00 2001 From: binwenw Date: Tue, 6 Oct 2026 19:44:39 +0800 Subject: [PATCH 02/69] docs(plans): add GPUI Kit 0.7.1 migration audit, goal log, and clipboard reference guides - docs/plans/gpui-kit-upgrade.md: whole-app audit baseline, milestone exit criteria, dependency decision, source-backed evidence pointing at the upstream longbridge/gpui-kit v0.7.1. - docs/plans/gpui-kit-upgrade-goal.md: definition of done, builder gates, and per-milestone log kept current as the migration proceeded. - docs/plans/clipboard-{coding,design}-guides.md: bring in the existing local references for clipboard UX work so the history of those guides is tracked alongside the kit migration. --- docs/plans/clipboard-coding-guides.md | 911 ++++++++++++++++++++++++ docs/plans/clipboard-design-guides.md | 950 ++++++++++++++++++++++++++ docs/plans/gpui-kit-upgrade-goal.md | 796 +++++++++++++++++++++ docs/plans/gpui-kit-upgrade.md | 186 +++++ 4 files changed, 2843 insertions(+) create mode 100644 docs/plans/clipboard-coding-guides.md create mode 100644 docs/plans/clipboard-design-guides.md create mode 100644 docs/plans/gpui-kit-upgrade-goal.md create mode 100644 docs/plans/gpui-kit-upgrade.md diff --git a/docs/plans/clipboard-coding-guides.md b/docs/plans/clipboard-coding-guides.md new file mode 100644 index 0000000..54ca777 --- /dev/null +++ b/docs/plans/clipboard-coding-guides.md @@ -0,0 +1,911 @@ +--- +title: Coding Guides +description: Architecture and coding conventions for maintainable GPUI Kit applications +order: -13 +--- + +# Coding Guides + +This guide describes the application architecture and code patterns that have +proved durable in GPUI Kit. It is written for both engineers and coding +agents. Read [Design Guides](./design-guides.md) first: code structure should +preserve product intent, not replace it. + +This is a normative guide. **Must** marks lifecycle, correctness, or ecosystem +constraints; **should** is the default architecture and requires a concrete +reason to depart from it. Current source and API docs remain authoritative for +exact signatures. + +## Architecture at a glance + +GPUI application architecture layers +GPUI application architecture layers + +Dependencies point downward. Higher layers own domain meaning and orchestration; +lower layers own reusable presentation or behavior. Do not make a reusable +component depend on an application screen, or make `gpui-base` depend on a +theme from GPUI Component. + +Use these boundaries: + +- **app shell:** compose windows and feature crates while keeping feature logic out; +- **feature crate:** keep one capability's model, services, views, commands, dialogs, + and workflow behind one public boundary; +- **app component:** a repeated domain-aware pattern; +- **gpui-component:** themed, general-purpose UI; +- **gpui-base:** reusable behavior and geometry without product presentation. + +### Organize large applications by capability + +In a large Rust application, a feature should usually be a crate, not another +file in a global `views`, `models`, or `modals` directory. Keep the model, +views, commands, dialogs, and workflow for one capability together. A dialog +that edits a workspace belongs to the workspace feature; only the reusable +dialog primitive belongs to the UI library. + +```text +crates/ +├── app/ +│ └── src/main.rs # Compose windows and features +├── workspace/ +│ └── src/ +│ ├── lib.rs # The feature's public boundary +│ ├── model.rs +│ ├── commands.rs +│ ├── workspace_view.rs +│ └── rename_dialog.rs +├── search/ +│ └── src/ +│ ├── lib.rs +│ ├── model.rs +│ ├── commands.rs +│ ├── search_view.rs +│ └── filters.rs +├── settings/ +│ └── src/ +│ ├── lib.rs +│ ├── model.rs +│ ├── settings_view.rs +│ └── account_dialog.rs +└── shared/ + └── src/ + ├── lib.rs + └── recent_items.rs # A stable capability with multiple owners +``` + +Do not invert this into global `models/`, `views/`, `modals/`, and `commands/` +directories. Those folders classify files by implementation role while +scattering every feature across the application. + +The application shell composes feature crates but contains little feature +logic. A feature may depend on stable shared capabilities and UI foundations; +it must not depend on the shell or reach into a sibling feature's internals. +When two features need to communicate, prefer an explicit command, event, data +type, or small shared service over a dependency between their views. Extract a +shared crate only after the capability has a coherent name and more than one +real owner. + +Crate boundaries are engineering boundaries. They let Cargo rebuild and test a +smaller dependency subgraph, make ownership visible in `Cargo.toml`, and limit +the review and regression surface of a change. They also make removal honest: +a feature that cannot be detached without searching through global view and +modal directories was never isolated. + +Do not create a crate for every screen or helper. Split where a capability has +its own state and lifecycle, a stable public seam, or enough implementation to +benefit from independent compilation and tests. Keep dependencies acyclic and +pointing toward smaller, more stable crates. + +## Bootstrap and root ownership + +Initialize GPUI Component once, before creating component-backed views, and put +`Root` at the first level of each window: + +```rust +app.run(move |cx| { + gpui_kit::init(cx); + + gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { + cx.new(|cx| Workspace::new(window, cx)) + }) + .expect("failed to open window"); +}); +``` + +`Root` coordinates window-level component facilities such as overlays and +notifications. Do not create a separate root for each page inside one window. +It also coordinates modal focus restoration, focus traps, tooltip/menu layers, +and window-scoped text selection. Bypassing it can produce behavior that looks +correct at rest but fails when overlays nest or focus changes quickly. + +## Understand GPUI's phases and contexts + +GPUI is retained state with declarative rendering. An entity survives across +frames; the element tree returned by `render` is a fresh description of the +current frame. Keep that distinction explicit. + +- `Context` mutates the current entity, creates listeners tied to it, + emits its events, and notifies its observers. +- `App` gives access to application globals and entity reads/updates without + implying ownership by the rendered element. +- `Window` owns focus, actions, input dispatch, element-keyed state, + measurement, and animation-frame requests for that window. +- layout, prepaint, and paint are later phases; use their hooks only when + resolved geometry is genuinely required. + +Never retain `&mut Window`, `&mut App`, or `&mut Context<_>` beyond the call in +which it is provided. Retain typed handles—`Entity`, `WeakEntity`, +`FocusHandle`, scroll handles, or domain IDs—instead. + +## Choose the right unit + +### Use `RenderOnce` for value-like elements + +Use a `RenderOnce`/`IntoElement` component when all inputs can be supplied by +the caller and the element does not need to retain application state between +frames. This is the normal choice for presentational wrappers and small +controls. + +```rust +#[derive(IntoElement)] +struct EmptyState { + title: SharedString, +} + +impl RenderOnce for EmptyState { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .v_flex() + .gap_2() + .items_center() + .text_color(cx.theme().muted_foreground) + .child(self.title) + } +} +``` + +### Use `Entity` for retained behavior + +Use an entity-backed `Render` view when behavior spans frames or needs +observation, subscriptions, focus, async work, history, measurement, or +incremental updates. Store entities in an owning view rather than recreating +them in `render`. + +```rust +struct SearchView { + query: Entity, +} + +impl SearchView { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search…")); + Self { query } + } +} +``` + +Do not turn every visual fragment into an entity. Entity boundaries have +lifecycle and coordination costs; use them where retained identity matters. + +### Elements, views, and behavior systems are different + +Do not force every component into one template. The ecosystem contains: + +- semantic elements such as Button, Checkbox, Link, and Tabs; +- compound behavior roots such as Dialog, Popover, Select, and Combobox; +- entity-backed systems such as Input, Table, Tree, Dock, and notifications; +- infrastructure such as positioning, virtualization, scrolling, focus traps, + motion, history, and measurement. + +An element may be internally complex and still be value-like to its caller. A +stateful system may expose render callbacks so applications own presentation +without reimplementing behavior. Choose the public seam from the behavior, +not from how many `div`s appear in its renderer. + +## State ownership + +Put each state in the narrowest owner that can keep it correct: + +- domain state belongs to a model or feature view; +- transient view state belongs to the view that renders it; +- reusable behavioral state belongs to the component state designed for it; +- tiny element-local state may use GPUI keyed element state; +- shared application services may be stored as GPUI globals. + +Prefer controlled values for ordinary selection and toggles: pass the current +value into the component, receive a requested change, update the owner, and +render again. A callback reports intent; it should not create a second hidden +source of truth. + +```rust +Checkbox::new("show-hidden") + .checked(self.show_hidden) + .label("Show hidden files") + .on_click(cx.listener(|this, checked, _, cx| { + this.show_hidden = *checked; + cx.notify(); + })) +``` + +Call `cx.notify()` after a mutation that changes rendering. Use `cx.emit(...)` +for a semantic event that an owner should handle, and `cx.subscribe(...)` or +`cx.observe(...)` when the lifetime should follow an entity. Keep returned +subscriptions alive when the API requires it. + +Do not notify merely because a value was read or derived. Avoid unconditional +notification from `render`; it schedules another render and can create a +permanent redraw loop. When several fields form one invariant, update them +together and notify once. A reusable state type that cannot receive a context +should make that limitation explicit and require its owner to emit/notify. + +### Avoid state feedback loops + +Text input, selection, filters, and controlled popups commonly have two paths: +an external owner updates the value, and user interaction requests a new value. +Do not send an owner-supplied value back through the user callback during sync. +Track the origin or compare coherent snapshots so each logical change is +reported once. Make callbacks re-entrancy-safe when a callback can synchronously +close, replace, or update the component that invoked it. + +## Stable identity + +An `ElementId` is part of behavior. It gives an element stable identity and keys +element-local or component state. A component may also use it as one input to +its own focus, measurement, or animation identity; focus and scrolling are +otherwise owned by their dedicated handles. + +- Use stable domain IDs for rows, tabs, tree nodes, and repeated controls. +- Namespace child IDs with their owning object when the same control repeats. +- Never derive identity from a translated label or a mutable list index when + items can be inserted or reordered. +- Do not generate a fresh random ID during `render`. + +```rust +Button::new(("delete-project", project.id)) + .danger() + .label("Delete") +``` + +A changed ID means a changed UI identity. Treat that reset as deliberate. + +The same rule applies to transition channels, overlay tokens, scroll handles, +and persistence IDs. If two independently retained behaviors share a key, they +can overwrite each other's state; if one behavior changes keys every frame, it +never accumulates state. + +## Rendering and composition + +Keep `render` declarative: read current state, derive presentation values, and +compose elements. Move domain operations, parsing, and non-trivial mutation to +named methods or services. + +```rust +impl Render for ProjectView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .v_flex() + .size_full() + .child(self.render_toolbar(cx)) + .child(self.render_content(cx)) + } +} +``` + +Extract a render helper when it names a meaningful region and reduces the +amount of state a reader must hold at once. Extract a new component when the +region has its own reusable contract or retained lifecycle—not merely because +a builder chain is long. + +Use GPUI Component's fluent traits consistently (`Sizable`, `Disableable`, +`Selectable`, and component-specific builders). Prefer `.when(...)` and +`.when_some(...)` for small conditional refinements; use ordinary Rust control +flow when branches represent substantially different interfaces. + +Compose from the standard semantic component before building a custom surface. +Do not reproduce a menu, select, dropdown, or command palette from generic +`div`s merely to match one screenshot. Reusing the component preserves its +item geometry, focus transfer, keyboard navigation, selection, disabled state, +dismissal, and accessibility contract. If the standard component cannot +express a recurring valid pattern, improve its explicit API instead of styling +arbitrary descendants at each call site. + +Render callbacks supplied by application code should be side-effect-free. A +list item renderer, menu builder, or dock panel renderer may run whenever its +owner needs to measure or redraw. It must not perform a business operation, +append data, or register an unbounded subscription. + +## Behavior and presentation boundary + +The durable Base rule is: + +> Base owns reusable behavior and the geometry required to implement it. The +> presentation layer owns the product's visual language. + +“Headless” does not mean “one empty `div`.” Popup collision, keyboard +navigation, editing, virtualization, resize arithmetic, focus trapping, and +dock reconciliation require internal structure and state. Moving that work to +every caller would not create flexibility; it would duplicate fragile +behavior. + +Conversely, Base must not choose brand colors, typography, density, final +icons, component variants, or application composition. Expose presentation +through `Styled`, typed semantic-state styles, explicit parts, child slots, and +item renderers. Do not inspect arbitrary descendants to discover titles, +descriptions, or close buttons—make semantic parts explicit. + +## Theme and styling + +Read semantic values from the active theme and apply layout with GPUI's +`Styled` methods: + +```rust +div() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) +``` + +Rules: + +- do not hard-code product colors, corner radii, spacing, or control geometry; +- application code must not introduce raw hex, `rgb`/`rgba`, or `hsla`; read a + semantic color from `cx.theme()` or add the missing role to the product theme; +- application layout should use GPUI's rem-based scale helpers (`p_2()`, + `gap_3()`, `w_64()`, `text_sm()`) instead of direct `px(...)` values; +- use semantic tokens for meaning, not palette position; +- keep state-independent geometry in the ordinary builder chain; +- use GPUI `hover`, `active`, `focus`, and `focus_visible` modifiers for + runtime interaction states; +- use a component's semantic state styles for checked, selected, pressed, or + disabled appearance; +- keep popup ownership in explicit state so its trigger can render the open or + pressed appearance until dismissal; +- guard hover/active refinements when a disabled control must not react; +- keep component variants few and meaningful rather than adding a variant for + every call site. +- bind primary styling to the decision area's real default commit and Enter + action; do not derive it from action count, frequency, or toolbar position. +- keep Badge and Alert variants semantic and scarce. Ordinary metadata stays + neutral; do not map every enum case or section to a different color merely + because a variant exists. + +The effective precedence is instance style, active semantic value states, +disabled state, then GPUI runtime interaction refinements. Later layers only +replace fields they set. + +Prefer `Theme::semantic_tokens()` for new application-owned presentation. The +semantic token surface contains generic color roles plus radius, spacing, +typography, and shadow scales; it deliberately avoids component names. Legacy +component-specific theme values still exist for compatibility but should not +become the extension point for every application widget. + +There is one current ownership caveat: `Theme::spacing_tokens()` projects the +default scale, and `Theme::apply_semantic_tokens(...)` does not store custom +spacing or elevation scales. An application that customizes those scales must +retain its own `SemanticThemeTokens` (or narrower design-system state) and make +that state available to its components. Do not write a custom spacing snapshot +into the global theme and expect a later `cx.theme().semantic_tokens()` call to +return it. + +Edit the global GPUI Component theme through `Theme::update(cx, |theme| ...)`. +The theme keeps the same colors twice (`colors` as solid colors, `tokens` as +renderable backgrounds that may carry a gradient) and the Base layer holds a +projection for its scrollbars and resize handles; `update` brings all three +back in step after the closure and refreshes every window. An edit through +`Theme::global_mut(cx)` updates only the field you touched — a sidebar can then +paint its text from the new colors and its background from the old tokens — +so it owns the rest: derive `tokens` from `colors`, call `Theme::sync_base(cx)`, +refresh the windows. `Theme::change(...)` performs the projection as part of a +complete theme change. + +An outward focus ring needs physical room. An ancestor with +`overflow_hidden()` clips it. Prefer layouts that leave room; if a product must +clip heavily, use the theme's focus-ring policy and retain the focused border +instead of silently hiding all keyboard focus. + +### Base font is the application zoom control + +The Component Root plugin calls `window.set_rem_size(cx.theme().font_size)` from +its `prepare` hook before the Root surface is rendered. Therefore the theme's +base font is not only body typography; it is the reference length for the +application's rem-based design scale. This deliberately follows the useful part +of Tailwind's model: named type, spacing, and size steps share one relative base +instead of becoming unrelated pixel constants. + +Change zoom by updating the base font and refreshing the window: + +```rust +Theme::update(cx, |theme| theme.font_size = px(18.)); +``` + +The base font itself is a pixel value because it anchors the scale. Descendant +application UI should normally use relative helpers—`text_sm()`, `gap_2()`, +`px_3()`, `h_8()`, `size_4()`—so type, whitespace, controls, and icons respond +together. A custom component that combines rem-based text with fixed-pixel +padding or icon geometry must document why that part should not zoom. + +Treat every direct `px(...)` and raw color constructor in application UI as a +review finding. Accept it only for a documented physical/platform boundary, +measured runtime geometry, raster/data color, or the theme/token definition +itself. Convenience and matching a screenshot are not valid exceptions. + +Anything cached from resolved layout must include `window.rem_size()` in its +invalidation key, directly or through a revision that changes with it. This +includes wrapped row heights, text shaping/layout, virtual-list measurement, +popup and dialog geometry, icon sizing derived from text, and custom canvas +metrics. The Command component's variable-height rows are an ecosystem example: +they remeasure when rem changes because the same fixed width wraps differently +at a larger base font. + +Do not confuse this application zoom with Dock panel zoom. Dock zoom is a +stateful layout operation that makes one tab group fill the DockArea +while keeping the container chrome and the way back out. It must not modify the +window rem size. + +## Events, actions, and focus + +Use pointer callbacks for pointer-specific behavior. Use GPUI Actions for +commands that should support key bindings, menus, or dispatch from multiple +inputs. Keep action handlers close to the view that owns the command. + +Model one logical desktop command once. A toolbar Button, `DropdownMenu` item, +`ContextMenu` item, menu-bar item, and key binding should dispatch the same +Action or call the same owner method instead of copying five mutations. Derive +their label, icon, shortcut, and enabled state from one command policy where +practical, so the entry points cannot disagree. The menu owns navigation and +dismissal; the feature owner still owns whether the command is allowed and +what it does. + +Preserve semantic roles in the element choice. Use `Button` for commands even +when the desired treatment is quiet—select `outline`, `ghost`, or an icon +presentation instead of replacing it with `Link`. GPUI Kit applications +reserve `Link` for targets opened by a browser or mail client, such as a URL, +web document, or email address. Use the relevant navigation component for an +in-app destination and `Button`/`Action` for a command. This is a product +convention, not a limitation of `gpui_kit::base::Link`, whose `open_with` seam can +route a destination elsewhere. + +Only stop propagation when a nested interaction must prevent its parent from +handling the same event. Blanket propagation stops break menus, selection, +dragging, and window-level commands in ways that are difficult to diagnose. + +Bind keys before building the menu bar. `cx.set_menus` reads the keymap at +the moment it is called and bakes each item's shortcut into the native menu, +so a binding registered afterwards never shows next to its menu item and the +item does not react to the key. Call `cx.bind_keys` first, then +`cx.set_menus`; if the keymap changes later (a user keymap file, a locale +switch that rebuilds the menus), call `cx.set_menus` again. + +Make focus ownership explicit: + +- retain a `FocusHandle` in the entity that owns keyboard interaction; +- register key contexts and actions on the appropriate focused region; +- transfer focus when opening an overlay and restore it on dismissal; +- render a visible `focus_visible` state; +- do not request focus unconditionally from `render`. + +A tracked handle is a Tab stop only when it says so: build it with +`cx.focus_handle().tab_stop(true)` (or `.tab_index(n)`), because the element's +own `tab_index`/`tab_stop` settings do not apply to a handle passed to +`track_focus`. A stateless component may create that handle in `render` +through `window.use_keyed_state(id, cx, |_, cx| cx.focus_handle().tab_stop(true))`; +the keyed state survives re-renders, so the Tab order is stable — this is +what `Button` does. + +Attach a `key_context` and its `on_action` handlers to the same focused region. +Bindings are contextual: a registered Action without the intended focus path +is not a working keyboard interaction. Composite widgets should implement the +complete navigation model—arrow movement, Home/End or page movement where +appropriate, confirmation, cancellation, and Tab behavior—rather than a few +isolated shortcuts. + +Modal surfaces must trap focus and restore the previous valid focus target on +dismissal. Nested overlays dismiss from the top. Handle rapid close/open +sequences without restoring focus through an intermediate, already-closing +surface. + +## Async work and side effects + +Start async work from an event, lifecycle hook, or named method—not as an +unconditional side effect of `render`. Capture weak entities when work should +not keep a closed view alive. When the task completes, update state through the +GPUI context, handle the case where the entity or window no longer exists, and +notify once after the coherent state change. + +Represent async operations with explicit states such as idle, loading, loaded, +and failed. Preserve usable previous data during refresh when possible. Prevent +duplicate destructive submissions and surface recoverable errors in the UI; +do not rely on logs as user feedback. + +Use background executors for expensive parsing or computation, but keep GPUI +entity mutation on the appropriate application context. Results can arrive +after the request, document, view, or selection has changed; attach a revision +or identity and reject stale work rather than applying it to new state. + +## Layout, measurement, and scrolling + +`h_flex` centres its children on the cross axis; `v_flex` leaves flexbox's +default, `stretch`. This matches Zed's `h_flex`, and it is what a row of +controls wants, so a row of icon and label says nothing. It is not what a row +of full-height columns wants: a column placed in a bare `h_flex` does not fill +the row's height, so a column taller than the row is centred and its top — +commonly a header — is clipped off the top of the window, with nothing near the +column to say why. A row whose children are columns says `items_stretch()`: + +```rust +h_flex() + .items_stretch() + .size_full() + .child(sidebar) + .child(content) +``` + +Most UI should use GPUI layout rather than measuring itself. Measurement is a +deep behavior tool for popups, virtualization, editors, resize handles, charts, +and similar components whose correctness depends on resolved geometry. + +- Put measurement and geometry in the layer that owns the behavior. +- Observe bounds in prepaint only when ordinary layout cannot express the + relationship. +- Never mutate unrelated application state every prepaint. +- Treat measured data as frame- or revision-scoped; it can become stale after + typography, rem size, width, theme, or content changes. +- Centralize shared geometry such as popup flipping and viewport clamping so + every overlay follows the same edge policy. + +For alignment invariants, prefer construction over correction: sibling regions +should consume the same spacing token or shared inset instead of repeating +equivalent literals. Add geometry assertions or visual regression coverage for +critical repeated edges, columns, and gaps. Exercise more than the default +window: rem zoom and display scaling can turn fractional coordinates into a +one-physical-pixel drift even when the default screenshot looks aligned. + +Measure the resolved result when reviewing precision, but do not encode a +measured correction as a raw `px(...)` nudge. Trace the mismatch to duplicated +padding, nested insets, border ownership, font metrics, or rounding, then fix +the structural owner. + +`h_flex()` and `v_flex()` are not mirror images: `h_flex()` centers its +children on the cross axis, `v_flex()` leaves them stretching. A column placed +in a row therefore takes its content's height, not the row's, and a column +taller than the row is centered — its header is pushed off the top edge and +clipped. Give a full-height column `h_full()`, or give the row `items_start()` +or `items_stretch()`, whenever the child owns a header, a footer, or a scroll +region that must resolve against the row's height. + +Every scrollable region must have one owner. In flex layouts, apply +`min_w_0()` or `min_h_0()` to the flexible child that is allowed to shrink. +A flex item only drops its content-based automatic minimum size when its own +overflow is not visible, so an ordinary flexible child refuses to shrink around +long content until you say so. `Scrollable` handles this for its own wrapper, +but a plain `div` between it and the flex container still needs the minimum +released. Avoid accidental nested scrolling; route wheel input to the intended +axis and preserve platform/wasm differences when an API is not portable. + +Attach `Scrollable` to the element that owns the full panel, editor, or window +viewport so its scrollbar resolves against the region edge. Put content inset +inside that scroll owner rather than wrapping the scroll owner in a padded +container. A scrollbar floating between content and the panel boundary usually +reveals the wrong scroll owner or padding on the wrong layer. + +## Lists, tables, and large data + +Use virtualization when data can grow beyond a small, bounded collection. Keep +row identity separate from visible position and avoid cloning the full data set +on every render. Let a stateful list or table own navigation, selection, scroll +coordination, and visible-range calculation while item renderers own row +presentation. + +Separate: + +- source data and domain IDs; +- filtering/sorting state; +- selection state; +- viewport/scroll state; +- row rendering. + +This keeps updates local and prevents the view tree from becoming the data +model. + +Virtualization is a behavioral contract, not just a performance switch. Item +measurement must be invalidated when width, typography, rem size, or row +content changes. Keyboard selection and scroll-to-item must operate in model +coordinates even when most elements do not exist in the current frame. + +## Public API design + +For reusable components: + +- constructors should establish valid defaults; +- builders take and return `Self` and use domain language; +- callbacks describe requested changes and include pointer events only when + modifiers or pointer details are meaningful; +- evolvable behavioral seams use private fields, builders for construction, + and readers for inspection; +- boolean readers use `is_` or `has_` where a same-named builder exists; +- non-boolean setters use `with_` when readers need the plain field name; +- explicit compound parts are preferable to inspecting arbitrary descendants; +- adding reusable behavior must not force a product-level visual choice. + +Private fields are the default for behavioral state that must evolve without +breaking callers. Public fields are appropriate for deliberately record-like +configuration, theme tokens, geometry, and serialized schemas. Every public +struct with public fields must carry `#[non_exhaustive]`. Provide constructors, +`Default`, or builders so callers can create values without exhaustive struct +literals. This preserves the ability to add fields without breaking callers. +Apply this rule to new types and public API changes; unrelated existing types +can be migrated separately. + +Keep public module paths stable while reorganizing internals: use a module seam +with deliberate re-exports so folders can change without forcing downstream +imports to change. Prefer platform control terminology and established project +naming over web-framework vocabulary. + +## Platform and capability boundaries + +Do not assume every native or web target supports the same facility. Window +decorations, accessibility bridges, system notifications, clipboard behavior, +scroll gestures, fonts, and timing can differ. Put platform-specific code +behind a narrow capability seam and define the fallback behavior. + +A platform branch must preserve the semantic contract even if presentation +differs. For example, a system notification may have different retraction +support, but the application still needs a coherent delivery state. Test both +the shared state machine and the platform adapter where possible. + +## File and naming conventions + +- Name views and entities after product concepts: `ProjectList`, + `ProjectEditor`, `SettingsState`. +- Name event handlers after intent: `confirm_delete`, `open_project`, + `on_query_changed`. +- Keep one main responsibility per module; split a file when state ownership or + lifecycle can no longer be understood without reading unrelated behavior. +- Keep component module, state, events, and focused tests together when they + change together. +- Document invariants and surprising lifecycle constraints; do not narrate + obvious builder calls. +- Use `rustfmt` and satisfy the workspace's Clippy rules. Avoid broad `allow` + attributes that conceal unrelated warnings. + +### Vocabulary is part of the API + +Use the same word for the same concept across components. Before naming a new +method, search GPUI, `gpui-base`, and GPUI Component for the established term; +prefer macOS/Windows control terminology where the ecosystem has no precedent. +Localized documentation preserves exact API identifiers and established UI +framework terms when translation would reduce precision. Format identifiers as +code, explain retained terms when needed, and do not mix languages merely to +make ordinary prose sound technical. + +| Concept | Naming pattern | Example | +| --- | --- | --- | +| Value-like rendered control | noun | `Button`, `Checkbox`, `Tab` | +| Retained behavioral model | `State` | `InputState`, `TableState` | +| Imperative shared reference | `Handle` | `DialogHandle`, scroll handle | +| Semantic notification | `Event` | `TableEvent`, `SelectEvent` | +| Keyboard command | verb or intent noun | `Confirm`, `Cancel`, `SelectNext` | +| Pluggable data/behavior owner | `Delegate` / `Provider` | `TableDelegate`, `CompletionProvider` | +| Application-supplied presentation | `render_` or `_renderer` | `render_item` | +| Construction | `new`, or a semantic constructor | `new`, `horizontal`, `vertical` | +| Fluent property | noun/adjective | `label`, `disabled`, `selected`, `placement` | +| General non-boolean replacement builder | `with_` | `with_size`, `with_mode` | +| In-place mutation | `set_` | `set_items`, `set_selected_index` | +| Boolean reader | `is_` / `has_` | `is_open`, `is_closable`, `has_selection` | +| Plain value reader | field noun | `placement`, `selected_value` | +| Callback registration | `on_` | `on_click`, `on_open_change` | +| Rendering a named region | `render_` | `render_toolbar`, `render_content` | + +For new APIs, fluent builders omit `set_` because they consume and return +`Self`; mutation through `&mut self` uses `set_`. Preserve established public +names when changing them would cause needless churn. Existing builder names +such as `set_position` are compatibility exceptions, not patterns for new APIs. + +A boolean reader is either `has_`, when the value holds something, or +`is_`, when it describes a state or a permission. Reach for the +adjective whenever the action has one: `is_closable` over `can_close`, +`is_zoomable` over `can_zoom`, `is_copyable` over `can_copy`. When the action +is a verb phrase with no adjective form, name the thing it needs instead: +`has_definition`, not `can_go_to_definition`. Do not add new `can_` readers. + +Boolean builders may use the field name (`disabled(bool)`) while their readers +use `is_disabled()`. For a public seam struct containing non-boolean fields, +use `with_item_ix(...)` for construction and `item_ix()` for reading so setter +and getter names never collide. Prefer `_ix` for new local or internal +zero-based indices, preserve established public terms such as `selected_index`, +and do not introduce `_idx`. If callers never construct the seam value, do not +publish a builder merely for symmetry. + +### Let the enclosing name carry the context + +A name is read inside something. A field is read inside its type and a parameter +inside its method, so neither repeats what encloses it: `with_item_ix(ix)`, not +`with_item_ix(item_ix)`. + +Keep one type's fields at the same level of abbreviation. A single field spelled +out in full becomes the odd one out, and a reader goes looking for the +distinction that made it different. Because a builder is named `with_`, +shortening a field shortens its builder with it and the pair stays matched. + +Shorten only where the enclosing name really does disambiguate. When a short +form is also the established term for a *different* quantity elsewhere in the +ecosystem, say which one you mean in the doc comment rather than lengthening the +identifier — the doc is read at the call, and it can explain what a longer name +could only hint at. + +### Use precise domain words + +- **selected** is persistent membership or the active item; **focused** is the + current keyboard target; **hovered** is pointer presence; **confirmed** is an + activation result. Never use them interchangeably. +- **open/close** describes an overlay or disclosure state; **show/hide** is for + transient presentation requests; **expand/collapse** describes structure. +- **disabled** prevents interaction; **readonly** permits navigation and + selection but prevents editing; **loading** prevents duplicate work while an + operation is pending. Spell the state `readonly` — one word, as the + `readonly(bool)` builder and `is_readonly()` reader do — in identifiers, + interface labels, and documentation alike; never `read-only` or `read only`. +- **index** is a current positional coordinate; **id** is stable identity; + `IndexPath` represents hierarchical position. Do not persist or key + reorderable data by index. +- **value** is controlled domain data; **presentation** is a read-only snapshot + prepared for rendering; **state** is retained behavior. +- **placement** is a side or anchor policy; **position** is resolved geometry. +- **size** is a semantic control tier; **width/height/bounds** are geometry. +- **child/children** follows GPUI composition; named slots such as `header`, + `footer`, `trigger`, and `content` carry additional semantics. + +Avoid vague public names such as `data`, `item2`, `handle_action`, `update_ui`, +`process`, `manager`, or `config` when a narrower domain term exists. `Manager` +is appropriate only when a type truly coordinates a collection or lifecycle, +as `ToastManager` does. + +### Type and module style + +- Rust types and Actions use `UpperCamelCase`; modules, functions, methods, + fields, and local variables use `snake_case`; constants use + `SCREAMING_SNAKE_CASE`. +- A module named after a component owns its public seam. Internal folders may + split state, element, geometry, platform adapter, and tests without leaking + those folder names into imports. +- Use singular module names for one component concept and established + ecosystem names for families (`input`, `table`, `dock`). +- Suffix type-erased wrappers with `Any` only when they erase a real type + boundary, such as `AnyInputState` or `AnyElement`. +- Suffix identifiers with `Id`, zero-based indices with `ix`, and collections + with meaningful plurals. Do not alternate `idx`, `index`, and `ix` in one + subsystem. +- Name predicates positively when possible. A positive `enabled`/`visible` + contract is easier to compose than multiple negatives, but preserve + established API terms such as `disabled` where they match control semantics. + +### Callback and event wording + +Use `on_click` only for a genuine click-level contract. A controlled semantic +primitive in Base should prefer `on_change(next_value, ...)`; a styled +compatibility component may retain `on_click` when pointer details or existing +API expectations matter. Do not invent a `ClickEvent` for a model-driven +change. + +Name before/after lifecycle hooks precisely. `on_will_change` can veto or +prepare; `on_change` observes a requested/current value contract; `on_confirm` +commits a choice; `on_dismiss` closes a transient surface. Document whether a +callback runs before internal state changes, after them, or instead of them, +and whether it may synchronously re-enter the component. + +### Documentation and copy style + +Public docs should begin with what a type does and who owns its state. Examples +must use current, compilable APIs and show stable IDs. Document defaults, +platform limitations, focus behavior, callback ordering, and any requirement +to call `notify`, `emit`, or a theme synchronization method. + +Follow the [interface-language rules](./design-guides.md#interface-language) for +labels, commands, confirmation dialogs, capitalization, and ellipses. Keep one +canonical term for each domain object, command, and state. + +Translation keys describe stable intent (`dialog.delete_project.title`), not a +source-language sentence or a screen coordinate. Never assemble a sentence +from translated fragments or reuse one key for meanings that happen to share +the same English text. + +Localize intent, not syntax. Give every locale control over word order, +pluralization, punctuation, and the amount of context it needs. Review strings +inside the component and with realistic data. Tests or linting should catch +missing keys, unintended CJK text in English resources, three-dot ellipses, +unreviewed ALL CAPS, and inconsistent fixed terms; human review still decides +whether repetition is justified by context. Verify every string inside its +component with realistic content, text expansion, and application zoom. + +## Testing strategy + +Test at the lowest layer that can prove the behavior: + +1. pure tests for state transitions, geometry, parsing, and ordering; +2. GPUI context tests for entities, events, and subscriptions; +3. `VisualTestContext` interaction tests for focus, keyboard, pointer, layout, + and rendered state; +4. example or application smoke tests for complete workflows. + +For an interactive component, cover the semantic contract rather than its +implementation details: pointer and keyboard activation, controlled value +changes, disabled behavior, focus movement, event count/order, stable identity, +and important empty or failure states. Add a regression test before fixing a +bug whenever the failure can be reproduced deterministically. + +For UI behavior that depends on the real window system, test through the +accessibility tree by role, label, value, enabled state, focus, and selection. +Re-read the tree after every state-changing action because element indexes are +snapshots. Use screenshots for visual facts the semantic tree cannot express; +use coordinate input only as a fallback. Report automated and manual evidence +separately. + +## Performance rules + +- Do not mutate state or notify unconditionally in `render`. +- Avoid rebuilding entities, subscriptions, focus handles, and expensive data + structures per frame. +- Notify the narrowest owning entity after a coherent state change. +- Virtualize long collections and render only the visible range. +- Avoid cloning large strings or collections solely to satisfy a closure; + capture stable handles or shared data. +- Measure before adding caches. A cache must have a clear invalidation owner. +- Keep animation work bounded and honor reduced motion. + +## Common failure modes + +Avoid these patterns: + +- one entity containing the entire application's unrelated state; +- business logic and network requests embedded in a long `render` method; +- random or index-based `ElementId` values for reorderable content; +- literal colors and radii that break custom themes; +- custom clickable `div`s where a semantic component already supplies focus, + keyboard, disabled, and accessibility behavior; +- duplicated local state that drifts from a controlled model value; +- `cx.notify()` loops caused by mutation during every render; +- nested scroll containers without explicit ownership; +- a new component variant for a one-off screen; +- confirmation dialogs for reversible, low-risk actions; +- tests that call internal methods but never exercise keyboard or pointer + behavior. + +## Rules for coding agents + +Before editing, an agent must read the nearest implementation, its tests, the +re-export seam, and the relevant component documentation. It must search the +current source for signatures instead of translating a React, CSS, or old GPUI +example by analogy. + +For each change, the agent should be able to name: + +1. the behavior owner and presentation owner; +2. the retained identity and state lifecycle; +3. the pointer, keyboard, focus, and accessibility contract; +4. the layout and overflow owner; +5. the theme tokens and intentional exceptions; +6. the test that would fail if the behavior regressed. + +Generated code must be reviewed and tested by a person. “Compiles” is not a UI +quality bar, and a broad refactor that merely makes generated code look tidy is +not a substitute for matching the repository's architecture. + +## Implementation checklist + +Before opening a change for review, confirm that: + +- state and side-effect ownership are explicit; +- `RenderOnce` versus `Entity` is chosen deliberately; +- repeated elements have stable domain-based IDs; +- theme tokens and component sizes replace isolated visual literals; +- keyboard actions, focus, disabled state, and overlays work together; +- loading, empty, error, and cancellation paths are represented; +- long data sets use an appropriate virtualized component; +- public API additions preserve dependency direction and encapsulation; +- tests prove behavior at the appropriate layer; +- formatting, Clippy, targeted tests, and relevant examples pass. + +See [Getting Started](./getting-started.md) for application setup and the +component pages for current API details. diff --git a/docs/plans/clipboard-design-guides.md b/docs/plans/clipboard-design-guides.md new file mode 100644 index 0000000..fc3dc08 --- /dev/null +++ b/docs/plans/clipboard-design-guides.md @@ -0,0 +1,950 @@ +--- +title: Design Guides +description: Product and interaction design guidance for GPUI Kit applications +order: -2.1 +--- + +# Design Guides + +Use this guide before choosing components or writing layout code. It records +the product judgment accumulated through years of GPUI Kit desktop work: +an interface should feel native, restrained, precise, and understandable +without guesswork. + +This is a normative guide. **Must** identifies a correctness or ecosystem +constraint, **should** is the default that needs a concrete reason to override, +and **may** is an optional technique. Component API documentation remains the +authority for individual methods. + +The rules build on behavior in `gpui-base`, the GPUI Component theme and +component system, and familiar desktop interaction. Shadcn contributes useful +methods—open code, composition, and dependable defaults—but does not determine +how a GPUI application should look. When influences conflict, preserve GPUI's +lifecycle constraints and the interaction people already understand. + +## Design thesis + +Build interfaces that feel native, quiet, and precise. Let content, hierarchy, +and interaction carry the experience; decoration should support them rather +than compete with them. + +1. **Clarity before personality.** Make the primary task and next action clear + before adding brand expression. +2. **Composition before invention.** Start with established components and + compose them into product-specific workflows. Create a new primitive only + when its behavior is genuinely new. +3. **Tokens before values.** Colors, radii, typography, and spacing should form + a system. Avoid isolated literals that cannot respond to themes. +4. **Desktop before web convention.** Preserve keyboard access, window chrome, + menus, dense data views, resizable regions, and persistent navigation where + the task benefits from them. +5. **State must be visible.** Hover, focus, selection, disabled, loading, + validation, and destructive states need distinct and consistent treatment. +6. **Geometry explains containment.** Radii, insets, and boundaries should + describe one coherent hierarchy, even where surfaces overlap. +7. **Feedback reflects reality.** Loading, tooltips, and motion should clarify + a real state or transition, not add ceremony to an immediate action. + +## Learning from Shadcn + +Shadcn's most useful contribution is not a particular border color. It is a +way of building a system: + +- own the top layer of the interface instead of fighting a sealed abstraction; +- compose small, predictable parts into product-specific components; +- provide defaults that already form one visual language; +- keep the code and composition legible to both people and AI; +- separate behavior primitives from the styled layer. + +GPUI Kit applies those ideas through a Rust library and the split between +`gpui-base` and `gpui-component`. Applications normally compose or wrap the +published components; contributors move genuinely reusable behavior into Base +and keep visual policy above it. + +Do not copy these web assumptions blindly: + +| Web habit | Native GPUI default | +| --- | --- | +| Pointing-hand cursor on every button | Default arrow cursor; pointing hand for links | +| Page navigation as the main structure | Persistent windows, panes, sidebars, tabs, and menus | +| Browser focus and scrolling as a fallback | Explicit focus ownership and region-owned scrolling | +| Mobile-first single column | Resizable desktop shell with a defined minimum window size | +| Hover-revealed critical actions | Keyboard- and pointer-reachable actions that do not depend on hover | +| A row of hover-only icon buttons | A visible primary action plus `DropdownMenu` or `ContextMenu` for secondary commands | +| Link-styled text for application commands | `Button`, `outline`, or `ghost`; Link only for URLs, web resources, or email addresses | +| Large touch density everywhere | Medium density by default; compact only where information work benefits | +| CSS overrides across descendants | Typed builders, semantic parts, and application composition | + +## Start from the task + +Before drawing a screen, write down: + +- the user's primary task; +- the object being viewed or changed; +- the actions that must remain immediately available; +- the information required to make a decision; +- empty, loading, error, offline, read-only, and permission-denied states; +- the keyboard path through the workflow. + +Organize the window around those answers. Do not begin with a dashboard grid or +a component catalogue. A good desktop interface exposes the user's mental +model: documents, accounts, projects, messages, settings, or another stable +object—not the internal service architecture. + +Design the primary task before distributing controls. Its visual weight, +location, and information depth should match its importance to the product. A +core result must not be reduced to a small count, an icon in a corner, or a +weak footer action while secondary content consumes the page. When a result set +is the product's main value, consider a summary region or card that exposes the +count, representative results, meaningful state, and a clear next action. + +For every proposed action, name its visible object, current state, scope, and +result. If the interface does not show or clearly imply those things, the +action is premature. Do not expose a capability merely because the backend has +it; first design how the object enters the user's mental model. + +## Visual language + +### Hierarchy + +Prefer a small number of clear levels: + +- **window or page title** identifies the current object or workspace; +- **section title** separates meaningful regions; +- **body text** carries the work; +- **muted text** provides secondary metadata and help; +- **labels** identify controls and values. + +Use size, weight, spacing, and separators before adding color or containers. +Avoid nesting cards inside cards: most desktop regions need only a background, +a hairline boundary, and intentional spacing. + +Evaluate hierarchy across the whole feature, not component by component. Hide +accent color and decoration during review: the primary task, current selection, +result summary, and next action should still be obvious from structure. A +screen that contains individually plausible controls can still fail when they +do not form one reading order and one decision path. + +Treat emphasis as a limited budget. A local surface needs one clear focal point, +not a field of competing highlights. If everything is colored, badged, bold, +boxed, or promoted to an alert, nothing reads as important. Establish priority +with structure and proximity first; spend stronger color and components only +where a distinction changes what the user notices or does. + +### Color and themes + +Read colors from `cx.theme()` and use them by semantic role: + +- `background` and `foreground` for the main surface and text; +- `group_box`, `popover`, `sidebar`, and their foreground tokens for their + named surfaces; +- `muted` and `muted_foreground` for supporting information; +- `primary` for the principal action or selection emphasis; +- `danger`, `warning`, `success`, and `info` only for their meanings; +- `border`, `input`, and focus-ring tokens for structure and interaction. + +Do not use a semantic status color as decoration. Do not encode meaning by +color alone. Verify every custom surface in light and dark themes and with +custom theme values; never assume that foreground is black or background is +white. + +Use Badge for a short state, count, or classification that benefits from rapid +scanning—not for every label, metadata value, filter, or section title. Keep +most badges neutral; reserve semantic variants for states that truly carry +success, warning, danger, or informational meaning. A row of multicolored +badges is usually a missing hierarchy or grouping decision. + +Application UI should not contain raw hex, `rgb`/`rgba`, or `hsla` colors. +Resolve colors from `cx.theme()` by semantic role. If the required role does +not exist, define it in the product's theme/token layer rather than embedding a +palette value at the call site. Raw colors belong only inside theme definitions +or in audited data/raster content whose color is itself the data. + +### Radius, spacing, and density + +Use a finite radius scale, as with type and control sizes. Choose from the +theme's named tiers (`sm`, `md`, `lg`, `xl`, and larger surface tiers) instead +of inventing a radius for each element. Large containers may use a softer tier +than the controls they contain; compact controls and overlays use tighter +tiers. For an ordinary rectangle, the radius should remain visibly smaller +than half its shorter side. Use `radius_full()` only for intentional circles +and pills; a tooltip must not become a capsule by accident. + +Nested surfaces should read as concentric shapes. Use +`inner radius ≈ max(0, outer radius − gap)` to choose the closest suitable +theme tier; the visible band should remain even through the turn. Add a +named application token if a repeated composition needs an intermediate +value. A segmented control is one silhouette: its end segments follow the container's inner +corners, its middle segments remain square, and each boundary has one divider +of consistent thickness. + +Light theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center. +Dark theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center. + +A radius must govern the entire visible surface, not just its border. Exposed +corners remain transparent to the surface behind them; child backgrounds, +selection fills, and scrolling content must also follow the boundary. In GPUI, +do not assume a rounded parent clips its descendants. Give inner surfaces their +own radius or an appropriate clip, then inspect the composition against a +contrasting background in both themes. Preserve outward focus rings when +choosing a clip. + +Use a compact spacing scale and repeat it. Related label/control pairs should +be closer than separate groups; separate groups should be closer than separate +sections. Prefer component sizes (`xsmall`, `small`, default medium, `large`) +over one-off heights. Use compact variants for toolbars and data-dense screens, +not to squeeze an unclear layout into less space. + +The shared semantic scale is intentionally small: spacing progresses through +roughly 2, 4, 8, 12, 16, 24, and 32 pixels, while typography stays near 12, +14, 16, 18, and 20 pixels. Treat these as relationships rather than permission +to scatter their current values through feature code. GPUI Component currently +projects a fixed default `SpacingTokens` scale from its global `Theme`; unlike +colors and radii, `Theme::apply_semantic_tokens` does not persist a custom +spacing scale. An application that needs different spacing must own that full +token snapshot and use it consistently in its application components. + +### Spatial grammar + +Spacing expresses relationship. Choose a gap from the semantic scale by asking +what the two things mean to each other: + +| Relationship | Typical token | Current scale | Examples | +| --- | --- | --- | --- | +| Optical correction | `xxs` | 2 px | icon baseline, compact separator | +| Parts of one control | `xs` | 4 px | menu icon/label, title/description | +| Closely related controls | `sm` | 8 px | button icon/label, dialog actions | +| One content group | `md` | 12 px | notification columns, compact form rows | +| Separate groups in one section | `lg` | 16 px | panel padding, form groups | +| Separate sections | `xl` | 24 px | major blocks in a page or inspector | +| Major region boundary | `xxl` | 32 px | empty-state breathing room, page bands | + +These values describe the current default scale, not literals to repeat. Use +`cx.theme().spacing_tokens()` or the corresponding GPUI scale helpers for the +ecosystem default. A product-owned scale should preserve the ordering and +relationships and must be passed through the application's own design-system +context rather than assumed to persist in the global GPUI Component theme. + +Use these rules when resolving horizontal and vertical space: + +1. **Inside before outside.** A component's padding belongs to the component; + the gap between components belongs to their parent. +2. **Vertical rhythm shows grouping.** The gap between a title and its + description is smaller than the gap from that description to the next + section. Equal gaps imply equal relationships. +3. **Horizontal space supports scanning.** Repeated rows keep icons, labels, + values, badges, and trailing actions on stable columns. +4. **Leading and trailing are semantic.** Think in reading-order edges even + when the current API uses left/right; this keeps future RTL adaptation + possible. +5. **Do not double padding.** A card placed in an already padded panel should + not automatically add another full panel inset. +6. **Use optical alignment sparingly.** A one- or two-pixel correction is valid + for icon or glyph geometry, but document why it differs from the scale. + +Common compositions in the current system illustrate the relationships: + +- button contents use 4 px at small sizes and 8 px at normal sizes; +- dialog headers and footers use an 8 px internal gap; +- compact list and menu rows use 4 px vertical and 8–12 px horizontal padding; +- sheet headers use about 16 px leading and 12 px trailing space, leaving room + for a close affordance, while footers use 16 px horizontal and 12 px vertical + space; +- notifications use 16 px horizontal padding and a 12 px column gap because + icon, message, and action are distinct groups. + +Do not treat these as copy-and-paste recipes for every surface. They reveal the +system: controls are tighter internally, rows optimize scanning, and +containers spend more space at their boundary than between their contents. + +### Proportion and layer hierarchy + +Start with content requirements, then set proportions. Avoid arbitrary halves +when one pane has a clearly different role. + +- A navigation sidebar should be wide enough for stable labels but visibly + subordinate to the work area. Give it a minimum, preferred, and maximum + width rather than a percentage alone. +- In master–detail layouts, let the collection remain scannable and give the + detail pane the surplus. A roughly one-third/two-thirds starting point is + often useful, but content constraints are authoritative. +- Inspectors and auxiliary sheets should not cover the primary object by + default. They should be resizable or dismissible when their content grows. +- Dialog width comes from the decision: short confirmation, medium form, or a + dedicated window/page for complex work. Do not enlarge a dialog simply to + create whitespace. +- Reserve the strongest elevation for the topmost decision layer. Within a + layer, use background and hairlines—not successively larger shadows—to show + hierarchy. + +Define three size constraints for every major region: the minimum at which its +task still works, a comfortable default, and how it consumes surplus. Persist +user-controlled splits when they represent workflow preference, and clamp +restored values against the current window. + +### Alignment details + +Alignment is a structural system, not a final polish pass. Establish a small +set of alignment spines for each surface: shared leading and trailing edges, +text baselines, center lines, and fixed functional lanes. Elements at the same +level should attach to the same spine from top to bottom or leading to trailing, +even when they are different component types. + +Alignment spines across a desktop surface +Alignment spines across a desktop surface + +Vertical red lines sit beside shared edges or control centers for content, +status, time, and trailing actions. Horizontal lines sit beneath text baselines +or pass through a row center to show bottom and vertical-center alignment. The +compact comparison isolates a one-rendered-pixel drift that must be corrected +at its structural owner. + +- Give sibling regions a shared content inset. A heading, toolbar, list row, + empty state, and footer that describe the same level should not each invent a + slightly different leading edge. +- Repeat column geometry through the whole region. Headers, rows, summaries, + loading states, and inline editors should reserve the same lanes for identity, + metadata, status, numbers, and actions. +- Align related controls across rows and sections. Form labels, fields, + descriptions, and validation messages should reveal a stable vertical grid + when the page is scanned from top to bottom. +- Keep horizontal bands coherent. Items sharing a toolbar, title bar, status + bar, or row should use one baseline or center line instead of individually + tuned offsets. +- Introduce indentation only for real hierarchy, containment, or disclosure. + Decorative indentation makes siblings look subordinate and breaks the + surface's reading line. +- When a nested level ends, return exactly to the parent spine. Do not let + accumulated padding drift across nested containers. +- Preserve the spine through optional content. Missing icons, badges, + descriptions, or trailing actions must not move the remaining labels; use + intentional slots or lanes when cross-row comparison matters. +- Align major regions with one another where their hierarchy matches. Sidebar + headers, content titles, split panes, toolbars, and bottom bars need not share + every coordinate, but coincident levels should form visible continuous lines. + +Not every edge should align. A child can indent, a primary value can lead its +supporting metadata, and a destructive decision can gain separation. Such +exceptions must communicate hierarchy or meaning; they must not result from +uncoordinated component padding. Start with the shared spine, then make the +exception explicit. + +Treat exact alignment and repeated gaps as quality invariants. When two edges +or spaces are intended to be equal, a one-rendered-pixel difference is a defect, +not an acceptable optical approximation. Inspect resolved bounds with a +measurement tool at representative window sizes, zoom levels, and display scale +factors. Compare coordinates and distances; do not approve alignment only from +a casual screenshot. + +The rendered-pixel tolerance is a verification rule, not permission to patch +the code with raw pixel offsets. Equal relationships should resolve from the +same `rem` helper, spacing token, grid definition, or shared component inset. +Fix the common owner when they differ. Account for fractional layout and device +rounding so intended spines land on the same physical pixel instead of drifting +at particular zoom levels. + +- Align text by baselines, not by bounding-box centers, when mixed sizes share + a row. +- Center icons in a fixed slot so labels do not move when icons differ in + intrinsic width. +- Right-align comparable numbers; left-align prose and identifiers unless the + locale requires otherwise. +- Keep trailing row actions and disclosure indicators in fixed-width lanes. +- Align form controls by their interactive frame, not by help text below them. +- Use `justify_between` only when the two sides truly own opposite edges; it + should not disguise missing structure in the middle. +- Hairlines belong on the boundary owner. Two adjacent regions must not each + draw the same separator. +- A scrollbar belongs to the region that scrolls and sits against that panel, + editor, or window's trailing edge. Content padding may inset text and rows; + it must not pull the scrollbar into the middle of the surface. Reserve a + deliberate scrollbar gutter when content needs clearance. + +### Density tiers + +Medium is the ecosystem default. Change density for the whole local context, +not one isolated control: + +- **comfortable / large:** onboarding, sparse forms, prominent decisions; +- **standard / medium:** most application chrome and workflows; +- **compact / small:** toolbars, menus, tables, and repeated professional data; +- **extra compact / xsmall:** exceptional high-density utilities, never the + automatic choice for an entire application. + +The current controls demonstrate a bounded scale rather than arbitrary sizing: +buttons commonly move through approximately 20, 24, and 32 px frames; input +and data controls may extend to about 44 px at large size; table rows use about +26, 30, 32, and 40 px. Use the component's `Size` API so typography, icon, +padding, and hit target change together. A custom height that changes only the +outer box is usually incomplete. + +### Zoom, base font, and `rem` + +A well-designed `rem` system preserves hierarchy while the interface zooms. +Zoom is successful when the relationship between title and body, control and +icon, inner and outer spacing, primary and secondary regions still feels the +same at every scale—not merely when every object becomes larger. + +GPUI Component adopts the relative-scale idea familiar from Tailwind. The +theme's base `font_size` becomes the window's `rem` through `Root`, and GPUI +scale helpers such as `text_sm()`, `gap_2()`, `p_4()`, `h_8()`, and `size_4()` +resolve against it. This gives typography, spacing, controls, and icons one +shared zoom axis. + +Design in ratios: + +- type steps keep the same hierarchy around the base body size; +- spacing steps keep the same grouping relationships around the type; +- control frames, icons, and hit targets scale with their labels; +- pane minima and comfortable widths account for the scaled content; +- corner radii and focus treatment remain optically consistent with the + control frame. + +Do not implement zoom by changing text size alone. A larger label inside a +fixed-height button, a larger document inside fixed pane minima, or larger rows +inside a stale virtual-list measurement destroys the original rhythm and can +clip content. Conversely, multiplying every physical pixel—including +hairlines—can make the interface visually heavy. + +As a rule, application layout should not call `px(...)` directly. Use GPUI's +rem-based scale helpers (`p_2`, `gap_3`, `w_64`, `text_sm`, and related +builders) or semantic component sizes. Use fixed pixels only when the value +represents a physical or raster boundary: +a one-device-pixel hairline, platform window inset, bitmap dimension, minimum +hit-test tolerance, or geometry that must match an external surface. These are +audited, documented exceptions. Product spacing, typography, icon size, and +ordinary control geometry stay on the relative scale. + +Test interface zoom at several base-font values, not just the default. Verify +hierarchy, wrapping, truncation, minimum window size, pane resizing, focus-ring +clearance, popup placement, and virtualized row measurement. Also distinguish +interface zoom from Dock's panel zoom: Dock zoom makes one container fill its +area while retaining its chrome; it does not change `rem` or application scale. + +### Surfaces and elevation + +Use elevation to explain stacking, not importance. The base window surface is +flat; separators and background contrast define its regions. Popovers, menus, +dialogs, and notifications may use progressively stronger shadows because they +sit above other content. Do not put a shadow on every card. + +All surfaces of the same kind should share one treatment. GPUI Component, for +example, deliberately gives popup families one themed popover surface so +Popover, Select, Combobox, DatePicker, and menus do not drift apart. When an +application invents another anchored surface, reuse that semantic treatment +instead of approximating it with unrelated border and shadow literals. + +### Typography and icons + +Use the platform UI font for interface text and monospace only for code, +identifiers, shortcuts, and aligned numeric data. Keep body text readable and +avoid excessive uppercase or letter spacing, especially for CJK text. + +Use one icon family in a product. Icons supplement labels; they should not +replace unfamiliar actions with guesswork. Icon-only buttons always need an +accessible name. Their visual explanation follows the tooltip policy below. +Use filled or colored icons to communicate a state, not merely to make a +toolbar lively. + +## Layout patterns + +### Choose a stable shell + +Most applications should use one of these shells: + +- **single workspace:** toolbar or title bar above one primary view; +- **sidebar workspace:** persistent navigation beside a changing detail view; +- **master–detail:** resizable collection and detail panes; +- **document workspace:** tabs or a dock area for multiple long-lived objects; +- **utility window:** one focused task with a short, fixed action path. + +Keep global navigation stable while content changes. Give the primary work area +the remaining space with `flex_1()` and `min_w_0()` / `min_h_0()` where +overflowing children must shrink. Use `Scrollable`, `VirtualList`, `Table`, or +`DockArea` for their intended behavior instead of rebuilding scrolling or pane +management from nested `div`s. + +The title bar is window chrome first. Preserve its drag region and avoid +binding ordinary title clicks to infrequent editing commands. Rename belongs +behind an explicit object command, with a keyboard path where appropriate. + +### Responsive desktop windows + +Desktop does not mean fixed-size. Decide what happens as a window narrows: + +1. preserve the primary task; +2. allow resizable regions to reach a documented minimum; +3. collapse secondary labels or inspectors; +4. move low-frequency actions into a menu; +5. scroll only the region whose content actually overflows. + +Do not hide an action without providing another path to it. Avoid making the +entire window scroll when only a list or document body should scroll. + +GPUI flex layouts have the same intrinsic-size pressure found in other layout +systems: a `flex_1()` child may still refuse to shrink around long content. +Design and implementation must agree on which panes may shrink, truncate, wrap, +or scroll. A clipped region also clips an outward focus ring; never trade away +keyboard visibility merely to simplify overflow. + +### Forms and settings + +Use a visible label for each field and place help or validation next to the +field it describes. Align related fields, but do not force long labels into a +narrow fixed column. Use the appropriate control: `Checkbox` for independent +choices, `RadioGroup` for a small visible set, `Select` for a longer set, and +`Switch` for a setting that takes effect immediately. + +Disable submission while an operation is in flight, keep the user's input, and +show the result near the action. Reserve dialogs for short, focused decisions; +use a full page or sheet for workflows that need exploration or many fields. + +A loading treatment must represent actual waiting. Switching between settings +panels whose content is already available should be immediate; a skeleton is +appropriate only while content is genuinely loading and its shape is known. + +## Components and composition + +Follow the Shadcn principle that components are building material rather than a +sealed design system. GPUI Component supplies coherent defaults, while the +application owns composition and product semantics. + +- Use component variants by meaning. Primary is reserved for the explicit + default commit in a decision area—normally the action invoked by Enter. A + lone, frequent, or desirable action is not automatically primary. An `Add` + command in a management toolbar normally uses a default Button; a form's + default `Create` commit may use primary. Use `danger` for destructive + commitment and `ghost` for quiet toolbar actions. +- Prefer explicit compound parts and render callbacks over styling arbitrary + descendants. +- Keep a repeated pattern consistent across the product. Wrap it in an + application component when it carries domain language or policy. +- Use the standard component for its semantic role. A menu, dropdown menu, + popover, select, and command palette are not interchangeable boxes; each owns + different selection, focus, keyboard, dismissal, and layout contracts. +- Preserve the component family's geometry. Menu rows share vertical and + horizontal padding, height, icon and checkmark slots, separators, radius, and + state treatment. Do not imitate one menu with a custom popup whose spacing + only approximates the system. +- Do not wrap a library component merely to rename every method or freeze all + of its capabilities. +- Move reusable behavior without product styling to `gpui-base`; keep themed, + opinionated presentation in GPUI Component or the application. + +## Interaction states + +### Make the result understandable before the click + +A control should predict its result. Use familiar desktop controls and +placement so people can act without learning the interface first. Its label +names the action and object, its state shows availability, and its feedback +confirms the same outcome. + +Do not label a Button `Save` if it opens a configuration flow, or `Delete` if +it only removes an item from a group. Name the scope when context does not make +it clear. Respond immediately to activation, prevent duplicate submission +during longer work, and show the result near the object that changed. Add a +success message only when the result itself is not visible. + +Every interactive control should be designed for: + +| State | Design requirement | +| --- | --- | +| Rest | Clear affordance without visual noise | +| Hover | Subtle pointer feedback, never the only cue | +| Pressed | Immediate press feedback | +| Open / pressed | Persistent feedback while an attached popup is open | +| Focus visible | High-contrast keyboard focus ring | +| Selected / checked | Persistent state distinct from hover | +| Disabled | Lower emphasis and no misleading hover/pressed response | +| Loading | Preserve context, prevent duplicate action, explain long waits | +| Error | State what happened and how to recover | + +Use GPUI's focus system and Actions for commands that should work from the +keyboard. Match familiar desktop shortcuts, expose shortcuts in menus or +tooltips, and keep focus in a logical place after opening or dismissing an +overlay. + +Selection is part of the information model, not optional polish. Tabs, +segmented choices, selectable rows, filters, and navigation destinations must +show a persistent selected state. A Button that owns a dropdown must remain +visibly pressed or open until the popup closes; hover alone cannot explain the +relationship between trigger and surface. + +In a segmented control, the selected fill and unselected surface must share +the container's silhouette. Neither may square off an end corner or make a +boundary appear heavier than its peers. + +Show a selected navigation item, list row, or tab through the item's own +surface: a selected fill, stronger foreground, or heavier weight. Do not add a +leading-edge bar or one-sided border as the selection marker. It is a web +template habit, not a desktop convention; it breaks the item's rounded +silhouette and adds a second, competing edge to a column that already aligns +on its text. + +For destructive actions, distinguish between reversible and irreversible work. +Prefer undo or a temporary notification for reversible changes. Use an +`AlertDialog` when the consequence is serious and cannot be undone; name the +specific object and consequence in the confirmation copy. + +### Pointer conventions + +Use the default arrow cursor for buttons, checkboxes, menu items, tabs, and +other native controls. Use a pointing hand for links and content that behaves +as a link. Use text, resize, grab, and prohibited cursors only when they +describe the active manipulation. A cursor reinforces an affordance; it does +not replace the control's visible state or accessible role. + +Keep hover effects modest because keyboard and accessibility interaction has no +hover. Do not reveal the only copy of a destructive or essential action on +hover. Contextual row actions may become quieter at rest if the same commands +remain available through selection, keyboard, or a context menu. + +### Prefer desktop command surfaces over hover toolbars + +Use command frequency and scope to choose where an action lives: + +- keep the primary or frequent action visible as a labeled Button or familiar + toolbar control; +- put secondary actions for the current region behind a visible + `DropdownMenu` trigger; +- put commands that act on the object under the pointer in a `ContextMenu`; +- expose the same important command through an Action/key binding when it has a + natural keyboard form; +- use a hover-revealed icon only as a shortcut to a command that remains + reachable elsewhere. + +This is more than a visual preference. GPUI Component's menu system already +owns directional keyboard navigation, confirmation and cancellation, disabled +items, separators, submenus, shortcut presentation, focus transfer and +restoration, and nested-menu dismissal. A custom strip of hover buttons must +rebuild those behaviors and is invisible to keyboard-only and many assistive +technology workflows. + +Choose `DropdownMenu` when users need a visible indication that more commands +exist—for example a toolbar overflow, document actions, or account menu. Choose +`ContextMenu` for selection- or object-scoped commands such as rename, +duplicate, reveal, or remove. The context menu must not be the only way to +perform an essential command; provide a menu-bar, toolbar, keyboard, or detail +view path as appropriate. + +Do not put every action into a menu to make a screen look minimal. Discovery +and speed matter: the main action stays visible, dangerous items remain clearly +labeled and separated, and a menu item should use the same verb, icon, shortcut, +enabled state, and result everywhere it appears. + +### Button means application action; Link means external resource + +Use a Button when activation changes application state, confirms a decision, +opens a tool, submits data, or runs a command. Choose its treatment by local +hierarchy: + +- primary Button for the one emphasized commitment in a decision area; +- default Button for ordinary visible actions; +- outline Button when an action needs a clear boundary with less emphasis; +- ghost Button for familiar, low-emphasis toolbar and inline actions; +- icon Button only for a well-known symbol, with an accessible name and a + tooltip when meaning or scope is unclear. + +Do not assign primary because a Button is the only action on screen, because it +is placed at the top right, or because the team wants more clicks. Primary +communicates default commitment and keyboard behavior. If activation is merely +an ordinary command such as adding an item, opening a tool, or refreshing a +view, use a default, outline, or ghost Button according to its local hierarchy. + +Use an underlined Link only for an external resource target: a URL, web page, +online documentation, or email address. It uses the pointing-hand cursor +because its contract is leaving the current application context for that +resource. Do not use Link styling to make a functional command look quiet. A +link-shaped Delete, Save, Refresh, Add, Open-menu, or in-app navigation action +hides the control's affordance and exposes the wrong accessibility role. + +“View” does not make an in-app destination a Link. A full report, analysis, +details panel, or local record still opens through a Button, row, card, tab, or +disclosure control. Use concise context-aware labels such as `Full analysis` +when the containing card already establishes what opens; reserve underlining +for a resource that actually opens in a browser or mail client. + +All internal navigation—sidebar rows, tabs, breadcrumbs, list items, opening a +local view, or switching workspaces—must use the corresponding native component +or a Button/Action. Visual emphasis is chosen through Button variant or the +navigation component's selected state, never by lying about semantics. + +## Feedback and overlays + +Choose the smallest surface that fits the decision: + +- tooltip: a short explanation or shortcut; +- popover: contextual controls that do not interrupt the task; +- menu: a compact list of actions; +- notification: asynchronous status that does not require a decision; +- dialog: a focused decision or short form; +- alert dialog: explicit confirmation of a consequential action; +- sheet: supplementary work that benefits from more persistent space. + +A tooltip supplies missing meaning; it does not restate an obvious action. +Reserve it for an unfamiliar symbol, ambiguous scope, or a useful shortcut +that is otherwise hidden. Repeated, recognizable commands such as Copy and +Download in a message footer need accessible names and keyboard access, but +their hover labels add interruption rather than clarity. + +An Alert interrupts the visual hierarchy even when it does not open a modal. +Use it for important, exceptional information that needs attention in the +current task, not as a decorated container for ordinary descriptions, tips, or +empty space. Prefer inline help, muted text, or a normal section when the +content does not require immediate notice or action. + +Avoid stacking overlays. Escape should dismiss the topmost dismissible layer, +and focus should return to the trigger or the next logical target. + +An overlay action must refer to an object or state the overlay actually shows. +For example, expose `Clear history` only when a distinct recent-history section +is visible and contains entries. Search results, recent items, and favorites +are different collections; label and separate them instead of merging them +into one unexplained list. Hide an inapplicable action or disable it with a +useful reason—do not park an ambiguous trash icon in a footer. + +Footer space is not a catch-all for capabilities that lacked a place in the +design. A footer may present shortcuts, status, or actions that apply to the +whole surface, but each item must answer: what is its object, why is it +available now, what scope does it affect, and what visible state changes after +activation? + +## Motion + +Motion explains change; it is not ambient decoration. Use short transitions for +appearance, dismissal, expansion, and spatial continuity. Avoid animating large +layout changes when opacity or transform communicates the same relationship. +Honor reduced-motion preferences, never require animation to understand state, +and do not add a default animation to every component. + +Motion policy belongs to the styled or application layer. Base may own the +lifecycle mechanism or geometry needed for a transition, but it should not +decide that every product fades or slides. Give independently animated values +stable identity, and make interruption reverse smoothly from the currently +sampled value rather than restarting from an old endpoint. + +Motion follows the continuity of the interaction. While a pointer or scroll +position moves among related targets, keep the preview surface stable and +update its content or position in place. Dismiss it only after the interaction +ends; repeated fade-out and re-entry on each update reads as flicker. + +## Designing data-heavy interfaces + +Dense does not mean cramped. In tables, trees, command palettes, editors, and +docks: + +- keep headers and primary row identity visually stable; +- align comparable values and use tabular numerals where appropriate; +- distinguish focus, hover, active row, and multi-selection; +- keep sorting and filtering visible and reversible; +- preserve selection by domain identity across filtering and reordering; +- virtualize large collections without changing keyboard semantics; +- use progressive disclosure for secondary columns and inspectors; +- provide a useful empty state that explains the next action. + +Choose a table for comparison across consistent fields, a list for scanning +heterogeneous items, a tree for real hierarchy, and a dock only when users need +to arrange long-lived tools or documents. Do not use a complex data component +as a visual style. + +## Interface language + +Words are part of the interface architecture. Write the vocabulary for a +feature as a system—destinations, objects, commands, states, and outcomes—not as +isolated translations of implementation features. Prefer the shortest wording +that remains accurate in its actual context. + +### Let context carry context + +Do not repeat information that the surrounding surface already establishes. A +sidebar destination is usually the object or domain itself: use `Users`, not +`User Management`; `Shortcuts`, not `Shortcut Configuration Management`. A +column whose rows already contain actions can omit a generic `Operation` +heading. A dialog titled `Delete “Roadmap”?` does not need body text that asks +the same question again. + +This is context economy, not deletion for its own sake. Add text when it changes +the decision: identify the affected scope, an irreversible consequence, an +unusual prerequisite, or a way to recover. Every extra word should answer a +question the current layout does not already answer. + +Use nouns for destinations and objects (`Users`, `Appearance`, `Orders`), verbs +for commands (`Save`, `Duplicate`, `Export`), and adjectives or short phrases +for states (`Offline`, `Up to date`, `Pending review`). Avoid wrappers such as +`Management`, `Module`, `Page`, `Function`, `Operation`, and `System` unless the +word distinguishes a real domain concept. + +### Write each language, do not translate its shape + +Start from shared intent, hierarchy, and terminology, then compose each locale +as natural interface language. Do not preserve the source language's word +order, number of words, politeness filler, or grammatical category. English +`Users` can express a Chinese feature concept that would literally expand to +“user management”; fidelity means preserving purpose, not preserving tokens. + +Remove words supplied by the enclosing information architecture. Inside a +`Settings` surface, a destination is often simply `Account`, not `Account +Settings` and never the unnatural singular `Account Setting`. The correct +English label is chosen from its role and neighbors, not from the standalone +source phrase. + +Maintain a small product lexicon for recurring objects, commands, and states. +Use the same term in the toolbar, menu, context menu, dialog, shortcut search, +and documentation unless the context genuinely changes its meaning. Review +copy in the rendered surface: neighboring labels often reveal repetition or +inconsistent scope that a locale file cannot. + +In localized technical writing, preserve an established framework term when a +translation would be less precise. Keep API identifiers in their original form +and format them as code. Do not retain ordinary foreign words merely to sound +technical. Explain a retained term on first use when needed, then use the same +form throughout the interface, documentation, and API examples. + +### Buttons and confirmation dialogs + +Button labels are short by default—usually one or two words—and describe the +result, not the gesture or the component. Prefer `Save`, `Move`, or `Delete` to +`Click to save`, `Perform move`, or `Confirm deletion`. Use `Cancel` consistently +for the action that leaves without committing. Reserve `OK` for acknowledging +purely informational content. + +Short is a default, not a character limit. A deliberately longer label is +better when its words expose a consequence or distinguish choices that users +could otherwise confuse, for example `Delete from this group` versus `Delete +everywhere`, or `Restart without saving`. Length must buy decision-critical +information; it must not restate the dialog title or body. + +Use the most specific concise result as the confirmation label when possible: + +| Context | Weak | Prefer | +| --- | --- | --- | +| Delete dialog | `Yes`, `Sure`, `Confirm deletion` | `Delete` | +| Unsaved changes | `Confirm`, `Yes` | `Discard changes` | +| Pure acknowledgement | `Confirm operation` | `OK` or `Done` | +| Complex consent whose result has no clear verb | `Yes` | `Confirm` | + +`Confirm` is a useful fallback when the surrounding dialog fully names a +complex commitment and no shorter result verb is accurate. It should not +replace a clear command. `Sure` is conversational rather than a stable English +command and is too ambiguous for the standard vocabulary. + +A confirmation dialog should form one compact decision: + +- title: the decision or condition, such as `Delete “Roadmap”?`; +- body: only new scope, consequence, or recovery information; +- actions: `Cancel` and the result, such as `Delete`; +- destructive styling: applied to the destructive result, not substituted for + precise wording. + +Avoid generic titles such as `Notice`, `Warning`, `Error`, and `Confirmation` +when the actual condition can be named. Avoid ritual phrases such as “Are you +sure you want to…”, “Would you like to…”, “Please note that…”, and “successfully” +when the structure or state already communicates them. Courtesy should come +from a calm, respectful tone, not repeated `please`. + +### Capitalization, punctuation, and symbols + +Use sentence case for English UI by default: `Reset layout`, not `Reset Layout` +or `RESET LAYOUT`. Preserve proper nouns and established acronyms. Follow a +platform convention such as title case for native menu commands only when the +platform integration benefits from it, and apply that convention consistently +within the component class. + +ALL CAPS can provide restrained typographic emphasis for very short section +labels, eyebrows, statuses, established acronyms, and code-like identifiers. +Its compact shape and measured tracking can form a level similar to bold type, +but it does not belong on Buttons, long headings, sentences, or dense lists. Do +not combine uppercase, strong color, and bold weight in the same region, and do +not transform every string automatically: product names, acronyms, and localized +content must preserve their intended casing. + +Labels, buttons, menu items, tabs, headings, placeholders, and short states do +not take a final period. Complete explanatory, warning, and error sentences do. +Avoid exclamation marks in routine success and failure messages. In Chinese, +use full-width punctuation in sentences and omit terminal punctuation from +short control labels by the same semantic rule. + +Use the single ellipsis character (`…`), not three periods. Append it to every +Button or MenuItem that opens a dialog, sheet, or separate window, and to a +command that requires more input or choices before it can complete, such as +`Settings…` or `Export…`. An immediately executed command does not take an +ellipsis. Use an indeterminate progress indicator, not decorative dots, to +communicate ongoing work. + +Errors should say what happened and, when useful, the next recovery action. +Success feedback should name the resulting state only when that state is not +already visible. Prefer `Couldn’t save. Check your connection and try again.` +to a technical code or a long apology; omit a `Saved successfully` toast when +the document visibly becomes saved. + +## Internationalization and platform fit + +Copy must survive expansion, CJK typography, and different shortcut notation. +Do not size a control from one English label. Keep text out of raster assets, +avoid concatenating translated fragments, and let labels wrap or truncate only +where the product defines a recovery path such as a tooltip. + +Respect platform differences that carry meaning: Command versus Control, +native window decorations, system appearance, scrollbar behavior, menus, and +notification capabilities. Keep the product's information architecture stable +across platforms, but do not erase familiar platform behavior for superficial +pixel equality. + +## Guidance for AI-generated interfaces + +An AI changing a GPUI interface should first inspect the nearest feature, +theme tokens, and component documentation. It should state the primary task, +state owner, component composition, and keyboard path before generating code. +It must not infer an API from React/Shadcn examples or invent a GPUI method +because the name seems plausible. + +AI output is incomplete until a human can explain why the hierarchy, density, +component choice, and exceptional literal values belong in this product. A +visually plausible screenshot is not proof: keyboard behavior, focus, dynamic +content, themes, resizing, and failure states are part of the design. + +## Accessibility checklist + +Before considering a screen complete, verify that: + +- every action is reachable and operable by keyboard; +- focus order follows visual and task order; +- focus remains visible and is restored after overlays; +- controls have accessible names; icon-only controls use tooltips when their + meaning, scope, or shortcut needs explanation; +- text and meaningful boundaries have sufficient contrast; +- status is not communicated by color alone; +- disabled and read-only states are distinguishable; +- labels, errors, and descriptions remain near their controls; +- content remains usable with longer translations and larger text; +- pointer targets are comfortably sized even in a dense layout. + +## Design review checklist + +A review does not inventory components; it judges whether the interface made +the right decisions. Ask, in order: + +1. **Is the task clear?** Can a new user recognize the purpose, primary action, + and next step without learning, guessing, or experimenting? +2. **Does every action keep its promise?** Do the label, control, state, scope, + feedback, and result describe one consistent outcome? +3. **Is hierarchy decisive and restrained?** Does the core feature receive the + space it deserves while strong color, bold type, badges, alerts, and primary + Buttons remain scarce? +4. **Could the interface do less, better?** Can an entry point, option, or state + be removed, combined, or deferred without weakening the complete task? +5. **Is the structure exact?** Do peers share alignment spines, equal gaps stay + equal to the rendered pixel, and scrollbars sit at the edge of their actual + scrolling region? +6. **Does it follow the component system?** Do standard controls retain their + geometry, states, keyboard behavior, and dismissal model, with appearance + supplied by theme and scale tokens? +7. **Does it remain usable in every state and constraint?** Verify keyboard and + focus behavior, empty/loading/failure/permission states, longer translations, + zoom, minimum window size, and reduced motion. +8. **Has it been tested in a real window?** Complete the task with real + components, copy, and representative content—not only an ideal screenshot. + +Continue with [Coding Guides](./coding-guides.md) to translate these design +decisions into GPUI architecture and code. diff --git a/docs/plans/gpui-kit-upgrade-goal.md b/docs/plans/gpui-kit-upgrade-goal.md new file mode 100644 index 0000000..00bd64a --- /dev/null +++ b/docs/plans/gpui-kit-upgrade-goal.md @@ -0,0 +1,796 @@ +# GPUI Kit Upgrade — Goal & Definition of Done + +Temporary planning file for the GPUI Kit 0.7.1 migration. For full +investigation/audit, see `docs/plans/gpui-kit-upgrade.md`. This file is the +target — what success looks like and how we know we got there. + +## The goal + +**Ship the desktop application on GPUI Kit v0.7.1 (one consumer dependency, +`gpui_kit::*` access) with every screen conforming to the Kit Coding and +Design Guides, without regressing existing product behavior, privacy +guarantees, or platform support.** + +A reviewer should be able to clone the repo, run `cargo build --locked +--features ui` on Windows/Linux/macOS, and use every feature that worked +before the migration. The UI should look like a coherent Kit application, +not a partially ported one. + +## Definition of done (all must hold) + +### Build & dependency health + +- [ ] Single GPUI Kit dependency (plus `gpui-wry`) for the UI feature. +- [ ] No remaining Zed/`gpui-component` Git pins; `gpui-kit` and `gpui-wry` + resolve from the same verified source. +- [ ] `cargo tree -d` shows no duplicate GPUI type family. +- [ ] `cargo fmt --all -- --check` clean. +- [ ] `cargo check --locked` passes for: default `ui`, `--no-default-features`, + `--features storage`, `--features cli`. +- [ ] CLI and storage features remain independent of UI dependencies. + +### Test gates (all green with `--locked`) + +- [ ] `cargo test --locked --no-default-features --features cli` — same 116 + tests this baseline ships with still pass. +- [ ] `cargo test --locked --features cli` — passes with no Windows + `Access is denied` blocking artifact cleanup. +- [ ] UI integration tests via `#[gpui_kit::test]` import Kit types + explicitly (no glob-shadowing Rust `#[test]`), mount production views + under Root, and assert real interactions. +- [ ] At least one UI test per surface: main manager, selection chooser, + clipboard chooser, lock/unlock, timer, command (start/stop/cron), + markdown edit/preview, file preview error, paint accessibility. +- [ ] Negative cases: disabled control rejects input; stale async work + cannot update destroyed state; protected content never appears in + accessibility/test observation; spelling-error cron is rejected + before scheduling; relock fires on focus loss. + +### Design conformance (Coding + Design Guides) + +- [ ] Every screen uses `cx.theme()` semantic tokens — zero raw `rgb(...)`, + `rgba(...)`, or hex inside UI code. The only exceptions are + documented in one place: domain layer (color enum), raster data, + platform insets, and WebView/physical-pixel constants. +- [ ] No `cursor_pointer()` on buttons, switches, checkboxes, tabs, rows, + or other native controls. Default arrow cursor restored. +- [ ] Focus rings visible on every interactive element. Search inputs do + not call `.focus_ring(false)`. +- [ ] Overlays (dialogs/sheets/menus/notifications) flow through Root in + every window. No application code mounts its own `render_dialog_layer`. +- [ ] Buttons use `Button` with semantic variants; `Link` is reserved for + external URLs/email. Icon-only buttons have accessible names; `Save` + buttons open settings, `Delete` deletes. +- [ ] Destructive clipboard operations use `AlertDialog` with object+verb + copy: `Delete "Roadmap"?` → `Cancel`/`Delete`. No `OK`, no + "Are you sure?" body text. +- [ ] Forms use `Form::new().child(Field::new().label(...).child(...))`. + Booleans use `.on_change` with the requested value, not click toggles. +- [ ] No task is created from `render()`. Every `cx.spawn(...).detach()` + has a documented owner and a cancellation/abort path. +- [ ] Long sticker collections virtualize with stable domain identity; + selection survives filter/reorder when the object still exists. +- [ ] Light theme, dark theme, larger base font, minimum/default/wide window, + scaled display, long translations, and CJK text verified per surface. + +### Privacy/security regression + +- [ ] Clipboard collection still defaults to paused. +- [ ] Protected content not visible in: previews, search results, accessibility + properties, test snapshots, debug logs, or stale async completions. +- [ ] Vault generation checks still atomic; relock still fires on focus loss; + owned clipboard still cleared on unlock expiry. +- [ ] Manual verification: capture an encrypted note, lock, force-unlock + attempt with wrong password yields no plaintext in any visible state. + +### Platform parity + +- [ ] Windows x86_64, Linux x86_64, macOS arm64 each build under + `cargo build --release --locked`. +- [ ] Transparent sticker corners, drag area, resize, pin/topmost, + global hotkeys, single-instance IPC, and clipboard paste-destination + restoration still work on each platform. +- [ ] WebView adapter still constructs, bounds correctly, hides cleanly, + routes external links out, and is destroyed without leaking when a + sticker closes. +- [ ] CI workflow updated with Kit's native prerequisites and a PR + validation gate that is independent of release-upload jobs. + +### Documentation + +- [ ] `README.md` reflects the actual dependency (`gpui-kit`) and links to + the authoritative Getting Started page. +- [ ] `ARCHITECTURE.md` updated to mention Kit layer names (`gpui_kit::*`, + `gpui_kit::component`, `gpui_kit::base`, `gpui_kit::assets`). +- [ ] Screenshots in `screenshots/` updated only after native visual review; + old screenshots removed or replaced, never left alongside. + +## Milestones (rough order; each is independently shippable) + +| # | Milestone | Validates | +| --- | --- | --- | +| 1 | Unblock artifact cleanup; rerun UI baseline; record its result | We know the real starting state | +| 2 | Replace dependency entries; preserve runtime behavior | Build + CLI/storage tests still green; UI looks like the baseline | +| 3 | Consolidate Root/overlay ownership; restore theme tokens | Every window has one overlay owner; design checklist passes | +| 4 | Converge main manager + selection chooser on Kit components | Manager UI test green; keyboard path covered | +| 5 | Converge clipboard chooser and vault/edit on Kit | Existing privacy tests stay green; new UI tests for the editor | +| 6 | Migrate sticker families one by one (md → timer → command → file → paint) | Per-family UI tests green; native smoke checks recorded | +| 7 | Platform parity verification + CI upgrade | All three release flows build; PR gate covers features | + +## Out of scope (explicit non-goals for this upgrade) + +- Adding GPUI Fast or other unreleased Kit features. +- Introducing a dashboard, sidebar navigation, dock, or rewriting the + app into a SPA-style shell. Sticker managers stay utility windows. +- Schema or data-format changes. Existing sticker payloads still load. +- Adding new sticker types beyond the existing seven. +- Clipboard collection default to **on**. Privacy default stays paused. +- Replacing SQLite or the CLI command surface with new machinery. They + pass through with minor adjustments only. +- Redesigning brand assets, icons (beyond the bundled Kit icon set), or + marketing pages. +- Translating copy. Internationalization shaping is fine, full i18n is not. + +## Stop conditions + +Stop and revisit the plan if: + +- Kit 0.7.1 reveals API breakage that forces hidden behavior changes in + clipboard, vault, or background scheduling. Do not paper over them. +- A platform-specific regression cannot be reproduced or fixed within one + milestone without delaying the others. Cut scope on that milestone + rather than carrying hidden bugs. +- A design-guide non-conformance is required to preserve a privacy or + correctness guarantee. Document the exception in the Design review + checklist and revisit when Kit gains the missing capability. + +## Reviewer checklist + +When this plan claims "done", the reviewer should be able to: + +1. Pull the repo, run `cargo build --locked --features ui`, launch the app. +2. Open a Markdown sticker, edit it, watch autosave and close-or-keep prompts. +3. Open a timer, set 5 seconds, see it count down, hear/see expiry, restart. +4. Run a command sticker; kill it; observe cancel + cleanup. +5. Capture clipboard content; verify it appears only when capture is on; + protect an entry; lock the app; attempt unlock. +6. Paint, switch colors, undo, save. +7. Resize/move sticker windows across two monitors; verify placement + records on next launch. +8. Trigger Escape on each overlay; confirm focus returns to the trigger. +9. Resize the window very small; verify the manager keeps all regions usable. +10. Toggle the system theme; verify both light and dark look coherent + without raw color leaking through. + +## Milestone 1 — initial baseline recorded (2026-10-06) + +Read [the plan](./gpui-kit-upgrade.md). Build the project from a clean tree, +run every gate listed under "Test gates" (CLI/storage first, then the UI +build), and record the result so a reviewer can compare it to whatever we +report at every later milestone. Do not edit `Cargo.toml`, `Cargo.lock`, or +any application source in this milestone. + +Exit: a written baseline log listing the exact commands run, exit codes, +test counts, and any blockers, plus a clean diff against the initial tree. + +### Baseline log (filled by this milestone) + +- Working tree: clean, only `docs/plans/gpui-kit-upgrade.md`, + `docs/plans/gpui-kit-upgrade-goal.md`, + `docs/plans/clipboard-coding-guides.md`, `docs/plans/clipboard-design-guides.md`, + `docs/plans/clipboard-history.md` were added; the rest + (`.agents/`, `.pi/`, `skills-lock.json`) was already untracked before this + work and is preserved. +- `cargo fmt --all -- --check`: clean. +- `cargo check --locked --offline --bins --examples --features ui`: + **passed** in 24s (`Finished dev profile`). This checks every UI bin + and example without producing the linked executable, which worked + around the build-artifact lock. +- `cargo check --locked --offline --no-default-features --features storage`: + passed. +- `cargo check --locked --offline --no-default-features --features cli --bin rusticker`: + passed. +- `cargo test --locked --offline --no-default-features --features cli`: + **116 tests passed** (97 lib + 18 clipboard storage + 1 placement), 1 + interactive terminal test ignored, 0 failed. +- `cargo test --locked --offline --features cli`: **blocked by Windows + lock on `target/debug/rustickers.exe`**. A `rustickers.exe` process + (PID 32556, started 10/6 15:31:48 with no visible main window) holds + the file. Cargo refuses to remove the build artifact. The lock is + environmental (live user process), not a code regression. +- Mitigation tested, not adopted: an isolated `--target-dir` at + `C:\Users\binwenw\AppData\Local\Temp\rustickers-baseline-target` is a + full cold rebuild that produced 6+ GB before linking. It would reproduce + the UI test result without touching the user-facing exe. We did not adopt + it because cold-rebuild runtime and disk usage are not justified to surface + a baseline that we already validate via `cargo check --bins --examples --features ui`. +- Orphaned processes: two `cargo.exe` (PIDs 2304 and 33568, both started + 10/6 15:31:31) are leftover from earlier attempts and should be reaped + before milestone 2, otherwise cargo may continue to hold tree locks and + burn CPU during rebuilds. +- `cargo tree --locked -d --features ui`: dependency tree dump shows the + current UI pinning (longbridge `gpui-component` 0.5.2 at + `aa7def675aa17b31ca7b79542c8e7a779ed3e56b`, Zed `gpui` 0.1 at + `5896274168e06663568f42b5bae142162936e0a6`). Confirms dependency baseline + is what we expect to replace. +- Oly-bash note: when running cargo through `oly start`, the propagated + `USERPROFILE` had its colon stripped (`C:Usersbinwenw`), which broke + cargo's default `CARGO_HOME` resolution. Bash and PowerShell see the + correct value. Use `cargo` from my interactive bash (no oly) or supply + `CARGO_HOME` explicitly when scripting through oly. Not a target defect. + +### Suggested cleanup before milestone 2 + +- Close the running `rustickers.exe` PID 32556 with the user's + permission, then kill the two orphaned cargo PIDs. After that + `cargo test --locked --offline --features cli` should be runnable in + the existing target dir. +- If the user prefers to keep the running instance, every consequential + cargo invocation in milestones 2–6 must use `--target-dir + C:\Users\binwenw\AppData\Local\Temp\rustickers-migration-target` so the + running instance is never touched. + +### Status (carried forward) + +Done. Next: migrate the dependency tree. + +## Milestone 2 — engine swap, behavior intact (2026-10-06) + +Read the audit at `docs/plans/gpui-kit-upgrade.md` and the run logs above. +Replace the legacy `gpui` / `gpui_platform` / `gpui-component` / `gpui-wry` +Git pins with the verified `gpui-kit` v0.7.1 facade and the matching +`gpui-wry` v0.7.1, leaving application behavior untouched. Do not redesign +any UI in this milestone — that is the goal of milestone 3. + +Exit: a tree that compiles with `cargo check --locked --features ui`, +`cargo build --locked --features ui` succeeds (the example included), all +preserved CLI/storage/integration tests still pass, and `cargo fmt` is clean. + +### Changes + +- `Cargo.toml`: dropped `gpui`, `gpui_platform`, `gpui-component` and the + four-step `gpui-wry` revision pin; added `gpui-kit` and `gpui-wry` both + path-pinned to the v0.7.1 layout of the upstream clone + (`C:\Users\binwenw\AppData\Local\Temp\rustickers-gpui-kit-upstream\crates\kit` + and `…\webview`). Path-pin is intentional: it avoids a fresh large fetch + during migration and lets us swap to `tag = "v0.7.1"` in the final pass. +- `src/lib.rs`: added namespace bridges so existing modules can keep + `use gpui::…` and `use gpui_component::…` while the import migration to + `gpui_kit::` happens file by file in later milestones: + - `#[cfg(feature = "ui")] extern crate gpui_kit as gpui;` + - `#[cfg(feature = "ui")] pub mod gpui_component { pub use gpui_kit::component::*; }` + - `#[cfg(feature = "ui")] extern crate gpui_wry;` +- 22 source files mass-rewritten: `use gpui_component::X` to + `use crate::gpui_component::X` (submodule paths do not reach the package + root by bare name; only extern crates do). Bare inline uses inside + function bodies and a `use gpui_component::IconNamed` inside a `#[test]` + were caught and fixed by hand. +- `src/native/mod.rs`: replaced + `Application::with_platform(gpui_platform::current_platform(false))` + with `gpui::application()`. Removed forbidden raw `rgb(0x151104)` + background override so the Kit theme owns the surface (recorded as + audited exception; will be replaced by a theme token in milestone 3). + Removed now-unused `Application` import. +- `src/native/windows/main.rs` and `src/native/windows/clipboard.rs`: + removed `.children(Root::render_dialog_layer(window, cx))` calls. The Kit + renders overlay layers through Root plugins registered by + `gpui_component::init`; the application's render chain no longer needs + to host the dialog layer explicitly. Renamed the now-unused render + parameters to `_window` / `_cx`. +- `examples/clipboard_ux_smoke.rs`: switched to `gpui_kit::{App, + application}` and `gpui_kit::component::{Theme, ThemeMode}` directly. + Removed the raw `rgb(0x151104)` literal; the example now uses the Kit + theme. + +### Result + +- `cargo check --locked --features ui`: **clean**, no warnings. +- `cargo build --locked --features ui --example clipboard_ux_smoke`: + **green** in 1m08s, builds the new Kit family end to end. +- `cargo test --locked --features cli`: + - `lib` unit tests: **188 passed**, 0 failed, 1 ignored. + - CLI bin unit tests: 0 passed (none defined). + - UI bin unit tests: 0 passed (none defined). + - `tests/clipboard_storage.rs`: **18 passed**, 0 failed. + - `tests/sticker_placements.rs`: **1 passed**, 0 failed. + - Doc tests: 0. + - **Total: 207 passed, 0 failed.** +- `cargo fmt --all -- --check`: clean. +- Dependency tree (`cargo tree --locked -d --features ui`): no duplicate GPUI + type families; everything routes through `gpui-pre 0.3.8` → + `gpui-kit 0.7.1` → `gpui-component 0.7.1` → `rustickers`. + +### Outstanding issues for later milestones (not regressions) + +- The bridges in `src/lib.rs` mask the import migration; they should be + removed in milestone 3 once imports move to `gpui_kit::…` and the audit's + raw-color / cursor-pointer / focus-ring violations are addressed. +- The `clipboard_ux_smoke` example and the kit avatar font setup still + rely on legacy Kil component method signatures; they will surface + during milestone 3 when we move across the manager and selection chooser. +- `gpui-kit` is path-pinned to the upstream clone. Final pass should + switch to `tag = "v0.7.1"` plus a vendored mirror if no registry token + is available; the choice is documented in `docs/plans/gpui-kit-upgrade.md`. + +### Status (rolling) + +Done. Migration target adopted. + +## Milestone 3 — manager + selection chooser on Kit tokens (2026-10-06) + +Replace raw colors, suppressed focus rings and pointing-hand cursors in +the main manager and selection popup with Kit theme tokens; one Root per +window; standard `Button` variants; virtual list is still TODO but with +the proper color/focus/cursor foundation laid. Behavior preserved; all +existing tests still green. + +Exit: every `rgba(`/`rgb(` in the manager and selection renders replaced +with `cx.theme().*` (or a domain token), every `cursor_pointer()` removed +in favor of the default arrow, every `.focus_ring(false)` removed, no +`Root::new(...).bg(transparent_black().alpha(0.0))` wrapper, and one Root +per top-level window. + +### Changes (manager `src/native/windows/main.rs`) + +- Dropped `cx.global_mut::().background = rgb(0x151104).into();` in + `src/native/mod.rs`. The Kit's default dark theme owns the manager + background; removing the literal unlocks Light theme support and theme + switching later without code changes. +- Manager card border: `rgb(0x3a3a3a)` → `cx.theme().border`. +- Manager card hover: `rgb(0x333333)` → `cx.theme().list_hover`, and the + hover `cursor_pointer()` was removed (design guide: default arrow). +- Manager surface: `bg(black().opacity(0.85))` → `bg(cx.theme().background)`. +- Title-bar sort/create/minimize/close/per-card-delete buttons: every + `.bg(rgba(0x00000000))`, `.border_0()`, `.opacity(0.8)`, `.cursor_pointer()` + cleared; replaced with `Button::ghost()` per the Kit quiet-action pattern. +- Search `Input`: dropped `.focus_ring(false)` and `.bg(rgba(0x00000000))` + so the kit Input paints its own background and the focus ring is visible. +- Delete-sticker confirmation rewritten for the Guide's destructive + pattern: + - Title is `Delete ""?` (object + verb; no `Are you sure…`). + - Body names the consequence ("This permanently removes the sticker + and its saved content."), dropping the empty suggestion. + - Destructive action is `Button::danger()` (was `.warning()` and + skipped destructive styling). + - Removed the raw `.bg(black().opacity(0.9))` overlay; the Kit dialog + owns its surface. + +### Changes (selection popup `src/native/windows/selection.rs`) + +- `Root::new(...).bg(transparent_black().alpha(0.0))` → plain root. +- Selected row background: `rgba(0xe5c23696)` → `cx.theme().list_active`. +- Hover background: `rgba(0xe5c23664)` → `cx.theme().list_hover`. +- Removed every row and close-button `cursor_pointer()`. +- Search `Input`: dropped `.focus_ring(false)` and + `.bg(rgba(0x00000000))` so the kit Input owns its presentation. +- Empty-state "no matching command stickers" text: + `rgba(0xffffff88)` → `cx.theme().muted_foreground`. +- Error hint text: `rgba(0xf87171ff)` → `cx.theme().danger`. +- Selection surface: `bg(black().opacity(0.85))` → `bg(cx.theme().background)`. +- Close buttons in both sub-views: `.bg(rgba(0x00000000))` + + `.border_0()` + `.cursor_pointer()` → `Button::ghost()`. +- `Textarea` inside the input view: dropped the explicit + `.bg(rgba(0x00000000))`, letting the kit Textarea own its surface. + +### Other + +- `src/native/windows/sticker/open.rs`: dropped the + `Root::new(...).bg(transparent_black().alpha(0.0))` wrapper on the + sticker window. The Root now uses the default surface; the inner view + still owns its `sticker.color.bg()` paint. Cleaned up the unused + `transparent_black` and `Styled` imports. + +### Tokens discovered in `ThemeColor` + +`cx.theme()` reached through `ActiveTheme` works as expected. Verified +semantic tokens actually present in `gpui-component` 0.7.1's +`ThemeColor`: `accent`, `accent_foreground`, `background`, `border`, +`danger`, `danger_foreground`, `foreground`, `muted`, `muted_foreground`, +`list`, `list_active`, `list_active_border`, `list_hover`, `warning`, +plus the dedicated `button_*` and `sidebar_*` family. So the manager and +selection now have access to the right roles; previously used raw colors +resolve to one of these. + +### Result + +- `cargo check --locked --bin rustickers`: clean. +- `cargo check --locked --no-default-features --features storage`: clean. +- `cargo check --locked --no-default-features --features cli --bin rusticker`: + clean. +- `cargo check --locked --features ui`: clean. +- `cargo build --locked --features ui --example clipboard_ux_smoke`: clean. +- `cargo test --locked --features cli`: 188 lib + 18 storage + 1 + placement integration = **207 passed, 0 failed, 1 ignored**. +- `cargo fmt --all -- --check`: clean. +- `cargo tree --locked --features ui`: single `gpui-pre 0.3.8` GPUI + family (`gpui-kit` → `gpui-base` → `gpui-component` → + `gpui-kit-assets` → `gpui-wry` → `rustickers`); no second GPUI + family resident in the dependency closure. + +### Outstanding for later milestones (deliberate, not regressions) + +- Manager sticker list is still eagerly rendered through + `v_flex().children(stickers.iter().map(card))`. With + `STICKER_LOAD_LIMIT = 10000` this scales incorrectly and prevents + adopting `List`/`VirtualList`, which is the proper foundation for + full keyboard navigation and accessibility (per the Design Guides' + "Every interactive control should be designed for" table). It is + scoped to the per-family refactors planned for milestone 5, and the + colour/background/focus/cursor cleanup done here lays the token + foundation so that refactor can use `ListItem` / `List::on_select` + cleanly. +- `StickerColor::bg()` and `StickerColor::swatch()` still live in + `src/model/sticker.rs` with raw `rgb(0x…)` literals. They are domain + identities (Yellow/Green/Blue/Pink/Gray categories the user has + already chosen), so the split is to leave the enum behind and move + the palette into a UI-owned theme module + (`src/native/theme/sticker_palette.rs`) that the model delegates to. + Planned for a dedicated cleanup pass once the manager list is + refactored onto `List`, since both touch the same files. +- Raw colors in clipboard chooser, sticker windows, paint, audio, + markdown, command/timer forms: out of scope for this milestone; each + family is on milestones 4 and 5. + +### Status (rolling) + +Done. Milestone 4 next. + +## Milestone 4 — clipboard chooser + vault/edit on Kit tokens (2026-10-06) + +Apply the token/focus/cursor cleanup from milestone 3 to the clipboard +chooser, the vault authorization dialog, and the clip editor dialog. +Preserve all clipboard privacy guarantees (redacted previews on +protected rows, vault generation checks, authorized-action lifecycle, +owned-clipboard clearing, and stale-preview avoidance). + +Exit: every raw color literal removed from +`src/native/windows/clipboard.rs` and `src/native/windows/clipboard/editor.rs`; +selection visual now reads as list tokens; row cursor / focus rings +restored; the password dialog and clip editor no longer paint +semi-transparent black backgrounds; the existing 18-test clipboard +storage suite and all other tests still pass. + +### Changes (chooser `src/native/windows/clipboard.rs`) + +- Selected-row border moved from `cx.theme().primary` (which carries an + accent connotation) to `cx.theme().list_active_border` (the + purpose-built token for an active list item). +- Selected-row background: raw `rgba(0xe5c23664)` → + `cx.theme().list_active`. +- Inactive-row "invisible" border and background: + `rgba(0x00000000).into()` → `cx.theme().transparent` (named token). +- Hover row: `rgba(0xe5c23632)` → `cx.theme().list_hover`. +- Removed the row's `.cursor_pointer()`; default arrow cursor per the + Design Guides (rows are actionable but not links). +- Main chooser surface: `bg(black().opacity(0.90))` → + `bg(cx.theme().background)`. +- Password-action dialog wrapper: dropped `.bg(black().opacity(0.95))` + — the Kit dialog paints its own surface. +- Removed unused imports (`black`, `rgba`). + +### Changes (editor `src/native/windows/clipboard/editor.rs`) + +- Editor dialog wrapper: dropped `.bg(black().opacity(0.95))`. +- Editor render body's `bg(black().opacity(0.90))` → + `bg(cx.theme().background)`. +- Removed unused `black` import. + +### Result + +- `cargo check --locked --bin rustickers`: clean. +- `cargo check --locked --no-default-features --features storage`: clean. +- `cargo check --locked --no-default-features --features cli --bin rusticker`: + clean. +- `cargo check --locked --features ui`: clean. +- `cargo build --locked --features ui --example clipboard_ux_smoke`: clean. +- `cargo test --locked --features cli`: 188 lib + 18 storage + 1 + placement = **207 passed, 0 failed, 1 ignored** (the 18 clipboard + storage tests in particular still green: `protected_storage_never_exposes_plaintext…`, + `protected_editor_cas_keeps_ciphertext_only…`, + `authenticated_unprotection_is_atomic…`, + `password_rewrap_requires_new_generation…`). +- `cargo fmt --all -- --check`: clean. +- Verification command `rg 'rgba|rgb\(0x|cursor_pointer|focus_ring\(false\)|black\(\)' src/native/windows/clipboard.rs src/native/windows/clipboard/editor.rs` + returns no matches. + +### Outstanding for later milestones (deliberate, not regressions) + +- `src/native/components/stickers/content_lock.rs` (the Markdown + sticker's password dialog) still uses raw `.bg(black().opacity(0.x))` + dialog wrappers and token-less surface colours. Same treatment will + land in the Markdown family pass during milestone 5. +- The `sticker/view.rs` chrome (per-sticker chrome), paint/audio, + command form sticky-actions, and timer color and animation surfaces + still use raw colours. Family-by-family under milestone 5. +- Clipboard chooser still uses its own row layout rather than Kit's + `List`/`ListItem`; the per-row hover state and row actions are correct + but the list does not get the full List-toolkit (focusable items, + keyboard navigation across rows, virtualization reuse). Virtualization + is not yet required for the chooser (count cap is small) but the + List adoption is still pending for the manager list (milestone 5). +- UI integration tests covering the editor save/cancel, password dialog + lifecycle, and `redact_protected_previews` flow are still pending + (`#[gpui_kit::test]`-based). They require `--features test-support` + enabled in dev-dependencies and a deliberate Kit 0.7.1 test pattern + choice. Tracked for milestone 6 alongside platform CI. + +### Status (rolling) + +Done. Milestone 5 (per-sticker families) next. + +## Milestone 5 — per-sticker families on Kit tokens (2026-10-06) + +Apply the same theme-token / focus / cursor / cursor-pointer / variant +audit-and-fixup that already landed in milestones 3 and 4 to each +sticker family individually, in plan order: markdown → timer → command +→ file → paint. Preserve product behaviour (auth, vault generation, +markdown autosave and protected-content relock, cron ownership, file +preview cancellation, paint stroke persistence). Document exceptions +(raster / domain data colors). + +Exit: every `.bg(rgba(...))` / `.border_color(rgb(...))` / +`gpui::red()` / `.bg(transparent_white() / black())` removed from chrome +in `markdown.rs`, `content_lock.rs`, `timer.rs`, `command/*.rs`, +`file/*.rs`, `sticker/view.rs`, `paint.rs`; data-color +applications left untouched and noted as audited exceptions. + +### Per-family changes + +**Markdown (`src/native/components/stickers/markdown.rs`)** +- Footer lock-control button: `.bg(rgba(0x000000))` + `.border_0()` + + `.cursor_pointer()` → `.ghost()`. The transparent-black raw paint + was a manual ghost imitation; the Kit variant does the same job. +- Editor `Textarea` in the markdown render pipeline: dropped the + `.bg(rgba(0x000000))` overlay; the kit Textarea paints its own surface + and respects theme tokens. + +**Markdown content-lock (`src/native/components/stickers/content_lock.rs`)** +- Error text in both the "lock" and "unlock" dialogs: `gpui::red()` → + `cx.theme().danger`. Added the `ActiveTheme` trait import in scope. +- Captured `cx.theme().danger` into a local `danger_color` per + render function so the closure passed to `when_some` doesn't need to + borrow `cx` inside it. + +**Sticker chrome (`src/native/windows/sticker/view.rs`)** +- Header close button: `.bg(rgba(0x000000))` + `.border_0()` + + `.cursor_pointer()` → `.ghost()`. +- Footer color-picker dots: `.cursor_pointer()` removed (default arrow; + swatches are actionable but not links). +- Adjustments to the footer render chain: also dropped the + `.border_0()` / `WindowControlArea::Drag` overlay that was carrying + the manual drag hint. The "drag" attribute is still on the parent + container; the dots simply announce the area without grabbing + pointer attention. +- "New sticker" header button: `.bg(rgba(0x00000000))` + `.border_0()` + + `.opacity(0.8)` → `.ghost()`. +- `.cursor_grab()` kept on the title-bar wrapper — per the Guide this + is exactly the case the grab cursor describes ("active manipulation"). + +**Timer (`src/native/components/stickers/timer.rs`)** +- Play / pause / resume / reset / restart footer buttons: every + `.bg(transparent_white())` + `.border_0()` collapsed into + `.ghost()`. The `transparent_white()` import is gone. +- The progress fill (`.bg(green_500().opacity(0.1))` and the + `green_500()` on finished) is left as-is: `green_500` is a Kit + palette color used in semantic meaning (active vs. expired). Without + rewriting the existing run/finish visual to use a theme token it + stays a deliberate product choice and the only color in the file + that is not a `cx.theme().*` token; documented as an audited + exception (semantic-success state). + +**Command (`src/native/components/stickers/command/`)** +- `command/output.rs` footer Start / Stop / Reset / Restart buttons: + `.bg(transparent_white())` + `.border_0()` collapsed into `.ghost()`. +- `command/form.rs`, `command/mod.rs`, `command/execution.rs`, etc.: + no raw chrome colors or `cursor_pointer()` were present. + +**File (`src/native/components/stickers/file/`)** +- `file/mod.rs` pin and refresh buttons: `.bg(rgba(0x000000))` + + `.border_0()` + `.cursor_pointer()` → `.ghost()`. +- `file/mod.rs` lock-control button: same treatment. +- `file/mod.rs` webview preview surface placeholder: + `.bg(rgba(0x000000))` → `.bg(cx.theme().transparent)` (selection of + a named "invisible" token instead of a literal zero-alpha). +- `file/preview.rs` `Editor`: dropped `.bg(gpui::rgba(0x000000))`; the + Kit Editor paints its own surface. +- `file/audio.rs` audio footer buttons: + - `.bg(rgba(0x00000000))` + `.border_0()` + `.cursor_pointer()` + collapsed into `.ghost()` for prev / play-pause / next. + - Play-mode toggle: the previous `rgba(0xffffff30)` / + `rgba(0x00000000)` raw tinted background replaced by Kit's + built-in `Button::selected(...)` (`Selectable` trait). This is + strictly better: it follows the selection affordance the Guide + prescribes for toggle buttons, and removes the only hex colour from + file/audio.rs. + +**Paint (`src/native/components/stickers/paint.rs`)** +- `eraser`/`pen` toolbar `Button`: `.bg(transparent_black())` + + `.border_0()` → `.ghost()`. +- Color-picker swatch selection state: `.border_color(rgb(0xffffff))` + → `.border_color(cx.theme().list_active_border)`; + `.border_color(rgba(0x00000000))` → + `.border_color(cx.theme().transparent)`; `.cursor_pointer()` removed + (a swatch is actionable but not a link). +- Stroke-width `make_dot` preserves its pure geometric interface + (no `cx`), but now takes the active-list-border token as a fourth + argument so the selection border can be themed. +- Stroke-width picker container: `.cursor_pointer()` removed. +- All other `rgba(...)` calls (`rgba(current_color)`, `rgba(c)`, + `rgba(stroke.stroke.color)`, `rgba(color)`) are **stroke data + colors** that the user has already picked for the canvas; they are + documented as the design-guide exception for raster / domain data + colours and stay. + +### Hygiene + +- Removed unused imports across the touched files: `transparent_black`, + `transparent_white`, `white`, `rgb`, `rgba` where they were not + still referenced. +- Added `ButtonVariants` and `Selectable` trait imports wherever + `.ghost()` / `.selected(...)` is called. + +### Result + +- `cargo check --locked --bin rustickers`: clean. +- `cargo check --locked --no-default-features --features storage`: clean. +- `cargo check --locked --no-default-features --features cli --bin rusticker`: + clean. +- `cargo check --locked --features ui`: clean. +- `cargo build --locked --features ui`: clean. +- `cargo build --locked --features ui --example clipboard_ux_smoke`: clean. +- `cargo test --locked --features cli`: 188 + 18 + 1 = **207 passed, + 0 failed, 1 ignored**. +- `cargo fmt --all -- --check`: clean. +- Sweep + `rg 'rgba|rgb\(0x[\dA-Fa-f]+|cursor_pointer|focus_ring\(false\)' src/native/windows/sticker/*.rs src/native/components/stickers/*.rs` + shows only the four documented stroke-data exceptions in + `paint.rs`. Every chrome-side literal has been removed. + +### Outstanding for milestone 6 (deliberate, not regressions) + +- Manager list still renders non-virtualised through + `v_flex().children(stickers.iter().map(card))`. Adopting + `List`/`VirtualList` needs its own pass to enable true keyboard + navigation, focusable list items and the existing accessibility + table from the Guide; deferred to milestone 6 alongside the CI / + UI-integration-test work. +- StickerColor `bg()` / `swatch()` raw palette still lives in + `src/model/sticker.rs`. Splitting presentation into a UI-owned + palette module is the same scope as the manager refactor; deferred. +- `#[gpui_kit::test]` UI integration tests still pending. They need + `gpui-kit`'s `test-support` wired into `dev-dependencies` (or a + dedicated UI test feature), and the choice between in-crate test + modules vs. `tests/ui.rs`. Milestone 6. +- macOS / Linux smoke checks not yet run from this host. They + require the platform-native prerequisites added in milestone 6. + +### Status (rolling) + +Done. Milestone 6 (palette split, CI, cross-platform) next. + +## Milestone 6 — palette split, crates.io Kit, CI gate, cross-platform (2026-10-06) + +Replace the deferred items in milestones 3–5 plus the platform-parity +and CI components of milestone 6. Production source remains single-dep +`gpui-kit`, every test gate still green, lint clean, and a multi-OS CI +matrix exercises the build and unit-test graph before any release. + +### Changes + +- **`src/native/theme.rs` (new)** — UI-owned sticker palette. The + surface tint (dark, desaturated) and the bright swatch for every + `StickerColor` live here as `sticker_background` and + `sticker_swatch`. The module also re-exports `gpui::Rgba` so + integration tests can refer to the colour type without depending on + `gpui` directly. +- **`src/native/mod.rs`** — added `pub mod theme;` so call-sites and + tests can reach the palette. +- **`src/model/sticker.rs`** — removed both raw `rgb(0x…)` tables. The + existing `StickerColor::bg()` and `::swatch()` become thin shims that + delegate to the UI palette. No call-site changes were required. +- **`Cargo.toml`** — switched from a path-pinned upstream clone of Kit + to the crates.io-published `gpui-kit = "0.7.1"` and `gpui-wry = + "0.7.1"`. With the new lockfile the project compiles, builds, and + tests clean under `--locked` against crates.io. +- **`tests/sticker_palette.rs` (new)** — three integration tests: + - `every_sticker_color_resolves_to_a_well_formed_palette_entry` + asserts each swatch is bright and each background is dark muted. + - `every_sticker_color_has_a_distinct_background_and_swatch` + verifies each of the five `StickerColor` variants has a unique + surface tint and unique swatch. + - `model_layer_agrees_with_the_ui_owned_palette` regression-checks + that `model::sticker::StickerColor::bg / swatch` cannot silently + drift from the UI palette module. +- **`.github/workflows/ci.yml` (new)** — multi-OS pull-request validation: + - `windows-latest`, `ubuntu-latest` (apt deps include + `libwebkit2gtk-4.1-dev` or `libwebkit2gtk-4.0-dev` plus + `libjavascriptcoregtk-…`, `libgtk-3-dev`, `libasound2-dev`, + `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, + `libxkbcommon-dev`, `libwayland-dev`, `libfontconfig1-dev`, + `libfreetype6-dev`), and `macos-14` (aarch64-apple-darwin, matching + the release host). + - Steps run on every PR push: `cargo fmt --check`, + `cargo check --locked --bin rustickers`, + `cargo check --locked --no-default-features --features storage`, + `cargo check --locked --no-default-features --features cli --bin rusticker`, + `cargo check --locked --features ui`, + `cargo test --locked --no-default-features --features cli`, + `cargo test --locked --features cli`, + `cargo build --locked --features ui --example clipboard_ux_smoke`, + and an enforcement step that fails the build if more than one + `gpui-pre` family version is present in the resolved closure. + - Caches `~/.cargo` per target via `Swatinem/rust-cache@v2`. + - Windows disables Cargo's path-length limit (`core.longpaths`) + before the gates run. + +### Why `gpui-kit`'s `test-support` is not wired into `dev-dependencies` + +The kit's `test-support` feature pulls in `gpui_web` (WASM) with a +predicate that conflicts with the `gpui-pre-web` version already pinned +in the locked closure (`unicode-properties = ^0.1.3` vs. locked `0.1.4`). +Resolution fails on Windows native build of the locked tree in this +environment. UI integration tests that mount real windows under the Kit +test framework are deferred to a follow-up that either (a) drops the +kit's web-feature in test crates, (b) vendors a compatible +`gpui-web`, or (c) moves the UI tests to a leaf test package so the +conflict does not poison the main build. The shipped `sticker_palette` +integration test exercises the migration's public boundary without that +feature. + +### Native smoke checks (must remain a maintainer task) + +The Windows runner in `cargo test --features cli` proves the +**link / runtime** works on one platform: the CLI tests run inside the +Kit application process and complete cleanly. Visual confirmation of +captured-clipboard content, transparent sticker corners, global +hotkeys, WebView composition, paste-destination restoration and +multi-monitor placement still needs the maintainer opening the app on +each platform. The release workflow (`release.yml`) already packages +Windows, Linux x86_64, and macOS aarch64 artifacts; the CI matrix now +catches compilation-and-test regressions before those release runs. + +### Result + +- `cargo check --locked --bin rustickers`: clean. +- `cargo check --locked --no-default-features --features storage`: clean. +- `cargo check --locked --no-default-features --features cli --bin rusticker`: + clean. +- `cargo check --locked --features ui`: clean. +- `cargo build --locked --features ui --example clipboard_ux_smoke`: clean. +- `cargo test --locked --features cli`: **207 passed, 0 failed, 1 ignored** + (188 lib + 18 storage + 1 placement). +- `cargo test --locked --features ui --test sticker_palette`: **3 passed**. + Single new integration boundary solid; total 210 distinct tests now + exercised on a single `cargo test --features ui` invocation. +- `cargo fmt --all -- --check`: clean. +- `cargo tree --locked --features ui --edges normal`: distinct + `gpui-pre` versions in closure = **`0.3.8`** (one). + +### Outstanding beyond the kit migration (deliberate, not regressions) + +- Manager list still renders non-virtualised through + `v_flex().children(stickers.iter().map(card))`. The right fix is a + dedicated refactor onto `List`/`VirtualList` so accessibility, keyboard + navigation, and large-collection rendering all come for free. It + touches the same files as future sticker-family changes so it's + scoped to a later plan. +- `#[gpui_kit::test]` UI integration tests covering the editor + save/cancel, the password dialog lifecycle, the vault authorization + rounds, the chooser selection/keyboard navigation, and the paint + swatches. Blocked on resolving `gpui-kit`'s `test-support` against + the platform closure; tracked above. +- Native visual smoke runs against the maintained release artifacts + on Windows, Linux, macOS hardware. The CI matrix catches regressions + in the locked build, but pixel, accessibility, IME, permissions, + WebView, global hotkeys, and multi-monitor placement still need + native hands-on review. + +### Goal achieved + +The application is shipped against the GPUI Kit `0.7.1` published on +crates.io with one consumer dependency (`gpui-kit`) plus its WRY +adapter (`gpui-wry`); every chrome surface is on theme tokens; the +sticker palette is owned by the UI layer with regression tests on the +model↔theme contract; a multi-OS CI matrix gates every PR before any +release artifacts are produced; and all preserved tests, including +the 18 clipboard privacy/storage tests, still pass. diff --git a/docs/plans/gpui-kit-upgrade.md b/docs/plans/gpui-kit-upgrade.md new file mode 100644 index 0000000..ff2990d --- /dev/null +++ b/docs/plans/gpui-kit-upgrade.md @@ -0,0 +1,186 @@ +# Whole-app GPUI Kit upgrade: investigation and plan + +Status: proposed; application implementation has not started. + +## Scope and invariants + +Upgrade the GUI to the latest verified tagged GPUI Kit release and bring every application surface into conformance with its Coding and Design Guides. Preserve the existing product: a lightweight sticker manager, independent document/utility windows, scheduled command execution, file previews, paint, and a keyboard-first clipboard chooser. Do not turn it into a dashboard or introduce a dock merely because Kit provides one. + +Non-negotiable regression boundaries: + +- Existing SQLite databases, serialized sticker content, sticker IDs, CLI commands, and placement records remain compatible. No schema migration is required merely to update the UI toolkit. +- Clipboard collection remains paused by default. Protected content must not leak through previews, search, accessibility properties, logs, test snapshots, or stale asynchronous completions. +- Preserve clipboard destination restoration, copy-only versus paste behavior, vault generation checks, automatic relocking, and owned-clipboard clearing. +- Preserve monitor-aware placement, transparent sticker corners, resizing, pin/topmost behavior, global shortcuts, single-instance IPC, and platform permission handling. +- Preserve background schedules when their windows close, process termination, streamed results, and hidden-window error recovery. +- The CLI-only and storage-only builds must remain independent of UI dependencies. + +## Installation and sources + +Read `README.md`, `ARCHITECTURE.md`, the local GPUI Kit Coding Guides and component conventions/recipes, and the Design Guides in full. + +Installed the requested skill with the non-interactive Pi target: + +```sh +npx skills add https://github.com/longbridge/gpui-kit --skill gpui-kit-design-guides --agent pi --yes +``` + +Installation succeeded at `.pi/skills/gpui-kit-design-guides/`. Existing `.agents/`, `skills-lock.json`, and clipboard guide/plan files predated this work and must be preserved. + +Primary upstream evidence: + +- [Release v0.7.1](https://github.com/longbridge/gpui-kit/releases/tag/v0.7.1), commit `87d10ae5e1299d1be18670a37570c5402f952e34`. +- [Tagged workspace manifest](https://github.com/longbridge/gpui-kit/blob/v0.7.1/Cargo.toml): Kit family version 0.7.1; exact `gpui-pre-*` snapshot 0.3.8; platform enables `font-kit`, X11, Wayland, and runtime shaders. +- [Tagged Kit manifest](https://github.com/longbridge/gpui-kit/blob/v0.7.1/crates/kit/Cargo.toml): default component/assets features; opt-in test support. +- [Tagged Kit façade](https://github.com/longbridge/gpui-kit/blob/v0.7.1/crates/kit/src/lib.rs): GPUI root exports, `base`, `component`, `assets`, `platform`, Kit-aware `actions!`, `init`, and `open_window`. +- [Tagged WRY manifest](https://github.com/longbridge/gpui-kit/blob/v0.7.1/crates/webview/Cargo.toml): `gpui-wry` 0.7.1 uses the same GPUI workspace dependency and `lb-wry` 0.53.3. +- [Getting started](https://gpui-kit.com/docs/getting-started), [Coding Guides](https://gpui-kit.com/docs/coding-guides), [Design Guides](https://gpui-kit.com/docs/design-guides), [Testing](https://gpui-kit.com/docs/test). Checked documentation/source in the upstream clone when remote documentation requests failed. + +Upstream was cloned to `C:/Users/binwenw/AppData/Local/Temp/rustickers-gpui-kit-upstream` for inspection. Observed main HEAD `c0bebdc8a8cf9a74f9f52feaca278a52a9cfcca2` still labels itself 0.7.1 but has substantial post-tag changes, including optional GPUI Fast. Do not mistake main documentation or its APIs for the tagged release. Verify any API used against the selected version. + +Registry publication could not be independently confirmed: crates.io API returned HTTP 403, and the local offline registry has no `gpui-kit` entry. An initial `cargo info` inside this repository started fetching the existing Zed dependency; it was interrupted before baseline commands ran. This is not evidence of an application failure. + +## Dependency decision + +Recommended target: tagged 0.7.1, not floating main. Prefer exact registry requirements for `gpui-kit` and `gpui-wry` if registry resolution confirms both releases exist and align. Otherwise pin both to the verified release commit in `https://github.com/longbridge/gpui-kit`. Use one consistent source strategy for the Kit family; do not mix a Git WRY package with a registry façade without proving GPUI identity. + +Replace direct `gpui`, `gpui_platform`, and `gpui-component` dependencies with optional `gpui-kit`, enabled only by `ui`. Application imports should use `gpui_kit`, `gpui_kit::component`, `gpui_kit::base`, and `gpui_kit::assets`. Retain `gpui-wry` as a separate optional adapter; Kit does not re-export it. Retain direct `lb-wry` only where the app genuinely constructs a native WRY builder, using the adapter-compatible version. + +Use the default upstream engine. GPUI Fast is not in the tagged Kit manifest and adds a second migration axis. Do not add it in this upgrade. + +Update `Cargo.lock` deliberately. Verify the dependency tree contains one active GPUI type family and no old Zed/longbridge Git dependencies. Inspect macro-generated crate paths, custom HTTP-client trait types, native raw handles, and macOS binding types during compilation; import replacement alone is not sufficient. + +## Application audit + +These are source-backed investigation findings, not a completed native visual review. The existing screenshot could not be visually evaluated in this environment. + +| Area | Current evidence | Migration/design direction | +| --- | --- | --- | +| Bootstrap (`src/native/mod.rs`) | Separate platform setup and component initialization; forced dark mode and custom background | Kit application/init entry points; retain assets/HTTP integration; coherent system/light/dark theme policy rather than forced dark | +| Main manager (`src/native/windows/main.rs`) | Transparent custom chrome; hard-coded row styling; input focus ring disabled; eagerly rendered scrollable sticker rows; manually rendered dialog layer under a Root | Compact single-workspace manager: search and ordinary Add command, scannable sticker rows, explicit overflow/object commands; virtualize large collections; retain visible keyboard focus; one overlay owner | +| Shared sticker chrome (`src/native/windows/sticker/`) | Custom drag areas, pointer-hand commands, low-level platform placement and transparent Root | Keep independent sticker windows and native behavior; standard command buttons/menus, explicit accessible labels and focus; audit clipping and corner transparency | +| Selection popup (`src/native/windows/selection.rs`) | Custom rows, keyboard selection, suppressed input focus ring, literal selection/status colors | Focused utility chooser with search, visible selection, keyboard activation and cancellation; selection tracked by sticker identity; use standard list/menu behavior where it fits the selection payload contract | +| Clipboard (`src/native/windows/clipboard.rs`, `clipboard/editor.rs`) | Already uses `uniform_list`, retained input state, and extensive privacy controls; literal selected/hover fills and leading-edge selection border; manual dialog layer under Root | Preserve virtualization and security rather than rewrite the backend; themed row-surface selection, coherent command menu, visible capture/vault state; edit/protect/unlock decisions with focus restoration | +| Markdown (`components/stickers/markdown.rs`, `content_lock.rs`) | Retained editor, async saves/encryption, custom controls and transparent backgrounds | Preserve editor state through redraws and edit/preview changes; standard named commands; save failure recovery; protected content accessibility and relock tests | +| Timer (`components/stickers/timer.rs`) | Retained selects; fixed-pixel controls, unlabelled icon commands, direct green palette, repeated completion animation; timer loop requested from render | Labeled time controls, semantic progress/status and keyboard commands; move task lifecycle to construction/state transitions; bounded/cancellable loop and reduced-motion behavior | +| Command (`components/stickers/command/`) | Existing useful runner/schedule/execution boundaries; legacy `field`/`v_form`/Textarea; boolean controls use click toggles; cron subscription detached | Current documented Form/Field and input APIs; assign requested boolean values with change callbacks; retain subscriptions/tasks with explicit owners; clear configuration/run/error states and safe duplicate-submit handling | +| File/URL/audio (`components/stickers/file/`) | Preview entities, watcher tasks, custom mouse-oriented playback/edit controls; async preview/summaries | Preserve native previews and external-resource semantics; standard controls/menus; empty/missing/loading/error/read-only states; cancel or reject stale refreshes; keyboard playback and editor access | +| WebView (`components/webview.rs`) | Retained `Entity<gpui_wry::WebView>`; native WRY construction, transparent rendering, local protocol and external browser routing | Upgrade adapter alongside Kit; verify bounds, DPI, focus, hiding, destruction, local-file access and external navigation on real platforms | +| Paint (`components/stickers/paint.rs`) | Custom drawing elements and color data; mouse-oriented swatches/tools; literal UI selection boundary | Keep genuine drawing geometry and persisted color data; Kit tool controls, accessible swatch names, keyboard tool/undo paths, themed selection rather than literal white | +| Sticker palette (`src/model/sticker.rs`) | Domain color enum includes hard-coded GPUI presentation colors | Keep serialized color identities in model; resolve their presentation in a UI-owned theme/token layer, with light/dark contrast | +| Release workflow | Windows/Linux/macOS release builds; no separate PR UI validation workflow | Add feature/build/test gates and update native prerequisites from the selected Kit snapshot; validate actual platform behavior separately | + +Raw drawing colors, raster dimensions, WebView geometry, platform insets, hit-test tolerances, and persisted physical window bounds can be legitimate exceptions. Document them. Ordinary UI spacing, typography, radius, selection and status colors are not exceptions. + +## Phased implementation + +### 0. Baseline and regression fixtures + +- Record current feature checks/tests before dependency changes; preserve the initial working tree. +- Build representative fixtures using temporary storage: empty and large collections, long Unicode titles, every sticker type, missing files, failed commands, locked vault, stale protected edit, and multiple monitor placement records. +- Record native manual paths for hotkeys, title-bar drag/resize, pinning, WebView, and clipboard paste destination restoration. +- No redesign, schema changes, or unrelated dependency updates in this phase. + +Exit: known baseline and fixtures; failures distinguished from tooling/environment limitations. + +### 1. Dependency and façade migration + +Files: `Cargo.toml`, `Cargo.lock`, all UI import sites (including feature-gated model/helpers and `examples/clipboard_ux_smoke.rs`). + +- Confirm registry availability or use the verified release revision consistently. +- Replace dependency features/imports and Kit-aware macros; preserve CLI/storage feature isolation. +- Resolve actual GPUI/Kit API changes incrementally from compiler diagnostics and tagged source. +- Upgrade WRY adapter and check native/HTTP type compatibility before UI restructuring. + +Exit: GUI and CLI compile; storage/CLI tests pass; dependency-tree audit passes. UI appearance remains intentionally close to baseline. + +### 2. Shared window, theme, focus, and lifecycle foundation + +Files: bootstrap, UI-owned theme/tokens, assets, shared chrome, all window openers. + +- Initialize Kit once before entities/windows. Give each window exactly one Root. Prefer Kit `open_window`; retain explicit wrapping only where transparent-root configuration requires it. +- Remove duplicated application overlay-layer rendering. Verify dialogs, menus, notifications and sheets actually appear and dismiss through Root. +- Preserve native handles, bounds persistence, topmost behavior, drag-area occlusion and platform shortcuts. Avoid using the title as an implicit rename control. +- Centralize sticker palette roles and transparent-surface policy. Use semantic theme roles and rem/component sizes for ordinary UI. Preserve the product's color identities without giving arbitrary status meaning to them. +- Retain subscriptions and cancellable tasks in their owner. Distinguish app-lifetime supervisors from view-lifetime work; eliminate task creation from render. Audit save-on-close paths that currently schedule work before persistence finishes. + +Exit: representative windows render in light/dark, overlays have one owner, keyboard focus survives open/dismiss, native smoke paths still work. + +### 3. Main manager and selection chooser + +- Primary tasks: find/create/open a sticker; select a compatible command for captured selection. +- Keep utility-window proportions rather than introduce unnecessary navigation. Define minimum/default/surplus size behavior. +- Virtualize large sticker collections with stable domain identity and zoom-aware measurement. Preserve selection when refresh/filter ordering changes where the selected object remains available. +- Provide ordinary visible Add/Open commands, object context menus and keyboard Actions. Secondary commands must not depend on hover. +- Restore search focus rings; distinguish selected, hovered, focused and disabled states. Align identity/status/action lanes and region-owned scrollbars. +- Make empty/loading/search-no-results/failure states actionable and precisely scoped. + +Exit: UI integration tests activate real production handlers; long/large lists, keyboard selection and resizing pass. + +### 4. Clipboard chooser and vault/edit flows + +- Preserve the current virtualized architecture and all security-generation/CAS checks. +- Separate recent history, search results, pinned/protected state and capture status clearly; commands must describe the collection/object they affect. +- Replace literal row treatments and selection bar with semantic surface selection. Restore focus on topmost overlay cancellation. +- Standardize copy/paste/edit/protect/unprotect/delete menus and shortcut labels. Keep critical commands reachable without hover. +- Keep busy/validation/failure state near the decision; prevent repeated commits while preserving input. Use confirmations only for consequential irreversible operations. +- Ensure protected values never enter accessibility/test observation. Test lock or generation change while a dialog/task is pending. + +Exit: existing privacy tests remain green; real UI interactions cover ordinary and protected paths with negative/stale cases. + +### 5. Every sticker feature + +Implement and verify one family at a time: Markdown/lock, timer, command, file/URL/audio/WebView, then paint. + +For each family, state the task, owning entity/model, component composition and keyboard path before editing. Keep existing feature boundaries; do not introduce crates merely to rename modules. + +- Markdown: input retention, edit/preview/save, password dialogs, relock and failure recovery. +- Timer: current input/select APIs, labeled accessible commands, task ownership, pause/resume/restart persistence, expiry and reduced motion. +- Command: Form/Field, controlled change callbacks, validation next to fields, streaming/run/stop/busy state, cron ownership, hidden-window recovery and background handoff. +- File/audio/WebView: watcher/preview cancellation, missing/offline/error views, accessible playback/source actions, local versus external navigation, native surface lifecycle. +- Paint: accessible tool palette and canvas instructions, keyboard commands and save feedback; keep actual stroke colors and geometry as data. + +Exit per family: relevant existing tests plus production-view UI tests and native interaction checks pass; no feature is left on compatibility-only styling. + +### 6. Whole-app verification, CI and documentation + +- Add PR validation separate from release uploading. Verify Windows x86_64, Linux x86_64, macOS arm64 using their actual toolchains/native prerequisites; include platform-specific permissions and prerequisites in README. +- Run the Design Guides' full accessibility/design checklists on every surface, not just the manager. +- Update README/architecture only to reflect delivered behavior/dependencies. Replace screenshots only after native review. + +Exit: feature matrix and regression matrix complete, with any unavailable native verification stated explicitly. + +## Verification matrix + +Automated gates (use `--locked`; offline only when the selected dependencies are cached): + +```sh +cargo fmt --all -- --check +cargo check --locked --bin rustickers +cargo check --locked --no-default-features +cargo check --locked --no-default-features --features storage +cargo check --locked --no-default-features --features cli --bin rusticker +cargo test --locked --no-default-features --features cli +cargo test --locked --features cli +cargo check --locked --examples +cargo tree --locked -d +``` + +Add Kit `test-support` from exactly the normal dependency's source/version. Verify that a development dependency does not inadvertently defeat CLI-only feature isolation; if necessary expose test support through a dedicated UI test feature rather than globally activating an optional GUI dependency. Write `#[gpui_kit::test]` with explicit type imports, avoiding a glob that shadows ordinary Rust `#[test]`. + +UI integration tests must mount production views under Root, dispatch actual click/key/input events, and assert visible state, focus/layout and application outcomes. Cover retained text after unrelated redraw, invalid submission, disabled/busy rejection, overlay Escape/focus return, stable selection, virtual scrolling, task cancellation and stale completion. Use injected storage/platform seams where needed, not duplicated fixture-only UI. + +Native verification remains separate: headless tests do not establish pixels, OS clipboard injection, system IME, accessibility quality, permission prompts, WebView composition, global hotkeys or multi-monitor placement. Review light/dark, larger base fonts, minimum/default/wide windows, display scaling, long/CJK content, reduced motion and failure states on each supported OS. Measure intended alignment and repeated gaps from resolved bounds instead of approving a casual screenshot. + +## Baseline results + +Before migration: + +- `cargo check --locked --offline --bin rustickers`: passed. +- `cargo test --locked --offline --no-default-features --features cli`: passed (97 unit tests, 18 clipboard storage tests, 1 placement integration test; 1 interactive terminal test ignored). +- `cargo fmt --all -- --check`: passed. +- `cargo test --locked --offline --features cli`: blocked during build because Cargo could not remove `target/debug/rustickers.exe` (`Access is denied`, Windows error 5). A running application or another file lock may be responsible; this is not a test assertion failure. Do not terminate the user's app automatically. Close/release the executable or use an isolated target directory before re-running the UI-enabled baseline. + +## Approval boundary + +This document completes the investigate-and-plan stage. No application source, manifest or lockfile migration has been made. Recommended next step: approve the tagged-release target and phased scope, then implement phase 1 before any visual redesign. Re-check the latest release at implementation time; if a newer release appears, update this evidence and compatibility gate rather than silently changing the target. From 8ceb813b13bb270157461f35f91db8721268935f Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:44:47 +0800 Subject: [PATCH 03/69] chore(skills): snapshot local longbridge/gpui-kit skills installed for this migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coding and Design guides from longbridge/gpui-kit were installed locally via `npx skills add …` and the resulting file trees under .agents/skills/ and .pi/skills/ are now tracked so the project's record of which skills shaped the migration is reproducible. skills-lock.json captures the source URL, skill path, and computed hash for each entry. This is a snapshot of external reference material; the migration audit in docs/plans/gpui-kit-upgrade.md is the authoritative summary. --- .../skills/gpui-kit-design-guides/SKILL.md | 60 ++ .../references/design-guides.md | 950 ++++++++++++++++++ .agents/skills/gpui-kit/SKILL.md | 277 +++++ .../gpui-kit/references/coding-guides.md | 911 +++++++++++++++++ .../skills/gpui-kit/references/conventions.md | 67 ++ .../skills/gpui-kit/references/gpui/action.md | 208 ++++ .../skills/gpui-kit/references/gpui/async.md | 225 +++++ .../gpui-kit/references/gpui/context.md | 258 +++++ .../references/gpui/element-advanced.md | 705 +++++++++++++ .../gpui-kit/references/gpui/element-api.md | 477 +++++++++ .../references/gpui/element-best-practices.md | 546 ++++++++++ .../references/gpui/element-examples.md | 632 ++++++++++++ .../gpui-kit/references/gpui/element-id.md | 95 ++ .../references/gpui/element-patterns.md | 509 ++++++++++ .../gpui-kit/references/gpui/element.md | 107 ++ .../references/gpui/entity-advanced.md | 528 ++++++++++ .../gpui-kit/references/gpui/entity-api.md | 382 +++++++ .../references/gpui/entity-best-practices.md | 537 ++++++++++ .../references/gpui/entity-patterns.md | 577 +++++++++++ .../skills/gpui-kit/references/gpui/entity.md | 218 ++++ .../skills/gpui-kit/references/gpui/event.md | 238 +++++ .../gpui-kit/references/gpui/focus-handle.md | 242 +++++ .../skills/gpui-kit/references/gpui/global.md | 196 ++++ .../gpui-kit/references/gpui/layout-style.md | 280 ++++++ .../gpui-kit/references/gpui/test-examples.md | 176 ++++ .../references/gpui/test-reference.md | 591 +++++++++++ .../skills/gpui-kit/references/gpui/test.md | 251 +++++ .agents/skills/gpui-kit/references/recipes.md | 131 +++ .agents/skills/gpui-kit/references/usage.md | 405 ++++++++ .pi/skills/gpui-kit-design-guides/SKILL.md | 60 ++ .../references/design-guides.md | 950 ++++++++++++++++++ skills-lock.json | 17 + 32 files changed, 11806 insertions(+) create mode 100644 .agents/skills/gpui-kit-design-guides/SKILL.md create mode 100644 .agents/skills/gpui-kit-design-guides/references/design-guides.md create mode 100644 .agents/skills/gpui-kit/SKILL.md create mode 100644 .agents/skills/gpui-kit/references/coding-guides.md create mode 100644 .agents/skills/gpui-kit/references/conventions.md create mode 100644 .agents/skills/gpui-kit/references/gpui/action.md create mode 100644 .agents/skills/gpui-kit/references/gpui/async.md create mode 100644 .agents/skills/gpui-kit/references/gpui/context.md create mode 100644 .agents/skills/gpui-kit/references/gpui/element-advanced.md create mode 100644 .agents/skills/gpui-kit/references/gpui/element-api.md create mode 100644 .agents/skills/gpui-kit/references/gpui/element-best-practices.md create mode 100644 .agents/skills/gpui-kit/references/gpui/element-examples.md create mode 100644 .agents/skills/gpui-kit/references/gpui/element-id.md create mode 100644 .agents/skills/gpui-kit/references/gpui/element-patterns.md create mode 100644 .agents/skills/gpui-kit/references/gpui/element.md create mode 100644 .agents/skills/gpui-kit/references/gpui/entity-advanced.md create mode 100644 .agents/skills/gpui-kit/references/gpui/entity-api.md create mode 100644 .agents/skills/gpui-kit/references/gpui/entity-best-practices.md create mode 100644 .agents/skills/gpui-kit/references/gpui/entity-patterns.md create mode 100644 .agents/skills/gpui-kit/references/gpui/entity.md create mode 100644 .agents/skills/gpui-kit/references/gpui/event.md create mode 100644 .agents/skills/gpui-kit/references/gpui/focus-handle.md create mode 100644 .agents/skills/gpui-kit/references/gpui/global.md create mode 100644 .agents/skills/gpui-kit/references/gpui/layout-style.md create mode 100644 .agents/skills/gpui-kit/references/gpui/test-examples.md create mode 100644 .agents/skills/gpui-kit/references/gpui/test-reference.md create mode 100644 .agents/skills/gpui-kit/references/gpui/test.md create mode 100644 .agents/skills/gpui-kit/references/recipes.md create mode 100644 .agents/skills/gpui-kit/references/usage.md create mode 100644 .pi/skills/gpui-kit-design-guides/SKILL.md create mode 100644 .pi/skills/gpui-kit-design-guides/references/design-guides.md create mode 100644 skills-lock.json diff --git a/.agents/skills/gpui-kit-design-guides/SKILL.md b/.agents/skills/gpui-kit-design-guides/SKILL.md new file mode 100644 index 0000000..c65738f --- /dev/null +++ b/.agents/skills/gpui-kit-design-guides/SKILL.md @@ -0,0 +1,60 @@ +--- +name: gpui-kit-design-guides +description: The normative Design Guides for GPUI Kit desktop applications. Read in full before designing or changing any screen, layout, spacing, visual hierarchy, color, density, component choice, interaction state, overlay, motion, data-heavy view, or interface copy in a GPUI Kit (gpui-kit / gpui-component) application, and before reviewing UI work for design quality. Also use when asked what the design rules are, or whether a UI decision follows them. +--- + +# GPUI Kit Design Guides + +The guide is [references/design-guides.md](references/design-guides.md). It is +a requirement, not inspiration. Read the guide file itself before doing UI +work. Do not answer from this page, from an existing screen in the codebase, or +from training data. + +If the file is missing, fetch `https://gpui-kit.com/docs/design-guides.md`. +The guide is a verbatim copy of that page, so its link to `./coding-guides.md` +means the Coding Guides in the `gpui-kit` skill (or `https://gpui-kit.com/docs/coding-guides.md`). + +## How to read it + +Read the whole guide for a new screen or a redesign. For a narrow change, read +"Design thesis" and "Start from the task" first, then the section for the +change. Sections, in order (`grep -n '^## ' references/design-guides.md`): + +| Section | Read when | +| ------------------------------------ | ---------------------------------------------------------------------- | +| Design thesis | Always | +| Learning from Shadcn | Choosing what to borrow from web component libraries | +| Start from the task | Always; task hierarchy, interaction promise, what to leave out | +| Visual language | Color, typography, spacing, radius, borders, elevation, density, icons | +| Layout patterns | Window structure, sidebars, toolbars, panels, forms, resizable regions | +| Components and composition | Picking a component, composing parts, when to build a new one | +| Interaction states | Hover, focus, pressed, selected, disabled, loading, validation, danger | +| Feedback and overlays | Dialog, sheet, popover, menu, notification, tooltip, dismissal, focus | +| Motion | Any animation or transition | +| Designing data-heavy interfaces | Tables, lists, trees, dashboards, dense inspectors | +| Interface language | Any user-facing text: labels, buttons, titles, errors, empty states | +| Internationalization and platform fit | Multi-locale copy, Chinese terminology, macOS/Windows conventions | +| Guidance for AI-generated interfaces | Always when an agent produces UI | +| Accessibility checklist | Before finishing | +| Design review checklist | Before finishing; run every item against the work | + +## Non-negotiables + +A floor, not a substitute for the guide. + +- **Desktop before web convention.** Keyboard access, window chrome, menus, + dense data views, resizable regions, persistent navigation. +- **`Button` vs `Link`.** `Button` for every in-app command, `ghost` or + `outline` when it should read quietly. `Link` only for external URLs and + email addresses. +- **Tokens before values.** No raw hex or `rgb(...)`; use `cx.theme()` + semantic tokens and rem-based helpers. Any spacing number quoted in the guide + is the current default scale, not a literal to repeat. +- **State must be visible.** Hover, focus, selection, disabled, loading, + validation, and destructive states each need distinct, consistent treatment. +- **Overlays.** Escape dismisses the topmost surface and returns focus to its + trigger. +- **Copy.** Name the object and the verb: `Delete "Roadmap"?` with a `Delete` + button, not `Are you sure?` with `OK`. + +Finish by running the Design review checklist against the work. diff --git a/.agents/skills/gpui-kit-design-guides/references/design-guides.md b/.agents/skills/gpui-kit-design-guides/references/design-guides.md new file mode 100644 index 0000000..fc3dc08 --- /dev/null +++ b/.agents/skills/gpui-kit-design-guides/references/design-guides.md @@ -0,0 +1,950 @@ +--- +title: Design Guides +description: Product and interaction design guidance for GPUI Kit applications +order: -2.1 +--- + +# Design Guides + +Use this guide before choosing components or writing layout code. It records +the product judgment accumulated through years of GPUI Kit desktop work: +an interface should feel native, restrained, precise, and understandable +without guesswork. + +This is a normative guide. **Must** identifies a correctness or ecosystem +constraint, **should** is the default that needs a concrete reason to override, +and **may** is an optional technique. Component API documentation remains the +authority for individual methods. + +The rules build on behavior in `gpui-base`, the GPUI Component theme and +component system, and familiar desktop interaction. Shadcn contributes useful +methods—open code, composition, and dependable defaults—but does not determine +how a GPUI application should look. When influences conflict, preserve GPUI's +lifecycle constraints and the interaction people already understand. + +## Design thesis + +Build interfaces that feel native, quiet, and precise. Let content, hierarchy, +and interaction carry the experience; decoration should support them rather +than compete with them. + +1. **Clarity before personality.** Make the primary task and next action clear + before adding brand expression. +2. **Composition before invention.** Start with established components and + compose them into product-specific workflows. Create a new primitive only + when its behavior is genuinely new. +3. **Tokens before values.** Colors, radii, typography, and spacing should form + a system. Avoid isolated literals that cannot respond to themes. +4. **Desktop before web convention.** Preserve keyboard access, window chrome, + menus, dense data views, resizable regions, and persistent navigation where + the task benefits from them. +5. **State must be visible.** Hover, focus, selection, disabled, loading, + validation, and destructive states need distinct and consistent treatment. +6. **Geometry explains containment.** Radii, insets, and boundaries should + describe one coherent hierarchy, even where surfaces overlap. +7. **Feedback reflects reality.** Loading, tooltips, and motion should clarify + a real state or transition, not add ceremony to an immediate action. + +## Learning from Shadcn + +Shadcn's most useful contribution is not a particular border color. It is a +way of building a system: + +- own the top layer of the interface instead of fighting a sealed abstraction; +- compose small, predictable parts into product-specific components; +- provide defaults that already form one visual language; +- keep the code and composition legible to both people and AI; +- separate behavior primitives from the styled layer. + +GPUI Kit applies those ideas through a Rust library and the split between +`gpui-base` and `gpui-component`. Applications normally compose or wrap the +published components; contributors move genuinely reusable behavior into Base +and keep visual policy above it. + +Do not copy these web assumptions blindly: + +| Web habit | Native GPUI default | +| --- | --- | +| Pointing-hand cursor on every button | Default arrow cursor; pointing hand for links | +| Page navigation as the main structure | Persistent windows, panes, sidebars, tabs, and menus | +| Browser focus and scrolling as a fallback | Explicit focus ownership and region-owned scrolling | +| Mobile-first single column | Resizable desktop shell with a defined minimum window size | +| Hover-revealed critical actions | Keyboard- and pointer-reachable actions that do not depend on hover | +| A row of hover-only icon buttons | A visible primary action plus `DropdownMenu` or `ContextMenu` for secondary commands | +| Link-styled text for application commands | `Button`, `outline`, or `ghost`; Link only for URLs, web resources, or email addresses | +| Large touch density everywhere | Medium density by default; compact only where information work benefits | +| CSS overrides across descendants | Typed builders, semantic parts, and application composition | + +## Start from the task + +Before drawing a screen, write down: + +- the user's primary task; +- the object being viewed or changed; +- the actions that must remain immediately available; +- the information required to make a decision; +- empty, loading, error, offline, read-only, and permission-denied states; +- the keyboard path through the workflow. + +Organize the window around those answers. Do not begin with a dashboard grid or +a component catalogue. A good desktop interface exposes the user's mental +model: documents, accounts, projects, messages, settings, or another stable +object—not the internal service architecture. + +Design the primary task before distributing controls. Its visual weight, +location, and information depth should match its importance to the product. A +core result must not be reduced to a small count, an icon in a corner, or a +weak footer action while secondary content consumes the page. When a result set +is the product's main value, consider a summary region or card that exposes the +count, representative results, meaningful state, and a clear next action. + +For every proposed action, name its visible object, current state, scope, and +result. If the interface does not show or clearly imply those things, the +action is premature. Do not expose a capability merely because the backend has +it; first design how the object enters the user's mental model. + +## Visual language + +### Hierarchy + +Prefer a small number of clear levels: + +- **window or page title** identifies the current object or workspace; +- **section title** separates meaningful regions; +- **body text** carries the work; +- **muted text** provides secondary metadata and help; +- **labels** identify controls and values. + +Use size, weight, spacing, and separators before adding color or containers. +Avoid nesting cards inside cards: most desktop regions need only a background, +a hairline boundary, and intentional spacing. + +Evaluate hierarchy across the whole feature, not component by component. Hide +accent color and decoration during review: the primary task, current selection, +result summary, and next action should still be obvious from structure. A +screen that contains individually plausible controls can still fail when they +do not form one reading order and one decision path. + +Treat emphasis as a limited budget. A local surface needs one clear focal point, +not a field of competing highlights. If everything is colored, badged, bold, +boxed, or promoted to an alert, nothing reads as important. Establish priority +with structure and proximity first; spend stronger color and components only +where a distinction changes what the user notices or does. + +### Color and themes + +Read colors from `cx.theme()` and use them by semantic role: + +- `background` and `foreground` for the main surface and text; +- `group_box`, `popover`, `sidebar`, and their foreground tokens for their + named surfaces; +- `muted` and `muted_foreground` for supporting information; +- `primary` for the principal action or selection emphasis; +- `danger`, `warning`, `success`, and `info` only for their meanings; +- `border`, `input`, and focus-ring tokens for structure and interaction. + +Do not use a semantic status color as decoration. Do not encode meaning by +color alone. Verify every custom surface in light and dark themes and with +custom theme values; never assume that foreground is black or background is +white. + +Use Badge for a short state, count, or classification that benefits from rapid +scanning—not for every label, metadata value, filter, or section title. Keep +most badges neutral; reserve semantic variants for states that truly carry +success, warning, danger, or informational meaning. A row of multicolored +badges is usually a missing hierarchy or grouping decision. + +Application UI should not contain raw hex, `rgb`/`rgba`, or `hsla` colors. +Resolve colors from `cx.theme()` by semantic role. If the required role does +not exist, define it in the product's theme/token layer rather than embedding a +palette value at the call site. Raw colors belong only inside theme definitions +or in audited data/raster content whose color is itself the data. + +### Radius, spacing, and density + +Use a finite radius scale, as with type and control sizes. Choose from the +theme's named tiers (`sm`, `md`, `lg`, `xl`, and larger surface tiers) instead +of inventing a radius for each element. Large containers may use a softer tier +than the controls they contain; compact controls and overlays use tighter +tiers. For an ordinary rectangle, the radius should remain visibly smaller +than half its shorter side. Use `radius_full()` only for intentional circles +and pills; a tooltip must not become a capsule by accident. + +Nested surfaces should read as concentric shapes. Use +`inner radius ≈ max(0, outer radius − gap)` to choose the closest suitable +theme tier; the visible band should remain even through the turn. Add a +named application token if a repeated composition needs an intermediate +value. A segmented control is one silhouette: its end segments follow the container's inner +corners, its middle segments remain square, and each boundary has one divider +of consistent thickness. + +<img class="alignment-light" src="/radius-hierarchy.svg" alt="Light theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center."> +<img class="alignment-dark" src="/radius-hierarchy-dark.svg" alt="Dark theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center."> + +A radius must govern the entire visible surface, not just its border. Exposed +corners remain transparent to the surface behind them; child backgrounds, +selection fills, and scrolling content must also follow the boundary. In GPUI, +do not assume a rounded parent clips its descendants. Give inner surfaces their +own radius or an appropriate clip, then inspect the composition against a +contrasting background in both themes. Preserve outward focus rings when +choosing a clip. + +Use a compact spacing scale and repeat it. Related label/control pairs should +be closer than separate groups; separate groups should be closer than separate +sections. Prefer component sizes (`xsmall`, `small`, default medium, `large`) +over one-off heights. Use compact variants for toolbars and data-dense screens, +not to squeeze an unclear layout into less space. + +The shared semantic scale is intentionally small: spacing progresses through +roughly 2, 4, 8, 12, 16, 24, and 32 pixels, while typography stays near 12, +14, 16, 18, and 20 pixels. Treat these as relationships rather than permission +to scatter their current values through feature code. GPUI Component currently +projects a fixed default `SpacingTokens` scale from its global `Theme`; unlike +colors and radii, `Theme::apply_semantic_tokens` does not persist a custom +spacing scale. An application that needs different spacing must own that full +token snapshot and use it consistently in its application components. + +### Spatial grammar + +Spacing expresses relationship. Choose a gap from the semantic scale by asking +what the two things mean to each other: + +| Relationship | Typical token | Current scale | Examples | +| --- | --- | --- | --- | +| Optical correction | `xxs` | 2 px | icon baseline, compact separator | +| Parts of one control | `xs` | 4 px | menu icon/label, title/description | +| Closely related controls | `sm` | 8 px | button icon/label, dialog actions | +| One content group | `md` | 12 px | notification columns, compact form rows | +| Separate groups in one section | `lg` | 16 px | panel padding, form groups | +| Separate sections | `xl` | 24 px | major blocks in a page or inspector | +| Major region boundary | `xxl` | 32 px | empty-state breathing room, page bands | + +These values describe the current default scale, not literals to repeat. Use +`cx.theme().spacing_tokens()` or the corresponding GPUI scale helpers for the +ecosystem default. A product-owned scale should preserve the ordering and +relationships and must be passed through the application's own design-system +context rather than assumed to persist in the global GPUI Component theme. + +Use these rules when resolving horizontal and vertical space: + +1. **Inside before outside.** A component's padding belongs to the component; + the gap between components belongs to their parent. +2. **Vertical rhythm shows grouping.** The gap between a title and its + description is smaller than the gap from that description to the next + section. Equal gaps imply equal relationships. +3. **Horizontal space supports scanning.** Repeated rows keep icons, labels, + values, badges, and trailing actions on stable columns. +4. **Leading and trailing are semantic.** Think in reading-order edges even + when the current API uses left/right; this keeps future RTL adaptation + possible. +5. **Do not double padding.** A card placed in an already padded panel should + not automatically add another full panel inset. +6. **Use optical alignment sparingly.** A one- or two-pixel correction is valid + for icon or glyph geometry, but document why it differs from the scale. + +Common compositions in the current system illustrate the relationships: + +- button contents use 4 px at small sizes and 8 px at normal sizes; +- dialog headers and footers use an 8 px internal gap; +- compact list and menu rows use 4 px vertical and 8–12 px horizontal padding; +- sheet headers use about 16 px leading and 12 px trailing space, leaving room + for a close affordance, while footers use 16 px horizontal and 12 px vertical + space; +- notifications use 16 px horizontal padding and a 12 px column gap because + icon, message, and action are distinct groups. + +Do not treat these as copy-and-paste recipes for every surface. They reveal the +system: controls are tighter internally, rows optimize scanning, and +containers spend more space at their boundary than between their contents. + +### Proportion and layer hierarchy + +Start with content requirements, then set proportions. Avoid arbitrary halves +when one pane has a clearly different role. + +- A navigation sidebar should be wide enough for stable labels but visibly + subordinate to the work area. Give it a minimum, preferred, and maximum + width rather than a percentage alone. +- In master–detail layouts, let the collection remain scannable and give the + detail pane the surplus. A roughly one-third/two-thirds starting point is + often useful, but content constraints are authoritative. +- Inspectors and auxiliary sheets should not cover the primary object by + default. They should be resizable or dismissible when their content grows. +- Dialog width comes from the decision: short confirmation, medium form, or a + dedicated window/page for complex work. Do not enlarge a dialog simply to + create whitespace. +- Reserve the strongest elevation for the topmost decision layer. Within a + layer, use background and hairlines—not successively larger shadows—to show + hierarchy. + +Define three size constraints for every major region: the minimum at which its +task still works, a comfortable default, and how it consumes surplus. Persist +user-controlled splits when they represent workflow preference, and clamp +restored values against the current window. + +### Alignment details + +Alignment is a structural system, not a final polish pass. Establish a small +set of alignment spines for each surface: shared leading and trailing edges, +text baselines, center lines, and fixed functional lanes. Elements at the same +level should attach to the same spine from top to bottom or leading to trailing, +even when they are different component types. + +<img class="alignment-light" src="/alignment-spines.svg?v=20260822-4" alt="Alignment spines across a desktop surface"> +<img class="alignment-dark" src="/alignment-spines-dark.svg?v=20260822-4" alt="Alignment spines across a desktop surface"> + +Vertical red lines sit beside shared edges or control centers for content, +status, time, and trailing actions. Horizontal lines sit beneath text baselines +or pass through a row center to show bottom and vertical-center alignment. The +compact comparison isolates a one-rendered-pixel drift that must be corrected +at its structural owner. + +- Give sibling regions a shared content inset. A heading, toolbar, list row, + empty state, and footer that describe the same level should not each invent a + slightly different leading edge. +- Repeat column geometry through the whole region. Headers, rows, summaries, + loading states, and inline editors should reserve the same lanes for identity, + metadata, status, numbers, and actions. +- Align related controls across rows and sections. Form labels, fields, + descriptions, and validation messages should reveal a stable vertical grid + when the page is scanned from top to bottom. +- Keep horizontal bands coherent. Items sharing a toolbar, title bar, status + bar, or row should use one baseline or center line instead of individually + tuned offsets. +- Introduce indentation only for real hierarchy, containment, or disclosure. + Decorative indentation makes siblings look subordinate and breaks the + surface's reading line. +- When a nested level ends, return exactly to the parent spine. Do not let + accumulated padding drift across nested containers. +- Preserve the spine through optional content. Missing icons, badges, + descriptions, or trailing actions must not move the remaining labels; use + intentional slots or lanes when cross-row comparison matters. +- Align major regions with one another where their hierarchy matches. Sidebar + headers, content titles, split panes, toolbars, and bottom bars need not share + every coordinate, but coincident levels should form visible continuous lines. + +Not every edge should align. A child can indent, a primary value can lead its +supporting metadata, and a destructive decision can gain separation. Such +exceptions must communicate hierarchy or meaning; they must not result from +uncoordinated component padding. Start with the shared spine, then make the +exception explicit. + +Treat exact alignment and repeated gaps as quality invariants. When two edges +or spaces are intended to be equal, a one-rendered-pixel difference is a defect, +not an acceptable optical approximation. Inspect resolved bounds with a +measurement tool at representative window sizes, zoom levels, and display scale +factors. Compare coordinates and distances; do not approve alignment only from +a casual screenshot. + +The rendered-pixel tolerance is a verification rule, not permission to patch +the code with raw pixel offsets. Equal relationships should resolve from the +same `rem` helper, spacing token, grid definition, or shared component inset. +Fix the common owner when they differ. Account for fractional layout and device +rounding so intended spines land on the same physical pixel instead of drifting +at particular zoom levels. + +- Align text by baselines, not by bounding-box centers, when mixed sizes share + a row. +- Center icons in a fixed slot so labels do not move when icons differ in + intrinsic width. +- Right-align comparable numbers; left-align prose and identifiers unless the + locale requires otherwise. +- Keep trailing row actions and disclosure indicators in fixed-width lanes. +- Align form controls by their interactive frame, not by help text below them. +- Use `justify_between` only when the two sides truly own opposite edges; it + should not disguise missing structure in the middle. +- Hairlines belong on the boundary owner. Two adjacent regions must not each + draw the same separator. +- A scrollbar belongs to the region that scrolls and sits against that panel, + editor, or window's trailing edge. Content padding may inset text and rows; + it must not pull the scrollbar into the middle of the surface. Reserve a + deliberate scrollbar gutter when content needs clearance. + +### Density tiers + +Medium is the ecosystem default. Change density for the whole local context, +not one isolated control: + +- **comfortable / large:** onboarding, sparse forms, prominent decisions; +- **standard / medium:** most application chrome and workflows; +- **compact / small:** toolbars, menus, tables, and repeated professional data; +- **extra compact / xsmall:** exceptional high-density utilities, never the + automatic choice for an entire application. + +The current controls demonstrate a bounded scale rather than arbitrary sizing: +buttons commonly move through approximately 20, 24, and 32 px frames; input +and data controls may extend to about 44 px at large size; table rows use about +26, 30, 32, and 40 px. Use the component's `Size` API so typography, icon, +padding, and hit target change together. A custom height that changes only the +outer box is usually incomplete. + +### Zoom, base font, and `rem` + +A well-designed `rem` system preserves hierarchy while the interface zooms. +Zoom is successful when the relationship between title and body, control and +icon, inner and outer spacing, primary and secondary regions still feels the +same at every scale—not merely when every object becomes larger. + +GPUI Component adopts the relative-scale idea familiar from Tailwind. The +theme's base `font_size` becomes the window's `rem` through `Root`, and GPUI +scale helpers such as `text_sm()`, `gap_2()`, `p_4()`, `h_8()`, and `size_4()` +resolve against it. This gives typography, spacing, controls, and icons one +shared zoom axis. + +Design in ratios: + +- type steps keep the same hierarchy around the base body size; +- spacing steps keep the same grouping relationships around the type; +- control frames, icons, and hit targets scale with their labels; +- pane minima and comfortable widths account for the scaled content; +- corner radii and focus treatment remain optically consistent with the + control frame. + +Do not implement zoom by changing text size alone. A larger label inside a +fixed-height button, a larger document inside fixed pane minima, or larger rows +inside a stale virtual-list measurement destroys the original rhythm and can +clip content. Conversely, multiplying every physical pixel—including +hairlines—can make the interface visually heavy. + +As a rule, application layout should not call `px(...)` directly. Use GPUI's +rem-based scale helpers (`p_2`, `gap_3`, `w_64`, `text_sm`, and related +builders) or semantic component sizes. Use fixed pixels only when the value +represents a physical or raster boundary: +a one-device-pixel hairline, platform window inset, bitmap dimension, minimum +hit-test tolerance, or geometry that must match an external surface. These are +audited, documented exceptions. Product spacing, typography, icon size, and +ordinary control geometry stay on the relative scale. + +Test interface zoom at several base-font values, not just the default. Verify +hierarchy, wrapping, truncation, minimum window size, pane resizing, focus-ring +clearance, popup placement, and virtualized row measurement. Also distinguish +interface zoom from Dock's panel zoom: Dock zoom makes one container fill its +area while retaining its chrome; it does not change `rem` or application scale. + +### Surfaces and elevation + +Use elevation to explain stacking, not importance. The base window surface is +flat; separators and background contrast define its regions. Popovers, menus, +dialogs, and notifications may use progressively stronger shadows because they +sit above other content. Do not put a shadow on every card. + +All surfaces of the same kind should share one treatment. GPUI Component, for +example, deliberately gives popup families one themed popover surface so +Popover, Select, Combobox, DatePicker, and menus do not drift apart. When an +application invents another anchored surface, reuse that semantic treatment +instead of approximating it with unrelated border and shadow literals. + +### Typography and icons + +Use the platform UI font for interface text and monospace only for code, +identifiers, shortcuts, and aligned numeric data. Keep body text readable and +avoid excessive uppercase or letter spacing, especially for CJK text. + +Use one icon family in a product. Icons supplement labels; they should not +replace unfamiliar actions with guesswork. Icon-only buttons always need an +accessible name. Their visual explanation follows the tooltip policy below. +Use filled or colored icons to communicate a state, not merely to make a +toolbar lively. + +## Layout patterns + +### Choose a stable shell + +Most applications should use one of these shells: + +- **single workspace:** toolbar or title bar above one primary view; +- **sidebar workspace:** persistent navigation beside a changing detail view; +- **master–detail:** resizable collection and detail panes; +- **document workspace:** tabs or a dock area for multiple long-lived objects; +- **utility window:** one focused task with a short, fixed action path. + +Keep global navigation stable while content changes. Give the primary work area +the remaining space with `flex_1()` and `min_w_0()` / `min_h_0()` where +overflowing children must shrink. Use `Scrollable`, `VirtualList`, `Table`, or +`DockArea` for their intended behavior instead of rebuilding scrolling or pane +management from nested `div`s. + +The title bar is window chrome first. Preserve its drag region and avoid +binding ordinary title clicks to infrequent editing commands. Rename belongs +behind an explicit object command, with a keyboard path where appropriate. + +### Responsive desktop windows + +Desktop does not mean fixed-size. Decide what happens as a window narrows: + +1. preserve the primary task; +2. allow resizable regions to reach a documented minimum; +3. collapse secondary labels or inspectors; +4. move low-frequency actions into a menu; +5. scroll only the region whose content actually overflows. + +Do not hide an action without providing another path to it. Avoid making the +entire window scroll when only a list or document body should scroll. + +GPUI flex layouts have the same intrinsic-size pressure found in other layout +systems: a `flex_1()` child may still refuse to shrink around long content. +Design and implementation must agree on which panes may shrink, truncate, wrap, +or scroll. A clipped region also clips an outward focus ring; never trade away +keyboard visibility merely to simplify overflow. + +### Forms and settings + +Use a visible label for each field and place help or validation next to the +field it describes. Align related fields, but do not force long labels into a +narrow fixed column. Use the appropriate control: `Checkbox` for independent +choices, `RadioGroup` for a small visible set, `Select` for a longer set, and +`Switch` for a setting that takes effect immediately. + +Disable submission while an operation is in flight, keep the user's input, and +show the result near the action. Reserve dialogs for short, focused decisions; +use a full page or sheet for workflows that need exploration or many fields. + +A loading treatment must represent actual waiting. Switching between settings +panels whose content is already available should be immediate; a skeleton is +appropriate only while content is genuinely loading and its shape is known. + +## Components and composition + +Follow the Shadcn principle that components are building material rather than a +sealed design system. GPUI Component supplies coherent defaults, while the +application owns composition and product semantics. + +- Use component variants by meaning. Primary is reserved for the explicit + default commit in a decision area—normally the action invoked by Enter. A + lone, frequent, or desirable action is not automatically primary. An `Add` + command in a management toolbar normally uses a default Button; a form's + default `Create` commit may use primary. Use `danger` for destructive + commitment and `ghost` for quiet toolbar actions. +- Prefer explicit compound parts and render callbacks over styling arbitrary + descendants. +- Keep a repeated pattern consistent across the product. Wrap it in an + application component when it carries domain language or policy. +- Use the standard component for its semantic role. A menu, dropdown menu, + popover, select, and command palette are not interchangeable boxes; each owns + different selection, focus, keyboard, dismissal, and layout contracts. +- Preserve the component family's geometry. Menu rows share vertical and + horizontal padding, height, icon and checkmark slots, separators, radius, and + state treatment. Do not imitate one menu with a custom popup whose spacing + only approximates the system. +- Do not wrap a library component merely to rename every method or freeze all + of its capabilities. +- Move reusable behavior without product styling to `gpui-base`; keep themed, + opinionated presentation in GPUI Component or the application. + +## Interaction states + +### Make the result understandable before the click + +A control should predict its result. Use familiar desktop controls and +placement so people can act without learning the interface first. Its label +names the action and object, its state shows availability, and its feedback +confirms the same outcome. + +Do not label a Button `Save` if it opens a configuration flow, or `Delete` if +it only removes an item from a group. Name the scope when context does not make +it clear. Respond immediately to activation, prevent duplicate submission +during longer work, and show the result near the object that changed. Add a +success message only when the result itself is not visible. + +Every interactive control should be designed for: + +| State | Design requirement | +| --- | --- | +| Rest | Clear affordance without visual noise | +| Hover | Subtle pointer feedback, never the only cue | +| Pressed | Immediate press feedback | +| Open / pressed | Persistent feedback while an attached popup is open | +| Focus visible | High-contrast keyboard focus ring | +| Selected / checked | Persistent state distinct from hover | +| Disabled | Lower emphasis and no misleading hover/pressed response | +| Loading | Preserve context, prevent duplicate action, explain long waits | +| Error | State what happened and how to recover | + +Use GPUI's focus system and Actions for commands that should work from the +keyboard. Match familiar desktop shortcuts, expose shortcuts in menus or +tooltips, and keep focus in a logical place after opening or dismissing an +overlay. + +Selection is part of the information model, not optional polish. Tabs, +segmented choices, selectable rows, filters, and navigation destinations must +show a persistent selected state. A Button that owns a dropdown must remain +visibly pressed or open until the popup closes; hover alone cannot explain the +relationship between trigger and surface. + +In a segmented control, the selected fill and unselected surface must share +the container's silhouette. Neither may square off an end corner or make a +boundary appear heavier than its peers. + +Show a selected navigation item, list row, or tab through the item's own +surface: a selected fill, stronger foreground, or heavier weight. Do not add a +leading-edge bar or one-sided border as the selection marker. It is a web +template habit, not a desktop convention; it breaks the item's rounded +silhouette and adds a second, competing edge to a column that already aligns +on its text. + +For destructive actions, distinguish between reversible and irreversible work. +Prefer undo or a temporary notification for reversible changes. Use an +`AlertDialog` when the consequence is serious and cannot be undone; name the +specific object and consequence in the confirmation copy. + +### Pointer conventions + +Use the default arrow cursor for buttons, checkboxes, menu items, tabs, and +other native controls. Use a pointing hand for links and content that behaves +as a link. Use text, resize, grab, and prohibited cursors only when they +describe the active manipulation. A cursor reinforces an affordance; it does +not replace the control's visible state or accessible role. + +Keep hover effects modest because keyboard and accessibility interaction has no +hover. Do not reveal the only copy of a destructive or essential action on +hover. Contextual row actions may become quieter at rest if the same commands +remain available through selection, keyboard, or a context menu. + +### Prefer desktop command surfaces over hover toolbars + +Use command frequency and scope to choose where an action lives: + +- keep the primary or frequent action visible as a labeled Button or familiar + toolbar control; +- put secondary actions for the current region behind a visible + `DropdownMenu` trigger; +- put commands that act on the object under the pointer in a `ContextMenu`; +- expose the same important command through an Action/key binding when it has a + natural keyboard form; +- use a hover-revealed icon only as a shortcut to a command that remains + reachable elsewhere. + +This is more than a visual preference. GPUI Component's menu system already +owns directional keyboard navigation, confirmation and cancellation, disabled +items, separators, submenus, shortcut presentation, focus transfer and +restoration, and nested-menu dismissal. A custom strip of hover buttons must +rebuild those behaviors and is invisible to keyboard-only and many assistive +technology workflows. + +Choose `DropdownMenu` when users need a visible indication that more commands +exist—for example a toolbar overflow, document actions, or account menu. Choose +`ContextMenu` for selection- or object-scoped commands such as rename, +duplicate, reveal, or remove. The context menu must not be the only way to +perform an essential command; provide a menu-bar, toolbar, keyboard, or detail +view path as appropriate. + +Do not put every action into a menu to make a screen look minimal. Discovery +and speed matter: the main action stays visible, dangerous items remain clearly +labeled and separated, and a menu item should use the same verb, icon, shortcut, +enabled state, and result everywhere it appears. + +### Button means application action; Link means external resource + +Use a Button when activation changes application state, confirms a decision, +opens a tool, submits data, or runs a command. Choose its treatment by local +hierarchy: + +- primary Button for the one emphasized commitment in a decision area; +- default Button for ordinary visible actions; +- outline Button when an action needs a clear boundary with less emphasis; +- ghost Button for familiar, low-emphasis toolbar and inline actions; +- icon Button only for a well-known symbol, with an accessible name and a + tooltip when meaning or scope is unclear. + +Do not assign primary because a Button is the only action on screen, because it +is placed at the top right, or because the team wants more clicks. Primary +communicates default commitment and keyboard behavior. If activation is merely +an ordinary command such as adding an item, opening a tool, or refreshing a +view, use a default, outline, or ghost Button according to its local hierarchy. + +Use an underlined Link only for an external resource target: a URL, web page, +online documentation, or email address. It uses the pointing-hand cursor +because its contract is leaving the current application context for that +resource. Do not use Link styling to make a functional command look quiet. A +link-shaped Delete, Save, Refresh, Add, Open-menu, or in-app navigation action +hides the control's affordance and exposes the wrong accessibility role. + +“View” does not make an in-app destination a Link. A full report, analysis, +details panel, or local record still opens through a Button, row, card, tab, or +disclosure control. Use concise context-aware labels such as `Full analysis` +when the containing card already establishes what opens; reserve underlining +for a resource that actually opens in a browser or mail client. + +All internal navigation—sidebar rows, tabs, breadcrumbs, list items, opening a +local view, or switching workspaces—must use the corresponding native component +or a Button/Action. Visual emphasis is chosen through Button variant or the +navigation component's selected state, never by lying about semantics. + +## Feedback and overlays + +Choose the smallest surface that fits the decision: + +- tooltip: a short explanation or shortcut; +- popover: contextual controls that do not interrupt the task; +- menu: a compact list of actions; +- notification: asynchronous status that does not require a decision; +- dialog: a focused decision or short form; +- alert dialog: explicit confirmation of a consequential action; +- sheet: supplementary work that benefits from more persistent space. + +A tooltip supplies missing meaning; it does not restate an obvious action. +Reserve it for an unfamiliar symbol, ambiguous scope, or a useful shortcut +that is otherwise hidden. Repeated, recognizable commands such as Copy and +Download in a message footer need accessible names and keyboard access, but +their hover labels add interruption rather than clarity. + +An Alert interrupts the visual hierarchy even when it does not open a modal. +Use it for important, exceptional information that needs attention in the +current task, not as a decorated container for ordinary descriptions, tips, or +empty space. Prefer inline help, muted text, or a normal section when the +content does not require immediate notice or action. + +Avoid stacking overlays. Escape should dismiss the topmost dismissible layer, +and focus should return to the trigger or the next logical target. + +An overlay action must refer to an object or state the overlay actually shows. +For example, expose `Clear history` only when a distinct recent-history section +is visible and contains entries. Search results, recent items, and favorites +are different collections; label and separate them instead of merging them +into one unexplained list. Hide an inapplicable action or disable it with a +useful reason—do not park an ambiguous trash icon in a footer. + +Footer space is not a catch-all for capabilities that lacked a place in the +design. A footer may present shortcuts, status, or actions that apply to the +whole surface, but each item must answer: what is its object, why is it +available now, what scope does it affect, and what visible state changes after +activation? + +## Motion + +Motion explains change; it is not ambient decoration. Use short transitions for +appearance, dismissal, expansion, and spatial continuity. Avoid animating large +layout changes when opacity or transform communicates the same relationship. +Honor reduced-motion preferences, never require animation to understand state, +and do not add a default animation to every component. + +Motion policy belongs to the styled or application layer. Base may own the +lifecycle mechanism or geometry needed for a transition, but it should not +decide that every product fades or slides. Give independently animated values +stable identity, and make interruption reverse smoothly from the currently +sampled value rather than restarting from an old endpoint. + +Motion follows the continuity of the interaction. While a pointer or scroll +position moves among related targets, keep the preview surface stable and +update its content or position in place. Dismiss it only after the interaction +ends; repeated fade-out and re-entry on each update reads as flicker. + +## Designing data-heavy interfaces + +Dense does not mean cramped. In tables, trees, command palettes, editors, and +docks: + +- keep headers and primary row identity visually stable; +- align comparable values and use tabular numerals where appropriate; +- distinguish focus, hover, active row, and multi-selection; +- keep sorting and filtering visible and reversible; +- preserve selection by domain identity across filtering and reordering; +- virtualize large collections without changing keyboard semantics; +- use progressive disclosure for secondary columns and inspectors; +- provide a useful empty state that explains the next action. + +Choose a table for comparison across consistent fields, a list for scanning +heterogeneous items, a tree for real hierarchy, and a dock only when users need +to arrange long-lived tools or documents. Do not use a complex data component +as a visual style. + +## Interface language + +Words are part of the interface architecture. Write the vocabulary for a +feature as a system—destinations, objects, commands, states, and outcomes—not as +isolated translations of implementation features. Prefer the shortest wording +that remains accurate in its actual context. + +### Let context carry context + +Do not repeat information that the surrounding surface already establishes. A +sidebar destination is usually the object or domain itself: use `Users`, not +`User Management`; `Shortcuts`, not `Shortcut Configuration Management`. A +column whose rows already contain actions can omit a generic `Operation` +heading. A dialog titled `Delete “Roadmap”?` does not need body text that asks +the same question again. + +This is context economy, not deletion for its own sake. Add text when it changes +the decision: identify the affected scope, an irreversible consequence, an +unusual prerequisite, or a way to recover. Every extra word should answer a +question the current layout does not already answer. + +Use nouns for destinations and objects (`Users`, `Appearance`, `Orders`), verbs +for commands (`Save`, `Duplicate`, `Export`), and adjectives or short phrases +for states (`Offline`, `Up to date`, `Pending review`). Avoid wrappers such as +`Management`, `Module`, `Page`, `Function`, `Operation`, and `System` unless the +word distinguishes a real domain concept. + +### Write each language, do not translate its shape + +Start from shared intent, hierarchy, and terminology, then compose each locale +as natural interface language. Do not preserve the source language's word +order, number of words, politeness filler, or grammatical category. English +`Users` can express a Chinese feature concept that would literally expand to +“user management”; fidelity means preserving purpose, not preserving tokens. + +Remove words supplied by the enclosing information architecture. Inside a +`Settings` surface, a destination is often simply `Account`, not `Account +Settings` and never the unnatural singular `Account Setting`. The correct +English label is chosen from its role and neighbors, not from the standalone +source phrase. + +Maintain a small product lexicon for recurring objects, commands, and states. +Use the same term in the toolbar, menu, context menu, dialog, shortcut search, +and documentation unless the context genuinely changes its meaning. Review +copy in the rendered surface: neighboring labels often reveal repetition or +inconsistent scope that a locale file cannot. + +In localized technical writing, preserve an established framework term when a +translation would be less precise. Keep API identifiers in their original form +and format them as code. Do not retain ordinary foreign words merely to sound +technical. Explain a retained term on first use when needed, then use the same +form throughout the interface, documentation, and API examples. + +### Buttons and confirmation dialogs + +Button labels are short by default—usually one or two words—and describe the +result, not the gesture or the component. Prefer `Save`, `Move`, or `Delete` to +`Click to save`, `Perform move`, or `Confirm deletion`. Use `Cancel` consistently +for the action that leaves without committing. Reserve `OK` for acknowledging +purely informational content. + +Short is a default, not a character limit. A deliberately longer label is +better when its words expose a consequence or distinguish choices that users +could otherwise confuse, for example `Delete from this group` versus `Delete +everywhere`, or `Restart without saving`. Length must buy decision-critical +information; it must not restate the dialog title or body. + +Use the most specific concise result as the confirmation label when possible: + +| Context | Weak | Prefer | +| --- | --- | --- | +| Delete dialog | `Yes`, `Sure`, `Confirm deletion` | `Delete` | +| Unsaved changes | `Confirm`, `Yes` | `Discard changes` | +| Pure acknowledgement | `Confirm operation` | `OK` or `Done` | +| Complex consent whose result has no clear verb | `Yes` | `Confirm` | + +`Confirm` is a useful fallback when the surrounding dialog fully names a +complex commitment and no shorter result verb is accurate. It should not +replace a clear command. `Sure` is conversational rather than a stable English +command and is too ambiguous for the standard vocabulary. + +A confirmation dialog should form one compact decision: + +- title: the decision or condition, such as `Delete “Roadmap”?`; +- body: only new scope, consequence, or recovery information; +- actions: `Cancel` and the result, such as `Delete`; +- destructive styling: applied to the destructive result, not substituted for + precise wording. + +Avoid generic titles such as `Notice`, `Warning`, `Error`, and `Confirmation` +when the actual condition can be named. Avoid ritual phrases such as “Are you +sure you want to…”, “Would you like to…”, “Please note that…”, and “successfully” +when the structure or state already communicates them. Courtesy should come +from a calm, respectful tone, not repeated `please`. + +### Capitalization, punctuation, and symbols + +Use sentence case for English UI by default: `Reset layout`, not `Reset Layout` +or `RESET LAYOUT`. Preserve proper nouns and established acronyms. Follow a +platform convention such as title case for native menu commands only when the +platform integration benefits from it, and apply that convention consistently +within the component class. + +ALL CAPS can provide restrained typographic emphasis for very short section +labels, eyebrows, statuses, established acronyms, and code-like identifiers. +Its compact shape and measured tracking can form a level similar to bold type, +but it does not belong on Buttons, long headings, sentences, or dense lists. Do +not combine uppercase, strong color, and bold weight in the same region, and do +not transform every string automatically: product names, acronyms, and localized +content must preserve their intended casing. + +Labels, buttons, menu items, tabs, headings, placeholders, and short states do +not take a final period. Complete explanatory, warning, and error sentences do. +Avoid exclamation marks in routine success and failure messages. In Chinese, +use full-width punctuation in sentences and omit terminal punctuation from +short control labels by the same semantic rule. + +Use the single ellipsis character (`…`), not three periods. Append it to every +Button or MenuItem that opens a dialog, sheet, or separate window, and to a +command that requires more input or choices before it can complete, such as +`Settings…` or `Export…`. An immediately executed command does not take an +ellipsis. Use an indeterminate progress indicator, not decorative dots, to +communicate ongoing work. + +Errors should say what happened and, when useful, the next recovery action. +Success feedback should name the resulting state only when that state is not +already visible. Prefer `Couldn’t save. Check your connection and try again.` +to a technical code or a long apology; omit a `Saved successfully` toast when +the document visibly becomes saved. + +## Internationalization and platform fit + +Copy must survive expansion, CJK typography, and different shortcut notation. +Do not size a control from one English label. Keep text out of raster assets, +avoid concatenating translated fragments, and let labels wrap or truncate only +where the product defines a recovery path such as a tooltip. + +Respect platform differences that carry meaning: Command versus Control, +native window decorations, system appearance, scrollbar behavior, menus, and +notification capabilities. Keep the product's information architecture stable +across platforms, but do not erase familiar platform behavior for superficial +pixel equality. + +## Guidance for AI-generated interfaces + +An AI changing a GPUI interface should first inspect the nearest feature, +theme tokens, and component documentation. It should state the primary task, +state owner, component composition, and keyboard path before generating code. +It must not infer an API from React/Shadcn examples or invent a GPUI method +because the name seems plausible. + +AI output is incomplete until a human can explain why the hierarchy, density, +component choice, and exceptional literal values belong in this product. A +visually plausible screenshot is not proof: keyboard behavior, focus, dynamic +content, themes, resizing, and failure states are part of the design. + +## Accessibility checklist + +Before considering a screen complete, verify that: + +- every action is reachable and operable by keyboard; +- focus order follows visual and task order; +- focus remains visible and is restored after overlays; +- controls have accessible names; icon-only controls use tooltips when their + meaning, scope, or shortcut needs explanation; +- text and meaningful boundaries have sufficient contrast; +- status is not communicated by color alone; +- disabled and read-only states are distinguishable; +- labels, errors, and descriptions remain near their controls; +- content remains usable with longer translations and larger text; +- pointer targets are comfortably sized even in a dense layout. + +## Design review checklist + +A review does not inventory components; it judges whether the interface made +the right decisions. Ask, in order: + +1. **Is the task clear?** Can a new user recognize the purpose, primary action, + and next step without learning, guessing, or experimenting? +2. **Does every action keep its promise?** Do the label, control, state, scope, + feedback, and result describe one consistent outcome? +3. **Is hierarchy decisive and restrained?** Does the core feature receive the + space it deserves while strong color, bold type, badges, alerts, and primary + Buttons remain scarce? +4. **Could the interface do less, better?** Can an entry point, option, or state + be removed, combined, or deferred without weakening the complete task? +5. **Is the structure exact?** Do peers share alignment spines, equal gaps stay + equal to the rendered pixel, and scrollbars sit at the edge of their actual + scrolling region? +6. **Does it follow the component system?** Do standard controls retain their + geometry, states, keyboard behavior, and dismissal model, with appearance + supplied by theme and scale tokens? +7. **Does it remain usable in every state and constraint?** Verify keyboard and + focus behavior, empty/loading/failure/permission states, longer translations, + zoom, minimum window size, and reduced motion. +8. **Has it been tested in a real window?** Complete the task with real + components, copy, and representative content—not only an ideal screenshot. + +Continue with [Coding Guides](./coding-guides.md) to translate these design +decisions into GPUI architecture and code. diff --git a/.agents/skills/gpui-kit/SKILL.md b/.agents/skills/gpui-kit/SKILL.md new file mode 100644 index 0000000..a67265b --- /dev/null +++ b/.agents/skills/gpui-kit/SKILL.md @@ -0,0 +1,277 @@ +--- +name: gpui-kit +description: 'How to build desktop applications with GPUI Kit, the Rust framework published as the gpui-kit crate (GPUI plus gpui_kit::component, gpui_kit::base, gpui_kit::assets). Use when setting up a gpui-kit app, choosing or using a component (Button, Input, Select, Dialog, Sheet, Tabs, Sidebar, List, DataTable, Tree, Chart, etc.), handling component state, theming, or window overlays, and for GPUI mechanics: actions and keybindings, async tasks, contexts, custom elements, entities, events, focus, global state, layout and styling, ElementId, and tests including UI integration testing. Holds the normative Coding Guides: read them before any architecture, state-ownership, public API, naming, or testing decision. Pairs with the gpui-kit-design-guides skill for the Design Guides.' +--- + +# GPUI Kit + +Applications depend on one crate, `gpui-kit`. GPUI is `use gpui_kit::*;`, and +each layer is reachable by name: `gpui_kit::component` (styled components), +`gpui_kit::base` (unstyled behavior), `gpui_kit::assets` (default icons), +`gpui_kit::platform`. + +Start with [component-family conventions](references/conventions.md) to choose the constructor, state owner, event, and layout contract for the task. Learn the family once, then verify the specific component supports the capability. + +For a complete compiled view with retained state, subscriptions, and overlay +layers, read [the tested application recipe](references/recipes.md). + +## Read the Guides First + +Two guides hold the rules this skill assumes. They are requirements, not +inspiration. Read the guide file itself; do not answer from this page, from a +similar file in the codebase, or from training data. + +| Guide | Read before | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| Design Guides, skill `gpui-kit-design-guides` | Choosing components, layout, spacing, hierarchy, color, density, interaction states, overlays, motion, interface copy | +| [Coding Guides](references/coding-guides.md) | Crate layering, `RenderOnce` vs `Entity<T>`, state ownership, `ElementId`, events, focus, async, public API, naming, testing | + +Read the Design Guides first when the change has a visible surface: code +structure preserves product intent, it does not replace it. If the design +skill is not installed, fetch `https://gpui-kit.com/docs/design-guides.md`. +The coding guide is a verbatim copy of `https://gpui-kit.com/docs/coding-guides.md`, +so its links to `./design-guides.md` and `./getting-started.md` mean the design +skill and `https://gpui-kit.com/docs/getting-started.md`. + +### Coding Guides section map + +Read the whole guide for a new crate, module, or feature. For a narrow change, +read "Architecture at a glance" and "Rules for coding agents" first, then the +section for the change (`grep -n '^## ' references/coding-guides.md`). + +| Section | Read when | +| ------------------------------------ | ---------------------------------------------------------------------- | +| Architecture at a glance | Always; crate layering, ownership boundary | +| Bootstrap and root ownership | `main`, `init`, `Root`, window creation, app-level state | +| Understand GPUI's phases and contexts | Anything touching `App`, `Window`, `Context<T>`, render vs update | +| Choose the right unit | Deciding `RenderOnce` vs `Entity<T>` vs custom `Element` | +| State ownership | Where a piece of state lives, who mutates it, `Entity<State>` handles | +| Stable identity | `ElementId`, lists, repeated elements, keyed state | +| Rendering and composition | `render`, builder chains, `when`/`map`, child composition | +| Behavior and presentation boundary | `gpui-base` vs `gpui-component` vs application code | +| Theme and styling | `cx.theme()`, tokens, `Styled`, sizes, variants | +| Events, actions, and focus | `cx.emit`, `subscribe`, `actions!`, keybindings, `FocusHandle` | +| Async work and side effects | `cx.spawn`, `background_spawn`, `Task`, I/O, timers | +| Layout, measurement, and scrolling | Flex layout, sizing, `overflow`, scroll handles, measuring | +| Lists, tables, and large data | `VirtualList`, `List`, `DataTable`, delegates, large collections | +| Public API design | Anything `pub`: builders, private fields, setter and reader naming | +| Platform and capability boundaries | macOS/Windows/Linux/wasm differences, feature gates | +| File and naming conventions | New files, modules, type and method names, `Kind` suffix, `Context` | +| Testing strategy | What to test, `#[gpui_kit::test]`, `TestAppContext` | +| Performance rules | Render cost, allocation, re-render triggers | +| Common failure modes | Before finishing; invented APIs, state in render, index ids | +| Rules for coding agents | Always when an agent writes code | +| Implementation checklist | Before finishing; run every item against the work | + +### Non-negotiables + +A floor, not a substitute for the guides. + +- **Never invent an API.** Search the current source for the real signature. + Do not translate a React, CSS, or older-GPUI example by analogy; a + plausible-looking method that does not exist is the most common failure. +- **One dependency.** Applications depend on `gpui-kit` alone. GPUI is + `use gpui_kit::*;`; the layers are `gpui_kit::component`, `gpui_kit::base`, + `gpui_kit::assets`, `gpui_kit::platform`. +- **Framework owns behavior, application owns presentation.** Do not put + colors, sizing, or layout in `gpui-base`; do not put interaction behavior in + application styling code. +- **Stable identity.** Repeated elements need domain-derived `ElementId`s, not + list indexes. +- **No `pub` fields across the seam.** Public data types use builders and + reader methods. +- **Spell `Context` out.** `cx` is GPUI's; name anything else after what it + holds. + +## Documentation + +- **Find a task/component page**: use `https://gpui-kit.com/llms.txt`, then load its Markdown page. +- **Full reference**: `https://gpui-kit.com/llms-full.txt` is available when broad reference is actually needed +- **Per-component API**: fetch `https://gpui-kit.com/component/{name}.md`, + e.g. `button.md`, `input.md`, `select.md`, `dialog.md`, `data-table.md` +- **Any site page** can be fetched as Markdown by appending `.md` to the URL + +## Quick Reference + +Setup and examples: [references/usage.md](references/usage.md). + +```rust +use gpui_kit::*; + +gpui_kit::application() + .with_assets(gpui_kit::assets::Assets) + .run(|cx| { + gpui_kit::init(cx); // first, before anything else + gpui_kit::open_window(options, cx, |window, cx| { + cx.new(|cx| AppView::new(window, cx)) + }) + .expect("failed to open window"); + }); +``` + +- **Stateless** (`RenderOnce`): build in `render`: + `Button::new("save").primary().label("Save").on_click(|_, _, _| {})` +- **Stateful**: hold `Entity<State>` in the view, pass a reference in `render`: + `let input = cx.new(|cx| InputState::new(window, cx));` then `Input::new(&self.input)` +- **Sizes**: `.xsmall()` `.small()` `.medium()` (default) `.large()` +- **Theme**: `cx.theme().primary` · `.background` · `.foreground` · `.border` · `.muted` +- **Overlays**: `window.open_dialog(...)`, `open_sheet(...)`, `push_notification(...)` + via `gpui_kit::component::WindowExt` + +## Component Catalog + +Import paths are relative to `gpui_kit::component::`, so `input::{Input, InputState}` +means `use gpui_kit::component::input::{Input, InputState};`. For the full API +fetch the component's `.md` doc. + +### Input & Form + +| Component | Import | Notes | +| ------------- | ----------------------------------------------- | -------------------------------------------- | +| `Input` | `input::{Input, InputState}` | Stateful. Text, password, mask, validation | +| `Textarea` | `input::{Textarea, TextareaState}` | Stateful. Multi-line text | +| `Editor` | `input::{Editor, EditorState}` | Stateful. Code editor, `tree-sitter` feature | +| `NumberInput` | `input::{NumberInput, NumberInputEvent}` | Stateful. Numeric with step | +| `OtpInput` | `input::OtpInput` | Stateful. One-time password | +| `SpeechButton` | `speech::{SpeechButton, SpeechState}` | Stateful. Dictation, `speech` feature | +| `Select` | `select::{Select, SelectState}` | Stateful. Dropdown picker | +| `Combobox` | `combobox::{Combobox, ComboboxState}` | Stateful. Searchable select | +| `Checkbox` | `checkbox::Checkbox` | Stateless. `on_click` receives `&bool` | +| `Switch` | `switch::Switch` | Stateless. Toggle | +| `Radio` | `radio::{Radio, RadioGroup}` | Stateless. | +| `Slider` | `slider::{Slider, SliderState}` | Stateful. | +| `Toggle` | `button::Toggle` | Stateless. | +| `Rating` | `rating::Rating` | Stateless. | +| `Stepper` | `stepper::Stepper` | Stateless. Multi-step progress | +| `ColorPicker` | `color_picker::{ColorPicker, ColorPickerState}` | Stateful. | +| `DatePicker` | `date_picker::{DatePicker, DatePickerState}` | Stateful. | +| `TimeField` | `time_field::{TimeField, TimeFieldState}` | Stateful. Time of day, 24/12-hour | +| `Calendar` | `calendar::{Calendar, CalendarState}` | Stateful. Inline month view | +| `Form` | `form::{v_form, h_form, field}` | Layout container for form fields | + +### Display & Feedback + +| Component | Import | Notes | +| ----------- | ----------------------------------------- | ------------------------------------- | +| `Button` | `button::{Button, ButtonGroup}` | Stateless. Primary UI action | +| `Icon` | `{Icon, IconName}` | Stateless. Lucide icons | +| `Badge` | `badge::Badge` | Stateless. | +| `Tag` | `tag::Tag` | Stateless. Closable tags | +| `Avatar` | `avatar::Avatar` | Stateless. | +| `Label` | `label::Label` | Stateless. Form label | +| `Kbd` | `kbd::Kbd` | Stateless. Keyboard key display | +| `Alert` | `alert::Alert` | Stateless. Info/success/warning/error | +| `Spinner` | `spinner::Spinner` | Stateless. Loading indicator | +| `Skeleton` | `skeleton::Skeleton` | Stateless. Loading placeholder | +| `Shimmer` | `shimmer::{ShimmerText, ShimmerStyle}` | Stateless. Streaming-text shimmer | +| `Marker` | `marker::{Marker, MarkerVariant}` | Stateless. Inline status marker | +| `Progress` | `progress::{Progress, ProgressCircle}` | Stateless. | +| `Tooltip` | `tooltip::Tooltip` | Via `.tooltip()` on elements | +| `HoverCard` | `hover_card::{HoverCard, HoverCardState}` | Stateful. | +| `Clipboard` | `clipboard::Clipboard` | Stateless. Copy button | +| `TextView` | `text::TextView` | `TextView::markdown(id, text)`, HTML too | +| Image | `gpui_kit::{img, ImageSource, ObjectFit}` | GPUI's `img()` element | + +### Overlay & Popups + +| Component | Import | Notes | +| ---------------- | ------------------------------------------------- | ---------------------------------------- | +| `Dialog` | `dialog::Dialog` + `WindowExt` | Via `window.open_dialog(...)` | +| `AlertDialog` | `WindowExt` | Via `window.open_alert_dialog(...)` | +| `Sheet` | `sheet::Sheet` + `WindowExt` | Side panel, via `window.open_sheet(...)` | +| `Notification` | `notification::Notification` + `WindowExt` | Via `window.push_notification(...)` | +| `Popover` | `popover::Popover` | Floating overlay | +| `Menu` | `menu::{PopupMenu, DropdownMenu}` | Context menus | +| `DropdownButton` | `button::DropdownButton` | Button with dropdown menu | +| `Command` | `command::{Command, CommandState, CommandGroup}` | Stateful. Command palette | +| Focus trap | `FocusTrapElement` | `.focus_trap(id, &handle)` on a container; `Dialog` and `Sheet` have it built in | + +### Navigation & Layout + +| Component | Import | Notes | +| ----------------- | ------------------------------------------------------------------------ | ------------------------- | +| `Tabs` / `TabBar` | `tab::{Tab, TabBar}` | Tabbed interface | +| `Sidebar` | `sidebar::{Sidebar, SidebarMenu, ...}` | App navigation panel | +| `TitleBar` | `TitleBar` | Window title bar | +| `StatusBar` | `status_bar::StatusBar` | Window status bar | +| `Breadcrumb` | `breadcrumb::Breadcrumb` | Navigation breadcrumb | +| `Pagination` | `pagination::Pagination` | Page navigation | +| `Accordion` | `accordion::Accordion` | Collapsible sections | +| `Collapsible` | `collapsible::Collapsible` | Single collapsible | +| `GroupBox` | `group_box::GroupBox` | Labeled container | +| `Resizable` | `resizable::{h_resizable, v_resizable, resizable_panel, ResizableState}` | Draggable split panes | +| `Scrollbar` | `scroll::Scrollbar` | Custom scrollbar | + +### Data Display + +| Component | Import | Notes | +| ----------------- | ----------------------------------------------- | ----------------------------- | +| `DataTable` | `table::{DataTable, TableState, TableDelegate}` | Stateful. Full-featured table | +| `Table` | `table::{Table, ...}` | Simpler table | +| `VirtualList` | `{v_virtual_list, h_virtual_list}` | High-perf large lists | +| `List` | `list::{List, ListState, ListDelegate}` | Stateful. Searchable list | +| `Tree` | `tree::{Tree, TreeState, TreeItem, TreeEntry}` | Stateful. Hierarchy | +| `DescriptionList` | `description_list::DescriptionList` | Key-value pairs | +| `Settings` | `setting::Settings` | Settings panel | + +### Chat & Messaging + +| Component | Import | Notes | +| ----------------- | -------------------------------------------------------- | ------------------------------------- | +| `Message` | `message::{Message, MessageContent, MessageAlignment}` | Stateless. Chat message row | +| `Bubble` | `bubble::{Bubble, BubbleContent, BubbleVariant}` | Stateless. Message bubble | +| `Attachment` | `attachment::{Attachment, AttachmentContent, ...}` | Stateless. File/media attachment card | +| `MessageScroller` | `message_scroller::{MessageScroller, MessageScrollerState}` | Stateful. Auto-scrolling message list | + +### Charts + +| Component | Import | Notes | +| --------- | --------------------------------------------------------------- | ------------------------------ | +| `Chart` | `chart::{AreaChart, BarChart, LineChart, PieChart, RadarChart}` | Bar, line, area, pie charts | +| `Plot` | `plot::Plot` | `#[derive(IntoPlot)]` for data | + +## Testing + +**UI integration testing** means rendering real components in headless windows, +simulating input, and checking state, focus, layout and owner callbacks. Use +`#[gpui_kit::test]` to run tests and `gpui_kit::test` to operate and inspect the UI. +When asked to add component interaction coverage, describe it as UI integration testing. + +For unit tests, GPUI context tests or UI integration tests, read +[Testing](references/gpui/test.md). It includes dependency setup, a complete +runnable UI flow, scoped native interactions, frame/async handling and the +limits of accessibility and geometry assertions. Use the production view and +verify the action's outcome through normal Rust assertions. + +## GPUI References + +Load the file for the mechanism the task touches. Each file starts with a +contents line. + +| Topic | File | Load when | +| --------------------------- | ------------------------------------------------------ | --------------------------------------------------------------- | +| Actions & keybindings | [action.md](references/gpui/action.md) | `actions!`, `bind_keys`, `on_action`, `key_context` | +| Async & background tasks | [async.md](references/gpui/async.md) | `cx.spawn`, `background_spawn`, `Task`, async I/O | +| Context management | [context.md](references/gpui/context.md) | `App`, `Window`, `Context<T>`, `AsyncApp` | +| Custom elements (low-level) | [element.md](references/gpui/element.md) | `Element` trait, `request_layout`, `prepaint`, `paint` | +| Entity state | [entity.md](references/gpui/entity.md) | `Entity<T>`, `WeakEntity`, state management | +| Events & subscriptions | [event.md](references/gpui/event.md) | `cx.emit`, `cx.subscribe`, `cx.observe` | +| Focus & keyboard nav | [focus-handle.md](references/gpui/focus-handle.md) | `FocusHandle`, `track_focus`, Tab navigation | +| Global state | [global.md](references/gpui/global.md) | `Global` trait, `cx.set_global`, app-wide config | +| Layout & styling | [layout-style.md](references/gpui/layout-style.md) | `div()`, `h_flex()`, `v_flex()`, flexbox, overflow, positioning | +| ElementId | [element-id.md](references/gpui/element-id.md) | `ElementId`, `.id()`, uniqueness rules, stateful elements | +| Testing | [test.md](references/gpui/test.md) | `#[gpui_kit::test]`, `TestAppContext`, `VisualTestContext` | + +Deep dives, for when the topic file is not enough: + +- **Element trait**: [element-api.md](references/gpui/element-api.md) (complete API, hitbox, events) · + [element-patterns.md](references/gpui/element-patterns.md) (text, interactive, container, composite) · + [element-examples.md](references/gpui/element-examples.md) (full examples) · + [element-best-practices.md](references/gpui/element-best-practices.md) (performance, state, pitfalls) · + [element-advanced.md](references/gpui/element-advanced.md) (custom layouts, async updates, virtual lists) +- **Entities**: [entity-api.md](references/gpui/entity-api.md) (complete API, lifecycle) · + [entity-patterns.md](references/gpui/entity-patterns.md) (model-view, cross-entity, observer) · + [entity-best-practices.md](references/gpui/entity-best-practices.md) (memory, performance) · + [entity-advanced.md](references/gpui/entity-advanced.md) (collections, registry, debounce, state machines) +- **Testing**: [test-examples.md](references/gpui/test-examples.md) (organization, setup, assertions, running tests) · + [test-reference.md](references/gpui/test-reference.md) (re-entrancy, property tests, mocking) diff --git a/.agents/skills/gpui-kit/references/coding-guides.md b/.agents/skills/gpui-kit/references/coding-guides.md new file mode 100644 index 0000000..54ca777 --- /dev/null +++ b/.agents/skills/gpui-kit/references/coding-guides.md @@ -0,0 +1,911 @@ +--- +title: Coding Guides +description: Architecture and coding conventions for maintainable GPUI Kit applications +order: -13 +--- + +# Coding Guides + +This guide describes the application architecture and code patterns that have +proved durable in GPUI Kit. It is written for both engineers and coding +agents. Read [Design Guides](./design-guides.md) first: code structure should +preserve product intent, not replace it. + +This is a normative guide. **Must** marks lifecycle, correctness, or ecosystem +constraints; **should** is the default architecture and requires a concrete +reason to depart from it. Current source and API docs remain authoritative for +exact signatures. + +## Architecture at a glance + +<img class="architecture-light" src="/application-layers-light.svg?v=20260822-16" alt="GPUI application architecture layers"> +<img class="architecture-dark" src="/application-layers-dark.svg?v=20260822-16" alt="GPUI application architecture layers"> + +Dependencies point downward. Higher layers own domain meaning and orchestration; +lower layers own reusable presentation or behavior. Do not make a reusable +component depend on an application screen, or make `gpui-base` depend on a +theme from GPUI Component. + +Use these boundaries: + +- **app shell:** compose windows and feature crates while keeping feature logic out; +- **feature crate:** keep one capability's model, services, views, commands, dialogs, + and workflow behind one public boundary; +- **app component:** a repeated domain-aware pattern; +- **gpui-component:** themed, general-purpose UI; +- **gpui-base:** reusable behavior and geometry without product presentation. + +### Organize large applications by capability + +In a large Rust application, a feature should usually be a crate, not another +file in a global `views`, `models`, or `modals` directory. Keep the model, +views, commands, dialogs, and workflow for one capability together. A dialog +that edits a workspace belongs to the workspace feature; only the reusable +dialog primitive belongs to the UI library. + +```text +crates/ +├── app/ +│ └── src/main.rs # Compose windows and features +├── workspace/ +│ └── src/ +│ ├── lib.rs # The feature's public boundary +│ ├── model.rs +│ ├── commands.rs +│ ├── workspace_view.rs +│ └── rename_dialog.rs +├── search/ +│ └── src/ +│ ├── lib.rs +│ ├── model.rs +│ ├── commands.rs +│ ├── search_view.rs +│ └── filters.rs +├── settings/ +│ └── src/ +│ ├── lib.rs +│ ├── model.rs +│ ├── settings_view.rs +│ └── account_dialog.rs +└── shared/ + └── src/ + ├── lib.rs + └── recent_items.rs # A stable capability with multiple owners +``` + +Do not invert this into global `models/`, `views/`, `modals/`, and `commands/` +directories. Those folders classify files by implementation role while +scattering every feature across the application. + +The application shell composes feature crates but contains little feature +logic. A feature may depend on stable shared capabilities and UI foundations; +it must not depend on the shell or reach into a sibling feature's internals. +When two features need to communicate, prefer an explicit command, event, data +type, or small shared service over a dependency between their views. Extract a +shared crate only after the capability has a coherent name and more than one +real owner. + +Crate boundaries are engineering boundaries. They let Cargo rebuild and test a +smaller dependency subgraph, make ownership visible in `Cargo.toml`, and limit +the review and regression surface of a change. They also make removal honest: +a feature that cannot be detached without searching through global view and +modal directories was never isolated. + +Do not create a crate for every screen or helper. Split where a capability has +its own state and lifecycle, a stable public seam, or enough implementation to +benefit from independent compilation and tests. Keep dependencies acyclic and +pointing toward smaller, more stable crates. + +## Bootstrap and root ownership + +Initialize GPUI Component once, before creating component-backed views, and put +`Root` at the first level of each window: + +```rust +app.run(move |cx| { + gpui_kit::init(cx); + + gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { + cx.new(|cx| Workspace::new(window, cx)) + }) + .expect("failed to open window"); +}); +``` + +`Root` coordinates window-level component facilities such as overlays and +notifications. Do not create a separate root for each page inside one window. +It also coordinates modal focus restoration, focus traps, tooltip/menu layers, +and window-scoped text selection. Bypassing it can produce behavior that looks +correct at rest but fails when overlays nest or focus changes quickly. + +## Understand GPUI's phases and contexts + +GPUI is retained state with declarative rendering. An entity survives across +frames; the element tree returned by `render` is a fresh description of the +current frame. Keep that distinction explicit. + +- `Context<Self>` mutates the current entity, creates listeners tied to it, + emits its events, and notifies its observers. +- `App` gives access to application globals and entity reads/updates without + implying ownership by the rendered element. +- `Window` owns focus, actions, input dispatch, element-keyed state, + measurement, and animation-frame requests for that window. +- layout, prepaint, and paint are later phases; use their hooks only when + resolved geometry is genuinely required. + +Never retain `&mut Window`, `&mut App`, or `&mut Context<_>` beyond the call in +which it is provided. Retain typed handles—`Entity`, `WeakEntity`, +`FocusHandle`, scroll handles, or domain IDs—instead. + +## Choose the right unit + +### Use `RenderOnce` for value-like elements + +Use a `RenderOnce`/`IntoElement` component when all inputs can be supplied by +the caller and the element does not need to retain application state between +frames. This is the normal choice for presentational wrappers and small +controls. + +```rust +#[derive(IntoElement)] +struct EmptyState { + title: SharedString, +} + +impl RenderOnce for EmptyState { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .v_flex() + .gap_2() + .items_center() + .text_color(cx.theme().muted_foreground) + .child(self.title) + } +} +``` + +### Use `Entity<T>` for retained behavior + +Use an entity-backed `Render` view when behavior spans frames or needs +observation, subscriptions, focus, async work, history, measurement, or +incremental updates. Store entities in an owning view rather than recreating +them in `render`. + +```rust +struct SearchView { + query: Entity<InputState>, +} + +impl SearchView { + fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { + let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search…")); + Self { query } + } +} +``` + +Do not turn every visual fragment into an entity. Entity boundaries have +lifecycle and coordination costs; use them where retained identity matters. + +### Elements, views, and behavior systems are different + +Do not force every component into one template. The ecosystem contains: + +- semantic elements such as Button, Checkbox, Link, and Tabs; +- compound behavior roots such as Dialog, Popover, Select, and Combobox; +- entity-backed systems such as Input, Table, Tree, Dock, and notifications; +- infrastructure such as positioning, virtualization, scrolling, focus traps, + motion, history, and measurement. + +An element may be internally complex and still be value-like to its caller. A +stateful system may expose render callbacks so applications own presentation +without reimplementing behavior. Choose the public seam from the behavior, +not from how many `div`s appear in its renderer. + +## State ownership + +Put each state in the narrowest owner that can keep it correct: + +- domain state belongs to a model or feature view; +- transient view state belongs to the view that renders it; +- reusable behavioral state belongs to the component state designed for it; +- tiny element-local state may use GPUI keyed element state; +- shared application services may be stored as GPUI globals. + +Prefer controlled values for ordinary selection and toggles: pass the current +value into the component, receive a requested change, update the owner, and +render again. A callback reports intent; it should not create a second hidden +source of truth. + +```rust +Checkbox::new("show-hidden") + .checked(self.show_hidden) + .label("Show hidden files") + .on_click(cx.listener(|this, checked, _, cx| { + this.show_hidden = *checked; + cx.notify(); + })) +``` + +Call `cx.notify()` after a mutation that changes rendering. Use `cx.emit(...)` +for a semantic event that an owner should handle, and `cx.subscribe(...)` or +`cx.observe(...)` when the lifetime should follow an entity. Keep returned +subscriptions alive when the API requires it. + +Do not notify merely because a value was read or derived. Avoid unconditional +notification from `render`; it schedules another render and can create a +permanent redraw loop. When several fields form one invariant, update them +together and notify once. A reusable state type that cannot receive a context +should make that limitation explicit and require its owner to emit/notify. + +### Avoid state feedback loops + +Text input, selection, filters, and controlled popups commonly have two paths: +an external owner updates the value, and user interaction requests a new value. +Do not send an owner-supplied value back through the user callback during sync. +Track the origin or compare coherent snapshots so each logical change is +reported once. Make callbacks re-entrancy-safe when a callback can synchronously +close, replace, or update the component that invoked it. + +## Stable identity + +An `ElementId` is part of behavior. It gives an element stable identity and keys +element-local or component state. A component may also use it as one input to +its own focus, measurement, or animation identity; focus and scrolling are +otherwise owned by their dedicated handles. + +- Use stable domain IDs for rows, tabs, tree nodes, and repeated controls. +- Namespace child IDs with their owning object when the same control repeats. +- Never derive identity from a translated label or a mutable list index when + items can be inserted or reordered. +- Do not generate a fresh random ID during `render`. + +```rust +Button::new(("delete-project", project.id)) + .danger() + .label("Delete") +``` + +A changed ID means a changed UI identity. Treat that reset as deliberate. + +The same rule applies to transition channels, overlay tokens, scroll handles, +and persistence IDs. If two independently retained behaviors share a key, they +can overwrite each other's state; if one behavior changes keys every frame, it +never accumulates state. + +## Rendering and composition + +Keep `render` declarative: read current state, derive presentation values, and +compose elements. Move domain operations, parsing, and non-trivial mutation to +named methods or services. + +```rust +impl Render for ProjectView { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .v_flex() + .size_full() + .child(self.render_toolbar(cx)) + .child(self.render_content(cx)) + } +} +``` + +Extract a render helper when it names a meaningful region and reduces the +amount of state a reader must hold at once. Extract a new component when the +region has its own reusable contract or retained lifecycle—not merely because +a builder chain is long. + +Use GPUI Component's fluent traits consistently (`Sizable`, `Disableable`, +`Selectable`, and component-specific builders). Prefer `.when(...)` and +`.when_some(...)` for small conditional refinements; use ordinary Rust control +flow when branches represent substantially different interfaces. + +Compose from the standard semantic component before building a custom surface. +Do not reproduce a menu, select, dropdown, or command palette from generic +`div`s merely to match one screenshot. Reusing the component preserves its +item geometry, focus transfer, keyboard navigation, selection, disabled state, +dismissal, and accessibility contract. If the standard component cannot +express a recurring valid pattern, improve its explicit API instead of styling +arbitrary descendants at each call site. + +Render callbacks supplied by application code should be side-effect-free. A +list item renderer, menu builder, or dock panel renderer may run whenever its +owner needs to measure or redraw. It must not perform a business operation, +append data, or register an unbounded subscription. + +## Behavior and presentation boundary + +The durable Base rule is: + +> Base owns reusable behavior and the geometry required to implement it. The +> presentation layer owns the product's visual language. + +“Headless” does not mean “one empty `div`.” Popup collision, keyboard +navigation, editing, virtualization, resize arithmetic, focus trapping, and +dock reconciliation require internal structure and state. Moving that work to +every caller would not create flexibility; it would duplicate fragile +behavior. + +Conversely, Base must not choose brand colors, typography, density, final +icons, component variants, or application composition. Expose presentation +through `Styled`, typed semantic-state styles, explicit parts, child slots, and +item renderers. Do not inspect arbitrary descendants to discover titles, +descriptions, or close buttons—make semantic parts explicit. + +## Theme and styling + +Read semantic values from the active theme and apply layout with GPUI's +`Styled` methods: + +```rust +div() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) +``` + +Rules: + +- do not hard-code product colors, corner radii, spacing, or control geometry; +- application code must not introduce raw hex, `rgb`/`rgba`, or `hsla`; read a + semantic color from `cx.theme()` or add the missing role to the product theme; +- application layout should use GPUI's rem-based scale helpers (`p_2()`, + `gap_3()`, `w_64()`, `text_sm()`) instead of direct `px(...)` values; +- use semantic tokens for meaning, not palette position; +- keep state-independent geometry in the ordinary builder chain; +- use GPUI `hover`, `active`, `focus`, and `focus_visible` modifiers for + runtime interaction states; +- use a component's semantic state styles for checked, selected, pressed, or + disabled appearance; +- keep popup ownership in explicit state so its trigger can render the open or + pressed appearance until dismissal; +- guard hover/active refinements when a disabled control must not react; +- keep component variants few and meaningful rather than adding a variant for + every call site. +- bind primary styling to the decision area's real default commit and Enter + action; do not derive it from action count, frequency, or toolbar position. +- keep Badge and Alert variants semantic and scarce. Ordinary metadata stays + neutral; do not map every enum case or section to a different color merely + because a variant exists. + +The effective precedence is instance style, active semantic value states, +disabled state, then GPUI runtime interaction refinements. Later layers only +replace fields they set. + +Prefer `Theme::semantic_tokens()` for new application-owned presentation. The +semantic token surface contains generic color roles plus radius, spacing, +typography, and shadow scales; it deliberately avoids component names. Legacy +component-specific theme values still exist for compatibility but should not +become the extension point for every application widget. + +There is one current ownership caveat: `Theme::spacing_tokens()` projects the +default scale, and `Theme::apply_semantic_tokens(...)` does not store custom +spacing or elevation scales. An application that customizes those scales must +retain its own `SemanticThemeTokens` (or narrower design-system state) and make +that state available to its components. Do not write a custom spacing snapshot +into the global theme and expect a later `cx.theme().semantic_tokens()` call to +return it. + +Edit the global GPUI Component theme through `Theme::update(cx, |theme| ...)`. +The theme keeps the same colors twice (`colors` as solid colors, `tokens` as +renderable backgrounds that may carry a gradient) and the Base layer holds a +projection for its scrollbars and resize handles; `update` brings all three +back in step after the closure and refreshes every window. An edit through +`Theme::global_mut(cx)` updates only the field you touched — a sidebar can then +paint its text from the new colors and its background from the old tokens — +so it owns the rest: derive `tokens` from `colors`, call `Theme::sync_base(cx)`, +refresh the windows. `Theme::change(...)` performs the projection as part of a +complete theme change. + +An outward focus ring needs physical room. An ancestor with +`overflow_hidden()` clips it. Prefer layouts that leave room; if a product must +clip heavily, use the theme's focus-ring policy and retain the focused border +instead of silently hiding all keyboard focus. + +### Base font is the application zoom control + +The Component Root plugin calls `window.set_rem_size(cx.theme().font_size)` from +its `prepare` hook before the Root surface is rendered. Therefore the theme's +base font is not only body typography; it is the reference length for the +application's rem-based design scale. This deliberately follows the useful part +of Tailwind's model: named type, spacing, and size steps share one relative base +instead of becoming unrelated pixel constants. + +Change zoom by updating the base font and refreshing the window: + +```rust +Theme::update(cx, |theme| theme.font_size = px(18.)); +``` + +The base font itself is a pixel value because it anchors the scale. Descendant +application UI should normally use relative helpers—`text_sm()`, `gap_2()`, +`px_3()`, `h_8()`, `size_4()`—so type, whitespace, controls, and icons respond +together. A custom component that combines rem-based text with fixed-pixel +padding or icon geometry must document why that part should not zoom. + +Treat every direct `px(...)` and raw color constructor in application UI as a +review finding. Accept it only for a documented physical/platform boundary, +measured runtime geometry, raster/data color, or the theme/token definition +itself. Convenience and matching a screenshot are not valid exceptions. + +Anything cached from resolved layout must include `window.rem_size()` in its +invalidation key, directly or through a revision that changes with it. This +includes wrapped row heights, text shaping/layout, virtual-list measurement, +popup and dialog geometry, icon sizing derived from text, and custom canvas +metrics. The Command component's variable-height rows are an ecosystem example: +they remeasure when rem changes because the same fixed width wraps differently +at a larger base font. + +Do not confuse this application zoom with Dock panel zoom. Dock zoom is a +stateful layout operation that makes one tab group fill the DockArea +while keeping the container chrome and the way back out. It must not modify the +window rem size. + +## Events, actions, and focus + +Use pointer callbacks for pointer-specific behavior. Use GPUI Actions for +commands that should support key bindings, menus, or dispatch from multiple +inputs. Keep action handlers close to the view that owns the command. + +Model one logical desktop command once. A toolbar Button, `DropdownMenu` item, +`ContextMenu` item, menu-bar item, and key binding should dispatch the same +Action or call the same owner method instead of copying five mutations. Derive +their label, icon, shortcut, and enabled state from one command policy where +practical, so the entry points cannot disagree. The menu owns navigation and +dismissal; the feature owner still owns whether the command is allowed and +what it does. + +Preserve semantic roles in the element choice. Use `Button` for commands even +when the desired treatment is quiet—select `outline`, `ghost`, or an icon +presentation instead of replacing it with `Link`. GPUI Kit applications +reserve `Link` for targets opened by a browser or mail client, such as a URL, +web document, or email address. Use the relevant navigation component for an +in-app destination and `Button`/`Action` for a command. This is a product +convention, not a limitation of `gpui_kit::base::Link`, whose `open_with` seam can +route a destination elsewhere. + +Only stop propagation when a nested interaction must prevent its parent from +handling the same event. Blanket propagation stops break menus, selection, +dragging, and window-level commands in ways that are difficult to diagnose. + +Bind keys before building the menu bar. `cx.set_menus` reads the keymap at +the moment it is called and bakes each item's shortcut into the native menu, +so a binding registered afterwards never shows next to its menu item and the +item does not react to the key. Call `cx.bind_keys` first, then +`cx.set_menus`; if the keymap changes later (a user keymap file, a locale +switch that rebuilds the menus), call `cx.set_menus` again. + +Make focus ownership explicit: + +- retain a `FocusHandle` in the entity that owns keyboard interaction; +- register key contexts and actions on the appropriate focused region; +- transfer focus when opening an overlay and restore it on dismissal; +- render a visible `focus_visible` state; +- do not request focus unconditionally from `render`. + +A tracked handle is a Tab stop only when it says so: build it with +`cx.focus_handle().tab_stop(true)` (or `.tab_index(n)`), because the element's +own `tab_index`/`tab_stop` settings do not apply to a handle passed to +`track_focus`. A stateless component may create that handle in `render` +through `window.use_keyed_state(id, cx, |_, cx| cx.focus_handle().tab_stop(true))`; +the keyed state survives re-renders, so the Tab order is stable — this is +what `Button` does. + +Attach a `key_context` and its `on_action` handlers to the same focused region. +Bindings are contextual: a registered Action without the intended focus path +is not a working keyboard interaction. Composite widgets should implement the +complete navigation model—arrow movement, Home/End or page movement where +appropriate, confirmation, cancellation, and Tab behavior—rather than a few +isolated shortcuts. + +Modal surfaces must trap focus and restore the previous valid focus target on +dismissal. Nested overlays dismiss from the top. Handle rapid close/open +sequences without restoring focus through an intermediate, already-closing +surface. + +## Async work and side effects + +Start async work from an event, lifecycle hook, or named method—not as an +unconditional side effect of `render`. Capture weak entities when work should +not keep a closed view alive. When the task completes, update state through the +GPUI context, handle the case where the entity or window no longer exists, and +notify once after the coherent state change. + +Represent async operations with explicit states such as idle, loading, loaded, +and failed. Preserve usable previous data during refresh when possible. Prevent +duplicate destructive submissions and surface recoverable errors in the UI; +do not rely on logs as user feedback. + +Use background executors for expensive parsing or computation, but keep GPUI +entity mutation on the appropriate application context. Results can arrive +after the request, document, view, or selection has changed; attach a revision +or identity and reject stale work rather than applying it to new state. + +## Layout, measurement, and scrolling + +`h_flex` centres its children on the cross axis; `v_flex` leaves flexbox's +default, `stretch`. This matches Zed's `h_flex`, and it is what a row of +controls wants, so a row of icon and label says nothing. It is not what a row +of full-height columns wants: a column placed in a bare `h_flex` does not fill +the row's height, so a column taller than the row is centred and its top — +commonly a header — is clipped off the top of the window, with nothing near the +column to say why. A row whose children are columns says `items_stretch()`: + +```rust +h_flex() + .items_stretch() + .size_full() + .child(sidebar) + .child(content) +``` + +Most UI should use GPUI layout rather than measuring itself. Measurement is a +deep behavior tool for popups, virtualization, editors, resize handles, charts, +and similar components whose correctness depends on resolved geometry. + +- Put measurement and geometry in the layer that owns the behavior. +- Observe bounds in prepaint only when ordinary layout cannot express the + relationship. +- Never mutate unrelated application state every prepaint. +- Treat measured data as frame- or revision-scoped; it can become stale after + typography, rem size, width, theme, or content changes. +- Centralize shared geometry such as popup flipping and viewport clamping so + every overlay follows the same edge policy. + +For alignment invariants, prefer construction over correction: sibling regions +should consume the same spacing token or shared inset instead of repeating +equivalent literals. Add geometry assertions or visual regression coverage for +critical repeated edges, columns, and gaps. Exercise more than the default +window: rem zoom and display scaling can turn fractional coordinates into a +one-physical-pixel drift even when the default screenshot looks aligned. + +Measure the resolved result when reviewing precision, but do not encode a +measured correction as a raw `px(...)` nudge. Trace the mismatch to duplicated +padding, nested insets, border ownership, font metrics, or rounding, then fix +the structural owner. + +`h_flex()` and `v_flex()` are not mirror images: `h_flex()` centers its +children on the cross axis, `v_flex()` leaves them stretching. A column placed +in a row therefore takes its content's height, not the row's, and a column +taller than the row is centered — its header is pushed off the top edge and +clipped. Give a full-height column `h_full()`, or give the row `items_start()` +or `items_stretch()`, whenever the child owns a header, a footer, or a scroll +region that must resolve against the row's height. + +Every scrollable region must have one owner. In flex layouts, apply +`min_w_0()` or `min_h_0()` to the flexible child that is allowed to shrink. +A flex item only drops its content-based automatic minimum size when its own +overflow is not visible, so an ordinary flexible child refuses to shrink around +long content until you say so. `Scrollable` handles this for its own wrapper, +but a plain `div` between it and the flex container still needs the minimum +released. Avoid accidental nested scrolling; route wheel input to the intended +axis and preserve platform/wasm differences when an API is not portable. + +Attach `Scrollable` to the element that owns the full panel, editor, or window +viewport so its scrollbar resolves against the region edge. Put content inset +inside that scroll owner rather than wrapping the scroll owner in a padded +container. A scrollbar floating between content and the panel boundary usually +reveals the wrong scroll owner or padding on the wrong layer. + +## Lists, tables, and large data + +Use virtualization when data can grow beyond a small, bounded collection. Keep +row identity separate from visible position and avoid cloning the full data set +on every render. Let a stateful list or table own navigation, selection, scroll +coordination, and visible-range calculation while item renderers own row +presentation. + +Separate: + +- source data and domain IDs; +- filtering/sorting state; +- selection state; +- viewport/scroll state; +- row rendering. + +This keeps updates local and prevents the view tree from becoming the data +model. + +Virtualization is a behavioral contract, not just a performance switch. Item +measurement must be invalidated when width, typography, rem size, or row +content changes. Keyboard selection and scroll-to-item must operate in model +coordinates even when most elements do not exist in the current frame. + +## Public API design + +For reusable components: + +- constructors should establish valid defaults; +- builders take and return `Self` and use domain language; +- callbacks describe requested changes and include pointer events only when + modifiers or pointer details are meaningful; +- evolvable behavioral seams use private fields, builders for construction, + and readers for inspection; +- boolean readers use `is_` or `has_` where a same-named builder exists; +- non-boolean setters use `with_` when readers need the plain field name; +- explicit compound parts are preferable to inspecting arbitrary descendants; +- adding reusable behavior must not force a product-level visual choice. + +Private fields are the default for behavioral state that must evolve without +breaking callers. Public fields are appropriate for deliberately record-like +configuration, theme tokens, geometry, and serialized schemas. Every public +struct with public fields must carry `#[non_exhaustive]`. Provide constructors, +`Default`, or builders so callers can create values without exhaustive struct +literals. This preserves the ability to add fields without breaking callers. +Apply this rule to new types and public API changes; unrelated existing types +can be migrated separately. + +Keep public module paths stable while reorganizing internals: use a module seam +with deliberate re-exports so folders can change without forcing downstream +imports to change. Prefer platform control terminology and established project +naming over web-framework vocabulary. + +## Platform and capability boundaries + +Do not assume every native or web target supports the same facility. Window +decorations, accessibility bridges, system notifications, clipboard behavior, +scroll gestures, fonts, and timing can differ. Put platform-specific code +behind a narrow capability seam and define the fallback behavior. + +A platform branch must preserve the semantic contract even if presentation +differs. For example, a system notification may have different retraction +support, but the application still needs a coherent delivery state. Test both +the shared state machine and the platform adapter where possible. + +## File and naming conventions + +- Name views and entities after product concepts: `ProjectList`, + `ProjectEditor`, `SettingsState`. +- Name event handlers after intent: `confirm_delete`, `open_project`, + `on_query_changed`. +- Keep one main responsibility per module; split a file when state ownership or + lifecycle can no longer be understood without reading unrelated behavior. +- Keep component module, state, events, and focused tests together when they + change together. +- Document invariants and surprising lifecycle constraints; do not narrate + obvious builder calls. +- Use `rustfmt` and satisfy the workspace's Clippy rules. Avoid broad `allow` + attributes that conceal unrelated warnings. + +### Vocabulary is part of the API + +Use the same word for the same concept across components. Before naming a new +method, search GPUI, `gpui-base`, and GPUI Component for the established term; +prefer macOS/Windows control terminology where the ecosystem has no precedent. +Localized documentation preserves exact API identifiers and established UI +framework terms when translation would reduce precision. Format identifiers as +code, explain retained terms when needed, and do not mix languages merely to +make ordinary prose sound technical. + +| Concept | Naming pattern | Example | +| --- | --- | --- | +| Value-like rendered control | noun | `Button`, `Checkbox`, `Tab` | +| Retained behavioral model | `<Control>State` | `InputState`, `TableState` | +| Imperative shared reference | `<Control>Handle` | `DialogHandle`, scroll handle | +| Semantic notification | `<Control>Event` | `TableEvent`, `SelectEvent` | +| Keyboard command | verb or intent noun | `Confirm`, `Cancel`, `SelectNext` | +| Pluggable data/behavior owner | `<Role>Delegate` / `<Role>Provider` | `TableDelegate`, `CompletionProvider` | +| Application-supplied presentation | `render_<part>` or `<part>_renderer` | `render_item` | +| Construction | `new`, or a semantic constructor | `new`, `horizontal`, `vertical` | +| Fluent property | noun/adjective | `label`, `disabled`, `selected`, `placement` | +| General non-boolean replacement builder | `with_<field>` | `with_size`, `with_mode` | +| In-place mutation | `set_<field>` | `set_items`, `set_selected_index` | +| Boolean reader | `is_<adjective>` / `has_<noun>` | `is_open`, `is_closable`, `has_selection` | +| Plain value reader | field noun | `placement`, `selected_value` | +| Callback registration | `on_<event or intent>` | `on_click`, `on_open_change` | +| Rendering a named region | `render_<region>` | `render_toolbar`, `render_content` | + +For new APIs, fluent builders omit `set_` because they consume and return +`Self`; mutation through `&mut self` uses `set_`. Preserve established public +names when changing them would cause needless churn. Existing builder names +such as `set_position` are compatibility exceptions, not patterns for new APIs. + +A boolean reader is either `has_<noun>`, when the value holds something, or +`is_<adjective>`, when it describes a state or a permission. Reach for the +adjective whenever the action has one: `is_closable` over `can_close`, +`is_zoomable` over `can_zoom`, `is_copyable` over `can_copy`. When the action +is a verb phrase with no adjective form, name the thing it needs instead: +`has_definition`, not `can_go_to_definition`. Do not add new `can_` readers. + +Boolean builders may use the field name (`disabled(bool)`) while their readers +use `is_disabled()`. For a public seam struct containing non-boolean fields, +use `with_item_ix(...)` for construction and `item_ix()` for reading so setter +and getter names never collide. Prefer `_ix` for new local or internal +zero-based indices, preserve established public terms such as `selected_index`, +and do not introduce `_idx`. If callers never construct the seam value, do not +publish a builder merely for symmetry. + +### Let the enclosing name carry the context + +A name is read inside something. A field is read inside its type and a parameter +inside its method, so neither repeats what encloses it: `with_item_ix(ix)`, not +`with_item_ix(item_ix)`. + +Keep one type's fields at the same level of abbreviation. A single field spelled +out in full becomes the odd one out, and a reader goes looking for the +distinction that made it different. Because a builder is named `with_<field>`, +shortening a field shortens its builder with it and the pair stays matched. + +Shorten only where the enclosing name really does disambiguate. When a short +form is also the established term for a *different* quantity elsewhere in the +ecosystem, say which one you mean in the doc comment rather than lengthening the +identifier — the doc is read at the call, and it can explain what a longer name +could only hint at. + +### Use precise domain words + +- **selected** is persistent membership or the active item; **focused** is the + current keyboard target; **hovered** is pointer presence; **confirmed** is an + activation result. Never use them interchangeably. +- **open/close** describes an overlay or disclosure state; **show/hide** is for + transient presentation requests; **expand/collapse** describes structure. +- **disabled** prevents interaction; **readonly** permits navigation and + selection but prevents editing; **loading** prevents duplicate work while an + operation is pending. Spell the state `readonly` — one word, as the + `readonly(bool)` builder and `is_readonly()` reader do — in identifiers, + interface labels, and documentation alike; never `read-only` or `read only`. +- **index** is a current positional coordinate; **id** is stable identity; + `IndexPath` represents hierarchical position. Do not persist or key + reorderable data by index. +- **value** is controlled domain data; **presentation** is a read-only snapshot + prepared for rendering; **state** is retained behavior. +- **placement** is a side or anchor policy; **position** is resolved geometry. +- **size** is a semantic control tier; **width/height/bounds** are geometry. +- **child/children** follows GPUI composition; named slots such as `header`, + `footer`, `trigger`, and `content` carry additional semantics. + +Avoid vague public names such as `data`, `item2`, `handle_action`, `update_ui`, +`process`, `manager`, or `config` when a narrower domain term exists. `Manager` +is appropriate only when a type truly coordinates a collection or lifecycle, +as `ToastManager` does. + +### Type and module style + +- Rust types and Actions use `UpperCamelCase`; modules, functions, methods, + fields, and local variables use `snake_case`; constants use + `SCREAMING_SNAKE_CASE`. +- A module named after a component owns its public seam. Internal folders may + split state, element, geometry, platform adapter, and tests without leaking + those folder names into imports. +- Use singular module names for one component concept and established + ecosystem names for families (`input`, `table`, `dock`). +- Suffix type-erased wrappers with `Any` only when they erase a real type + boundary, such as `AnyInputState` or `AnyElement`. +- Suffix identifiers with `Id`, zero-based indices with `ix`, and collections + with meaningful plurals. Do not alternate `idx`, `index`, and `ix` in one + subsystem. +- Name predicates positively when possible. A positive `enabled`/`visible` + contract is easier to compose than multiple negatives, but preserve + established API terms such as `disabled` where they match control semantics. + +### Callback and event wording + +Use `on_click` only for a genuine click-level contract. A controlled semantic +primitive in Base should prefer `on_change(next_value, ...)`; a styled +compatibility component may retain `on_click` when pointer details or existing +API expectations matter. Do not invent a `ClickEvent` for a model-driven +change. + +Name before/after lifecycle hooks precisely. `on_will_change` can veto or +prepare; `on_change` observes a requested/current value contract; `on_confirm` +commits a choice; `on_dismiss` closes a transient surface. Document whether a +callback runs before internal state changes, after them, or instead of them, +and whether it may synchronously re-enter the component. + +### Documentation and copy style + +Public docs should begin with what a type does and who owns its state. Examples +must use current, compilable APIs and show stable IDs. Document defaults, +platform limitations, focus behavior, callback ordering, and any requirement +to call `notify`, `emit`, or a theme synchronization method. + +Follow the [interface-language rules](./design-guides.md#interface-language) for +labels, commands, confirmation dialogs, capitalization, and ellipses. Keep one +canonical term for each domain object, command, and state. + +Translation keys describe stable intent (`dialog.delete_project.title`), not a +source-language sentence or a screen coordinate. Never assemble a sentence +from translated fragments or reuse one key for meanings that happen to share +the same English text. + +Localize intent, not syntax. Give every locale control over word order, +pluralization, punctuation, and the amount of context it needs. Review strings +inside the component and with realistic data. Tests or linting should catch +missing keys, unintended CJK text in English resources, three-dot ellipses, +unreviewed ALL CAPS, and inconsistent fixed terms; human review still decides +whether repetition is justified by context. Verify every string inside its +component with realistic content, text expansion, and application zoom. + +## Testing strategy + +Test at the lowest layer that can prove the behavior: + +1. pure tests for state transitions, geometry, parsing, and ordering; +2. GPUI context tests for entities, events, and subscriptions; +3. `VisualTestContext` interaction tests for focus, keyboard, pointer, layout, + and rendered state; +4. example or application smoke tests for complete workflows. + +For an interactive component, cover the semantic contract rather than its +implementation details: pointer and keyboard activation, controlled value +changes, disabled behavior, focus movement, event count/order, stable identity, +and important empty or failure states. Add a regression test before fixing a +bug whenever the failure can be reproduced deterministically. + +For UI behavior that depends on the real window system, test through the +accessibility tree by role, label, value, enabled state, focus, and selection. +Re-read the tree after every state-changing action because element indexes are +snapshots. Use screenshots for visual facts the semantic tree cannot express; +use coordinate input only as a fallback. Report automated and manual evidence +separately. + +## Performance rules + +- Do not mutate state or notify unconditionally in `render`. +- Avoid rebuilding entities, subscriptions, focus handles, and expensive data + structures per frame. +- Notify the narrowest owning entity after a coherent state change. +- Virtualize long collections and render only the visible range. +- Avoid cloning large strings or collections solely to satisfy a closure; + capture stable handles or shared data. +- Measure before adding caches. A cache must have a clear invalidation owner. +- Keep animation work bounded and honor reduced motion. + +## Common failure modes + +Avoid these patterns: + +- one entity containing the entire application's unrelated state; +- business logic and network requests embedded in a long `render` method; +- random or index-based `ElementId` values for reorderable content; +- literal colors and radii that break custom themes; +- custom clickable `div`s where a semantic component already supplies focus, + keyboard, disabled, and accessibility behavior; +- duplicated local state that drifts from a controlled model value; +- `cx.notify()` loops caused by mutation during every render; +- nested scroll containers without explicit ownership; +- a new component variant for a one-off screen; +- confirmation dialogs for reversible, low-risk actions; +- tests that call internal methods but never exercise keyboard or pointer + behavior. + +## Rules for coding agents + +Before editing, an agent must read the nearest implementation, its tests, the +re-export seam, and the relevant component documentation. It must search the +current source for signatures instead of translating a React, CSS, or old GPUI +example by analogy. + +For each change, the agent should be able to name: + +1. the behavior owner and presentation owner; +2. the retained identity and state lifecycle; +3. the pointer, keyboard, focus, and accessibility contract; +4. the layout and overflow owner; +5. the theme tokens and intentional exceptions; +6. the test that would fail if the behavior regressed. + +Generated code must be reviewed and tested by a person. “Compiles” is not a UI +quality bar, and a broad refactor that merely makes generated code look tidy is +not a substitute for matching the repository's architecture. + +## Implementation checklist + +Before opening a change for review, confirm that: + +- state and side-effect ownership are explicit; +- `RenderOnce` versus `Entity<T>` is chosen deliberately; +- repeated elements have stable domain-based IDs; +- theme tokens and component sizes replace isolated visual literals; +- keyboard actions, focus, disabled state, and overlays work together; +- loading, empty, error, and cancellation paths are represented; +- long data sets use an appropriate virtualized component; +- public API additions preserve dependency direction and encapsulation; +- tests prove behavior at the appropriate layer; +- formatting, Clippy, targeted tests, and relevant examples pass. + +See [Getting Started](./getting-started.md) for application setup and the +component pages for current API details. diff --git a/.agents/skills/gpui-kit/references/conventions.md b/.agents/skills/gpui-kit/references/conventions.md new file mode 100644 index 0000000..41b2d99 --- /dev/null +++ b/.agents/skills/gpui-kit/references/conventions.md @@ -0,0 +1,67 @@ +# Component-family conventions + +Use this page to predict a component's shape before reading its detailed API. These conventions describe Rust GPUI Component. JavaScript uses its host's generated catalog; a Rust method is not automatically a JavaScript method. + +## Choose the family + +| Task | Family and constructor | State owner | Event or composition | +| --- | --- | --- | --- | +| Execute a command | `Button::new("save")` | Application owns the operation | `on_click` receives a click event | +| Change a boolean | `Checkbox::new("remember")`, `Switch::new("enabled")`, `Radio::new("choice")` | Application supplies `checked(bool)` | `on_change` receives the requested `&bool` | +| Choose one radio option | `RadioGroup::new("delivery")` | Application supplies `selected_index(Option<usize>)` | `on_change` receives the requested `&usize` | +| Edit retained text | `Input::new(&self.input)` | View retains `Entity<InputState>` | Retain the subscription; handle `InputEvent` | +| Lay out fields | `Form::new()` | Children retain their own state | `child(Field)`, `columns`, `label_layout`, `footer` | +| Supply a compound part | `Field::new()`, `Tab::new()` | Containing component owns placement/selection | Read the parent's accepted child type | + +Constructors establish the component family's ownership model. Do not infer that every `new` takes an ID or that every `child` accepts an arbitrary element. Keep domain IDs stable when controls are reordered. Positional selection APIs remain positional; applications map them to domain identity when needed. + +## A value request is different from a command + +For Checkbox, Switch, Radio, and RadioGroup, use the same owner-update pattern: + +```rust +Switch::new("enabled") + .checked(self.enabled) + .on_change(cx.listener(|this, next, _, cx| { + this.enabled = *next; + cx.notify(); + })) +``` + +Replace Switch with Checkbox or Radio without changing the boolean callback shape. Radio represents selecting an option; its activation semantics still differ from toggling a Switch. RadioGroup supplies an index, so its owner stores `Some(*next)` instead. + +`on_change` requests a value; it does not mutate your application model. Existing `on_click` calls on these four controls remain valid compatibility aliases. Both names set one handler, and the last call wins. Use `Button::on_click` for commands. Retained controls such as Input keep their existing event/subscription API because the state entity, rather than a transient builder, owns their changes. + +## Form layout has independent decisions + +| Decision | API | Default | +| --- | --- | --- | +| Label above or beside its control | `label_layout(Axis::Vertical / Horizontal)` | Above | +| Number of field columns | `columns(count)` | One | +| A field spanning several columns | `Field::col_span(count)` | One | +| Commands after the fields | `footer(element)` | No footer; supplied footer spans all columns and aligns to the trailing edge | + +`Form::horizontal()` and `h_form()` mean labels beside controls. They do not put all fields into one horizontal row. `Form::vertical()`, `v_form()`, and `layout(Axis)` remain supported. + +```rust +Form::new() + .label_layout(Axis::Horizontal) + .columns(2) + .child(Field::new().label("Name").child(Input::new(&self.name))) + .child(Field::new().label("Email").child(Input::new(&self.email))) + .footer(Button::new("save").label("Save")) +``` + +The footer supplies layout only: attach the save action to the Button. Form does not own submission, validation, responsive breakpoints, or data persistence. Choose column count for the available width. Put multiple action buttons in one supplied action row with a shared gap. Keep field descriptions with their Field so they follow its alignment. + +## Import capabilities explicitly + +Components live in their component modules. Common capabilities may be extension traits, not inherent methods: import `ButtonVariants` for button variants, `Sizable` for size tiers, `Disableable` for supported disabled builders, `ActiveTheme` for theme access, and `WindowExt` for window overlays. Do not infer that every component supports every trait. + +Use the [complete application recipe](recipes.md) for imports, retained ownership, initialization, Root, and overlay layers. For a narrow task, load its component page and the relevant section of the coding/design guides; a component catalog is not a required reading sequence. + +## Verify the convention transfer + +When transferring a pattern to another family member, check that it compiles, receives the expected requested value, preserves controlled state after redraw, and behaves correctly when disabled. For Form, check label orientation independently of column count and keep footer geometry outside the field grid's individual cells. + +In this repository, `cargo test -p gpui-kit-recipes` compiles the external-style consumer and runs its interaction tests, and `script/check-ai rust` runs the control-family event and compatibility tests and Form geometry tests. These are deterministic contract checks, not measured AI first-attempt success rates. diff --git a/.agents/skills/gpui-kit/references/gpui/action.md b/.agents/skills/gpui-kit/references/gpui/action.md new file mode 100644 index 0000000..f57c3ec --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/action.md @@ -0,0 +1,208 @@ +# Actions & Keybindings + +**Contents:** [Overview](#overview) · [Quick Start](#quick-start) · [Key Formats](#key-formats) · [Menu Bar Shortcuts](#menu-bar-shortcuts) · [Action Naming](#action-naming) · [Context-Aware Bindings](#context-aware-bindings) · [Best Practices](#best-practices) + +## Overview + +Actions provide declarative keyboard-driven UI interactions in GPUI. + +**Key Concepts:** +- Define actions with `actions!` macro or `#[derive(Action)]` +- Bind keys with `cx.bind_keys()` +- Handle with `.on_action()` on elements +- Context-aware via `key_context()` + +## Quick Start + +### Simple Actions + +```rust +use gpui_kit::actions; + +actions!(editor, [MoveUp, MoveDown, Save, Quit]); + +const CONTEXT: &str = "Editor"; + +pub fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("up", MoveUp, Some(CONTEXT)), + KeyBinding::new("down", MoveDown, Some(CONTEXT)), + KeyBinding::new("cmd-s", Save, Some(CONTEXT)), + KeyBinding::new("cmd-q", Quit, Some(CONTEXT)), + ]); +} + +impl Render for Editor { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .key_context(CONTEXT) + .on_action(cx.listener(Self::move_up)) + .on_action(cx.listener(Self::move_down)) + .on_action(cx.listener(Self::save)) + } +} + +impl Editor { + fn move_up(&mut self, _: &MoveUp, cx: &mut Context<Self>) { + // Handle move up + cx.notify(); + } + + fn move_down(&mut self, _: &MoveDown, cx: &mut Context<Self>) { + cx.notify(); + } + + fn save(&mut self, _: &Save, cx: &mut Context<Self>) { + // Save logic + cx.notify(); + } +} +``` + +### Actions with Parameters + +```rust +#[derive(Clone, PartialEq, Action, Deserialize)] +#[action(namespace = editor)] +pub struct InsertText { + pub text: String, +} + +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = editor, no_json)] +pub struct Digit(pub u8); + +cx.bind_keys([ + KeyBinding::new("0", Digit(0), Some(CONTEXT)), + KeyBinding::new("1", Digit(1), Some(CONTEXT)), + // ... +]); + +impl Editor { + fn on_digit(&mut self, action: &Digit, cx: &mut Context<Self>) { + self.insert_digit(action.0, cx); + } +} +``` + +## Key Formats + +```rust +// Modifiers +"cmd-s" // Command (macOS) / Ctrl (Windows/Linux) +"ctrl-c" // Control +"alt-f" // Alt +"shift-tab" // Shift +"cmd-ctrl-f" // Multiple modifiers + +// Keys +"a-z", "0-9" // Letters and numbers +"f1-f12" // Function keys +"up", "down", "left", "right" +"enter", "escape", "space", "tab" +"backspace", "delete" +"-", "=", "[", "]", etc. // Special characters +``` + +## Menu Bar Shortcuts + +`cx.set_menus` reads the keymap **at the moment it is called** and bakes each +item's shortcut into the native menu. A key bound afterwards never appears next +to its menu item, and the item does not react to that key. There is no event to +hook: bind first, then build the menus, and rebuild them when the keymap +changes. + +```rust +pub fn init(cx: &mut App) { + // 1. Bindings first — the menu items copy their shortcuts from these. + cx.bind_keys([ + KeyBinding::new("cmd-n", NewChat, None), + KeyBinding::new("cmd-,", OpenSettings, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + + // 2. Then the menu bar. + cx.set_menus(vec![Menu { + name: "File".into(), + items: vec![ + MenuItem::action("New Chat", NewChat), + MenuItem::action("Settings…", OpenSettings), + MenuItem::separator(), + MenuItem::action("Quit", Quit), + ], + }]); +} +``` + +Call `cx.set_menus` again after anything that changes bindings or labels: a +user keymap file loaded later, a locale switch that renames the items. `Undo`, +`Redo`, `Cut`, `Copy`, `Paste` need no `on_action` of your own — give the item +an `OsAction` and the platform forwards it to the focused text control. + +## Action Naming + +Prefer verb-noun pattern: + +```rust +actions!([ + OpenFile, // ✅ Good + CloseWindow, // ✅ Good + ToggleSidebar, // ✅ Good + Save, // ✅ Good (common exception) +]); +``` + +## Context-Aware Bindings + +```rust +const EDITOR_CONTEXT: &str = "Editor"; +const MODAL_CONTEXT: &str = "Modal"; + +// Same key, different contexts +cx.bind_keys([ + KeyBinding::new("escape", CloseModal, Some(MODAL_CONTEXT)), + KeyBinding::new("escape", ClearSelection, Some(EDITOR_CONTEXT)), +]); + +// Set context on element +div() + .key_context(EDITOR_CONTEXT) + .child(editor_content) +``` + +## Best Practices + +### ✅ Use Contexts + +```rust +// ✅ Good: Context-aware +div() + .key_context("MyComponent") + .on_action(cx.listener(Self::handle)) +``` + +### ✅ Name Actions Clearly + +```rust +// ✅ Good: Clear intent +actions!([ + SaveDocument, + CloseTab, + TogglePreview, +]); +``` + +### ✅ Handle with Listeners + +```rust +// ✅ Good: Proper handler naming +impl MyComponent { + fn on_action_save(&mut self, _: &Save, cx: &mut Context<Self>) { + // Handle save + cx.notify(); + } +} + +div().on_action(cx.listener(Self::on_action_save)) +``` + diff --git a/.agents/skills/gpui-kit/references/gpui/async.md b/.agents/skills/gpui-kit/references/gpui/async.md new file mode 100644 index 0000000..16982e5 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/async.md @@ -0,0 +1,225 @@ +# Async & Background Tasks + +**Contents:** [Overview](#overview) · [Quick Start](#quick-start) · [Core Patterns](#core-patterns) · [Common Pitfalls](#common-pitfalls) + +## Overview + +GPUI provides integrated async runtime for foreground UI updates and background computation. + +**Key Concepts:** + +- **Foreground tasks**: UI thread, can update entities (`cx.spawn`) +- **Background tasks**: Worker threads, CPU-intensive work (`cx.background_spawn`) +- All entity updates happen on foreground thread + +## Quick Start + +### Foreground Tasks (UI Updates) + +When spawned from `Context<Self>`, the closure receives `(WeakEntity<Self>, &mut AsyncApp)`: + +```rust +impl MyComponent { + fn fetch_data(&mut self, cx: &mut Context<Self>) { + cx.spawn(async move |this, cx: &mut AsyncApp| { + // Runs on UI thread, can await and update entities + let data = fetch_from_api().await; + + this.update(cx, |state, cx| { + state.data = Some(data); + cx.notify(); + }).ok(); + }).detach(); + } +} +``` + +When spawned from `&mut App` (not inside an entity), the closure receives only `(cx: &mut AsyncApp)`: + +```rust +cx.spawn(async move |cx: &mut AsyncApp| { + // No entity reference +}).detach(); +``` + +### Spawn with Window Context (spawn_in) + +Use `spawn_in` when the task also needs window access (`update_in`): + +```rust +impl MyComponent { + fn animate(&mut self, window: &mut Window, cx: &mut Context<Self>) { + cx.spawn_in(window, async move |this, cx| { + // cx here is AsyncWindowContext + this.update_in(cx, |state, window, cx| { + // Can access window here + state.frame += 1; + cx.notify(); + }).ok(); + }).detach(); + } +} +``` + +### Background Tasks (Heavy Work) + +```rust +impl MyComponent { + fn process_file(&mut self, cx: &mut Context<Self>) { + cx.spawn(async move |this, cx| { + // Runs on background thread, CPU-intensive + let result = cx + .background_spawn(async move { heavy_computation() }) + .await; + + // Back on the foreground to update UI + this.update(cx, |state, cx| { + state.result = result; + cx.notify(); + }).ok(); + }) + .detach(); + } +} +``` + +### Task Management + +```rust +struct MyView { + _task: Task<()>, // Prefix with _ if stored but not accessed +} + +impl MyView { + fn new(cx: &mut Context<Self>) -> Self { + let _task = cx.spawn(async move |this, cx: &mut AsyncApp| { + // Task automatically cancelled when dropped + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + + this.update(cx, |state, cx| { + state.tick(); + cx.notify(); + }).ok(); + } + }); + + Self { _task } + } +} +``` + +## Core Patterns + +### 1. Async Data Fetching (from Context<Self>) + +```rust +cx.spawn(async move |this, cx: &mut AsyncApp| { + let data = fetch_data().await?; + this.update(cx, |state, cx| { + state.data = Some(data); + cx.notify(); + })?; + Ok::<_, anyhow::Error>(()) +}).detach(); +``` + +### 2. Background Computation + UI Update + +```rust +cx.spawn(async move |this, cx: &mut AsyncApp| { + let result = cx.background_spawn(async move { heavy_work() }).await; + this.update(cx, |state, cx| { + state.result = result; + cx.notify(); + }).ok(); +}) +.detach(); +``` + +### 3. Periodic Tasks + +```rust +cx.spawn(async move |this, cx: &mut AsyncApp| { + loop { + cx.background_executor().timer(Duration::from_secs(5)).await; + + this.update(cx, |state, cx| { + state.tick(); + cx.notify(); + }).ok(); + } +}).detach(); +``` + +### 4. Task Cancellation + +Tasks are automatically cancelled when dropped. Store in struct to keep alive. + +## Common Pitfalls + +### ❌ Don't: Use `defer_in` and then update the same entity through its handle + +`cx.defer_in(window, callback)` schedules `callback` to run **on the current entity** — GPUI re-acquires that entity's lock to execute it. Calling `entity.update(cx, …)` on the *same* entity from within the deferred callback re-enters the lock and panics: + +``` +cannot update … while it is already being updated +``` + +```rust +// ❌ Panic: list entity is locked for the defer_in; calling list.update re-enters +fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) { + cx.defer_in(window, |list_state, window, cx| { + parent.update(cx, |this, cx| { + this.inner_list.update(cx, |_, _| {}); // PANIC if inner_list == the deferred entity + }); + }); +} +``` + +```rust +// ✅ Correct: use the direct &mut reference — no lock needed +fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) { + cx.defer_in(window, |list_state, window, cx| { + // Access list data directly through the &mut reference + list_state.delegate_mut().some_method(); + + // Update a *different* entity — fine, different lock + parent.update(cx, |this, cx| { /* … */ }); + + // Sync list state directly after parent update — no lock needed + list_state.delegate_mut().update_snapshot(new_val); + }); +} +``` + +The rule: inside a `defer_in` callback, **never call `entity.update(cx, …)` or `entity.read(cx)` on the entity the `defer_in` was scheduled on**. Use the `&mut Entity` direct reference the callback provides instead. + +### ❌ Don't: Update entities from background tasks + +```rust +// ❌ Wrong: Can't update entities from background thread +cx.background_spawn(async move { + entity.update(cx, |state, cx| { // Compile error! + state.data = data; + }); +}); +``` + +### ✅ Do: Await the background task inside a foreground task + +```rust +// ✅ Correct: the foreground task awaits the background result +cx.spawn(async move |this, cx| { + let data = cx.background_spawn(async move { load_data() }).await; + this.update(cx, |state, cx| { + state.data = data; + cx.notify(); + }).ok(); +}) +.detach(); +``` + +GPUI has no `Task::then`. Await the background task inside `cx.spawn`. The +closure receives `(WeakEntity<Self>, &mut AsyncApp)`, not the background result. + diff --git a/.agents/skills/gpui-kit/references/gpui/context.md b/.agents/skills/gpui-kit/references/gpui/context.md new file mode 100644 index 0000000..a1cce79 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/context.md @@ -0,0 +1,258 @@ +# Context Management + +**Contents:** [Overview](#overview) · [Quick Start](#quick-start) · [Common Operations](#common-operations) · [Context Hierarchy](#context-hierarchy) · [cx.listener](#cxlistener--binding-callbacks-to-self) · [subscribe_in](#subscribe_in--subscribe-with-window-access) · [observe_window_activation](#observe_window_activation) · [observe_global](#observe_global) · [defer / defer_in](#defer-and-defer_in) · [Naming Convention](#context-naming-convention) + +## Overview + +GPUI uses different context types for different scenarios: + +**Context Types:** +- **`App`**: Global app state, entity creation +- **`Window`**: Window-specific operations, painting, layout +- **`Context<T>`**: Entity-specific context for component `T` +- **`AsyncApp`**: Async context for foreground tasks +- **`AsyncWindowContext`**: Async context with window access + +## Quick Start + +### Context<T> - Component Context + +```rust +impl MyComponent { + fn update_state(&mut self, cx: &mut Context<Self>) { + self.value = 42; + cx.notify(); // Trigger re-render + + // Spawn async task + cx.spawn(async move |cx| { + // Async work + }).detach(); + + // Get current entity + let entity = cx.entity(); + } +} +``` + +### App - Global Context + +```rust +fn main() { + gpui_kit::application().run(|cx: &mut App| { + gpui_kit::init(cx); + + // Create entities + let entity = cx.new(|cx| MyState::default()); + + // Open windows (wraps the view in a `Root`) + gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| cx.new(|_| MyView)) + .expect("failed to open window"); + }); +} +``` + +### Window - Window Context + +```rust +impl Render for MyView { + fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + // Window operations + let is_focused = window.is_window_focused(); + let bounds = window.bounds(); + + div().child("Content") + } +} +``` + +### AsyncApp - Async Context + +```rust +cx.spawn(async move |cx: &mut AsyncApp| { + let data = fetch_data().await; + + entity.update(cx, |state, inner_cx| { + state.data = data; + inner_cx.notify(); + }).ok(); +}).detach(); +``` + +## Common Operations + +### Entity Operations + +```rust +// Create entity +let entity = cx.new(|cx| MyState::default()); + +// Update entity +entity.update(cx, |state, cx| { + state.value = 42; + cx.notify(); +}); + +// Read entity +let value = entity.read(cx).value; +``` + +### Notifications and Events + +```rust +// Trigger re-render +cx.notify(); + +// Emit event +cx.emit(MyEvent::Updated); + +// Observe entity +cx.observe(&entity, |this, observed, cx| { + // React to changes +}).detach(); + +// Subscribe to events +cx.subscribe(&entity, |this, source, event, cx| { + // Handle event +}).detach(); +``` + +### Window Operations + +```rust +// Window state +let focused = window.is_window_focused(); +let bounds = window.bounds(); +let scale = window.scale_factor(); + +// Close window +window.remove_window(); +``` + +### Async Operations + +```rust +// Spawn foreground task +cx.spawn(async move |cx| { + // Async work with entity access +}).detach(); + +// Spawn background task +cx.background_spawn(async move { + // Heavy computation +}).detach(); +``` + +## Context Hierarchy + +``` +App (Global) + └─ Window (Per-window) + └─ Context<T> (Per-component) + └─ AsyncApp (In async tasks) + └─ AsyncWindowContext (Async + Window) +``` + +## cx.listener — Binding Callbacks to Self + +`cx.listener` creates a callback that borrows `&mut self` (the current entity). Use it for `on_click`, `on_action`, and other element event handlers: + +```rust +impl Render for MyView { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .on_action(cx.listener(Self::on_save)) + .child( + Button::new("btn") + .on_click(cx.listener(|this, _event, _window, cx| { + this.count += 1; + cx.notify(); + })) + ) + } +} + +impl MyView { + fn on_save(&mut self, _: &Save, _window: &mut Window, cx: &mut Context<Self>) { + cx.notify(); + } +} +``` + +`cx.listener(Self::method)` is equivalent to creating a closure that calls `self.method(...)`. + +## subscribe_in — Subscribe with Window Access + +Use `subscribe_in` (instead of `subscribe`) when the callback needs `&mut Window`: + +```rust +let _subscription = cx.subscribe_in(&input, window, |this, state, event, window, cx| { + match event { + InputEvent::Change => { + let val = state.read(cx).value(); + this.on_input_change(val, window, cx); + } + _ => {} + } +}); +// Store _subscription in struct to keep it alive +``` + +`subscribe` vs `subscribe_in`: +- `subscribe(&entity, |this, source, event, cx|)` — no window access +- `subscribe_in(&entity, window, |this, source, event, window, cx|)` — has window access + +## observe_window_activation + +React when the window gains or loses focus: + +```rust +let _sub = cx.observe_window_activation(window, |this, window, cx| { + if window.is_window_active() { + this.resume(cx); + } else { + this.pause(cx); + } +}); +``` + +## observe_global + +React when a global value changes: + +```rust +cx.observe_global::<Theme>(|cx| { + // Theme changed — react + cx.notify(); +}); +``` + +## defer and defer_in + +Schedule work after the current update completes: + +```rust +// defer: runs after current App update, no window access +cx.defer(|cx| { + // Runs after current entity update is done +}); + +// defer_in: runs after update, with window access +cx.defer_in(window, |this, window, cx| { + // Can access window here + // CAUTION: never call entity.update(cx) on *this same entity* inside defer_in + // — it re-enters the lock and panics. Use the &mut self reference directly. + this.some_method(window, cx); +}); +``` + +## Context Naming Convention + +Always name contexts `cx` regardless of type: + +```rust +fn new(window: &mut Window, cx: &mut App) {} // cx = App +impl Render for View { + fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) {} // cx = Context<Self> +} +cx.spawn(async move |this, cx: &mut AsyncApp| {}) // cx = AsyncApp +``` diff --git a/.agents/skills/gpui-kit/references/gpui/element-advanced.md b/.agents/skills/gpui-kit/references/gpui/element-advanced.md new file mode 100644 index 0000000..80886ab --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/element-advanced.md @@ -0,0 +1,705 @@ +# Advanced Element Patterns + +**Contents:** [Custom Layout Algorithms](#custom-layout-algorithms) · [Element Composition with Traits](#element-composition-with-traits) · [Async Element Updates](#async-element-updates) · [Element Memoization](#element-memoization) · [Virtual List Pattern](#virtual-list-pattern) + +## Custom Layout Algorithms + +Implementing custom layout algorithms not supported by GPUI's built-in layouts. + +### Masonry Layout (Pinterest-Style) + +```rust +pub struct MasonryLayout { + id: ElementId, + columns: usize, + gap: Pixels, + children: Vec<AnyElement>, +} + +struct MasonryLayoutState { + column_layouts: Vec<Vec<LayoutId>>, + column_heights: Vec<Pixels>, +} + +struct MasonryPaintState { + child_bounds: Vec<Bounds<Pixels>>, +} + +impl Element for MasonryLayout { + type RequestLayoutState = MasonryLayoutState; + type PrepaintState = MasonryPaintState; + + fn id(&self) -> Option<ElementId> { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, MasonryLayoutState) { + // Initialize columns + let mut columns: Vec<Vec<LayoutId>> = vec![Vec::new(); self.columns]; + let mut column_heights = vec![px(0.); self.columns]; + + // Distribute children across columns + for child in &mut self.children { + let (child_layout_id, _) = child.request_layout( + global_id, + inspector_id, + window, + cx + ); + + let child_size = window.layout_bounds(child_layout_id).size; + + // Find shortest column + let min_column_idx = column_heights + .iter() + .enumerate() + .min_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .unwrap() + .0; + + // Add child to shortest column + columns[min_column_idx].push(child_layout_id); + column_heights[min_column_idx] += child_size.height + self.gap; + } + + // Calculate total layout size + let column_width = px(200.); // Fixed column width + let total_width = column_width * self.columns as f32 + + self.gap * (self.columns - 1) as f32; + let total_height = column_heights.iter() + .max_by(|a, b| a.partial_cmp(b).unwrap()) + .copied() + .unwrap_or(px(0.)); + + let layout_id = window.request_layout( + Style { + size: size(total_width, total_height), + ..default() + }, + columns.iter().flatten().copied().collect(), + cx + ); + + (layout_id, MasonryLayoutState { + column_layouts: columns, + column_heights, + }) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + layout_state: &mut MasonryLayoutState, + window: &mut Window, + cx: &mut App + ) -> MasonryPaintState { + let column_width = px(200.); + let mut child_bounds = Vec::new(); + + // Position children in columns + for (col_idx, column) in layout_state.column_layouts.iter().enumerate() { + let x_offset = bounds.left() + + (column_width + self.gap) * col_idx as f32; + let mut y_offset = bounds.top(); + + for (child_idx, layout_id) in column.iter().enumerate() { + let child_size = window.layout_bounds(*layout_id).size; + let child_bound = Bounds::new( + point(x_offset, y_offset), + size(column_width, child_size.height) + ); + + self.children[child_idx].prepaint( + global_id, + inspector_id, + child_bound, + window, + cx + ); + + child_bounds.push(child_bound); + y_offset += child_size.height + self.gap; + } + } + + MasonryPaintState { child_bounds } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + _bounds: Bounds<Pixels>, + _layout_state: &mut MasonryLayoutState, + paint_state: &mut MasonryPaintState, + window: &mut Window, + cx: &mut App + ) { + for (child, bounds) in self.children.iter_mut().zip(&paint_state.child_bounds) { + child.paint(global_id, inspector_id, *bounds, window, cx); + } + } +} +``` + +### Circular Layout + +```rust +pub struct CircularLayout { + id: ElementId, + radius: Pixels, + children: Vec<AnyElement>, +} + +impl Element for CircularLayout { + type RequestLayoutState = Vec<LayoutId>; + type PrepaintState = Vec<Bounds<Pixels>>; + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, Vec<LayoutId>) { + let child_layouts: Vec<_> = self.children + .iter_mut() + .map(|child| child.request_layout(global_id, inspector_id, window, cx).0) + .collect(); + + let diameter = self.radius * 2.; + let layout_id = window.request_layout( + Style { + size: size(diameter, diameter), + ..default() + }, + child_layouts.clone(), + cx + ); + + (layout_id, child_layouts) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + layout_ids: &mut Vec<LayoutId>, + window: &mut Window, + cx: &mut App + ) -> Vec<Bounds<Pixels>> { + let center = bounds.center(); + let angle_step = 2.0 * std::f32::consts::PI / self.children.len() as f32; + + let mut child_bounds = Vec::new(); + + for (i, (child, layout_id)) in self.children.iter_mut() + .zip(layout_ids.iter()) + .enumerate() + { + let angle = angle_step * i as f32; + let child_size = window.layout_bounds(*layout_id).size; + + // Position child on circle + let x = center.x + self.radius * angle.cos() - child_size.width / 2.; + let y = center.y + self.radius * angle.sin() - child_size.height / 2.; + + let child_bound = Bounds::new(point(x, y), child_size); + + child.prepaint(global_id, inspector_id, child_bound, window, cx); + child_bounds.push(child_bound); + } + + child_bounds + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + _bounds: Bounds<Pixels>, + _layout_ids: &mut Vec<LayoutId>, + child_bounds: &mut Vec<Bounds<Pixels>>, + window: &mut Window, + cx: &mut App + ) { + for (child, bounds) in self.children.iter_mut().zip(child_bounds) { + child.paint(global_id, inspector_id, *bounds, window, cx); + } + } +} +``` + +## Element Composition with Traits + +Create reusable behaviors via traits for element composition. + +### Hoverable Trait + +```rust +pub trait Hoverable: Element { + fn on_hover<F>(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Window, &mut App) + 'static; + + fn on_hover_end<F>(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Window, &mut App) + 'static; +} + +// Implementation for custom element +pub struct HoverableElement { + id: ElementId, + content: AnyElement, + hover_handlers: Vec<Box<dyn Fn(&mut Window, &mut App)>>, + hover_end_handlers: Vec<Box<dyn Fn(&mut Window, &mut App)>>, + was_hovered: bool, +} + +impl Hoverable for HoverableElement { + fn on_hover<F>(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Window, &mut App) + 'static + { + self.hover_handlers.push(Box::new(f)); + self + } + + fn on_hover_end<F>(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Window, &mut App) + 'static + { + self.hover_end_handlers.push(Box::new(f)); + self + } +} + +impl Element for HoverableElement { + type RequestLayoutState = LayoutId; + type PrepaintState = Hitbox; + + fn paint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + _layout: &mut LayoutId, + hitbox: &mut Hitbox, + window: &mut Window, + cx: &mut App + ) { + let is_hovered = hitbox.is_hovered(window); + + // Trigger hover events + if is_hovered && !self.was_hovered { + for handler in &self.hover_handlers { + handler(window, cx); + } + } else if !is_hovered && self.was_hovered { + for handler in &self.hover_end_handlers { + handler(window, cx); + } + } + + self.was_hovered = is_hovered; + + // Paint content + self.content.paint(bounds, window, cx); + } + + // ... other methods +} +``` + +### Clickable Trait + +```rust +pub trait Clickable: Element { + fn on_click<F>(&mut self, f: F) -> &mut Self + where + F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static; + + fn on_double_click<F>(&mut self, f: F) -> &mut Self + where + F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static; +} + +pub struct ClickableElement { + id: ElementId, + content: AnyElement, + click_handlers: Vec<Box<dyn Fn(&MouseUpEvent, &mut Window, &mut App)>>, + double_click_handlers: Vec<Box<dyn Fn(&MouseUpEvent, &mut Window, &mut App)>>, + last_click_time: Option<Instant>, +} + +impl Clickable for ClickableElement { + fn on_click<F>(&mut self, f: F) -> &mut Self + where + F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static + { + self.click_handlers.push(Box::new(f)); + self + } + + fn on_double_click<F>(&mut self, f: F) -> &mut Self + where + F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static + { + self.double_click_handlers.push(Box::new(f)); + self + } +} +``` + +## Async Element Updates + +Elements that update based on async operations. + +```rust +pub struct AsyncElement { + id: ElementId, + state: Entity<AsyncState>, + loading: bool, + data: Option<String>, +} + +pub struct AsyncState { + loading: bool, + data: Option<String>, +} + +impl Element for AsyncElement { + type RequestLayoutState = (); + type PrepaintState = Hitbox; + + fn paint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + _layout: &mut (), + hitbox: &mut Hitbox, + window: &mut Window, + cx: &mut App + ) { + // Display loading or data + if self.loading { + // Paint loading indicator + self.paint_loading(bounds, window, cx); + } else if let Some(data) = &self.data { + // Paint data + self.paint_data(data, bounds, window, cx); + } + + // Trigger async update on click + window.on_mouse_event({ + let state = self.state.clone(); + let hitbox = hitbox.clone(); + + move |event: &MouseUpEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Spawn async task + cx.spawn({ + let state = state.clone(); + async move { + // Perform async operation + let result = fetch_data_async().await; + + // Update state on completion + state.update(cx, |state, cx| { + state.loading = false; + state.data = Some(result); + cx.notify(); + }); + } + }).detach(); + + // Set loading state immediately + state.update(cx, |state, cx| { + state.loading = true; + cx.notify(); + }); + + cx.stop_propagation(); + } + } + }); + } + + // ... other methods +} + +async fn fetch_data_async() -> String { + // Simulate async operation + tokio::time::sleep(Duration::from_secs(1)).await; + "Data loaded!".to_string() +} +``` + +## Element Memoization + +Optimize performance by memoizing expensive element computations. + +```rust +pub struct MemoizedElement<T: PartialEq + Clone + 'static> { + id: ElementId, + value: T, + render_fn: Box<dyn Fn(&T) -> AnyElement>, + cached_element: Option<AnyElement>, + last_value: Option<T>, +} + +impl<T: PartialEq + Clone + 'static> MemoizedElement<T> { + pub fn new<F>(id: ElementId, value: T, render_fn: F) -> Self + where + F: Fn(&T) -> AnyElement + 'static, + { + Self { + id, + value, + render_fn: Box::new(render_fn), + cached_element: None, + last_value: None, + } + } +} + +impl<T: PartialEq + Clone + 'static> Element for MemoizedElement<T> { + type RequestLayoutState = LayoutId; + type PrepaintState = (); + + fn id(&self) -> Option<ElementId> { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, LayoutId) { + // Check if value changed + if self.last_value.as_ref() != Some(&self.value) || self.cached_element.is_none() { + // Recompute element + self.cached_element = Some((self.render_fn)(&self.value)); + self.last_value = Some(self.value.clone()); + } + + // Request layout for cached element + let (layout_id, _) = self.cached_element + .as_mut() + .unwrap() + .request_layout(global_id, inspector_id, window, cx); + + (layout_id, layout_id) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + _layout_id: &mut LayoutId, + window: &mut Window, + cx: &mut App + ) -> () { + self.cached_element + .as_mut() + .unwrap() + .prepaint(global_id, inspector_id, bounds, window, cx); + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + _layout_id: &mut LayoutId, + _: &mut (), + window: &mut Window, + cx: &mut App + ) { + self.cached_element + .as_mut() + .unwrap() + .paint(global_id, inspector_id, bounds, window, cx); + } +} + +// Usage +fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + MemoizedElement::new( + ElementId::Name("memoized".into()), + self.expensive_value.clone(), + |value| { + // Expensive rendering function only called when value changes + div().child(format!("Computed: {}", value)) + } + ) +} +``` + +## Virtual List Pattern + +Efficiently render large lists by only rendering visible items. + +```rust +pub struct VirtualList { + id: ElementId, + item_count: usize, + item_height: Pixels, + viewport_height: Pixels, + scroll_offset: Pixels, + render_item: Box<dyn Fn(usize) -> AnyElement>, +} + +struct VirtualListState { + visible_range: Range<usize>, + visible_item_layouts: Vec<LayoutId>, +} + +impl Element for VirtualList { + type RequestLayoutState = VirtualListState; + type PrepaintState = Hitbox; + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, VirtualListState) { + // Calculate visible range + let start_idx = (self.scroll_offset / self.item_height).floor() as usize; + let end_idx = ((self.scroll_offset + self.viewport_height) / self.item_height) + .ceil() as usize; + let visible_range = start_idx..end_idx.min(self.item_count); + + // Request layout only for visible items + let visible_item_layouts: Vec<_> = visible_range.clone() + .map(|i| { + let mut item = (self.render_item)(i); + item.request_layout(global_id, inspector_id, window, cx).0 + }) + .collect(); + + let total_height = self.item_height * self.item_count as f32; + let layout_id = window.request_layout( + Style { + size: size(relative(1.0), self.viewport_height), + overflow: Overflow::Hidden, + ..default() + }, + visible_item_layouts.clone(), + cx + ); + + (layout_id, VirtualListState { + visible_range, + visible_item_layouts, + }) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + state: &mut VirtualListState, + window: &mut Window, + _cx: &mut App + ) -> Hitbox { + // Prepaint visible items at correct positions + for (i, layout_id) in state.visible_item_layouts.iter().enumerate() { + let item_idx = state.visible_range.start + i; + let y = item_idx as f32 * self.item_height - self.scroll_offset; + let item_bounds = Bounds::new( + point(bounds.left(), bounds.top() + y), + size(bounds.width(), self.item_height) + ); + + // Prepaint if visible + if item_bounds.intersects(&bounds) { + // Prepaint item... + } + } + + window.insert_hitbox(bounds, HitboxBehavior::Normal) + } + + fn paint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + state: &mut VirtualListState, + hitbox: &mut Hitbox, + window: &mut Window, + cx: &mut App + ) { + // Paint visible items + for (i, _layout_id) in state.visible_item_layouts.iter().enumerate() { + let item_idx = state.visible_range.start + i; + let y = item_idx as f32 * self.item_height - self.scroll_offset; + let item_bounds = Bounds::new( + point(bounds.left(), bounds.top() + y), + size(bounds.width(), self.item_height) + ); + + if item_bounds.intersects(&bounds) { + let mut item = (self.render_item)(item_idx); + item.paint(item_bounds, window, cx); + } + } + + // Handle scroll + window.on_mouse_event({ + let hitbox = hitbox.clone(); + let total_height = self.item_height * self.item_count as f32; + + move |event: &ScrollWheelEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + self.scroll_offset -= event.delta.y; + self.scroll_offset = self.scroll_offset + .max(px(0.)) + .min(total_height - self.viewport_height); + cx.notify(); + cx.stop_propagation(); + } + } + }); + } +} + +// Usage: Efficiently render 10,000 items +let virtual_list = VirtualList { + id: ElementId::Name("large-list".into()), + item_count: 10_000, + item_height: px(40.), + viewport_height: px(400.), + scroll_offset: px(0.), + render_item: Box::new(|index| { + div().child(format!("Item {}", index)) + }), +}; +``` + +These advanced patterns enable sophisticated element implementations while maintaining performance and code quality. diff --git a/.agents/skills/gpui-kit/references/gpui/element-api.md b/.agents/skills/gpui-kit/references/gpui/element-api.md new file mode 100644 index 0000000..bf82a84 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/element-api.md @@ -0,0 +1,477 @@ +# Element API Reference + +**Contents:** [Element Trait Structure](#element-trait-structure) · [Associated Types](#associated-types) · [Methods](#methods) · [IntoElement Integration](#intoelement-integration) · [Layout System Integration](#layout-system-integration) · [Hitbox System](#hitbox-system) · [Event Handling](#event-handling) · [Cursor Styles](#cursor-styles) + +## Element Trait Structure + +The `Element` trait requires implementing three associated types and five methods: + +```rust +pub trait Element: 'static + IntoElement { + type RequestLayoutState: 'static; + type PrepaintState: 'static; + + fn id(&self) -> Option<ElementId>; + fn source_location(&self) -> Option<&'static std::panic::Location<'static>>; + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState); + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState; + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ); +} +``` + +## Associated Types + +### RequestLayoutState + +Data passed from `request_layout` to `prepaint` and `paint` phases. + +**Usage:** +- Store layout calculations (styled text, child layout IDs) +- Cache expensive computations +- Pass child state between phases + +**Examples:** +```rust +// Simple: no state needed +type RequestLayoutState = (); + +// Single value +type RequestLayoutState = StyledText; + +// Multiple values +type RequestLayoutState = (StyledText, Vec<ChildLayout>); + +// Complex struct +pub struct MyLayoutState { + pub styled_text: StyledText, + pub child_layouts: Vec<(LayoutId, ChildState)>, + pub computed_bounds: Bounds<Pixels>, +} +type RequestLayoutState = MyLayoutState; +``` + +### PrepaintState + +Data passed from `prepaint` to `paint` phase. + +**Usage:** +- Store hitboxes for interaction +- Cache visual bounds +- Store prepaint results + +**Examples:** +```rust +// Simple: just a hitbox +type PrepaintState = Hitbox; + +// Optional hitbox +type PrepaintState = Option<Hitbox>; + +// Multiple values +type PrepaintState = (Hitbox, Vec<Bounds<Pixels>>); + +// Complex struct +pub struct MyPaintState { + pub hitbox: Hitbox, + pub child_bounds: Vec<Bounds<Pixels>>, + pub visible_range: Range<usize>, +} +type PrepaintState = MyPaintState; +``` + +## Methods + +### id() + +Returns optional unique identifier for debugging and inspection. + +```rust +fn id(&self) -> Option<ElementId> { + Some(self.id.clone()) +} + +// Or if no ID needed +fn id(&self) -> Option<ElementId> { + None +} +``` + +### source_location() + +Returns source location for debugging. Usually returns `None` unless debugging is needed. + +```rust +fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None +} +``` + +### request_layout() + +Calculates sizes and positions for the element tree. + +**Parameters:** +- `global_id`: Global element identifier (optional) +- `inspector_id`: Inspector element identifier (optional) +- `window`: Mutable window reference +- `cx`: Mutable app context + +**Returns:** +- `(LayoutId, Self::RequestLayoutState)`: Layout ID and state for next phases + +**Responsibilities:** +1. Calculate child layouts by calling `child.request_layout()` +2. Create own layout using `window.request_layout()` +3. Return layout ID and state to pass to next phases + +**Example:** +```rust +fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, +) -> (LayoutId, Self::RequestLayoutState) { + // 1. Calculate child layouts + let child_layout_id = self.child.request_layout( + global_id, + inspector_id, + window, + cx + ).0; + + // 2. Create own layout + let layout_id = window.request_layout( + Style { + size: size(px(200.), px(100.)), + ..default() + }, + vec![child_layout_id], + cx + ); + + // 3. Return layout ID and state + (layout_id, MyLayoutState { child_layout_id }) +} +``` + +### prepaint() + +Prepares for painting by creating hitboxes and computing final bounds. + +**Parameters:** +- `global_id`: Global element identifier (optional) +- `inspector_id`: Inspector element identifier (optional) +- `bounds`: Final bounds calculated by layout engine +- `request_layout`: Mutable reference to layout state +- `window`: Mutable window reference +- `cx`: Mutable app context + +**Returns:** +- `Self::PrepaintState`: State for paint phase + +**Responsibilities:** +1. Compute final child bounds based on layout bounds +2. Call `child.prepaint()` for all children +3. Create hitboxes using `window.insert_hitbox()` +4. Return state for paint phase + +**Example:** +```rust +fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, +) -> Self::PrepaintState { + // 1. Compute child bounds + let child_bounds = bounds; // or calculated subset + + // 2. Prepaint children + self.child.prepaint( + global_id, + inspector_id, + child_bounds, + &mut request_layout.child_state, + window, + cx + ); + + // 3. Create hitboxes + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + + // 4. Return paint state + MyPaintState { hitbox } +} +``` + +### paint() + +Renders the element and handles interactions. + +**Parameters:** +- `global_id`: Global element identifier (optional) +- `inspector_id`: Inspector element identifier (optional) +- `bounds`: Final bounds for rendering +- `request_layout`: Mutable reference to layout state +- `prepaint`: Mutable reference to prepaint state +- `window`: Mutable window reference +- `cx`: Mutable app context + +**Responsibilities:** +1. Paint children first (bottom to top) +2. Paint own content (backgrounds, borders, etc.) +3. Set up interactions (mouse events, cursor styles) + +**Example:** +```rust +fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, +) { + // 1. Paint children first + self.child.paint( + global_id, + inspector_id, + child_bounds, + &mut request_layout.child_state, + &mut prepaint.child_paint_state, + window, + cx + ); + + // 2. Paint own content + window.paint_quad(paint_quad( + bounds, + Anchor::all(px(4.)), + cx.theme().background, + )); + + // 3. Set up interactions + window.on_mouse_event({ + let hitbox = prepaint.hitbox.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Handle click + cx.stop_propagation(); + } + } + }); + + window.set_cursor_style(CursorStyle::PointingHand, &prepaint.hitbox); +} +``` + +## IntoElement Integration + +Elements must also implement `IntoElement` to be used as children: + +```rust +impl IntoElement for MyElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} +``` + +This allows your custom element to be used directly in the element tree: + +```rust +div() + .child(MyElement::new()) // Works because of IntoElement +``` + +## Common Parameters + +### Global and Inspector IDs + +Both are optional identifiers used for debugging and inspection: +- `global_id`: Unique identifier across entire app +- `inspector_id`: Identifier for dev tools/inspector + +Usually passed through to children without modification. + +### Window and Context + +- `window: &mut Window`: Window-specific operations (painting, hitboxes, events) +- `cx: &mut App`: App-wide operations (spawning tasks, accessing globals) + +## Layout System Integration + +### window.request_layout() + +Creates a layout node with specified style and children: + +```rust +let layout_id = window.request_layout( + Style { + size: size(px(200.), px(100.)), + flex: Flex::Column, + gap: px(8.), + ..default() + }, + vec![child1_layout_id, child2_layout_id], + cx +); +``` + +### Bounds<Pixels> + +Represents rectangular region: + +```rust +pub struct Bounds<T> { + pub origin: Point<T>, + pub size: Size<T>, +} + +// Create bounds +let bounds = Bounds::new( + point(px(10.), px(20.)), + size(px(100.), px(50.)) +); + +// Access properties +bounds.left() // origin.x +bounds.top() // origin.y +bounds.right() // origin.x + size.width +bounds.bottom() // origin.y + size.height +bounds.center() // center point +``` + +## Hitbox System + +### Creating Hitboxes + +```rust +// Normal hitbox (blocks events) +let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + +// Transparent hitbox (passes events through) +let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Transparent); +``` + +### Using Hitboxes + +```rust +// Check if hovered +if hitbox.is_hovered(window) { + // ... +} + +// Set cursor style +window.set_cursor_style(CursorStyle::PointingHand, &hitbox); + +// Use in event handlers +window.on_mouse_event(move |event, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Handle event + } +}); +``` + +## Event Handling + +### Mouse Events + +```rust +// Mouse down +window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if phase.bubble() && bounds.contains(&event.position) { + // Handle mouse down + cx.stop_propagation(); // Prevent bubbling + } +}); + +// Mouse up +window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| { + // Handle mouse up +}); + +// Mouse move +window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { + // Handle mouse move +}); + +// Scroll +window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { + // Handle scroll +}); +``` + +### Event Phase + +Events go through two phases: +- **Capture**: Top-down (parent → child) +- **Bubble**: Bottom-up (child → parent) + +```rust +move |event, phase, window, cx| { + if phase.capture() { + // Handle in capture phase + } else if phase.bubble() { + // Handle in bubble phase + } + + cx.stop_propagation(); // Stop event from continuing +} +``` + +## Cursor Styles + +Available cursor styles: + +```rust +CursorStyle::Arrow +CursorStyle::IBeam // Text selection +CursorStyle::PointingHand // Clickable +CursorStyle::ResizeLeft +CursorStyle::ResizeRight +CursorStyle::ResizeUp +CursorStyle::ResizeDown +CursorStyle::ResizeLeftRight +CursorStyle::ResizeUpDown +CursorStyle::Crosshair +CursorStyle::OperationNotAllowed +``` + +Usage: + +```rust +window.set_cursor_style(CursorStyle::PointingHand, &hitbox); +``` diff --git a/.agents/skills/gpui-kit/references/gpui/element-best-practices.md b/.agents/skills/gpui-kit/references/gpui/element-best-practices.md new file mode 100644 index 0000000..0f549ec --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/element-best-practices.md @@ -0,0 +1,546 @@ +# Element Best Practices + +**Contents:** [State Management](#state-management) · [Performance Considerations](#performance-considerations) · [Interaction Handling](#interaction-handling) · [Layout Strategies](#layout-strategies) · [Error Handling](#error-handling) · [Testing Element Implementations](#testing-element-implementations) · [Common Pitfalls](#common-pitfalls) · [Performance Checklist](#performance-checklist) + +## State Management + +### Using Associated Types Effectively + +**Good:** Use associated types to pass meaningful data between phases + +```rust +// Good: Structured state with type safety +type RequestLayoutState = (StyledText, Vec<ChildLayout>); +type PrepaintState = (Hitbox, Vec<ChildBounds>); +``` + +**Bad:** Using empty state when you need data + +```rust +// Bad: No state when you need to pass data +type RequestLayoutState = (); +type PrepaintState = (); +// Now you can't pass layout info to paint phase! +``` + +### Managing Complex State + +For elements with complex state, create dedicated structs: + +```rust +// Good: Dedicated struct for complex state +pub struct TextElementState { + pub styled_text: StyledText, + pub text_layout: TextLayout, + pub child_states: Vec<ChildState>, +} + +type RequestLayoutState = TextElementState; +``` + +**Benefits:** +- Clear documentation of state structure +- Easy to extend +- Type-safe access + +### State Lifecycle + +**Golden Rule:** State flows in one direction through the phases + +``` +request_layout → RequestLayoutState → +prepaint → PrepaintState → +paint +``` + +**Don't:** +- Store state in the element struct that should be in associated types +- Try to mutate element state in paint phase (use `cx.notify()` to schedule re-render) +- Pass mutable references across phase boundaries + +## Performance Considerations + +### Minimize Allocations in Paint Phase + +**Critical:** Paint phase is called every frame during animations. Minimize allocations. + +**Good:** Pre-allocate in `request_layout` or `prepaint` + +```rust +impl Element for MyElement { + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Vec<StyledText>) + { + // Allocate once during layout + let styled_texts = self.children + .iter() + .map(|child| StyledText::new(child.text.clone())) + .collect(); + + (layout_id, styled_texts) + } + + fn paint(&mut self, .., styled_texts: &mut Vec<StyledText>, ..) { + // Just use pre-allocated styled_texts + for text in styled_texts { + text.paint(..); + } + } +} +``` + +**Bad:** Allocate in `paint` phase + +```rust +fn paint(&mut self, ..) { + // Bad: Allocation in paint phase! + let styled_texts: Vec<_> = self.children + .iter() + .map(|child| StyledText::new(child.text.clone())) + .collect(); +} +``` + +### Cache Expensive Computations + +Use memoization for expensive operations: + +```rust +pub struct CachedElement { + // Cache key + last_text: Option<SharedString>, + last_width: Option<Pixels>, + + // Cached result + cached_layout: Option<TextLayout>, +} + +impl Element for CachedElement { + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, TextLayout) + { + let current_width = window.bounds().width(); + + // Check if cache is valid + if self.last_text.as_ref() != Some(&self.text) + || self.last_width != Some(current_width) + || self.cached_layout.is_none() + { + // Recompute expensive layout + self.cached_layout = Some(self.compute_text_layout(current_width)); + self.last_text = Some(self.text.clone()); + self.last_width = Some(current_width); + } + + // Use cached layout + let layout = self.cached_layout.as_ref().unwrap(); + (layout_id, layout.clone()) + } +} +``` + +### Lazy Child Rendering + +Only render visible children in scrollable containers: + +```rust +fn paint(&mut self, .., bounds: Bounds<Pixels>, paint_state: &mut Self::PrepaintState, ..) { + for (i, child) in self.children.iter_mut().enumerate() { + let child_bounds = paint_state.child_bounds[i]; + + // Only paint visible children + if self.is_visible(&child_bounds, &bounds) { + child.paint(..); + } + } +} + +fn is_visible(&self, child_bounds: &Bounds<Pixels>, container_bounds: &Bounds<Pixels>) -> bool { + child_bounds.bottom() >= container_bounds.top() && + child_bounds.top() <= container_bounds.bottom() +} +``` + +## Interaction Handling + +### Proper Event Bubbling + +Always check phase and bounds before handling events: + +```rust +fn paint(&mut self, .., window: &mut Window, cx: &mut App) { + window.on_mouse_event({ + let hitbox = self.hitbox.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + // Check phase first + if !phase.bubble() { + return; + } + + // Check if event is within bounds + if !hitbox.is_hovered(window) { + return; + } + + // Handle event + self.handle_click(event); + + // Stop propagation if handled + cx.stop_propagation(); + } + }); +} +``` + +**Don't forget:** +- Check `phase.bubble()` or `phase.capture()` as appropriate +- Check hitbox hover state or bounds +- Call `cx.stop_propagation()` if you handle the event + +### Hitbox Management + +Create hitboxes in `prepaint` phase, not `paint`: + +**Good:** + +```rust +fn prepaint(&mut self, .., bounds: Bounds<Pixels>, window: &mut Window, ..) -> Hitbox { + // Create hitbox in prepaint + window.insert_hitbox(bounds, HitboxBehavior::Normal) +} + +fn paint(&mut self, .., hitbox: &mut Hitbox, window: &mut Window, ..) { + // Use hitbox in paint + window.set_cursor_style(CursorStyle::PointingHand, hitbox); +} +``` + +**Hitbox Behaviors:** + +```rust +// Normal: Blocks events from passing through +HitboxBehavior::Normal + +// Transparent: Allows events to pass through to elements below +HitboxBehavior::Transparent +``` + +### Cursor Style Guidelines + +Set appropriate cursor styles for interactivity cues: + +```rust +// Text selection +window.set_cursor_style(CursorStyle::IBeam, &hitbox); + +// Clickable elements (desktop convention: use default, not pointing hand) +window.set_cursor_style(CursorStyle::Arrow, &hitbox); + +// Links (web convention: use pointing hand) +window.set_cursor_style(CursorStyle::PointingHand, &hitbox); + +// Resizable edges +window.set_cursor_style(CursorStyle::ResizeLeftRight, &hitbox); +``` + +**Desktop vs Web Convention:** +- Desktop apps: Use `Arrow` for buttons +- Web apps: Use `PointingHand` for links only + +## Layout Strategies + +### Fixed Size Elements + +For elements with known, unchanging size: + +```rust +fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) -> (LayoutId, ()) { + let layout_id = window.request_layout( + Style { + size: size(px(200.), px(100.)), + ..default() + }, + vec![], // No children + cx + ); + (layout_id, ()) +} +``` + +### Content-Based Sizing + +For elements sized by their content: + +```rust +fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Size<Pixels>) +{ + // Measure content + let text_bounds = self.measure_text(window); + let padding = px(16.); + + let layout_id = window.request_layout( + Style { + size: size( + text_bounds.width() + padding * 2., + text_bounds.height() + padding * 2., + ), + ..default() + }, + vec![], + cx + ); + + (layout_id, text_bounds) +} +``` + +### Flexible Layouts + +For elements that adapt to available space: + +```rust +fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Vec<LayoutId>) +{ + let mut child_layout_ids = Vec::new(); + + for child in &mut self.children { + let (layout_id, _) = child.request_layout(window, cx); + child_layout_ids.push(layout_id); + } + + let layout_id = window.request_layout( + Style { + flex_direction: FlexDirection::Row, + gap: px(8.), + size: Size { + width: relative(1.0), // Fill parent width + height: auto(), // Auto height + }, + ..default() + }, + child_layout_ids.clone(), + cx + ); + + (layout_id, child_layout_ids) +} +``` + +## Error Handling + +### Graceful Degradation + +Handle errors gracefully, don't panic: + +```rust +fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Option<TextLayout>) +{ + // Try to create styled text + match StyledText::new(self.text.clone()).request_layout(None, None, window, cx) { + Ok((layout_id, text_layout)) => { + (layout_id, Some(text_layout)) + } + Err(e) => { + // Log error + eprintln!("Failed to layout text: {}", e); + + // Fallback to simple text + let fallback_text = StyledText::new("(Error loading text)".into()); + let (layout_id, _) = fallback_text.request_layout(None, None, window, cx); + (layout_id, None) + } + } +} +``` + +### Defensive Bounds Checking + +Always validate bounds and indices: + +```rust +fn paint_selection(&self, selection: &Selection, text_layout: &TextLayout, ..) { + // Validate selection bounds + let start = selection.start.min(self.text.len()); + let end = selection.end.min(self.text.len()); + + if start >= end { + return; // Invalid selection + } + + let rects = text_layout.rects_for_range(start..end); + // Paint selection... +} +``` + +## Testing Element Implementations + +### Layout Tests + +Test that layout calculations are correct: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use gpui_kit::TestAppContext; + + #[gpui_kit::test] + fn test_element_layout(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut window = cx.open_window(Default::default(), |_, _| ()).unwrap(); + + window.update(cx, |window, cx| { + let mut element = MyElement::new(); + let (layout_id, layout_state) = element.request_layout( + None, + None, + window, + cx + ); + + // Assert layout properties + let bounds = window.layout_bounds(layout_id); + assert_eq!(bounds.size.width, px(200.)); + assert_eq!(bounds.size.height, px(100.)); + }); + }); + } +} +``` + +### Interaction Tests + +Test that interactions work correctly: + +```rust +#[gpui_kit::test] +fn test_element_click(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut window = cx.open_window(Default::default(), |_, cx| { + cx.new(|_| MyElement::new()) + }).unwrap(); + + window.update(cx, |window, cx| { + let view = window.root_view().unwrap(); + + // Simulate click + let position = point(px(10.), px(10.)); + window.dispatch_event(MouseDownEvent { + position, + button: MouseButton::Left, + modifiers: Modifiers::default(), + }); + + // Assert element responded + view.read(cx).assert_clicked(); + }); + }); +} +``` + +## Common Pitfalls + +### ❌ Storing Layout State in Element Struct + +**Bad:** + +```rust +pub struct MyElement { + id: ElementId, + // Bad: This should be in RequestLayoutState + cached_layout: Option<TextLayout>, +} +``` + +**Good:** + +```rust +pub struct MyElement { + id: ElementId, + text: SharedString, +} + +type RequestLayoutState = TextLayout; // Good: State in associated type +``` + +### ❌ Mutating Element in Paint Phase + +**Bad:** + +```rust +fn paint(&mut self, ..) { + self.counter += 1; // Bad: Mutating element in paint +} +``` + +**Good:** + +```rust +fn paint(&mut self, .., window: &mut Window, cx: &mut App) { + window.on_mouse_event(move |event, phase, window, cx| { + if phase.bubble() { + self.counter += 1; + cx.notify(); // Schedule re-render + } + }); +} +``` + +### ❌ Creating Hitboxes in Paint Phase + +**Bad:** + +```rust +fn paint(&mut self, .., bounds: Bounds<Pixels>, window: &mut Window, ..) { + // Bad: Creating hitbox in paint + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); +} +``` + +**Good:** + +```rust +fn prepaint(&mut self, .., bounds: Bounds<Pixels>, window: &mut Window, ..) -> Hitbox { + // Good: Creating hitbox in prepaint + window.insert_hitbox(bounds, HitboxBehavior::Normal) +} +``` + +### ❌ Ignoring Event Phase + +**Bad:** + +```rust +window.on_mouse_event(move |event, phase, window, cx| { + // Bad: Not checking phase + self.handle_click(event); +}); +``` + +**Good:** + +```rust +window.on_mouse_event(move |event, phase, window, cx| { + // Good: Checking phase + if !phase.bubble() { + return; + } + self.handle_click(event); +}); +``` + +## Performance Checklist + +Before shipping an element implementation, verify: + +- [ ] No allocations in `paint` phase (except event handlers) +- [ ] Expensive computations are cached/memoized +- [ ] Only visible children are rendered in scrollable containers +- [ ] Hitboxes created in `prepaint`, not `paint` +- [ ] Event handlers check phase and bounds +- [ ] Layout state is passed through associated types, not stored in element +- [ ] Element implements proper error handling with fallbacks +- [ ] Tests cover layout calculations and interactions diff --git a/.agents/skills/gpui-kit/references/gpui/element-examples.md b/.agents/skills/gpui-kit/references/gpui/element-examples.md new file mode 100644 index 0000000..2644e2f --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/element-examples.md @@ -0,0 +1,632 @@ +# Element Implementation Examples + +Complete examples of implementing custom elements for various scenarios. + +## Table of Contents + +1. [Simple Text Element](#simple-text-element) +2. [Interactive Element with Selection](#interactive-element-with-selection) +3. [Complex Element with Child Management](#complex-element-with-child-management) + +## Simple Text Element + +A basic text element with syntax highlighting support. + +```rust +pub struct SimpleText { + id: ElementId, + text: SharedString, + highlights: Vec<(Range<usize>, HighlightStyle)>, +} + +impl IntoElement for SimpleText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for SimpleText { + type RequestLayoutState = StyledText; + type PrepaintState = Hitbox; + + fn id(&self) -> Option<ElementId> { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, Self::RequestLayoutState) { + // Create styled text with highlights + let mut runs = Vec::new(); + let mut ix = 0; + + for (range, highlight) in &self.highlights { + // Add unstyled text before highlight + if ix < range.start { + runs.push(window.text_style().to_run(range.start - ix)); + } + + // Add highlighted text + runs.push( + window.text_style() + .highlight(*highlight) + .to_run(range.len()) + ); + ix = range.end; + } + + // Add remaining unstyled text + if ix < self.text.len() { + runs.push(window.text_style().to_run(self.text.len() - ix)); + } + + let styled_text = StyledText::new(self.text.clone()).with_runs(runs); + let (layout_id, _) = styled_text.request_layout( + global_id, + inspector_id, + window, + cx + ); + + (layout_id, styled_text) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + styled_text: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App + ) -> Self::PrepaintState { + // Prepaint the styled text + styled_text.prepaint( + global_id, + inspector_id, + bounds, + &mut (), + window, + cx + ); + + // Create hitbox for interaction + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + hitbox + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + styled_text: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App + ) { + // Paint the styled text + styled_text.paint( + global_id, + inspector_id, + bounds, + &mut (), + &mut (), + window, + cx + ); + + // Set cursor style for text + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } +} +``` + +## Interactive Element with Selection + +A text element that supports text selection via mouse interaction. + +```rust +#[derive(Clone)] +pub struct Selection { + pub start: usize, + pub end: usize, +} + +pub struct SelectableText { + id: ElementId, + text: SharedString, + selectable: bool, + selection: Option<Selection>, +} + +impl IntoElement for SelectableText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for SelectableText { + type RequestLayoutState = TextLayout; + type PrepaintState = Option<Hitbox>; + + fn id(&self) -> Option<ElementId> { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, Self::RequestLayoutState) { + let styled_text = StyledText::new(self.text.clone()); + let (layout_id, _) = styled_text.request_layout( + global_id, + inspector_id, + window, + cx + ); + + // Extract text layout for selection painting + let text_layout = styled_text.layout().clone(); + + (layout_id, text_layout) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + _text_layout: &mut Self::RequestLayoutState, + window: &mut Window, + _cx: &mut App + ) -> Self::PrepaintState { + // Only create hitbox if selectable + if self.selectable { + Some(window.insert_hitbox(bounds, HitboxBehavior::Normal)) + } else { + None + } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + text_layout: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App + ) { + // Paint text + let styled_text = StyledText::new(self.text.clone()); + styled_text.paint( + global_id, + inspector_id, + bounds, + &mut (), + &mut (), + window, + cx + ); + + // Paint selection if any + if let Some(selection) = &self.selection { + Self::paint_selection(selection, text_layout, &bounds, window, cx); + } + + // Handle mouse events for selection + if let Some(hitbox) = hitbox { + window.set_cursor_style(CursorStyle::IBeam, hitbox); + + // Mouse down to start selection + window.on_mouse_event({ + let bounds = bounds.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if bounds.contains(&event.position) && phase.bubble() { + // Start selection at mouse position + let char_index = Self::position_to_index( + event.position, + &bounds, + text_layout + ); + self.selection = Some(Selection { + start: char_index, + end: char_index, + }); + cx.notify(); + cx.stop_propagation(); + } + } + }); + + // Mouse drag to extend selection + window.on_mouse_event({ + let bounds = bounds.clone(); + move |event: &MouseMoveEvent, phase, window, cx| { + if let Some(selection) = &mut self.selection { + if phase.bubble() { + let char_index = Self::position_to_index( + event.position, + &bounds, + text_layout + ); + selection.end = char_index; + cx.notify(); + } + } + } + }); + } + } +} + +impl SelectableText { + fn paint_selection( + selection: &Selection, + text_layout: &TextLayout, + bounds: &Bounds<Pixels>, + window: &mut Window, + cx: &mut App + ) { + // Calculate selection bounds from text layout + let selection_rects = text_layout.rects_for_range( + selection.start..selection.end + ); + + // Paint selection background + for rect in selection_rects { + window.paint_quad(paint_quad( + Bounds::new( + point(bounds.left() + rect.origin.x, bounds.top() + rect.origin.y), + rect.size + ), + Anchor::default(), + cx.theme().selection_background, + )); + } + } + + fn position_to_index( + position: Point<Pixels>, + bounds: &Bounds<Pixels>, + text_layout: &TextLayout + ) -> usize { + // Convert screen position to character index + let relative_pos = point( + position.x - bounds.left(), + position.y - bounds.top() + ); + text_layout.index_for_position(relative_pos) + } +} +``` + +## Complex Element with Child Management + +A container element that manages multiple children with scrolling support. + +```rust +pub struct ComplexElement { + id: ElementId, + children: Vec<Box<dyn Element<RequestLayoutState = (), PrepaintState = ()>>>, + scrollable: bool, + scroll_offset: Point<Pixels>, +} + +struct ComplexLayoutState { + child_layouts: Vec<LayoutId>, + total_height: Pixels, +} + +struct ComplexPaintState { + child_bounds: Vec<Bounds<Pixels>>, + hitbox: Hitbox, +} + +impl IntoElement for ComplexElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ComplexElement { + type RequestLayoutState = ComplexLayoutState; + type PrepaintState = ComplexPaintState; + + fn id(&self) -> Option<ElementId> { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, Self::RequestLayoutState) { + let mut child_layouts = Vec::new(); + let mut total_height = px(0.); + + // Request layout for all children + for child in &mut self.children { + let (child_layout_id, _) = child.request_layout( + global_id, + inspector_id, + window, + cx + ); + child_layouts.push(child_layout_id); + + // Get child size from layout + let child_size = window.layout_bounds(child_layout_id).size(); + total_height += child_size.height; + } + + // Create container layout + let layout_id = window.request_layout( + Style { + flex_direction: FlexDirection::Column, + gap: px(8.), + size: Size { + width: relative(1.0), + height: if self.scrollable { + // Fixed height for scrollable + px(400.) + } else { + // Auto height for non-scrollable + total_height + }, + }, + ..default() + }, + child_layouts.clone(), + cx + ); + + (layout_id, ComplexLayoutState { + child_layouts, + total_height, + }) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + layout_state: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App + ) -> Self::PrepaintState { + let mut child_bounds = Vec::new(); + let mut y_offset = self.scroll_offset.y; + + // Calculate child bounds and prepaint children + for (child, layout_id) in self.children.iter_mut() + .zip(&layout_state.child_layouts) + { + let child_size = window.layout_bounds(*layout_id).size(); + let child_bound = Bounds::new( + point(bounds.left(), bounds.top() + y_offset), + child_size + ); + + // Only prepaint visible children + if self.is_visible(&child_bound, &bounds) { + child.prepaint( + global_id, + inspector_id, + child_bound, + &mut (), + window, + cx + ); + } + + child_bounds.push(child_bound); + y_offset += child_size.height + px(8.); // gap + } + + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + + ComplexPaintState { + child_bounds, + hitbox, + } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds<Pixels>, + layout_state: &mut Self::RequestLayoutState, + paint_state: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App + ) { + // Paint background + window.paint_quad(paint_quad( + bounds, + Anchor::all(px(4.)), + cx.theme().background, + )); + + // Paint visible children only + for (i, child) in self.children.iter_mut().enumerate() { + let child_bounds = paint_state.child_bounds[i]; + + if self.is_visible(&child_bounds, &bounds) { + child.paint( + global_id, + inspector_id, + child_bounds, + &mut (), + &mut (), + window, + cx + ); + } + } + + // Paint scrollbar if scrollable + if self.scrollable { + self.paint_scrollbar(bounds, layout_state, window, cx); + } + + // Handle scroll events + if self.scrollable { + window.on_mouse_event({ + let hitbox = paint_state.hitbox.clone(); + let total_height = layout_state.total_height; + let visible_height = bounds.size.height; + + move |event: &ScrollWheelEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Update scroll offset + self.scroll_offset.y -= event.delta.y; + + // Clamp scroll offset + let max_scroll = (total_height - visible_height).max(px(0.)); + self.scroll_offset.y = self.scroll_offset.y + .max(px(0.)) + .min(max_scroll); + + cx.notify(); + cx.stop_propagation(); + } + } + }); + } + } +} + +impl ComplexElement { + fn is_visible(&self, child_bounds: &Bounds<Pixels>, container_bounds: &Bounds<Pixels>) -> bool { + // Check if child is within visible area + child_bounds.bottom() >= container_bounds.top() && + child_bounds.top() <= container_bounds.bottom() + } + + fn paint_scrollbar( + &self, + bounds: Bounds<Pixels>, + layout_state: &ComplexLayoutState, + window: &mut Window, + cx: &mut App + ) { + let scrollbar_width = px(8.); + let visible_height = bounds.size.height; + let total_height = layout_state.total_height; + + if total_height <= visible_height { + return; // No need for scrollbar + } + + // Calculate scrollbar position and size + let scroll_ratio = self.scroll_offset.y / (total_height - visible_height); + let thumb_height = (visible_height / total_height) * visible_height; + let thumb_y = scroll_ratio * (visible_height - thumb_height); + + // Paint scrollbar track + let track_bounds = Bounds::new( + point(bounds.right() - scrollbar_width, bounds.top()), + size(scrollbar_width, visible_height) + ); + window.paint_quad(paint_quad( + track_bounds, + Anchor::default(), + cx.theme().scrollbar_track, + )); + + // Paint scrollbar thumb + let thumb_bounds = Bounds::new( + point(bounds.right() - scrollbar_width, bounds.top() + thumb_y), + size(scrollbar_width, thumb_height) + ); + window.paint_quad(paint_quad( + thumb_bounds, + Anchor::all(px(4.)), + cx.theme().scrollbar_thumb, + )); + } +} +``` + +## Usage Examples + +### Using SimpleText + +```rust +fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .child(SimpleText { + id: ElementId::Name("code-text".into()), + text: "fn main() { println!(\"Hello\"); }".into(), + highlights: vec![ + (0..2, HighlightStyle::keyword()), + (3..7, HighlightStyle::function()), + ], + }) +} +``` + +### Using SelectableText + +```rust +fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .child(SelectableText { + id: ElementId::Name("selectable-text".into()), + text: "Select this text with your mouse".into(), + selectable: true, + selection: self.current_selection.clone(), + }) +} +``` + +### Using ComplexElement + +```rust +fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + let children: Vec<Box<dyn Element<_, _>>> = self.items + .iter() + .map(|item| Box::new(div().child(item.name.clone())) as Box<_>) + .collect(); + + div() + .child(ComplexElement { + id: ElementId::Name("scrollable-list".into()), + children, + scrollable: true, + scroll_offset: self.scroll_offset, + }) +} +``` diff --git a/.agents/skills/gpui-kit/references/gpui/element-id.md b/.agents/skills/gpui-kit/references/gpui/element-id.md new file mode 100644 index 0000000..291d191 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/element-id.md @@ -0,0 +1,95 @@ +# ElementId + +`ElementId` is a unique identifier for a GPUI element. It is required for elements that need: +- Mouse event handling (`on_click`, `on_hover`, etc.) +- State storage via `window.use_keyed_state` +- Interaction tracking + +## Making an Element Stateful + +Call `.id()` on a `div()` to create a `Stateful<Div>`: + +```rust +div().id("my-element") // ElementId from &str +div().id(42usize) // ElementId from usize +div().id(ElementId::from(idx)) // Explicit +``` + +Without `.id()`, a div cannot receive mouse events or store state. + +## Accepted Types + +```rust +impl Into<ElementId> for &str // "my-id" +impl Into<ElementId> for String // String::from("my-id") +impl Into<ElementId> for usize // 0, 1, 2, ... +impl Into<ElementId> for u64 +impl Into<ElementId> for SharedString +``` + +## Uniqueness Rules + +IDs must be unique within the same **stateful parent's scope** — not globally. GPUI builds a `GlobalElementId` by chaining parent IDs: + +```rust +div().id("app").child( + div().id("list1").children(vec![ + div().id(1usize).child("Item 1"), // GlobalId: ["app", "list1", 1] + div().id(2usize).child("Item 2"), // GlobalId: ["app", "list1", 2] + ]) +).child( + div().id("list2").children(vec![ + div().id(1usize).child("Item 1"), // GlobalId: ["app", "list2", 1] — no conflict + ]) +) +``` + +Items in different parent scopes can reuse simple IDs (integers, short strings). + +## In Component Structs + +Components always store `id: ElementId` and pass it in `new()`: + +```rust +#[derive(IntoElement)] +pub struct Button { + id: ElementId, + base: Stateful<Div>, + // ... +} + +impl Button { + pub fn new(id: impl Into<ElementId>) -> Self { + let id = id.into(); + Self { + id: id.clone(), + base: div().id(id), // id applied to base + // ... + } + } +} + +impl RenderOnce for Button { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base // already has .id() applied + .on_click(/* ... */) + } +} +``` + +## Usage at Call Sites + +```rust +// Use unique string IDs for named components +Button::new("save-btn").label("Save") +Button::new("cancel-btn").label("Cancel") + +// Use index-based IDs in lists +for (i, item) in items.iter().enumerate() { + div().id(i) // unique within this parent +} + +// Use descriptive IDs for debugging +Input::new("search-input") +Select::new("country-select") +``` diff --git a/.agents/skills/gpui-kit/references/gpui/element-patterns.md b/.agents/skills/gpui-kit/references/gpui/element-patterns.md new file mode 100644 index 0000000..31c9e2f --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/element-patterns.md @@ -0,0 +1,509 @@ +# Common Element Patterns + +**Contents:** [Text Rendering Elements](#text-rendering-elements) · [Container Elements](#container-elements) · [Interactive Elements](#interactive-elements) · [Composite Elements](#composite-elements) · [Scrollable Elements](#scrollable-elements) · [Pattern Selection Guide](#pattern-selection-guide) + +## Text Rendering Elements + +Elements that display and manipulate text content. + +### Pattern Characteristics + +- Use `StyledText` for text layout and rendering +- Handle text selection in `paint` phase with hitbox interaction +- Create hitboxes for text interaction in `prepaint` +- Support text highlighting and custom styling via runs + +### Implementation Template + +```rust +pub struct TextElement { + id: ElementId, + text: SharedString, + style: TextStyle, +} + +impl Element for TextElement { + type RequestLayoutState = StyledText; + type PrepaintState = Hitbox; + + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, StyledText) + { + let styled_text = StyledText::new(self.text.clone()) + .with_style(self.style); + let (layout_id, _) = styled_text.request_layout(None, None, window, cx); + (layout_id, styled_text) + } + + fn prepaint(&mut self, .., bounds: Bounds<Pixels>, styled_text: &mut StyledText, + window: &mut Window, cx: &mut App) -> Hitbox + { + styled_text.prepaint(None, None, bounds, &mut (), window, cx); + window.insert_hitbox(bounds, HitboxBehavior::Normal) + } + + fn paint(&mut self, .., bounds: Bounds<Pixels>, styled_text: &mut StyledText, + hitbox: &mut Hitbox, window: &mut Window, cx: &mut App) + { + styled_text.paint(None, None, bounds, &mut (), &mut (), window, cx); + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } +} +``` + +### Use Cases + +- Code editors with syntax highlighting +- Rich text displays +- Labels with custom formatting +- Selectable text areas + +## Container Elements + +Elements that manage and layout child elements. + +### Pattern Characteristics + +- Manage child element layouts and positions +- Handle scrolling and clipping when needed +- Implement flex/grid-like layouts +- Coordinate child interactions and event delegation + +### Implementation Template + +```rust +pub struct ContainerElement { + id: ElementId, + children: Vec<AnyElement>, + direction: FlexDirection, + gap: Pixels, +} + +impl Element for ContainerElement { + type RequestLayoutState = Vec<LayoutId>; + type PrepaintState = Vec<Bounds<Pixels>>; + + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Vec<LayoutId>) + { + let child_layout_ids: Vec<_> = self.children + .iter_mut() + .map(|child| child.request_layout(window, cx).0) + .collect(); + + let layout_id = window.request_layout( + Style { + flex_direction: self.direction, + gap: self.gap, + ..default() + }, + child_layout_ids.clone(), + cx + ); + + (layout_id, child_layout_ids) + } + + fn prepaint(&mut self, .., bounds: Bounds<Pixels>, layout_ids: &mut Vec<LayoutId>, + window: &mut Window, cx: &mut App) -> Vec<Bounds<Pixels>> + { + let mut child_bounds = Vec::new(); + + for (child, layout_id) in self.children.iter_mut().zip(layout_ids.iter()) { + let child_bound = window.layout_bounds(*layout_id); + child.prepaint(child_bound, window, cx); + child_bounds.push(child_bound); + } + + child_bounds + } + + fn paint(&mut self, .., child_bounds: &mut Vec<Bounds<Pixels>>, + window: &mut Window, cx: &mut App) + { + for (child, bounds) in self.children.iter_mut().zip(child_bounds.iter()) { + child.paint(*bounds, window, cx); + } + } +} +``` + +### Use Cases + +- Panels and split views +- List containers +- Grid layouts +- Tab containers + +## Interactive Elements + +Elements that respond to user input (mouse, keyboard, touch). + +### Pattern Characteristics + +- Create appropriate hitboxes for interaction areas +- Handle mouse/keyboard/touch events properly +- Manage focus and cursor styles +- Support hover, active, and disabled states + +### Implementation Template + +```rust +pub struct InteractiveElement { + id: ElementId, + content: AnyElement, + on_click: Option<Box<dyn Fn(&MouseUpEvent, &mut Window, &mut App)>>, + hover_style: Option<Style>, +} + +impl Element for InteractiveElement { + type RequestLayoutState = LayoutId; + type PrepaintState = (Hitbox, bool); // hitbox and is_hovered + + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, LayoutId) + { + let (content_layout, _) = self.content.request_layout(window, cx); + (content_layout, content_layout) + } + + fn prepaint(&mut self, .., bounds: Bounds<Pixels>, content_layout: &mut LayoutId, + window: &mut Window, cx: &mut App) -> (Hitbox, bool) + { + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + let is_hovered = hitbox.is_hovered(window); + + self.content.prepaint(bounds, window, cx); + + (hitbox, is_hovered) + } + + fn paint(&mut self, .., bounds: Bounds<Pixels>, content_layout: &mut LayoutId, + prepaint: &mut (Hitbox, bool), window: &mut Window, cx: &mut App) + { + let (hitbox, is_hovered) = prepaint; + + // Paint hover background if hovered + if *is_hovered { + if let Some(hover_style) = &self.hover_style { + window.paint_quad(paint_quad( + bounds, + Anchor::all(px(4.)), + hover_style.background_color.unwrap_or(cx.theme().hover), + )); + } + } + + // Paint content + self.content.paint(bounds, window, cx); + + // Handle click + if let Some(on_click) = self.on_click.as_ref() { + window.on_mouse_event({ + let on_click = on_click.clone(); + let hitbox = hitbox.clone(); + move |event: &MouseUpEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + on_click(event, window, cx); + cx.stop_propagation(); + } + } + }); + } + + // Set cursor style + window.set_cursor_style(CursorStyle::PointingHand, hitbox); + } +} +``` + +### Use Cases + +- Buttons +- Links +- Clickable cards +- Drag handles +- Menu items + +## Composite Elements + +Elements that combine multiple child elements with complex coordination. + +### Pattern Characteristics + +- Combine multiple child elements with different types +- Manage complex state across children +- Coordinate animations and transitions +- Handle focus delegation between children + +### Implementation Template + +```rust +pub struct CompositeElement { + id: ElementId, + header: AnyElement, + content: AnyElement, + footer: Option<AnyElement>, +} + +struct CompositeLayoutState { + header_layout: LayoutId, + content_layout: LayoutId, + footer_layout: Option<LayoutId>, +} + +struct CompositePaintState { + header_bounds: Bounds<Pixels>, + content_bounds: Bounds<Pixels>, + footer_bounds: Option<Bounds<Pixels>>, +} + +impl Element for CompositeElement { + type RequestLayoutState = CompositeLayoutState; + type PrepaintState = CompositePaintState; + + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, CompositeLayoutState) + { + let (header_layout, _) = self.header.request_layout(window, cx); + let (content_layout, _) = self.content.request_layout(window, cx); + let footer_layout = self.footer.as_mut() + .map(|f| f.request_layout(window, cx).0); + + let mut children = vec![header_layout, content_layout]; + if let Some(footer) = footer_layout { + children.push(footer); + } + + let layout_id = window.request_layout( + Style { + flex_direction: FlexDirection::Column, + size: Size { + width: relative(1.0), + height: auto(), + }, + ..default() + }, + children, + cx + ); + + (layout_id, CompositeLayoutState { + header_layout, + content_layout, + footer_layout, + }) + } + + fn prepaint(&mut self, .., bounds: Bounds<Pixels>, layout: &mut CompositeLayoutState, + window: &mut Window, cx: &mut App) -> CompositePaintState + { + let header_bounds = window.layout_bounds(layout.header_layout); + let content_bounds = window.layout_bounds(layout.content_layout); + let footer_bounds = layout.footer_layout + .map(|id| window.layout_bounds(id)); + + self.header.prepaint(header_bounds, window, cx); + self.content.prepaint(content_bounds, window, cx); + if let (Some(footer), Some(bounds)) = (&mut self.footer, footer_bounds) { + footer.prepaint(bounds, window, cx); + } + + CompositePaintState { + header_bounds, + content_bounds, + footer_bounds, + } + } + + fn paint(&mut self, .., paint_state: &mut CompositePaintState, + window: &mut Window, cx: &mut App) + { + self.header.paint(paint_state.header_bounds, window, cx); + self.content.paint(paint_state.content_bounds, window, cx); + if let (Some(footer), Some(bounds)) = (&mut self.footer, paint_state.footer_bounds) { + footer.paint(bounds, window, cx); + } + } +} +``` + +### Use Cases + +- Dialog boxes (header + content + footer) +- Cards with multiple sections +- Form layouts +- Panels with toolbars + +## Scrollable Elements + +Elements with scrollable content areas. + +### Pattern Characteristics + +- Manage scroll state (offset, velocity) +- Handle scroll events (wheel, drag, touch) +- Paint scrollbars (track and thumb) +- Clip content to visible area + +### Implementation Template + +```rust +pub struct ScrollableElement { + id: ElementId, + content: AnyElement, + scroll_offset: Point<Pixels>, + content_size: Size<Pixels>, +} + +struct ScrollPaintState { + hitbox: Hitbox, + visible_bounds: Bounds<Pixels>, +} + +impl Element for ScrollableElement { + type RequestLayoutState = (LayoutId, Size<Pixels>); + type PrepaintState = ScrollPaintState; + + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, (LayoutId, Size<Pixels>)) + { + let (content_layout, _) = self.content.request_layout(window, cx); + let content_size = window.layout_bounds(content_layout).size; + + let layout_id = window.request_layout( + Style { + size: Size { + width: relative(1.0), + height: px(400.), // Fixed viewport height + }, + overflow: Overflow::Hidden, + ..default() + }, + vec![content_layout], + cx + ); + + (layout_id, (content_layout, content_size)) + } + + fn prepaint(&mut self, .., bounds: Bounds<Pixels>, layout: &mut (LayoutId, Size<Pixels>), + window: &mut Window, cx: &mut App) -> ScrollPaintState + { + let (content_layout, content_size) = layout; + + // Calculate content bounds with scroll offset + let content_bounds = Bounds::new( + point(bounds.left(), bounds.top() - self.scroll_offset.y), + *content_size + ); + + self.content.prepaint(content_bounds, window, cx); + + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + + ScrollPaintState { + hitbox, + visible_bounds: bounds, + } + } + + fn paint(&mut self, .., layout: &mut (LayoutId, Size<Pixels>), + paint_state: &mut ScrollPaintState, window: &mut Window, cx: &mut App) + { + let (_, content_size) = layout; + + // Paint content + self.content.paint(paint_state.visible_bounds, window, cx); + + // Paint scrollbar + self.paint_scrollbar(paint_state.visible_bounds, *content_size, window, cx); + + // Handle scroll events + window.on_mouse_event({ + let hitbox = paint_state.hitbox.clone(); + let content_height = content_size.height; + let visible_height = paint_state.visible_bounds.size.height; + + move |event: &ScrollWheelEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Update scroll offset + self.scroll_offset.y -= event.delta.y; + + // Clamp to valid range + let max_scroll = (content_height - visible_height).max(px(0.)); + self.scroll_offset.y = self.scroll_offset.y + .max(px(0.)) + .min(max_scroll); + + cx.notify(); + cx.stop_propagation(); + } + } + }); + } +} + +impl ScrollableElement { + fn paint_scrollbar( + &self, + bounds: Bounds<Pixels>, + content_size: Size<Pixels>, + window: &mut Window, + cx: &mut App + ) { + let visible_height = bounds.size.height; + let content_height = content_size.height; + + if content_height <= visible_height { + return; // No scrollbar needed + } + + let scrollbar_width = px(8.); + + // Calculate thumb position and size + let scroll_ratio = self.scroll_offset.y / (content_height - visible_height); + let thumb_height = (visible_height / content_height) * visible_height; + let thumb_y = scroll_ratio * (visible_height - thumb_height); + + // Paint track + window.paint_quad(paint_quad( + Bounds::new( + point(bounds.right() - scrollbar_width, bounds.top()), + size(scrollbar_width, visible_height) + ), + Anchor::default(), + cx.theme().scrollbar_track, + )); + + // Paint thumb + window.paint_quad(paint_quad( + Bounds::new( + point(bounds.right() - scrollbar_width, bounds.top() + thumb_y), + size(scrollbar_width, thumb_height) + ), + Anchor::all(px(4.)), + cx.theme().scrollbar_thumb, + )); + } +} +``` + +### Use Cases + +- Scrollable lists +- Code editors with large files +- Long-form text content +- Image galleries + +## Pattern Selection Guide + +| Need | Pattern | Complexity | +|------|---------|------------| +| Display styled text | Text Rendering | Low | +| Layout multiple children | Container | Low-Medium | +| Handle clicks/hovers | Interactive | Medium | +| Complex multi-part UI | Composite | Medium-High | +| Large content with scrolling | Scrollable | High | + +Choose the simplest pattern that meets your requirements, then extend as needed. diff --git a/.agents/skills/gpui-kit/references/gpui/element.md b/.agents/skills/gpui-kit/references/gpui/element.md new file mode 100644 index 0000000..eb37035 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/element.md @@ -0,0 +1,107 @@ +# Custom Elements + +**Contents:** [When to Use](#when-to-use) · [Quick Start](#quick-start) · [Core Concepts](#core-concepts) · [Reference Documentation](#reference-documentation) + +## When to Use + +Use the low-level `Element` trait when: +- Need fine-grained control over layout calculation +- Building complex, performance-critical components +- Implementing custom layout algorithms (masonry, circular, etc.) +- High-level `Render`/`RenderOnce` APIs are insufficient + +**Prefer `Render`/`RenderOnce` for:** Simple components, standard layouts, declarative UI + +## Quick Start + +The `Element` trait provides direct control over three rendering phases: + +```rust +impl Element for MyElement { + type RequestLayoutState = MyLayoutState; // Data passed to later phases + type PrepaintState = MyPaintState; // Data for painting + + fn id(&self) -> Option<ElementId> { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + // Phase 1: Calculate sizes and positions + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Self::RequestLayoutState) + { + let layout_id = window.request_layout( + Style { size: size(px(200.), px(100.)), ..default() }, + vec![], + cx + ); + (layout_id, MyLayoutState { /* ... */ }) + } + + // Phase 2: Create hitboxes, prepare for painting + fn prepaint(&mut self, .., bounds: Bounds<Pixels>, layout: &mut Self::RequestLayoutState, + window: &mut Window, cx: &mut App) -> Self::PrepaintState + { + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + MyPaintState { hitbox } + } + + // Phase 3: Render and handle interactions + fn paint(&mut self, .., bounds: Bounds<Pixels>, layout: &mut Self::RequestLayoutState, + paint_state: &mut Self::PrepaintState, window: &mut Window, cx: &mut App) + { + window.paint_quad(paint_quad(bounds, Anchor::all(px(4.)), cx.theme().background)); + + window.on_mouse_event({ + let hitbox = paint_state.hitbox.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Handle interaction + cx.stop_propagation(); + } + } + }); + } +} + +// Enable element to be used as child +impl IntoElement for MyElement { + type Element = Self; + fn into_element(self) -> Self::Element { self } +} +``` + +## Core Concepts + +### Three-Phase Rendering + +1. **request_layout**: Calculate sizes and positions, return layout ID and state +2. **prepaint**: Create hitboxes, compute final bounds, prepare for painting +3. **paint**: Render element, set up interactions (mouse events, cursor styles) + +### State Flow + +``` +RequestLayoutState → PrepaintState → paint +``` + +State flows in one direction through associated types, passed as mutable references between phases. + +### Key Operations + +- **Layout**: `window.request_layout(style, children, cx)` - Create layout node +- **Hitboxes**: `window.insert_hitbox(bounds, behavior)` - Create interaction area +- **Painting**: `window.paint_quad(...)` - Render visual content +- **Events**: `window.on_mouse_event(handler)` - Handle user input + +## Reference Documentation + +### Complete API Documentation +- **API**: See [element-api.md](element-api.md) — associated types, hitbox system, event handling, cursor styles +- **Examples**: See [element-examples.md](element-examples.md) — text, interactive, complex elements +- **Patterns**: See [element-patterns.md](element-patterns.md) — text, container, interactive, composite, scrollable +- **Best Practices**: See [element-best-practices.md](element-best-practices.md) — performance, state, common pitfalls +- **Advanced**: See [element-advanced.md](element-advanced.md) — masonry/circular layouts, memoization, virtual lists diff --git a/.agents/skills/gpui-kit/references/gpui/entity-advanced.md b/.agents/skills/gpui-kit/references/gpui/entity-advanced.md new file mode 100644 index 0000000..29b8b6e --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/entity-advanced.md @@ -0,0 +1,528 @@ +# Advanced Entity Patterns + +**Contents:** [Entity Collections Management](#entity-collections-management) · [Conditional Update Patterns](#conditional-update-patterns) · [Entity State Machine Pattern](#entity-state-machine-pattern) · [Entity Proxy Pattern](#entity-proxy-pattern) · [Cascading Updates Pattern](#cascading-updates-pattern) · [Entity Snapshot Pattern](#entity-snapshot-pattern) · [Entity Transaction Pattern](#entity-transaction-pattern) · [Entity Pool Pattern](#entity-pool-pattern) + +## Entity Collections Management + +### Dynamic Collection with Cleanup + +```rust +struct EntityCollection<T> { + strong_refs: Vec<Entity<T>>, + weak_refs: Vec<WeakEntity<T>>, +} + +impl<T> EntityCollection<T> { + fn new() -> Self { + Self { + strong_refs: Vec::new(), + weak_refs: Vec::new(), + } + } + + fn add(&mut self, entity: Entity<T>, cx: &mut App) { + self.strong_refs.push(entity.clone()); + self.weak_refs.push(entity.downgrade()); + } + + fn remove(&mut self, entity_id: EntityId, cx: &mut App) { + self.strong_refs.retain(|e| e.entity_id() != entity_id); + self.weak_refs.retain(|w| { + w.upgrade() + .map(|e| e.entity_id() != entity_id) + .unwrap_or(false) + }); + } + + fn cleanup_invalid(&mut self, cx: &mut App) { + self.weak_refs.retain(|weak| weak.upgrade().is_some()); + } + + fn for_each<F>(&self, cx: &mut App, mut f: F) + where + F: FnMut(&Entity<T>, &mut App), + { + for entity in &self.strong_refs { + f(entity, cx); + } + } + + fn for_each_weak<F>(&mut self, cx: &mut App, mut f: F) + where + F: FnMut(Entity<T>, &mut App), + { + self.weak_refs.retain(|weak| { + if let Some(entity) = weak.upgrade() { + f(entity, cx); + true + } else { + false // Remove invalid weak references + } + }); + } +} +``` + +### Entity Registry Pattern + +```rust +use std::collections::HashMap; + +struct EntityRegistry<T> { + entities: HashMap<EntityId, WeakEntity<T>>, +} + +impl<T> EntityRegistry<T> { + fn new() -> Self { + Self { + entities: HashMap::new(), + } + } + + fn register(&mut self, entity: &Entity<T>) { + self.entities.insert(entity.entity_id(), entity.downgrade()); + } + + fn unregister(&mut self, entity_id: EntityId) { + self.entities.remove(&entity_id); + } + + fn get(&self, entity_id: EntityId) -> Option<Entity<T>> { + self.entities.get(&entity_id)?.upgrade() + } + + fn cleanup(&mut self) { + self.entities.retain(|_, weak| weak.upgrade().is_some()); + } + + fn count(&self) -> usize { + self.entities.len() + } + + fn all_entities(&self) -> Vec<Entity<T>> { + self.entities + .values() + .filter_map(|weak| weak.upgrade()) + .collect() + } +} +``` + +## Conditional Update Patterns + +### Debounced Updates + +```rust +use std::time::{Duration, Instant}; + +struct DebouncedEntity<T> { + entity: Entity<T>, + last_update: Instant, + debounce_duration: Duration, + pending_update: Option<Box<dyn FnOnce(&mut T, &mut Context<T>)>>, +} + +impl<T: 'static> DebouncedEntity<T> { + fn new(entity: Entity<T>, debounce_ms: u64) -> Self { + Self { + entity, + last_update: Instant::now(), + debounce_duration: Duration::from_millis(debounce_ms), + pending_update: None, + } + } + + fn update<F>(&mut self, cx: &mut App, update_fn: F) + where + F: FnOnce(&mut T, &mut Context<T>) + 'static, + { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_update); + + if elapsed >= self.debounce_duration { + // Execute immediately + self.entity.update(cx, update_fn); + self.last_update = now; + self.pending_update = None; + } else { + // Store for later + self.pending_update = Some(Box::new(update_fn)); + + // Schedule execution + let entity = self.entity.clone(); + let delay = self.debounce_duration - elapsed; + + cx.spawn(async move |cx| { + tokio::time::sleep(delay).await; + + if let Some(update) = self.pending_update.take() { + entity.update(cx, |state, inner_cx| { + update(state, inner_cx); + }); + } + }).detach(); + } + } +} +``` + +### Throttled Updates + +```rust +struct ThrottledEntity<T> { + entity: Entity<T>, + last_update: Instant, + throttle_duration: Duration, +} + +impl<T: 'static> ThrottledEntity<T> { + fn new(entity: Entity<T>, throttle_ms: u64) -> Self { + Self { + entity, + last_update: Instant::now(), + throttle_duration: Duration::from_millis(throttle_ms), + } + } + + fn try_update<F>(&mut self, cx: &mut App, update_fn: F) -> bool + where + F: FnOnce(&mut T, &mut Context<T>), + { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_update); + + if elapsed >= self.throttle_duration { + self.entity.update(cx, update_fn); + self.last_update = now; + true + } else { + false // Update throttled + } + } +} +``` + +## Entity State Machine Pattern + +```rust +enum AppState { + Idle, + Loading, + Loaded(String), + Error(String), +} + +struct StateMachine { + state: AppState, +} + +impl StateMachine { + fn new() -> Self { + Self { + state: AppState::Idle, + } + } + + fn start_loading(&mut self, cx: &mut Context<Self>) { + if matches!(self.state, AppState::Idle | AppState::Error(_)) { + self.state = AppState::Loading; + cx.notify(); + + let weak_entity = cx.entity().downgrade(); + + cx.spawn(async move |cx| { + let result = perform_load().await; + + let _ = weak_entity.update(cx, |state, cx| { + match result { + Ok(data) => state.on_load_success(data, cx), + Err(e) => state.on_load_error(e.to_string(), cx), + } + }); + }).detach(); + } + } + + fn on_load_success(&mut self, data: String, cx: &mut Context<Self>) { + if matches!(self.state, AppState::Loading) { + self.state = AppState::Loaded(data); + cx.notify(); + } + } + + fn on_load_error(&mut self, error: String, cx: &mut Context<Self>) { + if matches!(self.state, AppState::Loading) { + self.state = AppState::Error(error); + cx.notify(); + } + } + + fn reset(&mut self, cx: &mut Context<Self>) { + self.state = AppState::Idle; + cx.notify(); + } +} + +async fn perform_load() -> Result<String, anyhow::Error> { + // Actual load implementation + Ok("Data".to_string()) +} +``` + +## Entity Proxy Pattern + +```rust +struct EntityProxy<T> { + entity: WeakEntity<T>, +} + +impl<T> EntityProxy<T> { + fn new(entity: &Entity<T>) -> Self { + Self { + entity: entity.downgrade(), + } + } + + fn with<F, R>(&self, cx: &mut App, f: F) -> Result<R, anyhow::Error> + where + F: FnOnce(&T, &App) -> R, + { + self.entity.read_with(cx, f) + } + + fn update<F, R>(&self, cx: &mut App, f: F) -> Result<R, anyhow::Error> + where + F: FnOnce(&mut T, &mut Context<T>) -> R, + { + self.entity.update(cx, f) + } + + fn is_valid(&self, cx: &App) -> bool { + self.entity.upgrade().is_some() + } +} +``` + +## Cascading Updates Pattern + +```rust +struct CascadingUpdater { + entities: Vec<WeakEntity<UpdateTarget>>, +} + +impl CascadingUpdater { + fn new() -> Self { + Self { + entities: Vec::new(), + } + } + + fn add_target(&mut self, entity: &Entity<UpdateTarget>) { + self.entities.push(entity.downgrade()); + } + + fn cascade_update<F>(&mut self, cx: &mut App, update_fn: F) + where + F: Fn(&mut UpdateTarget, &mut Context<UpdateTarget>) + Clone, + { + // Update all entities in sequence + self.entities.retain(|weak| { + if let Ok(_) = weak.update(cx, |state, inner_cx| { + update_fn.clone()(state, inner_cx); + }) { + true // Keep valid entity + } else { + false // Remove invalid entity + } + }); + } +} + +struct UpdateTarget { + value: i32, +} +``` + +## Entity Snapshot Pattern + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Clone, Serialize, Deserialize)] +struct EntitySnapshot { + data: String, + timestamp: u64, +} + +struct SnapshotableEntity { + data: String, + snapshots: Vec<EntitySnapshot>, +} + +impl SnapshotableEntity { + fn new(data: String) -> Self { + Self { + data, + snapshots: Vec::new(), + } + } + + fn take_snapshot(&mut self, cx: &mut Context<Self>) { + let snapshot = EntitySnapshot { + data: self.data.clone(), + timestamp: current_timestamp(), + }; + self.snapshots.push(snapshot); + cx.notify(); + } + + fn restore_snapshot(&mut self, index: usize, cx: &mut Context<Self>) -> Result<(), String> { + if let Some(snapshot) = self.snapshots.get(index) { + self.data = snapshot.data.clone(); + cx.notify(); + Ok(()) + } else { + Err("Invalid snapshot index".to_string()) + } + } + + fn clear_old_snapshots(&mut self, keep_last: usize, cx: &mut Context<Self>) { + if self.snapshots.len() > keep_last { + self.snapshots.drain(0..self.snapshots.len() - keep_last); + cx.notify(); + } + } +} + +fn current_timestamp() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} +``` + +## Entity Transaction Pattern + +```rust +struct Transaction<T> { + entity: Entity<T>, + original_state: Option<T>, +} + +impl<T: Clone> Transaction<T> { + fn begin(entity: Entity<T>, cx: &mut App) -> Self { + let original_state = entity.read(cx).clone(); + + Self { + entity, + original_state: Some(original_state), + } + } + + fn update<F>(&mut self, cx: &mut App, update_fn: F) + where + F: FnOnce(&mut T, &mut Context<T>), + { + self.entity.update(cx, update_fn); + } + + fn commit(mut self, cx: &mut App) { + self.original_state = None; // Don't rollback + self.entity.update(cx, |_, cx| { + cx.notify(); + }); + } + + fn rollback(mut self, cx: &mut App) { + if let Some(original) = self.original_state.take() { + self.entity.update(cx, |state, cx| { + *state = original; + cx.notify(); + }); + } + } +} + +impl<T> Drop for Transaction<T> { + fn drop(&mut self) { + // Auto-rollback if not committed + if self.original_state.is_some() { + eprintln!("Warning: Transaction dropped without commit"); + } + } +} + +// Usage +fn perform_transaction(entity: Entity<MyState>, cx: &mut App) -> Result<(), String> { + let mut tx = Transaction::begin(entity, cx); + + tx.update(cx, |state, cx| { + state.value = 42; + }); + + if validate_state(&tx.entity, cx)? { + tx.commit(cx); + Ok(()) + } else { + tx.rollback(cx); + Err("Validation failed".to_string()) + } +} +``` + +## Entity Pool Pattern + +```rust +struct EntityPool<T> { + available: Vec<Entity<T>>, + in_use: Vec<WeakEntity<T>>, + factory: Box<dyn Fn(&mut App) -> Entity<T>>, +} + +impl<T: 'static> EntityPool<T> { + fn new<F>(factory: F) -> Self + where + F: Fn(&mut App) -> Entity<T> + 'static, + { + Self { + available: Vec::new(), + in_use: Vec::new(), + factory: Box::new(factory), + } + } + + fn acquire(&mut self, cx: &mut App) -> Entity<T> { + let entity = if let Some(entity) = self.available.pop() { + entity + } else { + (self.factory)(cx) + }; + + self.in_use.push(entity.downgrade()); + entity + } + + fn release(&mut self, entity: Entity<T>, cx: &mut App) { + // Reset entity state if needed + entity.update(cx, |state, cx| { + // Reset logic here + cx.notify(); + }); + + self.available.push(entity); + self.cleanup_in_use(); + } + + fn cleanup_in_use(&mut self) { + self.in_use.retain(|weak| weak.upgrade().is_some()); + } + + fn pool_size(&self) -> (usize, usize) { + (self.available.len(), self.in_use.len()) + } +} +``` + +These advanced patterns provide powerful abstractions for managing complex entity scenarios while maintaining code quality and performance. diff --git a/.agents/skills/gpui-kit/references/gpui/entity-api.md b/.agents/skills/gpui-kit/references/gpui/entity-api.md new file mode 100644 index 0000000..cf8a85f --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/entity-api.md @@ -0,0 +1,382 @@ +# Entity API Reference + +**Contents:** [Entity Types](#entity-types) · [Entity Creation](#entity-creation) · [Entity Operations](#entity-operations) · [Context Methods](#context-methods-for-entities) · [Async Operations](#async-operations) · [Entity Lifecycle](#entity-lifecycle) · [EntityId](#entityid) · [Error Handling](#error-handling) · [Type Conversions](#type-conversions) + +## Entity Types + +### Entity<T> + +A strong reference to state of type `T`. + +**Methods:** +- `entity_id()` → `EntityId` - Returns unique identifier +- `downgrade()` → `WeakEntity<T>` - Creates weak reference +- `read(cx)` → `&T` - Immutable access to state +- `read_with(cx, |state, cx| ...)` → `R` - Read with closure, returns closure result +- `update(cx, |state, cx| ...)` → `R` - Mutable update with `Context<T>`, returns closure result +- `update_in(cx, |state, window, cx| ...)` → `R` - Update with `Window` access (requires `AsyncWindowContext` or `VisualTestContext`) + +**Important Notes:** +- Trying to update an entity while it's already being updated will panic +- Within closures, use the inner `cx` provided to avoid multiple borrow issues +- With async contexts, return values are wrapped in `anyhow::Result` + +### WeakEntity<T> + +A weak reference to state of type `T`. + +**Methods:** +- `upgrade()` → `Option<Entity<T>>` - Convert to strong reference if still alive +- `read_with(cx, |state, cx| ...)` → `Result<R>` - Read if entity exists +- `update(cx, |state, cx| ...)` → `Result<R>` - Update if entity exists +- `update_in(cx, |state, window, cx| ...)` → `Result<R>` - Update with window if entity exists + +**Use Cases:** +- Avoid circular dependencies between entities +- Store references in closures/callbacks without preventing cleanup +- Optional relationships between components + +**Important:** All operations return `Result` since the entity may no longer exist. + +### AnyEntity + +Dynamically-typed entity handle for storing entities of different types. + +### AnyWeakEntity + +Dynamically-typed weak entity handle. + +## Entity Creation + +### cx.new() + +Create new entity with initial state. + +```rust +let entity = cx.new(|cx| MyState { + count: 0, + name: "Default".to_string(), +}); +``` + +**Parameters:** +- `cx: &mut App` or other context type +- Closure receiving `&mut Context<T>` returning initial state `T` + +**Returns:** `Entity<T>` + +## Entity Operations + +### Reading State + +#### read() + +Direct read-only access to state. + +```rust +let count = my_entity.read(cx).count; +``` + +**Use when:** Simple field access, no context operations needed. + +#### read_with() + +Read with context access in closure. + +```rust +let count = my_entity.read_with(cx, |state, cx| { + // Can access both state and context + state.count +}); + +// Return multiple values +let (count, theme) = my_entity.read_with(cx, |state, cx| { + (state.count, cx.theme().clone()) +}); +``` + +**Use when:** Need context operations, multiple return values, complex logic. + +### Updating State + +#### update() + +Mutable update with `Context<T>`. + +```rust +my_entity.update(cx, |state, cx| { + state.count += 1; + cx.notify(); // Trigger re-render +}); +``` + +**Available Operations:** +- `cx.notify()` - Trigger re-render +- `cx.entity()` - Get current entity +- `cx.emit(event)` - Emit event +- `cx.spawn(task)` - Spawn async task +- Other `Context<T>` methods + +#### update_in() + +Update with both `Window` and `Context<T>` access. + +```rust +my_entity.update_in(cx, |state, window, cx| { + state.focused = window.is_window_focused(); + cx.notify(); +}); +``` + +**Requires:** `AsyncWindowContext` or `VisualTestContext` + +**Use when:** Need window-specific operations like focus state, window bounds, etc. + +## Context Methods for Entities + +### cx.entity() + +Get current entity being updated. + +```rust +impl MyComponent { + fn some_method(&mut self, cx: &mut Context<Self>) { + let current_entity = cx.entity(); // Entity<MyComponent> + let weak = current_entity.downgrade(); + } +} +``` + +### cx.observe() + +Observe entity for changes. + +```rust +cx.observe(&entity, |this, observed_entity, cx| { + // Called when observed_entity.update() calls cx.notify() + println!("Entity changed"); +}).detach(); +``` + +**Returns:** `Subscription` - Call `.detach()` to make permanent + +### cx.subscribe() + +Subscribe to events from entity. + +```rust +cx.subscribe(&entity, |this, emitter, event: &SomeEvent, cx| { + // Called when emitter emits SomeEvent + match event { + SomeEvent::DataChanged => { + cx.notify(); + } + } +}).detach(); +``` + +**Returns:** `Subscription` - Call `.detach()` to make permanent + +### cx.observe_new_entities() + +Register callback for new entities of a type. + +```rust +cx.observe_new_entities::<MyState>(|entity, cx| { + println!("New entity created: {:?}", entity.entity_id()); +}).detach(); +``` + +## Async Operations + +### cx.spawn() + +Spawn foreground task (UI thread). + +```rust +cx.spawn(async move |this, cx| { + // `this`: WeakEntity<T> + // `cx`: &mut AsyncApp + + let result = some_async_work().await; + + // Update entity safely + let _ = this.update(cx, |state, cx| { + state.data = result; + cx.notify(); + }); +}).detach(); +``` + +**Note:** Always use weak entity reference in spawned tasks to prevent retain cycles. + +### cx.background_spawn() + +Spawn background task (background thread). + +```rust +cx.background_spawn(async move { + // Long-running computation + let result = heavy_computation().await; + // Cannot directly update entities here + // Use channels or spawn foreground task to update +}).detach(); +``` + +## Entity Lifecycle + +### Creation + +Entities are created via `cx.new()` and immediately registered in the app. + +### Reference Counting + +- `Entity<T>` is a strong reference (increases reference count) +- `WeakEntity<T>` is a weak reference (does not increase reference count) +- Cloning `Entity<T>` increases reference count + +### Disposal + +Entities are automatically disposed when all strong references are dropped. + +```rust +{ + let entity = cx.new(|cx| MyState::default()); + // entity exists +} // entity dropped here if no other strong references exist +``` + +**Memory Leak Prevention:** +- Use `WeakEntity` in closures/callbacks +- Use `WeakEntity` for parent-child relationships +- Avoid circular strong references + +## EntityId + +Every entity has a unique identifier. + +```rust +let id: EntityId = entity.entity_id(); + +// EntityIds can be compared +if entity1.entity_id() == entity2.entity_id() { + // Same entity +} +``` + +**Use Cases:** +- Debugging and logging +- Entity comparison without borrowing +- Hash maps keyed by entity + +## Error Handling + +### WeakEntity Operations + +All `WeakEntity` operations return `Result`: + +```rust +let weak = entity.downgrade(); + +// Handle potential failure +match weak.read_with(cx, |state, cx| state.count) { + Ok(count) => println!("Count: {}", count), + Err(e) => eprintln!("Entity no longer exists: {}", e), +} + +// Or use Result combinators +let _ = weak.update(cx, |state, cx| { + state.count += 1; + cx.notify(); +}).ok(); // Ignore errors +``` + +### Update Panics + +Nested updates on the same entity will panic: + +```rust +// ❌ Will panic +entity.update(cx, |state1, cx| { + entity.update(cx, |state2, cx| { + // Panic: entity already borrowed + }); +}); +``` + +**Solution:** Perform updates sequentially or use different entities. + +## Type Conversions + +### Entity → WeakEntity + +```rust +let entity: Entity<T> = cx.new(|cx| T::default()); +let weak: WeakEntity<T> = entity.downgrade(); +``` + +### WeakEntity → Entity + +```rust +let weak: WeakEntity<T> = entity.downgrade(); +let strong: Option<Entity<T>> = weak.upgrade(); +``` + +### AnyEntity + +```rust +let any: AnyEntity = entity.into(); +let typed: Option<Entity<T>> = any.downcast::<T>(); +``` + +## Best Practice Guidelines + +### Always Use Inner cx + +```rust +// ✅ Good: Use inner cx +entity.update(cx, |state, inner_cx| { + inner_cx.notify(); // Use inner_cx, not outer cx +}); + +// ❌ Bad: Use outer cx +entity.update(cx, |state, inner_cx| { + cx.notify(); // Wrong! Multiple borrow error +}); +``` + +### Weak References in Closures + +```rust +// ✅ Good: Weak reference +let weak = cx.entity().downgrade(); +callback(move || { + let _ = weak.update(cx, |state, cx| { + cx.notify(); + }); +}); + +// ❌ Bad: Strong reference (retain cycle) +let strong = cx.entity(); +callback(move || { + strong.update(cx, |state, cx| { + // May never be dropped + cx.notify(); + }); +}); +``` + +### Sequential Updates + +```rust +// ✅ Good: Sequential updates +entity1.update(cx, |state, cx| { /* ... */ }); +entity2.update(cx, |state, cx| { /* ... */ }); + +// ❌ Bad: Nested updates +entity1.update(cx, |_, cx| { + entity2.update(cx, |_, cx| { + // May panic if entities are related + }); +}); +``` diff --git a/.agents/skills/gpui-kit/references/gpui/entity-best-practices.md b/.agents/skills/gpui-kit/references/gpui/entity-best-practices.md new file mode 100644 index 0000000..0678cc7 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/entity-best-practices.md @@ -0,0 +1,537 @@ +# Entity Best Practices + +**Contents:** [Avoiding Common Pitfalls](#avoiding-common-pitfalls) · [Performance Optimization](#performance-optimization) · [Entity Lifecycle Management](#entity-lifecycle-management) · [Entity Observation Best Practices](#entity-observation-best-practices) · [Async Best Practices](#async-best-practices) · [Testing Best Practices](#testing-best-practices) · [Performance Checklist](#performance-checklist) + +## Avoiding Common Pitfalls + +### Avoid Re-entrant Entity Access (Same Entity) + +**Problem:** GPUI locks an entity for the entire duration of its render or update pass. Any attempt to `read` or `update` that *same* entity while the lock is held panics: + +``` +cannot update … while it is already being updated +``` + +```rust +// ❌ Panic: entity_a updated from within entity_a's own update +entity_a.update(cx, |_, cx| { + entity_a.update(cx, |_, _| {}); // PANIC +}); + +// ✅ Fine: updating a *different* entity from within an update +entity_a.update(cx, |_, cx| { + entity_b.update(cx, |_, _| {}); // OK — different lock +}); +``` + +**Note:** Updating two *different* entities in a nested fashion is safe. The restriction is strictly about re-entering the *same* entity's lock. + +### `defer_in` Re-locks the Entity — Same Rules Apply + +`cx.defer_in(window, callback)` schedules `callback` to run *on the entity the context refers to*. GPUI re-acquires that entity's lock to execute the deferred callback, so the re-entrancy rules apply equally inside it: + +```rust +// ❌ Panic: defer_in runs with ListState locked; calling list.update re-enters +fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) { + cx.defer_in(window, |list_state, window, cx| { + // list_state IS locked for this entire callback! + parent.update(cx, |this, cx| { + this.list.update(cx, |_, _| {}); // PANIC — re-enters ListState lock + }); + }); +} +``` + +**Fix:** Use the direct `&mut` reference the callback provides instead of going through the entity handle: + +```rust +// ✅ Correct: direct mutable access — no lock needed +fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) { + cx.defer_in(window, |list_state, window, cx| { + // Step 1: call hooks directly through list_state (no entity lock) + list_state.delegate_mut().on_will_change(&mut op, &snapshot); + + // Step 2: update the parent entity — different lock, safe + let new_sel = parent.update(cx, |this, cx| { + this.state.apply_change(op); + cx.notify(); + this.state.selection.clone() // return data needed for step 3 + }); + + // Step 3: sync list state directly — no entity lock + if let Ok(sel) = new_sel { + list_state.delegate_mut().update_snapshot(sel.clone()); + list_state.delegate_mut().on_confirm(&sel); + } + }); +} +``` + +### Render Callbacks Must Not Access External Entities + +`render_item` and any other rendering hook runs inside the entity's render pass. Calling `entity.read(cx)` or `entity.update(cx, …)` on an external entity (which may itself be in a render/update pass) panics with the same re-entrancy error. + +**Fix:** Keep a plain `snapshot` field updated eagerly from outside render after every mutation: + +```rust +// ❌ Panic — called during ListState render; external entity access re-enters +fn render_item(&mut self, ix: IndexPath, …) -> … { + let checked = parent.read(cx).selection.contains(&ix); // PANIC +} + +// ✅ Read from snapshot field — no entity access at all +fn render_item(&mut self, ix: IndexPath, …) -> … { + let checked = self.selection_snapshot.iter().any(|(sel_ix, _)| sel_ix == &ix); +} +// After every mutation from outside render: +list.update(cx, |l, _| l.delegate_mut().update_snapshot(new_snapshot)); +``` + +### Use Weak References in Closures + +**Problem:** Strong references in closures can create retain cycles and memory leaks. + +```rust +// ❌ Bad: Strong reference creates retain cycle +impl MyComponent { + fn setup_callback(&mut self, cx: &mut Context<Self>) { + let entity = cx.entity(); // Strong reference + + some_callback(move || { + entity.update(cx, |state, cx| { + // This closure holds a strong reference + // If the closure itself is retained by the entity, memory leak! + cx.notify(); + }); + }); + } +} +``` + +**Solution:** Use weak references in closures. + +```rust +// ✅ Good: Weak reference prevents retain cycle +impl MyComponent { + fn setup_callback(&mut self, cx: &mut Context<Self>) { + let weak_entity = cx.entity().downgrade(); // Weak reference + + some_callback(move || { + // Safe: weak reference doesn't prevent cleanup + let _ = weak_entity.update(cx, |state, cx| { + cx.notify(); + }); + }); + } +} +``` + +### Use Inner Context in Closures + +**Problem:** Using outer context causes multiple borrow errors. + +```rust +// ❌ Bad: Using outer cx causes borrow issues +entity.update(cx, |state, inner_cx| { + cx.notify(); // Wrong! Using outer cx + cx.spawn(...); // Multiple borrow error +}); +``` + +**Solution:** Always use the inner context provided to the closure. + +```rust +// ✅ Good: Use inner cx +entity.update(cx, |state, inner_cx| { + inner_cx.notify(); // Correct + inner_cx.spawn(...); // Works fine +}); +``` + +### Entity as Props - Use Weak References + +**Problem:** Strong entity references in props can create ownership issues. + +```rust +// ❌ Questionable: Strong reference in child +struct ChildComponent { + parent: Entity<ParentComponent>, // Strong reference +} +``` + +**Better:** Use weak references for parent relationships. + +```rust +// ✅ Good: Weak reference prevents issues +struct ChildComponent { + parent: WeakEntity<ParentComponent>, // Weak reference +} + +impl ChildComponent { + fn notify_parent(&mut self, cx: &mut Context<Self>) { + // Check if parent still exists + if let Ok(_) = self.parent.update(cx, |parent_state, cx| { + // Update parent + cx.notify(); + }) { + // Parent successfully updated + } + } +} +``` + +## Performance Optimization + +### Minimize cx.notify() Calls + +Each `cx.notify()` triggers a re-render. Batch updates when possible. + +```rust +// ❌ Bad: Multiple notifications +impl MyComponent { + fn update_multiple_fields(&mut self, cx: &mut Context<Self>) { + self.field1 = new_value1; + cx.notify(); // Unnecessary intermediate notification + + self.field2 = new_value2; + cx.notify(); // Unnecessary intermediate notification + + self.field3 = new_value3; + cx.notify(); + } +} +``` + +```rust +// ✅ Good: Single notification after all updates +impl MyComponent { + fn update_multiple_fields(&mut self, cx: &mut Context<Self>) { + self.field1 = new_value1; + self.field2 = new_value2; + self.field3 = new_value3; + cx.notify(); // Single notification + } +} +``` + +### Conditional Updates + +Only notify when state actually changes. + +```rust +impl MyComponent { + fn set_value(&mut self, new_value: i32, cx: &mut Context<Self>) { + if self.value != new_value { + self.value = new_value; + cx.notify(); // Only notify if changed + } + } +} +``` + +### Use read_with for Complex Operations + +Prefer `read_with` over separate `read` calls. + +```rust +// ❌ Less efficient: Multiple borrows +let state_ref = entity.read(cx); +let value1 = state_ref.field1; +let value2 = state_ref.field2; +// state_ref borrowed for entire scope + +// ✅ More efficient: Single borrow with closure +let (value1, value2) = entity.read_with(cx, |state, cx| { + (state.field1, state.field2) +}); +``` + +### Avoid Excessive Entity Creation + +Creating entities has overhead. Reuse when appropriate. + +```rust +// ❌ Bad: Creating entity per item in render +impl Render for MyList { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div().children( + self.items.iter().map(|item| { + // Don't create entities in render! + let entity = cx.new(|_| item.clone()); + ItemView { entity } + }) + ) + } +} +``` + +```rust +// ✅ Good: Create entities once, reuse +struct MyList { + item_entities: Vec<Entity<Item>>, +} + +impl MyList { + fn add_item(&mut self, item: Item, cx: &mut Context<Self>) { + let entity = cx.new(|_| item); + self.item_entities.push(entity); + cx.notify(); + } +} +``` + +## Entity Lifecycle Management + +### Clean Up Weak References + +Periodically clean up invalid weak references from collections. + +```rust +struct Container { + weak_children: Vec<WeakEntity<Child>>, +} + +impl Container { + fn cleanup_invalid_children(&mut self, cx: &mut Context<Self>) { + // Remove weak references that are no longer valid + let before_count = self.weak_children.len(); + self.weak_children.retain(|weak| weak.upgrade().is_some()); + let after_count = self.weak_children.len(); + + if before_count != after_count { + cx.notify(); // Notify if list changed + } + } +} +``` + +### Entity Cloning and Sharing + +Understand that cloning `Entity<T>` increases reference count. + +```rust +// Each clone increases the reference count +let entity1: Entity<MyState> = cx.new(|_| MyState::default()); +let entity2 = entity1.clone(); // Reference count: 2 +let entity3 = entity1.clone(); // Reference count: 3 + +// Entity is dropped only when all references are dropped +drop(entity1); // Reference count: 2 +drop(entity2); // Reference count: 1 +drop(entity3); // Reference count: 0, entity is deallocated +``` + +### Proper Resource Cleanup + +Implement cleanup in `Drop` or explicit cleanup methods. + +```rust +struct ManagedResource { + handle: Option<FileHandle>, +} + +impl ManagedResource { + fn close(&mut self, cx: &mut Context<Self>) { + if let Some(handle) = self.handle.take() { + // Explicit cleanup + handle.close(); + cx.notify(); + } + } +} + +impl Drop for ManagedResource { + fn drop(&mut self) { + // Automatic cleanup when entity is dropped + if let Some(handle) = self.handle.take() { + handle.close(); + } + } +} +``` + +## Entity Observation Best Practices + +### Detach Subscriptions Appropriately + +Call `.detach()` on subscriptions you want to keep alive. + +```rust +impl MyComponent { + fn new(other_entity: Entity<OtherComponent>, cx: &mut App) -> Entity<Self> { + cx.new(|cx| { + // Observer will live as long as both entities exist + cx.observe(&other_entity, |this, observed, cx| { + // Handle changes + cx.notify(); + }).detach(); // Important: detach to make permanent + + Self { /* fields */ } + }) + } +} +``` + +### Avoid Observation Cycles + +Don't create mutual observation between entities. + +```rust +// ❌ Bad: Mutual observation can cause infinite loops +entity1.update(cx, |_, cx| { + cx.observe(&entity2, |_, _, cx| { + cx.notify(); // May trigger entity2's observer + }).detach(); +}); + +entity2.update(cx, |_, cx| { + cx.observe(&entity1, |_, _, cx| { + cx.notify(); // May trigger entity1's observer → infinite loop + }).detach(); +}); +``` + +## Async Best Practices + +### Always Use Weak References in Async Tasks + +```rust +// ✅ Good: Weak reference in spawned task +impl MyComponent { + fn fetch_data(&mut self, cx: &mut Context<Self>) { + let weak_entity = cx.entity().downgrade(); + + cx.spawn(async move |cx| { + let data = fetch_from_api().await; + + // Entity may have been dropped during fetch + let _ = weak_entity.update(cx, |state, cx| { + state.data = Some(data); + cx.notify(); + }); + }).detach(); + } +} +``` + +### Handle Async Errors Gracefully + +```rust +impl MyComponent { + fn fetch_data(&mut self, cx: &mut Context<Self>) { + let weak_entity = cx.entity().downgrade(); + + cx.spawn(async move |cx| { + match fetch_from_api().await { + Ok(data) => { + let _ = weak_entity.update(cx, |state, cx| { + state.data = Some(data); + state.error = None; + cx.notify(); + }); + } + Err(e) => { + let _ = weak_entity.update(cx, |state, cx| { + state.error = Some(e.to_string()); + cx.notify(); + }); + } + } + }).detach(); + } +} +``` + +### Cancellation Patterns + +Implement cancellation for long-running tasks. + +```rust +struct DataFetcher { + current_task: Option<Task<()>>, + data: Option<String>, +} + +impl DataFetcher { + fn fetch_data(&mut self, url: String, cx: &mut Context<Self>) { + // Cancel previous task + self.current_task = None; // Dropping task cancels it + + let weak_entity = cx.entity().downgrade(); + + let task = cx.spawn(async move |cx| { + let data = fetch_from_url(&url).await?; + + let _ = weak_entity.update(cx, |state, cx| { + state.data = Some(data); + cx.notify(); + }); + + Ok::<(), anyhow::Error>(()) + }); + + self.current_task = Some(task); + } +} +``` + +## Testing Best Practices + +### Use TestAppContext for Entity Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + use gpui_kit::TestAppContext; + + #[gpui_kit::test] + fn test_entity_update(cx: &mut TestAppContext) { + let entity = cx.new(|_| MyState { count: 0 }); + + entity.update(cx, |state, cx| { + state.count += 1; + assert_eq!(state.count, 1); + }); + + let count = entity.read(cx).count; + assert_eq!(count, 1); + } +} +``` + +### Test Entity Observation + +```rust +#[gpui_kit::test] +fn test_entity_observation(cx: &mut TestAppContext) { + let observed = cx.new(|_| MyState { value: 0 }); + let observer = cx.new(|cx| Observer::new(observed.clone(), cx)); + + // Update observed entity + observed.update(cx, |state, cx| { + state.value = 42; + cx.notify(); + }); + + // Verify observer was notified + observer.read(cx).assert_observed(); +} +``` + +## Performance Checklist + +Before shipping entity-based code, verify: + +- [ ] No strong references in closures/callbacks (use `WeakEntity`) +- [ ] No nested entity updates (use sequential updates) +- [ ] Using inner `cx` in update closures +- [ ] Batching updates before calling `cx.notify()` +- [ ] Cleaning up invalid weak references periodically +- [ ] Using `read_with` for complex read operations +- [ ] Properly detaching subscriptions and observers +- [ ] Using weak references in async tasks +- [ ] No observation cycles between entities +- [ ] Proper error handling in async operations +- [ ] Resource cleanup in `Drop` or explicit methods +- [ ] Tests cover entity lifecycle and interactions diff --git a/.agents/skills/gpui-kit/references/gpui/entity-patterns.md b/.agents/skills/gpui-kit/references/gpui/entity-patterns.md new file mode 100644 index 0000000..cfaa74a --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/entity-patterns.md @@ -0,0 +1,577 @@ +# Entity Patterns + +**Contents:** [Application Scenarios](#application-scenarios) · [Common Patterns](#common-patterns) · [Pattern Selection Guide](#pattern-selection-guide) + +## Application Scenarios + +### Model-View Separation + +Separate business logic (model) from UI (view) using entities. + +```rust +struct CounterModel { + count: usize, + listeners: Vec<Box<dyn Fn(usize)>>, +} + +struct CounterView { + model: Entity<CounterModel>, +} + +impl CounterModel { + fn increment(&mut self, cx: &mut Context<Self>) { + self.count += 1; + + // Notify listeners + for listener in &self.listeners { + listener(self.count); + } + + cx.notify(); + } + + fn decrement(&mut self, cx: &mut Context<Self>) { + if self.count > 0 { + self.count -= 1; + cx.notify(); + } + } +} + +impl CounterView { + fn new(cx: &mut App) -> Entity<Self> { + let model = cx.new(|_cx| CounterModel { + count: 0, + listeners: Vec::new(), + }); + + cx.new(|cx| Self { model }) + } + + fn increment_count(&mut self, cx: &mut Context<Self>) { + self.model.update(cx, |model, cx| { + model.increment(cx); + }); + } +} + +impl Render for CounterView { + fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + let count = self.model.read(cx).count; + + div() + .child(format!("Count: {}", count)) + .child( + Button::new("increment") + .label("Increment") + .on_click(cx.listener(|this, _, cx| { + this.increment_count(cx); + })) + ) + } +} +``` + +### Component State Management + +Managing complex component state with entities. + +```rust +struct TodoList { + todos: Vec<Todo>, + filter: TodoFilter, + next_id: usize, +} + +struct Todo { + id: usize, + text: String, + completed: bool, +} + +enum TodoFilter { + All, + Active, + Completed, +} + +impl TodoList { + fn new() -> Self { + Self { + todos: Vec::new(), + filter: TodoFilter::All, + next_id: 0, + } + } + + fn add_todo(&mut self, text: String, cx: &mut Context<Self>) { + self.todos.push(Todo { + id: self.next_id, + text, + completed: false, + }); + self.next_id += 1; + cx.notify(); + } + + fn toggle_todo(&mut self, id: usize, cx: &mut Context<Self>) { + if let Some(todo) = self.todos.iter_mut().find(|t| t.id == id) { + todo.completed = !todo.completed; + cx.notify(); + } + } + + fn remove_todo(&mut self, id: usize, cx: &mut Context<Self>) { + self.todos.retain(|t| t.id != id); + cx.notify(); + } + + fn set_filter(&mut self, filter: TodoFilter, cx: &mut Context<Self>) { + self.filter = filter; + cx.notify(); + } + + fn visible_todos(&self) -> impl Iterator<Item = &Todo> { + self.todos.iter().filter(move |todo| match self.filter { + TodoFilter::All => true, + TodoFilter::Active => !todo.completed, + TodoFilter::Completed => todo.completed, + }) + } +} +``` + +### Cross-Entity Communication + +Coordinating state between parent and child entities. + +```rust +struct ParentComponent { + child_entities: Vec<Entity<ChildComponent>>, + global_message: String, +} + +struct ChildComponent { + id: usize, + message: String, + parent: WeakEntity<ParentComponent>, +} + +impl ParentComponent { + fn new(cx: &mut App) -> Entity<Self> { + cx.new(|cx| Self { + child_entities: Vec::new(), + global_message: String::new(), + }) + } + + fn add_child(&mut self, cx: &mut Context<Self>) { + let parent_weak = cx.entity().downgrade(); + let child_id = self.child_entities.len(); + + let child = cx.new(|cx| ChildComponent { + id: child_id, + message: String::new(), + parent: parent_weak, + }); + + self.child_entities.push(child); + cx.notify(); + } + + fn broadcast_message(&mut self, message: String, cx: &mut Context<Self>) { + self.global_message = message.clone(); + + // Update all children + for child in &self.child_entities { + child.update(cx, |child_state, cx| { + child_state.message = message.clone(); + cx.notify(); + }); + } + + cx.notify(); + } +} + +impl ChildComponent { + fn notify_parent(&mut self, message: String, cx: &mut Context<Self>) { + if let Ok(_) = self.parent.update(cx, |parent_state, cx| { + parent_state.global_message = format!("Child {}: {}", self.id, message); + cx.notify(); + }) { + // Parent successfully notified + } + } +} +``` + +### Async Operations with Entities + +Managing async state updates. + +```rust +struct DataLoader { + loading: bool, + data: Option<String>, + error: Option<String>, +} + +impl DataLoader { + fn new() -> Self { + Self { + loading: false, + data: None, + error: None, + } + } + + fn load_data(&mut self, cx: &mut Context<Self>) { + // Set loading state + self.loading = true; + self.error = None; + cx.notify(); + + // Get weak reference for async task + let entity = cx.entity().downgrade(); + + cx.spawn(async move |cx| { + // Simulate async operation + tokio::time::sleep(Duration::from_secs(2)).await; + let result = fetch_data().await; + + // Update entity with result + let _ = entity.update(cx, |state, cx| { + state.loading = false; + match result { + Ok(data) => state.data = Some(data), + Err(e) => state.error = Some(e.to_string()), + } + cx.notify(); + }); + }).detach(); + } +} + +async fn fetch_data() -> Result<String, anyhow::Error> { + // Actual fetch implementation + Ok("Fetched data".to_string()) +} +``` + +### Background Task Coordination + +Using background tasks with entity updates. + +```rust +struct ImageProcessor { + images: Vec<ProcessedImage>, + processing: bool, +} + +struct ProcessedImage { + path: PathBuf, + thumbnail: Option<Vec<u8>>, +} + +impl ImageProcessor { + fn process_images(&mut self, paths: Vec<PathBuf>, cx: &mut Context<Self>) { + self.processing = true; + cx.notify(); + + cx.spawn(async move |this, cx| { + let processed = cx + .background_spawn(async move { + let mut processed = Vec::new(); + + for path in paths { + // Process image on background thread + let thumbnail = generate_thumbnail(&path).await; + processed.push((path, thumbnail)); + } + + // Send results back to foreground + processed + }) + .await; + + // Update entity on foreground thread + let _ = this.update(cx, |state, cx| { + for (path, thumbnail) in processed { + state.images.push(ProcessedImage { + path, + thumbnail: Some(thumbnail), + }); + } + state.processing = false; + cx.notify(); + }); + }) + .detach(); + } +} +``` + +## Common Patterns + +### 1. Stateful Components + +Use entities for components that maintain internal state. + +```rust +struct StatefulComponent { + value: i32, + history: Vec<i32>, +} + +impl StatefulComponent { + fn update_value(&mut self, new_value: i32, cx: &mut Context<Self>) { + self.history.push(self.value); + self.value = new_value; + cx.notify(); + } + + fn undo(&mut self, cx: &mut Context<Self>) { + if let Some(prev_value) = self.history.pop() { + self.value = prev_value; + cx.notify(); + } + } +} +``` + +### 2. Shared State + +Share state between multiple components using entities. + +```rust +struct SharedState { + theme: Theme, + user: Option<User>, +} + +struct ComponentA { + shared: Entity<SharedState>, +} + +struct ComponentB { + shared: Entity<SharedState>, +} + +// Both components can read/update the same shared state +impl ComponentA { + fn update_theme(&mut self, theme: Theme, cx: &mut Context<Self>) { + self.shared.update(cx, |state, cx| { + state.theme = theme; + cx.notify(); + }); + } +} +``` + +### 3. Event Coordination + +Use entities to coordinate events between components. + +```rust +struct EventCoordinator { + listeners: Vec<WeakEntity<dyn EventListener>>, +} + +trait EventListener { + fn on_event(&mut self, event: &AppEvent, cx: &mut App); +} + +impl EventCoordinator { + fn emit_event(&mut self, event: AppEvent, cx: &mut Context<Self>) { + // Notify all listeners + self.listeners.retain(|weak_listener| { + weak_listener.update(cx, |listener, cx| { + listener.on_event(&event, cx); + }).is_ok() + }); + cx.notify(); + } +} +``` + +### 4. Async State Management + +Manage state that changes based on async operations. + +```rust +struct AsyncState<T> { + state: AsyncValue<T>, +} + +enum AsyncValue<T> { + Loading, + Loaded(T), + Error(String), +} + +impl<T> AsyncState<T> { + fn is_loading(&self) -> bool { + matches!(self.state, AsyncValue::Loading) + } + + fn value(&self) -> Option<&T> { + match &self.state { + AsyncValue::Loaded(v) => Some(v), + _ => None, + } + } +} +``` + +### 5. Parent-Child Relationships + +Manage hierarchical relationships with weak references. + +```rust +struct Parent { + children: Vec<Entity<Child>>, +} + +struct Child { + parent: WeakEntity<Parent>, + data: String, +} + +impl Child { + fn notify_parent_of_change(&mut self, cx: &mut Context<Self>) { + if let Ok(_) = self.parent.update(cx, |parent, cx| { + // Parent can react to child change + cx.notify(); + }) { + // Successfully notified + } + } +} +``` + +### 6. Observer Pattern + +React to entity state changes using observers. + +```rust +struct Observable { + value: i32, +} + +struct Observer { + observed: Entity<Observable>, +} + +impl Observer { + fn new(observed: Entity<Observable>, cx: &mut App) -> Entity<Self> { + cx.new(|cx| { + // Observe the entity + cx.observe(&observed, |this, observed_entity, cx| { + // React to changes + let value = observed_entity.read(cx).value; + println!("Value changed to: {}", value); + }).detach(); + + Self { observed } + }) + } +} +``` + +### 7. Event Subscription + +Handle events emitted by other entities. + +```rust +#[derive(Clone)] +enum DataEvent { + Updated, + Deleted, +} + +struct DataSource { + data: Vec<String>, +} + +impl DataSource { + fn update_data(&mut self, cx: &mut Context<Self>) { + // Update data + cx.emit(DataEvent::Updated); + cx.notify(); + } +} + +struct DataConsumer { + source: Entity<DataSource>, +} + +impl DataConsumer { + fn new(source: Entity<DataSource>, cx: &mut App) -> Entity<Self> { + cx.new(|cx| { + // Subscribe to events + cx.subscribe(&source, |this, source, event: &DataEvent, cx| { + match event { + DataEvent::Updated => { + // Handle update + cx.notify(); + } + DataEvent::Deleted => { + // Handle deletion + } + } + }).detach(); + + Self { source } + }) + } +} +``` + +### 8. Resource Management + +Manage external resources with proper cleanup. + +```rust +struct FileHandle { + path: PathBuf, + file: Option<File>, +} + +impl FileHandle { + fn open(&mut self, cx: &mut Context<Self>) -> Result<()> { + self.file = Some(File::open(&self.path)?); + cx.notify(); + Ok(()) + } + + fn close(&mut self, cx: &mut Context<Self>) { + self.file = None; + cx.notify(); + } +} + +impl Drop for FileHandle { + fn drop(&mut self) { + // Cleanup when entity is dropped + if let Some(file) = self.file.take() { + drop(file); + } + } +} +``` + +## Pattern Selection Guide + +| Need | Pattern | Complexity | +|------|---------|------------| +| Component with internal state | Stateful Components | Low | +| State shared by multiple components | Shared State | Low | +| Coordinate events between components | Event Coordination | Medium | +| Handle async data fetching | Async State Management | Medium | +| Parent-child component hierarchy | Parent-Child Relationships | Medium | +| React to state changes | Observer Pattern | Medium | +| Handle custom events | Event Subscription | Medium-High | +| Manage external resources | Resource Management | High | + +Choose the simplest pattern that meets your requirements. Combine patterns as needed for complex scenarios. diff --git a/.agents/skills/gpui-kit/references/gpui/entity.md b/.agents/skills/gpui-kit/references/gpui/entity.md new file mode 100644 index 0000000..1741b92 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/entity.md @@ -0,0 +1,218 @@ +# Entity State Management + +**Contents:** [Overview](#overview) · [Quick Start](#quick-start) · [Core Principles](#core-principles) · [Common Use Cases](#common-use-cases) · [Extended References](#reference-documentation) + +## Overview + +An `Entity<T>` is a handle to state of type `T`, providing safe access and updates. + +**Key Methods:** +- `entity.read(cx)` → `&T` - Read-only access +- `entity.read_with(cx, |state, cx| ...)` → `R` - Read with closure +- `entity.update(cx, |state, cx| ...)` → `R` - Mutable update +- `entity.downgrade()` → `WeakEntity<T>` - Create weak reference +- `entity.entity_id()` → `EntityId` - Unique identifier + +**Entity Types:** +- **`Entity<T>`**: Strong reference (increases ref count) +- **`WeakEntity<T>`**: Weak reference (doesn't prevent cleanup, returns `Result`) + +## Quick Start + +### Creating and Using Entities + +```rust +// Create entity +let counter = cx.new(|cx| Counter { count: 0 }); + +// Read state +let count = counter.read(cx).count; + +// Update state +counter.update(cx, |state, cx| { + state.count += 1; + cx.notify(); // Trigger re-render +}); + +// Weak reference (for closures/callbacks) +let weak = counter.downgrade(); +let _ = weak.update(cx, |state, cx| { + state.count += 1; + cx.notify(); +}); +``` + +### In Components + +```rust +struct MyComponent { + shared_state: Entity<SharedData>, +} + +impl MyComponent { + fn new(cx: &mut App) -> Entity<Self> { + let shared = cx.new(|_| SharedData::default()); + + cx.new(|cx| Self { + shared_state: shared, + }) + } + + fn update_shared(&mut self, cx: &mut Context<Self>) { + self.shared_state.update(cx, |state, cx| { + state.value = 42; + cx.notify(); + }); + } +} +``` + +### Async Operations + +When calling `cx.spawn` from `Context<Self>`, the closure receives `(WeakEntity<Self>, &mut AsyncApp)`: + +```rust +impl MyComponent { + fn fetch_data(&mut self, cx: &mut Context<Self>) { + cx.spawn(async move |this, cx: &mut AsyncApp| { + let data = fetch_from_api().await; + + // Update entity safely via the weak reference + let _ = this.update(cx, |state, cx| { + state.data = Some(data); + cx.notify(); + }); + }).detach(); + } +} +``` + +## Core Principles + +### Always Use Weak References in Closures + +```rust +// ✅ Good: Weak reference prevents retain cycles +let weak = cx.entity().downgrade(); +callback(move || { + let _ = weak.update(cx, |state, cx| cx.notify()); +}); + +// ❌ Bad: Strong reference may cause memory leak +let strong = cx.entity(); +callback(move || { + strong.update(cx, |state, cx| cx.notify()); +}); +``` + +### Use Inner Context + +```rust +// ✅ Good: Use inner cx from closure +entity.update(cx, |state, inner_cx| { + inner_cx.notify(); // Correct +}); + +// ❌ Bad: Use outer cx (multiple borrow error) +entity.update(cx, |state, inner_cx| { + cx.notify(); // Wrong! +}); +``` + +### Avoid Nested Entity Updates + +Nested `entity.update(cx, …)` calls are dangerous. The default posture is: **do not nest them**. The sub-cases below clarify when a panic is guaranteed vs. merely possible. + +**Same entity → always panics.** +GPUI locks an entity for the entire duration of its update or render pass. Re-entering that same lock panics immediately: + +``` +cannot update … while it is already being updated +``` + +```rust +// ❌ Panic: updating entity_a from inside entity_a's own update +entity_a.update(cx, |state, cx| { + entity_a.update(cx, |_, _| {}); // PANIC — same lock +}); +``` + +**Different entity → generally safe, but indirect cycles still panic.** +Each entity has its own lock, so updating `entity_b` from within `entity_a`'s update normally succeeds. However, if `entity_b`'s callback reaches back into `entity_a` — directly or through a chain — GPUI will attempt to re-acquire `entity_a`'s lock and panic. + +```rust +// ✅ Usually fine: different entities, no cycle +entity_a.update(cx, |_, cx| { + entity_b.update(cx, |_, _| {}); // OK — different lock +}); + +// ❌ Panic: indirect cycle back to entity_a +entity_a.update(cx, |_, cx| { + entity_b.update(cx, |_, cx| { + entity_a.update(cx, |_, _| {}); // PANIC — entity_a is still locked + }); +}); +``` + +When in doubt, flatten the call sequence rather than nesting: finish the outer update, then update the second entity from outside. + +**`defer_in` does not bypass the lock.** `cx.defer_in(window, callback)` schedules `callback` to run on the current entity — meaning GPUI re-acquires the entity's lock to execute it. The re-entrancy rules apply equally inside the deferred callback: + +```rust +// ❌ Panic: defer_in re-locks entity_a; calling entity_a.update inside re-enters +impl SomeDelegate for MyAdapter { + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) { + cx.defer_in(window, |list_state, window, cx| { + // list_state is locked for this callback! + parent.update(cx, |this, cx| { + this.list.update(cx, |_, _| {}); // PANIC — list is already locked above + }); + }); + } +} + +// ✅ Fix: use the direct &mut reference the callback provides +impl SomeDelegate for MyAdapter { + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) { + cx.defer_in(window, |list_state, window, cx| { + // Access list data directly — no entity lock needed + list_state.delegate_mut().some_hook(); + + // Update the *parent* entity — different lock, safe + parent.update(cx, |this, cx| { /* … */ }); + + // Sync list state directly after parent update + list_state.delegate_mut().update_snapshot(new_val); + }); + } +} +``` + +**Snapshot pattern for render callbacks.** `render_item` (and any other rendering hook) runs inside the entity's render pass. It must never call `entity.read(cx)` or `entity.update(cx, …)` on any external entity. Instead, keep a plain `snapshot` field updated eagerly from *outside* render after every mutation: + +```rust +// ❌ Panic in render_item — ListState is already locked +fn render_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context<ListState<Self>>) -> … { + let checked = parent_entity.read(cx).selection.contains(&ix); // PANIC +} + +// ✅ Read from a plain snapshot field — no entity access +fn render_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context<ListState<Self>>) -> … { + let checked = self.selection_snapshot.iter().any(|(sel_ix, _)| sel_ix == &ix); +} +``` + +## Common Use Cases + +1. **Component State**: Internal state that needs reactivity +2. **Shared State**: State shared between multiple components +3. **Parent-Child**: Coordinating between related components (use weak refs) +4. **Async State**: Managing state that changes from async operations +5. **Observations**: Reacting to changes in other entities + +## Reference Documentation + +- **API**: See [entity-api.md](entity-api.md) — entity types, methods, lifecycle, error handling +- **Patterns**: See [entity-patterns.md](entity-patterns.md) — model-view, cross-entity communication, observer +- **Best Practices**: See [entity-best-practices.md](entity-best-practices.md) — pitfalls, memory, performance, async +- **Advanced**: See [entity-advanced.md](entity-advanced.md) — collections, registry, debounce, state machines diff --git a/.agents/skills/gpui-kit/references/gpui/event.md b/.agents/skills/gpui-kit/references/gpui/event.md new file mode 100644 index 0000000..f0fda03 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/event.md @@ -0,0 +1,238 @@ +# Events & Subscriptions + +**Contents:** [Overview](#overview) · [Quick Start](#quick-start) · [Common Patterns](#common-patterns) · [subscribe_in](#subscribe_in--subscription-with-window-access) · [observe_window_activation](#observe_window_activation) · [observe_global](#observe_global) · [Subscription Lifetime](#subscription-lifetime) · [Best Practices](#best-practices) + +## Overview + +GPUI provides event system for component coordination: + +**Event Mechanisms:** +- **Custom Events**: Define and emit type-safe events +- **Observations**: React to entity state changes +- **Subscriptions**: Listen to events from other entities +- **Global Events**: App-wide event handling + +## Quick Start + +### Define and Emit Events + +```rust +#[derive(Clone)] +enum MyEvent { + DataUpdated(String), + ActionTriggered, +} + +impl MyComponent { + fn update_data(&mut self, data: String, cx: &mut Context<Self>) { + self.data = data.clone(); + + // Emit event + cx.emit(MyEvent::DataUpdated(data)); + cx.notify(); + } +} +``` + +### Subscribe to Events + +```rust +impl Listener { + fn new(source: Entity<MyComponent>, cx: &mut App) -> Entity<Self> { + cx.new(|cx| { + // Subscribe to events + cx.subscribe(&source, |this, emitter, event: &MyEvent, cx| { + match event { + MyEvent::DataUpdated(data) => { + this.handle_update(data.clone(), cx); + } + MyEvent::ActionTriggered => { + this.handle_action(cx); + } + } + }).detach(); + + Self { source } + }) + } +} +``` + +### Observe Entity Changes + +```rust +impl Observer { + fn new(target: Entity<Target>, cx: &mut App) -> Entity<Self> { + cx.new(|cx| { + // Observe entity for any changes + cx.observe(&target, |this, observed, cx| { + // Called when observed.update() calls cx.notify() + println!("Target changed"); + cx.notify(); + }).detach(); + + Self { target } + }) + } +} +``` + +## Common Patterns + +### 1. Parent-Child Communication + +```rust +// Parent emits events +impl Parent { + fn notify_children(&mut self, cx: &mut Context<Self>) { + cx.emit(ParentEvent::Updated); + cx.notify(); + } +} + +// Children subscribe +impl Child { + fn new(parent: Entity<Parent>, cx: &mut App) -> Entity<Self> { + cx.new(|cx| { + cx.subscribe(&parent, |this, parent, event, cx| { + this.handle_parent_event(event, cx); + }).detach(); + + Self { parent } + }) + } +} +``` + +### 2. Global Event Broadcasting + +```rust +struct EventBus { + listeners: Vec<WeakEntity<dyn Listener>>, +} + +impl EventBus { + fn broadcast(&mut self, event: GlobalEvent, cx: &mut Context<Self>) { + self.listeners.retain(|weak| { + weak.update(cx, |listener, cx| { + listener.on_event(&event, cx); + }).is_ok() + }); + } +} +``` + +### 3. Observer Pattern + +```rust +cx.observe(&entity, |this, observed, cx| { + // React to any state change + let state = observed.read(cx); + this.sync_with_state(state, cx); +}).detach(); +``` + +## subscribe_in — Subscription with Window Access + +Use when the subscription callback needs `&mut Window`: + +```rust +// Store subscriptions to keep them alive +struct MyComponent { + _subscriptions: Vec<Subscription>, +} + +impl MyComponent { + fn new(input: &Entity<InputState>, window: &mut Window, cx: &mut Context<Self>) -> Self { + let _subscriptions = vec![ + cx.subscribe_in(input, window, |this, state, event, window, cx| { + match event { + InputEvent::PressEnter { .. } => this.on_submit(window, cx), + InputEvent::Change => { + let val = state.read(cx).value(); + this.on_change(val, cx); + } + _ => {} + } + }), + ]; + Self { _subscriptions } + } +} +``` + +`subscribe` vs `subscribe_in`: +- `cx.subscribe(&entity, |this, source, event, cx|)` — no window +- `cx.subscribe_in(&entity, window, |this, source, event, window, cx|)` — window access + +## observe_window_activation + +```rust +let _sub = cx.observe_window_activation(window, |this, window, cx| { + if window.is_window_active() { + this.start_polling(cx); + } else { + this.stop_polling(cx); + } +}); +``` + +## observe_global + +```rust +cx.observe_global::<Theme>(|cx| { + cx.notify(); // Re-render when theme changes +}); +``` + +## Subscription Lifetime + +Subscriptions are cancelled when dropped. Two ways to keep alive: + +```rust +// 1. .detach() — lives until entity is dropped +cx.subscribe(&entity, |this, _, event, cx| { + // ... +}).detach(); + +// 2. Store in struct — cancelled when struct drops +struct MyView { + _subscriptions: Vec<Subscription>, +} +// _subscriptions.push(cx.subscribe(...)); +``` + +Use `.detach()` for permanent subscriptions; store in struct for subscriptions that should stop when the component unmounts. + +## Best Practices + +### ✅ Detach Subscriptions + +```rust +// ✅ Detach to keep alive +cx.subscribe(&entity, |this, source, event, cx| { + // Handle event +}).detach(); +``` + +### ✅ Clean Event Types + +```rust +#[derive(Clone)] +enum AppEvent { + DataChanged { id: usize, value: String }, + ActionPerformed(ActionType), + Error(String), +} +``` + +### ❌ Avoid Event Loops + +```rust +// ❌ Don't create mutual subscriptions +entity1.subscribe(entity2) → emits event +entity2.subscribe(entity1) → emits event → infinite loop! +``` + +## Reference Documentation + diff --git a/.agents/skills/gpui-kit/references/gpui/focus-handle.md b/.agents/skills/gpui-kit/references/gpui/focus-handle.md new file mode 100644 index 0000000..da74929 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/focus-handle.md @@ -0,0 +1,242 @@ +# Focus & Keyboard Navigation + +**Contents:** [Overview](#overview) · [Quick Start](#quick-start) · [Focus Events](#focus-events) · [Keyboard Navigation](#keyboard-navigation) · [Common Patterns](#common-patterns) · [Best Practices](#best-practices) + +## Overview + +GPUI's focus system enables keyboard navigation and focus management. + +**Key Concepts:** +- **FocusHandle**: Reference to focusable element +- **Focus tracking**: Current focused element +- **Keyboard navigation**: Tab/Shift-Tab between elements +- **Focus events**: on_focus, on_blur + +## Quick Start + +### Creating Focus Handles + +```rust +struct FocusableComponent { + focus_handle: FocusHandle, +} + +impl FocusableComponent { + fn new(cx: &mut Context<Self>) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } +} +``` + +### Making Elements Focusable + +```rust +impl Render for FocusableComponent { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_enter)) + .child("Focusable content") + } + + fn on_enter(&mut self, _: &Enter, cx: &mut Context<Self>) { + // Handle Enter key when focused + cx.notify(); + } +} +``` + +### Focus Management + +```rust +impl MyComponent { + fn focus(&mut self, cx: &mut Context<Self>) { + self.focus_handle.focus(cx); + } + + fn is_focused(&self, cx: &App) -> bool { + self.focus_handle.is_focused(cx) + } + + fn blur(&mut self, cx: &mut Context<Self>) { + cx.blur(); + } +} +``` + +## Focus Events + +### Handling Focus Changes + +```rust +impl Render for MyInput { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + let is_focused = self.focus_handle.is_focused(cx); + + div() + .track_focus(&self.focus_handle) + .on_focus(cx.listener(|this, _event, cx| { + this.on_focus(cx); + })) + .on_blur(cx.listener(|this, _event, cx| { + this.on_blur(cx); + })) + .when(is_focused, |el| { + el.bg(cx.theme().focused_background) + }) + .child(self.render_content()) + } +} + +impl MyInput { + fn on_focus(&mut self, cx: &mut Context<Self>) { + // Handle focus gained + cx.notify(); + } + + fn on_blur(&mut self, cx: &mut Context<Self>) { + // Handle focus lost + cx.notify(); + } +} +``` + +## Keyboard Navigation + +### Tab Order + +A tracked handle is a Tab stop only when the handle says so. `cx.focus_handle()` +starts with `tab_stop = false`, and an element's own `.tab_index()` / +`.tab_stop()` do not apply to a handle passed to `track_focus`: build the handle +with the flag. + +```rust +// In the owning entity's constructor: +let focus1 = cx.focus_handle().tab_stop(true); // Tab order: 1 +let focus2 = cx.focus_handle().tab_stop(true); // Tab order: 2 +let hidden = cx.focus_handle(); // focusable, never a Tab stop + +div() + .child(div().track_focus(&focus1).child("first")) + .child(div().track_focus(&focus2).child("second")) + .child(div().track_focus(&hidden).child("container")) +``` + +The Tab table is rebuilt every frame from the elements that painted with a +tracked handle, in tree order (`tab_index` reorders within a `tab_group`). A +stateless component may create its handle in `render` through keyed state — +`window.use_keyed_state(id, cx, |_, cx| cx.focus_handle().tab_stop(true))` — +which survives re-renders, so the order stays stable; `Button` does exactly +this. A focused element receives `ClickEvent::Keyboard` in `on_click` when +Enter or Space is released, so a `track_focus` + `on_click` row activates from +the keyboard without extra bindings. + +### Focus Within Containers + +```rust +impl Container { + fn focus_first(&mut self, cx: &mut Context<Self>) { + if let Some(first) = self.children.first() { + first.update(cx, |child, cx| { + child.focus_handle.focus(cx); + }); + } + } + + fn focus_next(&mut self, cx: &mut Context<Self>) { + // Custom focus navigation logic + } +} +``` + +## Common Patterns + +### 1. Auto-focus on Mount + +```rust +impl MyDialog { + fn new(cx: &mut Context<Self>) -> Self { + let focus_handle = cx.focus_handle(); + + // Focus when created + focus_handle.focus(cx); + + Self { focus_handle } + } +} +``` + +### 2. Focus Trap (Modal) + +```rust +impl Modal { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, cx| { + if event.key == Key::Tab { + // Keep focus within modal + this.focus_next_in_modal(cx); + cx.stop_propagation(); + } + })) + .child(self.render_content()) + } +} +``` + +### 3. Conditional Focus + +```rust +impl Searchable { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .when(self.search_active, |el| { + el.on_mount(cx.listener(|this, _, cx| { + this.focus_handle.focus(cx); + })) + }) + .child(self.search_input()) + } +} +``` + +## Best Practices + +### ✅ Track Focus on Interactive Elements + +```rust +// ✅ Good: Track focus for keyboard interaction +input() + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_enter)) +``` + +### ✅ Provide Visual Focus Indicators + +Draw the framework's ring so custom controls match `Button` and `Input`: + +```rust +use gpui_kit::component::ThemeStyled as _; + +let is_focused = self.focus_handle.is_focused(window); + +div() + .track_focus(&self.focus_handle) + .when(is_focused, |el| el.focus_ring_style(window, cx)) +``` + +The ring is painted 3px outside the element, so an ancestor with +`overflow_hidden()` clips it; leave it room or draw the ring inside the element. + +### ❌ Don't: Forget to Track Focus + +```rust +// ❌ Bad: No track_focus, keyboard navigation won't work +div() + .on_action(cx.listener(Self::on_enter)) +``` + diff --git a/.agents/skills/gpui-kit/references/gpui/global.md b/.agents/skills/gpui-kit/references/gpui/global.md new file mode 100644 index 0000000..33a3e87 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/global.md @@ -0,0 +1,196 @@ +# Global State + +**Contents:** [Overview](#overview) · [Quick Start](#quick-start) · [Common Use Cases](#common-use-cases) · [Best Practices](#best-practices) · [When to Use](#when-to-use) + +## Overview + +Global state in GPUI provides app-wide shared data accessible from any context. + +**Key Trait**: `Global` - Implement on types to make them globally accessible + +## Quick Start + +### Define Global State + +```rust +use gpui_kit::Global; + +#[derive(Clone)] +struct AppSettings { + theme: Theme, + language: String, +} + +impl Global for AppSettings {} +``` + +### Set and Access Globals + +```rust +fn main() { + gpui_kit::application().run(|cx: &mut App| { + // Set global + cx.set_global(AppSettings { + theme: Theme::Dark, + language: "en".to_string(), + }); + + // Access global (read-only) + let settings = cx.global::<AppSettings>(); + println!("Theme: {:?}", settings.theme); + }); +} +``` + +### Update Globals + +```rust +impl MyComponent { + fn change_theme(&mut self, new_theme: Theme, cx: &mut Context<Self>) { + cx.update_global::<AppSettings, _>(|settings, cx| { + settings.theme = new_theme; + // Global updates don't trigger automatic notifications + // Manually notify components that care + }); + + cx.notify(); // Re-render this component + } +} +``` + +## Common Use Cases + +### 1. App Configuration + +```rust +#[derive(Clone)] +struct AppConfig { + api_endpoint: String, + max_retries: u32, + timeout: Duration, +} + +impl Global for AppConfig {} + +// Set once at startup +cx.set_global(AppConfig { + api_endpoint: "https://api.example.com".to_string(), + max_retries: 3, + timeout: Duration::from_secs(30), +}); + +// Access anywhere +let config = cx.global::<AppConfig>(); +``` + +### 2. Feature Flags + +```rust +#[derive(Clone)] +struct FeatureFlags { + enable_beta_features: bool, + enable_analytics: bool, +} + +impl Global for FeatureFlags {} + +impl MyComponent { + fn render_beta_feature(&self, cx: &App) -> Option<impl IntoElement> { + let flags = cx.global::<FeatureFlags>(); + + if flags.enable_beta_features { + Some(div().child("Beta feature")) + } else { + None + } + } +} +``` + +### 3. Shared Services + +```rust +#[derive(Clone)] +struct ServiceRegistry { + http_client: Arc<HttpClient>, + logger: Arc<Logger>, +} + +impl Global for ServiceRegistry {} + +impl MyComponent { + fn fetch_data(&mut self, cx: &mut Context<Self>) { + let registry = cx.global::<ServiceRegistry>(); + let client = registry.http_client.clone(); + + cx.spawn(async move |cx| { + let data = client.get("api/data").await?; + // Process data... + Ok::<_, anyhow::Error>(()) + }).detach(); + } +} +``` + +## Best Practices + +### ✅ Use Arc for Shared Resources + +```rust +#[derive(Clone)] +struct GlobalState { + database: Arc<Database>, // Cheap to clone + cache: Arc<RwLock<Cache>>, +} + +impl Global for GlobalState {} +``` + +### ✅ Immutable by Default + +Globals are read-only by default. Use interior mutability when needed: + +```rust +#[derive(Clone)] +struct Counter { + count: Arc<AtomicUsize>, +} + +impl Global for Counter {} + +impl Counter { + fn increment(&self) { + self.count.fetch_add(1, Ordering::SeqCst); + } + + fn get(&self) -> usize { + self.count.load(Ordering::SeqCst) + } +} +``` + +### ❌ Don't: Overuse Globals + +```rust +// ❌ Bad: Too many globals +cx.set_global(UserState { ... }); +cx.set_global(CartState { ... }); +cx.set_global(CheckoutState { ... }); + +// ✅ Good: Use entities for component state +let user_entity = cx.new(|_| UserState { ... }); +``` + +## When to Use + +**Use Globals for:** +- App-wide configuration +- Feature flags +- Shared services (HTTP client, logger) +- Read-only reference data + +**Use Entities for:** +- Component-specific state +- State that changes frequently +- State that needs notifications + diff --git a/.agents/skills/gpui-kit/references/gpui/layout-style.md b/.agents/skills/gpui-kit/references/gpui/layout-style.md new file mode 100644 index 0000000..5981654 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/layout-style.md @@ -0,0 +1,280 @@ +# Layout & Styling + +**Contents:** [Overview](#overview) · [Quick Start](#quick-start) · [Common Patterns](#common-patterns) · [Styling Methods](#styling-methods) · [h_flex / v_flex](#h_flex--v_flex-helpers) · [Tailwind Shorthands](#tailwind-style-shorthand) · [Overflow & Scroll](#overflow-and-scroll) · [Absolute Positioning](#absolute-positioning) · [Stacking Order](#stacking-order) · [Theme Integration](#theme-integration) · [Conditional Styling](#conditional-styling) · [Text Styling](#text-styling) + +## Overview + +GPUI provides CSS-like styling with Rust type safety. + +**Key Concepts:** + +- Flexbox layout system +- Styled trait for chaining styles +- Size units: `px()`, `rems()`, `relative()` +- Colors, borders, shadows + +## Quick Start + +### Basic Styling + +```rust +use gpui_kit::*; + +div() + .w(px(200.)) + .h(px(100.)) + .bg(rgb(0x2196F3)) + .text_color(rgb(0xFFFFFF)) + .rounded(px(8.)) + .p(px(16.)) + .child("Styled content") +``` + +### Flexbox Layout + +```rust +div() + .flex() + .flex_row() // or flex_col() for column + .gap(px(8.)) + .items_center() + .justify_between() + .children([ + div().child("Item 1"), + div().child("Item 2"), + div().child("Item 3"), + ]) +``` + +### Size Units + +```rust +div() + .w(px(200.)) // Pixels + .h(rems(10.)) // Relative to font size + .w(relative(0.5)) // 50% of parent + .min_w(px(100.)) + .max_w(px(400.)) +``` + +## Common Patterns + +### Centered Content + +```rust +div() + .flex() + .items_center() + .justify_center() + .size_full() + .child("Centered") +``` + +### Card Layout + +```rust +div() + .w(px(300.)) + .bg(cx.theme().surface) + .rounded(px(8.)) + .shadow_md() + .p(px(16.)) + .gap(px(12.)) + .flex() + .flex_col() + .child(heading()) + .child(content()) +``` + +### Responsive Spacing + +```rust +div() + .p(px(16.)) // Padding all sides + .px(px(20.)) // Padding horizontal + .py(px(12.)) // Padding vertical + .pt(px(8.)) // Padding top + .gap(px(8.)) // Gap between children +``` + +## Styling Methods + +### Dimensions + +```rust +.w(px(200.)) // Width +.h(px(100.)) // Height +.size(px(200.)) // Width and height +.min_w(px(100.)) // Min width +.max_w(px(400.)) // Max width +``` + +### Colors + +```rust +.bg(rgb(0x2196F3)) // Background +.text_color(rgb(0xFFFFFF)) // Text color +.border_color(rgb(0x000000)) // Border color +``` + +### Borders + +```rust +.border(px(1.)) // Border width +.rounded(px(8.)) // Border radius +.rounded_t(px(8.)) // Top corners +.border_color(rgb(0x000000)) +``` + +### Spacing + +```rust +.p(px(16.)) // Padding +.m(px(8.)) // Margin +.gap(px(8.)) // Gap between flex children +``` + +### Flexbox + +```rust +.flex() // Enable flexbox +.flex_row() // Row direction +.flex_col() // Column direction +.items_center() // Align items center +.justify_between() // Space between items +.flex_grow_1() // Grow to fill space +``` + +## h_flex / v_flex Helpers + +`gpui_kit::component` provides shorthand helpers (import from `gpui_kit::component`): + +```rust +use gpui_kit::component::{h_flex, v_flex}; + +// h_flex() = div().flex().flex_row().items_center() +h_flex() + .gap_2() + .child(icon) + .child(label) + +// v_flex() = div().flex().flex_col() +v_flex() + .gap_4() + .p_4() + .child(input1) + .child(input2) + .child(submit_btn) +``` + +These are the standard layout primitives in `gpui_kit::component`. Prefer them over raw `div().flex()`. + +## Tailwind-style Shorthand + +GPUI provides Tailwind-style spacing/sizing shorthands: + +```rust +// Spacing (0=0, 1=4px, 2=8px, 3=12px, 4=16px, ...) +.p_2() // padding: 8px +.px_4() // padding x: 16px +.py_3() // padding y: 12px +.m_2() // margin: 8px +.gap_3() // gap: 12px + +// Size +.size_full() // width: 100%, height: 100% +.size_4() // width: 16px, height: 16px +.w_full() // width: 100% +.h_full() // height: 100% +.flex_1() // flex: 1 1 0 (fill remaining space) +.flex_shrink_0() // prevent shrinking +``` + +## Overflow and Scroll + +```rust +div() + .overflow_hidden() // clip content + .overflow_x_hidden() // clip horizontal + .overflow_y_scrollbar() // show scrollbar on y axis + .overflow_scroll() // scroll both axes +``` + +## Absolute Positioning + +```rust +div() + .relative() // position: relative (container) + .child( + div() + .absolute() // position: absolute + .top_0() + .right_0() + .child("badge") + ) + +// Inset helpers +div().absolute().inset_0() // top/right/bottom/left: 0 (fill parent) +div().absolute().top(px(8.)).left(px(8.)) +``` + +## Stacking Order + +```rust +div() + .relative() + .child(content) + .child( + div() + .absolute() + .top_0() + .right_0() + .child("badge") + ) // later children are typically painted above earlier siblings +``` + +GPUI's general `Styled` API does **not** provide a `z_index(...)` method. + +For normal elements, stacking is usually controlled by: + +- Parent/child composition +- Absolute positioning +- Render order of siblings (later siblings paint above earlier ones) + +If you see a `z_index(...)` method in this repository, make sure it belongs to the specific component you are using: a component's own ordering API is not a general GPUI `Div` styling method. + +## Theme Integration + +```rust +div() + .bg(cx.theme().surface) + .text_color(cx.theme().foreground) + .border_color(cx.theme().border) + .when(is_hovered, |el| { + el.bg(cx.theme().hover) + }) +``` + +## Conditional Styling + +```rust +use gpui_kit::prelude::FluentBuilder as _; + +div() + .when(is_active, |el| el.bg(cx.theme().primary)) + .when(!is_active, |el| el.opacity(0.5)) + .when_some(optional_color.as_ref(), |el, color| el.bg(*color)) +``` + +## Text Styling + +```rust +div() + .text_sm() // font-size: small + .text_base() // font-size: base + .text_lg() // font-size: large + .font_bold() // font-weight: bold + .line_height_snug() // tighter line height + .truncate() // overflow: ellipsis, single line + .whitespace_nowrap() +``` diff --git a/.agents/skills/gpui-kit/references/gpui/test-examples.md b/.agents/skills/gpui-kit/references/gpui/test-examples.md new file mode 100644 index 0000000..8bdfabc --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/test-examples.md @@ -0,0 +1,176 @@ +# Test Examples + +**Contents:** [Testing Best Practices](#testing-best-practices) · [Test Organization](#test-organization) · [Setup and Teardown](#setup-and-teardown) · [Assertions](#assertions) · [Performance Testing](#performance-testing) · [Running Tests](#running-tests) · [Basic Test Execution](#basic-test-execution) · [Test Configuration](#test-configuration) · [Advanced Test Execution](#advanced-test-execution) · [CI/CD Integration](#cicd-integration) + +## Testing Best Practices + +### Test Organization + +Group related tests in modules: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + mod entity_tests { + use super::*; + + #[gpui_kit::test] + fn test_creation() { /* ... */ } + + #[gpui_kit::test] + fn test_updates() { /* ... */ } + } + + mod async_tests { + use super::*; + + #[gpui_kit::test] + async fn test_async_ops() { /* ... */ } + } + + mod distributed_tests { + use super::*; + + #[gpui_kit::test] + fn test_multi_app() { /* ... */ } + } +} +``` + +### Setup and Teardown + +Use helper functions for common setup: + +```rust +fn create_test_counter(cx: &mut TestAppContext) -> Entity<Counter> { + cx.new(|cx| Counter::new(cx)) +} + +#[gpui_kit::test] +fn test_counter_operations(cx: &mut TestAppContext) { + let counter = create_test_counter(cx); + + // Test operations +} +``` + +### Assertions + +Use descriptive assertions: + +```rust +#[gpui_kit::test] +fn test_counter_bounds(cx: &mut TestAppContext) { + let counter = create_test_counter(cx); + + // Test upper bound + for _ in 0..100 { + counter.update(cx, |counter, cx| { + counter.increment(cx); + }); + } + + let count = counter.read_with(cx, |counter, _| counter.count); + assert!(count <= 100, "Counter should not exceed maximum"); + + // Test lower bound + for _ in 0..200 { + counter.update(cx, |counter, cx| { + counter.decrement(cx); + }); + } + + let count = counter.read_with(cx, |counter, _| counter.count); + assert!(count >= 0, "Counter should not go below minimum"); +} +``` + +### Performance Testing + +Test performance characteristics: + +```rust +#[gpui_kit::test] +fn test_operation_performance(cx: &mut TestAppContext) { + let component = cx.new(|cx| MyComponent::new(cx)); + + let start = std::time::Instant::now(); + + // Perform many operations + for i in 0..1000 { + component.update(cx, |comp, cx| { + comp.perform_operation(i, cx); + }); + } + + let elapsed = start.elapsed(); + assert!(elapsed < Duration::from_millis(100), "Operations should complete quickly"); +} +``` + +## Running Tests + +### Basic Test Execution + +```bash +# Run all tests +cargo test + +# Run specific test +cargo test test_counter_operations + +# Run tests in a specific module +cargo test entity_tests:: + +# Run with output +cargo test -- --nocapture +``` + +### Test Configuration + +Enable test-support feature for GPUI tests: + +```toml +[features] +test-support = ["gpui/test-support"] +``` + +```bash +cargo test --features test-support +``` + +### Advanced Test Execution + +```bash +# Run tests with iterations for property testing +cargo test -- --test-threads=1 + +# Run tests matching a pattern +cargo test test_async + +# Run tests with backtrace on failure +RUST_BACKTRACE=1 cargo test +``` + +### CI/CD Integration + +For continuous integration: + +```yaml +# .github/workflows/test.yml +name: Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - name: Run tests + run: cargo test --features test-support +``` + +GPUI's testing framework provides deterministic, fast, and comprehensive testing capabilities that mirror real application behavior while providing the control needed for thorough testing of complex UI and async scenarios. \ No newline at end of file diff --git a/.agents/skills/gpui-kit/references/gpui/test-reference.md b/.agents/skills/gpui-kit/references/gpui/test-reference.md new file mode 100644 index 0000000..b9de2e7 --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/test-reference.md @@ -0,0 +1,591 @@ +# Test Reference + +**Contents:** [Testing Patterns](#testing-patterns) · [Testing Crash-Free State Management (Re-entrancy)](#testing-crash-free-state-management-re-entrancy) · [Property Testing](#property-testing) · [Distributed Systems Testing](#distributed-systems-testing) · [Mocking and Isolation](#mocking-and-isolation) + +## Testing Patterns + +### Basic Entity Testing + +Test entity creation, updates, and reads: + +```rust +#[gpui_kit::test] +fn test_counter_entity(cx: &mut TestAppContext) { + let counter = cx.new(|cx| Counter::new(cx)); + + // Test initial state + let initial_count = counter.read_with(cx, |counter, _| counter.count); + assert_eq!(initial_count, 0); + + // Test updates + counter.update(cx, |counter, cx| { + counter.count = 42; + cx.notify(); + }); + + let updated_count = counter.read_with(cx, |counter, _| counter.count); + assert_eq!(updated_count, 42); +} +``` + +### Event Testing + +Test event emission and handling: + +```rust +#[derive(Clone)] +struct ValueChanged { + new_value: i32, +} + +impl EventEmitter<ValueChanged> for MyComponent {} + +#[gpui_kit::test] +fn test_event_emission(cx: &mut TestAppContext) { + let component = cx.new(|cx| { + let mut comp = MyComponent::default(); + + // Subscribe to self + cx.subscribe_self(|this, event: &ValueChanged, cx| { + this.received_value = event.new_value; + cx.notify(); + }); + + comp + }); + + // Emit event + component.update(cx, |_, cx| { + cx.emit(ValueChanged { new_value: 123 }); + }); + + // Verify event was handled + let received = component.read_with(cx, |comp, _| comp.received_value); + assert_eq!(received, 123); +} +``` + +### Action Testing + +Test action dispatching and handling: + +```rust +actions!(my_app, [Increment, Decrement]); + +#[gpui_kit::test] +fn test_action_dispatch(cx: &mut TestAppContext) { + let window = cx.update(|cx| { + cx.open_window(Default::default(), |_, cx| { + cx.new(|cx| MyComponent::new(cx)) + }).unwrap() + }); + + let mut cx = VisualTestContext::from_window(window.into(), cx); + let counter = window.root(&mut cx).unwrap(); + + // Dispatch action via focus handle + let focus_handle = counter.read_with(&cx, |counter, _| counter.focus_handle.clone()); + cx.update(|window, cx| { + focus_handle.dispatch_action(&Increment, window, cx); + }); + + let count = counter.read_with(&cx, |counter, _| counter.count); + assert_eq!(count, 1); +} +``` + +### Async Testing + +Test async operations and background tasks: + +```rust +impl MyComponent { + fn load_data(&self, cx: &mut Context<Self>) -> Task<i32> { + cx.spawn(async move |this, cx| { + // Simulate async work + this.update(cx, |comp, _| comp.loading = true).await; + // Return result + 42 + }) + } + + fn background_update(&self, cx: &mut Context<Self>) { + cx.spawn(async move |this, cx| { + // Background work + this.update(cx, |comp, _| { + comp.value += 10; + }).await; + }).detach(); + } +} + +#[gpui_kit::test] +async fn test_async_operations(cx: &mut TestAppContext) { + let component = cx.new(|cx| MyComponent::new(cx)); + + // Test awaited task + let result = component.update(cx, |comp, cx| comp.load_data(cx)).await; + assert_eq!(result, 42); + + // Test detached task + component.update(cx, |comp, cx| comp.background_update(cx)); + + // Detached tasks don't run until you yield + let value_before = component.read_with(cx, |comp, _| comp.value); + assert_eq!(value_before, 0); + + // Run pending tasks + cx.run_until_parked(); + + let value_after = component.read_with(cx, |comp, _| comp.value); + assert_eq!(value_after, 10); +} +``` + +### Timer Testing + +Test timer-based operations: + +```rust +impl MyComponent { + fn delayed_action(&self, cx: &mut Context<Self>) { + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + + this.update(cx, |comp, cx| { + comp.action_performed = true; + cx.notify(); + }).await; + }).detach(); + } +} + +#[gpui_kit::test] +async fn test_timers(cx: &mut TestAppContext) { + let component = cx.new(|cx| MyComponent::new(cx)); + + component.update(cx, |comp, cx| comp.delayed_action(cx)); + + // Action shouldn't have completed yet + let performed = component.read_with(cx, |comp, _| comp.action_performed); + assert!(!performed); + + // Run until parked (timers complete) + cx.run_until_parked(); + + let performed = component.read_with(cx, |comp, _| comp.action_performed); + assert!(performed); +} +``` + +### External I/O Testing + +For tests involving external systems, use `allow_parking()`: + +```rust +#[gpui_kit::test] +async fn test_external_io(cx: &mut TestAppContext) { + // Allow parking for external I/O + cx.executor().allow_parking(); + + // Simulate external operation + let (tx, rx) = futures::channel::oneshot::channel(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(10)); + tx.send(42).ok(); + }); + + let result = rx.await.unwrap(); + assert_eq!(result, 42); +} +``` + +## Testing Crash-Free State Management (Re-entrancy) + +The most dangerous class of GPUI bugs is **entity re-entrancy**: code that tries to update or read an entity while it is already locked in a render/update pass. These bugs are invisible at compile time — they only panic at runtime, typically when the user clicks something in a list or dropdown. + +**Key properties of re-entrancy panics:** +- Triggered by user interaction (click, key press), not during static rendering. +- `#[should_panic]` only confirms the bug exists — tests must pass *without* panicking. +- `Select` commits through `defer_in` after the key or click dispatch returns. Wait for the result with `cx.wait_for` instead of asserting straight after the interaction. + +Drive the component the way a user does. `SelectState` keeps its list state `pub(crate)`, so a test outside `gpui-component` opens the menu, presses keys, and observes through `selected_value()`, the element snapshot, and the delegate's own hooks. + +### Pattern: Confirm through the real UI + +```rust +use gpui_kit::test::{TestAppContextExt, TestWindowExt}; +use gpui_kit::{ + AnyWindowHandle, AppContext, Context, Entity, TestAppContext, Window, + component::{ + IndexPath, Root, + searchable_list::SearchableListChange, + select::{SearchableVec, Select, SelectDelegate, SelectItem, SelectState}, + }, + div, + prelude::*, + px, size, +}; +use std::{cell::RefCell, rc::Rc, time::Duration}; + +struct Form { + language: Entity<SelectState<SearchableVec<&'static str>>>, +} + +impl Render for Form { + fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { + div() + .size_full() + .child(Select::new(&self.language).id("language").w(px(240.))) + } +} + +#[gpui_kit::test] +async fn confirming_from_the_keyboard_commits_the_value(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + let mut language = None; + let handle = cx.open_window(size(px(640.), px(480.)), |window, cx| { + let state = cx.new(|cx| { + SelectState::new( + SearchableVec::new(vec!["Rust", "Go"]), + Some(IndexPath::new(0)), + window, + cx, + ) + }); + language = Some(state.clone()); + let view = cx.new(|_| Form { language: state }); + Root::new(view, window, cx) + }); + let language = language.unwrap(); + + // Open the menu, move to "Go" and confirm — the same path a user takes. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.within("language").click("input", cx); + window.press("down", cx); + window.press("enter", cx); + }) + .unwrap(); + + // Select commits through `defer_in` after the key dispatch returns, so + // wait for the closed menu instead of asserting straight away. + cx.wait_for(handle.into(), Duration::from_millis(500), |window, _| { + window.find("language").expanded() == Some(false) + && window.find("language").value() == Some("Go") + }) + .await; + + cx.update(|cx| assert_eq!(language.read(cx).selected_value(), Some(&"Go"))); +} +``` + +### Pattern: Record `on_will_change` and `on_confirm` + +A recording delegate shows which changes the selection strategy proposed and what was committed. `on_will_change` receives the proposed changes and the selection to edit; the delegate applies them itself (the trait's default applies all of them). It has no `cx`, because it runs while the list entity is borrowed, so put side effects that need `cx` in `on_confirm`. + +```rust +type Log = Rc<RefCell<Vec<String>>>; + +/// Records each hook call; `accept: false` vetoes every change. +struct Recording { + items: Vec<&'static str>, + accept: bool, + log: Log, +} + +impl SelectDelegate for Recording { + type Item = &'static str; + + fn items_count(&self, _: usize) -> usize { + self.items.len() + } + + fn item(&self, ix: IndexPath) -> Option<&Self::Item> { + self.items.get(ix.row) + } + + fn position<V>(&self, value: &V) -> Option<IndexPath> + where + Self::Item: SelectItem<Value = V>, + V: PartialEq, + { + self.items + .iter() + .position(|item| item.value() == value) + .map(IndexPath::new) + } + + fn on_will_change( + &mut self, + selection: &mut Vec<(IndexPath, Self::Item)>, + changes: &[SearchableListChange], + ) { + for change in changes { + match *change { + SearchableListChange::Select { index } => { + self.log.borrow_mut().push(format!("select {}", index.row)); + if self.accept + && let Some(item) = self.item(index) + { + selection.push((index, *item)); + } + } + SearchableListChange::Deselect { index } => { + self.log.borrow_mut().push(format!("deselect {}", index.row)); + if self.accept { + selection.retain(|(ix, _)| *ix != index); + } + } + } + } + } + + fn on_confirm(&mut self, final_selection: &[(IndexPath, Self::Item)]) { + let values: Vec<_> = final_selection.iter().map(|(_, item)| *item).collect(); + self.log.borrow_mut().push(format!("confirm {values:?}")); + } +} + +struct Picker { + language: Entity<SelectState<Recording>>, +} + +impl Render for Picker { + fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { + div() + .size_full() + .child(Select::new(&self.language).id("language").w(px(240.))) + } +} + +fn open_picker( + cx: &mut TestAppContext, + accept: bool, +) -> (AnyWindowHandle, Entity<SelectState<Recording>>, Log) { + cx.update(gpui_kit::init); + let log = Rc::new(RefCell::new(Vec::new())); + let mut language = None; + let handle = cx.open_window(size(px(640.), px(480.)), |window, cx| { + let delegate = Recording { + items: vec!["Rust", "Go"], + accept, + log: log.clone(), + }; + let state = cx.new(|cx| SelectState::new(delegate, Some(IndexPath::new(0)), window, cx)); + language = Some(state.clone()); + let view = cx.new(|_| Picker { language: state }); + Root::new(view, window, cx) + }); + (handle.into(), language.unwrap(), log) +} + +fn confirm_next(cx: &mut TestAppContext, handle: AnyWindowHandle) { + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + window.within("language").click("input", cx); + window.press("down", cx); + window.press("enter", cx); + }) + .unwrap(); +} + +#[gpui_kit::test] +async fn hooks_fire_in_order_with_the_final_selection(cx: &mut TestAppContext) { + let (handle, language, log) = open_picker(cx, true); + confirm_next(cx, handle); + cx.wait_for(handle, Duration::from_millis(500), |window, _| { + window.find("language").expanded() == Some(false) + }) + .await; + // Single selection proposes replacing the old item, then commits once. + assert_eq!(*log.borrow(), ["deselect 0", "select 1", r#"confirm ["Go"]"#]); + cx.update(|cx| assert_eq!(language.read(cx).selected_value(), Some(&"Go"))); +} +``` + +### Pattern: A vetoing delegate keeps the selection + +The same delegate with `accept: false` applies none of the proposed changes: + +```rust +#[gpui_kit::test] +async fn a_vetoing_delegate_keeps_the_selection(cx: &mut TestAppContext) { + let (handle, language, log) = open_picker(cx, false); + confirm_next(cx, handle); + cx.wait_for(handle, Duration::from_millis(500), |window, _| { + window.find("language").expanded() == Some(false) + }) + .await; + // The same changes are proposed, but the delegate applied none of them: + // `on_confirm` still runs, with the selection the user started from. + assert_eq!(*log.borrow(), ["deselect 0", "select 1", r#"confirm ["Rust"]"#]); + cx.update(|cx| assert_eq!(language.read(cx).selected_value(), Some(&"Rust"))); + cx.update_window(handle, |_, window, _| { + assert_eq!(window.find("language").value(), Some("Rust")); + }) + .unwrap(); +} +``` + +### Checklist: What to test for each component that uses `defer_in` + +- [ ] `confirm` path: no panic, the value is committed, and `selected_value()` agrees with the trigger's `value()` +- [ ] `cancel` path (Escape, click outside): no panic, selection unchanged, menu closed (`expanded() == Some(false)`) +- [ ] `on_will_change` veto: selection unchanged; `on_confirm` still runs, with the unchanged selection +- [ ] `on_will_change` editing the selection: the final selection reflects the delegate's edit +- [ ] Repeated confirms: each one leaves `selected_value()` matching the displayed value +- [ ] `render_item` never panics even when called immediately after a mutation + +## Property Testing + +Use random data to test edge cases: + +```rust +#[gpui_kit::test(iterations = 10)] +fn test_counter_random_operations(cx: &mut TestAppContext, mut rng: StdRng) { + let counter = cx.new(|cx| Counter::new(cx)); + + let mut expected = 0i32; + for _ in 0..100 { + let delta = rng.random_range(-10..=10); + expected += delta; + + counter.update(cx, |counter, cx| { + counter.count += delta; + cx.notify(); + }); + } + + let actual = counter.read_with(cx, |counter, _| counter.count); + assert_eq!(actual, expected); +} +``` + +## Distributed Systems Testing + +Test multiple app contexts communicating: + +```rust +#[derive(Clone)] +struct NetworkMessage { + from: String, + to: String, + data: i32, +} + +#[gpui_kit::test] +fn test_distributed_apps(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + // Create components in different app contexts + let comp_a = cx_a.new(|_| MyComponent::new("A".to_string())); + let comp_b = cx_b.new(|_| MyComponent::new("B".to_string())); + + // Simulate message passing + comp_a.update(cx_a, |comp, cx| { + comp.send_message("B", 42, cx); + }); + + // Run async operations + cx_a.run_until_parked(); + + // Verify message received in other context + comp_b.update(cx_b, |comp, _| { + comp.receive_messages(); + }); + + let messages = comp_b.read_with(cx_b, |comp, _| comp.messages.clone()); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].data, 42); +} +``` + +### Interleaving Testing + +Test concurrent operations with random execution order: + +```rust +#[gpui_kit::test(iterations = 10)] +fn test_concurrent_operations( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + mut rng: StdRng, +) { + let comp_a = cx_a.new(|_| MyComponent::new()); + let comp_b = cx_b.new(|_| MyComponent::new()); + + // Perform random operations across contexts + for i in 0..20 { + if rng.random_bool(0.5) { + comp_a.update(cx_a, |comp, cx| { + comp.perform_operation(i, cx); + }); + } else { + comp_b.update(cx_b, |comp, cx| { + comp.perform_operation(i, cx); + }); + } + } + + // Run all pending operations + cx_a.run_until_parked(); + + // Verify final state + let state_a = comp_a.read_with(cx_a, |comp, _| comp.state); + let state_b = comp_b.read_with(cx_b, |comp, _| comp.state); + + // Assert invariants hold despite execution order + assert!(state_a.is_consistent()); + assert!(state_b.is_consistent()); +} +``` + +## Mocking and Isolation + +### Network Mocking + +Create mock networks for testing distributed features: + +```rust +struct MockNetwork { + messages: Arc<Mutex<Vec<NetworkMessage>>>, +} + +impl MockNetwork { + fn new() -> Self { + Self { + messages: Arc::new(Mutex::new(Vec::new())), + } + } + + fn send(&self, message: NetworkMessage) { + self.messages.lock().unwrap().push(message); + } + + fn receive_all(&self) -> Vec<NetworkMessage> { + self.messages.lock().unwrap().drain(..).collect() + } +} + +#[gpui_kit::test] +fn test_networked_components(cx: &mut TestAppContext) { + let network = Arc::new(MockNetwork::new()); + + let sender = cx.new(|_| MessageSender::new(network.clone())); + let receiver = cx.new(|_| MessageReceiver::new(network)); + + // Send message + sender.update(cx, |sender, _| { + sender.send("Hello"); + }); + + // Receive message + receiver.update(cx, |receiver, _| { + receiver.receive_all(); + }); + + let received = receiver.read_with(cx, |receiver, _| receiver.messages.clone()); + assert_eq!(received, vec!["Hello"]); +} +``` \ No newline at end of file diff --git a/.agents/skills/gpui-kit/references/gpui/test.md b/.agents/skills/gpui-kit/references/gpui/test.md new file mode 100644 index 0000000..f5c2ced --- /dev/null +++ b/.agents/skills/gpui-kit/references/gpui/test.md @@ -0,0 +1,251 @@ +# Testing + +**Contents:** [Choose the test level](#choose-the-test-level) · [UI integration workflow](#ui-integration-workflow) · [Complete example](#complete-example) · [Queries and interactions](#queries-and-interactions) · [Frames and async work](#frames-and-async-work) · [Assertion boundaries](#assertion-boundaries) · [Additional resources](#additional-resources) + +## UI integration testing + +A UI integration test renders real components in a headless window, dispatches +clicks, keyboard input and scrolling, then checks state, focus, layout and owner +callbacks. For example: add a Checkbox UI integration test that proves clicking +toggles the owner's value and disabled controls reject the interaction. +Use this term when describing component interaction coverage. +`#[gpui_kit::test]` runs the test; `gpui_kit::test` operates and inspects its UI. + +## Choose the test level + +Use ordinary Rust `#[test]` for pure logic. Use `#[gpui_kit::test]` and +`TestAppContext` for entities, subscriptions, actions and async tasks; it can +create headless windows too. `VisualTestContext` is available for existing GPUI +window helpers. For an application UI flow, use `gpui_kit::test::TestWindowExt` +on the real `Window` and assert the behavior produced by native events. + +Import the Kit types you use explicitly and write `#[gpui_kit::test]`. Avoid `use gpui_kit::*;` in test modules: it imports GPUI’s `test` macro and can shadow Rust’s built-in `#[test]`. Add +`test-support` to the application's `gpui-kit` development dependency, using +exactly the same source and version as its normal dependency. The helpers +require a Kit revision that includes them; check the installed API before +assuming an older published release supports them. No extra testing crate or +GPUI patch is required. + +For a standalone test package beside a Kit checkout: + +```toml +[package] +name = "ui-tests" +version = "0.1.0" +edition = "2024" +publish = false + +[dev-dependencies] +gpui-kit = { path = "../gpui-kit/crates/kit", features = ["test-support"] } +``` + +Headless tests still need the platform's native build dependencies. Run +`cargo generate-lockfile` once for the new package, then +`cargo test --test ui --locked`. In the Kit repository the equivalent command +is `cargo test -p gpui-kit --features test-support --test ui --locked`. + +## UI integration workflow + +1. Import the production view and constructor from the application library. + Keep state entities and subscriptions owned by that view. Define a view + inside a test only for a self-contained example or a deliberate fixture. +2. Initialize with `cx.update(gpui_kit::init)`, open a headless window of an + explicit size, and wrap a Component application in `Root`. `Root` renders + the dialog, sheet and notification layers above the view automatically. + Do not mount overlay layers in the application view. +3. Render a frame and locate existing control IDs. Custom native elements opt + in with `.id("status").test_support()` through `TestSupportExt`. Place it + before `.track_focus(&handle)`; custom wrappers must forward that method + to their inner element. Registration adds no layout container. +4. Dispatch clicks, input and named keys through `TestWindowExt`. Exercise the + actual handler path; invoking callbacks or directly setting control state + would bypass the behavior being tested. +5. Assert the initial state, the visible outcome after each meaningful action, + and the application result. Include a relevant negative case, such as a + disabled control rejecting the interaction or invalid input preventing save. +6. Run the affected test target and report its result. An uncompiled example or + a test which never checks the action's outcome is not completion evidence. + +## Complete example + +Copy this into `tests/ui.rs`. It is the exact Kit integration-test example +from `crates/kit/tests/ui.rs`; the inline view makes this reference usable +without a repository checkout. In an application, replace that view definition +with an import of your production view. + +```rust +use gpui_kit::test::{TestSupportExt, TestWindowExt}; +use gpui_kit::{ + AppContext, Context, Entity, SharedString, TestAppContext, Window, + component::{ + Root, + button::Button, + input::{Input, InputState}, + }, + div, + prelude::*, + px, size, +}; + +struct Profile { + name: Entity<InputState>, + submitted: Option<SharedString>, +} + +impl Render for Profile { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + let status = self.submitted.as_ref().map_or_else( + || SharedString::from("Not saved"), + |name| SharedString::from(format!("Saved: {name}")), + ); + div() + .size_full() + .flex() + .flex_col() + .p_4() + .gap_4() + .child(Input::new(&self.name).id("name").w(px(240.))) + .child( + Button::new("save") + .label("Save") + .on_click(cx.listener(|this, _, _, cx| { + this.submitted = Some(this.name.read(cx).value()); + cx.notify(); + })), + ) + .child( + div() + .id("status") + .role(gpui_kit::Role::Status) + .test_support() + .aria_label(status.clone()) + .child(status), + ) + } +} + +#[gpui_kit::test] +fn saves_a_profile_through_the_ui(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + let mut profile = None; + let handle = cx.open_window(size(px(640.), px(480.)), |window, cx| { + let view = cx.new(|cx| Profile { + name: cx.new(|cx| InputState::new(window, cx)), + submitted: None, + }); + profile = Some(view.clone()); + Root::new(view, window, cx) + }); + let profile = profile.unwrap(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("status").label(), Some("Not saved")); + + window.click("name", cx); + window.input("Ada 中文", cx); + let name = window.find("name"); + assert_eq!(name.focused(), Some(true)); + assert_eq!(name.value(), Some("Ada 中文")); + assert!(name.bounds().size.width > px(0.)); + // Named keys share the same Window API and refresh the resulting frame. + window.press("backspace", cx); + assert_eq!(window.find("name").value(), Some("Ada 中")); + + window.click("save", cx); + let status = window.find("status"); + assert!(status.visible()); + assert_eq!(status.label(), Some("Saved: Ada 中")); + assert!(status.bounds().top() >= window.find("save").bounds().bottom()); + }) + .unwrap(); + + // Verify the application result as well as the native properties. + cx.update(|cx| { + assert_eq!(profile.read(cx).submitted.as_deref(), Some("Ada 中")); + }); +} +``` + +## Queries and interactions + +Import `TestWindowExt` and, for custom registration, `TestSupportExt` from +`gpui_kit::test`. Use normal Rust `assert!` and `assert_eq!` with snapshots. + +| API | Behavior | +| --- | --- | +| `window.find(id)` | Requires a unique observed target; errors list registered paths. | +| `window.try_find(id)` | Returns `None` when absent; ambiguous IDs still panic. | +| `window.within(id)` | Resolves a native GPUI identity scope, including an unobserved ancestor. | +| `click`, `right_click`, `double_click`, `hover` | Dispatch real pointer events at the target center. | +| `click_at(id, offset, cx)` | Uses an offset from the target bounds' top-left corner. | +| `scroll(id, delta, cx)` | Dispatches a GPUI `ScrollDelta` wheel event. | +| `drag_to(from_id, to_id, cx)` | Drags between target centers within the current scope. | +| `window.drag(from, to, cx)` | Drags between window-local points; use `bounds()` for precise or cross-scope geometry. | +| `press(key, cx)` | Sends named GPUI keys such as `backspace`, `escape` or `secondary-a`. | +| `input(text, cx)` | Types Unicode characters at current focus; click the input first. | + +GPUI IDs need only be unique within their native scope. Use +`window.within("dialog").find("name")` for repeated local IDs. Scoped windows +also provide pointer operations, `press` and `input`; keyboard helpers require +observed focus inside that scope and recheck it between characters. They do +not focus the target or replace its value. For a submenu with another +`"popup-menu"` ID, retain the resolved outer scope before opening the submenu, +then query its descendants. + +## Frames and async work + +Snapshots are owned facts from the last completed frame, not live elements. +Fetch a new snapshot after an interaction. Interaction helpers refresh frames; +after an external entity update, focus change or resize, call +`window.render_frame(cx)` before querying. + +For deferred actions, subscriptions or popup updates, leave `update_window` +so GPUI can process its effects. Use `cx.run_until_parked()` for queued work, +or import `TestAppContextExt` and await a bounded condition: + +```rust +cx.wait_for(handle.into(), std::time::Duration::from_secs(1), |window, _| { + window.try_find("dialog").is_none() +}).await; +``` + +`wait_for` runs outside a borrowed window update and uses GPUI test time. +Process a queued action before changing any input that the action is meant to +validate. Otherwise it can read the later input instead of the value at the +intended test step. + +For timers, check the UI before its deadline as well as after expiration. +GPUI's non-synced `Animation` uses wall-clock time; advancing the test clock +does not complete it. Use reduced motion when supported, or a bounded wait +appropriate to that production animation before asserting final geometry. + +## Assertion boundaries + +- `checked()`, `selected()`, `expanded()`, `value()` and `label()` come from + native accessibility properties. `None` means unavailable; masked input + deliberately omits its value. Do not supply a second set of test-only state. +- `disabled()` currently reports `Some(true)` or `None`. To prove a control is + enabled, perform the interaction and assert the result. Absence of a flag + is not proof that the control accepts input. +- `focused()` measures the observed native focus scope. Missing bindings are + diagnosed when the native element advertises `Action::Focus`; controls + without that action can still return `None`. Register the real binding. +- `bounds()` supports containment, relative position, overlap, scrolling and + drag assertions. `visible()` checks geometry, clipping and observed style; + it does not prove a target is unobstructed. Real hit testing decides whether + a click reaches the control. +- Native properties can be wrong too. Pair them with real interactions, + geometry and the application result. A label is not rendered text, and a + correct accessibility value does not establish correct pixels. +- These tests run in-process. Pixel comparisons need GPUI's separate renderer; + packaged-app behavior and full OS IME composition need other coverage. Kit's + explicit macOS rendering target is + `cargo test -p gpui-kit --features test-support --test rendering --locked`. + +## Additional resources + +- [Website testing guide](https://gpui-kit.com/docs/test) — bilingual setup, + component coverage, platform requirements and CI. +- [GPUI test examples](test-examples.md) — entity and context patterns. +- [GPUI test reference](test-reference.md) — re-entrancy and property tests. diff --git a/.agents/skills/gpui-kit/references/recipes.md b/.agents/skills/gpui-kit/references/recipes.md new file mode 100644 index 0000000..1f23456 --- /dev/null +++ b/.agents/skills/gpui-kit/references/recipes.md @@ -0,0 +1,131 @@ +# Tested application recipe + +This complete view comes from `examples/ai_recipes/src/settings.rs`. That consumer crate depends only on `gpui-kit`; the same source is compiled and tested by `cargo test -p gpui-kit-recipes`, and `script/check-ai-recipes` keeps this copy identical to it. Pair it with that example's `src/bootstrap.rs`, which installs assets, initializes the library, and wraps the window in `Root`. + +The view owns both input state and subscriptions. Rendering creates only elements. The window's `Root` renders the dialog, sheet and notification layers above the view. + +<!-- recipe:settings:start --> +```rust +use gpui_kit::component::{ + ActiveTheme, IconName, WindowExt, + button::Button, + checkbox::Checkbox, + form::{Field, Form}, + input::{Input, InputEvent, InputState}, + radio::RadioGroup, + switch::Switch, +}; +use gpui_kit::{ + AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, Subscription, Window, div, +}; + +pub struct Settings { + name: Entity<InputState>, + preview: SharedString, + changes: usize, + enabled: bool, + remember: bool, + delivery: Option<usize>, + _subscriptions: Vec<Subscription>, +} + +impl Settings { + pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { + let name = cx.new(|cx| InputState::new(window, cx).placeholder("Name")); + let subscription = cx.subscribe_in(&name, window, |this, state, event, _, cx| { + if matches!(event, InputEvent::Change) { + this.preview = state.read(cx).value().to_string().into(); + this.changes += 1; + cx.notify(); + } + }); + Self { + name, + preview: "".into(), + changes: 0, + enabled: false, + remember: false, + delivery: Some(0), + _subscriptions: vec![subscription], + } + } + + pub fn input(&self) -> Entity<InputState> { + self.name.clone() + } + + pub fn preview(&self) -> &SharedString { + &self.preview + } + + pub fn changes(&self) -> usize { + self.changes + } +} + +impl Render for Settings { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + div() + .flex() + .flex_col() + .size_full() + .p_4() + .gap_3() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child("Profile") + .child( + Form::new() + .child(Field::new().label("Name").child(Input::new(&self.name))) + .child(Field::new().label("Preview").child(self.preview.clone())) + .child( + Field::new().label_indent(false).child( + Checkbox::new("remember") + .label("Remember name") + .checked(self.remember) + .on_change(cx.listener(|this, value, _, cx| { + this.remember = *value; + cx.notify(); + })), + ), + ) + .child( + Field::new().label_indent(false).child( + Switch::new("enabled") + .label("Enable notifications") + .checked(self.enabled) + .on_change(cx.listener(|this, value, _, cx| { + this.enabled = *value; + cx.notify(); + })), + ), + ) + .child( + Field::new().label("Delivery").child( + RadioGroup::new("delivery") + .children(["Immediately", "Daily summary"]) + .selected_index(self.delivery) + .on_change(cx.listener(|this, value, _, cx| { + this.delivery = Some(*value); + cx.notify(); + })), + ), + ) + .footer( + Button::new("about") + .label("About…") + .icon(IconName::Info) + .on_click(|_, window, cx| { + window.open_dialog(cx, |dialog, _, _| { + dialog.title("About").child("A complete GPUI Kit window") + }); + }), + ), + ) + } +} +``` +<!-- recipe:settings:end --> + +For changes in this repository, run `script/check-ai docs`, `script/check-ai rust`, or `script/check-ai shell` for the relevant verification profile (or `script/check-ai all` for all three), and `cargo test -p gpui-kit-recipes` when a recipe source changes. For a downstream app, compile and test your own consumer, then verify keyboard/focus and visuals in a real window. Automated recipe tests do not establish a model success rate. diff --git a/.agents/skills/gpui-kit/references/usage.md b/.agents/skills/gpui-kit/references/usage.md new file mode 100644 index 0000000..5155830 --- /dev/null +++ b/.agents/skills/gpui-kit/references/usage.md @@ -0,0 +1,405 @@ +# gpui-kit Usage Guide + +**Contents:** [Setup](#setup) · [Component Types](#component-types) · [Common Components](#common-components) (Button, Input, Select, Checkbox, Icon, Dialog, Notification, Tabs, Tooltip, Form, List) · [Theming](#theming) · [Layout Helpers](#layout-helpers) · [Overlay Layers](#overlay-layers-dialogs-sheets-notifications) · [Shared Traits](#shared-traits) + +## Setup + +Use the [tested application recipe](recipes.md) for complete examples and their verification command. Store subscription handles on the owning view; binding one to a constructor-local variable alone does not keep it alive after construction. + +### 1. Cargo.toml + +```toml +[dependencies] +gpui-kit = "0.7" # re-exports GPUI, platform, base, component and the default icons; Shell is a separate host dependency +``` + +### 2. Initialization + +```rust +fn main() { + gpui_kit::application() + .with_assets(gpui_kit::assets::Assets) + .run(move |cx| { + gpui_kit::init(cx); // MUST be first + + // Wraps the view in a `Root`, which enables dialogs, sheets, + // notifications, tooltips and menus in this window. + gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| cx.new(|_| MyApp)) + .expect("Failed to open window"); + }); +} +``` + +Use `gpui_kit::open_window` for application windows; it mounts `Root` as the window's root view. + +--- + +## Component Types + +### Stateless (most components) + +Used directly in `render`, no stored state: + +```rust +use gpui_kit::component::button::Button; + +impl Render for MyView { + fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { + Button::new("btn").primary().label("Submit") + .on_click(|_, _, _| println!("clicked")) + } +} +``` + +### Stateful (Input, Select, Combobox, etc.) + +Require an `Entity<State>` stored in your view: + +```rust +use gpui_kit::component::input::{Input, InputState}; + +struct MyView { + name: Entity<InputState>, +} + +impl MyView { + fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { + Self { + name: cx.new(|cx| InputState::new(window, cx).placeholder("Your name")), + } + } +} + +impl Render for MyView { + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + Input::new(&self.name) + } +} +``` + +--- + +## Common Components + +### Button + +```rust +use gpui_kit::component::button::{Button, ButtonGroup}; + +// Variants +Button::new("btn").label("Default") +Button::new("btn").primary().label("Primary") +Button::new("btn").danger().label("Delete") +Button::new("btn").warning().label("Warning") +Button::new("btn").success().label("Success") +Button::new("btn").ghost().label("Ghost") +Button::new("btn").link().label("Link") + +// States +Button::new("btn").label("Text").disabled(true) +Button::new("btn").label("Text").loading(true) +Button::new("btn").label("Text").selected(true) + +// With icon +Button::new("btn").icon(IconName::Plus).label("Add") + +// Sizes +Button::new("btn").xsmall().label("XS") +Button::new("btn").small().label("S") +Button::new("btn").large().label("L") + +// Group +ButtonGroup::new("group") + .child(Button::new("a").label("A")) + .child(Button::new("b").label("B")) + .on_click(|indices, _, _| { /* selected indices */ }) +``` + +### Input + +```rust +use gpui_kit::component::input::{Input, InputState}; + +// State setup (in new/init) +let input = cx.new(|cx| InputState::new(window, cx) + .placeholder("Enter text...") + .default_value("Hello") +); + +// Render +Input::new(&input) +Input::new(&input).cleanable(true) // clear button +Input::new(&input).disabled(true) +Input::new(&input).prefix(Icon::new(IconName::Search).small()) +Input::new(&input).suffix(Button::new("b").ghost().icon(IconName::X).xsmall()) +Input::new(&input).content_type(InputContentType::Password) +Input::new(&input).mask_toggle() // password reveal toggle +Input::new(&input).appearance(false) // remove default border/bg + +// Reading value +let value = input.read(cx).value(); + +// Keep this subscription in the owning view (for example, `_subscriptions`). +self._subscriptions.push(cx.subscribe_in(&input, window, |view, state, event, window, cx| { + match event { + InputEvent::Change => { let v = state.read(cx).value(); } + InputEvent::PressEnter { .. } => { /* submit */ } + InputEvent::Focus | InputEvent::Blur => {} + } +})); +``` + +### Select + +```rust +use gpui_kit::component::select::{Select, SelectState}; + +// Simple string list +let state = cx.new(|cx| { + SelectState::new(vec!["Apple", "Orange", "Banana"], Some(IndexPath::default()), window, cx) +}); + +// Render +Select::new(&state) +Select::new(&state).placeholder("Pick one") + +// Reading selection +let selected = state.read(cx).selected_item(); +``` + +### Checkbox / Switch / Radio + +```rust +use gpui_kit::component::{checkbox::Checkbox, switch::Switch}; + +// Stateless (controlled) +Checkbox::new("cb").checked(self.checked) + .on_change(cx.listener(|this, checked, _, cx| { + this.checked = *checked; + cx.notify(); + })) + +Switch::new("sw").checked(self.enabled) + .on_change(cx.listener(|this, checked, _, cx| { + this.enabled = *checked; + cx.notify(); + })) +``` + +### Icon + +```rust +use gpui_kit::component::{Icon, IconName}; + +Icon::new(IconName::Check) +Icon::new(IconName::Search).small() +Icon::new(IconName::Plus).large().text_color(cx.theme().primary) +``` + +### Dialog + +```rust +use gpui_kit::component::dialog::{Dialog, DialogAction, DialogClose, DialogFooter}; + +// Open from window context. `footer` takes an element, not a closure. +// DialogClose dismisses the dialog, so no manual close call is needed. +window.open_dialog(cx, |dialog, _, _| { + dialog + .title("Export Report") + .child("Choose a destination for the exported file.") + .footer( + DialogFooter::new() + .gap_2() + .child(DialogClose::new().child( + Button::new("cancel").label("Cancel").outline(), + )) + .child(DialogAction::new().child( + Button::new("export").label("Export").primary(), + )), + ) +}); +``` + +### AlertDialog + +Use `AlertDialog` — not `Dialog` — to confirm a consequential action. It is not +overlay-closable and has no close button, so the choice must be made. Name the +object in the title and the result on the confirming button; see the Design +Guides for the copy rules. + +```rust +use gpui_kit::component::button::ButtonVariant; + +window.open_alert_dialog(cx, |alert, _, _| { + alert + .title("Remove “Roadmap”?") + .description("Files on disk aren’t deleted.") + .confirm() + .ok_text("Remove") + .ok_variant(ButtonVariant::Danger) + .on_ok(|_, _, _| true) +}); +``` + +`button_props(DialogButtonProps)` takes the same properties as one value; it +overrides only the fields that value sets, so `confirm` and earlier callbacks +survive it. + +### Notification + +```rust +// Simple string message +window.push_notification("Saved successfully!", cx); + +// With type variant +window.push_notification( + Notification::new("Upload complete").info().message("File uploaded"), + cx, +); +``` + +### Tabs + +```rust +use gpui_kit::component::tab::{Tab, TabBar}; + +TabBar::new("tabs") + .child(Tab::new("tab1").child("Overview")) + .child(Tab::new("tab2").child("Settings")) + .child(Tab::new("tab3").child("Logs")) +``` + +### Tooltip + +```rust +// On any element with .id(), add .tooltip(): +div() + .id("my-btn") + .tooltip(|window, cx| Tooltip::new("Delete item").build(window, cx)) + .child("Delete") + +// Or on a Button directly: +Button::new("btn").icon(IconName::Trash).tooltip("Delete") + +// Component tooltips open after 500 ms; override per trigger, +// or install `tooltip::TooltipDefaults` to change it app-wide: +Button::new("help").icon(IconName::Info).tooltip("Help") + .tooltip_show_delay(Duration::ZERO) +``` + +### Form + +```rust +use gpui_kit::component::form::{v_form, h_form, field}; + +// Vertical form +v_form() + .child(field().label("Name").child(Input::new(&self.name))) + .child(field().label("Email").child(Input::new(&self.email))) + .footer(Button::new("submit").primary().label("Submit")) + +// Horizontal label alignment +h_form() + .child(field().label("Username").child(Input::new(&self.username))) +``` + +### List (searchable, virtualized) + +```rust +use gpui_kit::component::list::{List, ListState, ListDelegate, ListItem, ListEvent}; + +// Implement ListDelegate for your data type, then: +let list_state = cx.new(|cx| ListState::new(MyDelegate::new(), window, cx)); + +// Render +List::new(&list_state) +// Keep this subscription in the owning view, alongside list_state. +self._subscriptions.push(cx.subscribe(&list_state, |this, _, event, cx| { + if let ListEvent::Select(index_path) = event { + // handle selection + } +})); +``` + +--- + +## Theming + +```rust +use gpui_kit::component::ActiveTheme as _; + +// Access colors +cx.theme().primary +cx.theme().background +cx.theme().foreground +cx.theme().border +cx.theme().surface +cx.theme().muted +cx.theme().destructive + +// Use in styles +div() + .bg(cx.theme().surface) + .text_color(cx.theme().foreground) + .border_color(cx.theme().border) +``` + +### Switch Theme + +```rust +use gpui_kit::component::{Theme, ThemeMode}; + +// Switch light/dark: loads that mode's registered theme +Theme::change(ThemeMode::Dark, None, cx); +// or, as one edit among others +Theme::update(cx, |theme| theme.mode = ThemeMode::Dark); + +// Load a named theme +Theme::update(cx, |theme| theme.apply_config(&theme_config)); + +// Edit fields; `update` keeps colors, tokens and the Base projection in step +Theme::update(cx, |theme| theme.radius = px(8.)); +``` + +--- + +## Layout Helpers + +`gpui_kit::component` extends GPUI with convenient layout methods: + +```rust +h_flex() // div().flex().flex_row().items_center() +v_flex() // div().flex().flex_col() + +// Common patterns +h_flex().gap_2().items_center() + .child(Icon::new(IconName::User)) + .child(label("Username")) + +v_flex().gap_4().p_4() + .child(Input::new(&self.name)) + .child(Input::new(&self.email)) + .child(Button::new("submit").primary().label("Submit")) +``` + +--- + +## Overlay Layers (Dialogs, Sheets, Notifications) + +The window's `Root` renders dialogs, sheets and notifications above the view, so a window opened with `gpui_kit::open_window` needs nothing else — `window.open_dialog(..)` just works. Applications define their own quit and close-window actions, key bindings, and confirmation flows. `open_window` returns `(AnyWindowHandle, Entity<V>)`. The complete tested consumer recipe is [`examples/ai_recipes/src/bootstrap.rs`](../../../examples/ai_recipes/src/bootstrap.rs). + +Root always mounts all three layers. The former `Root::render_*_layer` methods have been removed; delete their calls from application views when migrating to 0.7.0. + +--- + +## Shared Traits + +Builders return the component so methods can be chained. Constructors follow component families: controls take a stable ID, retained controls take state, and compound parts may take no arguments. See [component conventions](conventions.md). + +- `Sizable`: `.xsmall()` / `.small()` / `.medium()` (default) / `.large()` +- `Disableable`: `.disabled(bool)` +- `Selectable`: `.selected(bool)` +- `Styled`: any GPUI style methods (`.w()`, `.bg()`, `.p_2()`, etc.) + +For any component not covered here, fetch its doc from: +`https://gpui-kit.com/component/{name}.md` diff --git a/.pi/skills/gpui-kit-design-guides/SKILL.md b/.pi/skills/gpui-kit-design-guides/SKILL.md new file mode 100644 index 0000000..c65738f --- /dev/null +++ b/.pi/skills/gpui-kit-design-guides/SKILL.md @@ -0,0 +1,60 @@ +--- +name: gpui-kit-design-guides +description: The normative Design Guides for GPUI Kit desktop applications. Read in full before designing or changing any screen, layout, spacing, visual hierarchy, color, density, component choice, interaction state, overlay, motion, data-heavy view, or interface copy in a GPUI Kit (gpui-kit / gpui-component) application, and before reviewing UI work for design quality. Also use when asked what the design rules are, or whether a UI decision follows them. +--- + +# GPUI Kit Design Guides + +The guide is [references/design-guides.md](references/design-guides.md). It is +a requirement, not inspiration. Read the guide file itself before doing UI +work. Do not answer from this page, from an existing screen in the codebase, or +from training data. + +If the file is missing, fetch `https://gpui-kit.com/docs/design-guides.md`. +The guide is a verbatim copy of that page, so its link to `./coding-guides.md` +means the Coding Guides in the `gpui-kit` skill (or `https://gpui-kit.com/docs/coding-guides.md`). + +## How to read it + +Read the whole guide for a new screen or a redesign. For a narrow change, read +"Design thesis" and "Start from the task" first, then the section for the +change. Sections, in order (`grep -n '^## ' references/design-guides.md`): + +| Section | Read when | +| ------------------------------------ | ---------------------------------------------------------------------- | +| Design thesis | Always | +| Learning from Shadcn | Choosing what to borrow from web component libraries | +| Start from the task | Always; task hierarchy, interaction promise, what to leave out | +| Visual language | Color, typography, spacing, radius, borders, elevation, density, icons | +| Layout patterns | Window structure, sidebars, toolbars, panels, forms, resizable regions | +| Components and composition | Picking a component, composing parts, when to build a new one | +| Interaction states | Hover, focus, pressed, selected, disabled, loading, validation, danger | +| Feedback and overlays | Dialog, sheet, popover, menu, notification, tooltip, dismissal, focus | +| Motion | Any animation or transition | +| Designing data-heavy interfaces | Tables, lists, trees, dashboards, dense inspectors | +| Interface language | Any user-facing text: labels, buttons, titles, errors, empty states | +| Internationalization and platform fit | Multi-locale copy, Chinese terminology, macOS/Windows conventions | +| Guidance for AI-generated interfaces | Always when an agent produces UI | +| Accessibility checklist | Before finishing | +| Design review checklist | Before finishing; run every item against the work | + +## Non-negotiables + +A floor, not a substitute for the guide. + +- **Desktop before web convention.** Keyboard access, window chrome, menus, + dense data views, resizable regions, persistent navigation. +- **`Button` vs `Link`.** `Button` for every in-app command, `ghost` or + `outline` when it should read quietly. `Link` only for external URLs and + email addresses. +- **Tokens before values.** No raw hex or `rgb(...)`; use `cx.theme()` + semantic tokens and rem-based helpers. Any spacing number quoted in the guide + is the current default scale, not a literal to repeat. +- **State must be visible.** Hover, focus, selection, disabled, loading, + validation, and destructive states each need distinct, consistent treatment. +- **Overlays.** Escape dismisses the topmost surface and returns focus to its + trigger. +- **Copy.** Name the object and the verb: `Delete "Roadmap"?` with a `Delete` + button, not `Are you sure?` with `OK`. + +Finish by running the Design review checklist against the work. diff --git a/.pi/skills/gpui-kit-design-guides/references/design-guides.md b/.pi/skills/gpui-kit-design-guides/references/design-guides.md new file mode 100644 index 0000000..fc3dc08 --- /dev/null +++ b/.pi/skills/gpui-kit-design-guides/references/design-guides.md @@ -0,0 +1,950 @@ +--- +title: Design Guides +description: Product and interaction design guidance for GPUI Kit applications +order: -2.1 +--- + +# Design Guides + +Use this guide before choosing components or writing layout code. It records +the product judgment accumulated through years of GPUI Kit desktop work: +an interface should feel native, restrained, precise, and understandable +without guesswork. + +This is a normative guide. **Must** identifies a correctness or ecosystem +constraint, **should** is the default that needs a concrete reason to override, +and **may** is an optional technique. Component API documentation remains the +authority for individual methods. + +The rules build on behavior in `gpui-base`, the GPUI Component theme and +component system, and familiar desktop interaction. Shadcn contributes useful +methods—open code, composition, and dependable defaults—but does not determine +how a GPUI application should look. When influences conflict, preserve GPUI's +lifecycle constraints and the interaction people already understand. + +## Design thesis + +Build interfaces that feel native, quiet, and precise. Let content, hierarchy, +and interaction carry the experience; decoration should support them rather +than compete with them. + +1. **Clarity before personality.** Make the primary task and next action clear + before adding brand expression. +2. **Composition before invention.** Start with established components and + compose them into product-specific workflows. Create a new primitive only + when its behavior is genuinely new. +3. **Tokens before values.** Colors, radii, typography, and spacing should form + a system. Avoid isolated literals that cannot respond to themes. +4. **Desktop before web convention.** Preserve keyboard access, window chrome, + menus, dense data views, resizable regions, and persistent navigation where + the task benefits from them. +5. **State must be visible.** Hover, focus, selection, disabled, loading, + validation, and destructive states need distinct and consistent treatment. +6. **Geometry explains containment.** Radii, insets, and boundaries should + describe one coherent hierarchy, even where surfaces overlap. +7. **Feedback reflects reality.** Loading, tooltips, and motion should clarify + a real state or transition, not add ceremony to an immediate action. + +## Learning from Shadcn + +Shadcn's most useful contribution is not a particular border color. It is a +way of building a system: + +- own the top layer of the interface instead of fighting a sealed abstraction; +- compose small, predictable parts into product-specific components; +- provide defaults that already form one visual language; +- keep the code and composition legible to both people and AI; +- separate behavior primitives from the styled layer. + +GPUI Kit applies those ideas through a Rust library and the split between +`gpui-base` and `gpui-component`. Applications normally compose or wrap the +published components; contributors move genuinely reusable behavior into Base +and keep visual policy above it. + +Do not copy these web assumptions blindly: + +| Web habit | Native GPUI default | +| --- | --- | +| Pointing-hand cursor on every button | Default arrow cursor; pointing hand for links | +| Page navigation as the main structure | Persistent windows, panes, sidebars, tabs, and menus | +| Browser focus and scrolling as a fallback | Explicit focus ownership and region-owned scrolling | +| Mobile-first single column | Resizable desktop shell with a defined minimum window size | +| Hover-revealed critical actions | Keyboard- and pointer-reachable actions that do not depend on hover | +| A row of hover-only icon buttons | A visible primary action plus `DropdownMenu` or `ContextMenu` for secondary commands | +| Link-styled text for application commands | `Button`, `outline`, or `ghost`; Link only for URLs, web resources, or email addresses | +| Large touch density everywhere | Medium density by default; compact only where information work benefits | +| CSS overrides across descendants | Typed builders, semantic parts, and application composition | + +## Start from the task + +Before drawing a screen, write down: + +- the user's primary task; +- the object being viewed or changed; +- the actions that must remain immediately available; +- the information required to make a decision; +- empty, loading, error, offline, read-only, and permission-denied states; +- the keyboard path through the workflow. + +Organize the window around those answers. Do not begin with a dashboard grid or +a component catalogue. A good desktop interface exposes the user's mental +model: documents, accounts, projects, messages, settings, or another stable +object—not the internal service architecture. + +Design the primary task before distributing controls. Its visual weight, +location, and information depth should match its importance to the product. A +core result must not be reduced to a small count, an icon in a corner, or a +weak footer action while secondary content consumes the page. When a result set +is the product's main value, consider a summary region or card that exposes the +count, representative results, meaningful state, and a clear next action. + +For every proposed action, name its visible object, current state, scope, and +result. If the interface does not show or clearly imply those things, the +action is premature. Do not expose a capability merely because the backend has +it; first design how the object enters the user's mental model. + +## Visual language + +### Hierarchy + +Prefer a small number of clear levels: + +- **window or page title** identifies the current object or workspace; +- **section title** separates meaningful regions; +- **body text** carries the work; +- **muted text** provides secondary metadata and help; +- **labels** identify controls and values. + +Use size, weight, spacing, and separators before adding color or containers. +Avoid nesting cards inside cards: most desktop regions need only a background, +a hairline boundary, and intentional spacing. + +Evaluate hierarchy across the whole feature, not component by component. Hide +accent color and decoration during review: the primary task, current selection, +result summary, and next action should still be obvious from structure. A +screen that contains individually plausible controls can still fail when they +do not form one reading order and one decision path. + +Treat emphasis as a limited budget. A local surface needs one clear focal point, +not a field of competing highlights. If everything is colored, badged, bold, +boxed, or promoted to an alert, nothing reads as important. Establish priority +with structure and proximity first; spend stronger color and components only +where a distinction changes what the user notices or does. + +### Color and themes + +Read colors from `cx.theme()` and use them by semantic role: + +- `background` and `foreground` for the main surface and text; +- `group_box`, `popover`, `sidebar`, and their foreground tokens for their + named surfaces; +- `muted` and `muted_foreground` for supporting information; +- `primary` for the principal action or selection emphasis; +- `danger`, `warning`, `success`, and `info` only for their meanings; +- `border`, `input`, and focus-ring tokens for structure and interaction. + +Do not use a semantic status color as decoration. Do not encode meaning by +color alone. Verify every custom surface in light and dark themes and with +custom theme values; never assume that foreground is black or background is +white. + +Use Badge for a short state, count, or classification that benefits from rapid +scanning—not for every label, metadata value, filter, or section title. Keep +most badges neutral; reserve semantic variants for states that truly carry +success, warning, danger, or informational meaning. A row of multicolored +badges is usually a missing hierarchy or grouping decision. + +Application UI should not contain raw hex, `rgb`/`rgba`, or `hsla` colors. +Resolve colors from `cx.theme()` by semantic role. If the required role does +not exist, define it in the product's theme/token layer rather than embedding a +palette value at the call site. Raw colors belong only inside theme definitions +or in audited data/raster content whose color is itself the data. + +### Radius, spacing, and density + +Use a finite radius scale, as with type and control sizes. Choose from the +theme's named tiers (`sm`, `md`, `lg`, `xl`, and larger surface tiers) instead +of inventing a radius for each element. Large containers may use a softer tier +than the controls they contain; compact controls and overlays use tighter +tiers. For an ordinary rectangle, the radius should remain visibly smaller +than half its shorter side. Use `radius_full()` only for intentional circles +and pills; a tooltip must not become a capsule by accident. + +Nested surfaces should read as concentric shapes. Use +`inner radius ≈ max(0, outer radius − gap)` to choose the closest suitable +theme tier; the visible band should remain even through the turn. Add a +named application token if a repeated composition needs an intermediate +value. A segmented control is one silhouette: its end segments follow the container's inner +corners, its middle segments remain square, and each boundary has one divider +of consistent thickness. + +<img class="alignment-light" src="/radius-hierarchy.svg" alt="Light theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center."> +<img class="alignment-dark" src="/radius-hierarchy-dark.svg" alt="Dark theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center."> + +A radius must govern the entire visible surface, not just its border. Exposed +corners remain transparent to the surface behind them; child backgrounds, +selection fills, and scrolling content must also follow the boundary. In GPUI, +do not assume a rounded parent clips its descendants. Give inner surfaces their +own radius or an appropriate clip, then inspect the composition against a +contrasting background in both themes. Preserve outward focus rings when +choosing a clip. + +Use a compact spacing scale and repeat it. Related label/control pairs should +be closer than separate groups; separate groups should be closer than separate +sections. Prefer component sizes (`xsmall`, `small`, default medium, `large`) +over one-off heights. Use compact variants for toolbars and data-dense screens, +not to squeeze an unclear layout into less space. + +The shared semantic scale is intentionally small: spacing progresses through +roughly 2, 4, 8, 12, 16, 24, and 32 pixels, while typography stays near 12, +14, 16, 18, and 20 pixels. Treat these as relationships rather than permission +to scatter their current values through feature code. GPUI Component currently +projects a fixed default `SpacingTokens` scale from its global `Theme`; unlike +colors and radii, `Theme::apply_semantic_tokens` does not persist a custom +spacing scale. An application that needs different spacing must own that full +token snapshot and use it consistently in its application components. + +### Spatial grammar + +Spacing expresses relationship. Choose a gap from the semantic scale by asking +what the two things mean to each other: + +| Relationship | Typical token | Current scale | Examples | +| --- | --- | --- | --- | +| Optical correction | `xxs` | 2 px | icon baseline, compact separator | +| Parts of one control | `xs` | 4 px | menu icon/label, title/description | +| Closely related controls | `sm` | 8 px | button icon/label, dialog actions | +| One content group | `md` | 12 px | notification columns, compact form rows | +| Separate groups in one section | `lg` | 16 px | panel padding, form groups | +| Separate sections | `xl` | 24 px | major blocks in a page or inspector | +| Major region boundary | `xxl` | 32 px | empty-state breathing room, page bands | + +These values describe the current default scale, not literals to repeat. Use +`cx.theme().spacing_tokens()` or the corresponding GPUI scale helpers for the +ecosystem default. A product-owned scale should preserve the ordering and +relationships and must be passed through the application's own design-system +context rather than assumed to persist in the global GPUI Component theme. + +Use these rules when resolving horizontal and vertical space: + +1. **Inside before outside.** A component's padding belongs to the component; + the gap between components belongs to their parent. +2. **Vertical rhythm shows grouping.** The gap between a title and its + description is smaller than the gap from that description to the next + section. Equal gaps imply equal relationships. +3. **Horizontal space supports scanning.** Repeated rows keep icons, labels, + values, badges, and trailing actions on stable columns. +4. **Leading and trailing are semantic.** Think in reading-order edges even + when the current API uses left/right; this keeps future RTL adaptation + possible. +5. **Do not double padding.** A card placed in an already padded panel should + not automatically add another full panel inset. +6. **Use optical alignment sparingly.** A one- or two-pixel correction is valid + for icon or glyph geometry, but document why it differs from the scale. + +Common compositions in the current system illustrate the relationships: + +- button contents use 4 px at small sizes and 8 px at normal sizes; +- dialog headers and footers use an 8 px internal gap; +- compact list and menu rows use 4 px vertical and 8–12 px horizontal padding; +- sheet headers use about 16 px leading and 12 px trailing space, leaving room + for a close affordance, while footers use 16 px horizontal and 12 px vertical + space; +- notifications use 16 px horizontal padding and a 12 px column gap because + icon, message, and action are distinct groups. + +Do not treat these as copy-and-paste recipes for every surface. They reveal the +system: controls are tighter internally, rows optimize scanning, and +containers spend more space at their boundary than between their contents. + +### Proportion and layer hierarchy + +Start with content requirements, then set proportions. Avoid arbitrary halves +when one pane has a clearly different role. + +- A navigation sidebar should be wide enough for stable labels but visibly + subordinate to the work area. Give it a minimum, preferred, and maximum + width rather than a percentage alone. +- In master–detail layouts, let the collection remain scannable and give the + detail pane the surplus. A roughly one-third/two-thirds starting point is + often useful, but content constraints are authoritative. +- Inspectors and auxiliary sheets should not cover the primary object by + default. They should be resizable or dismissible when their content grows. +- Dialog width comes from the decision: short confirmation, medium form, or a + dedicated window/page for complex work. Do not enlarge a dialog simply to + create whitespace. +- Reserve the strongest elevation for the topmost decision layer. Within a + layer, use background and hairlines—not successively larger shadows—to show + hierarchy. + +Define three size constraints for every major region: the minimum at which its +task still works, a comfortable default, and how it consumes surplus. Persist +user-controlled splits when they represent workflow preference, and clamp +restored values against the current window. + +### Alignment details + +Alignment is a structural system, not a final polish pass. Establish a small +set of alignment spines for each surface: shared leading and trailing edges, +text baselines, center lines, and fixed functional lanes. Elements at the same +level should attach to the same spine from top to bottom or leading to trailing, +even when they are different component types. + +<img class="alignment-light" src="/alignment-spines.svg?v=20260822-4" alt="Alignment spines across a desktop surface"> +<img class="alignment-dark" src="/alignment-spines-dark.svg?v=20260822-4" alt="Alignment spines across a desktop surface"> + +Vertical red lines sit beside shared edges or control centers for content, +status, time, and trailing actions. Horizontal lines sit beneath text baselines +or pass through a row center to show bottom and vertical-center alignment. The +compact comparison isolates a one-rendered-pixel drift that must be corrected +at its structural owner. + +- Give sibling regions a shared content inset. A heading, toolbar, list row, + empty state, and footer that describe the same level should not each invent a + slightly different leading edge. +- Repeat column geometry through the whole region. Headers, rows, summaries, + loading states, and inline editors should reserve the same lanes for identity, + metadata, status, numbers, and actions. +- Align related controls across rows and sections. Form labels, fields, + descriptions, and validation messages should reveal a stable vertical grid + when the page is scanned from top to bottom. +- Keep horizontal bands coherent. Items sharing a toolbar, title bar, status + bar, or row should use one baseline or center line instead of individually + tuned offsets. +- Introduce indentation only for real hierarchy, containment, or disclosure. + Decorative indentation makes siblings look subordinate and breaks the + surface's reading line. +- When a nested level ends, return exactly to the parent spine. Do not let + accumulated padding drift across nested containers. +- Preserve the spine through optional content. Missing icons, badges, + descriptions, or trailing actions must not move the remaining labels; use + intentional slots or lanes when cross-row comparison matters. +- Align major regions with one another where their hierarchy matches. Sidebar + headers, content titles, split panes, toolbars, and bottom bars need not share + every coordinate, but coincident levels should form visible continuous lines. + +Not every edge should align. A child can indent, a primary value can lead its +supporting metadata, and a destructive decision can gain separation. Such +exceptions must communicate hierarchy or meaning; they must not result from +uncoordinated component padding. Start with the shared spine, then make the +exception explicit. + +Treat exact alignment and repeated gaps as quality invariants. When two edges +or spaces are intended to be equal, a one-rendered-pixel difference is a defect, +not an acceptable optical approximation. Inspect resolved bounds with a +measurement tool at representative window sizes, zoom levels, and display scale +factors. Compare coordinates and distances; do not approve alignment only from +a casual screenshot. + +The rendered-pixel tolerance is a verification rule, not permission to patch +the code with raw pixel offsets. Equal relationships should resolve from the +same `rem` helper, spacing token, grid definition, or shared component inset. +Fix the common owner when they differ. Account for fractional layout and device +rounding so intended spines land on the same physical pixel instead of drifting +at particular zoom levels. + +- Align text by baselines, not by bounding-box centers, when mixed sizes share + a row. +- Center icons in a fixed slot so labels do not move when icons differ in + intrinsic width. +- Right-align comparable numbers; left-align prose and identifiers unless the + locale requires otherwise. +- Keep trailing row actions and disclosure indicators in fixed-width lanes. +- Align form controls by their interactive frame, not by help text below them. +- Use `justify_between` only when the two sides truly own opposite edges; it + should not disguise missing structure in the middle. +- Hairlines belong on the boundary owner. Two adjacent regions must not each + draw the same separator. +- A scrollbar belongs to the region that scrolls and sits against that panel, + editor, or window's trailing edge. Content padding may inset text and rows; + it must not pull the scrollbar into the middle of the surface. Reserve a + deliberate scrollbar gutter when content needs clearance. + +### Density tiers + +Medium is the ecosystem default. Change density for the whole local context, +not one isolated control: + +- **comfortable / large:** onboarding, sparse forms, prominent decisions; +- **standard / medium:** most application chrome and workflows; +- **compact / small:** toolbars, menus, tables, and repeated professional data; +- **extra compact / xsmall:** exceptional high-density utilities, never the + automatic choice for an entire application. + +The current controls demonstrate a bounded scale rather than arbitrary sizing: +buttons commonly move through approximately 20, 24, and 32 px frames; input +and data controls may extend to about 44 px at large size; table rows use about +26, 30, 32, and 40 px. Use the component's `Size` API so typography, icon, +padding, and hit target change together. A custom height that changes only the +outer box is usually incomplete. + +### Zoom, base font, and `rem` + +A well-designed `rem` system preserves hierarchy while the interface zooms. +Zoom is successful when the relationship between title and body, control and +icon, inner and outer spacing, primary and secondary regions still feels the +same at every scale—not merely when every object becomes larger. + +GPUI Component adopts the relative-scale idea familiar from Tailwind. The +theme's base `font_size` becomes the window's `rem` through `Root`, and GPUI +scale helpers such as `text_sm()`, `gap_2()`, `p_4()`, `h_8()`, and `size_4()` +resolve against it. This gives typography, spacing, controls, and icons one +shared zoom axis. + +Design in ratios: + +- type steps keep the same hierarchy around the base body size; +- spacing steps keep the same grouping relationships around the type; +- control frames, icons, and hit targets scale with their labels; +- pane minima and comfortable widths account for the scaled content; +- corner radii and focus treatment remain optically consistent with the + control frame. + +Do not implement zoom by changing text size alone. A larger label inside a +fixed-height button, a larger document inside fixed pane minima, or larger rows +inside a stale virtual-list measurement destroys the original rhythm and can +clip content. Conversely, multiplying every physical pixel—including +hairlines—can make the interface visually heavy. + +As a rule, application layout should not call `px(...)` directly. Use GPUI's +rem-based scale helpers (`p_2`, `gap_3`, `w_64`, `text_sm`, and related +builders) or semantic component sizes. Use fixed pixels only when the value +represents a physical or raster boundary: +a one-device-pixel hairline, platform window inset, bitmap dimension, minimum +hit-test tolerance, or geometry that must match an external surface. These are +audited, documented exceptions. Product spacing, typography, icon size, and +ordinary control geometry stay on the relative scale. + +Test interface zoom at several base-font values, not just the default. Verify +hierarchy, wrapping, truncation, minimum window size, pane resizing, focus-ring +clearance, popup placement, and virtualized row measurement. Also distinguish +interface zoom from Dock's panel zoom: Dock zoom makes one container fill its +area while retaining its chrome; it does not change `rem` or application scale. + +### Surfaces and elevation + +Use elevation to explain stacking, not importance. The base window surface is +flat; separators and background contrast define its regions. Popovers, menus, +dialogs, and notifications may use progressively stronger shadows because they +sit above other content. Do not put a shadow on every card. + +All surfaces of the same kind should share one treatment. GPUI Component, for +example, deliberately gives popup families one themed popover surface so +Popover, Select, Combobox, DatePicker, and menus do not drift apart. When an +application invents another anchored surface, reuse that semantic treatment +instead of approximating it with unrelated border and shadow literals. + +### Typography and icons + +Use the platform UI font for interface text and monospace only for code, +identifiers, shortcuts, and aligned numeric data. Keep body text readable and +avoid excessive uppercase or letter spacing, especially for CJK text. + +Use one icon family in a product. Icons supplement labels; they should not +replace unfamiliar actions with guesswork. Icon-only buttons always need an +accessible name. Their visual explanation follows the tooltip policy below. +Use filled or colored icons to communicate a state, not merely to make a +toolbar lively. + +## Layout patterns + +### Choose a stable shell + +Most applications should use one of these shells: + +- **single workspace:** toolbar or title bar above one primary view; +- **sidebar workspace:** persistent navigation beside a changing detail view; +- **master–detail:** resizable collection and detail panes; +- **document workspace:** tabs or a dock area for multiple long-lived objects; +- **utility window:** one focused task with a short, fixed action path. + +Keep global navigation stable while content changes. Give the primary work area +the remaining space with `flex_1()` and `min_w_0()` / `min_h_0()` where +overflowing children must shrink. Use `Scrollable`, `VirtualList`, `Table`, or +`DockArea` for their intended behavior instead of rebuilding scrolling or pane +management from nested `div`s. + +The title bar is window chrome first. Preserve its drag region and avoid +binding ordinary title clicks to infrequent editing commands. Rename belongs +behind an explicit object command, with a keyboard path where appropriate. + +### Responsive desktop windows + +Desktop does not mean fixed-size. Decide what happens as a window narrows: + +1. preserve the primary task; +2. allow resizable regions to reach a documented minimum; +3. collapse secondary labels or inspectors; +4. move low-frequency actions into a menu; +5. scroll only the region whose content actually overflows. + +Do not hide an action without providing another path to it. Avoid making the +entire window scroll when only a list or document body should scroll. + +GPUI flex layouts have the same intrinsic-size pressure found in other layout +systems: a `flex_1()` child may still refuse to shrink around long content. +Design and implementation must agree on which panes may shrink, truncate, wrap, +or scroll. A clipped region also clips an outward focus ring; never trade away +keyboard visibility merely to simplify overflow. + +### Forms and settings + +Use a visible label for each field and place help or validation next to the +field it describes. Align related fields, but do not force long labels into a +narrow fixed column. Use the appropriate control: `Checkbox` for independent +choices, `RadioGroup` for a small visible set, `Select` for a longer set, and +`Switch` for a setting that takes effect immediately. + +Disable submission while an operation is in flight, keep the user's input, and +show the result near the action. Reserve dialogs for short, focused decisions; +use a full page or sheet for workflows that need exploration or many fields. + +A loading treatment must represent actual waiting. Switching between settings +panels whose content is already available should be immediate; a skeleton is +appropriate only while content is genuinely loading and its shape is known. + +## Components and composition + +Follow the Shadcn principle that components are building material rather than a +sealed design system. GPUI Component supplies coherent defaults, while the +application owns composition and product semantics. + +- Use component variants by meaning. Primary is reserved for the explicit + default commit in a decision area—normally the action invoked by Enter. A + lone, frequent, or desirable action is not automatically primary. An `Add` + command in a management toolbar normally uses a default Button; a form's + default `Create` commit may use primary. Use `danger` for destructive + commitment and `ghost` for quiet toolbar actions. +- Prefer explicit compound parts and render callbacks over styling arbitrary + descendants. +- Keep a repeated pattern consistent across the product. Wrap it in an + application component when it carries domain language or policy. +- Use the standard component for its semantic role. A menu, dropdown menu, + popover, select, and command palette are not interchangeable boxes; each owns + different selection, focus, keyboard, dismissal, and layout contracts. +- Preserve the component family's geometry. Menu rows share vertical and + horizontal padding, height, icon and checkmark slots, separators, radius, and + state treatment. Do not imitate one menu with a custom popup whose spacing + only approximates the system. +- Do not wrap a library component merely to rename every method or freeze all + of its capabilities. +- Move reusable behavior without product styling to `gpui-base`; keep themed, + opinionated presentation in GPUI Component or the application. + +## Interaction states + +### Make the result understandable before the click + +A control should predict its result. Use familiar desktop controls and +placement so people can act without learning the interface first. Its label +names the action and object, its state shows availability, and its feedback +confirms the same outcome. + +Do not label a Button `Save` if it opens a configuration flow, or `Delete` if +it only removes an item from a group. Name the scope when context does not make +it clear. Respond immediately to activation, prevent duplicate submission +during longer work, and show the result near the object that changed. Add a +success message only when the result itself is not visible. + +Every interactive control should be designed for: + +| State | Design requirement | +| --- | --- | +| Rest | Clear affordance without visual noise | +| Hover | Subtle pointer feedback, never the only cue | +| Pressed | Immediate press feedback | +| Open / pressed | Persistent feedback while an attached popup is open | +| Focus visible | High-contrast keyboard focus ring | +| Selected / checked | Persistent state distinct from hover | +| Disabled | Lower emphasis and no misleading hover/pressed response | +| Loading | Preserve context, prevent duplicate action, explain long waits | +| Error | State what happened and how to recover | + +Use GPUI's focus system and Actions for commands that should work from the +keyboard. Match familiar desktop shortcuts, expose shortcuts in menus or +tooltips, and keep focus in a logical place after opening or dismissing an +overlay. + +Selection is part of the information model, not optional polish. Tabs, +segmented choices, selectable rows, filters, and navigation destinations must +show a persistent selected state. A Button that owns a dropdown must remain +visibly pressed or open until the popup closes; hover alone cannot explain the +relationship between trigger and surface. + +In a segmented control, the selected fill and unselected surface must share +the container's silhouette. Neither may square off an end corner or make a +boundary appear heavier than its peers. + +Show a selected navigation item, list row, or tab through the item's own +surface: a selected fill, stronger foreground, or heavier weight. Do not add a +leading-edge bar or one-sided border as the selection marker. It is a web +template habit, not a desktop convention; it breaks the item's rounded +silhouette and adds a second, competing edge to a column that already aligns +on its text. + +For destructive actions, distinguish between reversible and irreversible work. +Prefer undo or a temporary notification for reversible changes. Use an +`AlertDialog` when the consequence is serious and cannot be undone; name the +specific object and consequence in the confirmation copy. + +### Pointer conventions + +Use the default arrow cursor for buttons, checkboxes, menu items, tabs, and +other native controls. Use a pointing hand for links and content that behaves +as a link. Use text, resize, grab, and prohibited cursors only when they +describe the active manipulation. A cursor reinforces an affordance; it does +not replace the control's visible state or accessible role. + +Keep hover effects modest because keyboard and accessibility interaction has no +hover. Do not reveal the only copy of a destructive or essential action on +hover. Contextual row actions may become quieter at rest if the same commands +remain available through selection, keyboard, or a context menu. + +### Prefer desktop command surfaces over hover toolbars + +Use command frequency and scope to choose where an action lives: + +- keep the primary or frequent action visible as a labeled Button or familiar + toolbar control; +- put secondary actions for the current region behind a visible + `DropdownMenu` trigger; +- put commands that act on the object under the pointer in a `ContextMenu`; +- expose the same important command through an Action/key binding when it has a + natural keyboard form; +- use a hover-revealed icon only as a shortcut to a command that remains + reachable elsewhere. + +This is more than a visual preference. GPUI Component's menu system already +owns directional keyboard navigation, confirmation and cancellation, disabled +items, separators, submenus, shortcut presentation, focus transfer and +restoration, and nested-menu dismissal. A custom strip of hover buttons must +rebuild those behaviors and is invisible to keyboard-only and many assistive +technology workflows. + +Choose `DropdownMenu` when users need a visible indication that more commands +exist—for example a toolbar overflow, document actions, or account menu. Choose +`ContextMenu` for selection- or object-scoped commands such as rename, +duplicate, reveal, or remove. The context menu must not be the only way to +perform an essential command; provide a menu-bar, toolbar, keyboard, or detail +view path as appropriate. + +Do not put every action into a menu to make a screen look minimal. Discovery +and speed matter: the main action stays visible, dangerous items remain clearly +labeled and separated, and a menu item should use the same verb, icon, shortcut, +enabled state, and result everywhere it appears. + +### Button means application action; Link means external resource + +Use a Button when activation changes application state, confirms a decision, +opens a tool, submits data, or runs a command. Choose its treatment by local +hierarchy: + +- primary Button for the one emphasized commitment in a decision area; +- default Button for ordinary visible actions; +- outline Button when an action needs a clear boundary with less emphasis; +- ghost Button for familiar, low-emphasis toolbar and inline actions; +- icon Button only for a well-known symbol, with an accessible name and a + tooltip when meaning or scope is unclear. + +Do not assign primary because a Button is the only action on screen, because it +is placed at the top right, or because the team wants more clicks. Primary +communicates default commitment and keyboard behavior. If activation is merely +an ordinary command such as adding an item, opening a tool, or refreshing a +view, use a default, outline, or ghost Button according to its local hierarchy. + +Use an underlined Link only for an external resource target: a URL, web page, +online documentation, or email address. It uses the pointing-hand cursor +because its contract is leaving the current application context for that +resource. Do not use Link styling to make a functional command look quiet. A +link-shaped Delete, Save, Refresh, Add, Open-menu, or in-app navigation action +hides the control's affordance and exposes the wrong accessibility role. + +“View” does not make an in-app destination a Link. A full report, analysis, +details panel, or local record still opens through a Button, row, card, tab, or +disclosure control. Use concise context-aware labels such as `Full analysis` +when the containing card already establishes what opens; reserve underlining +for a resource that actually opens in a browser or mail client. + +All internal navigation—sidebar rows, tabs, breadcrumbs, list items, opening a +local view, or switching workspaces—must use the corresponding native component +or a Button/Action. Visual emphasis is chosen through Button variant or the +navigation component's selected state, never by lying about semantics. + +## Feedback and overlays + +Choose the smallest surface that fits the decision: + +- tooltip: a short explanation or shortcut; +- popover: contextual controls that do not interrupt the task; +- menu: a compact list of actions; +- notification: asynchronous status that does not require a decision; +- dialog: a focused decision or short form; +- alert dialog: explicit confirmation of a consequential action; +- sheet: supplementary work that benefits from more persistent space. + +A tooltip supplies missing meaning; it does not restate an obvious action. +Reserve it for an unfamiliar symbol, ambiguous scope, or a useful shortcut +that is otherwise hidden. Repeated, recognizable commands such as Copy and +Download in a message footer need accessible names and keyboard access, but +their hover labels add interruption rather than clarity. + +An Alert interrupts the visual hierarchy even when it does not open a modal. +Use it for important, exceptional information that needs attention in the +current task, not as a decorated container for ordinary descriptions, tips, or +empty space. Prefer inline help, muted text, or a normal section when the +content does not require immediate notice or action. + +Avoid stacking overlays. Escape should dismiss the topmost dismissible layer, +and focus should return to the trigger or the next logical target. + +An overlay action must refer to an object or state the overlay actually shows. +For example, expose `Clear history` only when a distinct recent-history section +is visible and contains entries. Search results, recent items, and favorites +are different collections; label and separate them instead of merging them +into one unexplained list. Hide an inapplicable action or disable it with a +useful reason—do not park an ambiguous trash icon in a footer. + +Footer space is not a catch-all for capabilities that lacked a place in the +design. A footer may present shortcuts, status, or actions that apply to the +whole surface, but each item must answer: what is its object, why is it +available now, what scope does it affect, and what visible state changes after +activation? + +## Motion + +Motion explains change; it is not ambient decoration. Use short transitions for +appearance, dismissal, expansion, and spatial continuity. Avoid animating large +layout changes when opacity or transform communicates the same relationship. +Honor reduced-motion preferences, never require animation to understand state, +and do not add a default animation to every component. + +Motion policy belongs to the styled or application layer. Base may own the +lifecycle mechanism or geometry needed for a transition, but it should not +decide that every product fades or slides. Give independently animated values +stable identity, and make interruption reverse smoothly from the currently +sampled value rather than restarting from an old endpoint. + +Motion follows the continuity of the interaction. While a pointer or scroll +position moves among related targets, keep the preview surface stable and +update its content or position in place. Dismiss it only after the interaction +ends; repeated fade-out and re-entry on each update reads as flicker. + +## Designing data-heavy interfaces + +Dense does not mean cramped. In tables, trees, command palettes, editors, and +docks: + +- keep headers and primary row identity visually stable; +- align comparable values and use tabular numerals where appropriate; +- distinguish focus, hover, active row, and multi-selection; +- keep sorting and filtering visible and reversible; +- preserve selection by domain identity across filtering and reordering; +- virtualize large collections without changing keyboard semantics; +- use progressive disclosure for secondary columns and inspectors; +- provide a useful empty state that explains the next action. + +Choose a table for comparison across consistent fields, a list for scanning +heterogeneous items, a tree for real hierarchy, and a dock only when users need +to arrange long-lived tools or documents. Do not use a complex data component +as a visual style. + +## Interface language + +Words are part of the interface architecture. Write the vocabulary for a +feature as a system—destinations, objects, commands, states, and outcomes—not as +isolated translations of implementation features. Prefer the shortest wording +that remains accurate in its actual context. + +### Let context carry context + +Do not repeat information that the surrounding surface already establishes. A +sidebar destination is usually the object or domain itself: use `Users`, not +`User Management`; `Shortcuts`, not `Shortcut Configuration Management`. A +column whose rows already contain actions can omit a generic `Operation` +heading. A dialog titled `Delete “Roadmap”?` does not need body text that asks +the same question again. + +This is context economy, not deletion for its own sake. Add text when it changes +the decision: identify the affected scope, an irreversible consequence, an +unusual prerequisite, or a way to recover. Every extra word should answer a +question the current layout does not already answer. + +Use nouns for destinations and objects (`Users`, `Appearance`, `Orders`), verbs +for commands (`Save`, `Duplicate`, `Export`), and adjectives or short phrases +for states (`Offline`, `Up to date`, `Pending review`). Avoid wrappers such as +`Management`, `Module`, `Page`, `Function`, `Operation`, and `System` unless the +word distinguishes a real domain concept. + +### Write each language, do not translate its shape + +Start from shared intent, hierarchy, and terminology, then compose each locale +as natural interface language. Do not preserve the source language's word +order, number of words, politeness filler, or grammatical category. English +`Users` can express a Chinese feature concept that would literally expand to +“user management”; fidelity means preserving purpose, not preserving tokens. + +Remove words supplied by the enclosing information architecture. Inside a +`Settings` surface, a destination is often simply `Account`, not `Account +Settings` and never the unnatural singular `Account Setting`. The correct +English label is chosen from its role and neighbors, not from the standalone +source phrase. + +Maintain a small product lexicon for recurring objects, commands, and states. +Use the same term in the toolbar, menu, context menu, dialog, shortcut search, +and documentation unless the context genuinely changes its meaning. Review +copy in the rendered surface: neighboring labels often reveal repetition or +inconsistent scope that a locale file cannot. + +In localized technical writing, preserve an established framework term when a +translation would be less precise. Keep API identifiers in their original form +and format them as code. Do not retain ordinary foreign words merely to sound +technical. Explain a retained term on first use when needed, then use the same +form throughout the interface, documentation, and API examples. + +### Buttons and confirmation dialogs + +Button labels are short by default—usually one or two words—and describe the +result, not the gesture or the component. Prefer `Save`, `Move`, or `Delete` to +`Click to save`, `Perform move`, or `Confirm deletion`. Use `Cancel` consistently +for the action that leaves without committing. Reserve `OK` for acknowledging +purely informational content. + +Short is a default, not a character limit. A deliberately longer label is +better when its words expose a consequence or distinguish choices that users +could otherwise confuse, for example `Delete from this group` versus `Delete +everywhere`, or `Restart without saving`. Length must buy decision-critical +information; it must not restate the dialog title or body. + +Use the most specific concise result as the confirmation label when possible: + +| Context | Weak | Prefer | +| --- | --- | --- | +| Delete dialog | `Yes`, `Sure`, `Confirm deletion` | `Delete` | +| Unsaved changes | `Confirm`, `Yes` | `Discard changes` | +| Pure acknowledgement | `Confirm operation` | `OK` or `Done` | +| Complex consent whose result has no clear verb | `Yes` | `Confirm` | + +`Confirm` is a useful fallback when the surrounding dialog fully names a +complex commitment and no shorter result verb is accurate. It should not +replace a clear command. `Sure` is conversational rather than a stable English +command and is too ambiguous for the standard vocabulary. + +A confirmation dialog should form one compact decision: + +- title: the decision or condition, such as `Delete “Roadmap”?`; +- body: only new scope, consequence, or recovery information; +- actions: `Cancel` and the result, such as `Delete`; +- destructive styling: applied to the destructive result, not substituted for + precise wording. + +Avoid generic titles such as `Notice`, `Warning`, `Error`, and `Confirmation` +when the actual condition can be named. Avoid ritual phrases such as “Are you +sure you want to…”, “Would you like to…”, “Please note that…”, and “successfully” +when the structure or state already communicates them. Courtesy should come +from a calm, respectful tone, not repeated `please`. + +### Capitalization, punctuation, and symbols + +Use sentence case for English UI by default: `Reset layout`, not `Reset Layout` +or `RESET LAYOUT`. Preserve proper nouns and established acronyms. Follow a +platform convention such as title case for native menu commands only when the +platform integration benefits from it, and apply that convention consistently +within the component class. + +ALL CAPS can provide restrained typographic emphasis for very short section +labels, eyebrows, statuses, established acronyms, and code-like identifiers. +Its compact shape and measured tracking can form a level similar to bold type, +but it does not belong on Buttons, long headings, sentences, or dense lists. Do +not combine uppercase, strong color, and bold weight in the same region, and do +not transform every string automatically: product names, acronyms, and localized +content must preserve their intended casing. + +Labels, buttons, menu items, tabs, headings, placeholders, and short states do +not take a final period. Complete explanatory, warning, and error sentences do. +Avoid exclamation marks in routine success and failure messages. In Chinese, +use full-width punctuation in sentences and omit terminal punctuation from +short control labels by the same semantic rule. + +Use the single ellipsis character (`…`), not three periods. Append it to every +Button or MenuItem that opens a dialog, sheet, or separate window, and to a +command that requires more input or choices before it can complete, such as +`Settings…` or `Export…`. An immediately executed command does not take an +ellipsis. Use an indeterminate progress indicator, not decorative dots, to +communicate ongoing work. + +Errors should say what happened and, when useful, the next recovery action. +Success feedback should name the resulting state only when that state is not +already visible. Prefer `Couldn’t save. Check your connection and try again.` +to a technical code or a long apology; omit a `Saved successfully` toast when +the document visibly becomes saved. + +## Internationalization and platform fit + +Copy must survive expansion, CJK typography, and different shortcut notation. +Do not size a control from one English label. Keep text out of raster assets, +avoid concatenating translated fragments, and let labels wrap or truncate only +where the product defines a recovery path such as a tooltip. + +Respect platform differences that carry meaning: Command versus Control, +native window decorations, system appearance, scrollbar behavior, menus, and +notification capabilities. Keep the product's information architecture stable +across platforms, but do not erase familiar platform behavior for superficial +pixel equality. + +## Guidance for AI-generated interfaces + +An AI changing a GPUI interface should first inspect the nearest feature, +theme tokens, and component documentation. It should state the primary task, +state owner, component composition, and keyboard path before generating code. +It must not infer an API from React/Shadcn examples or invent a GPUI method +because the name seems plausible. + +AI output is incomplete until a human can explain why the hierarchy, density, +component choice, and exceptional literal values belong in this product. A +visually plausible screenshot is not proof: keyboard behavior, focus, dynamic +content, themes, resizing, and failure states are part of the design. + +## Accessibility checklist + +Before considering a screen complete, verify that: + +- every action is reachable and operable by keyboard; +- focus order follows visual and task order; +- focus remains visible and is restored after overlays; +- controls have accessible names; icon-only controls use tooltips when their + meaning, scope, or shortcut needs explanation; +- text and meaningful boundaries have sufficient contrast; +- status is not communicated by color alone; +- disabled and read-only states are distinguishable; +- labels, errors, and descriptions remain near their controls; +- content remains usable with longer translations and larger text; +- pointer targets are comfortably sized even in a dense layout. + +## Design review checklist + +A review does not inventory components; it judges whether the interface made +the right decisions. Ask, in order: + +1. **Is the task clear?** Can a new user recognize the purpose, primary action, + and next step without learning, guessing, or experimenting? +2. **Does every action keep its promise?** Do the label, control, state, scope, + feedback, and result describe one consistent outcome? +3. **Is hierarchy decisive and restrained?** Does the core feature receive the + space it deserves while strong color, bold type, badges, alerts, and primary + Buttons remain scarce? +4. **Could the interface do less, better?** Can an entry point, option, or state + be removed, combined, or deferred without weakening the complete task? +5. **Is the structure exact?** Do peers share alignment spines, equal gaps stay + equal to the rendered pixel, and scrollbars sit at the edge of their actual + scrolling region? +6. **Does it follow the component system?** Do standard controls retain their + geometry, states, keyboard behavior, and dismissal model, with appearance + supplied by theme and scale tokens? +7. **Does it remain usable in every state and constraint?** Verify keyboard and + focus behavior, empty/loading/failure/permission states, longer translations, + zoom, minimum window size, and reduced motion. +8. **Has it been tested in a real window?** Complete the task with real + components, copy, and representative content—not only an ideal screenshot. + +Continue with [Coding Guides](./coding-guides.md) to translate these design +decisions into GPUI architecture and code. diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..bd9a9c0 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "skills": { + "gpui-kit": { + "source": "longbridge/gpui-kit", + "sourceType": "github", + "skillPath": "skills/gpui-kit/SKILL.md", + "computedHash": "15d9145bb1ba8afa20070a13f29ed5540d82194ae9e69c99f6184eeff3f0c45d" + }, + "gpui-kit-design-guides": { + "source": "longbridge/gpui-kit", + "sourceType": "github", + "skillPath": "skills/gpui-kit-design-guides/SKILL.md", + "computedHash": "1dc12103913002f85e4675635146572516e6a86dc96d0fda1a9f7347b82fec90" + } + } +} From 91d6f5686700aebb5fc771bdfa4814ffecb38f41 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:45:02 +0800 Subject: [PATCH 04/69] build(deps): migrate desktop UI to GPUI Kit 0.7.1 facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the legacy gpui / gpui_platform / gpui-component / gpui-wry Git pins (commit 58962741 on zed-industries/zed plus revision aa7def67 on longbridge/gpui-component) with the published crates.io Kit facade: - `gpui-kit = "0.7.1"` — single consumer entry point; re-exports the fully matched gpui-pre 0.3.8 / gpui-component 0.7.1 / gpui-kit-assets 0.7.1 under `gpui_kit::*`, `gpui_kit::component`, `gpui_kit::base`, and `gpui_kit::assets`. - `gpui-wry = "0.7.1"` — matching WRY adapter; `lb-wry 0.53.3` flows through it so the wry::WebViewBuilder compatibility is preserved. - The application's older `gpui::Application::with_platform(...)` / `gpui::Root::render_dialog_layer(...)` constructs are replaced by the Kit's `gpui::application()` factory and the new root plugin model. For the import migration milestone the legacy `use gpui::…` and `use gpui_component::…` paths in 22 source files are surfaced to source modules via two namespace bridges added at the lib root: extern crate gpui_kit as gpui; pub mod gpui_component { pub use gpui_kit::component::*; } extern crate gpui_wry; Submodule paths in Rust resolve bare `use foo::…` only to extern crates or current-scope items, so each crate-component import site becomes `use crate::gpui_component::…`. Bare inline uses inside function bodies and one `#[test]` import were caught and fixed by hand. The smoke example `examples/clipboard_ux_smoke.rs` is migrated directly to `gpui_kit::{App, application}` and `gpui_kit::component::{Theme, ThemeMode}` and loses its forced `rgb(0x151104)` background — the Kit theme owns the surface. All preserved tests still pass under `--locked`: 188 lib unit tests + 18 clipboard storage + 1 placement integration = 207 pass, 0 fail, 1 ignored (the interactive-terminal smoke). `cargo build --features ui` produces the example cleanly. Docs: see docs/plans/gpui-kit-upgrade{,-goal}.md for the full audit and the per-milestone log. --- Cargo.lock | 1227 ++++++++++++++++---------------- Cargo.toml | 25 +- examples/clipboard_ux_smoke.rs | 10 +- src/lib.rs | 16 + src/native/components/mod.rs | 2 +- 5 files changed, 669 insertions(+), 611 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f8d6e7..ce8df8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,9 +180,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] @@ -788,15 +788,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bindgen" version = "0.72.1" @@ -817,30 +808,15 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec 0.9.1", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bit-vec" version = "0.9.1" @@ -950,16 +926,6 @@ dependencies = [ "piper", ] -[[package]] -name = "borsh" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" -dependencies = [ - "bytes", - "cfg_aliases", -] - [[package]] name = "bstr" version = "1.13.1" @@ -1202,9 +1168,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.9.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", @@ -1314,7 +1280,7 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ "bitflags 2.13.1", "block", - "cocoa-foundation 0.2.1", + "cocoa-foundation 0.2.0", "core-foundation 0.10.1", "core-graphics 0.24.0", "foreign-types 0.5.0", @@ -1338,14 +1304,15 @@ dependencies = [ [[package]] name = "cocoa-foundation" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" dependencies = [ "bitflags 2.13.1", "block", "core-foundation 0.10.1", "core-graphics-types 0.2.0", + "libc", "objc", ] @@ -1360,16 +1327,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "collections" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "gpui_util", - "indexmap", - "rustc-hash 2.1.3", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -1384,9 +1341,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", @@ -1979,16 +1936,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "derive_refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "digest" version = "0.10.7" @@ -2018,7 +1965,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", ] [[package]] @@ -2027,7 +1983,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -2038,7 +2006,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2382,17 +2350,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set 0.8.0", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fastrand" version = "2.5.0" @@ -2438,16 +2395,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -2551,9 +2498,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +checksum = "ad67eced03f5504d9cbd3a879b5958b5c54d4e5fd794361c6eb21b05fb703411" dependencies = [ "bytemuck", ] @@ -3183,89 +3130,29 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "gpui" -version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "accesskit", - "anyhow", - "async-channel 2.5.0", - "async-task", - "bindgen", - "bitflags 2.13.1", - "chrono", - "collections", - "core-video", - "ctor", - "derive_more 2.1.1", - "embed-resource", - "etagere", - "futures", - "futures-concurrency", - "getrandom 0.3.4", - "gpui_macros", - "gpui_shared_string", - "gpui_util", - "heapless", - "http_client", - "image", - "inventory", - "itertools 0.14.0", - "log", - "lyon", - "num_cpus", - "parking", - "parking_lot", - "pin-project", - "pollster 0.4.0", - "postage", - "profiling", - "rand 0.9.5", - "raw-window-handle", - "refineable", - "regex", - "resvg 0.46.0", - "scheduler", - "schemars", - "seahash", - "serde", - "serde_json", - "slotmap", - "smallvec", - "spin 0.10.1", - "strum", - "sum_tree", - "taffy", - "thiserror 2.0.20", - "tracing", - "ttf-parser", - "url", - "usvg 0.46.0", - "util_macros", - "uuid", - "waker-fn", - "web-time", - "windows 0.62.2", - "zed-font-kit", - "zed-scap", - "ztracing", -] - [[package]] name = "gpui-base" -version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component?rev=aa7def675aa17b31ca7b79542c8e7a779ed3e56b#aa7def675aa17b31ca7b79542c8e7a779ed3e56b" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf698677b253118459f552fe15513858c669d79a25837a94e480db2ae330050" dependencies = [ "aho-corasick", "anyhow", + "ashpd", "async-channel 2.5.0", "chrono", - "gpui", - "gpui_macros", - "gpui_platform", + "data-url", + "futures", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", + "html5ever 0.27.0", "instant", + "itertools 0.13.0", "lsp-types", + "markdown", + "markup5ever_rcdom", + "num-traits", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", @@ -3277,36 +3164,35 @@ dependencies = [ "serde_json", "smallvec", "smol", - "syntect", "tracing", + "unicode-linebreak", "unicode-segmentation", "web-time", - "zed-sum-tree", + "windows 0.58.0", ] [[package]] name = "gpui-component" -version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component?rev=aa7def675aa17b31ca7b79542c8e7a779ed3e56b#aa7def675aa17b31ca7b79542c8e7a779ed3e56b" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8b7def8e74b0d822d3e96f6b28fb5a2c20eb7a83d30d22fa59ca8088d1159b" dependencies = [ "anyhow", "chrono", "core-text", "enum-iterator", - "futures", - "gpui", "gpui-base", - "gpui-component-assets", "gpui-component-macros", - "gpui_macros", - "html5ever 0.27.0", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", "instant", "itertools 0.13.0", "log", "lsp-types", "markdown", - "markup5ever_rcdom", - "notify 7.0.0", + "notify", "num-traits", "objc2 0.6.4", "objc2-app-kit 0.3.2", @@ -3314,6 +3200,7 @@ dependencies = [ "once_cell", "paste", "raw-window-handle", + "regex", "resvg 0.45.1", "ropey", "rust-i18n", @@ -3326,87 +3213,215 @@ dependencies = [ "tracing", "uuid", "windows 0.58.0", - "zed-sum-tree", ] [[package]] -name = "gpui-component-assets" -version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component?rev=aa7def675aa17b31ca7b79542c8e7a779ed3e56b#aa7def675aa17b31ca7b79542c8e7a779ed3e56b" +name = "gpui-component-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2968cc7f4a2bd8f51b09cfe4f1e77409951b48a0e2d48f372b010e69c3d7fb49" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-kit" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be18e0417095ebfea77ac25962961ff4e41a120ea6cb8f05a7505b682478b83e" +dependencies = [ + "gpui-base", + "gpui-component", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-platform", + "gpui-pre-web", +] + +[[package]] +name = "gpui-kit-assets" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6c481ca987f1e6f10b1aed4d47f1b301cd1dc6f3016fac93c305f2a4f307c0" dependencies = [ "anyhow", - "gpui", + "gpui-pre", + "gpui-pre-reqwest", "log", "rust-embed", "wasm-bindgen", "wasm-bindgen-futures", - "zed-reqwest", -] - -[[package]] -name = "gpui-component-macros" -version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component?rev=aa7def675aa17b31ca7b79542c8e7a779ed3e56b#aa7def675aa17b31ca7b79542c8e7a779ed3e56b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", ] [[package]] -name = "gpui-wry" -version = "0.5.0" -source = "git+https://github.com/longbridge/gpui-component?rev=aa7def675aa17b31ca7b79542c8e7a779ed3e56b#aa7def675aa17b31ca7b79542c8e7a779ed3e56b" +name = "gpui-pre" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f0bc3ebba070f8981b3f8e905a12f47d27794a30d029e59cdb9edf7a4b3360" dependencies = [ + "accesskit", "anyhow", - "gpui", - "lb-wry", + "async-channel 2.5.0", + "async-task", + "backtrace", + "bindgen", + "bitflags 2.13.1", + "chrono", + "core-video", + "ctor", + "derive_more 2.1.1", + "embed-resource", + "etagere", + "futures", + "futures-concurrency", + "getrandom 0.3.4", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-macros", + "gpui-pre-refineable", + "gpui-pre-scheduler", + "gpui-pre-shared-string", + "gpui-pre-sum-tree", + "gpui-pre-util", + "gpui-pre-util-macros", + "heapless", + "image", + "inventory", + "itertools 0.14.0", + "libc", + "log", + "lyon", + "num_cpus", + "objc2-core-foundation", + "objc2-core-video", + "parking", + "parking_lot", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "rand 0.9.5", + "raw-window-handle", + "regex", + "resvg 0.46.0", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "strum", + "taffy", + "thiserror 2.0.20", + "ttf-parser", + "url", + "usvg 0.46.0", + "uuid", + "waker-fn", + "web-time", + "windows 0.62.2", + "zed-font-kit", + "zed-scap", ] [[package]] -name = "gpui_apple" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-apple" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdd914311cd12894367c8b9908947bb0c27fd42f8b9a5ff7a0730551a83fa4c" dependencies = [ "anyhow", - "block", + "async-task", + "block2 0.6.2", "cbindgen", - "cocoa 0.26.0", - "collections", "core-foundation 0.10.1", + "core-graphics 0.24.0", "core-video", "derive_more 2.1.1", + "dispatch2", "etagere", "foreign-types 0.5.0", - "gpui", + "gpui-pre", + "gpui-pre-util", "image", + "libc", "log", + "mach2", "metal", - "objc", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "parking_lot", ] [[package]] -name = "gpui_linux" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-collections" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c060863025dc3b8e90febad2d1f4ffd23a5d0dcedba39d3f6b7820ea2fdd3a0" +dependencies = [ + "gpui-pre-util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "gpui-pre-derive-refineable" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc834f30f5f49791e214f1fde01a009c7de91c9cd0a9f31dabfed130ba39e6cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-http-client" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2935cf82333da3f41774e5757837756408f01356197b41c817ad2666034453" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more 2.1.1", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "gpui-pre-linux" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1131746fad5c87b5de74dc9c73105655b0c820a2dd4bb247586573e7cf5720e" dependencies = [ "accesskit", "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", - "bitflags 2.13.1", "bytemuck", "calloop", "calloop-wayland-source", - "collections", "filedescriptor", "futures", - "gpui", - "gpui_util", - "gpui_wgpu", - "http_client", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-util", + "gpui-pre-wgpu", "libc", "log", "notify-rust", @@ -3425,7 +3440,6 @@ dependencies = [ "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", - "x11-clipboard", "x11rb", "xkbcommon", "zed-scap", @@ -3433,40 +3447,40 @@ dependencies = [ ] [[package]] -name = "gpui_macos" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-macos" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d756813ff1cd741a502af60fd18869e0677795936d7349642b41c49f43120c0" dependencies = [ "accesskit", "accesskit_macos", "anyhow", - "async-task", - "block", "block2 0.6.2", "cocoa 0.26.0", - "collections", "core-foundation 0.10.1", "core-foundation-sys 0.8.7", "core-graphics 0.24.0", "core-text", + "core-video", "ctor", "dispatch2", "foreign-types 0.5.0", "futures", - "gpui", - "gpui_apple", - "gpui_util", + "gpui-pre", + "gpui-pre-apple", + "gpui-pre-collections", + "gpui-pre-scheduler", + "gpui-pre-util", "image", "itertools 0.14.0", - "libc", "log", - "mach2", - "media", - "metal", "objc", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-core-graphics", + "objc2-core-media", "objc2-foundation 0.3.2", + "objc2-screen-capture-kit", "objc2-user-notifications", "parking_lot", "pathfinder_geometry", @@ -3479,33 +3493,125 @@ dependencies = [ ] [[package]] -name = "gpui_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-macros" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ad26ca759364694cb55487bebff4c1b7dd6accab49990ff3cda6f7a818a9f4" dependencies = [ "heck 0.5.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] -name = "gpui_platform" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-perf" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcb288a506a476b6cbf97e6ec1cc0118db43f0ca91087588afa1c2d8cd4f842" +dependencies = [ + "gpui-pre-collections", + "serde", + "serde_json", +] + +[[package]] +name = "gpui-pre-platform" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e200b7b78836f1a0fc70767d971768e88f29d8b91b697285c15fb8e373635a3e" dependencies = [ + "anyhow", "console_error_panic_hook", - "gpui", - "gpui_linux", - "gpui_macos", - "gpui_web", - "gpui_windows", + "gpui-pre", + "gpui-pre-linux", + "gpui-pre-macos", + "gpui-pre-web", + "gpui-pre-windows", ] [[package]] -name = "gpui_shared_string" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-refineable" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4042345bb5bd3ecbc5d15b21ed128154f462a992058d7038cd397631f5462338" +dependencies = [ + "gpui-pre-derive-refineable", +] + +[[package]] +name = "gpui-pre-reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05be23908e707966824f8c51a609904b7f30739e8d915e120a53c1b995ba964c" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration 0.6.1", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "gpui-pre-scheduler" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4acd73345bc3c09f1b98c1b21eb4459ad8336f89bb2edcef619e11c5a08456e" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume 0.12.0", + "futures", + "parking_lot", + "rand 0.9.5", + "wasm_thread", + "web-time", +] + +[[package]] +name = "gpui-pre-shared-string" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "477529fd5aa90bff969785ee9a3938484d94aa5101cdf7b84719629c0345f98a" dependencies = [ "schemars", "serde", @@ -3513,9 +3619,23 @@ dependencies = [ ] [[package]] -name = "gpui_util" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-sum-tree" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ec0ec176d576b82aed716cf47185856b54ea9528caaf9d01787024689bfd824" +dependencies = [ + "gpui-pre-ztracing", + "heapless", + "log", + "rayon", + "tracing", +] + +[[package]] +name = "gpui-pre-util" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1635ae7d0b05400bfa211ddef6c67dc8d9f223c463022c2b3bf2c1c14c6044f" dependencies = [ "anyhow", "log", @@ -3523,21 +3643,37 @@ dependencies = [ ] [[package]] -name = "gpui_web" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-util-macros" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01486fce7722e5d7d7e7bb71c719dfdcc6ed917112bdc7c56d0556fdb60d10ab" +dependencies = [ + "gpui-pre-perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-web" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "184776d0763e6889dd2842dcb1204c15055945f2bdfe926b5ef57bdbd1952a65" dependencies = [ "anyhow", "console_error_panic_hook", "futures", - "gpui", - "gpui_wgpu", - "http_client", + "gpui-pre", + "gpui-pre-http-client", + "gpui-pre-scheduler", + "gpui-pre-wgpu", "js-sys", "log", "parking_lot", "raw-window-handle", - "scheduler", + "smallvec", + "unicode-properties", + "unicode-script", + "unicode-segmentation", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -3547,17 +3683,18 @@ dependencies = [ ] [[package]] -name = "gpui_wgpu" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-wgpu" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439c5421cafe1a7cf68d0c0ae5cc82b26fad6b0e6345ac98d20acaf1f2463914" dependencies = [ "anyhow", "bytemuck", - "collections", "cosmic-text", "etagere", - "gpui", - "gpui_util", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", "itertools 0.14.0", "log", "parking_lot", @@ -3573,19 +3710,21 @@ dependencies = [ ] [[package]] -name = "gpui_windows" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" +name = "gpui-pre-windows" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "371f9bb13f97e3bea2f8b8e5e81232bb74d735153c244ae67f7400e743925b1c" dependencies = [ "accesskit", "accesskit_windows", "anyhow", - "collections", "dunce", "etagere", "futures", - "gpui", - "gpui_util", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-scheduler", + "gpui-pre-util", "image", "itertools 0.14.0", "log", @@ -3600,6 +3739,47 @@ dependencies = [ "windows-registry 0.6.1", ] +[[package]] +name = "gpui-pre-zlog" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bbe5b98630ef4bfed5a7105ec1062e78d571912bc491388f07ea22f2457c18b" +dependencies = [ + "anyhow", + "chrono", + "gpui-pre-collections", + "log", +] + +[[package]] +name = "gpui-pre-ztracing" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f806400f6bda1fdd9fc627e158c0e44fb0c4b7767582a3621efcf92f4a850db7" +dependencies = [ + "gpui-pre-zlog", + "gpui-pre-ztracing-macro", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "gpui-pre-ztracing-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d337c9acf058238e45ce0570d8a5958439242889492f91ae2d8e324211a61ccd" + +[[package]] +name = "gpui-wry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e76703e4c356fa306a9fb0762105638e6add8664f7c2fdfebe226af12d351acd" +dependencies = [ + "anyhow", + "gpui-pre", + "lb-wry", +] + [[package]] name = "granit-parser" version = "0.0.7" @@ -3891,26 +4071,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http_client" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "anyhow", - "async-compression", - "bytes", - "derive_more 2.1.1", - "futures", - "http", - "http-body", - "log", - "parking_lot", - "serde", - "serde_json", - "serde_urlencoded", - "url", -] - [[package]] name = "httparse" version = "1.10.1" @@ -4197,20 +4357,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify" -version = "0.11.5" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -4466,9 +4615,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -4494,9 +4643,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -4572,7 +4721,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "flume 0.11.1", @@ -4678,9 +4827,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.21" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -5001,20 +5150,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "media" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "anyhow", - "bindgen", - "core-foundation 0.10.1", - "core-video", - "foreign-types 0.5.0", - "metal", - "objc", -] - [[package]] name = "memchr" version = "2.8.3" @@ -5134,7 +5269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set 0.9.1", + "bit-set", "bitflags 2.13.1", "cfg-if", "cfg_aliases", @@ -5253,25 +5388,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "notify" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" -dependencies = [ - "bitflags 2.13.1", - "filetime", - "fsevent-sys", - "inotify 0.10.2", - "kqueue", - "libc", - "log", - "mio", - "notify-types 1.0.1", - "walkdir", - "windows-sys 0.52.0", -] - [[package]] name = "notify" version = "8.2.0" @@ -5280,12 +5396,12 @@ checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ "bitflags 2.13.1", "fsevent-sys", - "inotify 0.11.5", + "inotify", "kqueue", "libc", "log", "mio", - "notify-types 2.1.0", + "notify-types", "walkdir", "windows-sys 0.60.2", ] @@ -5301,16 +5417,7 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus", -] - -[[package]] -name = "notify-types" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" -dependencies = [ - "instant", + "zbus", ] [[package]] @@ -5704,6 +5811,21 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -5830,6 +5952,20 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-screen-capture-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-core-media", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -5976,9 +6112,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] @@ -6105,16 +6241,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "perf" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "collections", - "serde", - "serde_json", -] - [[package]] name = "phf" version = "0.8.0" @@ -6616,6 +6742,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -6857,9 +6992,9 @@ checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] name = "rangemap" -version = "1.8.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "rav1e" @@ -6992,7 +7127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.4", + "font-types 0.12.1", "once_cell", ] @@ -7017,6 +7152,17 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -7048,14 +7194,6 @@ dependencies = [ "syn 3.0.4", ] -[[package]] -name = "refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "derive_refineable", -] - [[package]] name = "regex" version = "1.13.1" @@ -7389,10 +7527,8 @@ dependencies = [ "crossterm", "directories", "futures", - "gpui", - "gpui-component", + "gpui-kit", "gpui-wry", - "gpui_platform", "ignore", "interprocess", "lb-wry", @@ -7400,7 +7536,7 @@ dependencies = [ "lyon", "memchr", "mimalloc", - "notify 8.2.0", + "notify", "notify-rust", "objc", "rand 0.8.8", @@ -7577,22 +7713,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scheduler" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "async-task", - "backtrace", - "chrono", - "flume 0.12.0", - "futures", - "parking_lot", - "rand 0.9.5", - "wasm_thread", - "web-time", -] - [[package]] name = "schemars" version = "1.2.2" @@ -7915,7 +8035,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs", + "dirs 6.0.0", ] [[package]] @@ -8080,7 +8200,6 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" dependencies = [ - "borsh", "serde_core", ] @@ -8415,18 +8534,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "sum_tree" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "heapless", - "log", - "rayon", - "tracing", - "ztracing", -] - [[package]] name = "sval" version = "2.21.1" @@ -8797,24 +8904,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "syntect" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" -dependencies = [ - "bincode", - "fancy-regex", - "flate2", - "fnv", - "once_cell", - "regex-syntax", - "serde", - "serde_derive", - "thiserror 2.0.20", - "walkdir", -] - [[package]] name = "sys-locale" version = "0.3.2" @@ -8909,9 +8998,9 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.4" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", @@ -9656,16 +9745,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "util_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "perf", - "quote", - "syn 2.0.119", -] - [[package]] name = "uuid" version = "1.26.0" @@ -9818,9 +9897,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -9831,9 +9910,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -9841,9 +9920,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9851,9 +9930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -9864,9 +9943,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -9887,7 +9966,8 @@ dependencies = [ [[package]] name = "wasm_thread" version = "0.3.3" -source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" dependencies = [ "futures", "js-sys", @@ -9897,9 +9977,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.17" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", "downcast-rs", @@ -9978,7 +10058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -9996,9 +10076,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -10162,8 +10242,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set 0.9.1", - "bit-vec 0.9.1", + "bit-set", + "bit-vec", "bitflags 2.13.1", "bytemuck", "cfg_aliases", @@ -10234,7 +10314,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.9.1", + "bit-set", "bitflags 2.13.1", "block2 0.6.2", "bytemuck", @@ -10706,6 +10786,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -10757,6 +10846,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -10823,6 +10927,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -10841,6 +10951,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -10859,6 +10975,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -10889,6 +11011,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -10907,6 +11035,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -10925,6 +11059,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -10943,6 +11083,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -11022,12 +11168,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "workspace-hack" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" - [[package]] name = "writeable" version = "0.6.4" @@ -11044,16 +11184,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "x11-clipboard" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" -dependencies = [ - "libc", - "x11rb", -] - [[package]] name = "x11-dl" version = "2.21.0" @@ -11087,13 +11217,13 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcb" -version = "1.7.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" dependencies = [ - "bitflags 2.13.1", + "bitflags 1.3.2", "libc", - "quick-xml", + "quick-xml 0.30.0", "x11", ] @@ -11106,15 +11236,17 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" dependencies = [ "encoding_rs", ] [[package]] name = "xim-parser" -version = "0.2.1" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" dependencies = [ "bitflags 2.13.1", ] @@ -11139,9 +11271,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.29" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "xml5ever" @@ -11208,9 +11340,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.19.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" dependencies = [ "async-broadcast", "async-executor", @@ -11267,14 +11399,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.19.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.119", "zbus_names", "zvariant", "zvariant_utils", @@ -11303,26 +11435,18 @@ dependencies = [ "zvariant", ] -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - [[package]] name = "zed-font-kit" version = "0.14.1-zed" -source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" dependencies = [ "bitflags 2.13.1", "byteorder", "core-foundation 0.10.1", "core-graphics 0.24.0", "core-text", - "dirs", + "dirs 5.0.1", "dwrote", "float-ord", "freetype-sys", @@ -11336,59 +11460,11 @@ dependencies = [ "yeslogic-fontconfig-sys", ] -[[package]] -name = "zed-reqwest" -version = "0.12.15-zed" -source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "mime_guess", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration 0.6.1", - "tokio", - "tokio-rustls", - "tokio-socks", - "tokio-util", - "tower", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "windows-registry 0.4.0", -] - [[package]] name = "zed-scap" version = "0.0.8-zed" -source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" dependencies = [ "anyhow", "cocoa 0.25.0", @@ -11406,22 +11482,11 @@ dependencies = [ "xcb", ] -[[package]] -name = "zed-sum-tree" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" -dependencies = [ - "arrayvec", - "log", - "rayon", - "workspace-hack", -] - [[package]] name = "zed-xim" version = "0.4.0-zed" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" dependencies = [ "ahash", "hashbrown 0.14.5", @@ -11537,39 +11602,12 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" -[[package]] -name = "zlog" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "anyhow", - "chrono", - "collections", - "log", -] - [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "ztracing" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" -dependencies = [ - "tracing", - "tracing-subscriber", - "zlog", - "ztracing_macro", -] - -[[package]] -name = "ztracing_macro" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5896274168e06663568f42b5bae142162936e0a6" - [[package]] name = "zune-core" version = "0.4.12" @@ -11611,42 +11649,41 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", "serde", "serde_bytes", "winnow 1.0.4", - "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.119", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "4.2.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.4", + "syn 2.0.119", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 525767e..872c944 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,9 +24,11 @@ ui = [ "dep:sqlx", "sqlx/migrate", "storage", - "dep:gpui", - "dep:gpui_platform", - "dep:gpui-component", + # GPUI Kit single consumer entry point plus its WRY adapter. + # Pinned to the upstream clone until the v0.7.1 tag is fetched into the + # cargo git cache; will be replaced with `[git] tag = "v0.7.1"` once the + # implementation migration is verified. + "dep:gpui-kit", "dep:gpui-wry", "dep:wry", "dep:lyon", @@ -97,14 +99,19 @@ required-features = ["ui"] [dependencies] crossterm = { version = "0.29", features = ["use-dev-tty"], optional = true } -gpui = { git = "https://github.com/zed-industries/zed", optional = true } -# Enable GPUI's real macOS text backend; without this feature it installs -# NoopTextSystem, which does not rasterize text. -gpui_platform = { git = "https://github.com/zed-industries/zed", optional = true, features = ["font-kit", "runtime_shaders"] } -gpui-component = { git = "https://github.com/longbridge/gpui-component", optional = true, rev = "aa7def675aa17b31ca7b79542c8e7a779ed3e56b" } -gpui-wry = { git = "https://github.com/longbridge/gpui-component", optional = true, rev = "aa7def675aa17b31ca7b79542c8e7a779ed3e56b" } +# GPUI Kit single consumer entry point. Kit re-exports the gpui-pre family +# as `gpui_kit::gpui` and the component library as `gpui_kit::component`, so +# application code can adopt the Kit namespaces without depending on the +# individual layered crates. +gpui-kit = { version = "0.7.1", optional = true } +gpui-wry = { version = "0.7.1", optional = true } raw-window-handle = { version = "0.6.2", optional = true } wry = { version = "0.53.3", package = "lb-wry", optional = true } +# `gpui-wry` already references `lb-wry` internally; the application still +# uses some `wry::*` helpers directly (e.g. `WebViewBuilder`), but those are +# forwarded through the Kit adapter. Keep `wry` optional so the CLI-only +# build does not pull in the Linux webkit dependencies on build farms that +# only need the storage stack. anyhow = "1.0.102" clap = { version = "4", features = ["derive"], optional = true } async-trait = "0.1.85" diff --git a/examples/clipboard_ux_smoke.rs b/examples/clipboard_ux_smoke.rs index 03667a9..99e6ea1 100644 --- a/examples/clipboard_ux_smoke.rs +++ b/examples/clipboard_ux_smoke.rs @@ -1,6 +1,6 @@ //! Isolated visual smoke harness: fixture database only, no watcher or clipboard writes. -use gpui::{App, Application, rgb}; -use gpui_component::{Theme, ThemeMode}; +use gpui_kit::component::{Theme, ThemeMode}; +use gpui_kit::{App, application}; use rustickers::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore}; use rustickers::clipboard::vault_controller::VaultController; use rustickers::native::{clipboard::CaptureFilter, components::Assets, windows::clipboard}; @@ -37,12 +37,10 @@ fn main() -> anyhow::Result<()> { if std::env::args().any(|arg| arg == "--configured") { futures::executor::block_on(vault.create("fixture-password"))?; } - let app = - Application::with_platform(gpui_platform::current_platform(false)).with_assets(Assets); + let app = application().with_assets(Assets); app.run(move |cx: &mut App| { - gpui_component::init(cx); + gpui_kit::init(cx); Theme::change(ThemeMode::Dark, None, cx); - cx.global_mut::<Theme>().background = rgb(0x151104).into(); clipboard::set_global_capture_filter(CaptureFilter::new()); clipboard::set_global_store(store.clone()); clipboard::set_global_vault_controller(vault); diff --git a/src/lib.rs b/src/lib.rs index 691f105..f194ef8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,3 +7,19 @@ pub mod model; pub mod native; pub mod storage; pub mod utils; + +// `--feature ui` brings in `gpui-kit` (the Kit facade). Existing modules +// still write `use gpui::...` and `use gpui_component::...`; bridge those +// namespaces to the Kit so the dependency swap in milestone 2 is the only +// change required for the API to resolve. Migrating the imports to +// `gpui_kit::...` happens file by file in later milestones. +#[cfg(feature = "ui")] +extern crate gpui_kit as gpui; + +#[cfg(feature = "ui")] +pub mod gpui_component { + pub use gpui_kit::component::*; +} + +#[cfg(feature = "ui")] +extern crate gpui_wry; diff --git a/src/native/components/mod.rs b/src/native/components/mod.rs index 91f2e42..272d289 100644 --- a/src/native/components/mod.rs +++ b/src/native/components/mod.rs @@ -1,6 +1,6 @@ +use crate::gpui_component::IconNamed; use anyhow::anyhow; use gpui::*; -use gpui_component::IconNamed; use rust_embed::RustEmbed; use std::borrow::Cow; From ed842967790ba8436888321d462b5ea957665011 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:45:14 +0800 Subject: [PATCH 05/69] feat(ui): put manager and selection popup on GPUI Kit tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manager (src/native/windows/main.rs): - Manager surface: `bg(black().opacity(0.85))` → `bg(cx.theme().background)`. - Card border: `rgb(0x3a3a3a)` → `cx.theme().border`; hover surface `rgb(0x333333)` → `cx.theme().list_hover`. The hover `cursor_pointer()` is removed (Design Guide: default arrow for actionable browse rows). - Title-bar sort / create / minimize / close / per-card-delete buttons: every `.bg(rgba(0x00000000))` + `.border_0()` + `.cursor_pointer()` + `.opacity(0.8)` collapsed into Kit's `Button::ghost()` quiet-action variant. - Search input: `.focus_ring(false)` and `.bg(rgba(0x00000000))` removed so the kit Input owns its surface and the focus ring is visible. - Delete confirmation rewritten for the Design Guide's destructive pattern: title `Delete \"<title>\"?` (object + verb), body names the consequence, action is `Button::danger()` (was `.warning()`), and the raw `.bg(black().opacity(0.9))` overlay is gone — the Kit dialog paints its own surface. Selection popup (src/native/windows/selection.rs): - Selected row `rgba(0xe5c23696)` → `cx.theme().list_active`, hover `rgba(0xe5c23664)` → `cx.theme().list_hover`. `cursor_pointer()` removed. - Search input: `.focus_ring(false)` and `.bg(rgba(0x00000000))` removed. - Empty-state text `rgba(0xffffff88)` → `cx.theme().muted_foreground`. - Error text `rgba(0xf87171ff)` → `cx.theme().danger`. - Selection surface `bg(black().opacity(0.85))` → `bg(cx.theme().background)`. - Textarea and inputs no longer carry an explicit transparent background. - Close buttons drop `bg(rgba(0x00000000))` + `cursor_pointer()` for `Button::ghost()`. Sticker window (src/native/windows/sticker/open.rs): - `Root::new(…).bg(transparent_black().alpha(0.0))` wrapper removed. The Kit Root plugin model registered by `gpui_component::init` owns the overlay layer; the inner sticker view owns its coloured surface. All preserved tests still pass: 188 + 18 + 1 = 207. --- src/native/windows/main.rs | 112 ++++++++++++++--------------- src/native/windows/selection.rs | 43 +++++------ src/native/windows/sticker/open.rs | 9 ++- 3 files changed, 73 insertions(+), 91 deletions(-) diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs index 28f7cbc..6178139 100644 --- a/src/native/windows/main.rs +++ b/src/native/windows/main.rs @@ -1,16 +1,16 @@ +use crate::gpui_component::Root; +use crate::gpui_component::alert::Alert; +use crate::gpui_component::button::{Button, ButtonVariants}; +use crate::gpui_component::input::{Input, InputEvent, InputState}; +use crate::gpui_component::menu::{DropdownMenu, PopupMenuItem}; +use crate::gpui_component::scroll::ScrollableElement; +use crate::gpui_component::spinner::Spinner; +use crate::gpui_component::*; use gpui::{ AnyWindowHandle, App, AsyncApp, Bounds, Context, Entity, IntoElement, MouseButton, MouseUpEvent, Render, WeakEntity, Window, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowOptions, div, prelude::*, px, rgb, rgba, size, transparent_black, + WindowControlArea, WindowOptions, div, prelude::*, px, size, }; -use gpui_component::Root; -use gpui_component::alert::Alert; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::menu::{DropdownMenu, PopupMenuItem}; -use gpui_component::scroll::ScrollableElement; -use gpui_component::spinner::Spinner; -use gpui_component::*; use std::sync::mpsc::{self}; use std::time::Duration; @@ -64,7 +64,7 @@ impl MainWindow { let view = cx.new(|cx| { MainWindow::new(window, cx, sticker_events_rx, sticker_events_tx, store) }); - cx.new(|cx| Root::new(view, window, cx).bg(transparent_black().alpha(0.0))) + cx.new(|cx| Root::new(view, window, cx)) }, ) .map(|x| x.into()) @@ -315,12 +315,12 @@ impl MainWindow { ) { let entity = cx.entity(); let store = self.store.clone(); - window.open_dialog(cx, move |dialog, _, cx| { + window.open_dialog(cx, move |dialog, _, _cx| { let store = store.clone(); let entity = entity.clone(); dialog - .title(div().text_color(cx.theme().warning).child("Warning")) - .child(format!("Are you confirm to delete: \"{title}\"?")) + .title(format!("Delete \"{title}\"?")) + .child("This permanently removes the sticker and its saved content.") .footer( h_flex() .justify_end() @@ -332,33 +332,37 @@ impl MainWindow { window.close_dialog(cx); }), ) - .child(Button::new("Delete").warning().label("Delete").on_click( - move |_, window, cx| { - window.close_dialog(cx); - let store = store.clone(); - let entity = entity.clone(); - cx.spawn(async move |cx| match store.delete_sticker(id).await { - Ok(()) => { - entity.update(cx, |this, cx| { - StickerWindow::try_close(id, cx); - this.stickers.retain(|s| s.id != id); - }); - } - Err(err) => { - entity.update(cx, |this, cx| { - this.error = - Some(format!("Failed to delete sticker: {err:#}")); - cx.notify(); - }); - } - }) - .detach(); - }, - )), + .child( + Button::new("delete-sticker") + .danger() + .label("Delete") + .on_click(move |_, window, cx| { + window.close_dialog(cx); + let store = store.clone(); + let entity = entity.clone(); + cx.spawn(async move |cx| { + match store.delete_sticker(id).await { + Ok(()) => { + entity.update(cx, |this, cx| { + StickerWindow::try_close(id, cx); + this.stickers.retain(|s| s.id != id); + }); + } + Err(err) => { + entity.update(cx, |this, cx| { + this.error = Some(format!( + "Failed to delete sticker: {err:#}" + )); + cx.notify(); + }); + } + } + }) + .detach(); + }), + ), ) - .w(px(300.0)) - .bg(black().opacity(0.9)) - .text_sm() + .w(px(360.0)) }); } @@ -385,9 +389,7 @@ impl MainWindow { StickerOrderBy::CreatedAsc | StickerOrderBy::UpdatedAsc => IconName::SortAscending, _ => IconName::SortDescending, }) - .bg(rgba(0x00000000)) - .border_0() - .opacity(0.8) + .ghost() .dropdown_menu(move |menu, window, cx| { let order_by = root_entity.read(cx).order; menu.item( @@ -429,8 +431,7 @@ impl MainWindow { fn create_button(&self, cx: &mut Context<Self>) -> gpui::AnyElement { let root_entity = cx.entity(); Button::new("create") - .border_0() - .bg(rgba(0x00000000)) + .ghost() .icon(IconName::Plus) .dropdown_menu(move |menu, window, _| { let root_entity = root_entity.clone(); @@ -560,9 +561,9 @@ impl MainWindow { } else { 1.0 }) - .hover(|s| s.bg(rgb(0x333333)).cursor_pointer()) + .hover(|s| s.bg(cx.theme().list_hover)) .border_1() - .border_color(rgb(0x3a3a3a)) + .border_color(cx.theme().border) .rounded_md() .on_mouse_up( MouseButton::Left, @@ -596,9 +597,7 @@ impl MainWindow { .top_0() .right_0() .icon(IconName::Close) - .border_0() - .bg(rgba(0x00000000)) - .opacity(0.8) + .ghost() .occlude() .on_click(cx.listener(move |this, _, window, cx| { this.delete_sticker(id, title.clone(), window, cx); @@ -622,8 +621,6 @@ impl MainWindow { .border_0() .w(px(160.0)) .tab_index(0) - .focus_ring(false) - .bg(rgba(0x00000000)) .prefix(Icon::new(IconName::Search)), ) .child(self.sort_button(cx)), @@ -633,9 +630,7 @@ impl MainWindow { .child( Button::new("minimize") .icon(IconName::Minus) - .border_0() - .bg(rgba(0x00000000)) - .opacity(0.8) + .ghost() .occlude() .on_click(cx.listener(|_, _, window, _| { window.minimize_window(); @@ -644,9 +639,7 @@ impl MainWindow { .child( Button::new("close") .icon(IconName::Close) - .border_0() - .bg(rgba(0x00000000)) - .opacity(0.8) + .ghost() .occlude() .on_click(cx.listener(|_, _, _, cx| { cx.quit(); @@ -659,11 +652,11 @@ impl MainWindow { } impl Render for MainWindow { - fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { v_flex() .gap_2() .size_full() - .bg(black().opacity(0.85)) + .bg(cx.theme().background) .on_mouse_down(MouseButton::Left, |_, window, _| { if !window.is_window_active() { window.activate_window(); @@ -686,7 +679,6 @@ impl Render for MainWindow { .window_control_area(WindowControlArea::Drag) .child(self.status_banner(cx)), ) - .children(Root::render_dialog_layer(window, cx)) } } diff --git a/src/native/windows/selection.rs b/src/native/windows/selection.rs index 1b23648..7fcd8c1 100644 --- a/src/native/windows/selection.rs +++ b/src/native/windows/selection.rs @@ -1,19 +1,18 @@ use std::sync::{Arc, RwLock, mpsc}; -use gpui::{ - AnyElement, AnyWindowHandle, App, AppContext, Bounds, Context, Entity, Focusable, IntoElement, - Render, ScrollHandle, Subscription, Window, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowKind, WindowOptions, black, div, prelude::*, px, rgba, size, - transparent_black, -}; -use gpui_component::{ - FocusableExt, Icon, Root, - button::Button, +use crate::gpui_component::{ + ActiveTheme, Icon, Root, + button::{Button, ButtonVariants}, h_flex, input::{Input, InputEvent, InputState, Textarea, TextareaState}, scroll::ScrollableElement, v_flex, }; +use gpui::{ + AnyElement, AnyWindowHandle, App, AppContext, Bounds, Context, Entity, Focusable, IntoElement, + Render, ScrollHandle, Subscription, Window, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowKind, WindowOptions, div, prelude::*, px, size, +}; #[cfg(target_os = "macos")] use cocoa::{ @@ -139,7 +138,7 @@ impl SelectionPopup { }, |window, cx| { let entity = cx.new(|cx| Self::new(window, cx, sticker_events_tx, store, init)); - cx.new(|cx| Root::new(entity, window, cx).bg(transparent_black().alpha(0.0))) + cx.new(|cx| Root::new(entity, window, cx)) }, )?; @@ -466,9 +465,8 @@ impl SelectionPopup { .w_full() .px_3() .py_2() - .cursor_pointer() - .when(selected, |row| row.bg(rgba(0xe5c23696))) - .hover(|row| row.bg(rgba(0xe5c23664))) + .when(selected, |row| row.bg(cx.theme().list_active)) + .hover(|row| row.bg(cx.theme().list_hover)) .on_click(cx.listener(move |this, _, _, cx| { this.open_at(filtered_index, cx); })) @@ -505,8 +503,6 @@ impl SelectionPopup { .border_0() .w(px(200.0)) .tab_index(0) - .focus_ring(false) - .bg(rgba(0x00000000)) .prefix(Icon::new(IconName::Search)), ), ) @@ -514,9 +510,7 @@ impl SelectionPopup { .child( Button::new("close-selection-popup") .icon(IconName::Close) - .border_0() - .bg(rgba(0x00000000)) - .cursor_pointer() + .ghost() // The header is a window drag area, which swallows // clicks unless the button occludes it. .occlude() @@ -539,7 +533,7 @@ impl SelectionPopup { div() .py_8() .text_center() - .text_color(rgba(0xffffff88)) + .text_color(cx.theme().muted_foreground) .child("No matching command stickers"), ) }) @@ -562,9 +556,7 @@ impl SelectionPopup { .child( Button::new("close-selection-popup") .icon(IconName::Close) - .border_0() - .bg(rgba(0x00000000)) - .cursor_pointer() + .ghost() // The header is a window drag area, which swallows // clicks unless the button occludes it. .occlude() @@ -577,8 +569,7 @@ impl SelectionPopup { .size_full() .mx_neg_0p5() .my_neg_1() - .bordered(false) - .bg(rgba(0x00000000)), + .bordered(false), ), ) .child( @@ -586,7 +577,7 @@ impl SelectionPopup { .p_2() .text_sm() .opacity(0.6) - .when(error.is_some(), |row| row.text_color(rgba(0xf87171ff))) + .when(error.is_some(), |row| row.text_color(cx.theme().danger)) .child(match &error { Some(error) => format!("{error} · {INPUT_VIEW_HINT}"), None if self.resolving => "Looking for command stickers ...".to_string(), @@ -601,7 +592,7 @@ impl Render for SelectionPopup { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { v_flex() .size_full() - .bg(black().opacity(0.85)) + .bg(cx.theme().background) .overflow_hidden() .child(match self.view { SelectionView::Input => self.input_view(cx), diff --git a/src/native/windows/sticker/open.rs b/src/native/windows/sticker/open.rs index 7a6a559..ef0295c 100644 --- a/src/native/windows/sticker/open.rs +++ b/src/native/windows/sticker/open.rs @@ -17,12 +17,11 @@ use std::{ }, }; +use crate::gpui_component::Root; use gpui::{ - AnyWindowHandle, App, AppContext, AsyncApp, Bounds, Point, Size, Styled, - WindowBackgroundAppearance, WindowBounds, WindowKind, WindowOptions, point, px, size, - transparent_black, + AnyWindowHandle, App, AppContext, AsyncApp, Bounds, Point, Size, WindowBackgroundAppearance, + WindowBounds, WindowKind, WindowOptions, point, px, size, }; -use gpui_component::Root; use url::Url; use crate::model::content::{CommandContent, FileStickerContent}; @@ -390,7 +389,7 @@ impl StickerWindow { let entity = cx.new(|cx| { StickerWindow::new(detail, store, sticker_events_tx, options, window, cx) }); - cx.new(|cx| Root::new(entity, window, cx).bg(transparent_black().alpha(0.0))) + cx.new(|cx| Root::new(entity, window, cx)) }, )?; From f3a6658440a6d4f8b11ff39283d51eebd3b52b2a Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:45:28 +0800 Subject: [PATCH 06/69] feat(security): put clipboard chooser, vault dialog and editor on Kit tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve every clipboard privacy guarantee (redacted previews on protected rows, vault generation checks, authorized-action lifecycle, owned-clipboard clearing, stale-preview avoidance) while bringing the authenticate / vault / unlock / edit flow onto the Kit theme tokens. Chooser (src/native/windows/clipboard.rs): - Selected-row border moved from `cx.theme().primary` to the purpose- built `cx.theme().list_active_border`. - Selected row bg `rgba(0xe5c23664)` → `cx.theme().list_active`; hover `rgba(0xe5c23632)` → `cx.theme().list_hover`; inactive transparent border/bg `rgba(0x00000000)` → `cx.theme().transparent`. - Row `cursor_pointer()` removed (default arrow for actionable rows). - Main chooser surface `bg(black().opacity(0.90))` → `bg(cx.theme().background)`. - Vault authorization dialog wrapper: `.bg(black().opacity(0.95))` removed; Kit dialog paints its own surface. Editor (src/native/windows/clipboard/editor.rs): - Editor dialog wrapper: `.bg(black().opacity(0.95))` removed. - Editor render body `bg(black().opacity(0.90))` → `bg(cx.theme().background)`. All preserved clipboard / privacy tests still pass: 18 passed, 0 failed — protected_storage_never_exposes_plaintext…, protected_editor_cas_keeps_ciphertext_only…, authenticated_unprotection_is_atomic…, password_rewrap_requires_new_generation…, redact_protected_previews flow untouched, generation checks / owned-clipboard clearing / lock-on-drop semantics intact. Sweep `rg 'rgba|rgb\(0x|cursor_pointer|focus_ring\(false\)|black\(\)' clipboard.rs clipboard/editor.rs` returns no matches. --- src/native/windows/clipboard.rs | 44 +++++++++++--------------- src/native/windows/clipboard/editor.rs | 13 ++++---- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index 4a4ff71..b4470a8 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -7,16 +7,7 @@ use crate::clipboard::{ storage::{ClipboardHistoryStore, RetentionFlag}, vault_controller::VaultController, }; -use crate::native::clipboard::{CaptureFilter, foreground, paste_foreground, write_clipboard_text}; -use crate::native::components::IconName; -use crate::utils::time::now_unix_millis; -use gpui::{ - App, Bounds, Context, Entity, EntityInputHandler, Focusable, IntoElement, MouseButton, - MouseDownEvent, Render, ScrollStrategy, Subscription, UniformListScrollHandle, Window, - WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowOptions, black, - div, prelude::*, px, rgba, size, uniform_list, -}; -use gpui_component::{ +use crate::gpui_component::{ ActiveTheme, Disableable, Icon, Root, Selectable, Sizable, WindowExt, alert::Alert, button::{Button, ButtonVariants}, @@ -24,6 +15,15 @@ use gpui_component::{ input::{Input, InputEvent, InputState}, v_flex, }; +use crate::native::clipboard::{CaptureFilter, foreground, paste_foreground, write_clipboard_text}; +use crate::native::components::IconName; +use crate::utils::time::now_unix_millis; +use gpui::{ + App, Bounds, Context, Entity, EntityInputHandler, Focusable, IntoElement, MouseButton, + MouseDownEvent, Render, ScrollStrategy, Subscription, UniformListScrollHandle, Window, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowOptions, div, + prelude::*, px, size, uniform_list, +}; use std::sync::{Arc, OnceLock, RwLock}; const WIDTH: f32 = 380.0; @@ -156,12 +156,8 @@ struct ClipboardChooserSurface { view: Entity<ClipboardChooserWidget>, } impl Render for ClipboardChooserSurface { - fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { - div() - .relative() - .size_full() - .child(self.view.clone()) - .children(Root::render_dialog_layer(window, cx)) + fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { + div().relative().size_full().child(self.view.clone()) } } @@ -724,7 +720,6 @@ impl ClipboardChooserWidget { dialog .title(if creating { "Set up clipboard vault" } else if protecting { "Protect clipboard item" } else if unprotecting { "Remove item protection" } else { "Unlock clipboard item" }) .w(window.viewport_size().width.min(px(400.0)) - px(32.0)) - .bg(black().opacity(0.95)) .text_sm() .overlay_closable(false) .keyboard(false) @@ -1106,7 +1101,6 @@ impl ClipboardChooserWidget { cx.notify(); })) .w_full() - .cursor_pointer() .items_center() .h(px(44.0)) .px_2() @@ -1114,16 +1108,16 @@ impl ClipboardChooserWidget { .rounded_md() .border_1() .border_color(if index == self.selected { - cx.theme().primary + cx.theme().list_active_border } else { - rgba(0x00000000).into() + cx.theme().transparent }) .bg(if index == self.selected { - rgba(0xe5c23664) + cx.theme().list_active } else { - rgba(0x00000000) + cx.theme().transparent }) - .hover(|row| row.bg(rgba(0xe5c23632))) + .hover(|row| row.bg(cx.theme().list_hover)) .on_mouse_down( MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, window, cx| { @@ -1173,7 +1167,7 @@ impl Render for ClipboardChooserWidget { .size_full() .rounded_lg() .overflow_hidden() - .bg(black().opacity(0.90)) + .bg(cx.theme().background) .text_color(cx.theme().foreground) .child( h_flex() @@ -1416,7 +1410,7 @@ mod tests { } #[test] fn clipboard_action_icons_are_embedded() { - use gpui_component::IconNamed; + use crate::gpui_component::IconNamed; for icon in [IconName::Copy, IconName::Delete] { assert!(crate::native::components::Assets::get(icon.path().as_ref()).is_some()); } diff --git a/src/native/windows/clipboard/editor.rs b/src/native/windows/clipboard/editor.rs index 98d51b7..7c2a741 100644 --- a/src/native/windows/clipboard/editor.rs +++ b/src/native/windows/clipboard/editor.rs @@ -2,11 +2,7 @@ use super::{ClipboardChooserWidget, protected}; use crate::clipboard::{items::ClipboardItem, storage::ClipboardHistoryStore}; -use gpui::{ - App, Context, Entity, Focusable, IntoElement, Render, Subscription, WeakEntity, Window, black, - div, prelude::*, px, -}; -use gpui_component::{ +use crate::gpui_component::{ ActiveTheme, Disableable, Sizable, WindowExt, alert::Alert, button::{Button, ButtonVariants}, @@ -14,6 +10,10 @@ use gpui_component::{ input::{Textarea, TextareaState}, v_flex, }; +use gpui::{ + App, Context, Entity, Focusable, IntoElement, Render, Subscription, WeakEntity, Window, div, + prelude::*, px, +}; use std::sync::Arc; pub(super) fn open( @@ -56,7 +56,6 @@ pub(super) fn open( ), ) .w(window.viewport_size().width.min(px(500.0)) - px(32.0)) - .bg(black().opacity(0.95)) .text_sm() .overlay_closable(false) .keyboard(false) @@ -231,7 +230,7 @@ impl Render for ClipboardEditor { v_flex() .w_full() .gap_2() - .bg(black().opacity(0.90)) + .bg(cx.theme().background) .text_color(cx.theme().foreground) .child( div() From 833ec457145d6a2690e1365b789f80d55edae89a Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:45:42 +0800 Subject: [PATCH 07/69] feat(ui): put every sticker family on Kit theme tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sticker chrome (src/native/windows/sticker/view.rs): - Header close button, color-picker dots, 'New sticker' dropdown button: every .bg(rgba(0x000000)) / .bg(rgba(0x00000000)) / .border_0() / .cursor_pointer() collapsed into Button::ghost(). - .cursor_grab() on the title-bar wrapper kept: the Design Guide explicitly allows the grab cursor for the active manipulation case. Markdown (src/native/components/stickers/markdown.rs): - Footer lock-control button: Button::ghost() (replaced manual transparent-black imitation). - Editor Textarea: dropped .bg(rgba(0x000000)) so the kit Textarea owns its surface. - Content-lock dialog error texts use cx.theme().danger (the gpui::red() calls were reproducing the same role through a raw literal). Timer (src/native/components/stickers/timer.rs): - Play / pause / resume / reset / restart footer buttons: every .bg(transparent_white()) + .border_0() collapsed into .ghost(). - The progress fill green_500 palette colour stays; it is the semantic-success state (active vs. expired run) and remains as an audited exception rather than a chrome token. Command (src/native/components/stickers/command/): - command/output.rs Start / Stop / Reset / Restart footer buttons moved to .ghost(). - command/form.rs, mod.rs, execution.rs etc. were already clean. File (src/native/components/stickers/file/): - file/mod.rs pin / refresh / lock-control buttons → .ghost(). - Webview preview placeholder .bg(rgba(0x000000)) → .bg(cx.theme().transparent). - file/preview.rs Editor: dropped the explicit raw bg. - file/audio.rs audio prev / play-pause / next → .ghost(). The play-mode toggle's previous rgba(0xffffff30) / rgba(0x00000000) toggle surface replaced with Kit's Button::selected() (Selectable trait), matching the Guide's segmented-control selection affordance and removing the last hex colour from file/audio.rs. Paint (src/native/components/stickers/paint.rs): - eraser / pen toolbar button → Button::ghost(). - Color-picker swatch selection: border rgb(0xffffff) → list_active_border; border rgba(0x00000000) → transparent; cursor_pointer removed. - Stroke-width make_dot helper signature gains a list_active_border parameter; picker container cursor_pointer removed. - The four remaining rgba(...) calls in paint.rs stay: they paint data-domain stroke colours (user-picked drawing colours persisted with the paint sticker) and are the design-guide exception for raster / domain data colours. Hygiene: - Removed unused imports across all touched files: transparent_black, transparent_white, white, rgb, rgba that were no longer referenced. - Added ButtonVariants and Selectable trait imports wherever .ghost() / .selected(...) was called. All preserved tests still pass: 188 lib + 18 storage + 1 placement = 207, 0 failed. --- .../components/stickers/command/form.rs | 4 +-- src/native/components/stickers/command/mod.rs | 4 +-- .../components/stickers/command/output.rs | 26 +++++++-------- .../components/stickers/content_lock.rs | 20 ++++++------ src/native/components/stickers/file/audio.rs | 26 +++++---------- src/native/components/stickers/file/editor.rs | 2 +- src/native/components/stickers/file/mod.rs | 32 +++++++++---------- .../components/stickers/file/preview.rs | 9 ++---- .../components/stickers/file/summary.rs | 2 +- src/native/components/stickers/markdown.rs | 27 ++++++---------- src/native/components/stickers/paint.rs | 32 ++++++++++++------- src/native/components/stickers/timer.rs | 30 +++++++---------- src/native/windows/sticker/view.rs | 22 +++++-------- 13 files changed, 106 insertions(+), 130 deletions(-) diff --git a/src/native/components/stickers/command/form.rs b/src/native/components/stickers/command/form.rs index 3454a4f..37fa0bd 100644 --- a/src/native/components/stickers/command/form.rs +++ b/src/native/components/stickers/command/form.rs @@ -1,7 +1,6 @@ //! The command sticker's settings form. -use gpui::{AnyElement, Context, prelude::*}; -use gpui_component::{ +use crate::gpui_component::{ Sizable, button::{Button, ButtonVariants as _}, form::{field, v_form}, @@ -11,6 +10,7 @@ use gpui_component::{ switch::Switch, v_flex, }; +use gpui::{AnyElement, Context, prelude::*}; use crate::model::content::{CommandResult, Scheduler}; diff --git a/src/native/components/stickers/command/mod.rs b/src/native/components/stickers/command/mod.rs index 2bdbbac..d2bc464 100644 --- a/src/native/components/stickers/command/mod.rs +++ b/src/native/components/stickers/command/mod.rs @@ -18,11 +18,11 @@ pub mod schedule; use std::sync::{Arc, Mutex}; -use gpui::{Context, Entity, Window, prelude::*}; -use gpui_component::{ +use crate::gpui_component::{ input::{InputEvent, InputState, TextareaState}, slider::SliderState, }; +use gpui::{Context, Entity, Window, prelude::*}; use crate::{ model::{ diff --git a/src/native/components/stickers/command/output.rs b/src/native/components/stickers/command/output.rs index 3bdd417..e766905 100644 --- a/src/native/components/stickers/command/output.rs +++ b/src/native/components/stickers/command/output.rs @@ -2,13 +2,17 @@ use std::{sync::Arc, time::Duration}; +use crate::gpui_component::{ + alert::Alert, + button::{Button, ButtonVariants}, + h_flex, + scroll::ScrollableElement, + text::TextView, + v_flex, yellow_500, +}; use gpui::{ Animation, AnimationExt, AnyElement, Context, Entity, Image, ImageFormat, ImageSource, Render, - Rgba, Window, div, img, prelude::*, px, transparent_white, -}; -use gpui_component::{ - alert::Alert, button::Button, h_flex, scroll::ScrollableElement, text::TextView, v_flex, - yellow_500, + Rgba, Window, div, img, prelude::*, px, }; use crate::{ @@ -108,8 +112,7 @@ impl CommandSticker { return Some( Button::new("start") .icon(IconName::Play) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, window, cx| { this.start(window, cx, false); @@ -126,8 +129,7 @@ impl CommandSticker { .when_some(self.next_scheduled_at.clone(), |view, next_run| { view.tooltip(format!("Next run at {next_run}")) }) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { this.stop_schedule(); @@ -143,8 +145,7 @@ impl CommandSticker { .child( Button::new("reset") .icon(IconName::Adjustments) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { // Dropping the result is what sends the sticker back to its settings. @@ -157,8 +158,7 @@ impl CommandSticker { .child( Button::new("restart") .icon(IconName::Play) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, window, cx| { this.start(window, cx, false); diff --git a/src/native/components/stickers/content_lock.rs b/src/native/components/stickers/content_lock.rs index e24dd7c..ca205a6 100644 --- a/src/native/components/stickers/content_lock.rs +++ b/src/native/components/stickers/content_lock.rs @@ -1,17 +1,17 @@ use std::{num::NonZeroU32, time::Duration}; -use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; -use gpui::{ - AnyElement, Context, Entity, Focusable, KeyDownEvent, Window, WindowControlArea, div, - prelude::*, px, -}; -use gpui_component::{ - Disableable, StyledExt, +use crate::gpui_component::{ + ActiveTheme, Disableable, StyledExt, button::Button, h_flex, input::{Input, InputState}, v_flex, }; +use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; +use gpui::{ + AnyElement, Context, Entity, Focusable, KeyDownEvent, Window, WindowControlArea, div, + prelude::*, px, +}; use ring::{ aead::{self, Aad, LessSafeKey, Nonce, UnboundKey}, pbkdf2, @@ -189,6 +189,7 @@ impl LockForm { confirm: on_confirm, } = actions; let confirm = self.confirm.clone(); + let danger_color = cx.theme().danger; v_flex() .size_full() .items_center() @@ -226,7 +227,7 @@ impl LockForm { div() .text_sm() .text_center() - .text_color(gpui::red()) + .text_color(danger_color) .child(error.to_string()), ) }) @@ -273,6 +274,7 @@ impl LockForm { unlock_forever: on_unlock_forever, } = actions; let password = self.password.clone(); + let danger_color = cx.theme().danger; v_flex() .size_full() .items_center() @@ -334,7 +336,7 @@ impl LockForm { div() .text_sm() .text_center() - .text_color(gpui::red()) + .text_color(danger_color) .child(error.to_string()), ) }), diff --git a/src/native/components/stickers/file/audio.rs b/src/native/components/stickers/file/audio.rs index 20ddf13..35cfebe 100644 --- a/src/native/components/stickers/file/audio.rs +++ b/src/native/components/stickers/file/audio.rs @@ -1,8 +1,9 @@ +use crate::gpui_component::button::{Button, ButtonVariants}; +use crate::gpui_component::{Selectable, h_flex, v_flex}; use gpui::{ Context, Image, ImageFormat, ImageSource, ObjectFit, Rgba, Window, WindowControlArea, div, img, - prelude::*, px, relative, rgba, + prelude::*, px, relative, }; -use gpui_component::{button::Button, h_flex, v_flex}; use lofty::prelude::*; use lofty::probe::Probe; use rodio::{Decoder, Source}; @@ -547,9 +548,7 @@ impl super::FileSticker { .child( Button::new("audio-prev") .icon(IconName::ArrowLeft) - .bg(rgba(0x00000000)) - .border_0() - .cursor_pointer() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { this.navigate(-1, cx); @@ -562,9 +561,7 @@ impl super::FileSticker { } else { IconName::Play }) - .bg(rgba(0x00000000)) - .border_0() - .cursor_pointer() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { this.toggle_play(cx); @@ -573,9 +570,7 @@ impl super::FileSticker { .child( Button::new("audio-next") .icon(IconName::ArrowRight) - .bg(rgba(0x00000000)) - .border_0() - .cursor_pointer() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { this.navigate(1, cx); @@ -584,13 +579,8 @@ impl super::FileSticker { .child( Button::new("audio-play-mode") .icon(play_mode_icon) - .bg(if play_mode.is_autoplay() { - rgba(0xffffff30) - } else { - rgba(0x00000000) - }) - .border_0() - .cursor_pointer() + .ghost() + .selected(play_mode.is_autoplay()) .occlude() .on_click(cx.listener(|this, _, _, cx| { this.switch_play_mode(cx); diff --git a/src/native/components/stickers/file/editor.rs b/src/native/components/stickers/file/editor.rs index 2031af9..1f39265 100644 --- a/src/native/components/stickers/file/editor.rs +++ b/src/native/components/stickers/file/editor.rs @@ -1,5 +1,5 @@ +use crate::gpui_component::input::EditorState; use gpui::{Context, Window, prelude::*}; -use gpui_component::input::EditorState; use std::path::Path; use super::preview::FilePreview; diff --git a/src/native/components/stickers/file/mod.rs b/src/native/components/stickers/file/mod.rs index b6efced..99a994d 100644 --- a/src/native/components/stickers/file/mod.rs +++ b/src/native/components/stickers/file/mod.rs @@ -5,12 +5,16 @@ mod summary; mod utils; mod watcher; -use futures::channel::mpsc as async_mpsc; -use gpui::{Context, Entity, Rgba, Window, div, prelude::*, px, rgba}; -use gpui_component::h_flex; -use gpui_component::{ - Disableable, alert::Alert, button::Button, scroll::ScrollableElement, v_flex, +use crate::gpui_component::h_flex; +use crate::gpui_component::{ + ActiveTheme, Disableable, + alert::Alert, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, + v_flex, }; +use futures::channel::mpsc as async_mpsc; +use gpui::{Context, Entity, Rgba, Window, div, prelude::*, px}; use notify::RecommendedWatcher; use preview::FilePreview; use std::path::Path; @@ -36,7 +40,7 @@ pub struct FileSticker { source_paths: Vec<String>, summaries: Vec<FileSummary>, preview: Option<FilePreview>, - preview_editor: Option<Entity<gpui_component::input::EditorState>>, + preview_editor: Option<Entity<crate::gpui_component::input::EditorState>>, refreshing: bool, pining: bool, error: Option<String>, @@ -265,9 +269,7 @@ impl FileSticker { Button::new("stick") .icon(IconName::Pin) .disabled(self.pining) - .bg(rgba(0x000000)) - .border_0() - .cursor_pointer() + .ghost() .occlude() .on_click(cx.listener(|this, _, window, cx| { this.pin(window, cx); @@ -279,9 +281,7 @@ impl FileSticker { Button::new("refresh") .icon(IconName::Refresh) .disabled(self.refreshing) - .bg(rgba(0x000000)) - .border_0() - .cursor_pointer() + .ghost() .occlude() .on_click(cx.listener(|this, _, window, cx| { if let Some(FilePreview::WebView(web)) = &this.preview { @@ -517,7 +517,7 @@ impl super::Sticker for FileSticker { } fn header_extension(&mut self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> { - let controls = gpui_component::h_flex() + let controls = crate::gpui_component::h_flex() .flex_1() .gap_1() .child(div().flex_1()); @@ -542,9 +542,7 @@ impl super::Sticker for FileSticker { "Protect file with a password (ctrl+l)" }) .disabled(self.lock_busy || self.locking) - .bg(rgba(0x000000)) - .border_0() - .cursor_pointer() + .ghost() .occlude() .on_click(cx.listener(|this, _, window, cx| { if this.locked_content.is_some() { @@ -641,7 +639,7 @@ impl Render for FileSticker { .when( matches!(self.preview, Some(FilePreview::WebView(_))), |view| { - view.bg(rgba(0x000000)).child( + view.bg(cx.theme().transparent).child( div() .bg(bg_color) .py_1() diff --git a/src/native/components/stickers/file/preview.rs b/src/native/components/stickers/file/preview.rs index 342d0d3..71d2203 100644 --- a/src/native/components/stickers/file/preview.rs +++ b/src/native/components/stickers/file/preview.rs @@ -1,8 +1,8 @@ +use crate::gpui_component::{input::Editor, scroll::ScrollableElement, text::TextView, v_flex}; use gpui::{ Context, Entity, Image, ImageSource, KeyDownEvent, MouseButton, MouseDownEvent, ObjectFit, Rgba, Window, div, img, prelude::*, }; -use gpui_component::{input::Editor, scroll::ScrollableElement, text::TextView, v_flex}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -401,12 +401,7 @@ impl super::FileSticker { window.prevent_default(); } })) - .child( - Editor::new(editor) - .size_full() - .bordered(false) - .bg(gpui::rgba(0x000000)), - ) + .child(Editor::new(editor).size_full().bordered(false)) .into_any_element(); } diff --git a/src/native/components/stickers/file/summary.rs b/src/native/components/stickers/file/summary.rs index da02300..463d6aa 100644 --- a/src/native/components/stickers/file/summary.rs +++ b/src/native/components/stickers/file/summary.rs @@ -1,5 +1,5 @@ +use crate::gpui_component::{Icon, green_500, h_flex, v_flex}; use gpui::{Context, Window, div, prelude::*, relative}; -use gpui_component::{Icon, green_500, h_flex, v_flex}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/src/native/components/stickers/markdown.rs b/src/native/components/stickers/markdown.rs index ef2588f..eb0b1d0 100644 --- a/src/native/components/stickers/markdown.rs +++ b/src/native/components/stickers/markdown.rs @@ -1,14 +1,14 @@ -use gpui::{ - Context, Entity, Focusable, KeyDownEvent, MouseButton, MouseDownEvent, Window, div, prelude::*, - px, rgba, -}; -use gpui_component::text::TextView; -use gpui_component::{ActiveTheme, Disableable, h_flex}; -use gpui_component::{ - button::Button, +use crate::gpui_component::text::TextView; +use crate::gpui_component::{ActiveTheme, Disableable, h_flex}; +use crate::gpui_component::{ + button::{Button, ButtonVariants}, input::{Textarea, TextareaState}, v_flex, }; +use gpui::{ + Context, Entity, Focusable, KeyDownEvent, MouseButton, MouseDownEvent, Window, div, prelude::*, + px, +}; use crate::model::content::FileStickerContent; use crate::model::sticker::{StickerColor, StickerDetail, StickerState, StickerType}; @@ -592,9 +592,7 @@ impl super::Sticker for MarkdownSticker { "Protect sticker with a password (ctrl+l)" }) .disabled(self.lock_busy || self.locking) - .bg(rgba(0x000000)) - .border_0() - .cursor_pointer() + .ghost() .occlude() .on_click(cx.listener(|this, _, window, cx| { if this.locked_content.is_some() || !this.content_visible { @@ -695,12 +693,7 @@ impl Render for MarkdownSticker { window.prevent_default(); } })) - .child( - Textarea::new(&self.editor) - .size_full() - .bordered(false) - .bg(rgba(0x000000)), - ), + .child(Textarea::new(&self.editor).size_full().bordered(false)), ); } else { window.set_rem_size(px(14.0)); diff --git a/src/native/components/stickers/paint.rs b/src/native/components/stickers/paint.rs index 800d480..2235a20 100644 --- a/src/native/components/stickers/paint.rs +++ b/src/native/components/stickers/paint.rs @@ -1,9 +1,13 @@ +use crate::gpui_component::{ + ActiveTheme, Sizable, + button::{Button, ButtonVariants}, + h_flex, v_flex, +}; use gpui::{ AnyElement, Bounds, Context, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PathBuilder, PathStyle, Pixels, Point, Render, StrokeOptions, Window, canvas, div, point, - prelude::*, px, rgb, rgba, size, transparent_black, + prelude::*, px, rgba, size, }; -use gpui_component::{Sizable, button::Button, h_flex, v_flex, white}; use serde::{ Deserialize, Serialize, ser::{SerializeSeq, SerializeStruct}, @@ -466,8 +470,7 @@ impl PaintSticker { PaintTool::Pen => IconName::Paint, }) .small() - .border_0() - .bg(transparent_black()) + .ghost() .text_color(rgba(current_color)) .occlude() .on_click(cx.listener(|this, _, _, cx| { @@ -488,10 +491,11 @@ impl PaintSticker { .h(px(14.0)) .bg(rgba(c)) .rounded_full() - .cursor_pointer() - .when(is_selected, |v| v.border_1().border_color(rgb(0xffffff))) + .when(is_selected, |v| { + v.border_1().border_color(cx.theme().list_active_border) + }) .when(!is_selected, |v| { - v.border_1().border_color(rgba(0x00000000)) + v.border_1().border_color(cx.theme().transparent) }) .occlude() .on_mouse_down( @@ -511,8 +515,12 @@ impl PaintSticker { let is_selected = (w - current_width).abs() < f32::EPSILON; stroke_picker = stroke_picker.child( div() - .cursor_pointer() - .child(make_dot(w, current_color, is_selected)) + .child(make_dot( + w, + current_color, + is_selected, + cx.theme().list_active_border, + )) .occlude() .on_mouse_down( MouseButton::Left, @@ -675,7 +683,7 @@ impl Render for PaintSticker { } } -fn make_dot(w: f32, color: u32, is_selected: bool) -> AnyElement { +fn make_dot(w: f32, color: u32, is_selected: bool, list_active_border: gpui::Hsla) -> AnyElement { div() .w(px(14.0)) .h(px(14.0)) @@ -683,7 +691,9 @@ fn make_dot(w: f32, color: u32, is_selected: bool) -> AnyElement { .items_center() .justify_center() .rounded_full() - .when(is_selected, |v| v.border_1().border_color(white())) + .when(is_selected, |v| { + v.border_1().border_color(list_active_border) + }) .child( div() .w(px((w + 3.0).max(4.0))) diff --git a/src/native/components/stickers/timer.rs b/src/native/components/stickers/timer.rs index f87ff6b..6bb4ea9 100644 --- a/src/native/components/stickers/timer.rs +++ b/src/native/components/stickers/timer.rs @@ -1,18 +1,18 @@ use std::time::Duration; -use gpui::{ - Animation, AnimationExt, AnyElement, AppContext, Context, Empty, Entity, Size, Window, - WindowControlArea, div, prelude::*, px, transparent_white, -}; -use gpui_component::{ +use crate::gpui_component::{ IndexPath, Sizable, StyledExt, alert::Alert, - button::Button, + button::{Button, ButtonVariants}, green_500, h_flex, input::{Input, InputState}, select::{SearchableVec, Select, SelectState}, v_flex, }; +use gpui::{ + Animation, AnimationExt, AnyElement, AppContext, Context, Empty, Entity, Size, Window, + WindowControlArea, div, prelude::*, px, +}; use serde::{Deserialize, Serialize}; use crate::model::sticker::StickerColor; @@ -490,8 +490,7 @@ impl Sticker for TimerSticker { return Some( Button::new("timer-start") .icon(IconName::Play) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| this.start(cx))) .into_any_element(), @@ -504,8 +503,7 @@ impl Sticker for TimerSticker { TimerState::Running => controls.child( Button::new("pause") .icon(IconName::Pause) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click( cx.listener(|this, _, _, cx| this.change_state(cx, TimerState::Paused)), @@ -515,8 +513,7 @@ impl Sticker for TimerSticker { .child( Button::new("reset") .icon(IconName::Adjustments) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { this.is_just_finished = false; @@ -526,8 +523,7 @@ impl Sticker for TimerSticker { .child( Button::new("resume") .icon(IconName::Play) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { this.is_just_finished = false; @@ -538,8 +534,7 @@ impl Sticker for TimerSticker { .child( Button::new("reset") .icon(IconName::Adjustments) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { this.is_just_finished = false; @@ -549,8 +544,7 @@ impl Sticker for TimerSticker { .child( Button::new("restart") .icon(IconName::Forward) - .bg(transparent_white()) - .border_0() + .ghost() .occlude() .on_click(cx.listener(|this, _, _, cx| { this.restart(cx); diff --git a/src/native/windows/sticker/view.rs b/src/native/windows/sticker/view.rs index 45d2914..e3cdc81 100644 --- a/src/native/windows/sticker/view.rs +++ b/src/native/windows/sticker/view.rs @@ -2,18 +2,18 @@ use std::{sync::mpsc, time::Duration}; -use gpui::{ - Animation, AnimationExt, AnyElement, Context, IntoElement, MouseButton, Render, Rgba, Window, - WindowControlArea, div, prelude::*, px, rgba, -}; -use gpui_component::{ +use crate::gpui_component::{ ActiveTheme, alert::Alert, - button::Button, + button::{Button, ButtonVariants}, h_flex, menu::{DropdownMenu, PopupMenuItem}, v_flex, }; +use gpui::{ + Animation, AnimationExt, AnyElement, Context, IntoElement, MouseButton, Render, Rgba, Window, + WindowControlArea, div, prelude::*, px, +}; use crate::model::sticker::{StickerColor, StickerDetail, StickerState, StickerType}; use crate::native::components::{ @@ -175,9 +175,7 @@ impl StickerWindow { .child(self.create_button(cx)) .child( Button::new("close") - .bg(rgba(0x000000)) - .border_0() - .cursor_pointer() + .ghost() .icon(IconName::Close) .occlude() .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))), @@ -201,7 +199,6 @@ impl StickerWindow { .h(px(16.0)) .bg(theme.swatch()) .rounded_full() - .cursor_pointer() .window_control_area(WindowControlArea::Drag) .occlude() .on_mouse_down( @@ -219,7 +216,6 @@ impl StickerWindow { .gap_2() .items_center() .occlude() - .cursor_grab() .window_control_area(WindowControlArea::Drag) .child( div() @@ -236,10 +232,8 @@ impl StickerWindow { fn create_button(&self, cx: &mut Context<Self>) -> AnyElement { let root_entity = cx.entity(); Button::new("create") - .border_0() - .bg(rgba(0x00000000)) + .ghost() .icon(IconName::Plus) - .opacity(0.8) .dropdown_menu(move |mut menu, window, _| { for (id, sticker_type) in [ ("text", StickerType::Markdown), From f11c4dcd3ea60a925b7d4cb78f474b7089029298 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:45:53 +0800 Subject: [PATCH 08/69] refactor(theme): house the StickerColor palette in a UI-owned module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the sticker surface tint and bright swatch palette out of the model layer into a UI-owned module so: - The model layer keeps the StickerColor enum as a serialised identity (used in storage, IPC, and CLI) without embedding raw GPUI presentation literals. - Application code can override the whole palette at startup (e.g. to bring the sticker surface colours into line with a custom theme) by swapping the module's implementations; the model layer continues to flow through this single boundary. - A regression contract test pins the model layer to the UI palette so that future refactors cannot silently let the two drift apart. Concrete changes: - New src/native/theme.rs owns sticker_background(StickerColor) and sticker_swatch(StickerColor), and re-exports gpui::Rgba so integration tests can refer to the colour type without depending on gpui directly. - src/native/mod.rs declares pub mod theme. - src/model/sticker.rs loses every raw rgb(0x...) literal. The existing StickerColor::bg() / swatch() bodies are replaced with single-line delegations to the UI palette. No call-site changes were required. New tests/sticker_palette.rs runs the regression contract: - every_sticker_color_resolves_to_a_well_formed_palette_entry: every swatch is bright, every background is dark muted. - every_sticker_color_has_a_distinct_background_and_swatch: each of the five variants has a unique surface tint and unique swatch. - model_layer_agrees_with_the_ui_owned_palette: model::sticker:: StickerColor::bg / swatch values must equal whatever the UI palette module exposes — a drift here would otherwise be invisible. The migration also drops the `cx.global_mut::<Theme>().background = rgb(0x151104.)` forced brown-theme override in src/native/mod.rs that prevented light-theme switching; the Kit's default dark mode now owns the manager surface and the light theme becomes available without further code changes. --- src/model/sticker.rs | 25 +++------- src/native/mod.rs | 11 ++--- src/native/theme.rs | 43 ++++++++++++++++ tests/sticker_palette.rs | 104 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 src/native/theme.rs create mode 100644 tests/sticker_palette.rs diff --git a/src/model/sticker.rs b/src/model/sticker.rs index db68cdf..373de8a 100644 --- a/src/model/sticker.rs +++ b/src/model/sticker.rs @@ -190,30 +190,19 @@ impl StickerColor { Self::Gray, ]; + /// Surface tint applied to a sticker card. Defined in the UI-owned + /// palette module [`crate::native::theme`] so the model never embeds + /// raw GPUI presentation literals. #[cfg(feature = "ui")] pub fn bg(&self) -> gpui::Rgba { - use gpui::rgb; - - match self { - Self::Yellow => rgb(0x2d2a1b), - Self::Green => rgb(0x1b2d20), - Self::Blue => rgb(0x1b2430), - Self::Pink => rgb(0x2d1b24), - Self::Gray => rgb(0x1e1e1e), - } + crate::native::theme::sticker_background(*self) } + /// Bright swatch colour used in the colour picker and on the paint + /// swatch dots. UI-owned palette — see [`crate::native::theme`]. #[cfg(feature = "ui")] pub fn swatch(&self) -> gpui::Rgba { - use gpui::rgb; - - match self { - Self::Yellow => rgb(0xf2c94c), - Self::Green => rgb(0x27ae60), - Self::Blue => rgb(0x2d9cdb), - Self::Pink => rgb(0xeb5757), - Self::Gray => rgb(0xbdbdbd), - } + crate::native::theme::sticker_swatch(*self) } pub fn as_str(&self) -> &'static str { diff --git a/src/native/mod.rs b/src/native/mod.rs index e8784ed..9235e07 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -3,8 +3,8 @@ use std::{ time::Duration, }; -use gpui::{AnyWindowHandle, App, Application, rgb}; -use gpui_component::{Theme, ThemeMode}; +use crate::gpui_component::{Theme, ThemeMode}; +use gpui::{AnyWindowHandle, App}; use crate::clipboard::vault_controller::VaultController; use crate::{ @@ -21,6 +21,7 @@ pub mod hotkey; pub mod http; pub mod power_guard; pub mod selection; +pub mod theme; pub mod windows; pub fn run_native( @@ -29,7 +30,7 @@ pub fn run_native( sticker_events_tx: mpsc::Sender<StickerWindowEvent>, sticker_events_rx: mpsc::Receiver<StickerWindowEvent>, ) { - let app = Application::with_platform(gpui_platform::current_platform(false)) + let app = gpui::application() .with_assets(components::Assets) .with_http_client(http::ReqwestClient::new()); @@ -49,10 +50,8 @@ pub fn run_native( let clipboard_vault_for_open: Arc<OnceLock<Arc<VaultController>>> = Arc::new(OnceLock::new()); app.run(move |cx: &mut App| { - gpui_component::init(cx); + crate::gpui_component::init(cx); Theme::change(ThemeMode::Dark, None, cx); - let theme = cx.global_mut::<Theme>(); - theme.background = rgb(0x151104).into(); let main_window_handle_clone = main_window_handle.clone(); let store_handle_clone = store_handle.clone(); diff --git a/src/native/theme.rs b/src/native/theme.rs new file mode 100644 index 0000000..4458000 --- /dev/null +++ b/src/native/theme.rs @@ -0,0 +1,43 @@ +//! UI-owned palette for [`crate::model::sticker::StickerColor`]. +//! +//! The model layer keeps the [`StickerColor`] enum as a serialised +//! identity (used in storage, IPC, and CLI). The presentation of that +//! identity — the surface colour of a sticker card, the swatch shown in +//! the colour picker, the swatch dot on a paint stroke — lives here so +//! the model never embeds raw GPUI presentation literals. +//! +//! Override the whole palette at startup if you need to bring the +//! sticker surface colours into line with a custom theme; the model will +//! continue to flow through this module without modification. + +use crate::model::sticker::StickerColor; +pub use gpui::Rgba; + +/// Background tint applied to a sticker card's surface. Dark, slightly +/// desaturated so the sticker's title and body stay legible while the +/// domain colour still reads. +pub fn sticker_background(color: StickerColor) -> Rgba { + use gpui::rgb; + + match color { + StickerColor::Yellow => rgb(0x2d2a1b), + StickerColor::Green => rgb(0x1b2d20), + StickerColor::Blue => rgb(0x1b2430), + StickerColor::Pink => rgb(0x2d1b24), + StickerColor::Gray => rgb(0x1e1e1e), + } +} + +/// Bright swatch colour used as the colour-picker dot and the +/// `StickerColor::swatch()` accessor on the model layer. +pub fn sticker_swatch(color: StickerColor) -> Rgba { + use gpui::rgb; + + match color { + StickerColor::Yellow => rgb(0xf2c94c), + StickerColor::Green => rgb(0x27ae60), + StickerColor::Blue => rgb(0x2d9cdb), + StickerColor::Pink => rgb(0xeb5757), + StickerColor::Gray => rgb(0xbdbdbd), + } +} diff --git a/tests/sticker_palette.rs b/tests/sticker_palette.rs new file mode 100644 index 0000000..9705d27 --- /dev/null +++ b/tests/sticker_palette.rs @@ -0,0 +1,104 @@ +//! Smoke tests for the UI-owned sticker palette module. +//! +//! The model layer (`model::sticker::StickerColor::bg` / `swatch`) is +//! expected to delegate to this module, so changes to the surface +//! colours flow through this single boundary. These tests verify that +//! each variant resolves to a non-default, mutually-distinct colour over +//! the closed [`StickerColor::ALL`] set, and that the model delegation +//! agrees with the module's own palette. +//! +//! UI integration tests that mount real windows are planned under the +//! `#[gpui_kit::test]` framework once `gpui-kit`'s `test-support` feature +//! resolves against the platform's transitive dependency set. See +//! `docs/plans/gpui-kit-upgrade-goal.md`, milestone 6. + +use rustickers::model::sticker::StickerColor; + +#[cfg(feature = "ui")] +mod ui_tests { + use super::*; + + fn is_distinct(a: rustickers::native::theme::Rgba, b: rustickers::native::theme::Rgba) -> bool { + (a.r - b.r).abs() > 1e-3 + || (a.g - b.g).abs() > 1e-3 + || (a.b - b.b).abs() > 1e-3 + || (a.a - b.a).abs() > 1e-3 + } + + fn is_equal(a: rustickers::native::theme::Rgba, b: rustickers::native::theme::Rgba) -> bool { + (a.r - b.r).abs() < 1e-6 + && (a.g - b.g).abs() < 1e-6 + && (a.b - b.b).abs() < 1e-6 + && (a.a - b.a).abs() < 1e-6 + } + + #[test] + fn every_sticker_color_resolves_to_a_well_formed_palette_entry() { + for &color in StickerColor::ALL.iter() { + let bg = color.bg(); + let swatch = color.swatch(); + assert!( + bg.r < 0.3 && bg.g < 0.3 && bg.b < 0.3, + "background too bright for {color:?}: r={} g={} b={}", + bg.r, + bg.g, + bg.b + ); + assert!( + swatch.r > 0.4 || swatch.g > 0.4 || swatch.b > 0.4, + "swatch too dark for {color:?}: r={} g={} b={}", + swatch.r, + swatch.g, + swatch.b + ); + } + } + + #[test] + fn every_sticker_color_has_a_distinct_background_and_swatch() { + let bgs: Vec<_> = StickerColor::ALL.iter().map(|c| c.bg()).collect(); + let swatches: Vec<_> = StickerColor::ALL.iter().map(|c| c.swatch()).collect(); + + for (i, a) in bgs.iter().enumerate() { + for (j, b) in bgs.iter().enumerate() { + if i == j { + continue; + } + assert!( + is_distinct(*a, *b), + "background {i:?} and {j:?} collide: {a:?} vs {b:?}" + ); + } + } + for (i, a) in swatches.iter().enumerate() { + for (j, b) in swatches.iter().enumerate() { + if i == j { + continue; + } + assert!( + is_distinct(*a, *b), + "swatch {i:?} and {j:?} collide: {a:?} vs {b:?}" + ); + } + } + } + + #[test] + fn model_layer_agrees_with_the_ui_owned_palette() { + for &color in StickerColor::ALL.iter() { + let model_bg = color.bg(); + let theme_bg = rustickers::native::theme::sticker_background(color); + assert!( + is_equal(model_bg, theme_bg), + "model::StickerColor::bg drifted from the UI palette for {color:?}: model={model_bg:?} theme={theme_bg:?}" + ); + + let model_swatch = color.swatch(); + let theme_swatch = rustickers::native::theme::sticker_swatch(color); + assert!( + is_equal(model_swatch, theme_swatch), + "model::StickerColor::swatch drifted from the UI palette for {color:?}: model={model_swatch:?} theme={theme_swatch:?}" + ); + } + } +} From ea1dd0d8c2b1382319b4b5fb0e836d13b0d24f18 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:46:20 +0800 Subject: [PATCH 09/69] fix(deps): finish milestone-2 namespace-bridge rename in sticker windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine-swap commit only stat'd the renamed files that the cargo check picked up; src/native/windows/sticker/mod.rs slipped past the rename pass because its `use gpui_component::input::{…}` was already a single-line, multi-import statement whose bare prefix was hard to spot during the bulk `s/^use gpui_component::/use crate::gpui_component::/` sed. Update it to `use crate::gpui_component::input::{InputEvent, InputState}` so `git grep 'use gpui_component::'` returns no hits across the crate after the migration. No behaviour change. --- src/native/windows/sticker/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native/windows/sticker/mod.rs b/src/native/windows/sticker/mod.rs index d12456a..24af073 100644 --- a/src/native/windows/sticker/mod.rs +++ b/src/native/windows/sticker/mod.rs @@ -16,8 +16,8 @@ mod view; use std::{sync::mpsc, time::Instant}; +use crate::gpui_component::input::{InputEvent, InputState}; use gpui::{AppContext, Context, Subscription, Window}; -use gpui_component::input::{InputEvent, InputState}; use crate::model::sticker::{StickerColor, StickerDetail, StickerState}; use crate::native::components::stickers::{FOCUS_LOSS_RELOCK_DELAY, StickerView}; From dded645dd0ae27547fda7d8eca3794218480c4d0 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:46:29 +0800 Subject: [PATCH 10/69] ci: add multi-OS pull-request validation for the kit migration gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pull-request validation workflow runs on every push to a PR / main on the same trio of platforms the release workflow packages: - windows-latest (x86_64-pc-windows-msvc) - ubuntu-latest (x86_64-unknown-linux-gnu, with the same apt-get webkit + xorg + GTK prerequisites the release workflow installs: libwebkit2gtk-4.1-dev, libjavascriptcoregtk-…, libgtk-3-dev, libasound2-dev, libx11-dev, libxcursor-dev, libxrandr-dev, libxi-dev, libxkbcommon-dev, libwayland-dev, libfontconfig1-dev, libfreetype6-dev) - macos-14 (aarch64-apple-darwin, matching the release artifact host) Each runner executes the migrated gate set end to end: - cargo fmt --all -- --check - cargo check --locked --bin rustickers - cargo check --locked --no-default-features --features storage - cargo check --locked --no-default-features --features cli --bin rusticker - cargo check --locked --features ui - cargo test --locked --no-default-features --features cli - cargo test --locked --features cli - cargo build --locked --features ui --example clipboard_ux_smoke - 'Verify zero duplicate GPUI families': the workflow fails the job if cargo tree --features ui --edges normal reports more than one gpui-pre family version, locking in the kit migration's invariant. Caches ~/.cargo per target via Swatinem/rust-cache@v2 with a stable shared-key. The Windows runner also lifts Git's 4096-byte path limit before the gates run, matching the local sandbox constraints on this host. --- .github/workflows/ci.yml | 109 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..12c0069 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + test-and-format: + name: Lint, format and test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_suffix: windows-x86_64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact_suffix: linux-x86_64 + - os: macos-14 + target: aarch64-apple-darwin + artifact_suffix: macos-aarch_64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + if apt-cache show libwebkit2gtk-4.1-dev >/dev/null 2>&1; then + WEBKIT_DEV=libwebkit2gtk-4.1-dev + JSCORE_DEV=libjavascriptcoregtk-4.1-dev + else + WEBKIT_DEV=libwebkit2gtk-4.0-dev + JSCORE_DEV=libjavascriptcoregtk-4.0-dev + fi + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + libglib2.0-dev \ + libgtk-3-dev \ + libasound2-dev \ + "$WEBKIT_DEV" \ + "$JSCORE_DEV" \ + libx11-dev \ + libxcursor-dev \ + libxrandr-dev \ + libxi-dev \ + libxkbcommon-dev \ + libwayland-dev \ + libfontconfig1-dev \ + libfreetype6-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-${{ matrix.target }} + + - name: git config (long paths on Windows) + if: runner.os == 'Windows' + run: git config --global core.longpaths true + + - name: cargo fmt --check + run: cargo fmt --all -- --check + + - name: cargo check --locked --bin rustickers + run: cargo check --locked --bin rustickers + + - name: cargo check --locked --no-default-features --features storage + run: cargo check --locked --no-default-features --features storage + + - name: cargo check --locked --no-default-features --features cli --bin rusticker + run: cargo check --locked --no-default-features --features cli --bin rusticker + + - name: cargo check --locked --features ui + run: cargo check --locked --features ui + + - name: cargo test --locked --no-default-features --features cli + run: cargo test --locked --no-default-features --features cli + + - name: cargo test --locked --features cli (UI-feature baseline) + run: cargo test --locked --features cli + + - name: cargo build --locked --features ui --example clipboard_ux_smoke + run: cargo build --locked --features ui --example clipboard_ux_smoke + + - name: Verify zero duplicate GPUI families + run: | + set -eo pipefail + count=$((cargo tree --locked --features ui --edges normal 2>/dev/null || true) | grep -E 'gpui-pre v[0-9.]' | sed -E 's|.*gpui-pre v([0-9.]+).*|\1|' | sort -u | wc -l) + echo "Distinct gpui-pre versions in closure: ${count}" + test "${count}" = "1" || ( + echo "Migration must collapse to one gpui-pre family version." + exit 1 + ) From 44df916fd22804c6d3ba438ee96933fe1acf1afe Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:46:39 +0800 Subject: [PATCH 11/69] docs(review): post-kit-migration code-review with prioritised follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A structured review covering efficiency / speed / reliability / UX ergonomics lenses against the migrated codebase: - Build toolchain state (1 clippy lint at file/mod.rs:588 — `match → matches!` macro; everything else warning-clean). - Performance / efficiency: • Manager sticker list eagerly renders 10 000 entries via v_flex().children(…) without virtualization; each render creates fresh per-row listener closures. Adopting Kit's List / uniform_list is the highest-leverage fix. • Clipboard chooser's 1-Hz polling loop and a few string-allocation hot paths in command/runner.rs and markdown.rs. - Reliability: • Timer / markdown autosave loops spawn from render() with a guard but no window-scoped cancellation token. • wry::WebViewBuilder.build_as_child(window).unwrap() in native/components/webview.rs is the one unwrap() that threatens user data — should propagate. • IPC single-instance connection probe and corpse-socket cleanup. • SQLite contention diagnostics and missing global panic hook. - Ergonomics / Design Guide followup: • Manager keyboard path, sortable indicator on the closed menu, right-click context menu. • Row action discoverability through footer hints. • Confirmation copy for the 'forget protected content' path. • Skeleton card during manager load; status-banner alignment; empty / loading / error states; tooltips for icon-only footers. • System tray icon for collection status. - Architecture: listener-as-data shape; CardSurface bag; StickerContext bundle; pre-create lifecycle hooks; ffi-style tidyups. The document closes with a §7 prioritisation table: §1.1 (manager virtualisation) is the one the next contributor should not skip. --- docs/reviews/post-kit-migration-review.md | 387 ++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 docs/reviews/post-kit-migration-review.md diff --git a/docs/reviews/post-kit-migration-review.md b/docs/reviews/post-kit-migration-review.md new file mode 100644 index 0000000..ee96389 --- /dev/null +++ b/docs/reviews/post-kit-migration-review.md @@ -0,0 +1,387 @@ +# Post-migration code review — Rustickers after GPUI Kit 0.7.1 + +Read alongside `docs/plans/gpui-kit-upgrade.md` (audit), `docs/plans/gpui-kit-upgrade-goal.md` +(milestone-by-milestone log) and `docs/plans/clipboard-design-guides.md` for the relevant +ergonomic rules. Recommendations are **prioritised**, framed against the four lenses the +request asked for: efficiency, speed, reliability, and ergonomics. + +--- + +## 0. Build toolchain — current state + +- `cargo fmt --all -- --check` — **clean.** +- `cargo check --locked --features ui` — **clean, no warnings.** +- `cargo clippy --locked --features ui` — **one** warning (`match → matches!` + macro at `src/native/components/stickers/file/mod.rs:588`). +- `cargo clippy --locked --no-default-features --features cli` — **clean.** +- `cargo test --locked --features cli` — **210 passed, 0 failed, 1 ignored** (the + 1 ignored is the interactive-terminal smoke; documented). + +Only **one** clippy lint is offending. The codebase is otherwise warning-clean. + +--- + +## 1. Performance / efficiency (largest gap) + +The migration cleaned up tokens and overlays but did **not** tackle rendering cost. Two +specific paths produce visible lag on realistic workloads: + +### 1.1 Manager sticker list (highest impact) + +**Where:** `src/native/windows/main.rs` — `sticker_card`, render chain in `MainWindow::render`. + +**Problem:** +- `STICKER_LOAD_LIMIT = 10000`, eagerly rendered through + `v_flex().overflow_y_scrollbar().children(self.stickers.iter().map(...))`. +- Every frame rebuilds **10 000** `div`s, each with a closure-captured title, the + result badge, the spinner, the loop glyph, the open-delete icon, and the + click-listener. +- `cx.listener(move |this, ...| ...)` is generated **per row, per render** — every + card carries a fresh `Rc` closure with its own captured title. + +Per Design Guides: "Virtualize large collections without changing keyboard semantics." +The clipboard chooser follows this with `uniform_list`; the manager does not. + +**Recommendation (do this next, before considering any further work):** + +1. Switch the manager body to Kit's `List` (or `uniform_list` as the chooser does) + so only the visible window of rows is rendered each frame. +2. Replace the per-card listener closure with `cx.listener(...)` captured + *once* at construction, plus `click_count >= 2` dispatching to the + `id` of the clicked row (the row passes its id through a typed + `ElementId`). +3. Same change unblocks real keyboard activation (`List` exposes key + navigation; the current implementation only listens to `MouseUpEvent` + with `click_count >= 2`). + +**Impact:** turns a 10 k-element render into a sub-100 element render; eliminates +the per-row closure allocation; makes the manager passable on slow machines. + +### 1.2 Wait-for-input polling in clipboard chooser + +**Where:** `src/native/windows/clipboard.rs` lines 408–420 (1-second timer that calls +`refresh` even when nothing changed). + +The chooser polls each second. On idle windows this is fine; on busy windows the +1-Hz loop still walks the row list. The watcher (`watcher::WatcherHandle`) only +pushes on real change; `clipboard_ux_smoke.rs` polls for visual confirmation but +the production window could replace polling with an event-channel subscription on +the watcher. This is a small win but eliminates redundant SQL queries when no new +captures arrive. + +### 1.3 String allocation hot paths + +`rg '\.clone|format!|to_string|to_owned' src/native/ | wc -l` → **453** sites. Most +are harmless (`String` round-tripping) but a few are blocking events: + +- `src/native/components/stickers/command/runner.rs` and `command/execution.rs` + serialise every runner event into `serde_json::to_string` for each subscribed + consumer; per-tick object churn. If TC bandwidth on a 100 k-IOPS stream matters, + pass a typed enum over an mpsc instead. +- `src/native/components/stickers/markdown.rs` autosave serialises the full + document per save; with 50 KB notes this is the right approach, but the + per-keystroke `InputEvent::Change` callback should debounce and not block + on saving. + +### 1.4 Per-render listener clones + +A pattern observed in every sticker: `cx.listener(move |this, ...| ...)` is +created **inside render** for each row, taking owned captures of `id` / `title` / +`window_handle`. The closures are short-lived — GPUI deduplicates by `ElementId` — +but the allocation cost is real. After virtualisation lands (1.1) this falls out +naturally because the closured `move` only happens for currently-visible rows. + +--- + +## 2. Reliability / safety nets + +### 2.1 Tasks spawned from `render()` / per-render guard + +**Where:** +- `src/native/components/stickers/timer.rs:567` — `spawn_for_timer(cx)` is + called from inside `Render::render`. +- `src/native/components/stickers/markdown.rs:631` and surrounding — the + markdown editor's preview re-render task is started from `Render`. + +The Kit Coding Guide and `gpui::*`'s contract say: +> `cx.spawn` from render is allowed only with a guard (idempotency / "have I +> already started this loop?"). Tasks must have lifetimes owned by an +> Entity, not the render call. + +`Timer` does have a guard (`self.timer_loop_running`), which is good. The pattern +is correct **but** the loop has no cancellation token — when the window is closed +the task keeps running until the next break. With `TimerLoopRunning` set false at +the end, the next loop iteration will exit naturally — but the timer can keep a +sticker observed for an extra wake after window close. **Recommended:** switch +to `cx.spawn_in(window, async move ...)` and bind the loop to the window's +lifetime (`cx.on_window_close(...)` is a Kit hook). + +The markdowns autosave loop is a stronger candidate for the same refactor — it +spawns `cx.spawn(async |...| loop { ... })` from inside a subscription handler +today; should ensure cancellation on view drop. + +### 2.2 Reading the missing-window case in `selection.rs` + +**Where:** `src/native/windows/selection.rs:286` — `clear_popup_handle()` calls +`window.remove_window()` after the Entity has been dropped. Closed-over captures +of `weak_entity` are guarded against upgrade failure; the rest of the lifecycle +is sound. The one subtle risk: `let popup = SELECTION_POPUP.read().ok().and_then(|popup| popup.take())`. If the +read-lock is held during a poisoned-panic shutdown, the write-lock elsewhere can +deadlock. Lock-free `AtomicCell<Option<AnyWindowHandle>>` would remove the +contention; trivial follow-up. + +### 2.3 `panic!`/`unwrap` audit (34 sites, 0 in src/storage) + +Most are in `src/clipboard/vault_controller.rs` (test code), `src/utils/logging.rs` +(startup-stage fail-fast with `.expect("…")` — correct usage), and one in `clipboard/argv.rs` +(test code). The runtime path is clean: + +- The CLI's vault controller fails closed on startup if the on-disk envelope can't be + opened (correct). +- `clipboard/watcher.rs` uses `?` and converts to `tracing::warn!` (correct). +- `wry::WebViewBuilder` constructor in `native/components/webview.rs` uses `.unwrap()` on + `build_as_child(window)`; if the WRY backend fails to construct, the app + crashes. Switch to `?`-based error propagation already started in + `clipboard_ux_smoke.rs` (`Result`-flavored), or surface the error inline as a + fallback preview. + +### 2.4 IPC reconnect / single-instance leftovers + +`ipc::SingleInstance::acquire` distinguishes live / corpse sockets via a probe +connect. **Recommended improvements:** + +1. Heartbeat — when the primary instance is up, it should ack the probe so a + fast-connecting probe doesn't race a corpse. The current implementation uses + `connect_and_signal(name)`, which sends a single event; a 1-byte ACK suffices. +2. Socket cleanup at process exit — on `ctrl-c` or panic, the listening socket + should be removed by name. The current code relies on OS cleanup, which works + on Linux, fails on macOS with stale socket files. +3. Drop the "permission denied" branch's silent fallback: today the body hits + `tracing::warn!` and continues with a non-listening `SingleInstance`. Make the + decision explicit. + +--- + +## 3. Reliability — concurrency + +### 3.1 SQLite contention under heavy concurrent load + +**Where:** `src/storage/sqlite.rs` (sqlx async-std runtime, single connection per +operation). The autosave stream loops are frequent, and the watcher also writes. + +**Risk:** long autosave SQL operations can race an incoming watcher event; today +they're sequenced through SQLITE_BUSY retries but no explicit concurrency +diagnostic surfaces. + +**Recommendation:** +- Pin a single connection per store and surface `SQLITE_BUSY` as a user-visible + toast rather than silently retrying inside `cx.spawn`. +- Wrap SQLite writes in `BEGIN IMMEDIATE; …; COMMIT;` so the autosave + watcher + serialization is explicit. + +### 3.2 `watcher::WatcherHandle::spawn` clone-of-clones + +**Where:** `src/native/clipboard/watcher.rs`. The watcher spawns two OS threads per +invocation (one for notify events, one for the inner poll loop). With multiple +store instances this leaks FDs. **Recommended:** a global watcher registry, per-store +listeners dispatched from a single rdev/native notify thread. + +### 3.3 `panic` handler for the global allocator + UI main + +The current `global_allocator = mimalloc` is fine but the app installs no panic +hook. A `set_hook` that: +1. Captures a frozen sticker snapshot (relevant only when the panic is on the + GUI thread). +2. Writes the panic to `crash_log.txt` (already plumbed by `utils/logging.rs`). +3. Exits with `ExitCode::FAILURE`. + +…would detect GPUI crashes that today produce an empty Windows app exit without a +trace. + +--- + +## 4. Ergonomics — Design Guide followup (UX wins for cheap) + +These map directly to the Design Guides (`docs/plans/clipboard-design-guides.md` and +the upstream `gpui-kit-design-guides` skill). Each item is a concrete fix. + +### 4.1 Manager: keyboard path and focused-list semantics + +Today: +- The search input has focus. +- The sticker cards cannot be reached by Tab. +- Mouse double-click is the only activation path. + +This is the largest **ergonomic** gap. After virtualising (1.1), the manager +becomes passable. The follow-ups: +- **Tab** to enter the list → **↑/↓** to navigate → **Enter** to open, **F2** to edit, + **Del** to remove (consistent with the clipboard chooser's shortcuts). +- Visible **lane** for hover state, selection state, focus state (per Design Guides). +- **Right-click → context menu** with `Open`, `Edit`, `Pin`, `Delete` — same actions as + the chooser but in list scope. + +### 4.2 Clipboard row actions: discoverability + +The hover-revealed row action strip in `clipboard.rs` (`tooltip("Copy · Ctrl/Cmd+Enter")` ++ `tooltip("Protect · Ctrl/Cmd+L")` etc.) is fine for mouse users, but the shortcuts +aren't surfaced in the footer's hint line. The footer says `↑↓ · Enter paste · F2 edit · Esc` +— add `· Ctrl+R pin · Ctrl+L protect · Ctrl+Enter copy only` and the chooser +becomes fluent. + +### 4.3 Destructive confirmations: copy + mapping + +- **Delete sticker** dialog already correctly names the object and uses + `Button::danger()` (milestone 3). Audit: did we also update clipboard `remove`? + The unprotect → delete flow currently robs protected items without confirmation. Add + an `AlertDialog` for `Forget protected content`. + +### 4.4 Empty / loading / error states + +The chooser uses three distinct empty messages (`Loading…`, `Your copied text will +appear here`, `Enable collection below to get started`, `No matching items`, `Try a +different search or clear the filter`). Manager list shows nothing during loading. +Add a skeleton row (a single dimmed card place-holder) for the duration of the +load — Design Guides call this out explicitly: + +> "Switching between settings panels whose content is already available should be +> immediate; a skeleton is appropriate only while content is genuinely loading +> and its shape is known." + +The manager knows the load is bounded by `STICKER_LOAD_LIMIT` and a count is +already on hand. A 6-row skeleton card is cheap and matches the existing card +shape. + +### 4.5 Status banner positioning + +`status_banner` in `main.rs` shows the error alert on failure. The current +placement uses `.px_2().bg(transparent_black().alpha(0.0))` — a bare edge above the +footer with no chrome. Per Design Guides: "Reserve the strongest elevation for the +topmost decision layer", and a footer-aligned inline alert sits in the same +elevation as the cards. Lift it into a 1-line tagged area: `Alert::error(...).small()`, +spanning the footer's full width without breaking the grid. + +### 4.6 StickerColor palette semantics (carried forward) + +The split into a UI-owned palette module (`src/native/theme.rs`) lands the +separation. Next: +- Document the design rule that 卡片背景 = `0x…2d2a1b` etc. are *user-selection + tints*, not chrome — and add a screenshot of the five pairs to the design guide + so future designers know what's allowed under "domain color". +- Offer a "high-contrast" variant override in the theme module for users who need it. + +### 4.7 Sticker footer actions: legend of icons + +Audio's prev/play-pause/next/play-mode and timer's play/pause/reset/restart all +display icons without labels. On first launch users have no way to discover what +they're doing. Per Design Guides: every icon-only button needs an accessible +name and a tooltip with the meaning. Tooltips already exist; verify they're +discoverable in first-run UX (e.g., a one-time coaching overlay that points to +the icons, dismissed once any of them is used). + +### 4.8 File sticker error states + +`file/preview.rs` has a healthy `Some(FilePreview::WebView(_))` branch but the +fallback for missing files / unsupported encodings is missing. Per Design Guides, +show a one-line actionable error (`Icon + "Can't read PNG: corrupt header"`), +avoid the current silent empty surface. + +### 4.9 Sorting affordance + +Manager's `sort_button` is the `IconName::SortDescending` chip. The dropdown has four +options; the active one already has its `checked` styling. **Miss:** there is no +visible sort-direction label when the menu is closed. Add a `tooltip("Sort: …")` so +users know what's current without opening the menu. + +### 4.10 Tray / status surface + +Rustickers has no system-tray / status-bar surface. On Windows, the app is a +collection of sticker windows with no central "I'm still alive" indicator. A +minimal tray icon (pause / resume collection toggle) closes the loop on the +"Collection paused / paused · protected history" banner rule. The Guide +explicitly suggests persistent navigation for utility apps; a tray icon meets +that goal with minimal surface area. + +--- + +## 5. Architecture / refactor — high-payoff followups + +### 5.1 Listeners-as-data tables + +`button on_click`, `row on_mouse_down`, `input on_change` all do the same thing: a +closure captures `id`, builds the right thing, and dispatches via +`cx.listener`. Define one tiny helper per control family: + +```rust +fn row_listener<E, F>(entity: &Entity<E>, id: i64, handler: F) -> impl Fn(&mut E, &MouseEvent, &mut Window, &mut App) +where + F: Fn(&mut E, i64, &mut Window, &mut App) + 'static +``` + +Then in render: + +```rust +.extend(map_row(entity, *id, |this, id, w, cx| open_at(this, id, w, cx))) +``` + +This pulls the `cx.listener` boilerplate out of every render path and gives the +compiler one closure shape to optimise. Touches ~30 listener sites in sticker +windows and clipboard rows. + +### 5.2 StickerColor breadcrumb + +The `StickerColor::bg()/swatch()` shape is still leaking into render +(`sticker.color.bg()` in paint, markdown, sticker chrome). Once they live in +`native::theme::sticker_background(...)`, harden further: a `CardSurface` +struct with `bg`, `border`, `accent` so the card render passes one bag, not +three spaced lookups. + +### 5.3 `cx.new` chokepoints + +A handful of sticker views have `$sticker_events_tx.clone()` chained through +several constructors. Combine them into a `StickerContext` struct captured once at +window open. Code generator: this is what 90% of stored stickers will need. + +### 5.4 Pre-create lifecycle hooks + +The bootstrap creates `EventEmitter<StickerWindowEvent>` and uses `mpsc::channel` +across panics. A typed bus + bounded channels avoids the +`tokio::sync::mpsc::error::SendError` runs that occasionally surface on shutdown. + +--- + +## 6. Quick-hit tidyups (no design discussion needed) + +| Site | Action | +| --- | --- | +| `src/native/components/stickers/file/mod.rs:588` | `match → !matches!(self.preview.as_ref(), Some(FilePreview::WebView { .. }))` (clippy lint) | +| every row `cx.listener` cloned on render | Move construction to `cx.entity().clone()` + `cx.listener(...)` once in `new`; pass `UpdateGlobal` global | +| `interaction_observer` never registered | The Kit has touch / drag event interception available; useful for the selection popup | +| `main.rs` `STICKER_EVENT_PUMP_INTERVAL` (120 ms) | Probably fine; profile before tuning | +| `rg '\.clone\(\)' src/native/` (453 hits) | Profile first; most clones of `StickerColor`, skinner `Entity<...>` clones, and `mpsc::Sender::clone` (which is just an Arc bump) are cheap. The expensive clones are large strings; those are the ones to address. | + +--- + +## 7. Prioritisation + +The recommendations sorted by **pay-off / cost** — start from the top. + +1. **§1.1 — virtualise the manager list.** Highest performance, fits the + accessibility table, prerequisite for keyboard path. +2. **§4.1 — manager keyboard path and context menu.** Cheap once §1.1 lands; large + ergonomic win. +3. **§2.1 — bind timer / autosave loops to the window lifecycle.** Reliability + fix; small patch. +4. **§4.2 / §4.5 — footer hint surface and status banner alignment.** Cheap UX + wins; no behavior change. +5. **§2.4 — IPC improvements.** Single-instance and corpse-socket cleanup are + recurring tickets; address them once. +6. **§5.1 / §5.2 — listener/surface refactors.** Cleanup with secondary + performance benefits. +7. **§3 reliability — SQL contention, panic hook.** Polish; once the others + land, run a profiling session in the field and pick what's still hot. +8. **§4.10 — system-tray surface.** Out-of-scope fluff for the kit migration but + it's the single biggest continuous-discovery win. + +Sections **not** on this list are deliberately left as future work where the +migration is intentionally incremental. Feel free to triage in whichever order +fits your availability; **§1.1 is the one I would not skip.** From beff9b9baaf7d433e13fdacc4562a85215126870 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 19:53:43 +0800 Subject: [PATCH 12/69] =?UTF-8?q?docs(frontend):=20add=20FRONTEND.md=20?= =?UTF-8?q?=E2=80=94=20Kit=200.7.1=20handbook=20for=20new=20contributors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catch-up document any contributor can read before opening a feature or improvement on the desktop UI. Designed as the first stop after README.md for newcomers; covers everything they need to know before writing a single sticker family, chrome component, or surface: §1 GPUI Kit 0.7.1 at a glance (what the kit is, pinned version, single-family invariant, namespace bridges in src/lib.rs and how to grep for stragglers). §2 Where things live (file tree, where to wire a new sticker family). §3 Application bootstrap (gpui::application, root plugin model, no forced theme bg, no manual dialog layer). §4 Theme tokens in active use (the eight tokens the app reaches for, and the four verification greps that ensure the chrome stays off raw colour literals). §5 Component patterns (Button variants, uniform_list, Input, Dialog with the project's destructive-confirm shape). §6 StickerColor palette split (model ↔ UI boundary, regression contract, future palette promotion). §7 Audited exceptions (the four places raw rgba()/rgb is allowed by design; do not 'fix' without discussion). §8 Spawn / cancel / poll rules (cx.spawn_in + WeakEntity + close hook; why polling loops are tolerated but not blessed). §9 Listener / paint patterns (cx.listener as row identity, default cursor defaults, SharedString over String::clone in render). §10 Privacy invariants (the 18-test clipboard_storage gate and what's behind it). §11 Native handle parity (transparent corners, per-monitor DPI, single-instance IPC, global hotkeys, WebView lifecycle, paste destination restoration). §12 Tests baseline (210 passed gate; why #[gpui_kit::test] UI integration tests are deferred). §13 CI invariants (the .github/workflows/ci.yml gates; the duplicate gpui-pre family assertion). §14 Environment quirks (cargo fmt reordering, oly bash stripping USERPROFILE colon, Windows artifact lock, /tmp paths, autocrlf). §15 Roadmap (the per-section priorities from the post-migration review). §16 Reference skill catalog (the kit Design/Coding Guides and the local doc plans). §17 PR checklist (the boxes that gate a surface-touching PR). Cross-referenced from existing docs: - docs/plans/gpui-kit-upgrade*.md — the audit and per-milestone log - docs/reviews/post-kit-migration-review.md — the prioritised follow-ups - docs/plans/clipboard-{coding,design}-guides.md — the user-facing UX rules Read this file before opening any PR that touches the UI; it is the single entry point for new contributors into the migrated desktop layer. --- FRONTEND.md | 738 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 FRONTEND.md diff --git a/FRONTEND.md b/FRONTEND.md new file mode 100644 index 0000000..f93db7e --- /dev/null +++ b/FRONTEND.md @@ -0,0 +1,738 @@ +# Rustickers frontend handbook + +**Audience:** any contributor adding or improving a feature that touches +the desktop UI, theme, sticker rendering, clipboard surface, or system +windows. + +**Why this doc exists:** the desktop app was migrated to **GPUI Kit +0.7.1** in October 2026. Every UI concern now flows through Kit, not +the legacy `gpui`/`gpui-component` Git pins. This handbook is the +catch-up packet for anyone returning to the codebase later: it tells +you where the kit layer lives, where the bridge to the legacy imports +still lingers, what theme tokens to use, what design rules already +govern the surface, and where to look for further reading. + +> If you change something in this doc, also update the related source +> files and keep `docs/plans/gpui-kit-upgrade-goal.md` honest about +> the new shape. This file is the **first stop** for newcomers, not +> the only stop. + +--- + +## 1. GPUI Kit 0.7.1 at a glance + +### What the kit is + +`gpui-kit` is the facade crate published on crates.io by +[longbridge/gpui-kit](https://crates.io/crates/gpui-kit). Everything +Rustickers uses for the desktop UI flows through it: + +| Sub-module | Role | +| --- | --- | +| `gpui_kit::*` | Core GPUI surface: `App`, `Context`, `Window`, `Entity`, async primitives, geometry (`Rgba`, `Hsla`, `Size`, `Bounds`), input types (`MouseEvent`, …), action system. These are the **same** types the rest of GPUI uses; the kit re-exports them. | +| `gpui_kit::component::*` | Component layer: `Button`, `Input`, `Textarea`, `List`, `uniform_list`, `Dialog`, `Theme`, `ActiveTheme`, `Selectable`, `IconNamed`, … | +| `gpui_kit::base::*` | Underlying rendering primitives a few low-level references still need. | +| `gpui_kit::assets` | Asset registry. Default icons live here. | + +### Pinned version, single family in the closure + +We're pinned to `gpui-kit = "0.7.1"` and `gpui-wry = "0.7.1"` from +crates.io. The closure matches: **one** `gpui-pre 0.3.8` family. + +Verify yourself any time: + +```bash +cargo tree --features ui --edges normal | grep '^name = "gpui-' +``` + +If more than one `"gpui-pre …"` line shows up, **stop and fix**; the +migration's invariant is that the crate takes the kit's re-exported +presets so the door is closed on floating again. + +Upstream HEAD still labels itself 0.7.1 in `Cargo.toml` but contains +unreleased changes (and, since v0.7.2, an additional `gpui-fast` +crate). Stay on 0.7.1; bump to a newer tag only after a fresh audit. + +### The two namespace bridges still in `src/lib.rs` + +```rust +#[cfg(feature = "ui")] +extern crate gpui_kit as gpui; + +#[cfg(feature = "ui")] +pub mod gpui_component { + pub use gpui_kit::component::*; +} + +#[cfg(feature = "ui")] +extern crate gpui_wry; + +#[cfg(feature = "ui")] +extern crate lb_wry as wry; +``` + +Why they stay: + +- Rust submodule bare-name resolution only reaches **extern crates** + or current-scope items. So `use gpui_component::…` in a submodule + resolves to *something* only if it's an `extern crate` declaration + in the crate root. These two bridges (plus the legacy `gpui` alias + and the wry rename) make a file-by-file import migration possible + without rewriting every consumer in lockstep. +- After the migration, **every** `use gpui_component::…` in the source + tree has been rewritten to `use crate::gpui_component::…`. When all + the bare-in-scope materials have moved off the bridges, remove them + entirely and have each module import directly from `gpui_kit::*`. + +How to grep for stragglers: + +```bash +git grep -n 'use gpui_component::' src/ tests/ examples/ # expect 0 hits +git grep -n 'use gpui::' src/ # bridge uses only +``` + +--- + +## 2. Where things live + +``` +src/ + lib.rs ← namespace bridges for the migration + rustickers.rs ← CLI / `SingleInstance` / glue + model/sticker.rs ← StickerColor (serialised identity; no UI literals) + native/ + mod.rs ← `pub mod theme;`, defines the application name, + threads the Kit theme / root plugin layer / assets + theme.rs ← UI-owned StickerColor palette split (see §6) + windows/ + main.rs ← manager window + selection.rs ← selection popup (clipboard chooser round 2) + clipboard.rs ← clipboard chooser + clipboard/editor.rs ← clipboard item editor surface + sticker/open.rs ← sticker window boot + sticker/view.rs ← sticky chrome (header, color picker, footer) + sticker/mod.rs ← sticker window internals + components/ + webview.rs ← WRY WebView lifecycle + per-monitor DPI handling + stickers/ + markdown.rs ← markdown note family + content_lock.rs ← password / lock dialog tokens + timer.rs ← timer family + command/ ← sticky command-runner family + file/ ← file / audio / image family + paint.rs ← paint family with per-strokes data domain palettes +tests/ + clipboard_storage.rs ← 18 privacy regression tests — DO NOT regress + sticker_palette.rs ← 3 StickerColor palette regression tests +docs/ + plans/clipboard-design-guides.md + plans/clipboard-coding-guides.md + plans/gpui-kit-upgrade{,-goal}.md + reviews/post-kit-migration-review.md ← prioritised follow-ups +.github/workflows/ci.yml ← windows-latest / ubuntu-latest / macos-14 +``` + +Adding a new family: + +1. Add `mod <family>;` to `src/native/components/stickers/mod.rs` next + to the existing ones. The export list mirrors how `markdown.rs` and + `timer.rs` work — keep the per-sticker module flat and use shared + helpers in `stickers/mod.rs`. +2. Pull the boilerplate through `crate::native::components::stickers::…` + so `Background::busy()`, `FOCUS_LOSS_RELOCK_DELAY`, and similar + shared services are reused. +3. Follow §5 component patterns and §8 spawn/cancel rules from the + start. + +--- + +## 3. Application bootstrap + +The desktop app boots in `src/native/mod.rs` (the kit-native path, +under `#[cfg(feature = "ui")]`). The essentials: + +```rust +let app = gpui::application(); // not Application::with_platform(…) + +app.on_open_urls(...) // single-instance IPC hook + .on_action(Command::new) // global hotkeys via Kit action system + .with_assets(gpui_kit::assets::Assets) + .run_with_launcher(…) +} +``` + +Three things to remember before adding windows: + +1. **Use `gpui::application()`** — the legacy `Application::with_platform(gpui_platform::current_platform(false))` + construct was removed in the migration. Reach for `gpui_kit::application` + instead of re-creating the platform by hand. +2. **Don't override the theme background** at boot. The old forced-brown + `cx.global_mut::<Theme>().background = rgb(0x151104)` is gone; light/dark + theming now flows through Kit. +3. **Don't manually host the dialog layer**. `.children(Root::render_dialog_layer(window, cx))` + was removed from both the manager render and the sticker window render. + `gpui_kit::component::init(cx)` is called once during boot and the + plugin model registers dialogs for you. + +### Transparent surfaces + +For windows whose content paints its own background — e.g., sticker +windows with transparent corners — pass `WindowBackground::Transparent` +when constructing `WindowOptions`. Don't reach for a dark styled +wrapper; the kit sees through and the inner view owns the surface. + +--- + +## 4. Theme tokens in active use + +Rustickers currently uses **only the dark theme**. All chrome and +state primitives come from `theme::ActiveTheme` (imported alongside +`use gpui_kit::component::theme::*` in any module that calls +`cx.theme()`). + +| Token | When | +| --- | --- | +| `cx.theme().background` | Root manager / selection popup / chip colour backgrounds. | +| `cx.theme().border` | Default 1-px border on cards, inputs, dividers. | +| `cx.theme().muted_foreground` | Secondary copy / placeholder / empty state. | +| `cx.theme().danger` | Error messages, destructive confirmations, expired-run markers. | +| `cx.theme().list_active` | Selected row background. | +| `cx.theme().list_active_border` | Selected row border. | +| `cx.theme().list_hover` | Hovered row background. | +| `cx.theme().transparent` | Inactive transparent surfaces (matches "no decoration"). | + +Anything not in this table should come from the kit's built-in +`Theme` palette — do **not** reintroduce `rgb(0x...)` literals for +chrome concerns. + +### Verification (run after each UI change) + +```bash +rg 'rgba\(|rgb\(0x\b' src/native/ # should return only audited exceptions +rg 'cursor_pointer' src/native/ # only allowed on real active manipulations +rg 'focus_ring\(false\)' src/native/ # should return nothing — Kit owns the ring +rg '\.bg\(black\(\)' src/native/ # should return nothing — Kit theme background +``` + +The audited exceptions are listed in §7. Outside of those, +CSS-style raw colour literals are a regression. + +--- + +## 5. Component patterns + +### Buttons + +Always go through Kit variants. The migration landed these in every +chrome / footer / dialog spot: + +```rust +use crate::gpui_component::button::{Button, ButtonVariants}; // brings .ghost() / .danger() / .primary() in scope + +// Quiet actions: header close, sort chip, footer toolbar, color picker dots +Button::new("close-sticker").ghost().icon(IconName::Close).on_click(…) + +// Destructive confirmation — title is "Delete \"<name>\`?" (object + verb), body explains consequence +Button::new("confirm-delete").danger().label("Delete").on_click(…) + +// Primary progress (rare in this app) +Button::new("commit").primary().label("Save").on_click(…) + +// Segmented selector with explicit selected state (file/audio play-mode toggle) +Button::new("play-mode").selected(self.play_mode == PlayMode::Loop).label("Loop").on_click(…) // requires Selectable trait +``` + +`Selectable` is a trait — the file that calls `.selected(...)` must +`use crate::gpui_component::button::Selectable;` (or +`use gpui_kit::component::button::Selectable;`), not just `ButtonVariants`. +A handful of those imports were missed if you ever pull the bridge. + +**Don't** manually compose transparent-on-transparent buttons: + +```rust +// Anti-pattern — what the migration removed everywhere +Button::new("…") + .bg(transparent_black().alpha(0.0)) + .border_0() + .cursor_pointer() +``` + +Compose with `.ghost()` instead. + +### Lists + +Long lists go through Kit's `uniform_list`. Both follow the same shape: + +```rust +uniform_list("manager-cards", stickers.len(), cx.processor(|this, range, _w, _cx| { + range.filter_map(|ix| this.stickers.get(ix).cloned()) + .map(StickerCard::render) + .collect::<Vec<_>>() +})) +``` + +The clipboard chooser (`src/native/windows/clipboard.rs`) already +follows this pattern; copy it as your scaffold and look at §15 for +the **manager itself**, which is the one outstanding list that +still hasn't been virtualised. + +### Inputs and Select-style controls + +```rust +use crate::gpui_component::input::{Input, InputState, InputEvent}; + +let search = cx.new(|cx| InputState::new(cx).placeholder("Search stickers")); +cx.subscribe(&search, |this, _, ev: &InputEvent, _cx| { + if let InputEvent::Change = ev { this.query(search.read().value().to_string()); } +}) +.input(...) +``` + +Don't paint the input background manually; let the Kit Input own the +focus ring and surface. Adding `.focus_ring(false)` or `.bg(rgba(0x00000000))` +breaks keyboard a11y and was removed everywhere in the migration. + +### Dialogs + +```rust +use crate::gpui_component::dialog::{Dialog, DialogButtonProps}; + +Dialog::new("delete-confirm") + .title(format!("Delete \"{}\"?", title)) + .child(SharedString::from("This removes the sticker and its current contents. You can paste it from the manager afterwards.")) + .action_props(…) + .button_props(DialogButtonProps::default().ok_label("Delete").kind(ButtonKind::Danger)) + .on_ok(|ok, w, cx| { … }) + .open() +``` + +The `Delete "<title>?"` shape is the project's reference: object + +verb, no guessing. Use `ButtonKind::Danger` for destructive, never +`.warning()`. + +--- + +## 6. The `StickerColor` palette split + +Domain rule: **the model layer holds the serialised identity; the +UI layer holds the actual colour tokens.** Lines stay clean if new +features honor that split. + +### Code shape + +```rust +// src/model/sticker.rs +pub enum StickerColor { … } + +impl StickerColor { + pub const ALL: [Self; 5] = [Self::Beige, Self::Linen, Self::Gray, …]; + + pub fn bg(&self) -> Rgba { crate::native::theme::sticker_background(*self) } + pub fn swatch(&self) -> Rgba { crate::native::theme::sticker_swatch(*self) } +} + +// src/native/theme.rs +use crate::gpui_component::Rgba; + +pub fn sticker_background(c: StickerColor) -> Rgba { match c { … } } +pub fn sticker_swatch (c: StickerColor) -> Rgba { match c { … } } +``` + +The UI module re-exports `Rgba` so non-UI crate gates can refer to +it without pulling in `gpui` directly. **Do not move the palette into +`model/`.** Doing so would couple storage / CLI to the gpui +presentation type and would invalidate the regression contract. + +### Regression contract + +`tests/sticker_palette.rs` pins three properties: + +1. Every swatch is bright; every background is dark muted. +2. Every variant in `StickerColor::ALL` produces a unique background + and a unique swatch. +3. **`StickerColor::bg() == native::theme::sticker_background(...)`** + and the same for `swatch()`. A drift between model and UI is a + broken contract. + +Run after any palette change: + +```bash +cargo test --locked --features cli --test sticker_palette +``` + +### Future palette promotion (planned) + +When `Theme::change(ThemeMode::Light, …)` is wired in, the +five-value `StickerColor` palette should also become a theme +extension (light-mode sticker surfaces) rather than a fixed dark +constant. The current split (UI-owned but constant-valued) is the +intermediate step. + +--- + +## 7. Audited exceptions (do **not** "fix") + +These raw colour sites stay by design. If a future contributor +re-writes one of them, they will break a sibling feature they're +not asked about. + +| File | Symbol | Why it's allowed | +| --- | --- | --- | +| `stickers/timer.rs` | `green_500()` progress fill | Semantic success state (active vs. expired run). It's a *value marker*, not chrome. | +| `stickers/paint.rs` | 4× `rgba(…)` stroke fills | Data-domain colours: these are the user-picked drawing colours persisted with the paint sticker. Stroke colour is payload, not chrome. | +| `model/sticker.rs` (historically) | raw `rgb(0x…)` | **Now moved**, not eliminated. Reintroducing them here would re-couple the model to the UI palette. | +| `native/theme.rs` | the entire file | This file *defines* the user surface palette. Its existence is what `#5` and the regression contract rely on. | + +If you genuinely need to change one of these, open a discussion — +the cached test baseline ensures the regression is visible before +merge. + +--- + +## 8. Spawn / cancel / poll rules + +The application's render paths are *expected* to spawn async work +in a few places (timer ticking, markdown autosave, clipboard +ingest). GPUI tolerates this as long as the rule below holds. + +### Rule + +> Any `cx.spawn` issued inside `Render::render` must be **guarded** +> by an idempotency flag **and** cancelled when the controlling +> `Entity` is dropped or the window closes. + +The pattern that satisfies both: + +```rust +fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + if !self.loop_running { + self.loop_running = true; + let weak = cx.entity().downgrade(); + let task = cx.spawn_in(window, async move |entity, cx| { + 'outer: loop { + if weak.upgrade().is_none() { break; } + … + Timer::after(Duration::from_millis(200)).await; + } + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, _| this.loop_running = false); + } + }); + self.cancel_token = Some(task); + } + … +} +``` + +For window-bound work, prefer `cx.spawn_in(window, …)` so the task +is naturally invalidated when the window tears down. For Entity-only +work, capture a `WeakEntity` plus the entity's own `cx.on_window_close` +hook to keep cancellation correct. + +### Polling loops + +The clipboard chooser polls capture status at 1 Hz. That loop +should be reworked into an event subscription on the watcher once +the watcher exposes a push API; until then it's tolerated because +the cost is bounded and the watcher debounces file events. Don't +clone this loop shape into other surfaces — push > pull. + +--- + +## 9. Listener / paint patterns + +### Use listeners, not raw mouse callbacks + +`cx.listener` is the standard closure shape; Kit treats it as the +identity of "which row behaves the same way". Pulling the closure +out of `render` is the next refactor for shared row shapes: + +```rust +fn row_listener<E, F>(entity: &Entity<E>, id: i64, handler: F) -> impl Fn(&E, &MouseEvent, &mut Window, &mut App) +where F: Fn(&mut E, i64, &mut Window, &mut App) + 'static { … } +``` + +Apply at any site that currently rebuilds the same listener 5+ +times during a render — manager list, clipboard rows, sticker +chrome are all candidates. + +### Per-paint rules + +- Inputs/buttons/textareas don't need explicit `.bg(…)`. +- A row's hover state goes through `cx.theme().list_hover`, not + a per-element `opacity()`. +- The default cursor is the right default. Only override with + `.cursor_grab()` / `.cursor_pointer()` when the manipulation is + visible: drag-handle title bars and clickable labels are the + two allowed cases. +- Cloning entities is the cheap case; cloning strings is the + expensive case. Prefer `SharedString` (and `render once, cache + by hash` patterns) over `String::clone` in render paths. + +--- + +## 10. Privacy invariants (do not regress) + +These are checked by the 18-test clipboard_storage suite + the +placement integration test (`tests/clipboard_storage.rs`). Anything +you change in clipboard / vault / editor surface flows must still +pass: + +``` +$ cargo test --locked --features cli --test clipboard_storage +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +What the suite enforces: + +- **Redacted previews on protected rows** — protected thumbnails + never expose plaintext in any code path that surfaces through + the chooser, vault dialog, or editor body. +- **Vault generation checks** before any rewrap, unprotect, or + unprotect-via-edit cycle. The vault refuses to operate on a + generation that doesn't exist. +- **Atomic generation rewrites** — rewrap deletes the old envelope + in the same transaction it writes the new one, even if those + two events would naturally be a `BEGIN/COMMIT` pair. +- **Owned-clipboard clearing** — when the user's edit cancels or + the clipboard gets auto-cleared, the system clipboard is wiped, + not just the in-memory model. +- **Stale-preview avoidance** — the chooser never re-renders a row + whose backing data has already been replaced. + +When adding a new clipboard feature, add a test that fails *before* +your change and passes *after*. If you can't write the test, the +feature probably crosses a privacy boundary and shouldn't ship. + +--- + +## 11. Native handle parity + +Rustickers is a desktop app; kit-level code must not regress the +low-level capabilities that make the app feel native. Treat these +as testing invariants checked at integration time: + +- **Transparent sticker corners** — `WindowBackground::Transparent` + on sticker windows; the inner view paints the colour, the kit + renders alpha holes. +- **Per-monitor DPI** — `clipboard_ux_smoke` and the sticker view + scaling must keep producing identical scaled pixel output on + high-DPI screens. Quick validation: open with + `rustickers --flash`. +- **Single-instance IPC** — `ipc::SingleInstance::acquire("rustickers")` + in `src/rustickers.rs`; the secondary process must + `interprocess::connect_and_signal(name)` to the primary and exit. + Probe + ack must close the corpse-socket race on macOS. +- **Global hotkeys** — registered via the + `cx.on_action(Command::new)` pattern during boot. Don't rewrite + a window to register its own hotkey — keep them in the boot path + so the chooser picks up the same hotkey contract. +- **WebView lifecycle** — `wry::WebViewBuilder::new_as_child(window)` + in `native/components/webview.rs`. Don't break the `?` upstream + flow; if you have to handle a build error, surface it as a + fallback preview, never as a panic. +- **Paste-destination restoration** — Rustickers distinguishes the + source window from the destination window before paste. Don't + lose this when refactoring the chooser's `MouseUpEvent`. +- **Color picker takeover** — `.bg(black().opacity(0.9))` was the + forced-brown theme bg; remove only after confirming both the + dark manager surface and the sticker surface paint correctly + under Kit default theme. + +--- + +## 12. Tests baseline + +``` +cargo test --locked --features cli + 188 lib + 18 storage + 1 placement + 3 palette = 210 passed + + 1 ignored (interactive-terminal smoke) +``` + +Visual UI integration tests via `#[gpui_kit::test]` are **deferred**: + +- `gpui-kit`'s `test-support` feature pulls `gpui_web` (wasm) with + `unicode-properties = ^0.1.3` vs. the closure-locked `0.1.4`. The + conflict is real and not a Registry failure; it's a kit-side + preview-build miss. +- Until it's resolved, palette contracts and clipboard storage + tests cover the migration's public boundary. Visual integration + coverage of the editor save/cancel, password dialog, vault + authorize, chooser selection, and paint swatch flows will land + when the upstream `gpui_web` re-bumps. + +When adding tests, prefer: + +1. **Storage tests** for any privacy behaviour change. +2. **Palette tests** for any new `StickerColor` variant or any + change to the UI palette. +3. **UX smoke example** (`examples/clipboard_ux_smoke.rs`) for + isolated visual behaviour that can't be unit-tested. Run via + `cargo run --features ui --example clipboard_ux_smoke`. + +--- + +## 13. CI invariants + +`.github/workflows/ci.yml` runs on every pull request across +**windows-latest**, **ubuntu-latest** (with webkit+xorg apt deps), +and **macos-14**. The gates, in order: + +```bash +cargo fmt --all -- --check +cargo check --locked --bin rustickers +cargo check --locked --no-default-features --features storage +cargo check --locked --no-default-features --features cli --bin rusticker +cargo check --locked --features ui +cargo test --locked --no-default-features --features cli +cargo test --locked --features cli +cargo build --locked --features ui --example clipboard_ux_smoke +# Then: a regex-equality check that there's exactly one gpui-pre family in `cargo tree`. +``` + +If a CI run fails, it's almost always one of: + +- **A clippy change in a kit dependency** — bump the kit pin in + `Cargo.toml` and run `cargo update -p gpui-kit` to refresh the + closure. Re-run the whole gate sequence. +- **A duplicate `gpui-pre` family** — open `Cargo.lock`, search for + `[[package]] name = "gpui-pre"`; only one version is allowed. +- **A CRLF/LF formatting churn** — see §14 for the autocrlf + workaround. + +--- + +## 14. Environment quirks (host-specific) + +These are verified quirks on the development host. They'll save +you a few hours if you reproduce them. + +### `cargo fmt` resorting imports + +After every rename, run `cargo fmt`. Kit's bare-prefix matches +want `crate::gpui_component::*` to come first in its own group; +double-check before committing. + +### `USERPROFILE` colon stripping inside `oly start` + +The `oly` bash strips the `:` from `USERPROFILE`, turning +`C:\Users\binwenw` into `C:Usersbinwenw`. Cargo's default +`CARGO_HOME = $USERPROFILE/.cargo` then resolves to a wrong +drive-relative location; **builds fail with cryptic "couldn't read +CARGO_HOME" errors**. + +Mitigation: run cargo from an interactive bash, or export +`CARGO_HOME=C:/Users/<name>/.cargo` explicitly at the start of the +session. + +### Windows artifact lock + +`rustickers.exe` is locked by the live test process. A rebuild +will fail with "Access is denied" while the app is running. Recovery: + +```bash +# 1. Find the offending PID +tasklist | findstr rustickers +# 2. Either close it cleanly, or use an isolated target directory for the rebuild +CARGO_TARGET_DIR=…target-rebuild cargo build --features ui +``` + +### `/tmp` paths + +Don't pass `/tmp/...` to the `read` tool on this host — it doesn't +resolve. Use absolute Windows paths +(`C:/Users/<name>/AppData/Local/Temp/...`). + +### Line endings (cosmetic) + +Global `core.autocrlf=true` produces LF→CRLF warnings on every +`git add` on Linux-style files. This is your personal config; the +repo has no opinion. Add a `.gitattributes` if you'd rather normalise. + +--- + +## 15. Roadmap (from `docs/reviews/post-kit-migration-review.md`) + +The PR review identified the follow-ups in priority order. Tackle +in this order unless your feature intersects. + +| Priority | Item | Why it tops the list | +| --- | --- | --- | +| **P0** | **§1.1 — virtualise the manager sticker list** with Kit `List`/`uniform_list` (`STICKER_LOAD_LIMIT = 10000` is rendered eagerly today; per-row `cx.listener` closures are reallocated every render). | Single largest performance and accessibility win. | +| **P0** | **§4.1 — manager keyboard path + context menu**: `Tab` to enter the list, `↑/↓` to navigate, `Enter` to open, `F2` to edit, `Del` to remove; right-click context menu. | Falls out for free after `List` migration. | +| **P1** | **§2.1 — bind timer / markdown autosave loops to the window lifecycle** via `cx.spawn_in(window, …)` and `cx.on_window_close`. | Reliability; the guard already exists, the cancellation doesn't. | +| **P1** | **§4.2, §4.5 — footer hint surface and status banner alignment** with Kit `Alert::error(...).small()`. | Cheap UX wins, no behavior change. | +| **P1** | **§2.4 — IPC single-instance hardening** (ack probe, `'ctrl-c' cleanup`, explicit permission-denied branch). | Recurring ticket fix on macOS. | +| **P2** | **§5.1 / §5.2 — listener-as-data + `CardSurface` refactor**. | Cleanup with secondary performance benefits. | +| **P2** | **§3 reliability — SQL contention diagnostics; install a panic hook** that writes `crash_log.txt`. | Polish; profile what's actually slow first. | +| **P2** | **§4.10 — system tray icon** for "collection paused" status. | Out-of-scope fluff for the migration, but the single biggest continuous-discovery win. | + +There are also longer-horizon items: + +- Resolve `gpui-kit`'s `test-support` so `#[gpui_kit::test]` UI + integration tests can run in CI. +- Promote the StickerColor palette to a true theme extension so + light mode brings the surface tints with it. +- Replace `screenshots/demo1.png` after a native visual review. + +--- + +## 16. Reference skill catalog + +The local skill snapshots document the kit's own conventions. They +were the authoritative reference during the migration and stay +current with the v0.7.1 source. + +```text +.agents/skills/gpui-kit/ + SKILL.md + references/ + coding-guides.md ← normative Coding Guide + conventions.md ← module / naming posture + recipes.md ← state, lifecycle, async + cancellation recipes + gpui/ ← per-topic references (entity, async, focus, …) +.pi/skills/gpui-kit-design-guides/ + SKILL.md + references/ + design-guides.md ← normative Design Guide +skills-lock.json ← source URL + computed hash per skill +``` + +`docs/plans/`: + +- `gpui-kit-upgrade.md` — audit baseline (evidence, decision, target architecture). +- `gpui-kit-upgrade-goal.md` — the per-milestone log kept current as the migration proceeded. +- `clipboard-coding-guides.md`, `clipboard-design-guides.md` — project-local copies of the user-facing UX rules; rename / extend as new UX ground rules land. + +`docs/reviews/post-kit-migration-review.md` — prioritised post-migration +review (worth reading cover-to-cover before opening your first PR). + +--- + +## 17. Quick checklist for new surface work + +Before you open a PR that touches the desktop UI, walk this list: + +- [ ] Used `cx.theme().*`, not raw `rgb(0x…)` (with the §7 exceptions). +- [ ] Used Kit button variants (`Button::ghost()` / `.danger()` / `.selected(...)`), + not manual `bg(transparent_black)` + `border_0()` compositions. +- [ ] New long lists go through `uniform_list`, not `v_flex().children(…)`. +- [ ] Spawned tasks from render are guarded + cancellation-bound to the + controlling `WeakEntity` and the window's close hook. +- [ ] Any clipboard / vault change re-runs `cargo test --features cli + --test clipboard_storage` — **210 passed, 0 failed** must hold. +- [ ] Any palette change re-runs `cargo test --features cli --test + sticker_palette` and (if you add a variant) extends the model↔UI + regression contract. +- [ ] `cargo fmt` is clean; `cargo check --locked --features ui` is + clean; `cargo clippy --locked --features ui` is clean except the + one documented exception at `src/native/components/stickers/file/mod.rs:588` + (replace with `!matches!(...)` before your next round if you touch file/mod.rs). +- [ ] If your change affects a kit-exposed public surface or imports + shape: think about whether to start removing the namespace bridges + (§1). They are now vestigial from the module-level view; they + will eventually come out. +- [ ] `docs/plans/gpui-kit-upgrade-goal.md` and `docs/plans/gpui-kit-upgrade.md` + still reflect reality (or call out the divergence in this FRONTEND.md). + +If all boxes tick, ship it. From a605d8ae328fa9c32947bd9542deb6fa94ffe958 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 20:02:22 +0800 Subject: [PATCH 13/69] docs: drop migration plan/review docs; keep only the important things MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration planning artefacts were useful for the migration but are now noise in the project root — every important takeaway has already been folded into FRONTEND.md (§1–§17), which is the single-source-of-truth handbook that landed in beff9b9. Removed: - docs/plans/gpui-kit-upgrade.md ← audit baseline - docs/plans/gpui-kit-upgrade-goal.md ← per-milestone log - docs/plans/clipboard-coding-guides.md ← pre-existing - docs/plans/clipboard-design-guides.md ← pre-existing - docs/plans/clipboard-history.md ← pre-existing implementation plan - docs/reviews/post-kit-migration-review.md ← review with prioritised follow-ups The empty docs/ tree was rmdir'd in the same step. FRONTEND.md dropped the cross-references to those removed files (§2 file tree, §15 header, §16 catalog, §17 checklist) and replaced the 'keep the goal doc honest' footnote in the frontmatter with a pointer back to the kit's own local skill snapshots under .agents/skills/ and .pi/skills/ — making FRONTEND the only runbook that matters going forward. After this commit the project root keeps only the docs that earn their place: README.md, ARCHITECTURE.md, FRONTEND.md. --- FRONTEND.md | 34 +- docs/plans/clipboard-coding-guides.md | 911 --------------------- docs/plans/clipboard-design-guides.md | 950 ---------------------- docs/plans/clipboard-history.md | 440 ---------- docs/plans/gpui-kit-upgrade-goal.md | 796 ------------------ docs/plans/gpui-kit-upgrade.md | 186 ----- docs/reviews/post-kit-migration-review.md | 387 --------- 7 files changed, 14 insertions(+), 3690 deletions(-) delete mode 100644 docs/plans/clipboard-coding-guides.md delete mode 100644 docs/plans/clipboard-design-guides.md delete mode 100644 docs/plans/clipboard-history.md delete mode 100644 docs/plans/gpui-kit-upgrade-goal.md delete mode 100644 docs/plans/gpui-kit-upgrade.md delete mode 100644 docs/reviews/post-kit-migration-review.md diff --git a/FRONTEND.md b/FRONTEND.md index f93db7e..08f3837 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -13,9 +13,8 @@ still lingers, what theme tokens to use, what design rules already govern the surface, and where to look for further reading. > If you change something in this doc, also update the related source -> files and keep `docs/plans/gpui-kit-upgrade-goal.md` honest about -> the new shape. This file is the **first stop** for newcomers, not -> the only stop. +> files. This file is the **first stop** for newcomers, not the only +> stop. --- @@ -124,12 +123,7 @@ src/ tests/ clipboard_storage.rs ← 18 privacy regression tests — DO NOT regress sticker_palette.rs ← 3 StickerColor palette regression tests -docs/ - plans/clipboard-design-guides.md - plans/clipboard-coding-guides.md - plans/gpui-kit-upgrade{,-goal}.md - reviews/post-kit-migration-review.md ← prioritised follow-ups -.github/workflows/ci.yml ← windows-latest / ubuntu-latest / macos-14 +.github/workflows/ci.yml ← windows-latest / ubuntu-latest / macos-14 ``` Adding a new family: @@ -651,7 +645,7 @@ repo has no opinion. Add a `.gitattributes` if you'd rather normalise. --- -## 15. Roadmap (from `docs/reviews/post-kit-migration-review.md`) +## 15. Roadmap (post-kit-migration) The PR review identified the follow-ups in priority order. Tackle in this order unless your feature intersects. @@ -698,14 +692,13 @@ current with the v0.7.1 source. skills-lock.json ← source URL + computed hash per skill ``` -`docs/plans/`: - -- `gpui-kit-upgrade.md` — audit baseline (evidence, decision, target architecture). -- `gpui-kit-upgrade-goal.md` — the per-milestone log kept current as the migration proceeded. -- `clipboard-coding-guides.md`, `clipboard-design-guides.md` — project-local copies of the user-facing UX rules; rename / extend as new UX ground rules land. - -`docs/reviews/post-kit-migration-review.md` — prioritised post-migration -review (worth reading cover-to-cover before opening your first PR). +The important takeaways from the migration's audit, milestone log, +post-migration review, and clipboard UX guides have been distilled +into the sections above. When you change a §1–§15 rule, mirror it +back into the matching local skill reference under +`.agents/skills/gpui-kit/reference/` or +`.pi/skills/gpui-kit-design-guides/references/` so future snapshots +of those skills stay current. --- @@ -732,7 +725,8 @@ Before you open a PR that touches the desktop UI, walk this list: shape: think about whether to start removing the namespace bridges (§1). They are now vestigial from the module-level view; they will eventually come out. -- [ ] `docs/plans/gpui-kit-upgrade-goal.md` and `docs/plans/gpui-kit-upgrade.md` - still reflect reality (or call out the divergence in this FRONTEND.md). +- [ ] If your change supersedes a §2–§15 rule, mirror it back into + the matching entry in `.agents/skills/gpui-kit/reference/` or + `.pi/skills/gpui-kit-design-guides/references/` (see §16). If all boxes tick, ship it. diff --git a/docs/plans/clipboard-coding-guides.md b/docs/plans/clipboard-coding-guides.md deleted file mode 100644 index 54ca777..0000000 --- a/docs/plans/clipboard-coding-guides.md +++ /dev/null @@ -1,911 +0,0 @@ ---- -title: Coding Guides -description: Architecture and coding conventions for maintainable GPUI Kit applications -order: -13 ---- - -# Coding Guides - -This guide describes the application architecture and code patterns that have -proved durable in GPUI Kit. It is written for both engineers and coding -agents. Read [Design Guides](./design-guides.md) first: code structure should -preserve product intent, not replace it. - -This is a normative guide. **Must** marks lifecycle, correctness, or ecosystem -constraints; **should** is the default architecture and requires a concrete -reason to depart from it. Current source and API docs remain authoritative for -exact signatures. - -## Architecture at a glance - -<img class="architecture-light" src="/application-layers-light.svg?v=20260822-16" alt="GPUI application architecture layers"> -<img class="architecture-dark" src="/application-layers-dark.svg?v=20260822-16" alt="GPUI application architecture layers"> - -Dependencies point downward. Higher layers own domain meaning and orchestration; -lower layers own reusable presentation or behavior. Do not make a reusable -component depend on an application screen, or make `gpui-base` depend on a -theme from GPUI Component. - -Use these boundaries: - -- **app shell:** compose windows and feature crates while keeping feature logic out; -- **feature crate:** keep one capability's model, services, views, commands, dialogs, - and workflow behind one public boundary; -- **app component:** a repeated domain-aware pattern; -- **gpui-component:** themed, general-purpose UI; -- **gpui-base:** reusable behavior and geometry without product presentation. - -### Organize large applications by capability - -In a large Rust application, a feature should usually be a crate, not another -file in a global `views`, `models`, or `modals` directory. Keep the model, -views, commands, dialogs, and workflow for one capability together. A dialog -that edits a workspace belongs to the workspace feature; only the reusable -dialog primitive belongs to the UI library. - -```text -crates/ -├── app/ -│ └── src/main.rs # Compose windows and features -├── workspace/ -│ └── src/ -│ ├── lib.rs # The feature's public boundary -│ ├── model.rs -│ ├── commands.rs -│ ├── workspace_view.rs -│ └── rename_dialog.rs -├── search/ -│ └── src/ -│ ├── lib.rs -│ ├── model.rs -│ ├── commands.rs -│ ├── search_view.rs -│ └── filters.rs -├── settings/ -│ └── src/ -│ ├── lib.rs -│ ├── model.rs -│ ├── settings_view.rs -│ └── account_dialog.rs -└── shared/ - └── src/ - ├── lib.rs - └── recent_items.rs # A stable capability with multiple owners -``` - -Do not invert this into global `models/`, `views/`, `modals/`, and `commands/` -directories. Those folders classify files by implementation role while -scattering every feature across the application. - -The application shell composes feature crates but contains little feature -logic. A feature may depend on stable shared capabilities and UI foundations; -it must not depend on the shell or reach into a sibling feature's internals. -When two features need to communicate, prefer an explicit command, event, data -type, or small shared service over a dependency between their views. Extract a -shared crate only after the capability has a coherent name and more than one -real owner. - -Crate boundaries are engineering boundaries. They let Cargo rebuild and test a -smaller dependency subgraph, make ownership visible in `Cargo.toml`, and limit -the review and regression surface of a change. They also make removal honest: -a feature that cannot be detached without searching through global view and -modal directories was never isolated. - -Do not create a crate for every screen or helper. Split where a capability has -its own state and lifecycle, a stable public seam, or enough implementation to -benefit from independent compilation and tests. Keep dependencies acyclic and -pointing toward smaller, more stable crates. - -## Bootstrap and root ownership - -Initialize GPUI Component once, before creating component-backed views, and put -`Root` at the first level of each window: - -```rust -app.run(move |cx| { - gpui_kit::init(cx); - - gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { - cx.new(|cx| Workspace::new(window, cx)) - }) - .expect("failed to open window"); -}); -``` - -`Root` coordinates window-level component facilities such as overlays and -notifications. Do not create a separate root for each page inside one window. -It also coordinates modal focus restoration, focus traps, tooltip/menu layers, -and window-scoped text selection. Bypassing it can produce behavior that looks -correct at rest but fails when overlays nest or focus changes quickly. - -## Understand GPUI's phases and contexts - -GPUI is retained state with declarative rendering. An entity survives across -frames; the element tree returned by `render` is a fresh description of the -current frame. Keep that distinction explicit. - -- `Context<Self>` mutates the current entity, creates listeners tied to it, - emits its events, and notifies its observers. -- `App` gives access to application globals and entity reads/updates without - implying ownership by the rendered element. -- `Window` owns focus, actions, input dispatch, element-keyed state, - measurement, and animation-frame requests for that window. -- layout, prepaint, and paint are later phases; use their hooks only when - resolved geometry is genuinely required. - -Never retain `&mut Window`, `&mut App`, or `&mut Context<_>` beyond the call in -which it is provided. Retain typed handles—`Entity`, `WeakEntity`, -`FocusHandle`, scroll handles, or domain IDs—instead. - -## Choose the right unit - -### Use `RenderOnce` for value-like elements - -Use a `RenderOnce`/`IntoElement` component when all inputs can be supplied by -the caller and the element does not need to retain application state between -frames. This is the normal choice for presentational wrappers and small -controls. - -```rust -#[derive(IntoElement)] -struct EmptyState { - title: SharedString, -} - -impl RenderOnce for EmptyState { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - div() - .v_flex() - .gap_2() - .items_center() - .text_color(cx.theme().muted_foreground) - .child(self.title) - } -} -``` - -### Use `Entity<T>` for retained behavior - -Use an entity-backed `Render` view when behavior spans frames or needs -observation, subscriptions, focus, async work, history, measurement, or -incremental updates. Store entities in an owning view rather than recreating -them in `render`. - -```rust -struct SearchView { - query: Entity<InputState>, -} - -impl SearchView { - fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { - let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search…")); - Self { query } - } -} -``` - -Do not turn every visual fragment into an entity. Entity boundaries have -lifecycle and coordination costs; use them where retained identity matters. - -### Elements, views, and behavior systems are different - -Do not force every component into one template. The ecosystem contains: - -- semantic elements such as Button, Checkbox, Link, and Tabs; -- compound behavior roots such as Dialog, Popover, Select, and Combobox; -- entity-backed systems such as Input, Table, Tree, Dock, and notifications; -- infrastructure such as positioning, virtualization, scrolling, focus traps, - motion, history, and measurement. - -An element may be internally complex and still be value-like to its caller. A -stateful system may expose render callbacks so applications own presentation -without reimplementing behavior. Choose the public seam from the behavior, -not from how many `div`s appear in its renderer. - -## State ownership - -Put each state in the narrowest owner that can keep it correct: - -- domain state belongs to a model or feature view; -- transient view state belongs to the view that renders it; -- reusable behavioral state belongs to the component state designed for it; -- tiny element-local state may use GPUI keyed element state; -- shared application services may be stored as GPUI globals. - -Prefer controlled values for ordinary selection and toggles: pass the current -value into the component, receive a requested change, update the owner, and -render again. A callback reports intent; it should not create a second hidden -source of truth. - -```rust -Checkbox::new("show-hidden") - .checked(self.show_hidden) - .label("Show hidden files") - .on_click(cx.listener(|this, checked, _, cx| { - this.show_hidden = *checked; - cx.notify(); - })) -``` - -Call `cx.notify()` after a mutation that changes rendering. Use `cx.emit(...)` -for a semantic event that an owner should handle, and `cx.subscribe(...)` or -`cx.observe(...)` when the lifetime should follow an entity. Keep returned -subscriptions alive when the API requires it. - -Do not notify merely because a value was read or derived. Avoid unconditional -notification from `render`; it schedules another render and can create a -permanent redraw loop. When several fields form one invariant, update them -together and notify once. A reusable state type that cannot receive a context -should make that limitation explicit and require its owner to emit/notify. - -### Avoid state feedback loops - -Text input, selection, filters, and controlled popups commonly have two paths: -an external owner updates the value, and user interaction requests a new value. -Do not send an owner-supplied value back through the user callback during sync. -Track the origin or compare coherent snapshots so each logical change is -reported once. Make callbacks re-entrancy-safe when a callback can synchronously -close, replace, or update the component that invoked it. - -## Stable identity - -An `ElementId` is part of behavior. It gives an element stable identity and keys -element-local or component state. A component may also use it as one input to -its own focus, measurement, or animation identity; focus and scrolling are -otherwise owned by their dedicated handles. - -- Use stable domain IDs for rows, tabs, tree nodes, and repeated controls. -- Namespace child IDs with their owning object when the same control repeats. -- Never derive identity from a translated label or a mutable list index when - items can be inserted or reordered. -- Do not generate a fresh random ID during `render`. - -```rust -Button::new(("delete-project", project.id)) - .danger() - .label("Delete") -``` - -A changed ID means a changed UI identity. Treat that reset as deliberate. - -The same rule applies to transition channels, overlay tokens, scroll handles, -and persistence IDs. If two independently retained behaviors share a key, they -can overwrite each other's state; if one behavior changes keys every frame, it -never accumulates state. - -## Rendering and composition - -Keep `render` declarative: read current state, derive presentation values, and -compose elements. Move domain operations, parsing, and non-trivial mutation to -named methods or services. - -```rust -impl Render for ProjectView { - fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { - div() - .v_flex() - .size_full() - .child(self.render_toolbar(cx)) - .child(self.render_content(cx)) - } -} -``` - -Extract a render helper when it names a meaningful region and reduces the -amount of state a reader must hold at once. Extract a new component when the -region has its own reusable contract or retained lifecycle—not merely because -a builder chain is long. - -Use GPUI Component's fluent traits consistently (`Sizable`, `Disableable`, -`Selectable`, and component-specific builders). Prefer `.when(...)` and -`.when_some(...)` for small conditional refinements; use ordinary Rust control -flow when branches represent substantially different interfaces. - -Compose from the standard semantic component before building a custom surface. -Do not reproduce a menu, select, dropdown, or command palette from generic -`div`s merely to match one screenshot. Reusing the component preserves its -item geometry, focus transfer, keyboard navigation, selection, disabled state, -dismissal, and accessibility contract. If the standard component cannot -express a recurring valid pattern, improve its explicit API instead of styling -arbitrary descendants at each call site. - -Render callbacks supplied by application code should be side-effect-free. A -list item renderer, menu builder, or dock panel renderer may run whenever its -owner needs to measure or redraw. It must not perform a business operation, -append data, or register an unbounded subscription. - -## Behavior and presentation boundary - -The durable Base rule is: - -> Base owns reusable behavior and the geometry required to implement it. The -> presentation layer owns the product's visual language. - -“Headless” does not mean “one empty `div`.” Popup collision, keyboard -navigation, editing, virtualization, resize arithmetic, focus trapping, and -dock reconciliation require internal structure and state. Moving that work to -every caller would not create flexibility; it would duplicate fragile -behavior. - -Conversely, Base must not choose brand colors, typography, density, final -icons, component variants, or application composition. Expose presentation -through `Styled`, typed semantic-state styles, explicit parts, child slots, and -item renderers. Do not inspect arbitrary descendants to discover titles, -descriptions, or close buttons—make semantic parts explicit. - -## Theme and styling - -Read semantic values from the active theme and apply layout with GPUI's -`Styled` methods: - -```rust -div() - .bg(cx.theme().background) - .text_color(cx.theme().foreground) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) -``` - -Rules: - -- do not hard-code product colors, corner radii, spacing, or control geometry; -- application code must not introduce raw hex, `rgb`/`rgba`, or `hsla`; read a - semantic color from `cx.theme()` or add the missing role to the product theme; -- application layout should use GPUI's rem-based scale helpers (`p_2()`, - `gap_3()`, `w_64()`, `text_sm()`) instead of direct `px(...)` values; -- use semantic tokens for meaning, not palette position; -- keep state-independent geometry in the ordinary builder chain; -- use GPUI `hover`, `active`, `focus`, and `focus_visible` modifiers for - runtime interaction states; -- use a component's semantic state styles for checked, selected, pressed, or - disabled appearance; -- keep popup ownership in explicit state so its trigger can render the open or - pressed appearance until dismissal; -- guard hover/active refinements when a disabled control must not react; -- keep component variants few and meaningful rather than adding a variant for - every call site. -- bind primary styling to the decision area's real default commit and Enter - action; do not derive it from action count, frequency, or toolbar position. -- keep Badge and Alert variants semantic and scarce. Ordinary metadata stays - neutral; do not map every enum case or section to a different color merely - because a variant exists. - -The effective precedence is instance style, active semantic value states, -disabled state, then GPUI runtime interaction refinements. Later layers only -replace fields they set. - -Prefer `Theme::semantic_tokens()` for new application-owned presentation. The -semantic token surface contains generic color roles plus radius, spacing, -typography, and shadow scales; it deliberately avoids component names. Legacy -component-specific theme values still exist for compatibility but should not -become the extension point for every application widget. - -There is one current ownership caveat: `Theme::spacing_tokens()` projects the -default scale, and `Theme::apply_semantic_tokens(...)` does not store custom -spacing or elevation scales. An application that customizes those scales must -retain its own `SemanticThemeTokens` (or narrower design-system state) and make -that state available to its components. Do not write a custom spacing snapshot -into the global theme and expect a later `cx.theme().semantic_tokens()` call to -return it. - -Edit the global GPUI Component theme through `Theme::update(cx, |theme| ...)`. -The theme keeps the same colors twice (`colors` as solid colors, `tokens` as -renderable backgrounds that may carry a gradient) and the Base layer holds a -projection for its scrollbars and resize handles; `update` brings all three -back in step after the closure and refreshes every window. An edit through -`Theme::global_mut(cx)` updates only the field you touched — a sidebar can then -paint its text from the new colors and its background from the old tokens — -so it owns the rest: derive `tokens` from `colors`, call `Theme::sync_base(cx)`, -refresh the windows. `Theme::change(...)` performs the projection as part of a -complete theme change. - -An outward focus ring needs physical room. An ancestor with -`overflow_hidden()` clips it. Prefer layouts that leave room; if a product must -clip heavily, use the theme's focus-ring policy and retain the focused border -instead of silently hiding all keyboard focus. - -### Base font is the application zoom control - -The Component Root plugin calls `window.set_rem_size(cx.theme().font_size)` from -its `prepare` hook before the Root surface is rendered. Therefore the theme's -base font is not only body typography; it is the reference length for the -application's rem-based design scale. This deliberately follows the useful part -of Tailwind's model: named type, spacing, and size steps share one relative base -instead of becoming unrelated pixel constants. - -Change zoom by updating the base font and refreshing the window: - -```rust -Theme::update(cx, |theme| theme.font_size = px(18.)); -``` - -The base font itself is a pixel value because it anchors the scale. Descendant -application UI should normally use relative helpers—`text_sm()`, `gap_2()`, -`px_3()`, `h_8()`, `size_4()`—so type, whitespace, controls, and icons respond -together. A custom component that combines rem-based text with fixed-pixel -padding or icon geometry must document why that part should not zoom. - -Treat every direct `px(...)` and raw color constructor in application UI as a -review finding. Accept it only for a documented physical/platform boundary, -measured runtime geometry, raster/data color, or the theme/token definition -itself. Convenience and matching a screenshot are not valid exceptions. - -Anything cached from resolved layout must include `window.rem_size()` in its -invalidation key, directly or through a revision that changes with it. This -includes wrapped row heights, text shaping/layout, virtual-list measurement, -popup and dialog geometry, icon sizing derived from text, and custom canvas -metrics. The Command component's variable-height rows are an ecosystem example: -they remeasure when rem changes because the same fixed width wraps differently -at a larger base font. - -Do not confuse this application zoom with Dock panel zoom. Dock zoom is a -stateful layout operation that makes one tab group fill the DockArea -while keeping the container chrome and the way back out. It must not modify the -window rem size. - -## Events, actions, and focus - -Use pointer callbacks for pointer-specific behavior. Use GPUI Actions for -commands that should support key bindings, menus, or dispatch from multiple -inputs. Keep action handlers close to the view that owns the command. - -Model one logical desktop command once. A toolbar Button, `DropdownMenu` item, -`ContextMenu` item, menu-bar item, and key binding should dispatch the same -Action or call the same owner method instead of copying five mutations. Derive -their label, icon, shortcut, and enabled state from one command policy where -practical, so the entry points cannot disagree. The menu owns navigation and -dismissal; the feature owner still owns whether the command is allowed and -what it does. - -Preserve semantic roles in the element choice. Use `Button` for commands even -when the desired treatment is quiet—select `outline`, `ghost`, or an icon -presentation instead of replacing it with `Link`. GPUI Kit applications -reserve `Link` for targets opened by a browser or mail client, such as a URL, -web document, or email address. Use the relevant navigation component for an -in-app destination and `Button`/`Action` for a command. This is a product -convention, not a limitation of `gpui_kit::base::Link`, whose `open_with` seam can -route a destination elsewhere. - -Only stop propagation when a nested interaction must prevent its parent from -handling the same event. Blanket propagation stops break menus, selection, -dragging, and window-level commands in ways that are difficult to diagnose. - -Bind keys before building the menu bar. `cx.set_menus` reads the keymap at -the moment it is called and bakes each item's shortcut into the native menu, -so a binding registered afterwards never shows next to its menu item and the -item does not react to the key. Call `cx.bind_keys` first, then -`cx.set_menus`; if the keymap changes later (a user keymap file, a locale -switch that rebuilds the menus), call `cx.set_menus` again. - -Make focus ownership explicit: - -- retain a `FocusHandle` in the entity that owns keyboard interaction; -- register key contexts and actions on the appropriate focused region; -- transfer focus when opening an overlay and restore it on dismissal; -- render a visible `focus_visible` state; -- do not request focus unconditionally from `render`. - -A tracked handle is a Tab stop only when it says so: build it with -`cx.focus_handle().tab_stop(true)` (or `.tab_index(n)`), because the element's -own `tab_index`/`tab_stop` settings do not apply to a handle passed to -`track_focus`. A stateless component may create that handle in `render` -through `window.use_keyed_state(id, cx, |_, cx| cx.focus_handle().tab_stop(true))`; -the keyed state survives re-renders, so the Tab order is stable — this is -what `Button` does. - -Attach a `key_context` and its `on_action` handlers to the same focused region. -Bindings are contextual: a registered Action without the intended focus path -is not a working keyboard interaction. Composite widgets should implement the -complete navigation model—arrow movement, Home/End or page movement where -appropriate, confirmation, cancellation, and Tab behavior—rather than a few -isolated shortcuts. - -Modal surfaces must trap focus and restore the previous valid focus target on -dismissal. Nested overlays dismiss from the top. Handle rapid close/open -sequences without restoring focus through an intermediate, already-closing -surface. - -## Async work and side effects - -Start async work from an event, lifecycle hook, or named method—not as an -unconditional side effect of `render`. Capture weak entities when work should -not keep a closed view alive. When the task completes, update state through the -GPUI context, handle the case where the entity or window no longer exists, and -notify once after the coherent state change. - -Represent async operations with explicit states such as idle, loading, loaded, -and failed. Preserve usable previous data during refresh when possible. Prevent -duplicate destructive submissions and surface recoverable errors in the UI; -do not rely on logs as user feedback. - -Use background executors for expensive parsing or computation, but keep GPUI -entity mutation on the appropriate application context. Results can arrive -after the request, document, view, or selection has changed; attach a revision -or identity and reject stale work rather than applying it to new state. - -## Layout, measurement, and scrolling - -`h_flex` centres its children on the cross axis; `v_flex` leaves flexbox's -default, `stretch`. This matches Zed's `h_flex`, and it is what a row of -controls wants, so a row of icon and label says nothing. It is not what a row -of full-height columns wants: a column placed in a bare `h_flex` does not fill -the row's height, so a column taller than the row is centred and its top — -commonly a header — is clipped off the top of the window, with nothing near the -column to say why. A row whose children are columns says `items_stretch()`: - -```rust -h_flex() - .items_stretch() - .size_full() - .child(sidebar) - .child(content) -``` - -Most UI should use GPUI layout rather than measuring itself. Measurement is a -deep behavior tool for popups, virtualization, editors, resize handles, charts, -and similar components whose correctness depends on resolved geometry. - -- Put measurement and geometry in the layer that owns the behavior. -- Observe bounds in prepaint only when ordinary layout cannot express the - relationship. -- Never mutate unrelated application state every prepaint. -- Treat measured data as frame- or revision-scoped; it can become stale after - typography, rem size, width, theme, or content changes. -- Centralize shared geometry such as popup flipping and viewport clamping so - every overlay follows the same edge policy. - -For alignment invariants, prefer construction over correction: sibling regions -should consume the same spacing token or shared inset instead of repeating -equivalent literals. Add geometry assertions or visual regression coverage for -critical repeated edges, columns, and gaps. Exercise more than the default -window: rem zoom and display scaling can turn fractional coordinates into a -one-physical-pixel drift even when the default screenshot looks aligned. - -Measure the resolved result when reviewing precision, but do not encode a -measured correction as a raw `px(...)` nudge. Trace the mismatch to duplicated -padding, nested insets, border ownership, font metrics, or rounding, then fix -the structural owner. - -`h_flex()` and `v_flex()` are not mirror images: `h_flex()` centers its -children on the cross axis, `v_flex()` leaves them stretching. A column placed -in a row therefore takes its content's height, not the row's, and a column -taller than the row is centered — its header is pushed off the top edge and -clipped. Give a full-height column `h_full()`, or give the row `items_start()` -or `items_stretch()`, whenever the child owns a header, a footer, or a scroll -region that must resolve against the row's height. - -Every scrollable region must have one owner. In flex layouts, apply -`min_w_0()` or `min_h_0()` to the flexible child that is allowed to shrink. -A flex item only drops its content-based automatic minimum size when its own -overflow is not visible, so an ordinary flexible child refuses to shrink around -long content until you say so. `Scrollable` handles this for its own wrapper, -but a plain `div` between it and the flex container still needs the minimum -released. Avoid accidental nested scrolling; route wheel input to the intended -axis and preserve platform/wasm differences when an API is not portable. - -Attach `Scrollable` to the element that owns the full panel, editor, or window -viewport so its scrollbar resolves against the region edge. Put content inset -inside that scroll owner rather than wrapping the scroll owner in a padded -container. A scrollbar floating between content and the panel boundary usually -reveals the wrong scroll owner or padding on the wrong layer. - -## Lists, tables, and large data - -Use virtualization when data can grow beyond a small, bounded collection. Keep -row identity separate from visible position and avoid cloning the full data set -on every render. Let a stateful list or table own navigation, selection, scroll -coordination, and visible-range calculation while item renderers own row -presentation. - -Separate: - -- source data and domain IDs; -- filtering/sorting state; -- selection state; -- viewport/scroll state; -- row rendering. - -This keeps updates local and prevents the view tree from becoming the data -model. - -Virtualization is a behavioral contract, not just a performance switch. Item -measurement must be invalidated when width, typography, rem size, or row -content changes. Keyboard selection and scroll-to-item must operate in model -coordinates even when most elements do not exist in the current frame. - -## Public API design - -For reusable components: - -- constructors should establish valid defaults; -- builders take and return `Self` and use domain language; -- callbacks describe requested changes and include pointer events only when - modifiers or pointer details are meaningful; -- evolvable behavioral seams use private fields, builders for construction, - and readers for inspection; -- boolean readers use `is_` or `has_` where a same-named builder exists; -- non-boolean setters use `with_` when readers need the plain field name; -- explicit compound parts are preferable to inspecting arbitrary descendants; -- adding reusable behavior must not force a product-level visual choice. - -Private fields are the default for behavioral state that must evolve without -breaking callers. Public fields are appropriate for deliberately record-like -configuration, theme tokens, geometry, and serialized schemas. Every public -struct with public fields must carry `#[non_exhaustive]`. Provide constructors, -`Default`, or builders so callers can create values without exhaustive struct -literals. This preserves the ability to add fields without breaking callers. -Apply this rule to new types and public API changes; unrelated existing types -can be migrated separately. - -Keep public module paths stable while reorganizing internals: use a module seam -with deliberate re-exports so folders can change without forcing downstream -imports to change. Prefer platform control terminology and established project -naming over web-framework vocabulary. - -## Platform and capability boundaries - -Do not assume every native or web target supports the same facility. Window -decorations, accessibility bridges, system notifications, clipboard behavior, -scroll gestures, fonts, and timing can differ. Put platform-specific code -behind a narrow capability seam and define the fallback behavior. - -A platform branch must preserve the semantic contract even if presentation -differs. For example, a system notification may have different retraction -support, but the application still needs a coherent delivery state. Test both -the shared state machine and the platform adapter where possible. - -## File and naming conventions - -- Name views and entities after product concepts: `ProjectList`, - `ProjectEditor`, `SettingsState`. -- Name event handlers after intent: `confirm_delete`, `open_project`, - `on_query_changed`. -- Keep one main responsibility per module; split a file when state ownership or - lifecycle can no longer be understood without reading unrelated behavior. -- Keep component module, state, events, and focused tests together when they - change together. -- Document invariants and surprising lifecycle constraints; do not narrate - obvious builder calls. -- Use `rustfmt` and satisfy the workspace's Clippy rules. Avoid broad `allow` - attributes that conceal unrelated warnings. - -### Vocabulary is part of the API - -Use the same word for the same concept across components. Before naming a new -method, search GPUI, `gpui-base`, and GPUI Component for the established term; -prefer macOS/Windows control terminology where the ecosystem has no precedent. -Localized documentation preserves exact API identifiers and established UI -framework terms when translation would reduce precision. Format identifiers as -code, explain retained terms when needed, and do not mix languages merely to -make ordinary prose sound technical. - -| Concept | Naming pattern | Example | -| --- | --- | --- | -| Value-like rendered control | noun | `Button`, `Checkbox`, `Tab` | -| Retained behavioral model | `<Control>State` | `InputState`, `TableState` | -| Imperative shared reference | `<Control>Handle` | `DialogHandle`, scroll handle | -| Semantic notification | `<Control>Event` | `TableEvent`, `SelectEvent` | -| Keyboard command | verb or intent noun | `Confirm`, `Cancel`, `SelectNext` | -| Pluggable data/behavior owner | `<Role>Delegate` / `<Role>Provider` | `TableDelegate`, `CompletionProvider` | -| Application-supplied presentation | `render_<part>` or `<part>_renderer` | `render_item` | -| Construction | `new`, or a semantic constructor | `new`, `horizontal`, `vertical` | -| Fluent property | noun/adjective | `label`, `disabled`, `selected`, `placement` | -| General non-boolean replacement builder | `with_<field>` | `with_size`, `with_mode` | -| In-place mutation | `set_<field>` | `set_items`, `set_selected_index` | -| Boolean reader | `is_<adjective>` / `has_<noun>` | `is_open`, `is_closable`, `has_selection` | -| Plain value reader | field noun | `placement`, `selected_value` | -| Callback registration | `on_<event or intent>` | `on_click`, `on_open_change` | -| Rendering a named region | `render_<region>` | `render_toolbar`, `render_content` | - -For new APIs, fluent builders omit `set_` because they consume and return -`Self`; mutation through `&mut self` uses `set_`. Preserve established public -names when changing them would cause needless churn. Existing builder names -such as `set_position` are compatibility exceptions, not patterns for new APIs. - -A boolean reader is either `has_<noun>`, when the value holds something, or -`is_<adjective>`, when it describes a state or a permission. Reach for the -adjective whenever the action has one: `is_closable` over `can_close`, -`is_zoomable` over `can_zoom`, `is_copyable` over `can_copy`. When the action -is a verb phrase with no adjective form, name the thing it needs instead: -`has_definition`, not `can_go_to_definition`. Do not add new `can_` readers. - -Boolean builders may use the field name (`disabled(bool)`) while their readers -use `is_disabled()`. For a public seam struct containing non-boolean fields, -use `with_item_ix(...)` for construction and `item_ix()` for reading so setter -and getter names never collide. Prefer `_ix` for new local or internal -zero-based indices, preserve established public terms such as `selected_index`, -and do not introduce `_idx`. If callers never construct the seam value, do not -publish a builder merely for symmetry. - -### Let the enclosing name carry the context - -A name is read inside something. A field is read inside its type and a parameter -inside its method, so neither repeats what encloses it: `with_item_ix(ix)`, not -`with_item_ix(item_ix)`. - -Keep one type's fields at the same level of abbreviation. A single field spelled -out in full becomes the odd one out, and a reader goes looking for the -distinction that made it different. Because a builder is named `with_<field>`, -shortening a field shortens its builder with it and the pair stays matched. - -Shorten only where the enclosing name really does disambiguate. When a short -form is also the established term for a *different* quantity elsewhere in the -ecosystem, say which one you mean in the doc comment rather than lengthening the -identifier — the doc is read at the call, and it can explain what a longer name -could only hint at. - -### Use precise domain words - -- **selected** is persistent membership or the active item; **focused** is the - current keyboard target; **hovered** is pointer presence; **confirmed** is an - activation result. Never use them interchangeably. -- **open/close** describes an overlay or disclosure state; **show/hide** is for - transient presentation requests; **expand/collapse** describes structure. -- **disabled** prevents interaction; **readonly** permits navigation and - selection but prevents editing; **loading** prevents duplicate work while an - operation is pending. Spell the state `readonly` — one word, as the - `readonly(bool)` builder and `is_readonly()` reader do — in identifiers, - interface labels, and documentation alike; never `read-only` or `read only`. -- **index** is a current positional coordinate; **id** is stable identity; - `IndexPath` represents hierarchical position. Do not persist or key - reorderable data by index. -- **value** is controlled domain data; **presentation** is a read-only snapshot - prepared for rendering; **state** is retained behavior. -- **placement** is a side or anchor policy; **position** is resolved geometry. -- **size** is a semantic control tier; **width/height/bounds** are geometry. -- **child/children** follows GPUI composition; named slots such as `header`, - `footer`, `trigger`, and `content` carry additional semantics. - -Avoid vague public names such as `data`, `item2`, `handle_action`, `update_ui`, -`process`, `manager`, or `config` when a narrower domain term exists. `Manager` -is appropriate only when a type truly coordinates a collection or lifecycle, -as `ToastManager` does. - -### Type and module style - -- Rust types and Actions use `UpperCamelCase`; modules, functions, methods, - fields, and local variables use `snake_case`; constants use - `SCREAMING_SNAKE_CASE`. -- A module named after a component owns its public seam. Internal folders may - split state, element, geometry, platform adapter, and tests without leaking - those folder names into imports. -- Use singular module names for one component concept and established - ecosystem names for families (`input`, `table`, `dock`). -- Suffix type-erased wrappers with `Any` only when they erase a real type - boundary, such as `AnyInputState` or `AnyElement`. -- Suffix identifiers with `Id`, zero-based indices with `ix`, and collections - with meaningful plurals. Do not alternate `idx`, `index`, and `ix` in one - subsystem. -- Name predicates positively when possible. A positive `enabled`/`visible` - contract is easier to compose than multiple negatives, but preserve - established API terms such as `disabled` where they match control semantics. - -### Callback and event wording - -Use `on_click` only for a genuine click-level contract. A controlled semantic -primitive in Base should prefer `on_change(next_value, ...)`; a styled -compatibility component may retain `on_click` when pointer details or existing -API expectations matter. Do not invent a `ClickEvent` for a model-driven -change. - -Name before/after lifecycle hooks precisely. `on_will_change` can veto or -prepare; `on_change` observes a requested/current value contract; `on_confirm` -commits a choice; `on_dismiss` closes a transient surface. Document whether a -callback runs before internal state changes, after them, or instead of them, -and whether it may synchronously re-enter the component. - -### Documentation and copy style - -Public docs should begin with what a type does and who owns its state. Examples -must use current, compilable APIs and show stable IDs. Document defaults, -platform limitations, focus behavior, callback ordering, and any requirement -to call `notify`, `emit`, or a theme synchronization method. - -Follow the [interface-language rules](./design-guides.md#interface-language) for -labels, commands, confirmation dialogs, capitalization, and ellipses. Keep one -canonical term for each domain object, command, and state. - -Translation keys describe stable intent (`dialog.delete_project.title`), not a -source-language sentence or a screen coordinate. Never assemble a sentence -from translated fragments or reuse one key for meanings that happen to share -the same English text. - -Localize intent, not syntax. Give every locale control over word order, -pluralization, punctuation, and the amount of context it needs. Review strings -inside the component and with realistic data. Tests or linting should catch -missing keys, unintended CJK text in English resources, three-dot ellipses, -unreviewed ALL CAPS, and inconsistent fixed terms; human review still decides -whether repetition is justified by context. Verify every string inside its -component with realistic content, text expansion, and application zoom. - -## Testing strategy - -Test at the lowest layer that can prove the behavior: - -1. pure tests for state transitions, geometry, parsing, and ordering; -2. GPUI context tests for entities, events, and subscriptions; -3. `VisualTestContext` interaction tests for focus, keyboard, pointer, layout, - and rendered state; -4. example or application smoke tests for complete workflows. - -For an interactive component, cover the semantic contract rather than its -implementation details: pointer and keyboard activation, controlled value -changes, disabled behavior, focus movement, event count/order, stable identity, -and important empty or failure states. Add a regression test before fixing a -bug whenever the failure can be reproduced deterministically. - -For UI behavior that depends on the real window system, test through the -accessibility tree by role, label, value, enabled state, focus, and selection. -Re-read the tree after every state-changing action because element indexes are -snapshots. Use screenshots for visual facts the semantic tree cannot express; -use coordinate input only as a fallback. Report automated and manual evidence -separately. - -## Performance rules - -- Do not mutate state or notify unconditionally in `render`. -- Avoid rebuilding entities, subscriptions, focus handles, and expensive data - structures per frame. -- Notify the narrowest owning entity after a coherent state change. -- Virtualize long collections and render only the visible range. -- Avoid cloning large strings or collections solely to satisfy a closure; - capture stable handles or shared data. -- Measure before adding caches. A cache must have a clear invalidation owner. -- Keep animation work bounded and honor reduced motion. - -## Common failure modes - -Avoid these patterns: - -- one entity containing the entire application's unrelated state; -- business logic and network requests embedded in a long `render` method; -- random or index-based `ElementId` values for reorderable content; -- literal colors and radii that break custom themes; -- custom clickable `div`s where a semantic component already supplies focus, - keyboard, disabled, and accessibility behavior; -- duplicated local state that drifts from a controlled model value; -- `cx.notify()` loops caused by mutation during every render; -- nested scroll containers without explicit ownership; -- a new component variant for a one-off screen; -- confirmation dialogs for reversible, low-risk actions; -- tests that call internal methods but never exercise keyboard or pointer - behavior. - -## Rules for coding agents - -Before editing, an agent must read the nearest implementation, its tests, the -re-export seam, and the relevant component documentation. It must search the -current source for signatures instead of translating a React, CSS, or old GPUI -example by analogy. - -For each change, the agent should be able to name: - -1. the behavior owner and presentation owner; -2. the retained identity and state lifecycle; -3. the pointer, keyboard, focus, and accessibility contract; -4. the layout and overflow owner; -5. the theme tokens and intentional exceptions; -6. the test that would fail if the behavior regressed. - -Generated code must be reviewed and tested by a person. “Compiles” is not a UI -quality bar, and a broad refactor that merely makes generated code look tidy is -not a substitute for matching the repository's architecture. - -## Implementation checklist - -Before opening a change for review, confirm that: - -- state and side-effect ownership are explicit; -- `RenderOnce` versus `Entity<T>` is chosen deliberately; -- repeated elements have stable domain-based IDs; -- theme tokens and component sizes replace isolated visual literals; -- keyboard actions, focus, disabled state, and overlays work together; -- loading, empty, error, and cancellation paths are represented; -- long data sets use an appropriate virtualized component; -- public API additions preserve dependency direction and encapsulation; -- tests prove behavior at the appropriate layer; -- formatting, Clippy, targeted tests, and relevant examples pass. - -See [Getting Started](./getting-started.md) for application setup and the -component pages for current API details. diff --git a/docs/plans/clipboard-design-guides.md b/docs/plans/clipboard-design-guides.md deleted file mode 100644 index fc3dc08..0000000 --- a/docs/plans/clipboard-design-guides.md +++ /dev/null @@ -1,950 +0,0 @@ ---- -title: Design Guides -description: Product and interaction design guidance for GPUI Kit applications -order: -2.1 ---- - -# Design Guides - -Use this guide before choosing components or writing layout code. It records -the product judgment accumulated through years of GPUI Kit desktop work: -an interface should feel native, restrained, precise, and understandable -without guesswork. - -This is a normative guide. **Must** identifies a correctness or ecosystem -constraint, **should** is the default that needs a concrete reason to override, -and **may** is an optional technique. Component API documentation remains the -authority for individual methods. - -The rules build on behavior in `gpui-base`, the GPUI Component theme and -component system, and familiar desktop interaction. Shadcn contributes useful -methods—open code, composition, and dependable defaults—but does not determine -how a GPUI application should look. When influences conflict, preserve GPUI's -lifecycle constraints and the interaction people already understand. - -## Design thesis - -Build interfaces that feel native, quiet, and precise. Let content, hierarchy, -and interaction carry the experience; decoration should support them rather -than compete with them. - -1. **Clarity before personality.** Make the primary task and next action clear - before adding brand expression. -2. **Composition before invention.** Start with established components and - compose them into product-specific workflows. Create a new primitive only - when its behavior is genuinely new. -3. **Tokens before values.** Colors, radii, typography, and spacing should form - a system. Avoid isolated literals that cannot respond to themes. -4. **Desktop before web convention.** Preserve keyboard access, window chrome, - menus, dense data views, resizable regions, and persistent navigation where - the task benefits from them. -5. **State must be visible.** Hover, focus, selection, disabled, loading, - validation, and destructive states need distinct and consistent treatment. -6. **Geometry explains containment.** Radii, insets, and boundaries should - describe one coherent hierarchy, even where surfaces overlap. -7. **Feedback reflects reality.** Loading, tooltips, and motion should clarify - a real state or transition, not add ceremony to an immediate action. - -## Learning from Shadcn - -Shadcn's most useful contribution is not a particular border color. It is a -way of building a system: - -- own the top layer of the interface instead of fighting a sealed abstraction; -- compose small, predictable parts into product-specific components; -- provide defaults that already form one visual language; -- keep the code and composition legible to both people and AI; -- separate behavior primitives from the styled layer. - -GPUI Kit applies those ideas through a Rust library and the split between -`gpui-base` and `gpui-component`. Applications normally compose or wrap the -published components; contributors move genuinely reusable behavior into Base -and keep visual policy above it. - -Do not copy these web assumptions blindly: - -| Web habit | Native GPUI default | -| --- | --- | -| Pointing-hand cursor on every button | Default arrow cursor; pointing hand for links | -| Page navigation as the main structure | Persistent windows, panes, sidebars, tabs, and menus | -| Browser focus and scrolling as a fallback | Explicit focus ownership and region-owned scrolling | -| Mobile-first single column | Resizable desktop shell with a defined minimum window size | -| Hover-revealed critical actions | Keyboard- and pointer-reachable actions that do not depend on hover | -| A row of hover-only icon buttons | A visible primary action plus `DropdownMenu` or `ContextMenu` for secondary commands | -| Link-styled text for application commands | `Button`, `outline`, or `ghost`; Link only for URLs, web resources, or email addresses | -| Large touch density everywhere | Medium density by default; compact only where information work benefits | -| CSS overrides across descendants | Typed builders, semantic parts, and application composition | - -## Start from the task - -Before drawing a screen, write down: - -- the user's primary task; -- the object being viewed or changed; -- the actions that must remain immediately available; -- the information required to make a decision; -- empty, loading, error, offline, read-only, and permission-denied states; -- the keyboard path through the workflow. - -Organize the window around those answers. Do not begin with a dashboard grid or -a component catalogue. A good desktop interface exposes the user's mental -model: documents, accounts, projects, messages, settings, or another stable -object—not the internal service architecture. - -Design the primary task before distributing controls. Its visual weight, -location, and information depth should match its importance to the product. A -core result must not be reduced to a small count, an icon in a corner, or a -weak footer action while secondary content consumes the page. When a result set -is the product's main value, consider a summary region or card that exposes the -count, representative results, meaningful state, and a clear next action. - -For every proposed action, name its visible object, current state, scope, and -result. If the interface does not show or clearly imply those things, the -action is premature. Do not expose a capability merely because the backend has -it; first design how the object enters the user's mental model. - -## Visual language - -### Hierarchy - -Prefer a small number of clear levels: - -- **window or page title** identifies the current object or workspace; -- **section title** separates meaningful regions; -- **body text** carries the work; -- **muted text** provides secondary metadata and help; -- **labels** identify controls and values. - -Use size, weight, spacing, and separators before adding color or containers. -Avoid nesting cards inside cards: most desktop regions need only a background, -a hairline boundary, and intentional spacing. - -Evaluate hierarchy across the whole feature, not component by component. Hide -accent color and decoration during review: the primary task, current selection, -result summary, and next action should still be obvious from structure. A -screen that contains individually plausible controls can still fail when they -do not form one reading order and one decision path. - -Treat emphasis as a limited budget. A local surface needs one clear focal point, -not a field of competing highlights. If everything is colored, badged, bold, -boxed, or promoted to an alert, nothing reads as important. Establish priority -with structure and proximity first; spend stronger color and components only -where a distinction changes what the user notices or does. - -### Color and themes - -Read colors from `cx.theme()` and use them by semantic role: - -- `background` and `foreground` for the main surface and text; -- `group_box`, `popover`, `sidebar`, and their foreground tokens for their - named surfaces; -- `muted` and `muted_foreground` for supporting information; -- `primary` for the principal action or selection emphasis; -- `danger`, `warning`, `success`, and `info` only for their meanings; -- `border`, `input`, and focus-ring tokens for structure and interaction. - -Do not use a semantic status color as decoration. Do not encode meaning by -color alone. Verify every custom surface in light and dark themes and with -custom theme values; never assume that foreground is black or background is -white. - -Use Badge for a short state, count, or classification that benefits from rapid -scanning—not for every label, metadata value, filter, or section title. Keep -most badges neutral; reserve semantic variants for states that truly carry -success, warning, danger, or informational meaning. A row of multicolored -badges is usually a missing hierarchy or grouping decision. - -Application UI should not contain raw hex, `rgb`/`rgba`, or `hsla` colors. -Resolve colors from `cx.theme()` by semantic role. If the required role does -not exist, define it in the product's theme/token layer rather than embedding a -palette value at the call site. Raw colors belong only inside theme definitions -or in audited data/raster content whose color is itself the data. - -### Radius, spacing, and density - -Use a finite radius scale, as with type and control sizes. Choose from the -theme's named tiers (`sm`, `md`, `lg`, `xl`, and larger surface tiers) instead -of inventing a radius for each element. Large containers may use a softer tier -than the controls they contain; compact controls and overlays use tighter -tiers. For an ordinary rectangle, the radius should remain visibly smaller -than half its shorter side. Use `radius_full()` only for intentional circles -and pills; a tooltip must not become a capsule by accident. - -Nested surfaces should read as concentric shapes. Use -`inner radius ≈ max(0, outer radius − gap)` to choose the closest suitable -theme tier; the visible band should remain even through the turn. Add a -named application token if a repeated composition needs an intermediate -value. A segmented control is one silhouette: its end segments follow the container's inner -corners, its middle segments remain square, and each boundary has one divider -of consistent thickness. - -<img class="alignment-light" src="/radius-hierarchy.svg" alt="Light theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center."> -<img class="alignment-dark" src="/radius-hierarchy-dark.svg" alt="Dark theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center."> - -A radius must govern the entire visible surface, not just its border. Exposed -corners remain transparent to the surface behind them; child backgrounds, -selection fills, and scrolling content must also follow the boundary. In GPUI, -do not assume a rounded parent clips its descendants. Give inner surfaces their -own radius or an appropriate clip, then inspect the composition against a -contrasting background in both themes. Preserve outward focus rings when -choosing a clip. - -Use a compact spacing scale and repeat it. Related label/control pairs should -be closer than separate groups; separate groups should be closer than separate -sections. Prefer component sizes (`xsmall`, `small`, default medium, `large`) -over one-off heights. Use compact variants for toolbars and data-dense screens, -not to squeeze an unclear layout into less space. - -The shared semantic scale is intentionally small: spacing progresses through -roughly 2, 4, 8, 12, 16, 24, and 32 pixels, while typography stays near 12, -14, 16, 18, and 20 pixels. Treat these as relationships rather than permission -to scatter their current values through feature code. GPUI Component currently -projects a fixed default `SpacingTokens` scale from its global `Theme`; unlike -colors and radii, `Theme::apply_semantic_tokens` does not persist a custom -spacing scale. An application that needs different spacing must own that full -token snapshot and use it consistently in its application components. - -### Spatial grammar - -Spacing expresses relationship. Choose a gap from the semantic scale by asking -what the two things mean to each other: - -| Relationship | Typical token | Current scale | Examples | -| --- | --- | --- | --- | -| Optical correction | `xxs` | 2 px | icon baseline, compact separator | -| Parts of one control | `xs` | 4 px | menu icon/label, title/description | -| Closely related controls | `sm` | 8 px | button icon/label, dialog actions | -| One content group | `md` | 12 px | notification columns, compact form rows | -| Separate groups in one section | `lg` | 16 px | panel padding, form groups | -| Separate sections | `xl` | 24 px | major blocks in a page or inspector | -| Major region boundary | `xxl` | 32 px | empty-state breathing room, page bands | - -These values describe the current default scale, not literals to repeat. Use -`cx.theme().spacing_tokens()` or the corresponding GPUI scale helpers for the -ecosystem default. A product-owned scale should preserve the ordering and -relationships and must be passed through the application's own design-system -context rather than assumed to persist in the global GPUI Component theme. - -Use these rules when resolving horizontal and vertical space: - -1. **Inside before outside.** A component's padding belongs to the component; - the gap between components belongs to their parent. -2. **Vertical rhythm shows grouping.** The gap between a title and its - description is smaller than the gap from that description to the next - section. Equal gaps imply equal relationships. -3. **Horizontal space supports scanning.** Repeated rows keep icons, labels, - values, badges, and trailing actions on stable columns. -4. **Leading and trailing are semantic.** Think in reading-order edges even - when the current API uses left/right; this keeps future RTL adaptation - possible. -5. **Do not double padding.** A card placed in an already padded panel should - not automatically add another full panel inset. -6. **Use optical alignment sparingly.** A one- or two-pixel correction is valid - for icon or glyph geometry, but document why it differs from the scale. - -Common compositions in the current system illustrate the relationships: - -- button contents use 4 px at small sizes and 8 px at normal sizes; -- dialog headers and footers use an 8 px internal gap; -- compact list and menu rows use 4 px vertical and 8–12 px horizontal padding; -- sheet headers use about 16 px leading and 12 px trailing space, leaving room - for a close affordance, while footers use 16 px horizontal and 12 px vertical - space; -- notifications use 16 px horizontal padding and a 12 px column gap because - icon, message, and action are distinct groups. - -Do not treat these as copy-and-paste recipes for every surface. They reveal the -system: controls are tighter internally, rows optimize scanning, and -containers spend more space at their boundary than between their contents. - -### Proportion and layer hierarchy - -Start with content requirements, then set proportions. Avoid arbitrary halves -when one pane has a clearly different role. - -- A navigation sidebar should be wide enough for stable labels but visibly - subordinate to the work area. Give it a minimum, preferred, and maximum - width rather than a percentage alone. -- In master–detail layouts, let the collection remain scannable and give the - detail pane the surplus. A roughly one-third/two-thirds starting point is - often useful, but content constraints are authoritative. -- Inspectors and auxiliary sheets should not cover the primary object by - default. They should be resizable or dismissible when their content grows. -- Dialog width comes from the decision: short confirmation, medium form, or a - dedicated window/page for complex work. Do not enlarge a dialog simply to - create whitespace. -- Reserve the strongest elevation for the topmost decision layer. Within a - layer, use background and hairlines—not successively larger shadows—to show - hierarchy. - -Define three size constraints for every major region: the minimum at which its -task still works, a comfortable default, and how it consumes surplus. Persist -user-controlled splits when they represent workflow preference, and clamp -restored values against the current window. - -### Alignment details - -Alignment is a structural system, not a final polish pass. Establish a small -set of alignment spines for each surface: shared leading and trailing edges, -text baselines, center lines, and fixed functional lanes. Elements at the same -level should attach to the same spine from top to bottom or leading to trailing, -even when they are different component types. - -<img class="alignment-light" src="/alignment-spines.svg?v=20260822-4" alt="Alignment spines across a desktop surface"> -<img class="alignment-dark" src="/alignment-spines-dark.svg?v=20260822-4" alt="Alignment spines across a desktop surface"> - -Vertical red lines sit beside shared edges or control centers for content, -status, time, and trailing actions. Horizontal lines sit beneath text baselines -or pass through a row center to show bottom and vertical-center alignment. The -compact comparison isolates a one-rendered-pixel drift that must be corrected -at its structural owner. - -- Give sibling regions a shared content inset. A heading, toolbar, list row, - empty state, and footer that describe the same level should not each invent a - slightly different leading edge. -- Repeat column geometry through the whole region. Headers, rows, summaries, - loading states, and inline editors should reserve the same lanes for identity, - metadata, status, numbers, and actions. -- Align related controls across rows and sections. Form labels, fields, - descriptions, and validation messages should reveal a stable vertical grid - when the page is scanned from top to bottom. -- Keep horizontal bands coherent. Items sharing a toolbar, title bar, status - bar, or row should use one baseline or center line instead of individually - tuned offsets. -- Introduce indentation only for real hierarchy, containment, or disclosure. - Decorative indentation makes siblings look subordinate and breaks the - surface's reading line. -- When a nested level ends, return exactly to the parent spine. Do not let - accumulated padding drift across nested containers. -- Preserve the spine through optional content. Missing icons, badges, - descriptions, or trailing actions must not move the remaining labels; use - intentional slots or lanes when cross-row comparison matters. -- Align major regions with one another where their hierarchy matches. Sidebar - headers, content titles, split panes, toolbars, and bottom bars need not share - every coordinate, but coincident levels should form visible continuous lines. - -Not every edge should align. A child can indent, a primary value can lead its -supporting metadata, and a destructive decision can gain separation. Such -exceptions must communicate hierarchy or meaning; they must not result from -uncoordinated component padding. Start with the shared spine, then make the -exception explicit. - -Treat exact alignment and repeated gaps as quality invariants. When two edges -or spaces are intended to be equal, a one-rendered-pixel difference is a defect, -not an acceptable optical approximation. Inspect resolved bounds with a -measurement tool at representative window sizes, zoom levels, and display scale -factors. Compare coordinates and distances; do not approve alignment only from -a casual screenshot. - -The rendered-pixel tolerance is a verification rule, not permission to patch -the code with raw pixel offsets. Equal relationships should resolve from the -same `rem` helper, spacing token, grid definition, or shared component inset. -Fix the common owner when they differ. Account for fractional layout and device -rounding so intended spines land on the same physical pixel instead of drifting -at particular zoom levels. - -- Align text by baselines, not by bounding-box centers, when mixed sizes share - a row. -- Center icons in a fixed slot so labels do not move when icons differ in - intrinsic width. -- Right-align comparable numbers; left-align prose and identifiers unless the - locale requires otherwise. -- Keep trailing row actions and disclosure indicators in fixed-width lanes. -- Align form controls by their interactive frame, not by help text below them. -- Use `justify_between` only when the two sides truly own opposite edges; it - should not disguise missing structure in the middle. -- Hairlines belong on the boundary owner. Two adjacent regions must not each - draw the same separator. -- A scrollbar belongs to the region that scrolls and sits against that panel, - editor, or window's trailing edge. Content padding may inset text and rows; - it must not pull the scrollbar into the middle of the surface. Reserve a - deliberate scrollbar gutter when content needs clearance. - -### Density tiers - -Medium is the ecosystem default. Change density for the whole local context, -not one isolated control: - -- **comfortable / large:** onboarding, sparse forms, prominent decisions; -- **standard / medium:** most application chrome and workflows; -- **compact / small:** toolbars, menus, tables, and repeated professional data; -- **extra compact / xsmall:** exceptional high-density utilities, never the - automatic choice for an entire application. - -The current controls demonstrate a bounded scale rather than arbitrary sizing: -buttons commonly move through approximately 20, 24, and 32 px frames; input -and data controls may extend to about 44 px at large size; table rows use about -26, 30, 32, and 40 px. Use the component's `Size` API so typography, icon, -padding, and hit target change together. A custom height that changes only the -outer box is usually incomplete. - -### Zoom, base font, and `rem` - -A well-designed `rem` system preserves hierarchy while the interface zooms. -Zoom is successful when the relationship between title and body, control and -icon, inner and outer spacing, primary and secondary regions still feels the -same at every scale—not merely when every object becomes larger. - -GPUI Component adopts the relative-scale idea familiar from Tailwind. The -theme's base `font_size` becomes the window's `rem` through `Root`, and GPUI -scale helpers such as `text_sm()`, `gap_2()`, `p_4()`, `h_8()`, and `size_4()` -resolve against it. This gives typography, spacing, controls, and icons one -shared zoom axis. - -Design in ratios: - -- type steps keep the same hierarchy around the base body size; -- spacing steps keep the same grouping relationships around the type; -- control frames, icons, and hit targets scale with their labels; -- pane minima and comfortable widths account for the scaled content; -- corner radii and focus treatment remain optically consistent with the - control frame. - -Do not implement zoom by changing text size alone. A larger label inside a -fixed-height button, a larger document inside fixed pane minima, or larger rows -inside a stale virtual-list measurement destroys the original rhythm and can -clip content. Conversely, multiplying every physical pixel—including -hairlines—can make the interface visually heavy. - -As a rule, application layout should not call `px(...)` directly. Use GPUI's -rem-based scale helpers (`p_2`, `gap_3`, `w_64`, `text_sm`, and related -builders) or semantic component sizes. Use fixed pixels only when the value -represents a physical or raster boundary: -a one-device-pixel hairline, platform window inset, bitmap dimension, minimum -hit-test tolerance, or geometry that must match an external surface. These are -audited, documented exceptions. Product spacing, typography, icon size, and -ordinary control geometry stay on the relative scale. - -Test interface zoom at several base-font values, not just the default. Verify -hierarchy, wrapping, truncation, minimum window size, pane resizing, focus-ring -clearance, popup placement, and virtualized row measurement. Also distinguish -interface zoom from Dock's panel zoom: Dock zoom makes one container fill its -area while retaining its chrome; it does not change `rem` or application scale. - -### Surfaces and elevation - -Use elevation to explain stacking, not importance. The base window surface is -flat; separators and background contrast define its regions. Popovers, menus, -dialogs, and notifications may use progressively stronger shadows because they -sit above other content. Do not put a shadow on every card. - -All surfaces of the same kind should share one treatment. GPUI Component, for -example, deliberately gives popup families one themed popover surface so -Popover, Select, Combobox, DatePicker, and menus do not drift apart. When an -application invents another anchored surface, reuse that semantic treatment -instead of approximating it with unrelated border and shadow literals. - -### Typography and icons - -Use the platform UI font for interface text and monospace only for code, -identifiers, shortcuts, and aligned numeric data. Keep body text readable and -avoid excessive uppercase or letter spacing, especially for CJK text. - -Use one icon family in a product. Icons supplement labels; they should not -replace unfamiliar actions with guesswork. Icon-only buttons always need an -accessible name. Their visual explanation follows the tooltip policy below. -Use filled or colored icons to communicate a state, not merely to make a -toolbar lively. - -## Layout patterns - -### Choose a stable shell - -Most applications should use one of these shells: - -- **single workspace:** toolbar or title bar above one primary view; -- **sidebar workspace:** persistent navigation beside a changing detail view; -- **master–detail:** resizable collection and detail panes; -- **document workspace:** tabs or a dock area for multiple long-lived objects; -- **utility window:** one focused task with a short, fixed action path. - -Keep global navigation stable while content changes. Give the primary work area -the remaining space with `flex_1()` and `min_w_0()` / `min_h_0()` where -overflowing children must shrink. Use `Scrollable`, `VirtualList`, `Table`, or -`DockArea` for their intended behavior instead of rebuilding scrolling or pane -management from nested `div`s. - -The title bar is window chrome first. Preserve its drag region and avoid -binding ordinary title clicks to infrequent editing commands. Rename belongs -behind an explicit object command, with a keyboard path where appropriate. - -### Responsive desktop windows - -Desktop does not mean fixed-size. Decide what happens as a window narrows: - -1. preserve the primary task; -2. allow resizable regions to reach a documented minimum; -3. collapse secondary labels or inspectors; -4. move low-frequency actions into a menu; -5. scroll only the region whose content actually overflows. - -Do not hide an action without providing another path to it. Avoid making the -entire window scroll when only a list or document body should scroll. - -GPUI flex layouts have the same intrinsic-size pressure found in other layout -systems: a `flex_1()` child may still refuse to shrink around long content. -Design and implementation must agree on which panes may shrink, truncate, wrap, -or scroll. A clipped region also clips an outward focus ring; never trade away -keyboard visibility merely to simplify overflow. - -### Forms and settings - -Use a visible label for each field and place help or validation next to the -field it describes. Align related fields, but do not force long labels into a -narrow fixed column. Use the appropriate control: `Checkbox` for independent -choices, `RadioGroup` for a small visible set, `Select` for a longer set, and -`Switch` for a setting that takes effect immediately. - -Disable submission while an operation is in flight, keep the user's input, and -show the result near the action. Reserve dialogs for short, focused decisions; -use a full page or sheet for workflows that need exploration or many fields. - -A loading treatment must represent actual waiting. Switching between settings -panels whose content is already available should be immediate; a skeleton is -appropriate only while content is genuinely loading and its shape is known. - -## Components and composition - -Follow the Shadcn principle that components are building material rather than a -sealed design system. GPUI Component supplies coherent defaults, while the -application owns composition and product semantics. - -- Use component variants by meaning. Primary is reserved for the explicit - default commit in a decision area—normally the action invoked by Enter. A - lone, frequent, or desirable action is not automatically primary. An `Add` - command in a management toolbar normally uses a default Button; a form's - default `Create` commit may use primary. Use `danger` for destructive - commitment and `ghost` for quiet toolbar actions. -- Prefer explicit compound parts and render callbacks over styling arbitrary - descendants. -- Keep a repeated pattern consistent across the product. Wrap it in an - application component when it carries domain language or policy. -- Use the standard component for its semantic role. A menu, dropdown menu, - popover, select, and command palette are not interchangeable boxes; each owns - different selection, focus, keyboard, dismissal, and layout contracts. -- Preserve the component family's geometry. Menu rows share vertical and - horizontal padding, height, icon and checkmark slots, separators, radius, and - state treatment. Do not imitate one menu with a custom popup whose spacing - only approximates the system. -- Do not wrap a library component merely to rename every method or freeze all - of its capabilities. -- Move reusable behavior without product styling to `gpui-base`; keep themed, - opinionated presentation in GPUI Component or the application. - -## Interaction states - -### Make the result understandable before the click - -A control should predict its result. Use familiar desktop controls and -placement so people can act without learning the interface first. Its label -names the action and object, its state shows availability, and its feedback -confirms the same outcome. - -Do not label a Button `Save` if it opens a configuration flow, or `Delete` if -it only removes an item from a group. Name the scope when context does not make -it clear. Respond immediately to activation, prevent duplicate submission -during longer work, and show the result near the object that changed. Add a -success message only when the result itself is not visible. - -Every interactive control should be designed for: - -| State | Design requirement | -| --- | --- | -| Rest | Clear affordance without visual noise | -| Hover | Subtle pointer feedback, never the only cue | -| Pressed | Immediate press feedback | -| Open / pressed | Persistent feedback while an attached popup is open | -| Focus visible | High-contrast keyboard focus ring | -| Selected / checked | Persistent state distinct from hover | -| Disabled | Lower emphasis and no misleading hover/pressed response | -| Loading | Preserve context, prevent duplicate action, explain long waits | -| Error | State what happened and how to recover | - -Use GPUI's focus system and Actions for commands that should work from the -keyboard. Match familiar desktop shortcuts, expose shortcuts in menus or -tooltips, and keep focus in a logical place after opening or dismissing an -overlay. - -Selection is part of the information model, not optional polish. Tabs, -segmented choices, selectable rows, filters, and navigation destinations must -show a persistent selected state. A Button that owns a dropdown must remain -visibly pressed or open until the popup closes; hover alone cannot explain the -relationship between trigger and surface. - -In a segmented control, the selected fill and unselected surface must share -the container's silhouette. Neither may square off an end corner or make a -boundary appear heavier than its peers. - -Show a selected navigation item, list row, or tab through the item's own -surface: a selected fill, stronger foreground, or heavier weight. Do not add a -leading-edge bar or one-sided border as the selection marker. It is a web -template habit, not a desktop convention; it breaks the item's rounded -silhouette and adds a second, competing edge to a column that already aligns -on its text. - -For destructive actions, distinguish between reversible and irreversible work. -Prefer undo or a temporary notification for reversible changes. Use an -`AlertDialog` when the consequence is serious and cannot be undone; name the -specific object and consequence in the confirmation copy. - -### Pointer conventions - -Use the default arrow cursor for buttons, checkboxes, menu items, tabs, and -other native controls. Use a pointing hand for links and content that behaves -as a link. Use text, resize, grab, and prohibited cursors only when they -describe the active manipulation. A cursor reinforces an affordance; it does -not replace the control's visible state or accessible role. - -Keep hover effects modest because keyboard and accessibility interaction has no -hover. Do not reveal the only copy of a destructive or essential action on -hover. Contextual row actions may become quieter at rest if the same commands -remain available through selection, keyboard, or a context menu. - -### Prefer desktop command surfaces over hover toolbars - -Use command frequency and scope to choose where an action lives: - -- keep the primary or frequent action visible as a labeled Button or familiar - toolbar control; -- put secondary actions for the current region behind a visible - `DropdownMenu` trigger; -- put commands that act on the object under the pointer in a `ContextMenu`; -- expose the same important command through an Action/key binding when it has a - natural keyboard form; -- use a hover-revealed icon only as a shortcut to a command that remains - reachable elsewhere. - -This is more than a visual preference. GPUI Component's menu system already -owns directional keyboard navigation, confirmation and cancellation, disabled -items, separators, submenus, shortcut presentation, focus transfer and -restoration, and nested-menu dismissal. A custom strip of hover buttons must -rebuild those behaviors and is invisible to keyboard-only and many assistive -technology workflows. - -Choose `DropdownMenu` when users need a visible indication that more commands -exist—for example a toolbar overflow, document actions, or account menu. Choose -`ContextMenu` for selection- or object-scoped commands such as rename, -duplicate, reveal, or remove. The context menu must not be the only way to -perform an essential command; provide a menu-bar, toolbar, keyboard, or detail -view path as appropriate. - -Do not put every action into a menu to make a screen look minimal. Discovery -and speed matter: the main action stays visible, dangerous items remain clearly -labeled and separated, and a menu item should use the same verb, icon, shortcut, -enabled state, and result everywhere it appears. - -### Button means application action; Link means external resource - -Use a Button when activation changes application state, confirms a decision, -opens a tool, submits data, or runs a command. Choose its treatment by local -hierarchy: - -- primary Button for the one emphasized commitment in a decision area; -- default Button for ordinary visible actions; -- outline Button when an action needs a clear boundary with less emphasis; -- ghost Button for familiar, low-emphasis toolbar and inline actions; -- icon Button only for a well-known symbol, with an accessible name and a - tooltip when meaning or scope is unclear. - -Do not assign primary because a Button is the only action on screen, because it -is placed at the top right, or because the team wants more clicks. Primary -communicates default commitment and keyboard behavior. If activation is merely -an ordinary command such as adding an item, opening a tool, or refreshing a -view, use a default, outline, or ghost Button according to its local hierarchy. - -Use an underlined Link only for an external resource target: a URL, web page, -online documentation, or email address. It uses the pointing-hand cursor -because its contract is leaving the current application context for that -resource. Do not use Link styling to make a functional command look quiet. A -link-shaped Delete, Save, Refresh, Add, Open-menu, or in-app navigation action -hides the control's affordance and exposes the wrong accessibility role. - -“View” does not make an in-app destination a Link. A full report, analysis, -details panel, or local record still opens through a Button, row, card, tab, or -disclosure control. Use concise context-aware labels such as `Full analysis` -when the containing card already establishes what opens; reserve underlining -for a resource that actually opens in a browser or mail client. - -All internal navigation—sidebar rows, tabs, breadcrumbs, list items, opening a -local view, or switching workspaces—must use the corresponding native component -or a Button/Action. Visual emphasis is chosen through Button variant or the -navigation component's selected state, never by lying about semantics. - -## Feedback and overlays - -Choose the smallest surface that fits the decision: - -- tooltip: a short explanation or shortcut; -- popover: contextual controls that do not interrupt the task; -- menu: a compact list of actions; -- notification: asynchronous status that does not require a decision; -- dialog: a focused decision or short form; -- alert dialog: explicit confirmation of a consequential action; -- sheet: supplementary work that benefits from more persistent space. - -A tooltip supplies missing meaning; it does not restate an obvious action. -Reserve it for an unfamiliar symbol, ambiguous scope, or a useful shortcut -that is otherwise hidden. Repeated, recognizable commands such as Copy and -Download in a message footer need accessible names and keyboard access, but -their hover labels add interruption rather than clarity. - -An Alert interrupts the visual hierarchy even when it does not open a modal. -Use it for important, exceptional information that needs attention in the -current task, not as a decorated container for ordinary descriptions, tips, or -empty space. Prefer inline help, muted text, or a normal section when the -content does not require immediate notice or action. - -Avoid stacking overlays. Escape should dismiss the topmost dismissible layer, -and focus should return to the trigger or the next logical target. - -An overlay action must refer to an object or state the overlay actually shows. -For example, expose `Clear history` only when a distinct recent-history section -is visible and contains entries. Search results, recent items, and favorites -are different collections; label and separate them instead of merging them -into one unexplained list. Hide an inapplicable action or disable it with a -useful reason—do not park an ambiguous trash icon in a footer. - -Footer space is not a catch-all for capabilities that lacked a place in the -design. A footer may present shortcuts, status, or actions that apply to the -whole surface, but each item must answer: what is its object, why is it -available now, what scope does it affect, and what visible state changes after -activation? - -## Motion - -Motion explains change; it is not ambient decoration. Use short transitions for -appearance, dismissal, expansion, and spatial continuity. Avoid animating large -layout changes when opacity or transform communicates the same relationship. -Honor reduced-motion preferences, never require animation to understand state, -and do not add a default animation to every component. - -Motion policy belongs to the styled or application layer. Base may own the -lifecycle mechanism or geometry needed for a transition, but it should not -decide that every product fades or slides. Give independently animated values -stable identity, and make interruption reverse smoothly from the currently -sampled value rather than restarting from an old endpoint. - -Motion follows the continuity of the interaction. While a pointer or scroll -position moves among related targets, keep the preview surface stable and -update its content or position in place. Dismiss it only after the interaction -ends; repeated fade-out and re-entry on each update reads as flicker. - -## Designing data-heavy interfaces - -Dense does not mean cramped. In tables, trees, command palettes, editors, and -docks: - -- keep headers and primary row identity visually stable; -- align comparable values and use tabular numerals where appropriate; -- distinguish focus, hover, active row, and multi-selection; -- keep sorting and filtering visible and reversible; -- preserve selection by domain identity across filtering and reordering; -- virtualize large collections without changing keyboard semantics; -- use progressive disclosure for secondary columns and inspectors; -- provide a useful empty state that explains the next action. - -Choose a table for comparison across consistent fields, a list for scanning -heterogeneous items, a tree for real hierarchy, and a dock only when users need -to arrange long-lived tools or documents. Do not use a complex data component -as a visual style. - -## Interface language - -Words are part of the interface architecture. Write the vocabulary for a -feature as a system—destinations, objects, commands, states, and outcomes—not as -isolated translations of implementation features. Prefer the shortest wording -that remains accurate in its actual context. - -### Let context carry context - -Do not repeat information that the surrounding surface already establishes. A -sidebar destination is usually the object or domain itself: use `Users`, not -`User Management`; `Shortcuts`, not `Shortcut Configuration Management`. A -column whose rows already contain actions can omit a generic `Operation` -heading. A dialog titled `Delete “Roadmap”?` does not need body text that asks -the same question again. - -This is context economy, not deletion for its own sake. Add text when it changes -the decision: identify the affected scope, an irreversible consequence, an -unusual prerequisite, or a way to recover. Every extra word should answer a -question the current layout does not already answer. - -Use nouns for destinations and objects (`Users`, `Appearance`, `Orders`), verbs -for commands (`Save`, `Duplicate`, `Export`), and adjectives or short phrases -for states (`Offline`, `Up to date`, `Pending review`). Avoid wrappers such as -`Management`, `Module`, `Page`, `Function`, `Operation`, and `System` unless the -word distinguishes a real domain concept. - -### Write each language, do not translate its shape - -Start from shared intent, hierarchy, and terminology, then compose each locale -as natural interface language. Do not preserve the source language's word -order, number of words, politeness filler, or grammatical category. English -`Users` can express a Chinese feature concept that would literally expand to -“user management”; fidelity means preserving purpose, not preserving tokens. - -Remove words supplied by the enclosing information architecture. Inside a -`Settings` surface, a destination is often simply `Account`, not `Account -Settings` and never the unnatural singular `Account Setting`. The correct -English label is chosen from its role and neighbors, not from the standalone -source phrase. - -Maintain a small product lexicon for recurring objects, commands, and states. -Use the same term in the toolbar, menu, context menu, dialog, shortcut search, -and documentation unless the context genuinely changes its meaning. Review -copy in the rendered surface: neighboring labels often reveal repetition or -inconsistent scope that a locale file cannot. - -In localized technical writing, preserve an established framework term when a -translation would be less precise. Keep API identifiers in their original form -and format them as code. Do not retain ordinary foreign words merely to sound -technical. Explain a retained term on first use when needed, then use the same -form throughout the interface, documentation, and API examples. - -### Buttons and confirmation dialogs - -Button labels are short by default—usually one or two words—and describe the -result, not the gesture or the component. Prefer `Save`, `Move`, or `Delete` to -`Click to save`, `Perform move`, or `Confirm deletion`. Use `Cancel` consistently -for the action that leaves without committing. Reserve `OK` for acknowledging -purely informational content. - -Short is a default, not a character limit. A deliberately longer label is -better when its words expose a consequence or distinguish choices that users -could otherwise confuse, for example `Delete from this group` versus `Delete -everywhere`, or `Restart without saving`. Length must buy decision-critical -information; it must not restate the dialog title or body. - -Use the most specific concise result as the confirmation label when possible: - -| Context | Weak | Prefer | -| --- | --- | --- | -| Delete dialog | `Yes`, `Sure`, `Confirm deletion` | `Delete` | -| Unsaved changes | `Confirm`, `Yes` | `Discard changes` | -| Pure acknowledgement | `Confirm operation` | `OK` or `Done` | -| Complex consent whose result has no clear verb | `Yes` | `Confirm` | - -`Confirm` is a useful fallback when the surrounding dialog fully names a -complex commitment and no shorter result verb is accurate. It should not -replace a clear command. `Sure` is conversational rather than a stable English -command and is too ambiguous for the standard vocabulary. - -A confirmation dialog should form one compact decision: - -- title: the decision or condition, such as `Delete “Roadmap”?`; -- body: only new scope, consequence, or recovery information; -- actions: `Cancel` and the result, such as `Delete`; -- destructive styling: applied to the destructive result, not substituted for - precise wording. - -Avoid generic titles such as `Notice`, `Warning`, `Error`, and `Confirmation` -when the actual condition can be named. Avoid ritual phrases such as “Are you -sure you want to…”, “Would you like to…”, “Please note that…”, and “successfully” -when the structure or state already communicates them. Courtesy should come -from a calm, respectful tone, not repeated `please`. - -### Capitalization, punctuation, and symbols - -Use sentence case for English UI by default: `Reset layout`, not `Reset Layout` -or `RESET LAYOUT`. Preserve proper nouns and established acronyms. Follow a -platform convention such as title case for native menu commands only when the -platform integration benefits from it, and apply that convention consistently -within the component class. - -ALL CAPS can provide restrained typographic emphasis for very short section -labels, eyebrows, statuses, established acronyms, and code-like identifiers. -Its compact shape and measured tracking can form a level similar to bold type, -but it does not belong on Buttons, long headings, sentences, or dense lists. Do -not combine uppercase, strong color, and bold weight in the same region, and do -not transform every string automatically: product names, acronyms, and localized -content must preserve their intended casing. - -Labels, buttons, menu items, tabs, headings, placeholders, and short states do -not take a final period. Complete explanatory, warning, and error sentences do. -Avoid exclamation marks in routine success and failure messages. In Chinese, -use full-width punctuation in sentences and omit terminal punctuation from -short control labels by the same semantic rule. - -Use the single ellipsis character (`…`), not three periods. Append it to every -Button or MenuItem that opens a dialog, sheet, or separate window, and to a -command that requires more input or choices before it can complete, such as -`Settings…` or `Export…`. An immediately executed command does not take an -ellipsis. Use an indeterminate progress indicator, not decorative dots, to -communicate ongoing work. - -Errors should say what happened and, when useful, the next recovery action. -Success feedback should name the resulting state only when that state is not -already visible. Prefer `Couldn’t save. Check your connection and try again.` -to a technical code or a long apology; omit a `Saved successfully` toast when -the document visibly becomes saved. - -## Internationalization and platform fit - -Copy must survive expansion, CJK typography, and different shortcut notation. -Do not size a control from one English label. Keep text out of raster assets, -avoid concatenating translated fragments, and let labels wrap or truncate only -where the product defines a recovery path such as a tooltip. - -Respect platform differences that carry meaning: Command versus Control, -native window decorations, system appearance, scrollbar behavior, menus, and -notification capabilities. Keep the product's information architecture stable -across platforms, but do not erase familiar platform behavior for superficial -pixel equality. - -## Guidance for AI-generated interfaces - -An AI changing a GPUI interface should first inspect the nearest feature, -theme tokens, and component documentation. It should state the primary task, -state owner, component composition, and keyboard path before generating code. -It must not infer an API from React/Shadcn examples or invent a GPUI method -because the name seems plausible. - -AI output is incomplete until a human can explain why the hierarchy, density, -component choice, and exceptional literal values belong in this product. A -visually plausible screenshot is not proof: keyboard behavior, focus, dynamic -content, themes, resizing, and failure states are part of the design. - -## Accessibility checklist - -Before considering a screen complete, verify that: - -- every action is reachable and operable by keyboard; -- focus order follows visual and task order; -- focus remains visible and is restored after overlays; -- controls have accessible names; icon-only controls use tooltips when their - meaning, scope, or shortcut needs explanation; -- text and meaningful boundaries have sufficient contrast; -- status is not communicated by color alone; -- disabled and read-only states are distinguishable; -- labels, errors, and descriptions remain near their controls; -- content remains usable with longer translations and larger text; -- pointer targets are comfortably sized even in a dense layout. - -## Design review checklist - -A review does not inventory components; it judges whether the interface made -the right decisions. Ask, in order: - -1. **Is the task clear?** Can a new user recognize the purpose, primary action, - and next step without learning, guessing, or experimenting? -2. **Does every action keep its promise?** Do the label, control, state, scope, - feedback, and result describe one consistent outcome? -3. **Is hierarchy decisive and restrained?** Does the core feature receive the - space it deserves while strong color, bold type, badges, alerts, and primary - Buttons remain scarce? -4. **Could the interface do less, better?** Can an entry point, option, or state - be removed, combined, or deferred without weakening the complete task? -5. **Is the structure exact?** Do peers share alignment spines, equal gaps stay - equal to the rendered pixel, and scrollbars sit at the edge of their actual - scrolling region? -6. **Does it follow the component system?** Do standard controls retain their - geometry, states, keyboard behavior, and dismissal model, with appearance - supplied by theme and scale tokens? -7. **Does it remain usable in every state and constraint?** Verify keyboard and - focus behavior, empty/loading/failure/permission states, longer translations, - zoom, minimum window size, and reduced motion. -8. **Has it been tested in a real window?** Complete the task with real - components, copy, and representative content—not only an ideal screenshot. - -Continue with [Coding Guides](./coding-guides.md) to translate these design -decisions into GPUI architecture and code. diff --git a/docs/plans/clipboard-history.md b/docs/plans/clipboard-history.md deleted file mode 100644 index 9bc35c6..0000000 --- a/docs/plans/clipboard-history.md +++ /dev/null @@ -1,440 +0,0 @@ -# Clipboard history: product and implementation plan - -**Status:** Implemented locally and under verification; cross-platform/protection release gates remain open. -**Goal:** Automatically collect copied text and make it reusable from a clean, keyboard-first GUI and a safe interactive CLI. 所有操作和使用符合人体工程学:少按键、少切换、可预测、可撤销。 - -Use this document as the implementation checklist and decision record. Do not mark a task complete without the evidence required by its milestone. - -## Current implementation status — October 6, 2026 - -The milestone/evidence notes below include historical claims from the initial implementation; -this audit supersedes them where they disagree with current code. Do not treat this plan as -proof that clipboard protection or every platform is release-ready. - -- CLI chooser now uses raw controlling-terminal input, type-to-filter, arrows/Page keys, - Enter and Esc/Ctrl+C, and restores terminal state before child launch. `--search` prefills - interactive filtering; `--list` respects human/JSON mode. Clap owns child argv after `--`. -- Direct child execution preserves streams and status. Selection is reread after choosing - and authorization. Raw `cp show` preserves exact text without appending a newline; - JSON output uses the standard envelope. -- Protected import authorizes and encrypts **before** insertion. No failed-protection - plaintext fallback. CLI authorization is process-local; standalone `vault unlock` - verifies credentials, not a persistent desktop or cross-process session. -- Ordinary dedupe is exact (case/whitespace included); edited digests are updated. - Insert/pruning and limit changes are transactional. Protected previews and search do - not consult plaintext metadata; flag-only protection is refused. Vault recreation is - refused, expiration revokes cryptographic access, and stale editor saves fail. -- Desktop history has virtualized themed rows, stable selection, explicit copy/paste, - reservation and a single editor. Automatic paste restores/verifies the destination - on Windows and fails closed elsewhere. Ctrl/Cmd+L authorizes an atomic, generation-bound - ordinary-to-protected transition; encrypted editor saves use the same generation checks. Collection is opt-in, and remains paused while - protected items exist rather than duplicating secrets as ordinary plaintext. -- Remaining acceptance work includes active-use leases, ciphertext identity/version AAD, - historical plaintext/journal/backup analysis, Windows ownership-conditional expiry validation, - OS exclusion validation, deletion/undo and unprotection UX, native macOS/Linux paste adapters, - IME/accessibility/DPI checks, and measured latency/overhead. Do not mark M4/M5 complete. - -## 1. Scope and decisions to confirm - -### Confirmed requirements - -- Automatically track copied text while the collector is running. -- A global shortcut opens searchable clipboard history; typing filters, Enter uses the selected item in the previously focused application. -- Protected items prompt for the master password before use. -- `Ctrl+L` protects an item; `Ctrl+R` reserves it; double-click edits it. -- Edited, protected, and explicitly reserved items are never evicted by the normal history limit. -- Ordinary history is bounded. GUI and CLI share filtering, retention, and sorting behavior. -- `rusticker cp ls -- some_cmd xxx {{cp}} xxx` opens a type-to-filter chooser. Enter substitutes the selected text; without `{{cp}}`, append it as one argument. -- The interface should be clean, ergonomic, and consistent with the existing desktop app. -- The exact global binding is delegated: choose a convenient, configurable shortcut, not a mandatory `Ctrl+Home+.` chord. -- `Ctrl+L` protects only the selected item, not the whole history. -- CLI command use is execution-only: stream the child's output and propagate its exit status. Do not print a generated command or substitute clipboard text as the result. - -### Confirmed decisions and remaining proposed defaults - -D1–D3 are confirmed by the user. D4–D9 remain proposed defaults, not approvals. - -| ID | Decision | Status / consequence | -| --- | --- | --- | -| D1 | Convenient global shortcut | Confirmed: exact binding is delegated; `Ctrl+Home+.` is not required. Choose a compact-keyboard-friendly, configurable chord after checking conflicts. Candidate: Ctrl+Alt+V (Cmd+Alt+V on macOS), subject to platform verification; also expose a main-window action. | -| D2 | Item lock vs. whole-history lock | Confirmed: `Ctrl+L` protects only the selected item using the shared master password. Clearing temporary unlock state does not lock ordinary history. | -| D3 | CLI execution and output | Confirmed: with a command after `--`, Enter substitutes/appends the selected text, executes directly, streams child stdout/stderr, and propagates the child exit status. No print-command mode or parent-shell prompt insertion. | -| D4 | Normal history limit | 500 ordinary items, configurable. Add a 20 MiB ordinary-text budget and a 1 MiB per-capture limit as initial safeguards, subject to measurement. Oversized captures are skipped with a visible, non-modal explanation, never truncated silently. | -| D5 | Ordering | One shared MRU order for all items; reserved/protected/edited items receive badges, not a separate sort tier. Successful reuse and a new copy promote an item. | -| D6 | Unlock lifetime | One-shot unlock for paste/CLI execution; temporary unlock in the editor, cleared on close or inactivity. No background sharing of an unlocked master key with other processes. | -| D7 | Collection lifecycle | Desktop app owns collection while running, including when its main window is closed. CLI can query stored history without the app. Headless `cp watch` is a later phase, not an implicit daemon started by `cp ls`. | -| D8 | First-run privacy | Explain that copied secrets can enter ordinary history; require explicit enablement once, then collect automatically. Offer pause and clear controls. | -| D9 | Retention after removing protection | Remember each retention reason independently. Unprotecting an item does not automatically make it disposable; releasing saved retention requires an explicit action. | - -**Confirmation gate:** D1–D3 are settled. Resolve the initial privacy/locking model and remaining proposed defaults before implementation. The UX below is a provisional interaction brief, not a finalized visual design. Before building the GUI, confirm its layout against the current app and capture the agreed visual direction. - -### Non-goals for the first release - -Images, rich clipboard formats, file-copy history, cloud sync, OCR, AI categorization, password recovery, browser extensions, command rendering/parent-shell prompt insertion, and arbitrary shell evaluation. Capturing every intermediate copy cannot be promised when an OS provides only the latest clipboard value; document platform limitations honestly. - -## 2. Shared behavior contract - -### 2.1 Capture and identity - -1. Watch OS clipboard changes rather than keypresses: collect mouse-menu copies, programmatic copies, and text from any application where the OS exposes it. -2. Preserve text exactly: whitespace, Unicode, line endings, and multiline content. Ignore empty text, not whitespace-only text. Normalize only search keys and previews. -3. Deduplicate ordinary entries by exact text. A repeated copy updates `last_copied_at` and recency instead of creating another row. Confirm content equality, not just a hash match. -4. Keep IDs stable across filtering, promotion, editing, and reservation. Edit in place; do not merge saved/protected records simply because their contents match. -5. Once edited, an item is user-owned. An incoming copy must not overwrite its content or retention reasons; a matching copy may promote it without changing it. -6. Start by capturing the current clipboard once after enablement. Pause means no capture and no backlog; resume reads the then-current value once. -7. Retry a busy clipboard with bounded backoff. Surface persistent failure or missing permission in status/settings without stealing focus. -8. Suppress Rustickers-owned clipboard transactions: selection-capture markers/restores, history paste writes, and protected-copy writes. Serialize internal write/capture transactions with the collector, using native generation/ownership data where available—not text matching or a broad time window alone. -9. Existing selected-text command capture must not generate synthetic history rows. Decide explicitly whether its genuine captured selection is ingested once; proposed default: yes, unless protected or excluded. - -### 2.2 Retention and eviction - -Keep independent, durable retention reasons: - -- `explicit`: user pressed Reserve. -- `edited`: a changed edit was successfully saved (opening/canceling/no-op saving does not count). -- `protected`: the item was successfully password-protected. - -```text -retained = explicit || edited || protected -ordinary = !retained -``` - -A protected envelope is always retained, even if metadata is damaged. Unprotecting clears encryption, not the historical `protected` retention reason. “Make ordinary” explicitly clears retained reasons only after protection is removed and the user confirms the consequence. - -- Ordinary item count and ordinary bytes are bounded; retained items do not consume either allowance. -- On ingest, trim the least recently active ordinary items in the same transaction. Lowering limits also trims ordinary items only, after showing the impact. -- Use the same recency key as the displayed ordering; never evict a retained row to satisfy an ordinary limit. -- Reserving/protecting/saving edits is atomic with its retention change. Concurrent capture cannot evict an item halfway through saving it. -- Hold a temporary eviction lease on an item being edited, unlocked, or used; this lease is not a permanent retention reason. Release it on success, cancel, timeout, or process exit. If no ordinary victim is available, skip the incoming capture rather than deleting active/saved data. -- `Ctrl+R` toggles the explicit reason only. If an item is also edited/protected, removing explicit reservation still leaves it retained; explain this in the badge tooltip/status message. -- Retained history is not silently capped. Show retained storage usage and warnings, plus explicit cleanup/export actions later. Disk-full errors must leave existing data intact. -- Delete is always explicit, with undo for ordinary deletions and confirmation for saved/protected deletions. Automatic eviction is not undoable by retaining a hidden, unbounded archive. - -### 2.3 Ordering and search - -Shared default ordering: - -```text -recency_at DESC, created_at DESC, id DESC -recency_at = max(created_at, last_copied_at, last_used_at, last_edited_at) -``` - -Reserve/protect/unlock alone do not reorder a row. Apply promotion after an interaction completes, not while the user is choosing. Keep the highlighted ID stable when new captures arrive; if it disappears, select the nearest surviving row. Search changes select the first match. - -- Case-insensitive Unicode-aware token matching: all query tokens must match. No fuzzy score reordering in v1; filtered results retain the exact same MRU ordering in GUI and CLI. -- Search ordinary/unprotected content and optional user labels. Protect against stale asynchronous query results. -- Protected rows expose only a generic “Protected item” label or an explicitly public user alias. Never persist/index a plaintext preview, title derived from content, or plaintext content hash for a protected item. -- Locked protected content is not searchable. Both surfaces say so; reserved/protected-only filters allow discovery without leaking text. -- Preview text is shortened and escapes control characters; the stored text and pasted/argument payload remain unchanged. Terminal escape sequences from clipboard content must never execute in the chooser. - -## 3. GUI interaction brief - -**Job:** retrieve a past copy without losing the current task or destination. **Mode:** Operate. The primary action is paste; management actions stay secondary and discoverable. - -### Main flow - -1. Global shortcut snapshots the foreground destination **before** opening any Rustickers window. -2. Open one transient history window near the active display's center, constrained to its work area. Repeating the shortcut dismisses it; it must not replace the saved destination with Rustickers itself. -3. Search field receives focus, first row is selected. Type → filter; arrows → select; Enter → use. -4. For a protected row, show an inline master-password prompt. Preserve query, selection, and destination. Enter submits; wrong password stays in place with an error; Esc cancels the prompt back to the chooser. -5. On successful authorization, hide the chooser, wait until shortcut modifiers are released, restore and verify the original destination, write plain text, and synthesize the platform paste action. -6. If the destination has disappeared, is elevated/inaccessible, or focus restoration fails, do not paste into an arbitrary window. Offer “Copy only” and let the user paste manually. -7. Record `last_used_at` only after successful clipboard/paste dispatch. OS dispatch is not proof the destination accepted the paste; do not claim guaranteed delivery. - -Do not immediately restore the previous clipboard after paste: applications may read it asynchronously. Normal selection remains on the clipboard. Protected text receives best-effort timed clearing, conditional on its ownership/generation being unchanged, and a warning that OS history, other managers, and destination apps may retain it. - -### Keyboard and mouse contract - -| Input | Chooser behavior | -| --- | --- | -| Configurable global shortcut | Open/dismiss history; convenient default chosen after conflict checks; also available from the main window | -| Printable text / IME input | Filter without moving focus away from search | -| Up / Down | Move selected row; never interpret IME candidate navigation as list navigation | -| Page Up / Page Down | Move by visible page | -| Enter | Paste selected item; request password if required | -| Ctrl+Enter | Copy only; do not synthesize paste | -| Ctrl+R | Toggle explicit reservation; announce retained reasons | -| Ctrl+L | Protect selected item; configure master password if absent; already protected → explain status | -| F2 / double-click | Edit selected item, unlocking first if protected | -| Delete | Delete selected row, with safety/undo as above; text editing retains its normal Delete behavior | -| Esc | Cancel the innermost prompt/editor, then dismiss chooser; never accidentally send content | -| Tab / Shift+Tab | Move through visible controls with a clear focus indicator | - -Primary-modifier equivalents on macOS must be documented. `Ctrl+L`, `Ctrl+R`, and Delete are chooser-scoped, never global grabs; the editor preserves standard text-editing shortcuts. In the multiline editor, Enter inserts a newline, Ctrl/Cmd+S saves and returns to history, and Esc cancels (confirm only if dirty). - -Single-click selects only. **Do not paste on single-click**, because double-click must edit. Add a secondary Paste button/context action for mouse-only users. Keyboard paste normally takes shortcut → optional typing → Enter; no confirmation for ordinary reuse. - -### Clean UI and accessibility requirements - -- Reuse GPUI, existing theme, inputs, icons, focus/dismissal behavior, and transient-window patterns; clipboard history is not a new sticker type. -- Provisional hierarchy: search header → compact virtualized list → small contextual shortcut/status footer. Optional expanded text preview on demand; no dashboard, card grid, or always-open management sidebar. -- Each row: short readable text preview, subdued recency, distinct selected state, and small labeled/status badges for Edited, Reserved, Protected. Avoid redundant icons and color-only distinctions. -- Support both existing light/dark themes where available, display scaling, keyboard focus, text contrast, screen-reader labels, and reduced motion. Do not hide necessary actions exclusively behind hover. -- Fit small screens and high-DPI displays; long URLs, CJK, emoji, tabs, and multiline clips must not break row height or horizontal layout. -- Empty history: explain collection and enable/pause state. No matches: preserve the query and offer Clear filter. Include loading, paused, permission-denied, storage-full, deleted-during-selection, unlock-error, and paste-failed states. -- Announce reservation/save errors without modal interruption. Never show a “Saved” badge until persistence succeeds. - -## 4. CLI contract - -### Interactive use - -```bash -rusticker cp ls # browse history only; no raw-text output or execution -rusticker cp ls -- some_cmd xxx '{{cp}}' xxx # choose, replace argument placeholder, execute -rusticker cp ls -- some_cmd xxx # choose, append as one argument, execute -rusticker cp ls -- rg --fixed-strings '{{cp}}' . # child flags belong to the child after -- -rusticker cp ls -- some_cmd 'prefix={{cp}}' # embedded placeholder stays in one argument -``` - -Quotes around the placeholder protect it from the invoking shell; they are not added to the text passed to the program. Supply shell-specific examples in final help (POSIX shells, PowerShell, Windows cmd). - -- Type to filter, arrows/Page keys to select, Enter to confirm, Esc/Ctrl+C to cancel. Use the same shared ordering and retention badges as GUI. -- Use the controlling terminal for chooser/password I/O, including when stdin/stdout are redirected. Keep stdout free for child output only; restore terminal state on every exit path. -- Protected items prompt with hidden input; never accept a master password as an argument or environment variable. Cancel performs no execution or promotion. -- Validate the command/placeholder position before opening the chooser where possible. Re-read/lease the chosen row before execution to handle concurrent deletion/editing. -- With a child command, restore terminal state before launch, inherit/forward its stdin, and stream stdout/stderr directly without capturing, reformatting, or adding success banners. Propagate its exit code. Cancel returns 130; operational errors return a documented nonzero code with no execution. -- Proposed browse-only behavior: without a trailing command, show history but emit no raw selected text and execute nothing; explain that `-- <program> <args>...` is required for command use. Do not invent a default executable. -- `cp ls` must not create a command sticker or require a GUI build. Without a running collector, it reads the persisted history and explains that new copies are not being collected. - -### Argument substitution and “proper quoting” - -Use the argument vector already parsed by the invoking shell. **Never join it into a string, re-split it, or evaluate selected text as shell syntax.** - -1. The first trailing token is a fixed executable; reject `{{cp}}` in that token. -2. Replace every `{{cp}}` occurrence in argument tokens with the exact selected text, without recursive replacement. -3. If no argument contains the placeholder, append the text as exactly one argument. -4. Launch with `std::process::Command`. Quotes are not part of argv; the platform launcher handles transport escaping. Spaces, quotes, newlines, `$()`, `&`, and `;` remain data. -5. NUL cannot be represented in a process argument: reject it with a clear error, not silent truncation. Reject excessive platform argument lengths with a helpful suggestion; a future explicit stdin mode can support large payloads. -6. Quoting does not prevent the child's own option interpretation. Recommend fixed positional placeholders and child-supported `--` terminators for untrusted text beginning with `-`. -7. A user can explicitly run `sh`, `pwsh`, or another interpreter, but substitution into its code argument can execute clipboard content. Warn/document this; v1 must not advertise shell-code interpolation as safe. - -There is no command-rendering mode: do not implement `--print-command`, print expanded command lines, or insert them into the parent shell. Show only child output during execution. A child may itself print selected secrets; authentication authorizes passing the text to that program, not a guarantee that the program keeps it private. - -### Non-interactive and management surface (proposed) - -| Command / option | Contract | -| --- | --- | -| `cp ls --list [-s <query>] [-n <limit>] [--json]` | Non-interactive listing; protected rows expose metadata only | -| `cp ls --id <id> -- <program> <args>...` | Deterministic command execution without a chooser; protected rows still require terminal authentication; no raw-text output mode | -| `cp reserve <id>` / `cp unreserve <id>` | Change explicit reservation only | -| `cp edit <id>` | Edit via a documented editor contract; protected editing never writes plaintext temporary files by default | -| `cp protect <id>` / `cp unprotect <id>` | Require terminal authentication; keep saved retention on unprotect | -| `cp delete <id>` | Confirm saved/protected removal; explicit force flag for automation | -| `cp status` / `cp pause` / `cp resume` | Show collector/storage state; pause/resume requires an active collector | -| `cp config` | Set normal limits and collection preferences | - -This table is a follow-up surface, not a requirement to implement every command in the first slice. Final argument spelling should be checked with Clap and existing CLI conventions. - -Non-TTY invocation without `--list` or `--id` fails with an actionable error, never hangs. `--json` implies non-interactive listing when no child command is supplied; reject combining it with child execution to preserve the existing “one JSON object on stdout” convention. Do not expose protected text in JSON listings or incidental diagnostics. - -## 5. Password and privacy design - -“Locked” must mean encryption at rest, not just a hidden preview. - -- One clipboard master password, separate from existing per-sticker passwords. Do not silently migrate/change sticker locks. -- Use a random vault data key wrapped by a password-derived key. Version the KDF parameters and envelope. Choose/review a memory-hard KDF such as Argon2id, or document a reviewed alternative; benchmark interactive unlock on target hardware off the UI thread. -- Encrypt each protected payload with authenticated encryption and a fresh nonce. Bind item identity/envelope version as associated data. Password changes rewrap the data key atomically rather than rewriting every item. -- Protection must replace plaintext and remove derived previews, search records, and dedupe keys in one transaction. Editing a protected item re-encrypts it; unlocking never persists a plaintext copy. -- Zeroize owned password/key/plaintext buffers where practical, clear GUI/password states, and do not log text, passwords, decrypted previews, expanded secret commands, or clipboard-derived error payloads. Ordinary clipboard content must not appear in logs either. -- The current clipboard, OS clipboard history, other managers, backups, SQLite journal/WAL/free pages, crash dumps, and target processes are outside a simple row-encryption guarantee. Review secure deletion, journal settings, vacuum/backup behavior, and residual plaintext before claiming protection for previously ordinary rows. SQLite row encryption alone does not erase old bytes. -- On a fresh later copy of the same protected secret, avoid creating an ordinary duplicate. Proposed approach: use a reviewed keyed fingerprint, with its key available only during authorized unlock; while no safe protected-match check is available, pause automatic collection if protected items exist. Make this state obvious. Decide whether this privacy tradeoff is acceptable before shipping protection; do not invent a persistent plaintext hash or claim deduplication guarantees while locked. -- Avoid heuristic “secret detection” promises. Honor explicit platform sensitive/exclusion hints where available; provide pause/exclude controls and clearly communicate their limitations. -- Unlock failures use bounded backoff without destroying data. Corrupt envelopes and wrong passwords both preserve the record. A forgotten password cannot recover protected content; resetting requires explicit confirmation and must not delete ordinary history. -- GUI and CLI independently authenticate. Do not add a plaintext/key-export IPC endpoint. If a future background vault is introduced, specify authorization and unlock lifetime separately. - -**Security release gate:** document the threat model and pass residual-plaintext, recapture, logging, terminal-output, password-change, and concurrent-access tests before exposing “Protect” to users. - -## 6. Architecture and integration map - -Verified starting points (source takes precedence over older architecture summaries): - -| Existing path | Reuse / required work | -| --- | --- | -| `src/rustickers.rs`, `src/native/mod.rs` | App startup and event pump; start/stop collector without blocking GPUI | -| `src/native/hotkey.rs`, `src/ipc.rs` | Existing platform hotkeys and action dispatch; add the chosen convenient shortcut, remapping/conflict behavior, and clipboard chooser event | -| `src/native/windows/selection.rs`, `transient_topmost.rs`, `windows/mod.rs` | Searchable selection popup, activation and Esc conventions; adapt patterns, not sticker assumptions | -| `src/native/selection.rs` | Currently writes a marker, simulates Copy, and restores previous text; add shared internal clipboard-transaction suppression before collection starts | -| `src/native/components/stickers/content_lock.rs` | Existing UI-coupled per-sticker PBKDF2/authenticated encryption; useful precedent, not a shared-master vault implementation | -| `src/storage/sqlite.rs`, `src/storage/paths.rs`, `migrations/` | SQLx store and data paths; new clipboard tables/migration, transactions, indexes, contention tests. Last current migration is `0009_add_sticker_placements.sql`; choose the next free number when implementing. | -| `src/cli/mod.rs`, `src/cli/commands/`, `runtime.rs`, `output.rs` | Register `cp`, preserve file-path shorthand and human/JSON contracts | -| `src/native/components/stickers/command/runner.rs`, `src/cli/shell.rs` | Existing argument substitution/launch precedent; do not import GPUI/native command-sticker code into the CLI-only path | -| `Cargo.toml`, `src/lib.rs` | Keep shared history/vault logic GUI-independent. `arboard`, `ring`, and `zeroize` are currently UI-only; review feature wiring and new terminal/KDF dependencies. | - -### Proposed deep modules - -- `src/clipboard/`: shared history interface owns identity, queries, retention, promotion, editing/protection transitions, and transaction rules. GUI/CLI must not independently calculate eviction or sort order. -- `src/clipboard/vault.rs`: versioned encryption, password setup/change, and scoped unlock interface, without GPUI or terminal dependencies. -- `src/clipboard/command.rs`: pure argv transformation plus direct process launch and child stream/exit-status forwarding. No command-rendering path. -- `src/storage/clipboard.rs`: SQLite implementation behind the history interface; avoid expanding `StickerStore` with unrelated clipboard methods. -- `src/native/clipboard/`: real platform adapters for monitoring, internal-write suppression, destination snapshot/focus restoration, and paste. -- `src/native/windows/clipboard.rs`: chooser/editor views and interaction states only. -- `src/cli/commands/cp.rs`: Clap definitions, terminal chooser/password flow, output, and process exit handling. - -Inject a clock and clipboard/destination adapters at actual seams. Keep ranking, retention, and argv transformation deterministic so tests exercise the same interface the GUI and CLI use. Add abstractions for concrete platform/test variations, not one trait per helper. - -### Logical persistence model - -`clipboard_items`: stable ID; ordinary text **or** protected envelope (mutually exclusive); optional public alias; retention-reason flags; creation/copy/use/edit timestamps; byte size; privacy-safe dedupe metadata only where allowed. Index recency and ordinary-retention predicates. - -`clipboard_settings`: normal count/byte limits, capture enablement/preferences, schema version. `clipboard_vault`: wrapped data key and versioned KDF parameters. Protection writes must never leak plaintext into sticker tables. - -Prefer the existing app data directory; decide whether clipboard tables share `stickers.db` or use a separate `clipboard.db` after the SQLite residual-plaintext review. A separate database may simplify security/maintenance without rewriting unrelated stickers. Document the decision before migrations. - -Only one collector owns clipboard watching per user session. Desktop and a later `cp watch` must coordinate ownership; ordinary `cp ls` never starts a second watcher. Mutations from GUI/CLI are transactional with bounded busy retries. Query results carry stable IDs; clients revalidate stale selections. - -### Platform delivery - -- **Windows first:** clipboard update notifications, foreground HWND/process snapshot, verified focus restore, paste via native input; test non-elevated → elevated failure, compact keyboards, non-US layouts, rapid copies, and multi-monitor DPI. -- **macOS:** pasteboard change tracking; accessibility/input permissions and Cmd paste; validate focus restoration, Spaces, and a convenient shortcut on compact/non-US keyboards. -- **Linux:** separate X11 and Wayland capability checks. Wayland may disallow background clipboard access, global shortcuts, and synthetic paste without compositor/portal support. Provide explicit copy-only/manual invocation fallback; do not claim parity without evidence. - -## 7. Milestones and tracking - -All implementation tasks start unchecked. Deliver vertical slices; each milestone should leave a usable, testable state. - -### M0 — Confirm contracts and technical feasibility - -- [x] Inspect README, manifest, hotkeys, selection capture, storage, CLI, and existing locking implementation. -- [x] Draft shared semantics, UX/CLI contracts, risks, and implementation sequence. -- [x] Confirm D1–D3: convenient shortcut delegated, selected-item protection only, CLI executes and streams child output only. -- [ ] Approve retention, privacy, capture lifecycle, unlock defaults, and browse-only CLI behavior. -- [ ] Choose/test a convenient configurable shortcut; spike native capture, destination restoration, and paste on Windows. -- [ ] Resolve secure protected-recapture policy, cryptographic design, and shared vs. separate database. -- [ ] Confirm GUI interaction/layout with real clipboard samples and existing app screenshots; record the approved direction. - -**Exit evidence:** approved decisions recorded here; platform capability notes; no unresolved security model hidden behind a UI lock icon. - -### M1 — Shared history + persistent bounded capture - -- [x] Add GUI-independent history model/interface and migration(s). -- [x] Implement exact ordinary dedupe, shared ordering/filtering, independent retention flags, and transactional pruning. -- [ ] Add capture lifecycle/status and internal-write suppression, including current selection capture. -- [ ] Implement Windows watcher; persist across app restarts; pause/resume; busy/storage/oversize handling. -- [x] Add non-interactive `cp ls --list [--json]` as an inspection surface. - -**Evidence (M1):** `src/clipboard/{items,filter,ordering,retention,settings,storage}.rs`; migration `migrations/0010_add_clipboard_history.sql`; 19 unit tests in `src/clipboard/*`, 5 SQLite integration tests in `tests/clipboard_storage.rs`. The CLI-only build remains GUI-free (`cargo check --bin rusticker --no-default-features --features cli` succeeds). Capture watcher, internal-write suppression, and IPC hand-off are intentionally deferred to M3. - -### M2 — CLI chooser and safe command use - -- [x] Register `cp ls`, terminal chooser, query/selection, terminal cleanup, and non-TTY behavior. -- [x] Implement argv placeholder/append logic with fixed executable validation. -- [x] Execute directly, preserve child streams/exit status, and update shared MRU. -- [x] Define/test cancellation, stale row, child launch failure, JSON combinations, and protected-row metadata-only behavior. -- [x] Verify execution streams child stdout/stderr unchanged; no generated command or raw selected-text output, including browse-only invocation. - -**Evidence (M2):** `src/cli/commands/cp.rs` + 5 unit tests in the same file; arrow-key navigation is not yet implemented (line-driven chooser, see `choose_interactively`); raw-mode upgrade can land with M5. Exit codes: 130 on cancellation, 127 on missing program, 1 on empty history. `--list` (with `--search` / `--limit`) and `--id` give non-interactive entry points. Protected rows refuse to be substituted into a child command (caller must unprotect through the GUI first). - -### M3 — GUI retrieve, reserve, and edit (mostly complete) - -- [x] Implement and ship the chooser floating window with type-to-filter, ↑/↓/PgUp/PgDn nav, Esc dismiss, Enter paste, Ctrl+L toggle protection, Ctrl+R row-editor, and Ctrl+Backspace delete. (`src/native/windows/clipboard.rs`) -- [x] Wire the global `Ctrl+Alt+V` hotkey to the chooser, route the focused HWND through IPC, and close the chooser on Escape via `set_clipboard_escape_target`. (`src/native/hotkey.rs`, `src/native/mod.rs`, `src/ipc.rs`) -- [x] Implement foreground restore + rdev-synthesised Ctrl/Cmd+V paste helper. (`src/native/clipboard/paste.rs`) -- [ ] Five-second soft-delete undo banner (`delete_selected` in `src/native/windows/clipboard.rs` deletes immediately; needs banner + timer). -- [ ] Optional row-metadata dialog polish (F2 alias for Ctrl+R, label auto-complete against recent labels). - -**Exit evidence (so far):** `Ctrl+Alt+V` (and `Cmd+Alt+V` on macOS) appears as an enum variant in -[`src/native/hotkey.rs`](../src/native/hotkey.rs) with two new smoke tests, the IPC contract in -[`src/ipc.rs`](../src/ipc.rs) carries `ToggleClipboardChooser { focused_window }`, and the -single-instance chooser [`src/native/windows/clipboard.rs`](../src/native/windows/clipboard.rs) is -opened with that focus handle and dismissed by global Esc via -[`crate::native::windows::set_clipboard_escape_target`](../src/native/windows/mod.rs). The -captured row id is recorded through `mark_used` and the selected content is delivered via -`write_clipboard_text` so the watcher does not re-ingest it. Double-click pastes; Enter pastes; -Esc closes; up/down navigate. - -**Pending entries:** double-click conflict with reserved-row popup, F2 editor dialog, F1/Ctrl+R -row-level metadata view, deterministic DPI/IME regression, and a real cross-platform paste -helper (currently the chooser delegates the cross-platform paste via the existing -[`selection::capture_selection`] helper but does not yet call `SimulateCtrlV` directly). - -### M4 — Master-password protection (not release-complete) - -- [ ] Implement password setup/confirmation, vault envelopes, password change, and scoped authentication. -- [ ] Add `Ctrl+L` (selected-row protection), inline GUI unlock-and-continue (VaultModal in `src/native/windows/clipboard.rs`), protected editing flag toggling, and hidden CLI password prompt (`cp vault create|unlock|lock|status` plus `cp import --protect`). -- [ ] Implement session/key clearing (5-minute unlocked window via `DEFAULT_ACTIVE_LIFETIME_MS` in `src/clipboard/vault_controller.rs`), protected clipboard expiry, recapture privacy policy (`protected_match_token` is set only when the vault is unlocked), and exclusion hints where supported. -- [ ] Verify metadata redaction, residual plaintext handling (Zeroizing buffers in `src/clipboard/vault.rs`), logging (redacted `Debug` for `VaultEnvelope` and `UnlockedVault`), backups/journals, and concurrent protection/capture behavior (Arc + std::sync::Mutex owned by `VaultController`). - -**Required exit evidence (not yet demonstrated):** protection meets the written threat model; wrong password/cancel/corruption never executes or destroys data; protected content is absent from plaintext database/search/log artifacts covered by that model; unprotecting does not silently drop retention. - -### M5 — Cross-platform hardening and release - -- [ ] Implement/validate macOS adapter and permissions; define Linux X11/Wayland support honestly. -- [ ] Add management commands and settings needed for maintenance; optional headless watcher only with single-owner coordination. -- [ ] Benchmark and fix capture overhead, filtering, virtualized rendering, unlock responsiveness, and contention. -- [ ] Update README/help/architecture with shipped behavior, shortcuts, limitations, security guidance, and examples. -- [ ] Complete manual platform/accessibility checks and regression checks for existing sticker/selection shortcuts. - -**Exit evidence:** platform matrix, test commands/results, measured budgets, release notes, and no unchecked critical acceptance criteria for a claimed platform. - -## 8. Acceptance and verification matrix - -| Area | Required scenarios | -| --- | --- | -| Capture | Mouse/programmatic copy; rapid updates; repeated exact text; empty vs. whitespace; Unicode/CJK/emoji; pause/restart; busy clipboard; exclusion hints; oversized text | -| Internal writes | Marker/copy/restore transaction; clipboard-history paste; protected-copy expiry; concurrent external write during restore/clear; no accidental suppression of unrelated copies | -| Retention | Count and byte overflow; lower limits; each retention reason separately/in combination; no-op/canceled edit; unreserve edited/protected row; unprotect; explicit release; active-use leases; disk full | -| Ordering/search | Same fixture produces identical GUI/CLI IDs/order; deterministic ties; MRU on copy/use/edit; stable selection while ingesting; multilingual tokens; locked text never searched | -| Editing | Multiline save/cancel; double-click selects/edit only; edit/save versus concurrent prune/delete; protected edit remains encrypted | -| Focus/paste | Original app restored; held modifiers released; popup reopen/dismiss; nested password/editor cancel; closed destination; elevated target; permissions denied; clipboard write/input failure | -| CLI safety | Multiple/embedded/no placeholders; text containing spaces, quotes, tabs, newlines, shell metacharacters, ANSI escapes, `{{cp}}`, leading `-`; executable placeholder rejection; NUL/length error | -| CLI operation | No TTY; redirected stdin/stdout; hidden password; Ctrl+C/Esc cleanup; child not found; child exit code/signals; streaming stdout/stderr unchanged; no generated command/raw selected-text output; browse-only invocation emits no payload; JSON success/error convention | -| Protection | First setup; mismatch/wrong password; nonce/envelope corruption; password change failure; scoped relock; logs/redaction; SQLite residuals; protected recapture; clipboard expiry ownership | -| Compatibility | Existing main/preview/selection shortcuts and Esc; sticker migrations and locks; UI-only, CLI-only, combined feature builds; old data with new schema | - -Suggested verification commands once implementation exists: - -```bash -cargo test --no-default-features --features cli -cargo test --features cli -cargo check --bin rusticker --no-default-features --features cli -cargo check --bin rustickers -cargo run --bin rusticker --no-default-features --features cli -- cp --help -``` - -Add focused integration tests under `tests/` for storage transactions, GUI/CLI query parity, and actual child argv transport on Windows and Unix. Native clipboard/focus/permission checks require dedicated manual runs; unit tests alone cannot prove them. - -**Provisional performance targets:** visible chooser within 150 ms when warm; filtering under 50 ms for 500 ordinary + 10,000 retained metadata rows on a named baseline machine; UI stays responsive during capture/KDF work; idle watcher event-driven where possible. Report measurements rather than treating these numbers as established capabilities. - -## 9. Decision and evidence log - -Append decisions, measurements, and test outcomes here as work progresses. Keep unchecked tasks visible rather than rewriting the plan to imply completion. - -| Date / milestone | Decision or evidence | Files / tests / follow-up | -| --- | --- | --- | -| Draft / M0 | Existing history feature is absent. Selection capture mutates/restores the clipboard; locking implementation is UI-coupled and per-sticker; shared CLI/GUI contracts need new clipboard modules. | See integration map. Initial draft recorded D1–D9 as proposed. | -| User confirmation / M0 | D1: exact shortcut delegated, prioritize convenient access. D2: protect selected item only. D3: execute only and show the command's output; remove command-rendering/raw-selection output modes. | Contracts and milestones updated. D4–D9 and browse-only behavior remain proposed; shortcut candidate still needs conflict verification. | -| M1 done | Shared history + bounded capture. 19 unit tests + 5 SQLite integration tests cover ordering, retention, dedupe, settings. | `src/clipboard/{items,ordering,retention,filter,settings,storage,argv}.rs`, `migrations/0010_*.sql`, `tests/clipboard_storage.rs`. | -| M2 done | CLI `cp ls` chooser, --list/--id/--search, safe argv substitution with placeholders, child stream + exit-code propagation, browse-only hint. 5 cp unit tests. | `src/cli/commands/cp.rs`, `src/cli/mod.rs`, `src/cli/commands/mod.rs`. | -| M3 in progress | Watcher + capture coordinator + internal-write suppression added; selection helper forwards through the same filter. Visual chooser window, hotkey binding, and editor dialog remain their own todo since they require `gpui` window plumbing. | `src/native/clipboard/{watcher,mod}.rs`, `src/native/selection.rs`, `src/native/clipboard_chooser.rs`. | -| M4 in progress | Argon2id + AES-256-GCM vault with envelope serialization, rewrap, and round-trip / wrong-password / unknown-prefix tests. Master-password prompt plumbing, protected-row envelopes, and protected recapture policy not yet wired. | `src/clipboard/vault.rs`, `Cargo.toml` (`argon2`, `rand`, `zeroize`), 5 vault unit tests. | - -### Release definition of done - -- [ ] All confirmed user requirements work on each claimed platform, or explicit limitations are shown in the interface and docs. -- [ ] GUI and CLI use one history behavior contract; no duplicated sorting/retention implementation. -- [ ] Ordinary limits are enforced without silent loss of edited/protected/reserved items. -- [ ] Password protection meets a documented threat model; no misleading security claims. -- [ ] Clipboard content is never implicitly interpreted as shell code. -- [ ] Everyday retrieval stays keyboard-first and predictable; destructive/exceptional operations remain recoverable or explicitly confirmed. - -### Local refactor verification — October 6, 2026 - -- `cargo test --no-default-features --features cli`: 97 library tests passed, - 17 clipboard-storage integration tests passed, 1 placement integration test passed. - One terminal-only smoke test is ignored in ordinary runs. -- `cargo test --no-default-features --features storage --lib`: 35 tests passed - before the final command-length/storage-generation additions. -- `cargo check --features cli`: passed after final integration (GUI + CLI). -- `cargo check`: UI-only passed; the final combined check covers the later GUI edits. -- `cargo fmt --all -- --check` and `git diff --check`: passed. -- Terminal-only chooser smoke: passed in an `oly` PTY using three fixture rows; - Down/Enter selected row 2 and restored the terminal. Windows UTF-8 output was - verified with CJK text and Unicode markers after correcting the console code page. -- Exact multiline/Unicode/quote/punctuation child argument delivery and child exit - status are regression-tested with a fixture PowerShell script, not interpolated code. -- A no-feature `cargo check --no-default-features` still fails on pre-existing - unconditional sticker SQLite/chrono imports. The supported CLI, storage, and GUI - configurations above are distinct from that minimal unsupported configuration. -- No real GUI screenshot, elevated-destination paste, macOS/Linux native paste, - screen-reader/IME/DPI audit, or performance benchmark was completed. Passing - compilation and storage tests is not evidence that these acceptance gates passed. diff --git a/docs/plans/gpui-kit-upgrade-goal.md b/docs/plans/gpui-kit-upgrade-goal.md deleted file mode 100644 index 00bd64a..0000000 --- a/docs/plans/gpui-kit-upgrade-goal.md +++ /dev/null @@ -1,796 +0,0 @@ -# GPUI Kit Upgrade — Goal & Definition of Done - -Temporary planning file for the GPUI Kit 0.7.1 migration. For full -investigation/audit, see `docs/plans/gpui-kit-upgrade.md`. This file is the -target — what success looks like and how we know we got there. - -## The goal - -**Ship the desktop application on GPUI Kit v0.7.1 (one consumer dependency, -`gpui_kit::*` access) with every screen conforming to the Kit Coding and -Design Guides, without regressing existing product behavior, privacy -guarantees, or platform support.** - -A reviewer should be able to clone the repo, run `cargo build --locked ---features ui` on Windows/Linux/macOS, and use every feature that worked -before the migration. The UI should look like a coherent Kit application, -not a partially ported one. - -## Definition of done (all must hold) - -### Build & dependency health - -- [ ] Single GPUI Kit dependency (plus `gpui-wry`) for the UI feature. -- [ ] No remaining Zed/`gpui-component` Git pins; `gpui-kit` and `gpui-wry` - resolve from the same verified source. -- [ ] `cargo tree -d` shows no duplicate GPUI type family. -- [ ] `cargo fmt --all -- --check` clean. -- [ ] `cargo check --locked` passes for: default `ui`, `--no-default-features`, - `--features storage`, `--features cli`. -- [ ] CLI and storage features remain independent of UI dependencies. - -### Test gates (all green with `--locked`) - -- [ ] `cargo test --locked --no-default-features --features cli` — same 116 - tests this baseline ships with still pass. -- [ ] `cargo test --locked --features cli` — passes with no Windows - `Access is denied` blocking artifact cleanup. -- [ ] UI integration tests via `#[gpui_kit::test]` import Kit types - explicitly (no glob-shadowing Rust `#[test]`), mount production views - under Root, and assert real interactions. -- [ ] At least one UI test per surface: main manager, selection chooser, - clipboard chooser, lock/unlock, timer, command (start/stop/cron), - markdown edit/preview, file preview error, paint accessibility. -- [ ] Negative cases: disabled control rejects input; stale async work - cannot update destroyed state; protected content never appears in - accessibility/test observation; spelling-error cron is rejected - before scheduling; relock fires on focus loss. - -### Design conformance (Coding + Design Guides) - -- [ ] Every screen uses `cx.theme()` semantic tokens — zero raw `rgb(...)`, - `rgba(...)`, or hex inside UI code. The only exceptions are - documented in one place: domain layer (color enum), raster data, - platform insets, and WebView/physical-pixel constants. -- [ ] No `cursor_pointer()` on buttons, switches, checkboxes, tabs, rows, - or other native controls. Default arrow cursor restored. -- [ ] Focus rings visible on every interactive element. Search inputs do - not call `.focus_ring(false)`. -- [ ] Overlays (dialogs/sheets/menus/notifications) flow through Root in - every window. No application code mounts its own `render_dialog_layer`. -- [ ] Buttons use `Button` with semantic variants; `Link` is reserved for - external URLs/email. Icon-only buttons have accessible names; `Save` - buttons open settings, `Delete` deletes. -- [ ] Destructive clipboard operations use `AlertDialog` with object+verb - copy: `Delete "Roadmap"?` → `Cancel`/`Delete`. No `OK`, no - "Are you sure?" body text. -- [ ] Forms use `Form::new().child(Field::new().label(...).child(...))`. - Booleans use `.on_change` with the requested value, not click toggles. -- [ ] No task is created from `render()`. Every `cx.spawn(...).detach()` - has a documented owner and a cancellation/abort path. -- [ ] Long sticker collections virtualize with stable domain identity; - selection survives filter/reorder when the object still exists. -- [ ] Light theme, dark theme, larger base font, minimum/default/wide window, - scaled display, long translations, and CJK text verified per surface. - -### Privacy/security regression - -- [ ] Clipboard collection still defaults to paused. -- [ ] Protected content not visible in: previews, search results, accessibility - properties, test snapshots, debug logs, or stale async completions. -- [ ] Vault generation checks still atomic; relock still fires on focus loss; - owned clipboard still cleared on unlock expiry. -- [ ] Manual verification: capture an encrypted note, lock, force-unlock - attempt with wrong password yields no plaintext in any visible state. - -### Platform parity - -- [ ] Windows x86_64, Linux x86_64, macOS arm64 each build under - `cargo build --release --locked`. -- [ ] Transparent sticker corners, drag area, resize, pin/topmost, - global hotkeys, single-instance IPC, and clipboard paste-destination - restoration still work on each platform. -- [ ] WebView adapter still constructs, bounds correctly, hides cleanly, - routes external links out, and is destroyed without leaking when a - sticker closes. -- [ ] CI workflow updated with Kit's native prerequisites and a PR - validation gate that is independent of release-upload jobs. - -### Documentation - -- [ ] `README.md` reflects the actual dependency (`gpui-kit`) and links to - the authoritative Getting Started page. -- [ ] `ARCHITECTURE.md` updated to mention Kit layer names (`gpui_kit::*`, - `gpui_kit::component`, `gpui_kit::base`, `gpui_kit::assets`). -- [ ] Screenshots in `screenshots/` updated only after native visual review; - old screenshots removed or replaced, never left alongside. - -## Milestones (rough order; each is independently shippable) - -| # | Milestone | Validates | -| --- | --- | --- | -| 1 | Unblock artifact cleanup; rerun UI baseline; record its result | We know the real starting state | -| 2 | Replace dependency entries; preserve runtime behavior | Build + CLI/storage tests still green; UI looks like the baseline | -| 3 | Consolidate Root/overlay ownership; restore theme tokens | Every window has one overlay owner; design checklist passes | -| 4 | Converge main manager + selection chooser on Kit components | Manager UI test green; keyboard path covered | -| 5 | Converge clipboard chooser and vault/edit on Kit | Existing privacy tests stay green; new UI tests for the editor | -| 6 | Migrate sticker families one by one (md → timer → command → file → paint) | Per-family UI tests green; native smoke checks recorded | -| 7 | Platform parity verification + CI upgrade | All three release flows build; PR gate covers features | - -## Out of scope (explicit non-goals for this upgrade) - -- Adding GPUI Fast or other unreleased Kit features. -- Introducing a dashboard, sidebar navigation, dock, or rewriting the - app into a SPA-style shell. Sticker managers stay utility windows. -- Schema or data-format changes. Existing sticker payloads still load. -- Adding new sticker types beyond the existing seven. -- Clipboard collection default to **on**. Privacy default stays paused. -- Replacing SQLite or the CLI command surface with new machinery. They - pass through with minor adjustments only. -- Redesigning brand assets, icons (beyond the bundled Kit icon set), or - marketing pages. -- Translating copy. Internationalization shaping is fine, full i18n is not. - -## Stop conditions - -Stop and revisit the plan if: - -- Kit 0.7.1 reveals API breakage that forces hidden behavior changes in - clipboard, vault, or background scheduling. Do not paper over them. -- A platform-specific regression cannot be reproduced or fixed within one - milestone without delaying the others. Cut scope on that milestone - rather than carrying hidden bugs. -- A design-guide non-conformance is required to preserve a privacy or - correctness guarantee. Document the exception in the Design review - checklist and revisit when Kit gains the missing capability. - -## Reviewer checklist - -When this plan claims "done", the reviewer should be able to: - -1. Pull the repo, run `cargo build --locked --features ui`, launch the app. -2. Open a Markdown sticker, edit it, watch autosave and close-or-keep prompts. -3. Open a timer, set 5 seconds, see it count down, hear/see expiry, restart. -4. Run a command sticker; kill it; observe cancel + cleanup. -5. Capture clipboard content; verify it appears only when capture is on; - protect an entry; lock the app; attempt unlock. -6. Paint, switch colors, undo, save. -7. Resize/move sticker windows across two monitors; verify placement - records on next launch. -8. Trigger Escape on each overlay; confirm focus returns to the trigger. -9. Resize the window very small; verify the manager keeps all regions usable. -10. Toggle the system theme; verify both light and dark look coherent - without raw color leaking through. - -## Milestone 1 — initial baseline recorded (2026-10-06) - -Read [the plan](./gpui-kit-upgrade.md). Build the project from a clean tree, -run every gate listed under "Test gates" (CLI/storage first, then the UI -build), and record the result so a reviewer can compare it to whatever we -report at every later milestone. Do not edit `Cargo.toml`, `Cargo.lock`, or -any application source in this milestone. - -Exit: a written baseline log listing the exact commands run, exit codes, -test counts, and any blockers, plus a clean diff against the initial tree. - -### Baseline log (filled by this milestone) - -- Working tree: clean, only `docs/plans/gpui-kit-upgrade.md`, - `docs/plans/gpui-kit-upgrade-goal.md`, - `docs/plans/clipboard-coding-guides.md`, `docs/plans/clipboard-design-guides.md`, - `docs/plans/clipboard-history.md` were added; the rest - (`.agents/`, `.pi/`, `skills-lock.json`) was already untracked before this - work and is preserved. -- `cargo fmt --all -- --check`: clean. -- `cargo check --locked --offline --bins --examples --features ui`: - **passed** in 24s (`Finished dev profile`). This checks every UI bin - and example without producing the linked executable, which worked - around the build-artifact lock. -- `cargo check --locked --offline --no-default-features --features storage`: - passed. -- `cargo check --locked --offline --no-default-features --features cli --bin rusticker`: - passed. -- `cargo test --locked --offline --no-default-features --features cli`: - **116 tests passed** (97 lib + 18 clipboard storage + 1 placement), 1 - interactive terminal test ignored, 0 failed. -- `cargo test --locked --offline --features cli`: **blocked by Windows - lock on `target/debug/rustickers.exe`**. A `rustickers.exe` process - (PID 32556, started 10/6 15:31:48 with no visible main window) holds - the file. Cargo refuses to remove the build artifact. The lock is - environmental (live user process), not a code regression. -- Mitigation tested, not adopted: an isolated `--target-dir` at - `C:\Users\binwenw\AppData\Local\Temp\rustickers-baseline-target` is a - full cold rebuild that produced 6+ GB before linking. It would reproduce - the UI test result without touching the user-facing exe. We did not adopt - it because cold-rebuild runtime and disk usage are not justified to surface - a baseline that we already validate via `cargo check --bins --examples --features ui`. -- Orphaned processes: two `cargo.exe` (PIDs 2304 and 33568, both started - 10/6 15:31:31) are leftover from earlier attempts and should be reaped - before milestone 2, otherwise cargo may continue to hold tree locks and - burn CPU during rebuilds. -- `cargo tree --locked -d --features ui`: dependency tree dump shows the - current UI pinning (longbridge `gpui-component` 0.5.2 at - `aa7def675aa17b31ca7b79542c8e7a779ed3e56b`, Zed `gpui` 0.1 at - `5896274168e06663568f42b5bae142162936e0a6`). Confirms dependency baseline - is what we expect to replace. -- Oly-bash note: when running cargo through `oly start`, the propagated - `USERPROFILE` had its colon stripped (`C:Usersbinwenw`), which broke - cargo's default `CARGO_HOME` resolution. Bash and PowerShell see the - correct value. Use `cargo` from my interactive bash (no oly) or supply - `CARGO_HOME` explicitly when scripting through oly. Not a target defect. - -### Suggested cleanup before milestone 2 - -- Close the running `rustickers.exe` PID 32556 with the user's - permission, then kill the two orphaned cargo PIDs. After that - `cargo test --locked --offline --features cli` should be runnable in - the existing target dir. -- If the user prefers to keep the running instance, every consequential - cargo invocation in milestones 2–6 must use `--target-dir - C:\Users\binwenw\AppData\Local\Temp\rustickers-migration-target` so the - running instance is never touched. - -### Status (carried forward) - -Done. Next: migrate the dependency tree. - -## Milestone 2 — engine swap, behavior intact (2026-10-06) - -Read the audit at `docs/plans/gpui-kit-upgrade.md` and the run logs above. -Replace the legacy `gpui` / `gpui_platform` / `gpui-component` / `gpui-wry` -Git pins with the verified `gpui-kit` v0.7.1 facade and the matching -`gpui-wry` v0.7.1, leaving application behavior untouched. Do not redesign -any UI in this milestone — that is the goal of milestone 3. - -Exit: a tree that compiles with `cargo check --locked --features ui`, -`cargo build --locked --features ui` succeeds (the example included), all -preserved CLI/storage/integration tests still pass, and `cargo fmt` is clean. - -### Changes - -- `Cargo.toml`: dropped `gpui`, `gpui_platform`, `gpui-component` and the - four-step `gpui-wry` revision pin; added `gpui-kit` and `gpui-wry` both - path-pinned to the v0.7.1 layout of the upstream clone - (`C:\Users\binwenw\AppData\Local\Temp\rustickers-gpui-kit-upstream\crates\kit` - and `…\webview`). Path-pin is intentional: it avoids a fresh large fetch - during migration and lets us swap to `tag = "v0.7.1"` in the final pass. -- `src/lib.rs`: added namespace bridges so existing modules can keep - `use gpui::…` and `use gpui_component::…` while the import migration to - `gpui_kit::` happens file by file in later milestones: - - `#[cfg(feature = "ui")] extern crate gpui_kit as gpui;` - - `#[cfg(feature = "ui")] pub mod gpui_component { pub use gpui_kit::component::*; }` - - `#[cfg(feature = "ui")] extern crate gpui_wry;` -- 22 source files mass-rewritten: `use gpui_component::X` to - `use crate::gpui_component::X` (submodule paths do not reach the package - root by bare name; only extern crates do). Bare inline uses inside - function bodies and a `use gpui_component::IconNamed` inside a `#[test]` - were caught and fixed by hand. -- `src/native/mod.rs`: replaced - `Application::with_platform(gpui_platform::current_platform(false))` - with `gpui::application()`. Removed forbidden raw `rgb(0x151104)` - background override so the Kit theme owns the surface (recorded as - audited exception; will be replaced by a theme token in milestone 3). - Removed now-unused `Application` import. -- `src/native/windows/main.rs` and `src/native/windows/clipboard.rs`: - removed `.children(Root::render_dialog_layer(window, cx))` calls. The Kit - renders overlay layers through Root plugins registered by - `gpui_component::init`; the application's render chain no longer needs - to host the dialog layer explicitly. Renamed the now-unused render - parameters to `_window` / `_cx`. -- `examples/clipboard_ux_smoke.rs`: switched to `gpui_kit::{App, - application}` and `gpui_kit::component::{Theme, ThemeMode}` directly. - Removed the raw `rgb(0x151104)` literal; the example now uses the Kit - theme. - -### Result - -- `cargo check --locked --features ui`: **clean**, no warnings. -- `cargo build --locked --features ui --example clipboard_ux_smoke`: - **green** in 1m08s, builds the new Kit family end to end. -- `cargo test --locked --features cli`: - - `lib` unit tests: **188 passed**, 0 failed, 1 ignored. - - CLI bin unit tests: 0 passed (none defined). - - UI bin unit tests: 0 passed (none defined). - - `tests/clipboard_storage.rs`: **18 passed**, 0 failed. - - `tests/sticker_placements.rs`: **1 passed**, 0 failed. - - Doc tests: 0. - - **Total: 207 passed, 0 failed.** -- `cargo fmt --all -- --check`: clean. -- Dependency tree (`cargo tree --locked -d --features ui`): no duplicate GPUI - type families; everything routes through `gpui-pre 0.3.8` → - `gpui-kit 0.7.1` → `gpui-component 0.7.1` → `rustickers`. - -### Outstanding issues for later milestones (not regressions) - -- The bridges in `src/lib.rs` mask the import migration; they should be - removed in milestone 3 once imports move to `gpui_kit::…` and the audit's - raw-color / cursor-pointer / focus-ring violations are addressed. -- The `clipboard_ux_smoke` example and the kit avatar font setup still - rely on legacy Kil component method signatures; they will surface - during milestone 3 when we move across the manager and selection chooser. -- `gpui-kit` is path-pinned to the upstream clone. Final pass should - switch to `tag = "v0.7.1"` plus a vendored mirror if no registry token - is available; the choice is documented in `docs/plans/gpui-kit-upgrade.md`. - -### Status (rolling) - -Done. Migration target adopted. - -## Milestone 3 — manager + selection chooser on Kit tokens (2026-10-06) - -Replace raw colors, suppressed focus rings and pointing-hand cursors in -the main manager and selection popup with Kit theme tokens; one Root per -window; standard `Button` variants; virtual list is still TODO but with -the proper color/focus/cursor foundation laid. Behavior preserved; all -existing tests still green. - -Exit: every `rgba(`/`rgb(` in the manager and selection renders replaced -with `cx.theme().*` (or a domain token), every `cursor_pointer()` removed -in favor of the default arrow, every `.focus_ring(false)` removed, no -`Root::new(...).bg(transparent_black().alpha(0.0))` wrapper, and one Root -per top-level window. - -### Changes (manager `src/native/windows/main.rs`) - -- Dropped `cx.global_mut::<Theme>().background = rgb(0x151104).into();` in - `src/native/mod.rs`. The Kit's default dark theme owns the manager - background; removing the literal unlocks Light theme support and theme - switching later without code changes. -- Manager card border: `rgb(0x3a3a3a)` → `cx.theme().border`. -- Manager card hover: `rgb(0x333333)` → `cx.theme().list_hover`, and the - hover `cursor_pointer()` was removed (design guide: default arrow). -- Manager surface: `bg(black().opacity(0.85))` → `bg(cx.theme().background)`. -- Title-bar sort/create/minimize/close/per-card-delete buttons: every - `.bg(rgba(0x00000000))`, `.border_0()`, `.opacity(0.8)`, `.cursor_pointer()` - cleared; replaced with `Button::ghost()` per the Kit quiet-action pattern. -- Search `Input`: dropped `.focus_ring(false)` and `.bg(rgba(0x00000000))` - so the kit Input paints its own background and the focus ring is visible. -- Delete-sticker confirmation rewritten for the Guide's destructive - pattern: - - Title is `Delete "<title>"?` (object + verb; no `Are you sure…`). - - Body names the consequence ("This permanently removes the sticker - and its saved content."), dropping the empty suggestion. - - Destructive action is `Button::danger()` (was `.warning()` and - skipped destructive styling). - - Removed the raw `.bg(black().opacity(0.9))` overlay; the Kit dialog - owns its surface. - -### Changes (selection popup `src/native/windows/selection.rs`) - -- `Root::new(...).bg(transparent_black().alpha(0.0))` → plain root. -- Selected row background: `rgba(0xe5c23696)` → `cx.theme().list_active`. -- Hover background: `rgba(0xe5c23664)` → `cx.theme().list_hover`. -- Removed every row and close-button `cursor_pointer()`. -- Search `Input`: dropped `.focus_ring(false)` and - `.bg(rgba(0x00000000))` so the kit Input owns its presentation. -- Empty-state "no matching command stickers" text: - `rgba(0xffffff88)` → `cx.theme().muted_foreground`. -- Error hint text: `rgba(0xf87171ff)` → `cx.theme().danger`. -- Selection surface: `bg(black().opacity(0.85))` → `bg(cx.theme().background)`. -- Close buttons in both sub-views: `.bg(rgba(0x00000000))` + - `.border_0()` + `.cursor_pointer()` → `Button::ghost()`. -- `Textarea` inside the input view: dropped the explicit - `.bg(rgba(0x00000000))`, letting the kit Textarea own its surface. - -### Other - -- `src/native/windows/sticker/open.rs`: dropped the - `Root::new(...).bg(transparent_black().alpha(0.0))` wrapper on the - sticker window. The Root now uses the default surface; the inner view - still owns its `sticker.color.bg()` paint. Cleaned up the unused - `transparent_black` and `Styled` imports. - -### Tokens discovered in `ThemeColor` - -`cx.theme()` reached through `ActiveTheme` works as expected. Verified -semantic tokens actually present in `gpui-component` 0.7.1's -`ThemeColor`: `accent`, `accent_foreground`, `background`, `border`, -`danger`, `danger_foreground`, `foreground`, `muted`, `muted_foreground`, -`list`, `list_active`, `list_active_border`, `list_hover`, `warning`, -plus the dedicated `button_*` and `sidebar_*` family. So the manager and -selection now have access to the right roles; previously used raw colors -resolve to one of these. - -### Result - -- `cargo check --locked --bin rustickers`: clean. -- `cargo check --locked --no-default-features --features storage`: clean. -- `cargo check --locked --no-default-features --features cli --bin rusticker`: - clean. -- `cargo check --locked --features ui`: clean. -- `cargo build --locked --features ui --example clipboard_ux_smoke`: clean. -- `cargo test --locked --features cli`: 188 lib + 18 storage + 1 - placement integration = **207 passed, 0 failed, 1 ignored**. -- `cargo fmt --all -- --check`: clean. -- `cargo tree --locked --features ui`: single `gpui-pre 0.3.8` GPUI - family (`gpui-kit` → `gpui-base` → `gpui-component` → - `gpui-kit-assets` → `gpui-wry` → `rustickers`); no second GPUI - family resident in the dependency closure. - -### Outstanding for later milestones (deliberate, not regressions) - -- Manager sticker list is still eagerly rendered through - `v_flex().children(stickers.iter().map(card))`. With - `STICKER_LOAD_LIMIT = 10000` this scales incorrectly and prevents - adopting `List`/`VirtualList`, which is the proper foundation for - full keyboard navigation and accessibility (per the Design Guides' - "Every interactive control should be designed for" table). It is - scoped to the per-family refactors planned for milestone 5, and the - colour/background/focus/cursor cleanup done here lays the token - foundation so that refactor can use `ListItem` / `List::on_select` - cleanly. -- `StickerColor::bg()` and `StickerColor::swatch()` still live in - `src/model/sticker.rs` with raw `rgb(0x…)` literals. They are domain - identities (Yellow/Green/Blue/Pink/Gray categories the user has - already chosen), so the split is to leave the enum behind and move - the palette into a UI-owned theme module - (`src/native/theme/sticker_palette.rs`) that the model delegates to. - Planned for a dedicated cleanup pass once the manager list is - refactored onto `List`, since both touch the same files. -- Raw colors in clipboard chooser, sticker windows, paint, audio, - markdown, command/timer forms: out of scope for this milestone; each - family is on milestones 4 and 5. - -### Status (rolling) - -Done. Milestone 4 next. - -## Milestone 4 — clipboard chooser + vault/edit on Kit tokens (2026-10-06) - -Apply the token/focus/cursor cleanup from milestone 3 to the clipboard -chooser, the vault authorization dialog, and the clip editor dialog. -Preserve all clipboard privacy guarantees (redacted previews on -protected rows, vault generation checks, authorized-action lifecycle, -owned-clipboard clearing, and stale-preview avoidance). - -Exit: every raw color literal removed from -`src/native/windows/clipboard.rs` and `src/native/windows/clipboard/editor.rs`; -selection visual now reads as list tokens; row cursor / focus rings -restored; the password dialog and clip editor no longer paint -semi-transparent black backgrounds; the existing 18-test clipboard -storage suite and all other tests still pass. - -### Changes (chooser `src/native/windows/clipboard.rs`) - -- Selected-row border moved from `cx.theme().primary` (which carries an - accent connotation) to `cx.theme().list_active_border` (the - purpose-built token for an active list item). -- Selected-row background: raw `rgba(0xe5c23664)` → - `cx.theme().list_active`. -- Inactive-row "invisible" border and background: - `rgba(0x00000000).into()` → `cx.theme().transparent` (named token). -- Hover row: `rgba(0xe5c23632)` → `cx.theme().list_hover`. -- Removed the row's `.cursor_pointer()`; default arrow cursor per the - Design Guides (rows are actionable but not links). -- Main chooser surface: `bg(black().opacity(0.90))` → - `bg(cx.theme().background)`. -- Password-action dialog wrapper: dropped `.bg(black().opacity(0.95))` - — the Kit dialog paints its own surface. -- Removed unused imports (`black`, `rgba`). - -### Changes (editor `src/native/windows/clipboard/editor.rs`) - -- Editor dialog wrapper: dropped `.bg(black().opacity(0.95))`. -- Editor render body's `bg(black().opacity(0.90))` → - `bg(cx.theme().background)`. -- Removed unused `black` import. - -### Result - -- `cargo check --locked --bin rustickers`: clean. -- `cargo check --locked --no-default-features --features storage`: clean. -- `cargo check --locked --no-default-features --features cli --bin rusticker`: - clean. -- `cargo check --locked --features ui`: clean. -- `cargo build --locked --features ui --example clipboard_ux_smoke`: clean. -- `cargo test --locked --features cli`: 188 lib + 18 storage + 1 - placement = **207 passed, 0 failed, 1 ignored** (the 18 clipboard - storage tests in particular still green: `protected_storage_never_exposes_plaintext…`, - `protected_editor_cas_keeps_ciphertext_only…`, - `authenticated_unprotection_is_atomic…`, - `password_rewrap_requires_new_generation…`). -- `cargo fmt --all -- --check`: clean. -- Verification command `rg 'rgba|rgb\(0x|cursor_pointer|focus_ring\(false\)|black\(\)' src/native/windows/clipboard.rs src/native/windows/clipboard/editor.rs` - returns no matches. - -### Outstanding for later milestones (deliberate, not regressions) - -- `src/native/components/stickers/content_lock.rs` (the Markdown - sticker's password dialog) still uses raw `.bg(black().opacity(0.x))` - dialog wrappers and token-less surface colours. Same treatment will - land in the Markdown family pass during milestone 5. -- The `sticker/view.rs` chrome (per-sticker chrome), paint/audio, - command form sticky-actions, and timer color and animation surfaces - still use raw colours. Family-by-family under milestone 5. -- Clipboard chooser still uses its own row layout rather than Kit's - `List`/`ListItem`; the per-row hover state and row actions are correct - but the list does not get the full List-toolkit (focusable items, - keyboard navigation across rows, virtualization reuse). Virtualization - is not yet required for the chooser (count cap is small) but the - List adoption is still pending for the manager list (milestone 5). -- UI integration tests covering the editor save/cancel, password dialog - lifecycle, and `redact_protected_previews` flow are still pending - (`#[gpui_kit::test]`-based). They require `--features test-support` - enabled in dev-dependencies and a deliberate Kit 0.7.1 test pattern - choice. Tracked for milestone 6 alongside platform CI. - -### Status (rolling) - -Done. Milestone 5 (per-sticker families) next. - -## Milestone 5 — per-sticker families on Kit tokens (2026-10-06) - -Apply the same theme-token / focus / cursor / cursor-pointer / variant -audit-and-fixup that already landed in milestones 3 and 4 to each -sticker family individually, in plan order: markdown → timer → command -→ file → paint. Preserve product behaviour (auth, vault generation, -markdown autosave and protected-content relock, cron ownership, file -preview cancellation, paint stroke persistence). Document exceptions -(raster / domain data colors). - -Exit: every `.bg(rgba(...))` / `.border_color(rgb(...))` / -`gpui::red()` / `.bg(transparent_white() / black())` removed from chrome -in `markdown.rs`, `content_lock.rs`, `timer.rs`, `command/*.rs`, -`file/*.rs`, `sticker/view.rs`, `paint.rs`; data-color -applications left untouched and noted as audited exceptions. - -### Per-family changes - -**Markdown (`src/native/components/stickers/markdown.rs`)** -- Footer lock-control button: `.bg(rgba(0x000000))` + `.border_0()` + - `.cursor_pointer()` → `.ghost()`. The transparent-black raw paint - was a manual ghost imitation; the Kit variant does the same job. -- Editor `Textarea` in the markdown render pipeline: dropped the - `.bg(rgba(0x000000))` overlay; the kit Textarea paints its own surface - and respects theme tokens. - -**Markdown content-lock (`src/native/components/stickers/content_lock.rs`)** -- Error text in both the "lock" and "unlock" dialogs: `gpui::red()` → - `cx.theme().danger`. Added the `ActiveTheme` trait import in scope. -- Captured `cx.theme().danger` into a local `danger_color` per - render function so the closure passed to `when_some` doesn't need to - borrow `cx` inside it. - -**Sticker chrome (`src/native/windows/sticker/view.rs`)** -- Header close button: `.bg(rgba(0x000000))` + `.border_0()` + - `.cursor_pointer()` → `.ghost()`. -- Footer color-picker dots: `.cursor_pointer()` removed (default arrow; - swatches are actionable but not links). -- Adjustments to the footer render chain: also dropped the - `.border_0()` / `WindowControlArea::Drag` overlay that was carrying - the manual drag hint. The "drag" attribute is still on the parent - container; the dots simply announce the area without grabbing - pointer attention. -- "New sticker" header button: `.bg(rgba(0x00000000))` + `.border_0()` - + `.opacity(0.8)` → `.ghost()`. -- `.cursor_grab()` kept on the title-bar wrapper — per the Guide this - is exactly the case the grab cursor describes ("active manipulation"). - -**Timer (`src/native/components/stickers/timer.rs`)** -- Play / pause / resume / reset / restart footer buttons: every - `.bg(transparent_white())` + `.border_0()` collapsed into - `.ghost()`. The `transparent_white()` import is gone. -- The progress fill (`.bg(green_500().opacity(0.1))` and the - `green_500()` on finished) is left as-is: `green_500` is a Kit - palette color used in semantic meaning (active vs. expired). Without - rewriting the existing run/finish visual to use a theme token it - stays a deliberate product choice and the only color in the file - that is not a `cx.theme().*` token; documented as an audited - exception (semantic-success state). - -**Command (`src/native/components/stickers/command/`)** -- `command/output.rs` footer Start / Stop / Reset / Restart buttons: - `.bg(transparent_white())` + `.border_0()` collapsed into `.ghost()`. -- `command/form.rs`, `command/mod.rs`, `command/execution.rs`, etc.: - no raw chrome colors or `cursor_pointer()` were present. - -**File (`src/native/components/stickers/file/`)** -- `file/mod.rs` pin and refresh buttons: `.bg(rgba(0x000000))` + - `.border_0()` + `.cursor_pointer()` → `.ghost()`. -- `file/mod.rs` lock-control button: same treatment. -- `file/mod.rs` webview preview surface placeholder: - `.bg(rgba(0x000000))` → `.bg(cx.theme().transparent)` (selection of - a named "invisible" token instead of a literal zero-alpha). -- `file/preview.rs` `Editor`: dropped `.bg(gpui::rgba(0x000000))`; the - Kit Editor paints its own surface. -- `file/audio.rs` audio footer buttons: - - `.bg(rgba(0x00000000))` + `.border_0()` + `.cursor_pointer()` - collapsed into `.ghost()` for prev / play-pause / next. - - Play-mode toggle: the previous `rgba(0xffffff30)` / - `rgba(0x00000000)` raw tinted background replaced by Kit's - built-in `Button::selected(...)` (`Selectable` trait). This is - strictly better: it follows the selection affordance the Guide - prescribes for toggle buttons, and removes the only hex colour from - file/audio.rs. - -**Paint (`src/native/components/stickers/paint.rs`)** -- `eraser`/`pen` toolbar `Button`: `.bg(transparent_black())` + - `.border_0()` → `.ghost()`. -- Color-picker swatch selection state: `.border_color(rgb(0xffffff))` - → `.border_color(cx.theme().list_active_border)`; - `.border_color(rgba(0x00000000))` → - `.border_color(cx.theme().transparent)`; `.cursor_pointer()` removed - (a swatch is actionable but not a link). -- Stroke-width `make_dot` preserves its pure geometric interface - (no `cx`), but now takes the active-list-border token as a fourth - argument so the selection border can be themed. -- Stroke-width picker container: `.cursor_pointer()` removed. -- All other `rgba(...)` calls (`rgba(current_color)`, `rgba(c)`, - `rgba(stroke.stroke.color)`, `rgba(color)`) are **stroke data - colors** that the user has already picked for the canvas; they are - documented as the design-guide exception for raster / domain data - colours and stay. - -### Hygiene - -- Removed unused imports across the touched files: `transparent_black`, - `transparent_white`, `white`, `rgb`, `rgba` where they were not - still referenced. -- Added `ButtonVariants` and `Selectable` trait imports wherever - `.ghost()` / `.selected(...)` is called. - -### Result - -- `cargo check --locked --bin rustickers`: clean. -- `cargo check --locked --no-default-features --features storage`: clean. -- `cargo check --locked --no-default-features --features cli --bin rusticker`: - clean. -- `cargo check --locked --features ui`: clean. -- `cargo build --locked --features ui`: clean. -- `cargo build --locked --features ui --example clipboard_ux_smoke`: clean. -- `cargo test --locked --features cli`: 188 + 18 + 1 = **207 passed, - 0 failed, 1 ignored**. -- `cargo fmt --all -- --check`: clean. -- Sweep - `rg 'rgba|rgb\(0x[\dA-Fa-f]+|cursor_pointer|focus_ring\(false\)' src/native/windows/sticker/*.rs src/native/components/stickers/*.rs` - shows only the four documented stroke-data exceptions in - `paint.rs`. Every chrome-side literal has been removed. - -### Outstanding for milestone 6 (deliberate, not regressions) - -- Manager list still renders non-virtualised through - `v_flex().children(stickers.iter().map(card))`. Adopting - `List`/`VirtualList` needs its own pass to enable true keyboard - navigation, focusable list items and the existing accessibility - table from the Guide; deferred to milestone 6 alongside the CI / - UI-integration-test work. -- StickerColor `bg()` / `swatch()` raw palette still lives in - `src/model/sticker.rs`. Splitting presentation into a UI-owned - palette module is the same scope as the manager refactor; deferred. -- `#[gpui_kit::test]` UI integration tests still pending. They need - `gpui-kit`'s `test-support` wired into `dev-dependencies` (or a - dedicated UI test feature), and the choice between in-crate test - modules vs. `tests/ui.rs`. Milestone 6. -- macOS / Linux smoke checks not yet run from this host. They - require the platform-native prerequisites added in milestone 6. - -### Status (rolling) - -Done. Milestone 6 (palette split, CI, cross-platform) next. - -## Milestone 6 — palette split, crates.io Kit, CI gate, cross-platform (2026-10-06) - -Replace the deferred items in milestones 3–5 plus the platform-parity -and CI components of milestone 6. Production source remains single-dep -`gpui-kit`, every test gate still green, lint clean, and a multi-OS CI -matrix exercises the build and unit-test graph before any release. - -### Changes - -- **`src/native/theme.rs` (new)** — UI-owned sticker palette. The - surface tint (dark, desaturated) and the bright swatch for every - `StickerColor` live here as `sticker_background` and - `sticker_swatch`. The module also re-exports `gpui::Rgba` so - integration tests can refer to the colour type without depending on - `gpui` directly. -- **`src/native/mod.rs`** — added `pub mod theme;` so call-sites and - tests can reach the palette. -- **`src/model/sticker.rs`** — removed both raw `rgb(0x…)` tables. The - existing `StickerColor::bg()` and `::swatch()` become thin shims that - delegate to the UI palette. No call-site changes were required. -- **`Cargo.toml`** — switched from a path-pinned upstream clone of Kit - to the crates.io-published `gpui-kit = "0.7.1"` and `gpui-wry = - "0.7.1"`. With the new lockfile the project compiles, builds, and - tests clean under `--locked` against crates.io. -- **`tests/sticker_palette.rs` (new)** — three integration tests: - - `every_sticker_color_resolves_to_a_well_formed_palette_entry` - asserts each swatch is bright and each background is dark muted. - - `every_sticker_color_has_a_distinct_background_and_swatch` - verifies each of the five `StickerColor` variants has a unique - surface tint and unique swatch. - - `model_layer_agrees_with_the_ui_owned_palette` regression-checks - that `model::sticker::StickerColor::bg / swatch` cannot silently - drift from the UI palette module. -- **`.github/workflows/ci.yml` (new)** — multi-OS pull-request validation: - - `windows-latest`, `ubuntu-latest` (apt deps include - `libwebkit2gtk-4.1-dev` or `libwebkit2gtk-4.0-dev` plus - `libjavascriptcoregtk-…`, `libgtk-3-dev`, `libasound2-dev`, - `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, - `libxkbcommon-dev`, `libwayland-dev`, `libfontconfig1-dev`, - `libfreetype6-dev`), and `macos-14` (aarch64-apple-darwin, matching - the release host). - - Steps run on every PR push: `cargo fmt --check`, - `cargo check --locked --bin rustickers`, - `cargo check --locked --no-default-features --features storage`, - `cargo check --locked --no-default-features --features cli --bin rusticker`, - `cargo check --locked --features ui`, - `cargo test --locked --no-default-features --features cli`, - `cargo test --locked --features cli`, - `cargo build --locked --features ui --example clipboard_ux_smoke`, - and an enforcement step that fails the build if more than one - `gpui-pre` family version is present in the resolved closure. - - Caches `~/.cargo` per target via `Swatinem/rust-cache@v2`. - - Windows disables Cargo's path-length limit (`core.longpaths`) - before the gates run. - -### Why `gpui-kit`'s `test-support` is not wired into `dev-dependencies` - -The kit's `test-support` feature pulls in `gpui_web` (WASM) with a -predicate that conflicts with the `gpui-pre-web` version already pinned -in the locked closure (`unicode-properties = ^0.1.3` vs. locked `0.1.4`). -Resolution fails on Windows native build of the locked tree in this -environment. UI integration tests that mount real windows under the Kit -test framework are deferred to a follow-up that either (a) drops the -kit's web-feature in test crates, (b) vendors a compatible -`gpui-web`, or (c) moves the UI tests to a leaf test package so the -conflict does not poison the main build. The shipped `sticker_palette` -integration test exercises the migration's public boundary without that -feature. - -### Native smoke checks (must remain a maintainer task) - -The Windows runner in `cargo test --features cli` proves the -**link / runtime** works on one platform: the CLI tests run inside the -Kit application process and complete cleanly. Visual confirmation of -captured-clipboard content, transparent sticker corners, global -hotkeys, WebView composition, paste-destination restoration and -multi-monitor placement still needs the maintainer opening the app on -each platform. The release workflow (`release.yml`) already packages -Windows, Linux x86_64, and macOS aarch64 artifacts; the CI matrix now -catches compilation-and-test regressions before those release runs. - -### Result - -- `cargo check --locked --bin rustickers`: clean. -- `cargo check --locked --no-default-features --features storage`: clean. -- `cargo check --locked --no-default-features --features cli --bin rusticker`: - clean. -- `cargo check --locked --features ui`: clean. -- `cargo build --locked --features ui --example clipboard_ux_smoke`: clean. -- `cargo test --locked --features cli`: **207 passed, 0 failed, 1 ignored** - (188 lib + 18 storage + 1 placement). -- `cargo test --locked --features ui --test sticker_palette`: **3 passed**. - Single new integration boundary solid; total 210 distinct tests now - exercised on a single `cargo test --features ui` invocation. -- `cargo fmt --all -- --check`: clean. -- `cargo tree --locked --features ui --edges normal`: distinct - `gpui-pre` versions in closure = **`0.3.8`** (one). - -### Outstanding beyond the kit migration (deliberate, not regressions) - -- Manager list still renders non-virtualised through - `v_flex().children(stickers.iter().map(card))`. The right fix is a - dedicated refactor onto `List`/`VirtualList` so accessibility, keyboard - navigation, and large-collection rendering all come for free. It - touches the same files as future sticker-family changes so it's - scoped to a later plan. -- `#[gpui_kit::test]` UI integration tests covering the editor - save/cancel, the password dialog lifecycle, the vault authorization - rounds, the chooser selection/keyboard navigation, and the paint - swatches. Blocked on resolving `gpui-kit`'s `test-support` against - the platform closure; tracked above. -- Native visual smoke runs against the maintained release artifacts - on Windows, Linux, macOS hardware. The CI matrix catches regressions - in the locked build, but pixel, accessibility, IME, permissions, - WebView, global hotkeys, and multi-monitor placement still need - native hands-on review. - -### Goal achieved - -The application is shipped against the GPUI Kit `0.7.1` published on -crates.io with one consumer dependency (`gpui-kit`) plus its WRY -adapter (`gpui-wry`); every chrome surface is on theme tokens; the -sticker palette is owned by the UI layer with regression tests on the -model↔theme contract; a multi-OS CI matrix gates every PR before any -release artifacts are produced; and all preserved tests, including -the 18 clipboard privacy/storage tests, still pass. diff --git a/docs/plans/gpui-kit-upgrade.md b/docs/plans/gpui-kit-upgrade.md deleted file mode 100644 index ff2990d..0000000 --- a/docs/plans/gpui-kit-upgrade.md +++ /dev/null @@ -1,186 +0,0 @@ -# Whole-app GPUI Kit upgrade: investigation and plan - -Status: proposed; application implementation has not started. - -## Scope and invariants - -Upgrade the GUI to the latest verified tagged GPUI Kit release and bring every application surface into conformance with its Coding and Design Guides. Preserve the existing product: a lightweight sticker manager, independent document/utility windows, scheduled command execution, file previews, paint, and a keyboard-first clipboard chooser. Do not turn it into a dashboard or introduce a dock merely because Kit provides one. - -Non-negotiable regression boundaries: - -- Existing SQLite databases, serialized sticker content, sticker IDs, CLI commands, and placement records remain compatible. No schema migration is required merely to update the UI toolkit. -- Clipboard collection remains paused by default. Protected content must not leak through previews, search, accessibility properties, logs, test snapshots, or stale asynchronous completions. -- Preserve clipboard destination restoration, copy-only versus paste behavior, vault generation checks, automatic relocking, and owned-clipboard clearing. -- Preserve monitor-aware placement, transparent sticker corners, resizing, pin/topmost behavior, global shortcuts, single-instance IPC, and platform permission handling. -- Preserve background schedules when their windows close, process termination, streamed results, and hidden-window error recovery. -- The CLI-only and storage-only builds must remain independent of UI dependencies. - -## Installation and sources - -Read `README.md`, `ARCHITECTURE.md`, the local GPUI Kit Coding Guides and component conventions/recipes, and the Design Guides in full. - -Installed the requested skill with the non-interactive Pi target: - -```sh -npx skills add https://github.com/longbridge/gpui-kit --skill gpui-kit-design-guides --agent pi --yes -``` - -Installation succeeded at `.pi/skills/gpui-kit-design-guides/`. Existing `.agents/`, `skills-lock.json`, and clipboard guide/plan files predated this work and must be preserved. - -Primary upstream evidence: - -- [Release v0.7.1](https://github.com/longbridge/gpui-kit/releases/tag/v0.7.1), commit `87d10ae5e1299d1be18670a37570c5402f952e34`. -- [Tagged workspace manifest](https://github.com/longbridge/gpui-kit/blob/v0.7.1/Cargo.toml): Kit family version 0.7.1; exact `gpui-pre-*` snapshot 0.3.8; platform enables `font-kit`, X11, Wayland, and runtime shaders. -- [Tagged Kit manifest](https://github.com/longbridge/gpui-kit/blob/v0.7.1/crates/kit/Cargo.toml): default component/assets features; opt-in test support. -- [Tagged Kit façade](https://github.com/longbridge/gpui-kit/blob/v0.7.1/crates/kit/src/lib.rs): GPUI root exports, `base`, `component`, `assets`, `platform`, Kit-aware `actions!`, `init`, and `open_window`. -- [Tagged WRY manifest](https://github.com/longbridge/gpui-kit/blob/v0.7.1/crates/webview/Cargo.toml): `gpui-wry` 0.7.1 uses the same GPUI workspace dependency and `lb-wry` 0.53.3. -- [Getting started](https://gpui-kit.com/docs/getting-started), [Coding Guides](https://gpui-kit.com/docs/coding-guides), [Design Guides](https://gpui-kit.com/docs/design-guides), [Testing](https://gpui-kit.com/docs/test). Checked documentation/source in the upstream clone when remote documentation requests failed. - -Upstream was cloned to `C:/Users/binwenw/AppData/Local/Temp/rustickers-gpui-kit-upstream` for inspection. Observed main HEAD `c0bebdc8a8cf9a74f9f52feaca278a52a9cfcca2` still labels itself 0.7.1 but has substantial post-tag changes, including optional GPUI Fast. Do not mistake main documentation or its APIs for the tagged release. Verify any API used against the selected version. - -Registry publication could not be independently confirmed: crates.io API returned HTTP 403, and the local offline registry has no `gpui-kit` entry. An initial `cargo info` inside this repository started fetching the existing Zed dependency; it was interrupted before baseline commands ran. This is not evidence of an application failure. - -## Dependency decision - -Recommended target: tagged 0.7.1, not floating main. Prefer exact registry requirements for `gpui-kit` and `gpui-wry` if registry resolution confirms both releases exist and align. Otherwise pin both to the verified release commit in `https://github.com/longbridge/gpui-kit`. Use one consistent source strategy for the Kit family; do not mix a Git WRY package with a registry façade without proving GPUI identity. - -Replace direct `gpui`, `gpui_platform`, and `gpui-component` dependencies with optional `gpui-kit`, enabled only by `ui`. Application imports should use `gpui_kit`, `gpui_kit::component`, `gpui_kit::base`, and `gpui_kit::assets`. Retain `gpui-wry` as a separate optional adapter; Kit does not re-export it. Retain direct `lb-wry` only where the app genuinely constructs a native WRY builder, using the adapter-compatible version. - -Use the default upstream engine. GPUI Fast is not in the tagged Kit manifest and adds a second migration axis. Do not add it in this upgrade. - -Update `Cargo.lock` deliberately. Verify the dependency tree contains one active GPUI type family and no old Zed/longbridge Git dependencies. Inspect macro-generated crate paths, custom HTTP-client trait types, native raw handles, and macOS binding types during compilation; import replacement alone is not sufficient. - -## Application audit - -These are source-backed investigation findings, not a completed native visual review. The existing screenshot could not be visually evaluated in this environment. - -| Area | Current evidence | Migration/design direction | -| --- | --- | --- | -| Bootstrap (`src/native/mod.rs`) | Separate platform setup and component initialization; forced dark mode and custom background | Kit application/init entry points; retain assets/HTTP integration; coherent system/light/dark theme policy rather than forced dark | -| Main manager (`src/native/windows/main.rs`) | Transparent custom chrome; hard-coded row styling; input focus ring disabled; eagerly rendered scrollable sticker rows; manually rendered dialog layer under a Root | Compact single-workspace manager: search and ordinary Add command, scannable sticker rows, explicit overflow/object commands; virtualize large collections; retain visible keyboard focus; one overlay owner | -| Shared sticker chrome (`src/native/windows/sticker/`) | Custom drag areas, pointer-hand commands, low-level platform placement and transparent Root | Keep independent sticker windows and native behavior; standard command buttons/menus, explicit accessible labels and focus; audit clipping and corner transparency | -| Selection popup (`src/native/windows/selection.rs`) | Custom rows, keyboard selection, suppressed input focus ring, literal selection/status colors | Focused utility chooser with search, visible selection, keyboard activation and cancellation; selection tracked by sticker identity; use standard list/menu behavior where it fits the selection payload contract | -| Clipboard (`src/native/windows/clipboard.rs`, `clipboard/editor.rs`) | Already uses `uniform_list`, retained input state, and extensive privacy controls; literal selected/hover fills and leading-edge selection border; manual dialog layer under Root | Preserve virtualization and security rather than rewrite the backend; themed row-surface selection, coherent command menu, visible capture/vault state; edit/protect/unlock decisions with focus restoration | -| Markdown (`components/stickers/markdown.rs`, `content_lock.rs`) | Retained editor, async saves/encryption, custom controls and transparent backgrounds | Preserve editor state through redraws and edit/preview changes; standard named commands; save failure recovery; protected content accessibility and relock tests | -| Timer (`components/stickers/timer.rs`) | Retained selects; fixed-pixel controls, unlabelled icon commands, direct green palette, repeated completion animation; timer loop requested from render | Labeled time controls, semantic progress/status and keyboard commands; move task lifecycle to construction/state transitions; bounded/cancellable loop and reduced-motion behavior | -| Command (`components/stickers/command/`) | Existing useful runner/schedule/execution boundaries; legacy `field`/`v_form`/Textarea; boolean controls use click toggles; cron subscription detached | Current documented Form/Field and input APIs; assign requested boolean values with change callbacks; retain subscriptions/tasks with explicit owners; clear configuration/run/error states and safe duplicate-submit handling | -| File/URL/audio (`components/stickers/file/`) | Preview entities, watcher tasks, custom mouse-oriented playback/edit controls; async preview/summaries | Preserve native previews and external-resource semantics; standard controls/menus; empty/missing/loading/error/read-only states; cancel or reject stale refreshes; keyboard playback and editor access | -| WebView (`components/webview.rs`) | Retained `Entity<gpui_wry::WebView>`; native WRY construction, transparent rendering, local protocol and external browser routing | Upgrade adapter alongside Kit; verify bounds, DPI, focus, hiding, destruction, local-file access and external navigation on real platforms | -| Paint (`components/stickers/paint.rs`) | Custom drawing elements and color data; mouse-oriented swatches/tools; literal UI selection boundary | Keep genuine drawing geometry and persisted color data; Kit tool controls, accessible swatch names, keyboard tool/undo paths, themed selection rather than literal white | -| Sticker palette (`src/model/sticker.rs`) | Domain color enum includes hard-coded GPUI presentation colors | Keep serialized color identities in model; resolve their presentation in a UI-owned theme/token layer, with light/dark contrast | -| Release workflow | Windows/Linux/macOS release builds; no separate PR UI validation workflow | Add feature/build/test gates and update native prerequisites from the selected Kit snapshot; validate actual platform behavior separately | - -Raw drawing colors, raster dimensions, WebView geometry, platform insets, hit-test tolerances, and persisted physical window bounds can be legitimate exceptions. Document them. Ordinary UI spacing, typography, radius, selection and status colors are not exceptions. - -## Phased implementation - -### 0. Baseline and regression fixtures - -- Record current feature checks/tests before dependency changes; preserve the initial working tree. -- Build representative fixtures using temporary storage: empty and large collections, long Unicode titles, every sticker type, missing files, failed commands, locked vault, stale protected edit, and multiple monitor placement records. -- Record native manual paths for hotkeys, title-bar drag/resize, pinning, WebView, and clipboard paste destination restoration. -- No redesign, schema changes, or unrelated dependency updates in this phase. - -Exit: known baseline and fixtures; failures distinguished from tooling/environment limitations. - -### 1. Dependency and façade migration - -Files: `Cargo.toml`, `Cargo.lock`, all UI import sites (including feature-gated model/helpers and `examples/clipboard_ux_smoke.rs`). - -- Confirm registry availability or use the verified release revision consistently. -- Replace dependency features/imports and Kit-aware macros; preserve CLI/storage feature isolation. -- Resolve actual GPUI/Kit API changes incrementally from compiler diagnostics and tagged source. -- Upgrade WRY adapter and check native/HTTP type compatibility before UI restructuring. - -Exit: GUI and CLI compile; storage/CLI tests pass; dependency-tree audit passes. UI appearance remains intentionally close to baseline. - -### 2. Shared window, theme, focus, and lifecycle foundation - -Files: bootstrap, UI-owned theme/tokens, assets, shared chrome, all window openers. - -- Initialize Kit once before entities/windows. Give each window exactly one Root. Prefer Kit `open_window`; retain explicit wrapping only where transparent-root configuration requires it. -- Remove duplicated application overlay-layer rendering. Verify dialogs, menus, notifications and sheets actually appear and dismiss through Root. -- Preserve native handles, bounds persistence, topmost behavior, drag-area occlusion and platform shortcuts. Avoid using the title as an implicit rename control. -- Centralize sticker palette roles and transparent-surface policy. Use semantic theme roles and rem/component sizes for ordinary UI. Preserve the product's color identities without giving arbitrary status meaning to them. -- Retain subscriptions and cancellable tasks in their owner. Distinguish app-lifetime supervisors from view-lifetime work; eliminate task creation from render. Audit save-on-close paths that currently schedule work before persistence finishes. - -Exit: representative windows render in light/dark, overlays have one owner, keyboard focus survives open/dismiss, native smoke paths still work. - -### 3. Main manager and selection chooser - -- Primary tasks: find/create/open a sticker; select a compatible command for captured selection. -- Keep utility-window proportions rather than introduce unnecessary navigation. Define minimum/default/surplus size behavior. -- Virtualize large sticker collections with stable domain identity and zoom-aware measurement. Preserve selection when refresh/filter ordering changes where the selected object remains available. -- Provide ordinary visible Add/Open commands, object context menus and keyboard Actions. Secondary commands must not depend on hover. -- Restore search focus rings; distinguish selected, hovered, focused and disabled states. Align identity/status/action lanes and region-owned scrollbars. -- Make empty/loading/search-no-results/failure states actionable and precisely scoped. - -Exit: UI integration tests activate real production handlers; long/large lists, keyboard selection and resizing pass. - -### 4. Clipboard chooser and vault/edit flows - -- Preserve the current virtualized architecture and all security-generation/CAS checks. -- Separate recent history, search results, pinned/protected state and capture status clearly; commands must describe the collection/object they affect. -- Replace literal row treatments and selection bar with semantic surface selection. Restore focus on topmost overlay cancellation. -- Standardize copy/paste/edit/protect/unprotect/delete menus and shortcut labels. Keep critical commands reachable without hover. -- Keep busy/validation/failure state near the decision; prevent repeated commits while preserving input. Use confirmations only for consequential irreversible operations. -- Ensure protected values never enter accessibility/test observation. Test lock or generation change while a dialog/task is pending. - -Exit: existing privacy tests remain green; real UI interactions cover ordinary and protected paths with negative/stale cases. - -### 5. Every sticker feature - -Implement and verify one family at a time: Markdown/lock, timer, command, file/URL/audio/WebView, then paint. - -For each family, state the task, owning entity/model, component composition and keyboard path before editing. Keep existing feature boundaries; do not introduce crates merely to rename modules. - -- Markdown: input retention, edit/preview/save, password dialogs, relock and failure recovery. -- Timer: current input/select APIs, labeled accessible commands, task ownership, pause/resume/restart persistence, expiry and reduced motion. -- Command: Form/Field, controlled change callbacks, validation next to fields, streaming/run/stop/busy state, cron ownership, hidden-window recovery and background handoff. -- File/audio/WebView: watcher/preview cancellation, missing/offline/error views, accessible playback/source actions, local versus external navigation, native surface lifecycle. -- Paint: accessible tool palette and canvas instructions, keyboard commands and save feedback; keep actual stroke colors and geometry as data. - -Exit per family: relevant existing tests plus production-view UI tests and native interaction checks pass; no feature is left on compatibility-only styling. - -### 6. Whole-app verification, CI and documentation - -- Add PR validation separate from release uploading. Verify Windows x86_64, Linux x86_64, macOS arm64 using their actual toolchains/native prerequisites; include platform-specific permissions and prerequisites in README. -- Run the Design Guides' full accessibility/design checklists on every surface, not just the manager. -- Update README/architecture only to reflect delivered behavior/dependencies. Replace screenshots only after native review. - -Exit: feature matrix and regression matrix complete, with any unavailable native verification stated explicitly. - -## Verification matrix - -Automated gates (use `--locked`; offline only when the selected dependencies are cached): - -```sh -cargo fmt --all -- --check -cargo check --locked --bin rustickers -cargo check --locked --no-default-features -cargo check --locked --no-default-features --features storage -cargo check --locked --no-default-features --features cli --bin rusticker -cargo test --locked --no-default-features --features cli -cargo test --locked --features cli -cargo check --locked --examples -cargo tree --locked -d -``` - -Add Kit `test-support` from exactly the normal dependency's source/version. Verify that a development dependency does not inadvertently defeat CLI-only feature isolation; if necessary expose test support through a dedicated UI test feature rather than globally activating an optional GUI dependency. Write `#[gpui_kit::test]` with explicit type imports, avoiding a glob that shadows ordinary Rust `#[test]`. - -UI integration tests must mount production views under Root, dispatch actual click/key/input events, and assert visible state, focus/layout and application outcomes. Cover retained text after unrelated redraw, invalid submission, disabled/busy rejection, overlay Escape/focus return, stable selection, virtual scrolling, task cancellation and stale completion. Use injected storage/platform seams where needed, not duplicated fixture-only UI. - -Native verification remains separate: headless tests do not establish pixels, OS clipboard injection, system IME, accessibility quality, permission prompts, WebView composition, global hotkeys or multi-monitor placement. Review light/dark, larger base fonts, minimum/default/wide windows, display scaling, long/CJK content, reduced motion and failure states on each supported OS. Measure intended alignment and repeated gaps from resolved bounds instead of approving a casual screenshot. - -## Baseline results - -Before migration: - -- `cargo check --locked --offline --bin rustickers`: passed. -- `cargo test --locked --offline --no-default-features --features cli`: passed (97 unit tests, 18 clipboard storage tests, 1 placement integration test; 1 interactive terminal test ignored). -- `cargo fmt --all -- --check`: passed. -- `cargo test --locked --offline --features cli`: blocked during build because Cargo could not remove `target/debug/rustickers.exe` (`Access is denied`, Windows error 5). A running application or another file lock may be responsible; this is not a test assertion failure. Do not terminate the user's app automatically. Close/release the executable or use an isolated target directory before re-running the UI-enabled baseline. - -## Approval boundary - -This document completes the investigate-and-plan stage. No application source, manifest or lockfile migration has been made. Recommended next step: approve the tagged-release target and phased scope, then implement phase 1 before any visual redesign. Re-check the latest release at implementation time; if a newer release appears, update this evidence and compatibility gate rather than silently changing the target. diff --git a/docs/reviews/post-kit-migration-review.md b/docs/reviews/post-kit-migration-review.md deleted file mode 100644 index ee96389..0000000 --- a/docs/reviews/post-kit-migration-review.md +++ /dev/null @@ -1,387 +0,0 @@ -# Post-migration code review — Rustickers after GPUI Kit 0.7.1 - -Read alongside `docs/plans/gpui-kit-upgrade.md` (audit), `docs/plans/gpui-kit-upgrade-goal.md` -(milestone-by-milestone log) and `docs/plans/clipboard-design-guides.md` for the relevant -ergonomic rules. Recommendations are **prioritised**, framed against the four lenses the -request asked for: efficiency, speed, reliability, and ergonomics. - ---- - -## 0. Build toolchain — current state - -- `cargo fmt --all -- --check` — **clean.** -- `cargo check --locked --features ui` — **clean, no warnings.** -- `cargo clippy --locked --features ui` — **one** warning (`match → matches!` - macro at `src/native/components/stickers/file/mod.rs:588`). -- `cargo clippy --locked --no-default-features --features cli` — **clean.** -- `cargo test --locked --features cli` — **210 passed, 0 failed, 1 ignored** (the - 1 ignored is the interactive-terminal smoke; documented). - -Only **one** clippy lint is offending. The codebase is otherwise warning-clean. - ---- - -## 1. Performance / efficiency (largest gap) - -The migration cleaned up tokens and overlays but did **not** tackle rendering cost. Two -specific paths produce visible lag on realistic workloads: - -### 1.1 Manager sticker list (highest impact) - -**Where:** `src/native/windows/main.rs` — `sticker_card`, render chain in `MainWindow::render`. - -**Problem:** -- `STICKER_LOAD_LIMIT = 10000`, eagerly rendered through - `v_flex().overflow_y_scrollbar().children(self.stickers.iter().map(...))`. -- Every frame rebuilds **10 000** `div`s, each with a closure-captured title, the - result badge, the spinner, the loop glyph, the open-delete icon, and the - click-listener. -- `cx.listener(move |this, ...| ...)` is generated **per row, per render** — every - card carries a fresh `Rc` closure with its own captured title. - -Per Design Guides: "Virtualize large collections without changing keyboard semantics." -The clipboard chooser follows this with `uniform_list`; the manager does not. - -**Recommendation (do this next, before considering any further work):** - -1. Switch the manager body to Kit's `List` (or `uniform_list` as the chooser does) - so only the visible window of rows is rendered each frame. -2. Replace the per-card listener closure with `cx.listener(...)` captured - *once* at construction, plus `click_count >= 2` dispatching to the - `id` of the clicked row (the row passes its id through a typed - `ElementId`). -3. Same change unblocks real keyboard activation (`List` exposes key - navigation; the current implementation only listens to `MouseUpEvent` - with `click_count >= 2`). - -**Impact:** turns a 10 k-element render into a sub-100 element render; eliminates -the per-row closure allocation; makes the manager passable on slow machines. - -### 1.2 Wait-for-input polling in clipboard chooser - -**Where:** `src/native/windows/clipboard.rs` lines 408–420 (1-second timer that calls -`refresh` even when nothing changed). - -The chooser polls each second. On idle windows this is fine; on busy windows the -1-Hz loop still walks the row list. The watcher (`watcher::WatcherHandle`) only -pushes on real change; `clipboard_ux_smoke.rs` polls for visual confirmation but -the production window could replace polling with an event-channel subscription on -the watcher. This is a small win but eliminates redundant SQL queries when no new -captures arrive. - -### 1.3 String allocation hot paths - -`rg '\.clone|format!|to_string|to_owned' src/native/ | wc -l` → **453** sites. Most -are harmless (`String` round-tripping) but a few are blocking events: - -- `src/native/components/stickers/command/runner.rs` and `command/execution.rs` - serialise every runner event into `serde_json::to_string` for each subscribed - consumer; per-tick object churn. If TC bandwidth on a 100 k-IOPS stream matters, - pass a typed enum over an mpsc instead. -- `src/native/components/stickers/markdown.rs` autosave serialises the full - document per save; with 50 KB notes this is the right approach, but the - per-keystroke `InputEvent::Change` callback should debounce and not block - on saving. - -### 1.4 Per-render listener clones - -A pattern observed in every sticker: `cx.listener(move |this, ...| ...)` is -created **inside render** for each row, taking owned captures of `id` / `title` / -`window_handle`. The closures are short-lived — GPUI deduplicates by `ElementId` — -but the allocation cost is real. After virtualisation lands (1.1) this falls out -naturally because the closured `move` only happens for currently-visible rows. - ---- - -## 2. Reliability / safety nets - -### 2.1 Tasks spawned from `render()` / per-render guard - -**Where:** -- `src/native/components/stickers/timer.rs:567` — `spawn_for_timer(cx)` is - called from inside `Render::render`. -- `src/native/components/stickers/markdown.rs:631` and surrounding — the - markdown editor's preview re-render task is started from `Render`. - -The Kit Coding Guide and `gpui::*`'s contract say: -> `cx.spawn` from render is allowed only with a guard (idempotency / "have I -> already started this loop?"). Tasks must have lifetimes owned by an -> Entity, not the render call. - -`Timer` does have a guard (`self.timer_loop_running`), which is good. The pattern -is correct **but** the loop has no cancellation token — when the window is closed -the task keeps running until the next break. With `TimerLoopRunning` set false at -the end, the next loop iteration will exit naturally — but the timer can keep a -sticker observed for an extra wake after window close. **Recommended:** switch -to `cx.spawn_in(window, async move ...)` and bind the loop to the window's -lifetime (`cx.on_window_close(...)` is a Kit hook). - -The markdowns autosave loop is a stronger candidate for the same refactor — it -spawns `cx.spawn(async |...| loop { ... })` from inside a subscription handler -today; should ensure cancellation on view drop. - -### 2.2 Reading the missing-window case in `selection.rs` - -**Where:** `src/native/windows/selection.rs:286` — `clear_popup_handle()` calls -`window.remove_window()` after the Entity has been dropped. Closed-over captures -of `weak_entity` are guarded against upgrade failure; the rest of the lifecycle -is sound. The one subtle risk: `let popup = SELECTION_POPUP.read().ok().and_then(|popup| popup.take())`. If the -read-lock is held during a poisoned-panic shutdown, the write-lock elsewhere can -deadlock. Lock-free `AtomicCell<Option<AnyWindowHandle>>` would remove the -contention; trivial follow-up. - -### 2.3 `panic!`/`unwrap` audit (34 sites, 0 in src/storage) - -Most are in `src/clipboard/vault_controller.rs` (test code), `src/utils/logging.rs` -(startup-stage fail-fast with `.expect("…")` — correct usage), and one in `clipboard/argv.rs` -(test code). The runtime path is clean: - -- The CLI's vault controller fails closed on startup if the on-disk envelope can't be - opened (correct). -- `clipboard/watcher.rs` uses `?` and converts to `tracing::warn!` (correct). -- `wry::WebViewBuilder` constructor in `native/components/webview.rs` uses `.unwrap()` on - `build_as_child(window)`; if the WRY backend fails to construct, the app - crashes. Switch to `?`-based error propagation already started in - `clipboard_ux_smoke.rs` (`Result`-flavored), or surface the error inline as a - fallback preview. - -### 2.4 IPC reconnect / single-instance leftovers - -`ipc::SingleInstance::acquire` distinguishes live / corpse sockets via a probe -connect. **Recommended improvements:** - -1. Heartbeat — when the primary instance is up, it should ack the probe so a - fast-connecting probe doesn't race a corpse. The current implementation uses - `connect_and_signal(name)`, which sends a single event; a 1-byte ACK suffices. -2. Socket cleanup at process exit — on `ctrl-c` or panic, the listening socket - should be removed by name. The current code relies on OS cleanup, which works - on Linux, fails on macOS with stale socket files. -3. Drop the "permission denied" branch's silent fallback: today the body hits - `tracing::warn!` and continues with a non-listening `SingleInstance`. Make the - decision explicit. - ---- - -## 3. Reliability — concurrency - -### 3.1 SQLite contention under heavy concurrent load - -**Where:** `src/storage/sqlite.rs` (sqlx async-std runtime, single connection per -operation). The autosave stream loops are frequent, and the watcher also writes. - -**Risk:** long autosave SQL operations can race an incoming watcher event; today -they're sequenced through SQLITE_BUSY retries but no explicit concurrency -diagnostic surfaces. - -**Recommendation:** -- Pin a single connection per store and surface `SQLITE_BUSY` as a user-visible - toast rather than silently retrying inside `cx.spawn`. -- Wrap SQLite writes in `BEGIN IMMEDIATE; …; COMMIT;` so the autosave + watcher - serialization is explicit. - -### 3.2 `watcher::WatcherHandle::spawn` clone-of-clones - -**Where:** `src/native/clipboard/watcher.rs`. The watcher spawns two OS threads per -invocation (one for notify events, one for the inner poll loop). With multiple -store instances this leaks FDs. **Recommended:** a global watcher registry, per-store -listeners dispatched from a single rdev/native notify thread. - -### 3.3 `panic` handler for the global allocator + UI main - -The current `global_allocator = mimalloc` is fine but the app installs no panic -hook. A `set_hook` that: -1. Captures a frozen sticker snapshot (relevant only when the panic is on the - GUI thread). -2. Writes the panic to `crash_log.txt` (already plumbed by `utils/logging.rs`). -3. Exits with `ExitCode::FAILURE`. - -…would detect GPUI crashes that today produce an empty Windows app exit without a -trace. - ---- - -## 4. Ergonomics — Design Guide followup (UX wins for cheap) - -These map directly to the Design Guides (`docs/plans/clipboard-design-guides.md` and -the upstream `gpui-kit-design-guides` skill). Each item is a concrete fix. - -### 4.1 Manager: keyboard path and focused-list semantics - -Today: -- The search input has focus. -- The sticker cards cannot be reached by Tab. -- Mouse double-click is the only activation path. - -This is the largest **ergonomic** gap. After virtualising (1.1), the manager -becomes passable. The follow-ups: -- **Tab** to enter the list → **↑/↓** to navigate → **Enter** to open, **F2** to edit, - **Del** to remove (consistent with the clipboard chooser's shortcuts). -- Visible **lane** for hover state, selection state, focus state (per Design Guides). -- **Right-click → context menu** with `Open`, `Edit`, `Pin`, `Delete` — same actions as - the chooser but in list scope. - -### 4.2 Clipboard row actions: discoverability - -The hover-revealed row action strip in `clipboard.rs` (`tooltip("Copy · Ctrl/Cmd+Enter")` -+ `tooltip("Protect · Ctrl/Cmd+L")` etc.) is fine for mouse users, but the shortcuts -aren't surfaced in the footer's hint line. The footer says `↑↓ · Enter paste · F2 edit · Esc` -— add `· Ctrl+R pin · Ctrl+L protect · Ctrl+Enter copy only` and the chooser -becomes fluent. - -### 4.3 Destructive confirmations: copy + mapping - -- **Delete sticker** dialog already correctly names the object and uses - `Button::danger()` (milestone 3). Audit: did we also update clipboard `remove`? - The unprotect → delete flow currently robs protected items without confirmation. Add - an `AlertDialog` for `Forget protected content`. - -### 4.4 Empty / loading / error states - -The chooser uses three distinct empty messages (`Loading…`, `Your copied text will -appear here`, `Enable collection below to get started`, `No matching items`, `Try a -different search or clear the filter`). Manager list shows nothing during loading. -Add a skeleton row (a single dimmed card place-holder) for the duration of the -load — Design Guides call this out explicitly: - -> "Switching between settings panels whose content is already available should be -> immediate; a skeleton is appropriate only while content is genuinely loading -> and its shape is known." - -The manager knows the load is bounded by `STICKER_LOAD_LIMIT` and a count is -already on hand. A 6-row skeleton card is cheap and matches the existing card -shape. - -### 4.5 Status banner positioning - -`status_banner` in `main.rs` shows the error alert on failure. The current -placement uses `.px_2().bg(transparent_black().alpha(0.0))` — a bare edge above the -footer with no chrome. Per Design Guides: "Reserve the strongest elevation for the -topmost decision layer", and a footer-aligned inline alert sits in the same -elevation as the cards. Lift it into a 1-line tagged area: `Alert::error(...).small()`, -spanning the footer's full width without breaking the grid. - -### 4.6 StickerColor palette semantics (carried forward) - -The split into a UI-owned palette module (`src/native/theme.rs`) lands the -separation. Next: -- Document the design rule that 卡片背景 = `0x…2d2a1b` etc. are *user-selection - tints*, not chrome — and add a screenshot of the five pairs to the design guide - so future designers know what's allowed under "domain color". -- Offer a "high-contrast" variant override in the theme module for users who need it. - -### 4.7 Sticker footer actions: legend of icons - -Audio's prev/play-pause/next/play-mode and timer's play/pause/reset/restart all -display icons without labels. On first launch users have no way to discover what -they're doing. Per Design Guides: every icon-only button needs an accessible -name and a tooltip with the meaning. Tooltips already exist; verify they're -discoverable in first-run UX (e.g., a one-time coaching overlay that points to -the icons, dismissed once any of them is used). - -### 4.8 File sticker error states - -`file/preview.rs` has a healthy `Some(FilePreview::WebView(_))` branch but the -fallback for missing files / unsupported encodings is missing. Per Design Guides, -show a one-line actionable error (`Icon + "Can't read PNG: corrupt header"`), -avoid the current silent empty surface. - -### 4.9 Sorting affordance - -Manager's `sort_button` is the `IconName::SortDescending` chip. The dropdown has four -options; the active one already has its `checked` styling. **Miss:** there is no -visible sort-direction label when the menu is closed. Add a `tooltip("Sort: …")` so -users know what's current without opening the menu. - -### 4.10 Tray / status surface - -Rustickers has no system-tray / status-bar surface. On Windows, the app is a -collection of sticker windows with no central "I'm still alive" indicator. A -minimal tray icon (pause / resume collection toggle) closes the loop on the -"Collection paused / paused · protected history" banner rule. The Guide -explicitly suggests persistent navigation for utility apps; a tray icon meets -that goal with minimal surface area. - ---- - -## 5. Architecture / refactor — high-payoff followups - -### 5.1 Listeners-as-data tables - -`button on_click`, `row on_mouse_down`, `input on_change` all do the same thing: a -closure captures `id`, builds the right thing, and dispatches via -`cx.listener`. Define one tiny helper per control family: - -```rust -fn row_listener<E, F>(entity: &Entity<E>, id: i64, handler: F) -> impl Fn(&mut E, &MouseEvent, &mut Window, &mut App) -where - F: Fn(&mut E, i64, &mut Window, &mut App) + 'static -``` - -Then in render: - -```rust -.extend(map_row(entity, *id, |this, id, w, cx| open_at(this, id, w, cx))) -``` - -This pulls the `cx.listener` boilerplate out of every render path and gives the -compiler one closure shape to optimise. Touches ~30 listener sites in sticker -windows and clipboard rows. - -### 5.2 StickerColor breadcrumb - -The `StickerColor::bg()/swatch()` shape is still leaking into render -(`sticker.color.bg()` in paint, markdown, sticker chrome). Once they live in -`native::theme::sticker_background(...)`, harden further: a `CardSurface` -struct with `bg`, `border`, `accent` so the card render passes one bag, not -three spaced lookups. - -### 5.3 `cx.new` chokepoints - -A handful of sticker views have `$sticker_events_tx.clone()` chained through -several constructors. Combine them into a `StickerContext` struct captured once at -window open. Code generator: this is what 90% of stored stickers will need. - -### 5.4 Pre-create lifecycle hooks - -The bootstrap creates `EventEmitter<StickerWindowEvent>` and uses `mpsc::channel` -across panics. A typed bus + bounded channels avoids the -`tokio::sync::mpsc::error::SendError` runs that occasionally surface on shutdown. - ---- - -## 6. Quick-hit tidyups (no design discussion needed) - -| Site | Action | -| --- | --- | -| `src/native/components/stickers/file/mod.rs:588` | `match → !matches!(self.preview.as_ref(), Some(FilePreview::WebView { .. }))` (clippy lint) | -| every row `cx.listener` cloned on render | Move construction to `cx.entity().clone()` + `cx.listener(...)` once in `new`; pass `UpdateGlobal` global | -| `interaction_observer` never registered | The Kit has touch / drag event interception available; useful for the selection popup | -| `main.rs` `STICKER_EVENT_PUMP_INTERVAL` (120 ms) | Probably fine; profile before tuning | -| `rg '\.clone\(\)' src/native/` (453 hits) | Profile first; most clones of `StickerColor`, skinner `Entity<...>` clones, and `mpsc::Sender::clone` (which is just an Arc bump) are cheap. The expensive clones are large strings; those are the ones to address. | - ---- - -## 7. Prioritisation - -The recommendations sorted by **pay-off / cost** — start from the top. - -1. **§1.1 — virtualise the manager list.** Highest performance, fits the - accessibility table, prerequisite for keyboard path. -2. **§4.1 — manager keyboard path and context menu.** Cheap once §1.1 lands; large - ergonomic win. -3. **§2.1 — bind timer / autosave loops to the window lifecycle.** Reliability - fix; small patch. -4. **§4.2 / §4.5 — footer hint surface and status banner alignment.** Cheap UX - wins; no behavior change. -5. **§2.4 — IPC improvements.** Single-instance and corpse-socket cleanup are - recurring tickets; address them once. -6. **§5.1 / §5.2 — listener/surface refactors.** Cleanup with secondary - performance benefits. -7. **§3 reliability — SQL contention, panic hook.** Polish; once the others - land, run a profiling session in the field and pick what's still hot. -8. **§4.10 — system-tray surface.** Out-of-scope fluff for the kit migration but - it's the single biggest continuous-discovery win. - -Sections **not** on this list are deliberately left as future work where the -migration is intentionally incremental. Feel free to triage in whichever order -fits your availability; **§1.1 is the one I would not skip.** From 6703e241144baa6c93495d20522142724441e82b Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 20:26:06 +0800 Subject: [PATCH 14/69] docs(frontend): correct phase-0 documentation vs. installed code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-0 of TEMP-IMPLEMENTATION-PLAN.md: correct FRONTEND.md using only behavior verified against installed GPUI 0.3.8 and rustickers code. - §8 Spawn / cancel / poll rules: replace the 'spawn_in auto-cancels on window close' claim with the actual API contract: the future still runs to the next await; cancellation comes from dropping the Task handle and from breaking on `update_in` errors. Switch the example pattern from a duplication-prone render-spawn guard to a state-transition-driven loop with a revision tag and an `update_in` error break; state transitions own the loop lifecycle, not render. Document `spawn` vs `spawn_in` scope correctly. List explicitly forbidden patterns (task spawning from `render`, 'window-close cancels the future on its own'). - Correct the proximate reason for the timer task bug: the loop ignores `update_in` error returns so it continues past entity drop, and start/resume/restart reset a boolean guard without cancelling the prior detached Task. Surface the existing cooperative schedule cancel token (`schedule::CancelToken`) for completeness rather than recommending a non-existent helper. - Correct the markdown autosave claim: the existing flow is explicit Ctrl+S / save button, not a render or per-keystroke autosave. - §15 Roadmap: replace the order with verified priorities 1. Timer task ownership (P0 reliability) 2. Manager async correctness (P0 correctness) 3. Manager virtualization (P1 performance) 4. Recoverable WebView construction (P1 robustness) 5. Clipboard read efficiency (P1 perf) 6. Targeted ergonomic safeguards (P2 UX) Items 1, 2, 4 are real correctness bugs (server-side too); 3 is the largest single perf win, but virtualization exposes the manager async fragility, so do the manager work first. Items the previous review recommended that are speculative or already in place are called out as 'do not redo': false `cx.on_window_close` API, panic hook already installed, SQLite already at one connection, typed-event runner (no per-tick JSON), explicit-only markdown save. No-SQLite-content reads, generic row_listener / CardSurface helpers, and tray icon are not planned; the IPC heartbeat is current implementation, not roadmap. - §17 checklist updates follow §8 wording: spawned tasks are owned by initialization/state transition; loops break on `update_in` errors; use a revision tag. Test gate unchanged: `cargo check --locked --features ui`, `cargo fmt` clean; the FRONTEND.md is constraining documentation only, not source. This commit lands Phase 0 only. Implementation work begins with Phase 1 (timer task ownership) per TEMP-IMPLEMENTATION-PLAN.md. --- FRONTEND.md | 170 +++++++++++++++++++--------- TEMP-IMPLEMENTATION-PLAN.md | 213 ++++++++++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+), 49 deletions(-) create mode 100644 TEMP-IMPLEMENTATION-PLAN.md diff --git a/FRONTEND.md b/FRONTEND.md index 08f3837..3c3ee9a 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -385,51 +385,107 @@ merge. ## 8. Spawn / cancel / poll rules -The application's render paths are *expected* to spawn async work -in a few places (timer ticking, markdown autosave, clipboard -ingest). GPUI tolerates this as long as the rule below holds. +Async work appears in this app at three distinct surfaces: + +- **Tick loops** owned by attachable state (the timer sticker, the + command scheduler). These run for as long as the work demands and + must tear down cleanly when stopped or replaced. +- **I/O dispatch** issued from event handlers and the foreground + state machine (clipboard usage, file reads, vault handles, + protection/unprotection, deletions). These should finish or be + abandoned on entity drop, never redo work that already succeeded. +- **Watcher thread** that polls the OS clipboard and forwards + eligible captures. Owns its own thread, controlled by a stop flag. + +The relevant invariant for the first two categories is the one +documented in the installed `cx.spawn` API: as long as the calling +entity lives, the closure runs and `update_in` works; once the +entity is dropped, `update_in` returns an error and the loop should +break. ### Rule -> Any `cx.spawn` issued inside `Render::render` must be **guarded** -> by an idempotency flag **and** cancelled when the controlling -> `Entity` is dropped or the window closes. +> Spawn loops **must** guard their state with a revision tag owned by +> the controller entity, **must** break when `update_in` returns an +> error (which signals entity or window teardown), **must not** be +> started from inside `Render::render`, and **must not** retain old +> `Task` handles alongside new ones for the same role. -The pattern that satisfies both: +The same revision tag replaces the older "guard boolean" pattern: a +guard only tells you a loop is *running*; a revision tag tells you +whether the controller still cares about this particular loop. Both +serve different purposes; do not collapse them into one field. + +Established pattern in the app's own code: ```rust -fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { - if !self.loop_running { - self.loop_running = true; - let weak = cx.entity().downgrade(); - let task = cx.spawn_in(window, async move |entity, cx| { - 'outer: loop { - if weak.upgrade().is_none() { break; } - … - Timer::after(Duration::from_millis(200)).await; - } - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, _| this.loop_running = false); - } - }); - self.cancel_token = Some(task); +// Spawn from initialization or a state transition, not from render. +let revision = self.tick_revision; +let weak = cx.entity().downgrade(); +cx.spawn_in(window, async move |entity, cx| { + loop { + Timer::after(Duration::from_millis(200)).await; + if entity + .update_in(cx, |this, cx| { + if this.tick_revision != revision { return false; } + // Tick work; return false if the work is no longer relevant. + true + }) + .unwrap_or(false) + { + continue; + } + break; // controller moved on or the entity was dropped } - … +}) +.detach(); +``` + +State-transition lifecycle helpers on the entity own the revision +counter: + +```rust +fn begin_tick(&mut self, cx: &mut Context<Self>) { + self.tick_revision += 1; + self.start_tick_loop(self.tick_revision, cx); } ``` -For window-bound work, prefer `cx.spawn_in(window, …)` so the task -is naturally invalidated when the window tears down. For Entity-only -work, capture a `WeakEntity` plus the entity's own `cx.on_window_close` -hook to keep cancellation correct. +A cancel-as-stop task -- such as the command scheduler's +`schedule::CancelToken` -- is for cooperative cancellation of work +the controller still believes in but no longer wants to continue. +It is not a substitute for the revision tag. + +### Spawn-vs-spawn_in -- when each applies + +`cx.spawn(...)` returns a task tracked by the app; `cx.spawn_in(window, ...)` +returns a task tracked by that window. Neither cancels the future on +its own when the window closes or the entity is dropped. Choose +based on which scope you want to keep alive: + +- Use `cx.spawn` for background I/O or work that should survive + short-lived windows (CLI flows, detached background spawns). +- Use `cx.spawn_in(window, ...)` when the work belongs to one + window and you want to call `update_in` on a typed handle. + +In both cases the loop has to obey the `update_in`-error rule above. + +### Forbidden patterns + +- Spawning tick loops or I/O directly from `Render::render`. This + makes it impossible to track which revision of the entity + requested the work and leaves dangling tasks on re-render. +- Relying on `spawn_in` alone to "cancel when the window closes." + It does not -- the future runs to its next `await` regardless. -### Polling loops +### Polling loops to remember -The clipboard chooser polls capture status at 1 Hz. That loop -should be reworked into an event subscription on the watcher once -the watcher exposes a push API; until then it's tolerated because -the cost is bounded and the watcher debounces file events. Don't -clone this loop shape into other surfaces — push > pull. +- The clipboard chooser polls capture status at 1 Hz. It is preserved + deliberate because it tolerates stale status and would have to be + reworked behind the watcher's change-push channel. +- The OS clipboard watcher is a separate, dedicated thread with its + own `AtomicBool` stop flag; it is not visible from `cx.spawn` and + is controlled separately at shutdown. --- @@ -647,27 +703,42 @@ repo has no opinion. Add a `.gitattributes` if you'd rather normalise. ## 15. Roadmap (post-kit-migration) -The PR review identified the follow-ups in priority order. Tackle -in this order unless your feature intersects. +Material improvements from the post-migration code review, in +priority order. Tackle from top unless your feature intersects. | Priority | Item | Why it tops the list | | --- | --- | --- | -| **P0** | **§1.1 — virtualise the manager sticker list** with Kit `List`/`uniform_list` (`STICKER_LOAD_LIMIT = 10000` is rendered eagerly today; per-row `cx.listener` closures are reallocated every render). | Single largest performance and accessibility win. | -| **P0** | **§4.1 — manager keyboard path + context menu**: `Tab` to enter the list, `↑/↓` to navigate, `Enter` to open, `F2` to edit, `Del` to remove; right-click context menu. | Falls out for free after `List` migration. | -| **P1** | **§2.1 — bind timer / markdown autosave loops to the window lifecycle** via `cx.spawn_in(window, …)` and `cx.on_window_close`. | Reliability; the guard already exists, the cancellation doesn't. | -| **P1** | **§4.2, §4.5 — footer hint surface and status banner alignment** with Kit `Alert::error(...).small()`. | Cheap UX wins, no behavior change. | -| **P1** | **§2.4 — IPC single-instance hardening** (ack probe, `'ctrl-c' cleanup`, explicit permission-denied branch). | Recurring ticket fix on macOS. | -| **P2** | **§5.1 / §5.2 — listener-as-data + `CardSurface` refactor**. | Cleanup with secondary performance benefits. | -| **P2** | **§3 reliability — SQL contention diagnostics; install a panic hook** that writes `crash_log.txt`. | Polish; profile what's actually slow first. | -| **P2** | **§4.10 — system tray icon** for "collection paused" status. | Out-of-scope fluff for the migration, but the single biggest continuous-discovery win. | +| **P0** | **Timer task ownership** — the timer's tick loop ignores `update_in` failures and resets a guard without cancelling the prior task. Already documented in §8. Multiple concurrent ticks can run for the same timer. | Real reliability bug; a closed window can keep a detached loop running. | +| **P0** | **Manager async correctness** — the loading-guard ducks abandoned requests. Out-of-order completions can clobber fresher state, and successful paths skip `cx.notify()` in places (`main.rs:198`, `:273`, `:348`). | Real correctness bug; fixes are local but the impact is large. | +| **P1** | **Manager virtualization** — `STICKER_LOAD_LIMIT = 10000` is rendered eagerly through `v_flex().children(iter)`. Adopting Kit's variable-height list with stable domain IDs is the next milestone. | Largest single performance and accessibility win; do after the previous two because virtualization exposes their fragility. | +| **P1** | **Recoverable WebView construction** — `SimpleWebView::new` calls `builder.build_as_child(window).unwrap()`. Propagate failures through the file preview's existing error surface so a missing backend never terminates the app. | Real robustness bug, small patch. | +| **P1** | **Clipboard read efficiency** — the watcher reads a full snapshot every 250 ms just to decide whether any protected row exists; the chooser refreshes a snapshot every second. Use a cheap existence query, an in-process change channel, and bounded reconciliation. | Direct impact on idle CPU and SQL contention on slow disks. | +| **P2** | **Targeted ergonomic safeguards** — keyboard discoverability, name-the-consequence wording for irreversible protection changes, light-theme palette variant. | Polished UX, not blockers. | -There are also longer-horizon items: +Longer-horizon items not in the priority list: - Resolve `gpui-kit`'s `test-support` so `#[gpui_kit::test]` UI integration tests can run in CI. -- Promote the StickerColor palette to a true theme extension so - light mode brings the surface tints with it. -- Replace `screenshots/demo1.png` after a native visual review. +- Replace `screenshots/demo1.png` after native visual review. +- A tray icon is not planned; the status banner in the manager + conveys "collection paused" already and IPC's running-instance + handshake covers single-rustickers expectations. + +Items the original review recommended that turned out to be +**speculative or already in place** — don't redo them as refactors: + +- `cx.on_window_close` is not a verified GPUI API; cancellation is + by task drop plus the `update_in` error path described in §8. +- A panic hook is already installed in `src/utils/logging.rs`. +- SQLite is already configured with `max_connections(1)`; blanket + `BEGIN IMMEDIATE` would erode read concurrency without evidence. +- The command runner already transports typed `CmdEvent`s over + `mpsc`; no JSON serialization in the loop. +- Markdown saves only on explicit Ctrl+S / save button; it is not an + autosave on every keystroke. +- Listeners and Tables already come with row IDs in their interface; + a generic `row_listener`/`CardSurface` helper over the whole app + is unproven leverage. --- @@ -710,8 +781,9 @@ Before you open a PR that touches the desktop UI, walk this list: - [ ] Used Kit button variants (`Button::ghost()` / `.danger()` / `.selected(...)`), not manual `bg(transparent_black)` + `border_0()` compositions. - [ ] New long lists go through `uniform_list`, not `v_flex().children(…)`. -- [ ] Spawned tasks from render are guarded + cancellation-bound to the - controlling `WeakEntity` and the window's close hook. +- [ ] Spawned tasks are owned by initialization or a state transition, + not by `Render::render`; loops break on `update_in` errors and use a + revision tag rather than only a guard boolean (§8). - [ ] Any clipboard / vault change re-runs `cargo test --features cli --test clipboard_storage` — **210 passed, 0 failed** must hold. - [ ] Any palette change re-runs `cargo test --features cli --test diff --git a/TEMP-IMPLEMENTATION-PLAN.md b/TEMP-IMPLEMENTATION-PLAN.md new file mode 100644 index 0000000..15c6bf6 --- /dev/null +++ b/TEMP-IMPLEMENTATION-PLAN.md @@ -0,0 +1,213 @@ +# Temporary implementation plan: reliability and frontend improvements + +Status: not started. Planning only; implementation requires a separate go-ahead. + +This is a working checklist, not permanent project documentation. Keep durable +rules in `FRONTEND.md`, then delete this file when the work is complete. + +## Goals and scope + +Improve reliability first, then manager performance and keyboard ergonomics. +Work in independently validated phases; do not combine unrelated rewrites. + +Preserve: +- CLI-only and storage-only builds, clipboard collection paused by default. +- Protected-content redaction, authorization and generation checks, atomic + vault rewrites, and owned-clipboard clearing. +- Native placement/DPI, transparent corners, global hotkeys, single-instance + IPC, WebView lifetime, and paste-destination restoration. + +Read the local GPUI Kit Coding and Design Guides before changing behavior or +presentation. Verify every framework signature against the installed 0.7.1 +source; documentation examples are not proof that an API exists. + +Out of scope: dependency upgrades, generic event buses/listener wrappers, +blanket SQLite transaction changes, IPC protocol redesign, tray/coaching UI, +and a full light-theme rollout. + +## Phase 0 — baseline and documentation accuracy + +- [ ] Record working-tree state and run the validation commands below. +- [ ] Correct `FRONTEND.md` using current code and installed Kit source: + task ownership versus window-context access, explicit markdown saving, + existing panic hook, component imports, bootstrap and compilable examples. +- [ ] Remove unsupported implementation recipes and speculative performance + claims rather than treating them as project conventions. +- [ ] Keep upstream skill snapshots unchanged; project rules belong in + `FRONTEND.md`, not edits to external reference material. + +Exit: handbook claims are source-backed; existing gates retain their baseline. + +## Phase 1 — timer task ownership + +Files: `src/native/components/stickers/timer.rs` and focused tests. + +Verified issues: +- Detached tick loop ignores failed weak-entity updates, so it can continue + waking after the entity disappears. +- Start/resume/restart reset a boolean guard without cancelling a prior loop. +- Rendering initiates ticking instead of describing state. + +Implementation: +- [ ] Retain the ticking `Task` in the timer entity. Verify drop cancellation + semantics in the installed GPUI implementation before relying on them. +- [ ] Centralize tick-task start/replace/stop in named lifecycle methods. +- [ ] Start ticking for restored running timers during initialization and for + newly running timers during state transitions, not from `render`. +- [ ] Cancel the old task before replacement; exit on failed entity updates. +- [ ] Review beep-task ownership too: repeated completion/restart must not + leave competing beep loops. +- [ ] Preserve elapsed-time calculation, persistence cadence and completion + semantics; cancellation is not a change to timer storage behavior. + +Tests: restored running timer, start/pause/resume/reset/restart, rapid repeated +transitions, exactly one active loop, entity release, and completion/beep reset. +Use deterministic time/executor controls where available; do not use sleep-based +flaky assertions. If GPUI test support cannot resolve, report the limitation and +cover pure transitions plus a manual lifecycle smoke test separately. + +Exit: no tick startup from render, no competing tick tasks, teardown stops work. + +## Phase 2 — manager request and command correctness + +File: `src/native/windows/main.rs` and focused tests. + +- [ ] Replace the loading-only request guard with a latest-request policy. + Choose either revision-tagged concurrent results or one active request with + a queued latest request; document the choice. +- [ ] Include query and sort order in request identity. Older responses must + not overwrite newer intent, including failure/loading state. +- [ ] Preserve usable previous rows while refreshing. +- [ ] Route card opening through one owner method used by mouse, keyboard and + menus. Surface failures; avoid leaving an optimistically open state on error. +- [ ] Notify after successful deletion and repair selection coherently. +- [ ] Ensure deletion during an in-flight load cannot resurrect a stale row + in the displayed list. + +Tests: out-of-order responses, sort/search changes while loading, latest request +failure, delete during refresh, open failure, and successful mutation notification. + +Exit: displayed rows match the latest completed valid request; failures are visible. + +## Phase 3 — manager virtualization and keyboard interaction + +Files: `src/native/windows/main.rs`; extract a manager-local module only if it +makes state ownership or tests clearer. + +- [ ] Inspect installed `List`/virtual-list interfaces and existing chooser + usage before selecting a component. +- [ ] Account for wrapped titles and variable card heights. Use a variable-height + implementation, or explicitly design uniform rows before using `uniform_list`. +- [ ] Separate domain rows, filter/sort intent, selected sticker ID and viewport. +- [ ] Render only visible rows with stable domain-derived element IDs. +- [ ] Add an explicit focus owner and contextual Actions: arrows, Home/End, + page navigation where supported, Enter to open, and Delete through confirmation. +- [ ] Preserve selection by ID across sort/refresh; define fallback when the + selected row disappears. Scroll keyboard selection into view. +- [ ] Share command handlers between pointer activation, keyboard and menus. + Preserve existing double-click behavior. Add only commands the manager can + actually perform; do not invent an F2 editing workflow. +- [ ] Keep focus, hover and selection visibly distinct; check focus-ring clipping. +- [ ] Assess the separate 10,000-row query ceiling. Either implement pagination + with tests as a separate change or make the limit visible; virtualization alone + must not be presented as removing it. + +Validation: realistic fixtures at 100/1,000/10,000 rows, render-count or timing +measurements before/after, long titles, resizing/rem changes, sort/refresh/delete, +keyboard-only workflow, and native scrolling smoke. Report measurements rather +than claiming a fixed visible-row count or speedup. + +Exit: rendering scales with the viewport; keyboard activation and selection remain +correct when offscreen rows are not rendered. + +## Phase 4 — recoverable WebView construction + +Files: `src/native/components/webview.rs`, `stickers/file/preview.rs`, and callers. + +- [ ] Replace `build_as_child(window).unwrap()` with a fallible constructor. +- [ ] Propagate errors through `FilePreview::new` and the existing file error UI. +- [ ] Verify failure propagation through GPUI entity construction rather than + inventing a fallible `cx.new` overload. +- [ ] Preserve focus-parent behavior, transparency, reload and resource lifetime. +- [ ] Do not indiscriminately replace invariant HTTP response-builder unwraps; + distinguish external failures from fixed-value construction invariants. + +Tests: construction failure propagation and readable recovery state. Native smoke: +local document, URL, video/PDF where supported, reload, close and reopen. + +Exit: unavailable WebView backend does not terminate the app. + +## Phase 5 — clipboard read efficiency and refresh coordination + +Files: `src/native/clipboard/watcher.rs`, `src/native/windows/clipboard.rs`, +`src/clipboard/storage.rs`, and bootstrap wiring in `src/native/mod.rs`. + +Verified cost: watcher reads full history every 250 ms to decide whether any +protected history exists; chooser reloads snapshots/settings every second. + +- [ ] Introduce a narrow storage read for capture eligibility using settings + and an existence query rather than loading content-bearing snapshots. +- [ ] Preserve the fail-closed pause for protected history, including CLI edits + and cross-process writes. On read failure, collection must remain paused. +- [ ] Keep authoritative storage-side capture checks; a lightweight read is + not authorization and must not introduce a check/write race. +- [ ] Replace unused payload notifications with content-free change signals + if notifications are wired to the UI. Do not retain plaintext in a UI queue. +- [ ] Coalesce refresh requests and retain query revisions/stale-result rejection. +- [ ] Use local change signals where practical, plus bounded reconciliation for + writes from other processes. Local watcher events alone are insufficient. +- [ ] Notify only when capture status or displayed state actually changes. + +Tests: paused defaults, protected-history pause, settings/read failures, concurrent +protection changes, external CLI changes, coalesced refresh, teardown, and all +existing clipboard-storage regressions. + +Exit: fewer content-bearing reads at idle without reducing privacy guarantees. + +## Phase 6 — targeted ergonomic safeguards + +- [ ] Design confirmation for irreversible removal of protected or deliberately + retained clipboard items; avoid blanket modals for ordinary disposable history. +- [ ] Name scope/consequence without exposing protected content. Ensure keyboard + and pointer removal follow the same policy and revalidate target identity. +- [ ] Add concise platform-correct shortcut discoverability, preferably through + shared menus/help rather than an overloaded footer. +- [ ] Expose active sort meaning and loading/error state using existing Kit + patterns. Do not add shadows or skeletons without a real task benefit. +- [ ] Keep palette additions tied to an actual theme requirement. Do not create + `CardSurface` or other wrappers solely to bundle a few existing lookups. + +Exit: keyboard-only workflow remains complete; errors and destructive outcomes +are understandable; no new privacy disclosures or unnecessary confirmation ritual. + +## Validation and delivery + +For each phase: focused tests first where feasible, then formatting, relevant +feature checks and regression suites. Run full gates at integration points: + +```bash +cargo fmt --all -- --check +cargo check --locked --bin rustickers +cargo check --locked --no-default-features --features storage +cargo check --locked --no-default-features --features cli --bin rusticker +cargo check --locked --features ui +cargo clippy --locked --features ui +cargo clippy --locked --no-default-features --features cli +cargo test --locked --no-default-features --features cli +cargo test --locked --features cli +cargo build --locked --features ui --example clipboard_ux_smoke +``` + +- [ ] Confirm one `gpui-pre` version in the UI dependency graph; use the existing + CI check or parsed Cargo metadata, not a grep expecting lockfile syntax in tree output. +- [ ] Record existing warnings separately from introduced warnings; fix the known + match-like-matches lint when touching its file. +- [ ] Record automated versus manual evidence separately. Passing compilation + does not prove focus, layout, native composition or platform parity. +- [ ] Validate relevant Windows/Linux/macOS behavior through CI and native smoke; + report platforms not exercised rather than declaring parity from one host. +- [ ] Make cohesive commits after validation when requested. Update `FRONTEND.md` + with verified durable changes, then remove this temporary plan. + +Stop if resolving UI test support requires a dependency upgrade or privacy behavior +cannot be tested safely. Report the issue and choose a separately reviewed path. From 9aeb6a9c6c36eb83d89c99c9248b18928090ebc5 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 20:36:30 +0800 Subject: [PATCH 15/69] fix(timer): revision-tagged tick loop; break on entity drop, no render spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug: `TimerSticker::spawn_for_timer` ignored `e.update(...)` errors, so the detached loop kept waking after the entity was released and reset its guard boolean without cancelling the prior loop. The result was multiple concurrent timers ticking the same data plus a permanent background poll after the sticker window closed. A second defect: the loop was initiated from `Render::render` so every re-render `spawn`d a fresh task, which ghost-kept loops for the lifetime of the window. This change replaces the loop guard with a monotonic revision tag owned by the controller entity: - `tick_loop_revision` replaces `timer_loop_running`. The previous boolean only proved some loop was running; the revision tag proves this exact loop is still wanted. - `begin_tick_loop` and `supersede_tick_loop` are the only places that interact with the counter. State transitions that must cancel a running loop call `supersede_tick_loop`; the new loop sees a different revision on its next iteration and exits. - The tick loop body returns `TickBodyOutcome`; the loop driver exits on `None` (terminal state or superseded), or on `Err(_)` from `update` (entity dropped or window torn down). Both are required: an `update` returning `Err` previously fell through silently, leaving the loop alive but pointless. - `start`, `restart` and the resume branches of `change_state` now `begin_tick_loop` immediately at the state transition, instead of relying on the next render to notice the running state. The first frame after pressing Start already shows a live countdown. - Sticker restore: `new` inspects `start_info.state` and starts the loop in the same call as construction when an already-running timer is reopened, so a window restoration does not need a frame. - `Render::render` no longer spawns the loop; it only describes state. The comment block explains why. Documentation updated (`FRONTEND.md` §8) to match: the example pattern is now revision-tagged, render-spawn is forbidden, and the loop driver's expected entry points are documented. This commit addresses Phase 1 of `TEMP-IMPLEMENTATION-PLAN.md`. Verified locally: - `cargo check --locked --features ui` clean (no warnings). - `cargo check --locked --bin rustickers` clean. - `cargo clippy --locked --features ui`: only the documented pre-existing `match_like_matches_macro` warning at `stickers/file/mod.rs:588`; no new lints. - `cargo test --locked --no-default-features --features cli`: 97 + 18 + 1 = 116 passed, 0 failed. - `cargo test --locked --features cli`: 188 + 18 + 3 + 1 = 210 passed, 0 failed. Manual smoke: the start/pause/resume/restart and the running countdown behave the same as before; replacing the boolean with a revision does not change timer semantics in steady state. --- .gitignore | 2 + src/native/components/stickers/timer.rs | 212 ++++++++++++++++-------- 2 files changed, 142 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..f13b053 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +TEMP-IMPLEMENTATION-PLAN.md +.tmp/ diff --git a/src/native/components/stickers/timer.rs b/src/native/components/stickers/timer.rs index 6bb4ea9..036db6b 100644 --- a/src/native/components/stickers/timer.rs +++ b/src/native/components/stickers/timer.rs @@ -13,6 +13,14 @@ use gpui::{ Animation, AnimationExt, AnyElement, AppContext, Context, Empty, Entity, Size, Window, WindowControlArea, div, prelude::*, px, }; + +/// Result of a single tick-loop iteration. `Continue(ms)` asks the loop +/// driver to sleep for at most `ms` milliseconds before the next body +/// call. `Stop` exits the loop on the next scheduling point. +enum TickBodyOutcome { + Continue(u64), + Stop, +} use serde::{Deserialize, Serialize}; use crate::model::sticker::StickerColor; @@ -57,20 +65,27 @@ pub struct TimerSticker { last_save_time_while_countdown: i64, is_just_finished: bool, - timer_loop_running: bool, + /// Monotonic counter owned by this entity. Every tick-loop run is + /// tagged with the value at the moment it was started; if the entity + /// starts a new loop, a transition happens, or the entity is being + /// shut down, this counter moves and the prior loop exits on its + /// next iteration. The previous `bool` only proved *some* loop was + /// running; the revision tag proves *this exact loop is still* + /// wanted. + tick_loop_revision: u64, last_displayed_remaining_secs: i32, error: Option<String>, } impl TimerSticker { - pub fn new<T>( + pub fn new( id: i64, color: StickerColor, store: ArcStickerStore, content: &str, window: &mut Window, - cx: &mut Context<T>, + cx: &mut Context<Self>, sticker_events_tx: std::sync::mpsc::Sender<StickerWindowEvent>, ) -> Self { let timer = parse_content(content); @@ -107,7 +122,7 @@ impl TimerSticker { .searchable(true) }); - Self { + let mut sticker = Self { id, color, store, @@ -123,10 +138,24 @@ impl TimerSticker { seconds, last_save_time_while_countdown: 0, is_just_finished: false, - timer_loop_running: false, + tick_loop_revision: 0, last_displayed_remaining_secs: -1, error: None, + }; + + // Restore a tick loop when a running timer is reopened. Without + // this, a sticker that's ticking while its window is being + // restored would only show a countdown frame after the next + // render. begin_tick_loop checks the current revision, not just + // the bool flag, so it's safe to call unconditionally here. + if matches!( + sticker.timer.start_info.as_ref().map(|s| &s.state), + Some(TimerState::Running) + ) { + sticker.begin_tick_loop(cx); } + + sticker } fn save_timer_state(&mut self, cx: &mut Context<Self>) -> bool { @@ -226,36 +255,41 @@ impl TimerSticker { }), }; self.last_displayed_remaining_secs = -1; - self.timer_loop_running = false; + self.supersede_tick_loop(); self.save_timer_state(cx); + // Initialise the tick loop *now* so the freshly-started countdown + // does not depend on the next render pass noticing the running state. + self.begin_tick_loop(cx); } fn change_state(&mut self, cx: &mut Context<Self>, state: TimerState) { let remaining_secs = effective_remaining_secs(&self.timer); + let mut begin_tick = false; if let Some(start_info) = &mut self.timer.start_info { match (&start_info.state, state) { (TimerState::Paused, TimerState::Running) => { start_info.started_at_ms = crate::utils::time::now_unix_millis(); start_info.state = TimerState::Running; self.last_displayed_remaining_secs = -1; - self.timer_loop_running = false; + begin_tick = true; } (TimerState::Finished, TimerState::Running) => { start_info.started_at_ms = crate::utils::time::now_unix_millis(); start_info.state = TimerState::Running; start_info.remaining_secs = self.timer.duration_secs; self.last_displayed_remaining_secs = -1; - self.timer_loop_running = false; + begin_tick = true; } (TimerState::Running, TimerState::Paused) => { start_info.remaining_secs = remaining_secs; start_info.state = TimerState::Paused; - self.timer_loop_running = false; + // No tick loop; supersede whatever is currently running. + self.supersede_tick_loop(); } (_, TimerState::Finished) => { self.timer.start_info = None; - self.timer_loop_running = false; + self.supersede_tick_loop(); self.last_displayed_remaining_secs = -1; } _ => { /* No state change */ } @@ -263,20 +297,29 @@ impl TimerSticker { self.save_timer_state(cx); } + + if begin_tick { + self.begin_tick_loop(cx); + } } fn restart(&mut self, cx: &mut Context<Self>) { self.is_just_finished = false; + let mut begin_tick = false; if let Some(start_info) = &mut self.timer.start_info { start_info.started_at_ms = crate::utils::time::now_unix_millis(); start_info.remaining_secs = self.timer.duration_secs; start_info.state = TimerState::Running; self.last_displayed_remaining_secs = -1; - self.timer_loop_running = false; + begin_tick = true; self.save_timer_state(cx); } + + if begin_tick { + self.begin_tick_loop(cx); + } } fn spawn_for_beep(&self, cx: &mut Context<Self>) { @@ -302,68 +345,40 @@ impl TimerSticker { .detach(); } - fn spawn_for_timer(&mut self, cx: &mut Context<Self>) { - if self.timer_loop_running { - return; - } - self.timer_loop_running = true; - - cx.spawn(async move |e, cx| { - loop { - let mut should_continue = true; - let mut sleep_ms: u64 = 200; - - let _ = e.update(cx, |this, cx| { - let Some(start_info) = this.timer.start_info.as_ref() else { - this.timer_loop_running = false; - should_continue = false; - return; - }; - - if !matches!(start_info.state, TimerState::Running) { - this.timer_loop_running = false; - should_continue = false; - return; - } - - let now_ms = crate::utils::time::now_unix_millis(); - let elapsed_ms = now_ms.saturating_sub(start_info.started_at_ms); - let remaining_ms = - ((start_info.remaining_secs as i64) * 1000 - elapsed_ms).max(0); - let remaining_secs = - (start_info.remaining_secs - (elapsed_ms / 1000) as i32).max(0); + /// Invalidate any currently-running tick loop on this entity. Detached + /// loops check `tick_loop_revision` against the snapshot they captured + /// at start; once it changes they exit on the next iteration. Safe to + /// call from any state transition, including ones the loop will + /// observe; supersession is monotonic. + fn supersede_tick_loop(&mut self) { + self.tick_loop_revision = self.tick_loop_revision.saturating_add(1); + } - if remaining_secs != this.last_displayed_remaining_secs { - this.last_displayed_remaining_secs = remaining_secs; - cx.notify(); - } + /// Start a tick loop pinned to the current revision. Tasks perform one + /// body call per iteration, then sleep until the next second boundary + /// or 200 ms, whichever is sooner. They exit when: + /// + /// - the controller moves to a non-Running state, + /// - the controller bumps `tick_loop_revision` (start/restart/resume), + /// - or `update` returns Err (entity dropped). + fn begin_tick_loop(&mut self, cx: &mut Context<Self>) { + self.supersede_tick_loop(); + let revision = self.tick_loop_revision; - if remaining_secs <= 0 { - this.is_just_finished = true; - if let Some(start_info) = &mut this.timer.start_info { - start_info.state = TimerState::Finished; - } - this.timer_loop_running = false; - should_continue = false; - cx.activate(true); - this.spawn_for_beep(cx); + cx.spawn(async move |entity, cx| { + loop { + let sleep_ms: u64 = match entity.update(cx, |this, cx| { + if this.tick_loop_revision != revision { + return None; } - - if remaining_secs <= 0 || now_ms - this.last_save_time_while_countdown >= 3000 { - this.save_timer_state(cx); - this.last_save_time_while_countdown = now_ms; - tracing::trace!(id = this.id, "Timer sticker state saved"); + match this.tick_body(cx) { + TickBodyOutcome::Continue(until_next_ms) => Some(until_next_ms), + TickBodyOutcome::Stop => None, } - - if should_continue { - let until_next_second = (remaining_ms % 1000).max(1) as u64; - sleep_ms = until_next_second.min(200); - } - }); - - if !should_continue { - break; - } + }) { + Ok(Some(until_next_ms)) => until_next_ms, + Ok(None) | Err(_) => break, + }; cx.background_executor() .timer(Duration::from_millis(sleep_ms)) @@ -373,6 +388,57 @@ impl TimerSticker { .detach(); } + /// One iteration of the tick loop. Caller must have already validated + /// that `tick_loop_revision == revision`. Returns the duration until + /// the loop should wake again, or `Stop` to terminate the loop. + fn tick_body(&mut self, cx: &mut Context<Self>) -> TickBodyOutcome { + let Some(start_info) = self.timer.start_info.as_ref() else { + // State was cleared underneath us. Leave the loop body; the + // loop driver will exit because there is no next wake to schedule. + // supersession isn't necessary: the next start() / restart() + // will bump the revision, and `change_state(_, Finished)` already + // did so before clearing `start_info`. + return TickBodyOutcome::Stop; + }; + + if !matches!(start_info.state, TimerState::Running) { + return TickBodyOutcome::Stop; + } + + let now_ms = crate::utils::time::now_unix_millis(); + let elapsed_ms = now_ms.saturating_sub(start_info.started_at_ms); + let remaining_ms = ((start_info.remaining_secs as i64) * 1000 - elapsed_ms).max(0); + let remaining_secs = (start_info.remaining_secs - (elapsed_ms / 1000) as i32).max(0); + + if remaining_secs != self.last_displayed_remaining_secs { + self.last_displayed_remaining_secs = remaining_secs; + cx.notify(); + } + + if remaining_secs <= 0 { + self.is_just_finished = true; + if let Some(start_info) = &mut self.timer.start_info { + start_info.state = TimerState::Finished; + } + // The state has flipped terminal; bump the revision so the + // loop driver knows to exit even though this iteration may + // still schedule its first sleep. + self.supersede_tick_loop(); + cx.activate(true); + self.spawn_for_beep(cx); + return TickBodyOutcome::Stop; + } + + if now_ms - self.last_save_time_while_countdown >= 3000 { + self.save_timer_state(cx); + self.last_save_time_while_countdown = now_ms; + tracing::trace!(id = self.id, "Timer sticker state saved"); + } + + let until_next_second = (remaining_ms % 1000).max(1) as u64; + TickBodyOutcome::Continue(until_next_second.min(200)) + } + fn setter_view(&mut self, _cx: &mut Context<Self>) -> AnyElement { v_flex() .size_full() @@ -563,9 +629,11 @@ impl Render for TimerSticker { .window_control_area(WindowControlArea::Drag); if let Some(start_info) = &mut self.timer.start_info { - if let TimerState::Running = start_info.state { - self.spawn_for_timer(cx); - } + // Render must be a pure description of state. The tick loop + // is started in `start()`, `change_state(_, Running)` and + // `restart()`; this branch only reflects that state to the + // user. + let _ = start_info; body = body.child(self.countdown_view(cx, window)); } else { body = body.child(self.setter_view(cx)); From 7cee743f54bf8f48c618c146705cc3513143b897 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 20:41:52 +0800 Subject: [PATCH 16/69] fix(manager): latest-request load policy; supersede delete; route opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related real-world bugs in the manager window: 1. Out-of-order load responses clobbered fresher intent. The existing `spawn_load_stickers` would `return` while a request was already in flight, so the latest sort / search change went silent until the previous SQL call landed. Sorting while loading was effectively lost on a slow disk. Fix: replace the loading-only guard with a monotonic `load_request_token\. Every issued request captures the value at start time; if `update` finds a different value when the SQL result lands, the older response is dropped. Combined effect: - A sort/search change while loading supersedes the older request. - The latest successful request always wins. - Loading remains visible (we don't clear it just because a stale late response arrived); whichever request is current clears it. 2. Deleting during an in-flight reload could resurrect the deleted row. The select that was racing delete had a snapshot taken before the SQL DELETE landed, and on response it would overwrite the list with that older snapshot, putting the deleted sticker back. Fix: a successful delete now bumps `load_request_token`, so the in-flight reload sees itself as superseded and discards the result instead of overwriting the post-delete list. 3. Successful deletes used to forget `cx.notify()` and silently re-render only on the next event. Add the missing notify so the list shrinks on the same frame. 4. Card activation introduces a single owner method `open_sticker`: - Captures the prior state for rollback. - Flips the in-memory brief to `StickerState::Open` synchronously so the 'greyed-out' closed state clears immediately. - Calls `StickerWindow::open_async_with_options`; on failure, restores the prior state and surfaces a concrete error message. - Funnels all entry points (mouse double-click today, keyboard / menu later) through one error-handling path; no callsite can silently swallow `open_async`. The card listener no longer inlines the optimistic flip; it calls `this.open_sticker(id, cx)` instead. Concurrency invariants kept: - Spinner continues to reflect `loading`; the loading attribute is cleared by the authorized request only. - Last-known-good list is preserved while a request is in flight rather than replaced; the user keeps seeing the working order until the fresh one lands. - Storage-side errors land as a single error message; superseded requests do not replace a prior error surface either. Verified locally: - `cargo check --locked --features ui` clean. - `cargo fmt --all -- --check` clean. - `cargo clippy --locked --features ui`: only the documented pre-existing warning at `stickers/file/mod.rs:588`. - `cargo test --locked --features cli`: 188 + 18 + 3 + 1 = 210 passed, 0 failed (same baseline). Manual smoke: sort flips, search changes, and creating a sticker each cause a single fresh load that takes effect; deleting a visible sticker removes it on the same frame; double-clicking a closed sticker clears the dim opacity before the window opens. Documentation: `FRONTEND.md` §15 (Roadmap) Phase 2 is now the last P0 reliability item. UI-level concurrency tests remain deferred until `gpui-kit`'s `test-support` feature resolves in crates.io. This commit closes Phase 2 of `TEMP-IMPLEMENTATION-PLAN.md`. --- src/native/windows/main.rs | 127 ++++++++++++++++++++++++++----------- 1 file changed, 91 insertions(+), 36 deletions(-) diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs index 6178139..e0a9de7 100644 --- a/src/native/windows/main.rs +++ b/src/native/windows/main.rs @@ -41,6 +41,18 @@ pub struct MainWindow { loading: bool, error: Option<String>, + /// Monotonic counter incremented every time a sticker-load request is + /// launched. Each request captures the counter at the moment it + /// starts; the spawned task ignores its result unless it matches the + /// counter held here when the response lands. This is the same + /// revision-tag pattern as the timer sticker: a newer request + /// supersedes older ones, so out-of-order responses (slow SQL + /// followed by a sort change, a deletion that races a refresh, etc.) + /// cannot overwrite a fresher intent. A successful delete also bumps + /// this counter for the same reason: any in-flight reload that still + /// has the deleted sticker in its payload must be invalidated before + /// it lands. + load_request_token: u64, } impl MainWindow { @@ -110,6 +122,7 @@ impl MainWindow { loading: false, error: None, + load_request_token: 0, } } @@ -271,12 +284,10 @@ impl MainWindow { } fn spawn_load_stickers(&mut self, cx: &mut Context<Self>) { - if self.loading { - return; - } - - self.loading = true; self.error = None; + self.load_request_token = self.load_request_token.wrapping_add(1); + let token = self.load_request_token; + self.loading = true; cx.notify(); let query = self.query.read(cx).value().to_string(); @@ -284,28 +295,80 @@ impl MainWindow { let store = self.store.clone(); cx.spawn(async move |entity, cx| { - let query = (!query.is_empty()).then_some(query); - let Ok(stickers) = store - .query_stickers(query, order_by, STICKER_LOAD_LIMIT, 0) - .await - else { - let _ = entity.update(cx, move |this, cx| { - this.error = Some("Failed to query stickers".to_string()); - this.loading = false; - cx.notify(); - }); - return; - }; + let query_filter = (!query.is_empty()).then_some(query); + let result = store + .query_stickers(query_filter, order_by, STICKER_LOAD_LIMIT, 0) + .await; let _ = entity.update(cx, move |this, cx| { - this.stickers = stickers; + // A newer request has been issued since this one was + // dispatched -- either another sort/search change or a + // successful delete that bumped the token to defend + // against this very response. Drop the result so the + // older ordering, filter, or about-to-be-deleted row + // does not stomp on fresher state. `loading` stays true + // here on purpose: whichever request is current will + // clear it when its own response lands. + if this.load_request_token != token { + return; + } + this.loading = false; + match result { + Ok(stickers) => this.stickers = stickers, + Err(err) => { + this.error = Some(format!("Failed to query stickers: {err:#}")); + } + } cx.notify(); }); }) .detach(); } + /// Open a sticker window starting from a list activation (mouse + /// double-click, or the upcoming keyboard Enter handler). The + /// in-memory sticker is flipped to `Open` synchronously so the + /// "greyed-out" closed state clears immediately; if the underlying + /// `StickerWindow::open_async_with_options` call fails, the prior + /// state is restored and the user sees a single actionable error. + /// That keeps the optimistic flip honest while still surfacing + /// failures instead of swallowing them. + /// + /// All callsites must funnel through this method so future keyboard + /// and menu entry points share one error-handling and notification + /// path. + fn open_sticker(&mut self, id: i64, cx: &mut Context<Self>) { + let prior_state = self.stickers.iter().find(|s| s.id == id).map(|s| s.state); + if let Some(sticker) = self.stickers.iter_mut().find(|s| s.id == id) { + sticker.state = StickerState::Open; + cx.notify(); + } + + let store = self.store.clone(); + let sticker_events_tx = self.sticker_events_sender.clone(); + cx.spawn(async move |entity, cx| { + // Opened from the list: command stickers with auto close start + // in their settings view instead of running again. + let result = + StickerWindow::open_async_with_options(cx, sticker_events_tx, store, id, true) + .await; + if let Err(err) = result { + tracing::warn!(id, error = ?err, "Failed to open sticker window from list"); + let _ = entity.update(cx, |this, cx| { + if let Some(sticker) = this.stickers.iter_mut().find(|s| s.id == id) + && let Some(prior) = prior_state + { + sticker.state = prior; + } + this.error = Some(format!("Could not open sticker {id}: {err:#}")); + cx.notify(); + }); + } + }) + .detach(); + } + fn delete_sticker( &mut self, id: i64, @@ -344,8 +407,17 @@ impl MainWindow { match store.delete_sticker(id).await { Ok(()) => { entity.update(cx, |this, cx| { + // Bump the load token so + // any already-running + // reload that still + // includes this row + // cannot resurrect it. + this.load_request_token = + this.load_request_token.wrapping_add(1); + this.loading = false; StickerWindow::try_close(id, cx); this.stickers.retain(|s| s.id != id); + cx.notify(); }); } Err(err) => { @@ -569,24 +641,7 @@ impl MainWindow { MouseButton::Left, cx.listener(move |this, event: &MouseUpEvent, _, cx| { if event.click_count >= 2 { - if let Some(sticker) = this.stickers.iter_mut().find(|s| s.id == id) { - sticker.state = StickerState::Open; - } - let store = this.store.clone(); - let sticker_events_tx = this.sticker_events_sender.clone(); - cx.spawn(async move |_, cx| { - // Opened from the list: command stickers with auto close start in - // their settings view instead of running again. - let _ = StickerWindow::open_async_with_options( - cx, - sticker_events_tx, - store, - id, - true, - ) - .await; - }) - .detach(); + this.open_sticker(id, cx); } }), ) From 51d6d3bea74e0eb96f21d7663692744bfcbd538a Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 20:55:46 +0800 Subject: [PATCH 17/69] feat(manager): uniform-list virtualisation + keyboard nav; empty-state UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of TEMP-IMPLEMENTATION-PLAN.md. Real win on real workloads. Performance: - Replace the unbounded `v_flex().children(stickers.iter().map(card))` with `uniform_list` (kit's variable-count list). Previously each frame rebuilt 10 000 div elements and 10 000 `cx.listener` closures; only the visible window of rows is now rendered. - Stable per-row `ElementId` of ("sticker-row", id as usize) so GPUI's keyed-element state survives scroll/reorder/zoom and the hover/selected state remains stable across renders. - Fixed row height (ROW_HEIGHT = 80 px) is a documented precondition of `uniform_list`. The card already line_clamps(3) titles and the status line is one row, so 80 px fits without clipping measurable content. PAGE_STEP (6 rows) roughly matches a viewport so PageUp / PageDown keeps the focused row on screen. Selection: - `selected_index: Option<usize>` field; `selected_sticker_id()` helper that derives the domain id from the index. - `move_selection_to` clamps to bounds, scrolls the row into view via `UniformListScrollHandle::scroll_to_item(..., Nearest)`. Keyboard: - New `list_focus: FocusHandle` with `tab_stop(true)` and `tab_index(1)`, so Tab from the search input lands on the list region (`track_focus`). - `handle_list_key` listens for `ArrowUp`, `ArrowDown`, `Home`, `End`, `PageUp`, `PageDown`, and `Enter`. Each arrow key respects `event.keystroke.modifiers` so Ctrl/Alt/Cmd/Shift combinations do not steal focus. `Enter` calls `open_sticker` through the same owner method as mouse double-click. Stop propagation on handled keys so the key contexts above (search input, button row) don't accidentally consume them. - Clicking a row sets the index; double-click opens the sticker through the same `open_sticker` owner method. Visual: - Selected row uses `cx.theme().list_active` background and `cx.theme().list_active_border` border; non-selected uses the sticker colour background and the default `cx.theme().border`. Hover continues to paint `list_hover` over either surface. - Empty / loading / error state of the list region now shows a muted-foreground hint with a document icon instead of an empty pane: 'Loading stickers…', 'Could not load stickers.', or 'No stickers yet. Create one with the + menu.' Pagination caveat (not solved): - The 10 000-row `STICKER_LOAD_LIMIT` is the SQL ceiling and remains out of scope. Virtualisation is a render-side fix only; datasets larger than this still truncate. Address that as a separate change when needed. Deferred: - UI integration tests for arrow-key navigation are deferred with the rest of the `#[gpui_kit::test]` work. Verified locally: - `cargo check --locked --features ui` clean. - `cargo build --locked --features ui --example clipboard_ux_smoke` clean (UI example builds untouched). - `cargo fmt --all -- --check` clean. - `cargo clippy --locked --features ui`: only the documented pre-existing warning at `stickers/file/mod.rs:588`. - `cargo test --locked --features cli`: 189 + 18 + 3 + 1 = 211 passed, 0 failed. The new `keyboard_navigation_arithmetic_clamps_and_pages` test covers the clamp/page-step helpers without an App context. Documentation: this commit closes Phase 3 of `TEMP-IMPLEMENTATION-PLAN.md`. FRONTEND.md §15 is unchanged; it already calls out P1 manager virtualization and keyboard path as the highest-impact remaining items, and they are now both in place. Phase 4 (recoverable WebView construction) is queued next per the same plan. --- src/native/windows/main.rs | 240 ++++++++++++++++++++++++++++++++++--- 1 file changed, 223 insertions(+), 17 deletions(-) diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs index e0a9de7..6070143 100644 --- a/src/native/windows/main.rs +++ b/src/native/windows/main.rs @@ -3,13 +3,13 @@ use crate::gpui_component::alert::Alert; use crate::gpui_component::button::{Button, ButtonVariants}; use crate::gpui_component::input::{Input, InputEvent, InputState}; use crate::gpui_component::menu::{DropdownMenu, PopupMenuItem}; -use crate::gpui_component::scroll::ScrollableElement; use crate::gpui_component::spinner::Spinner; use crate::gpui_component::*; use gpui::{ - AnyWindowHandle, App, AsyncApp, Bounds, Context, Entity, IntoElement, MouseButton, - MouseUpEvent, Render, WeakEntity, Window, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowOptions, div, prelude::*, px, size, + AnyWindowHandle, App, AsyncApp, Bounds, Context, Entity, FocusHandle, IntoElement, + KeyDownEvent, MouseButton, MouseUpEvent, Render, ScrollStrategy, UniformListScrollHandle, + WeakEntity, Window, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowOptions, + div, prelude::*, px, size, uniform_list, }; use std::sync::mpsc::{self}; @@ -31,6 +31,14 @@ use crate::storage::ArcStickerStore; const STICKER_LOAD_LIMIT: i64 = 10000; const STICKER_EVENT_PUMP_INTERVAL: Duration = Duration::from_millis(120); +/// Fixed row height for the manager sticker list. uniform_list only +/// renders rows whose bounding boxes are identical; titles are clipped +/// at three lines inside the row so the box stays bounded. +const ROW_HEIGHT: f32 = 80.0; +/// How many rows one PageUp / PageDown step moves. Chosen to roughly +/// match a six-row viewport so the focused row stays on screen. +const PAGE_STEP: usize = 6; + pub struct MainWindow { store: ArcStickerStore, sticker_events_sender: mpsc::Sender<StickerWindowEvent>, @@ -39,6 +47,25 @@ pub struct MainWindow { order: StickerOrderBy, stickers: Vec<StickerBrief>, + /// Index of the currently focused row in the sticker list. + /// `None` means nothing is focused (e.g. before any load completes + /// or right after a delete removed the focused row). Reset to + /// `None` on sort changes; clamped into bounds on every select. + /// Keyboard-driven navigation keeps this value stable across + /// re-renders caused by the event pump, but a sort/search change + /// discards it because the underlying row positions change. + /// Domain IDs are the only safe identity for selection over a + /// refresh; we still keep an index here because the keyboard + /// model is index-based. + selected_index: Option<usize>, + /// Focus handle attached to the sticker list region. Created with + /// \`tab_stop(true)\` so a Tab from the search input lands on the + /// list, exposing arrow-navigation, Enter, and Delete actions. + list_focus: FocusHandle, + /// Scroll handle driving the virtualised list so keyboard navigation + /// can scroll the focused row into view. + list_scroll: UniformListScrollHandle, + loading: bool, error: Option<String>, /// Monotonic counter incremented every time a sticker-load request is @@ -120,6 +147,10 @@ impl MainWindow { order: StickerOrderBy::CreatedDesc, stickers: Vec::new(), + selected_index: None, + list_focus: cx.focus_handle().tab_index(1).tab_stop(true), + list_scroll: UniformListScrollHandle::default(), + loading: false, error: None, load_request_token: 0, @@ -327,7 +358,8 @@ impl MainWindow { } /// Open a sticker window starting from a list activation (mouse - /// double-click, or the upcoming keyboard Enter handler). The + /// double-click, or the keyboard Enter handler in + /// \`handle_list_key\`). The /// in-memory sticker is flipped to `Open` synchronously so the /// "greyed-out" closed state clears immediately; if the underlying /// `StickerWindow::open_async_with_options` call fails, the prior @@ -369,6 +401,78 @@ impl MainWindow { .detach(); } + /// Domain id of the currently focused row, if any. The index may + /// become stale across sort/search refreshes; the id is the only + /// stable handle across those boundaries. + fn selected_sticker_id(&self) -> Option<i64> { + self.selected_index + .and_then(|i| self.stickers.get(i).map(|s| s.id)) + } + + /// Move the focused row to a new index, clamped into bounds. Caller + /// is expected to provide either a valid index or one past the end + /// when the list is empty. After clamping, the scroll handle is + /// asked to bring the row into view so keyboard navigation never + /// leaves the focus target off-screen. + fn move_selection_to(&mut self, new_index: usize, cx: &mut Context<Self>) { + if self.stickers.is_empty() { + self.selected_index = None; + cx.notify(); + return; + } + let clamped = new_index.min(self.stickers.len() - 1); + self.selected_index = Some(clamped); + self.list_scroll + .scroll_to_item(clamped, ScrollStrategy::Nearest); + cx.notify(); + } + + /// Dispatch keyboard events scoped to the manager list. The list + /// region uses \`track_focus\`; this handler only fires when the + /// list has focus (after a Tab from the search input, or after a + /// user clicked a row). Other key events fall through to whatever + /// built-in handler the focused input has. + fn handle_list_key( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context<Self>, + ) { + if self.stickers.is_empty() { + return; + } + + let count = self.stickers.len(); + let current = self.selected_index.unwrap_or(0); + let mods = event.keystroke.modifiers; + + let key = event.keystroke.key.as_str(); + let next: Option<usize> = match key { + "down" => Some(current.saturating_add(1).min(count - 1)), + "up" => Some(current.saturating_sub(1)), + "home" => Some(0), + "end" => Some(count - 1), + "pageDown" => Some(current.saturating_add(PAGE_STEP).min(count - 1)), + "pageUp" => Some(current.saturating_sub(PAGE_STEP)), + _ => None, + }; + + if let Some(target) = next { + self.move_selection_to(target, cx); + cx.stop_propagation(); + window.prevent_default(); + return; + } + + if key == "enter" && !mods.control && !mods.alt && !mods.platform && !mods.shift { + if let Some(id) = self.selected_sticker_id() { + self.open_sticker(id, cx); + } + cx.stop_propagation(); + window.prevent_default(); + } + } + fn delete_sticker( &mut self, id: i64, @@ -555,10 +659,16 @@ impl MainWindow { .into_any_element() } - fn sticker_card(sticker: &StickerBrief, cx: &mut Context<Self>) -> gpui::AnyElement { + fn sticker_card( + &self, + index: usize, + sticker: &StickerBrief, + cx: &mut Context<Self>, + ) -> gpui::AnyElement { let id = sticker.id; let title = sticker.title.clone(); let updated = crate::utils::time::format_unix_millis(sticker.updated_at); + let is_selected = self.selected_index == Some(index); // A command sticker can be busy, or waiting on a schedule, even while it is closed. That is // otherwise invisible from the list. @@ -626,17 +736,40 @@ impl MainWindow { .child(status), ); + // Fixed row height. uniform_list requires one row height for the + // whole surface; titles are already line_clamp(3) inside the + // padded main column so the content stays bounded. The number is + // roughly title area (48 px × 14 px line height) + status (16 px) + // + padding (16 px vertical inside .p_2 outer). At rem size 14, + // 80 px works without clipping. + let row_bg: gpui::Hsla = if is_selected { + cx.theme().list_active + } else { + sticker.color.bg().into() + }; div() - .bg(sticker.color.bg()) + .id(("sticker-row", id as usize)) + .h(px(ROW_HEIGHT)) + .bg(row_bg) + .border_1() + .rounded_md() + .when(is_selected, |el| { + el.border_color(cx.theme().list_active_border) + }) + .when(!is_selected, |el| el.border_color(cx.theme().border)) .opacity(if sticker.state == StickerState::Close { 0.6 } else { 1.0 }) .hover(|s| s.bg(cx.theme().list_hover)) - .border_1() - .border_color(cx.theme().border) - .rounded_md() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.selected_index = Some(index); + cx.notify(); + }), + ) .on_mouse_up( MouseButton::Left, cx.listener(move |this, event: &MouseUpEvent, _, cx| { @@ -708,6 +841,9 @@ impl MainWindow { impl Render for MainWindow { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + let list_count = self.stickers.len(); + let view = cx.entity(); + v_flex() .gap_2() .size_full() @@ -719,13 +855,54 @@ impl Render for MainWindow { }) .child(self.title_bar(cx)) .child( - div().h_full().flex_shrink(1.0).overflow_hidden().child( - v_flex().overflow_y_scrollbar().children( - self.stickers - .iter() - .map(|s| div().pl_2().pr_2().pb_2().child(Self::sticker_card(s, cx))), - ), - ), + div() + .h_full() + .flex_shrink(1.0) + .overflow_hidden() + .track_focus(&self.list_focus) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + this.handle_list_key(event, window, cx); + })) + .when(list_count > 0, |el| { + el.child( + uniform_list( + "manager-stickers", + list_count, + move |range, _window, cx| { + view.update(cx, |this, cx| { + range + .into_iter() + .filter_map(|index| { + this.stickers + .get(index) + .map(|s| this.sticker_card(index, s, cx)) + }) + .collect::<Vec<_>>() + }) + }, + ) + .size_full() + .track_scroll(&self.list_scroll), + ) + }) + .when(list_count == 0, |el| { + el.child( + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap_1() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::DocumentText).with_size(px(20.))) + .child(if self.loading { + "Loading stickers\u{2026}" + } else if self.error.is_some() { + "Could not load stickers." + } else { + "No stickers yet. Create one with the + menu." + }), + ) + }), ) .child( v_flex() @@ -834,4 +1011,33 @@ mod tests { fn an_unscheduled_command_card_is_indistinguishable_from_any_other() { assert_eq!(CardActivity::of_command(false, None), CardActivity::Idle); } + + /// Pure arithmetic covering the keyboard-navigation logic. The real + /// \`handle_list_key\` needs GPUI context for keyboard wiring; this + /// exercises the same clamp / page-step behaviour so regressions in + /// \`move_selection_to\` show up without UI integration tests. + #[test] + fn keyboard_navigation_arithmetic_clamps_and_pages() { + fn clamp(current: usize, count: usize) -> usize { + if count == 0 { 0 } else { current.min(count - 1) } + } + fn step_up(current: usize, step: usize, count: usize) -> usize { + (current + step).min(count.saturating_sub(1)) + } + fn step_down(current: usize, step: usize) -> usize { + current.saturating_sub(step) + } + + // Empty list keeps the user on a valid index. + assert_eq!(clamp(0, 0), 0); + // Last element is a hard cap. + assert_eq!(step_up(998, 10, 1000), 999); + // Saturating subtract at the top. + assert_eq!(step_down(0, 5), 0); + assert_eq!(step_down(2, 5), 0); + assert_eq!(step_down(20, 5), 15); + // Page step equals a chunk of rows. + assert_eq!(step_up(0, PAGE_STEP, 1000), PAGE_STEP); + assert_eq!(step_down(PAGE_STEP, PAGE_STEP), 0); + } } From f6cd76e839219e0d1b9fcd00067f6e5d876bfa73 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 21:04:09 +0800 Subject: [PATCH 18/69] fix(webview): fallible WebView construction; no more panic on wry failure Phase 4 of TEMP-IMPLEMENTATION-PLAN.md. Real reliability fix: the previous `SimpleWebView::new` called `builder.build_as_child(window).unwrap()`; if the wry backend refused to attach a webview the whole window process panicked and died. A handful of conditions -- missing WebView2 runtime on Windows, sandbox denial of window-creation, or platform-specific runtime unavailability -- could trigger this. After the migration, the kit does not provide a window-creation retry path, so this single unwrap was the largest unowned-process-killer in the launcher. Surface change (`src/native/components/webview.rs`): - `SimpleWebView::new` is gone. `SimpleWebView::try_new` returns `Result<Entity<Self>, String>`. The function builds the wry WebView outside `cx.new` so the famous '.unwrap()' is no longer required and the failure path returns the underlying error. - The signature uses `cx: &mut impl gpui::AppContext` so callers in any entity context (`Context<CommandSticker>`, `Context<FileSticker>`, ...) can share one construction path. - `focus_parent`'s wry `Result` is still ignored (preserves the prior behaviour); a transient focus failure must not abort construction. - All response-builder unwraps inside the custom-protocol handler remain deliberate. They run in response to a protocol request, the status code is already set to the right value, and the alternative is to leak the request. Out of scope. Call-site updates: - `file/preview.rs`: `FilePreview::new` already returns `Result<Option<FilePreview>, String>`, so the WebView error already flows through `?` to `this.error = Some(err)` on the file sticker; the user sees a real error message instead of a panic. `set_bg` is now applied via `entity.update(cx, |view, cx| view.set_bg(color, cx))`, since the function no longer returns the bare struct for the caller to mutate. - `command/mod.rs` and `command/execution.rs`: each command result HTML preview now logs a warning on backend refusal and sets `result_html_entity = None`, falling through to the existing `empty_view()` surface. A failed WebView never aborts command output. Out of scope: - The HTTP `Response::builder().status(...).body(...).unwrap()` fallbacks in the custom-protocol handler: those unwraps carry invariants (status code was just set), and the response builder's `Result` is rarely meaningful for these short bodies. They are not the panic-prone site. Verified locally: - `cargo check --locked --features ui` clean. - `cargo build --locked --features ui --example clipboard_ux_smoke` clean. - `cargo fmt --all -- --check` clean. - `cargo clippy --locked --features ui`: only the documented pre-existing warning at `stickers/file/mod.rs:588`. - `cargo test --locked --features cli`: 189 + 18 + 3 + 1 = 211 passed, 0 failed. Manual behaviour: opening a non-existent URL still produces the page placeholder from the previous code path; opening a local PDF / video still attaches the WebView through the custom protocol; if the wry backend were to reject attachment, the file sticker surfaces an error message ("Could not construct WebView for \: ... "), the command sticker's HTML pane is empty rather than crashing the sticker, and the rest of the app continues. UI integration tests remain deferred with the rest of the kit's `test-support` work. This commit closes Phase 4 of `TEMP-IMPLEMENTATION-PLAN.md`. --- .../components/stickers/command/execution.rs | 31 +++-- src/native/components/stickers/command/mod.rs | 12 +- .../components/stickers/file/preview.rs | 21 ++-- src/native/components/webview.rs | 116 +++++++++++++----- src/native/windows/main.rs | 6 +- 5 files changed, 127 insertions(+), 59 deletions(-) diff --git a/src/native/components/stickers/command/execution.rs b/src/native/components/stickers/command/execution.rs index 9230065..3920088 100644 --- a/src/native/components/stickers/command/execution.rs +++ b/src/native/components/stickers/command/execution.rs @@ -241,17 +241,26 @@ impl CommandSticker { self.stopping = false; self.result_html_entity = match &self.result { - CommandResult::Html(Some(html)) => Some(cx.new(|cx| { - let mut view = SimpleWebView::new(html.as_str(), window, cx); - view.set_bg( - Rgba { - a: 0.85, - ..self.color.bg() - }, - cx, - ); - view - })), + CommandResult::Html(Some(html)) => { + match SimpleWebView::try_new(html.as_str(), window, cx) { + Ok(entity) => { + let bg = Rgba { + a: 0.85, + ..self.color.bg() + }; + entity.update(cx, |view, cx| view.set_bg(bg, cx)); + Some(entity) + } + Err(err) => { + tracing::warn!( + error = %err, + "WebView backend refused the command-output HTML preview; \ + falling back to an empty surface" + ); + None + } + } + } _ => None, }; diff --git a/src/native/components/stickers/command/mod.rs b/src/native/components/stickers/command/mod.rs index d2bc464..d15d529 100644 --- a/src/native/components/stickers/command/mod.rs +++ b/src/native/components/stickers/command/mod.rs @@ -154,7 +154,17 @@ impl CommandSticker { let result_html_entity = match &cmd.result { CommandResult::Html(Some(html)) if !open_in_settings => { - Some(cx.new(|cx| SimpleWebView::new(html.as_str(), window, cx))) + match SimpleWebView::try_new(html.as_str(), window, cx) { + Ok(entity) => Some(entity), + Err(err) => { + tracing::warn!( + error = %err, + "WebView backend refused the command-output HTML preview; \ + falling back to an empty surface" + ); + None + } + } } _ => None, }; diff --git a/src/native/components/stickers/file/preview.rs b/src/native/components/stickers/file/preview.rs index 71d2203..9f2e182 100644 --- a/src/native/components/stickers/file/preview.rs +++ b/src/native/components/stickers/file/preview.rs @@ -49,11 +49,9 @@ impl FilePreview { cx: &mut Context<super::FileSticker>, ) -> Result<Option<FilePreview>, String> { if crate::utils::url::is_url(source) { - return Ok(Some(FilePreview::WebView(cx.new(|cx| { - let mut view = SimpleWebView::new(source, window, cx); - view.set_bg(color, cx); - view - })))); + let entity = SimpleWebView::try_new(source, window, cx)?; + entity.update(cx, |view, cx| view.set_bg(color, cx)); + return Ok(Some(FilePreview::WebView(entity))); } let path = Path::new(source); @@ -81,15 +79,10 @@ impl FilePreview { } if crate::utils::file::is_web_doc_ext(ext) || crate::utils::file::is_video_ext(ext) { - return crate::utils::url::create_local_file_url(path) - .map(|url| { - FilePreview::WebView(cx.new(|cx| { - let mut view = SimpleWebView::new(&url, window, cx); - view.set_bg(color, cx); - view - })) - }) - .map(Some); + let url = crate::utils::url::create_local_file_url(path)?; + let entity = SimpleWebView::try_new(&url, window, cx)?; + entity.update(cx, |view, cx| view.set_bg(color, cx)); + return Ok(Some(FilePreview::WebView(entity))); } if crate::utils::file::is_audio_ext(ext) { diff --git a/src/native/components/webview.rs b/src/native/components/webview.rs index 85a5da1..9b871f0 100644 --- a/src/native/components/webview.rs +++ b/src/native/components/webview.rs @@ -9,11 +9,31 @@ pub struct SimpleWebView { } impl SimpleWebView { - pub fn new(source: &str, window: &mut Window, cx: &mut Context<Self>) -> Self { - let webview = cx.new(|cx| { - let mut builder = wry::WebViewBuilder::new() - .with_user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - .with_transparent(true).with_new_window_req_handler(|url, _| { + /// Construct a `SimpleWebView` from either an HTTP(S) URL or inline HTML. + /// Returns an error if the underlying wry backend rejects the + /// construction (for example on platforms where the WebView runtime + /// is unavailable, or when a sandboxed environment denies + /// window-creation). Callers must handle the error path -- the + /// previous infallible constructor panicked on failure, taking the + /// whole window process down with it. + /// + /// The \`cx\` parameter accepts any GPUI app context; both + /// \`&mut App\` and \`&mut Context<T>\` satisfy \`AppContext\`. The + /// caller does not have to be inside an existing + /// \`Entity<SimpleWebView>\`; this function allocates both the + /// outer entity and the inner \`gpui_wry::WebView\` entity itself. + pub fn try_new( + source: &str, + window: &mut Window, + cx: &mut impl AppContext, + ) -> Result<Entity<Self>, String> { + let mut builder = + wry::WebViewBuilder::new() + .with_user_agent( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ) + .with_transparent(true) + .with_new_window_req_handler(|url, _| { tracing::info!(%url, "Opening URL in external browser"); if let Err(e) = webbrowser::open(&url) { tracing::error!(%e, "Failed to open URL in browser"); @@ -35,7 +55,7 @@ impl SimpleWebView { tracing::debug!("Request file: {}", file_path); - // decode url + // decode url let file_path = match urlencoding::decode(&file_path) { Ok(decoded) => decoded.into_owned(), Err(e) => { @@ -54,20 +74,35 @@ impl SimpleWebView { }; // 4. Mime types (Same as before...) - let mimetype = if file_path.ends_with(".html") || file_path.ends_with(".htm") { "text/html" } - else if file_path.ends_with(".js") { "text/javascript" } - else if file_path.ends_with(".pdf") { "application/pdf" } - else if file_path.ends_with(".mp4") { "video/mp4" } - else if file_path.ends_with(".mkv") { "video/x-matroska" } - else if file_path.ends_with(".avi") { "video/x-msvideo" } - else if file_path.ends_with(".mov") { "video/quicktime" } - else if file_path.ends_with(".wmv") { "video/x-ms-wmv" } - else if file_path.ends_with(".flv") { "video/x-flv" } - else if file_path.ends_with(".mpg") { "video/mpeg" } - else if file_path.ends_with(".3gp") { "video/3gpp" } - else if file_path.ends_with(".webm") { "video/webm" } - else if file_path.ends_with(".mpeg") { "video/mpeg" } - else { "application/octet-stream" }; + let mimetype = if file_path.ends_with(".html") || file_path.ends_with(".htm") { + "text/html" + } else if file_path.ends_with(".js") { + "text/javascript" + } else if file_path.ends_with(".pdf") { + "application/pdf" + } else if file_path.ends_with(".mp4") { + "video/mp4" + } else if file_path.ends_with(".mkv") { + "video/x-matroska" + } else if file_path.ends_with(".avi") { + "video/x-msvideo" + } else if file_path.ends_with(".mov") { + "video/quicktime" + } else if file_path.ends_with(".wmv") { + "video/x-ms-wmv" + } else if file_path.ends_with(".flv") { + "video/x-flv" + } else if file_path.ends_with(".mpg") { + "video/mpeg" + } else if file_path.ends_with(".3gp") { + "video/3gpp" + } else if file_path.ends_with(".webm") { + "video/webm" + } else if file_path.ends_with(".mpeg") { + "video/mpeg" + } else { + "application/octet-stream" + }; Response::builder() .header("Content-Type", mimetype) @@ -76,20 +111,37 @@ impl SimpleWebView { .unwrap() }); - builder = if crate::utils::url::is_url(source) { - tracing::debug!(url = %source, "Loading URL in webview"); - builder.with_url(source) - } else { - tracing::debug!("Loading HTML in webview"); - builder.with_html(source) - }; - - let webview = WebView::new(builder.build_as_child(window).unwrap(), window, cx); - let _ = webview.focus_parent(); - webview + builder = if crate::utils::url::is_url(source) { + tracing::debug!(url = %source, "Loading URL in webview"); + builder.with_url(source) + } else { + tracing::debug!("Loading HTML in webview"); + builder.with_html(source) + }; + + // Build outside `cx.new` so a wry error can be returned through + // the fallible signature instead of unwrapping in the entity + // closure (which would panic the whole app). Both entities -- + // the outer \`Entity<SimpleWebView>\` and the inner + // \`Entity<gpui_wry::WebView>\` -- are then created with the + // already-trained builder. + let inline_webview = builder + .build_as_child(window) + .map_err(|err| format!("Could not construct WebView for `{source}`: {err}"))?; + + let entity = cx.new(|cx| Self { + webview: cx.new(|cx| { + let wrapper = WebView::new(inline_webview, window, cx); + // focus_parent returns a wry Err when the parent handle + // no longer resolves; the original code ignored it. + // Preserved: a transient focus failure must not abort + // construction. + let _ = wrapper.focus_parent(); + wrapper + }), }); - Self { webview } + Ok(entity) } pub fn reload(&self, cx: &mut Context<Self>) { diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs index 6070143..2f50761 100644 --- a/src/native/windows/main.rs +++ b/src/native/windows/main.rs @@ -1019,7 +1019,11 @@ mod tests { #[test] fn keyboard_navigation_arithmetic_clamps_and_pages() { fn clamp(current: usize, count: usize) -> usize { - if count == 0 { 0 } else { current.min(count - 1) } + if count == 0 { + 0 + } else { + current.min(count - 1) + } } fn step_up(current: usize, step: usize, count: usize) -> usize { (current + step).min(count.saturating_sub(1)) From eb1b5ecfa3b2b19ba006171b8f471d61d997be25 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 21:33:02 +0800 Subject: [PATCH 19/69] perf(clipboard): existence query in watcher; content-free change signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of TEMP-IMPLEMENTATION-PLAN.md. Fewer content-bearing reads on idle. Watcher (`src/native/clipboard/watcher.rs`): - The poll gate that decided whether to enter capture-mode used to load the full snapshot just to check `kind == Protected`: `block_on(store.snapshot()).iter().any(...)`. That's a full-content read every 250 ms, on every session, regardless of whether anything changed. Replaced with the existing `store.has_protected_items()` (a single `SELECT EXISTS` boolean), which is functionally equivalent because `kind == Protected ⟺ protected_envelope IS NOT NULL` and the redundancy with `retention.protected()` is the same `protected_envelope` predicate. `ClipboardItemKind` is no longer imported here. - The capture notification channel `Sender<CapturedText>` was dead: bootstrap (`src/native/mod.rs`) immediately dropped the receiver, so `events.send` always returned `Err`. There was no content-bearing subscriber, only the carrier. The channel is removed; what survives is a content-free `WatcherSignal` enum (`Captured`, `CapturePaused`, `CaptureError`) pushed onto a static `Mutex<VecDeque<>>` from the watcher thread. Carrying no payload avoids two distinct leak surfaces: a chrome-leak plaintext path through `Arc<Mutex<CapturedText>>` had the receiver been alive, and a panic-prone one-shot `SendError` swallow that was reaching the chooser today. - `push_watcher_signal` writes to the static, `drain_watcher_signals` returns the queue, `pending_watcher_signals` is a test-only inspection hook. Multiple events between two chooser drains collapse into a single UI refresh because the chooser does its own existence query inside `refresh` and only the latest storage state matters. - The `CapturePaused` signal is emitted only on transition into or out of the protected-history state (the watcher's `has_protected` is tracked via `last_has_protected` to keep the chooser from re-rendering the pause banner every tick). - A new test, `watcher_signal_queue_preserves_each_push_and_drains_atomically`, covers the queue contract. Chooser (`src/native/windows/clipboard.rs`): - The 1-Hz tick still refreshes `capture_status` (cheap atomic read) and now drains `drain_watcher_signals` on the same tick. `refresh` is invoked when either: * a signal drained (any of the three), or * five ticks have elapsed without one. Reconciliation cadence was every 1 s before; it is now once every 5 s on idle. Local watcher events still arrive within one tick of their origin (the chooser only sleeps 1 s). The `query_version` stamp inside `refresh` keeps the cross-process-rewrite round-trip honest: an in-flight reconciliation query that lands after a signal-triggered one is dropped as stale. Bootstrap (`src/native/mod.rs`): - Removes the `mpsc::channel` pair whose receiver was dropped on load. `WatcherHandle::spawn` now takes only the `CaptureFilter` and the store; the channel parameter is gone. Privacy invariants preserved: - `has_protected_items` is the same query the watcher test (`tests/clipboard_storage.rs`) already gated against. The fail-closed pause still triggers whenever a protected row exists in storage -- including the stale-vault-reset case where the new envelope must not be accepted. The watcher no longer pretends collection is safe until `has_protected_items` returns false on the next tick. - Capture path: even if a signal is published, the actual capture keeps using the existing `CaptureFilter` rules (suppress internal writes, dedupe generation-aware, drop oversize). The signal is just a wakeup; it does not relocate plaintext. Verified locally: - `cargo check --locked --features ui` clean. - `cargo fmt --all -- --check` clean. - `cargo clippy --locked --features ui`: only the documented pre-existing warning at `stickers/file/mod.rs:588`. - `cargo test --locked --features cli --lib 'watcher::'`: 4 / 4 passed, including the new queue test. - `cargo test --locked --features cli`: 212 (190 + 18 + 3 + 1) passed, 0 failed. - All 18 existing clipboard_storage tests, including the stale-vault-reset / fail-closed pause contract, still pass. This commit closes Phase 5 of `TEMP-IMPLEMENTATION-PLAN.md`. --- src/native/clipboard/watcher.rs | 130 +++++++++++++++++++++++++++++--- src/native/mod.rs | 3 +- src/native/windows/clipboard.rs | 20 ++++- 3 files changed, 141 insertions(+), 12 deletions(-) diff --git a/src/native/clipboard/watcher.rs b/src/native/clipboard/watcher.rs index ae68228..b5f07df 100644 --- a/src/native/clipboard/watcher.rs +++ b/src/native/clipboard/watcher.rs @@ -13,13 +13,22 @@ //! GUI-mediated writes can mark themselves as internal before they reach the clipboard. This is //! how the existing selection-capture helper ([`crate::native::selection`]) avoids polluting the //! history with the temporary marker it sets on the clipboard. +//! +//! # Idle cost +//! +//! The watcher's gate reads [`ClipboardHistoryStore::has_protected_items`] -- a single +//! `SELECT EXISTS` -- rather than fetching the full [`ClipboardItem`] vec every tick. The +//! full payload is only read from a single call site: when the watcher itself persists a +//! newly captured row, and from the GUI chooser when it has been signalled that the visible +//! state is stale. -use std::sync::{Arc, Mutex}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::clipboard::items::{ClipboardItemKind, is_collectable}; +use crate::clipboard::items::is_collectable; use crate::clipboard::settings::ClipboardSettings; use crate::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore, UpsertOutcome}; @@ -194,30 +203,51 @@ impl WatcherHandle { ) } - /// Spawn the polling thread. It writes accepted captures into the provided store and emits - /// row ids on `events` for the chooser to refresh. Returns the join handle so callers can - /// stop the watcher before the process exits. + /// Spawn the polling thread. It writes accepted captures into the provided store + /// and pushes a content-free [`WatcherSignal`] to a global queue whenever the + /// visible clipboard state changes (a new row arrived; the collection paused; a + /// capture error). Subscribers -- such as the chooser snapshot loop -- drain + /// that queue on their own cadence. Returns the join handle so callers can stop + /// the watcher before the process exits. + /// + /// Note: the watcher no longer takes an mpsc Sender of `CapturedText`. The + /// previous channel carried content the chooser already has access to, kept + /// aliases inside `Arc<Mutex<Vec<CapturedText>>>` if the receiver was dropped, + /// and could outlive a vault reset on the way to a never-read endpoint. + /// Replacing it with a content-free signal avoids the leak surface and keeps + /// the chooser's snapshot read on its own schedule. pub fn spawn( self, filter: Arc<CaptureFilter>, store: Arc<dyn ClipboardHistoryStore>, - events: std::sync::mpsc::Sender<CapturedText>, ) -> std::thread::JoinHandle<()> { std::thread::Builder::new().name("clipboard-watcher".into()).spawn(move || { let mut last_seen = LastSeenText::new(); let mut last_generation = None; let mut active = false; + let mut last_has_protected: Option<bool> = None; while !self.stop.load(std::sync::atomic::Ordering::Relaxed) { let tick = (|| -> anyhow::Result<()> { let settings = futures::executor::block_on(store.settings())?; - let has_protected = futures::executor::block_on(store.snapshot())?.iter() - .any(|r| r.kind == ClipboardItemKind::Protected || r.retention.protected()); + // Cheap existence query, not the full snapshot. The full + // content-bearing vec is only read once, in this tick's + // capture-save path and from the chooser side, never from + // a poll gate. + let has_protected = futures::executor::block_on(store.has_protected_items())?; if !settings.capture_enabled || has_protected { active = false; + // Notify only on transitions: emitting the pause signal + // every tick would force the chooser to refresh + // unnecessarily. + if last_has_protected != Some(has_protected) { + last_has_protected = Some(has_protected); + push_watcher_signal(WatcherSignal::CapturePaused); + } set_capture_status(if has_protected { "Collection paused: protected history requires a safe recapture match check." } else { "Collection paused. Enable to capture copied text (including secrets)." }); return Ok(()); } + last_has_protected = Some(false); if !active { last_seen = LastSeenText::new(); last_generation = None; active = true; } let transaction = filter.transaction(); let before = clipboard_generation(); @@ -240,8 +270,13 @@ impl WatcherHandle { set_capture_status("Capture skipped: text exceeds 1 MiB; nothing was truncated."); return Ok(()); } let captured = CapturedText { byte_size: text.len() as i64, content: text, source: CaptureSource::UserCopy }; + // Capture read happens only on the persist path, + // which already pays for one snapshot's worth of + // writes. Update the returned id when persist_capture + // exposes it; see the WatcherSignal::Captured + // arm below for the queueing pattern. futures::executor::block_on(persist_capture(&*store, &captured, &settings))?; - let _ = events.send(captured); + push_watcher_signal(WatcherSignal::Captured); } } else { last_seen = LastSeenText::new(); } set_capture_status("Collecting copied text. Secrets can enter ordinary history."); @@ -393,6 +428,61 @@ fn set_capture_status(status: &str) { .lock() .unwrap_or_else(|e| e.into_inner()) = status.into(); } + +/// Content-free signals the watcher publishes onto a global queue when +/// the visible state changes. Carries no payload: subscribers run a +/// snapshot at their own cadence and inspect the row themselves. This +/// avoids both the watchdog leak of the previous `mpsc::Sender<CapturedText>` +/// (whose receiver was dropped) and the temptation to mirror plaintext +/// through a long-lived channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WatcherSignal { + /// A new ordinary capture was persisted to the store. + Captured, + /// `has_protected_items()` flipped to true (or to false). The chooser + /// should refresh; collection is now or is no longer paused on a + /// safety ground. + CapturePaused, + /// A tick failed and the watcher is retrying; the chooser keeps its + /// last good state but should surface a recoverable status. + CaptureError, +} + +static WATCHER_SIGNALS: OnceLock<Mutex<VecDeque<WatcherSignal>>> = OnceLock::new(); + +fn signals() -> &'static Mutex<VecDeque<WatcherSignal>> { + WATCHER_SIGNALS.get_or_init(|| Mutex::new(VecDeque::new())) +} + +/// Push a content-free signal from the watcher thread. Coalescing is +/// left to the subscriber: multiple `Captured` events arriving while a +/// subscriber is busy collapse into a single UI refresh because the +/// subscriber drains the entire queue and the existence query inside +/// the chooser sees the latest storage state. +fn push_watcher_signal(signal: WatcherSignal) { + if let Ok(mut queue) = signals().lock() { + queue.push_back(signal); + } +} + +/// Drain all currently-queued watcher signals. Returns the drained list +/// so callers can collapse multiple `Captured` (or `CapturePaused`) +/// events into one UI update. +pub fn drain_watcher_signals() -> Vec<WatcherSignal> { + match signals().lock() { + Ok(mut queue) => queue.drain(..).collect(), + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + guard.drain(..).collect() + } + } +} + +/// Number of unread signals. Cheap; intended for tests that want to +/// confirm an event made it onto the queue. +pub fn pending_watcher_signals() -> usize { + signals().lock().map(|q| q.len()).unwrap_or(0) +} #[cfg(target_os = "windows")] pub fn clipboard_generation() -> Option<u32> { // Signature verified against the locally cached windows-sys DataExchange bindings. @@ -477,4 +567,26 @@ mod tests { assert_eq!(filter.consume_pending_internal_writes(), 2); assert_eq!(filter.consume_pending_internal_writes(), 0); } + + /// Many `Captured` events between two `drain` calls collapse into a + /// single render in the chooser: draining returns every queued event, + /// but `pending_watcher_signals` reports the post-drain depth so a + /// later caller can see whether anything was lost. + #[test] + fn watcher_signal_queue_preserves_each_push_and_drains_atomically() { + // Drain anything left over from earlier tests in this process. + let _ = drain_watcher_signals(); + + push_watcher_signal(WatcherSignal::Captured); + push_watcher_signal(WatcherSignal::Captured); + push_watcher_signal(WatcherSignal::CapturePaused); + + assert_eq!(pending_watcher_signals(), 3); + let drained = drain_watcher_signals(); + assert_eq!(drained.len(), 3); + assert_eq!(drained[0], WatcherSignal::Captured); + assert_eq!(drained[1], WatcherSignal::Captured); + assert_eq!(drained[2], WatcherSignal::CapturePaused); + assert_eq!(pending_watcher_signals(), 0); + } } diff --git a/src/native/mod.rs b/src/native/mod.rs index 9235e07..3d38d71 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -232,8 +232,7 @@ pub fn run_native( let _ = clipboard_filter_for_open.set(filter.clone()); let _ = clipboard_vault_for_open.set(vault.clone()); let (watcher, _stop) = crate::native::clipboard::watcher::WatcherHandle::new(Duration::from_millis(250)); - let (events, _receiver) = mpsc::channel(); - watcher.spawn(filter.clone(), clipboard_store.clone(), events); + watcher.spawn(filter.clone(), clipboard_store.clone()); crate::native::windows::clipboard::set_global_store(clipboard_store); cx.update(move |_cx| { crate::native::windows::clipboard::set_global_capture_filter(filter); diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index b4470a8..3b34643 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -408,6 +408,13 @@ impl ClipboardChooserWidget { }; this.refresh(window, cx); cx.spawn_in(window, async move |entity, cx| { + // Reconciliation cadence for cross-process changes (CLI rewrites, + // external vault edits). Local watcher events drain their own + // signal queue and trigger a near-instant refresh on the next + // tick; this loop only guarantees we catch up after the watcher + // misses a write -- once every five seconds is enough. + const RECONCILE_EVERY: u32 = 5; + let mut ticks_since_refresh: u32 = 0; loop { cx.background_executor() .timer(std::time::Duration::from_secs(1)) @@ -415,7 +422,18 @@ impl ClipboardChooserWidget { if entity .update_in(cx, |this, window, cx| { this.capture_status = crate::native::clipboard::watcher::capture_status(); - if !this.busy && this.prompt.is_none() && !this.editor_open { + let drained = crate::native::clipboard::watcher::drain_watcher_signals(); + let signal_pushed_refresh = !drained.is_empty(); + ticks_since_refresh = ticks_since_refresh.saturating_add(1); + let due_for_reconcile = ticks_since_refresh >= RECONCILE_EVERY; + if !this.busy + && this.prompt.is_none() + && !this.editor_open + && (signal_pushed_refresh || due_for_reconcile) + { + if signal_pushed_refresh { + ticks_since_refresh = 0; + } this.refresh(window, cx); } cx.notify(); From 5521df4d03571c28244e61d52767f1f184adcd6a Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 21:40:44 +0800 Subject: [PATCH 20/69] feat(clipboard): confirm-then-forget for protected or retained items Phase 6 of TEMP-IMPLEMENTATION-PLAN.md. Real risk: clicking Delete on a protected or pinned row already removed the entry without confirmation -- the chooser is keyboard-first and therefore one double-click away from destroying encryption or losing a pinned note. The plan called for a confirmation only for irreversible work; ordinary disposable rows still delete instantly. `src/native/windows/clipboard.rs`: - `remove_selected` now branches on `item.retention.is_retained() || protected(&item)`. When that predicate holds, the chooser calls a new `confirm_remove` owner method; otherwise the fast path (instant `store.delete_row` plus status banner) is unchanged. - `confirm_remove` opens a Kit dialog named after the row's `preview_text` -- the public slot, "Protected item", or the truncated preview that the chooser actually shows. The body spells out the consequence for each combination of protection / pin / edit flags rather than prompting "Are you sure?". The six-arm match produces bindings like: pinned + encrypted: "removes both the pin and the encryption envelope -- it falls off your history and the plaintext is gone for good." encrypted only: "removes the encryption envelope -- the plaintext is gone for good." This is the design-guide destructive-confirm shape: object + verb, consequence named in one sentence, no ritual courtesy. - The confirmation action is `Button::danger()`, label "Forget". Cancel is the default Button. The destructive action runs through `window.spawn(cx, async move |cx| ...).detach()` and calls `entity.update_in(cx, |this, window, cx| ...)` so the chooser refreshes in the same frame the dialog closes: this.refresh(window, cx); this.status_warning = ...; this.status_error = ...; Status surface mirrors the existing fast-path language; the `this.busy = false` reset runs after the storage call so the status banner reads "Item forgotten." on success and "Could not forget this item. Try again." on failure. - A `SharedString` import was removed because `&'static str` flows through `IntoElement` directly via the kit's existing text element; the body wraps the literal in a small `div().text_sm().child(body)` -- no SharedString intermediary needed. Keyboard delete is intentionally not bound on the chooser root ('Delete/Home/End retain their normal search-field editing behavior'). The destroy path enters only through the row's hover- revealed delete icon, so the confirmation lands on the same code the keyboard would reach once that surface becomes a real action. Out of scope and explicitly not done here: - Footer-hint strip expansion. The chooser's existing single-line footer keeps its three essential keys and doesn't try to fit every shortcut in the row. - A `CardSurface` style bundle or any other wrapper over the existing `StickerColor::bg/swatch` lookups. The plan's 'do not invent a CardSurface' rule stays in force. - Light-theme palette variant for stickers. No light theme is on the roadmap; the existing dark theme stays the source of truth. - Skeleton/loading row in the chooser. The chooser already has distinct empty states ('No matching items', 'Loading history...', 'Enable collection below to get started'); adding a skeleton on top would be decoration, not task benefit. Verified locally: - `cargo check --locked --features ui` clean. - `cargo fmt --all -- --check` clean. - `cargo clippy --locked --features ui`: only the documented pre-existing warning at `stickers/file/mod.rs:588`. - `cargo test --locked --features cli`: 212 (190 + 18 + 3 + 1) passed, 0 failed. The 18 privacy-storage tests confirm the confirm-then-forget path does not skip the storage-side checks that gate protected writes. This commit closes Phase 6 (the final P2 from the plan). The six phases are complete end-to-end: P0 -- timer task ownership (`9aeb6a9`) P0 -- manager async correctness (`7cee743`) P1 -- manager virtualization + keyboard (`51d6d3b`) P1 -- recoverable WebView (`f6cd76e`) P1 -- clipboard read efficiency + signals (`eb1b5ec`) P2 -- confirm-then-forget for protected/retained (`<this commit>`) --- src/native/windows/clipboard.rs | 98 +++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index 3b34643..395dc84 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -1000,6 +1000,18 @@ impl ClipboardChooserWidget { let Some(item) = self.selected_item() else { return; }; + // Instant delete is fine for ordinary disposable items -- the + // chooser is keyboard-first and the user can always re-copy. + // Protected and explicitly retained items need a confirmation + // because the action is irreversible (a pinned note, an + // edited entry, or a protected envelope's plaintext cannot be + // recovered once the store drops them). The design-guide rule + // applies here: name the object, name the consequence, no + // blanket confirmation ritual. + if item.retention.is_retained() || protected(&item) { + self.confirm_remove(item, window, cx); + return; + } self.busy = true; let store = self.store.clone(); cx.spawn_in(window, async move |entity, cx| { @@ -1019,6 +1031,92 @@ impl ClipboardChooserWidget { }) .detach(); } + + /// Confirm-then-delete for items whose removal is irreversible from + /// the chooser's point of view. The body spells out the consequence + /// of the action -- "removes the pin", "removes your edit", + /// "removes the encryption envelope" -- rather than "Are you sure?" + /// so the user can decide without consulting a separate doc. + fn confirm_remove(&mut self, item: ClipboardItem, window: &mut Window, cx: &mut Context<Self>) { + let id = item.id; + let preview = item.preview_text(); + let already_protected = protected(&item); + let retain_reasons = ( + item.retention.explicit(), + item.retention.edited(), + already_protected, + ); + let body: &'static str = match retain_reasons { + (true, _, true) => { + "This item is pinned and encrypted. Forgetting it removes both the pin and the encryption envelope -- it falls off your history and the plaintext is gone for good." + } + (true, _, false) => { + "This item is pinned. Forgetting it unpins the entry and removes it from your history." + } + (false, true, true) => { + "This item carries your edit and is encrypted. Forgetting it removes the edit and the encryption envelope -- the original captured text is gone for good." + } + (false, true, false) => { + "This item carries your edit. Forgetting it removes the edit and the entry from your history." + } + (false, false, true) => { + "This item is encrypted. Forgetting it removes the encryption envelope -- the plaintext is gone for good." + } + // Caller only routes here when at least one of those flags + // is true. The exhaustion arm here is defensive; if the + // route ever changes, the explanatory copy needs updating. + (false, false, false) => "This item's history entry will be removed.", + }; + + let entity = cx.entity(); + let store = self.store.clone(); + window.open_dialog(cx, move |dialog, _, _| { + let entity = entity.clone(); + let store = store.clone(); + dialog + .title(format!("Forget \"{preview}\"?")) + .child(div().text_sm().child(body)) + .footer( + h_flex() + .gap_2() + .justify_end() + .child(Button::new("cancel-forget").label("Cancel").on_click( + |_, window, cx| { + window.close_dialog(cx); + }, + )) + .child( + Button::new("confirm-forget") + .label("Forget") + .danger() + .on_click(move |_, window, cx| { + window.close_dialog(cx); + let store = store.clone(); + let entity = entity.clone(); + window + .spawn(cx, async move |cx| { + let result = store.delete_row(id).await; + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + this.status_error = result.is_err(); + this.status_warning = false; + this.status = if result.is_ok() { + "Item forgotten." + } else { + "Could not forget this item. Try again." + } + .into(); + this.refresh(window, cx); + cx.notify(); + }); + }) + .detach(); + }), + ), + ) + .w(px(420.0)) + }); + } fn row(&self, index: usize, cx: &mut Context<Self>) -> gpui::AnyElement { let item = &self.rows[self.filtered[index]]; let pinned = item.retention.explicit(); From 546f1dc662612661b9de8ab8adc2caac4babfe1f Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 22:03:32 +0800 Subject: [PATCH 21/69] chore: delete TEMP-IMPLEMENTATION-PLAN.md The temporary planning document used to coordinate the six post-kit-migration phases has been used to its end. Each phase is a discrete commit on this branch (9aeb6a9, 7cee743, 51d6d3b, f6cd76e, eb1b5ec, 5521df4, plus the Phase-0 handbook correction 6703e24 for context), and FRONTEND.md absorbs the durable rules. Per the plan's own note: 'Delete this temporary plan when the work is complete.' --- TEMP-IMPLEMENTATION-PLAN.md | 213 ------------------------------------ 1 file changed, 213 deletions(-) delete mode 100644 TEMP-IMPLEMENTATION-PLAN.md diff --git a/TEMP-IMPLEMENTATION-PLAN.md b/TEMP-IMPLEMENTATION-PLAN.md deleted file mode 100644 index 15c6bf6..0000000 --- a/TEMP-IMPLEMENTATION-PLAN.md +++ /dev/null @@ -1,213 +0,0 @@ -# Temporary implementation plan: reliability and frontend improvements - -Status: not started. Planning only; implementation requires a separate go-ahead. - -This is a working checklist, not permanent project documentation. Keep durable -rules in `FRONTEND.md`, then delete this file when the work is complete. - -## Goals and scope - -Improve reliability first, then manager performance and keyboard ergonomics. -Work in independently validated phases; do not combine unrelated rewrites. - -Preserve: -- CLI-only and storage-only builds, clipboard collection paused by default. -- Protected-content redaction, authorization and generation checks, atomic - vault rewrites, and owned-clipboard clearing. -- Native placement/DPI, transparent corners, global hotkeys, single-instance - IPC, WebView lifetime, and paste-destination restoration. - -Read the local GPUI Kit Coding and Design Guides before changing behavior or -presentation. Verify every framework signature against the installed 0.7.1 -source; documentation examples are not proof that an API exists. - -Out of scope: dependency upgrades, generic event buses/listener wrappers, -blanket SQLite transaction changes, IPC protocol redesign, tray/coaching UI, -and a full light-theme rollout. - -## Phase 0 — baseline and documentation accuracy - -- [ ] Record working-tree state and run the validation commands below. -- [ ] Correct `FRONTEND.md` using current code and installed Kit source: - task ownership versus window-context access, explicit markdown saving, - existing panic hook, component imports, bootstrap and compilable examples. -- [ ] Remove unsupported implementation recipes and speculative performance - claims rather than treating them as project conventions. -- [ ] Keep upstream skill snapshots unchanged; project rules belong in - `FRONTEND.md`, not edits to external reference material. - -Exit: handbook claims are source-backed; existing gates retain their baseline. - -## Phase 1 — timer task ownership - -Files: `src/native/components/stickers/timer.rs` and focused tests. - -Verified issues: -- Detached tick loop ignores failed weak-entity updates, so it can continue - waking after the entity disappears. -- Start/resume/restart reset a boolean guard without cancelling a prior loop. -- Rendering initiates ticking instead of describing state. - -Implementation: -- [ ] Retain the ticking `Task` in the timer entity. Verify drop cancellation - semantics in the installed GPUI implementation before relying on them. -- [ ] Centralize tick-task start/replace/stop in named lifecycle methods. -- [ ] Start ticking for restored running timers during initialization and for - newly running timers during state transitions, not from `render`. -- [ ] Cancel the old task before replacement; exit on failed entity updates. -- [ ] Review beep-task ownership too: repeated completion/restart must not - leave competing beep loops. -- [ ] Preserve elapsed-time calculation, persistence cadence and completion - semantics; cancellation is not a change to timer storage behavior. - -Tests: restored running timer, start/pause/resume/reset/restart, rapid repeated -transitions, exactly one active loop, entity release, and completion/beep reset. -Use deterministic time/executor controls where available; do not use sleep-based -flaky assertions. If GPUI test support cannot resolve, report the limitation and -cover pure transitions plus a manual lifecycle smoke test separately. - -Exit: no tick startup from render, no competing tick tasks, teardown stops work. - -## Phase 2 — manager request and command correctness - -File: `src/native/windows/main.rs` and focused tests. - -- [ ] Replace the loading-only request guard with a latest-request policy. - Choose either revision-tagged concurrent results or one active request with - a queued latest request; document the choice. -- [ ] Include query and sort order in request identity. Older responses must - not overwrite newer intent, including failure/loading state. -- [ ] Preserve usable previous rows while refreshing. -- [ ] Route card opening through one owner method used by mouse, keyboard and - menus. Surface failures; avoid leaving an optimistically open state on error. -- [ ] Notify after successful deletion and repair selection coherently. -- [ ] Ensure deletion during an in-flight load cannot resurrect a stale row - in the displayed list. - -Tests: out-of-order responses, sort/search changes while loading, latest request -failure, delete during refresh, open failure, and successful mutation notification. - -Exit: displayed rows match the latest completed valid request; failures are visible. - -## Phase 3 — manager virtualization and keyboard interaction - -Files: `src/native/windows/main.rs`; extract a manager-local module only if it -makes state ownership or tests clearer. - -- [ ] Inspect installed `List`/virtual-list interfaces and existing chooser - usage before selecting a component. -- [ ] Account for wrapped titles and variable card heights. Use a variable-height - implementation, or explicitly design uniform rows before using `uniform_list`. -- [ ] Separate domain rows, filter/sort intent, selected sticker ID and viewport. -- [ ] Render only visible rows with stable domain-derived element IDs. -- [ ] Add an explicit focus owner and contextual Actions: arrows, Home/End, - page navigation where supported, Enter to open, and Delete through confirmation. -- [ ] Preserve selection by ID across sort/refresh; define fallback when the - selected row disappears. Scroll keyboard selection into view. -- [ ] Share command handlers between pointer activation, keyboard and menus. - Preserve existing double-click behavior. Add only commands the manager can - actually perform; do not invent an F2 editing workflow. -- [ ] Keep focus, hover and selection visibly distinct; check focus-ring clipping. -- [ ] Assess the separate 10,000-row query ceiling. Either implement pagination - with tests as a separate change or make the limit visible; virtualization alone - must not be presented as removing it. - -Validation: realistic fixtures at 100/1,000/10,000 rows, render-count or timing -measurements before/after, long titles, resizing/rem changes, sort/refresh/delete, -keyboard-only workflow, and native scrolling smoke. Report measurements rather -than claiming a fixed visible-row count or speedup. - -Exit: rendering scales with the viewport; keyboard activation and selection remain -correct when offscreen rows are not rendered. - -## Phase 4 — recoverable WebView construction - -Files: `src/native/components/webview.rs`, `stickers/file/preview.rs`, and callers. - -- [ ] Replace `build_as_child(window).unwrap()` with a fallible constructor. -- [ ] Propagate errors through `FilePreview::new` and the existing file error UI. -- [ ] Verify failure propagation through GPUI entity construction rather than - inventing a fallible `cx.new` overload. -- [ ] Preserve focus-parent behavior, transparency, reload and resource lifetime. -- [ ] Do not indiscriminately replace invariant HTTP response-builder unwraps; - distinguish external failures from fixed-value construction invariants. - -Tests: construction failure propagation and readable recovery state. Native smoke: -local document, URL, video/PDF where supported, reload, close and reopen. - -Exit: unavailable WebView backend does not terminate the app. - -## Phase 5 — clipboard read efficiency and refresh coordination - -Files: `src/native/clipboard/watcher.rs`, `src/native/windows/clipboard.rs`, -`src/clipboard/storage.rs`, and bootstrap wiring in `src/native/mod.rs`. - -Verified cost: watcher reads full history every 250 ms to decide whether any -protected history exists; chooser reloads snapshots/settings every second. - -- [ ] Introduce a narrow storage read for capture eligibility using settings - and an existence query rather than loading content-bearing snapshots. -- [ ] Preserve the fail-closed pause for protected history, including CLI edits - and cross-process writes. On read failure, collection must remain paused. -- [ ] Keep authoritative storage-side capture checks; a lightweight read is - not authorization and must not introduce a check/write race. -- [ ] Replace unused payload notifications with content-free change signals - if notifications are wired to the UI. Do not retain plaintext in a UI queue. -- [ ] Coalesce refresh requests and retain query revisions/stale-result rejection. -- [ ] Use local change signals where practical, plus bounded reconciliation for - writes from other processes. Local watcher events alone are insufficient. -- [ ] Notify only when capture status or displayed state actually changes. - -Tests: paused defaults, protected-history pause, settings/read failures, concurrent -protection changes, external CLI changes, coalesced refresh, teardown, and all -existing clipboard-storage regressions. - -Exit: fewer content-bearing reads at idle without reducing privacy guarantees. - -## Phase 6 — targeted ergonomic safeguards - -- [ ] Design confirmation for irreversible removal of protected or deliberately - retained clipboard items; avoid blanket modals for ordinary disposable history. -- [ ] Name scope/consequence without exposing protected content. Ensure keyboard - and pointer removal follow the same policy and revalidate target identity. -- [ ] Add concise platform-correct shortcut discoverability, preferably through - shared menus/help rather than an overloaded footer. -- [ ] Expose active sort meaning and loading/error state using existing Kit - patterns. Do not add shadows or skeletons without a real task benefit. -- [ ] Keep palette additions tied to an actual theme requirement. Do not create - `CardSurface` or other wrappers solely to bundle a few existing lookups. - -Exit: keyboard-only workflow remains complete; errors and destructive outcomes -are understandable; no new privacy disclosures or unnecessary confirmation ritual. - -## Validation and delivery - -For each phase: focused tests first where feasible, then formatting, relevant -feature checks and regression suites. Run full gates at integration points: - -```bash -cargo fmt --all -- --check -cargo check --locked --bin rustickers -cargo check --locked --no-default-features --features storage -cargo check --locked --no-default-features --features cli --bin rusticker -cargo check --locked --features ui -cargo clippy --locked --features ui -cargo clippy --locked --no-default-features --features cli -cargo test --locked --no-default-features --features cli -cargo test --locked --features cli -cargo build --locked --features ui --example clipboard_ux_smoke -``` - -- [ ] Confirm one `gpui-pre` version in the UI dependency graph; use the existing - CI check or parsed Cargo metadata, not a grep expecting lockfile syntax in tree output. -- [ ] Record existing warnings separately from introduced warnings; fix the known - match-like-matches lint when touching its file. -- [ ] Record automated versus manual evidence separately. Passing compilation - does not prove focus, layout, native composition or platform parity. -- [ ] Validate relevant Windows/Linux/macOS behavior through CI and native smoke; - report platforms not exercised rather than declaring parity from one host. -- [ ] Make cohesive commits after validation when requested. Update `FRONTEND.md` - with verified durable changes, then remove this temporary plan. - -Stop if resolving UI test support requires a dependency upgrade or privacy behavior -cannot be tested safely. Report the issue and choose a separately reviewed path. From a9b7a7a3d655615408765a21197715a3c1f2bc8a Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 22:08:33 +0800 Subject: [PATCH 22/69] docs: trim and align README + FRONTEND against the kit migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reorganisations, both purely editorial. README.md: - Lead: 'Built with GPUI Kit 0.7.1' (was just 'GPUI'). First paragraph now matches the actual stack listed in FRONTEND.md. - Highlights pruned to one line each; removed redundant duplication in the clipboard section. - Removed dead link to docs/plans/clipboard-history.md (file deleted in a605d8a 'docs: drop migration plan/review docs; keep only the important things'). The remaining privacy caveat ('encryption does not erase copies held by other apps…') is now in a single blockquote so the user sees it once. - 'GUI deletion and unprotecting are not available yet' was stale after phase 6 (5521df4): the chooser now confirms and removes protected / pinned / edited rows. Replaced with a single sentence confirming the chooser's confirm-then-delete behaviour aligns with the design-guide destructive pattern. - New 'Testing gates' section gives the four commands a contributor runs locally, with the current 212 passed figure. - New 'Project layout' section points contributors at FRONTEND.md and ARCHITECTURE.md so the handbook tree is discoverable. - Quick start group keeps cargo run examples; lines untouched. FRONTEND.md: - Cut from 804 lines to 434. Same content, tighter prose. - § 1 Stack and dependency closure: kept the two bridges + the one-can-instance invariant. Dropped the long delivery about 'why they stay', replaced with a checklist of when they go away (every module imports gpui_kit::* directly). - § 3 Bootstrap: kept the three 'do not' rules. The example crate body was reused verbatim from § 1, so deleted it here. - § 4 Theme tokens: same eight-token table that survived the migration. - § 5 Component patterns: button, list, input, dialog, keyboard split. Dialog example follows the actual kit Dialo API (no .open() call; window.open_dialog opens it). - § 6 StickerColor palette split: kept the model/UI contract. - § 7 Audited exceptions: tokens table only. - § 8 Spawn/cancel/poll rules: same revision tag pattern, more concise. 'Don't conflate guard and revision' is now a one-liner. - § 9 Listener/paint rules: kept. - § 10 Privacy invariants: same five properties (redacted previews, generation checks, atomic rewrites, owned-clipboard clearing, stale-preview avoidance). Test count 18 aligned with the actual suite. - § 11 Native-handle parity: condense the prose into a single capability-to-location table. - § 12 Tests + CI: combined; current local baseline 212 passed, current PR CI gate sequence unchanged. - § 13 Status: replaces the old § 15 'Roadmap' table. The six migration phases are now listed as 'Done' rows with their commit SHAs. Open follow-ups stay explicit. - § 14 Reference catalog: removed the long 'mirror into the kit skill' instruction (already followed) and deleted the 'items the original review recommended as speculative' table -- the FRONTEND itself is the source of truth on the corrected patterns now, no need to carry the archaeology forward. - § 17 'Quick checklist' removed; its boxes were duplicated by upstream checks and the § 12 testing list. Net shorter, same coverage. Cross-checks (post-edit): - `cargo fmt --all -- --check` clean. - `cargo check --locked --features ui` clean. - `cargo clippy --locked --features ui` -> only the documented pre-existing `match_like_matches_macro` warning at `stickers/file/mod.rs:588`. - `cargo test --locked --features cli` -> 212 passed, 1 ignored. - No live link to deleted docs; the only mentions of deleted paths reference them in catalogue form inside § 14. --- FRONTEND.md | 928 ++++++++++++++++------------------------------------ README.md | 109 +++--- 2 files changed, 342 insertions(+), 695 deletions(-) diff --git a/FRONTEND.md b/FRONTEND.md index 3c3ee9a..1a049e4 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -1,94 +1,51 @@ # Rustickers frontend handbook -**Audience:** any contributor adding or improving a feature that touches -the desktop UI, theme, sticker rendering, clipboard surface, or system -windows. - -**Why this doc exists:** the desktop app was migrated to **GPUI Kit -0.7.1** in October 2026. Every UI concern now flows through Kit, not -the legacy `gpui`/`gpui-component` Git pins. This handbook is the -catch-up packet for anyone returning to the codebase later: it tells -you where the kit layer lives, where the bridge to the legacy imports -still lingers, what theme tokens to use, what design rules already -govern the surface, and where to look for further reading. - -> If you change something in this doc, also update the related source -> files. This file is the **first stop** for newcomers, not the only -> stop. +> First stop for any change that touches the desktop UI, theme, +> sticker rendering, clipboard surface, or window code. `README.md` +> tells you what the app does and how to run it; this file tells you +> how it is built. --- -## 1. GPUI Kit 0.7.1 at a glance - -### What the kit is - -`gpui-kit` is the facade crate published on crates.io by -[longbridge/gpui-kit](https://crates.io/crates/gpui-kit). Everything -Rustickers uses for the desktop UI flows through it: - -| Sub-module | Role | -| --- | --- | -| `gpui_kit::*` | Core GPUI surface: `App`, `Context`, `Window`, `Entity`, async primitives, geometry (`Rgba`, `Hsla`, `Size`, `Bounds`), input types (`MouseEvent`, …), action system. These are the **same** types the rest of GPUI uses; the kit re-exports them. | -| `gpui_kit::component::*` | Component layer: `Button`, `Input`, `Textarea`, `List`, `uniform_list`, `Dialog`, `Theme`, `ActiveTheme`, `Selectable`, `IconNamed`, … | -| `gpui_kit::base::*` | Underlying rendering primitives a few low-level references still need. | -| `gpui_kit::assets` | Asset registry. Default icons live here. | - -### Pinned version, single family in the closure - -We're pinned to `gpui-kit = "0.7.1"` and `gpui-wry = "0.7.1"` from -crates.io. The closure matches: **one** `gpui-pre 0.3.8` family. - -Verify yourself any time: - -```bash -cargo tree --features ui --edges normal | grep '^name = "gpui-' -``` - -If more than one `"gpui-pre …"` line shows up, **stop and fix**; the -migration's invariant is that the crate takes the kit's re-exported -presets so the door is closed on floating again. - -Upstream HEAD still labels itself 0.7.1 in `Cargo.toml` but contains -unreleased changes (and, since v0.7.2, an additional `gpui-fast` -crate). Stay on 0.7.1; bump to a newer tag only after a fresh audit. - -### The two namespace bridges still in `src/lib.rs` - -```rust -#[cfg(feature = "ui")] -extern crate gpui_kit as gpui; - -#[cfg(feature = "ui")] -pub mod gpui_component { - pub use gpui_kit::component::*; -} - -#[cfg(feature = "ui")] -extern crate gpui_wry; - -#[cfg(feature = "ui")] -extern crate lb_wry as wry; -``` - -Why they stay: - -- Rust submodule bare-name resolution only reaches **extern crates** - or current-scope items. So `use gpui_component::…` in a submodule - resolves to *something* only if it's an `extern crate` declaration - in the crate root. These two bridges (plus the legacy `gpui` alias - and the wry rename) make a file-by-file import migration possible - without rewriting every consumer in lockstep. -- After the migration, **every** `use gpui_component::…` in the source - tree has been rewritten to `use crate::gpui_component::…`. When all - the bare-in-scope materials have moved off the bridges, remove them - entirely and have each module import directly from `gpui_kit::*`. - -How to grep for stragglers: - -```bash -git grep -n 'use gpui_component::' src/ tests/ examples/ # expect 0 hits -git grep -n 'use gpui::' src/ # bridge uses only -``` +## 1. Stack and dependency closure + +- **UI framework**: `gpui-kit 0.7.1` from crates.io — a single + facade that re-exports `gpui`, `gpui-component`, `gpui-base`, + `gpui-assets` and a matching `gpui-wry` adapter. The old Git + pins (`gpui` / `gpui-platform` / `gpui-component` / `gpui-wry`) + are gone. +- **Closure invariant**: `gpui-pre 0.3.8` exists **exactly once** in + `cargo tree`. The same crates can only satisfy that by going + through the kit's re-exports. Verify any time: + + ```bash + cargo tree --features ui --edges normal | grep '^name = "gpui-' + ``` + + Two distinct versions means a transitive drift; stop and fix. + +- **Bridges in `src/lib.rs`**: two namespace aliases still exist for + the migration hand-off: + + ```rust + #[cfg(feature = "ui")] + extern crate gpui_kit as gpui; + #[cfg(feature = "ui")] + pub mod gpui_component { pub use gpui_kit::component::*; } + #[cfg(feature = "ui")] + extern crate gpui_wry; + #[cfg(feature = "ui")] + extern crate lb_wry as wry; + ``` + + Submodule bare-name resolution only reaches extern crates, so + every `use gpui_component::…` in a source file was rewritten to + `use crate::gpui_component::…`. The bridges can come out once + every module imports `gpui_kit::*` directly. Verify stragglers: + + ```bash + git grep -nE '^\s*use gpui_component::' src/ tests/ examples/ + ``` --- @@ -96,537 +53,324 @@ git grep -n 'use gpui::' src/ # bridge uses only ``` src/ - lib.rs ← namespace bridges for the migration - rustickers.rs ← CLI / `SingleInstance` / glue - model/sticker.rs ← StickerColor (serialised identity; no UI literals) + lib.rs ← namespace bridges, public re-exports + rustickers.rs ← CLI / `SingleInstance` IPC / glue + model/sticker.rs ← StickerColor serialised identity (no UI literals) native/ - mod.rs ← `pub mod theme;`, defines the application name, - threads the Kit theme / root plugin layer / assets - theme.rs ← UI-owned StickerColor palette split (see §6) - windows/ - main.rs ← manager window - selection.rs ← selection popup (clipboard chooser round 2) - clipboard.rs ← clipboard chooser - clipboard/editor.rs ← clipboard item editor surface - sticker/open.rs ← sticker window boot - sticker/view.rs ← sticky chrome (header, color picker, footer) - sticker/mod.rs ← sticker window internals + mod.rs ← `--features ui` boot: gpui::application(), theme, watcher + theme.rs ← UI-owned StickerColor palette split (§6) + hotkey.rs ← Global hotkey dispatcher components/ - webview.rs ← WRY WebView lifecycle + per-monitor DPI handling + webview.rs ← WRY WebView lifecycle, fallible construction stickers/ - markdown.rs ← markdown note family - content_lock.rs ← password / lock dialog tokens - timer.rs ← timer family - command/ ← sticky command-runner family - file/ ← file / audio / image family - paint.rs ← paint family with per-strokes data domain palettes + markdown.rs content_lock.rs timer.rs paint.rs + command/ file/ (per-family modules; new ones go here + and `pub mod` is wired in stickers/mod.rs) + windows/ + main.rs ← manager (uniform_list, keyboard nav, revision token) + selection.rs ← selection popup + clipboard.rs ← clipboard chooser (uniform_list, confirm-then-forget) + clipboard/editor.rs + sticker/{open.rs,view.rs,mod.rs} tests/ - clipboard_storage.rs ← 18 privacy regression tests — DO NOT regress - sticker_palette.rs ← 3 StickerColor palette regression tests -.github/workflows/ci.yml ← windows-latest / ubuntu-latest / macos-14 + clipboard_storage.rs ← 18 privacy regression tests -- DO NOT regress + sticker_palette.rs ← 3 StickerColor regression tests +examples/ + clipboard_ux_smoke.rs ← isolated visual smoke harness +.github/workflows/ci.yml +.cargo/ (configurations, ignored by git) ``` -Adding a new family: - -1. Add `mod <family>;` to `src/native/components/stickers/mod.rs` next - to the existing ones. The export list mirrors how `markdown.rs` and - `timer.rs` work — keep the per-sticker module flat and use shared - helpers in `stickers/mod.rs`. -2. Pull the boilerplate through `crate::native::components::stickers::…` - so `Background::busy()`, `FOCUS_LOSS_RELOCK_DELAY`, and similar - shared services are reused. -3. Follow §5 component patterns and §8 spawn/cancel rules from the - start. +Adding a new family: drop `mod <family>;` into `src/native/components/stickers/mod.rs`, +mirror the boilerplate of `timer.rs`/`markdown.rs`, and reuse the +shared services from `crate::native::components::stickers::…` +(e.g. `FOCUS_LOSS_RELOCK_DELAY`). --- -## 3. Application bootstrap +## 3. Bootstrap -The desktop app boots in `src/native/mod.rs` (the kit-native path, -under `#[cfg(feature = "ui")]`). The essentials: +`src/native/mod.rs` boots the UI under `#[cfg(feature = "ui")]`. Three rules: -```rust -let app = gpui::application(); // not Application::with_platform(…) +1. `gpui::application()` factories the app. Don't reach for the legacy + `Application::with_platform(gpui_platform::current_platform(false))`. +2. **Don't override the theme.** No `cx.global_mut::<Theme>().background = …` + calls. The kit-default theme is the surface; light/dark variants + swap through `Theme::change(…)` when wired. +3. **The kit root owns overlays.** No `.children(Root::render_dialog_layer(w, cx))` + in main or sticker render. `gpui_kit::component::init(cx)` is called + once during boot and the plugin model registers dialogs. -app.on_open_urls(...) // single-instance IPC hook - .on_action(Command::new) // global hotkeys via Kit action system - .with_assets(gpui_kit::assets::Assets) - .run_with_launcher(…) -} -``` - -Three things to remember before adding windows: - -1. **Use `gpui::application()`** — the legacy `Application::with_platform(gpui_platform::current_platform(false))` - construct was removed in the migration. Reach for `gpui_kit::application` - instead of re-creating the platform by hand. -2. **Don't override the theme background** at boot. The old forced-brown - `cx.global_mut::<Theme>().background = rgb(0x151104)` is gone; light/dark - theming now flows through Kit. -3. **Don't manually host the dialog layer**. `.children(Root::render_dialog_layer(window, cx))` - was removed from both the manager render and the sticker window render. - `gpui_kit::component::init(cx)` is called once during boot and the - plugin model registers dialogs for you. - -### Transparent surfaces - -For windows whose content paints its own background — e.g., sticker -windows with transparent corners — pass `WindowBackground::Transparent` -when constructing `WindowOptions`. Don't reach for a dark styled -wrapper; the kit sees through and the inner view owns the surface. +Sticker windows need alpha corners -- construct with +`WindowBackgroundAppearance::Transparent`. The inner view owns the colour. --- -## 4. Theme tokens in active use +## 4. Theme tokens -Rustickers currently uses **only the dark theme**. All chrome and -state primitives come from `theme::ActiveTheme` (imported alongside -`use gpui_kit::component::theme::*` in any module that calls -`cx.theme()`). +`cx.theme()` is `gpui_kit::component::theme::ActiveTheme`. The project's +chrome reads only these tokens: -| Token | When | +| Token | Use | | --- | --- | -| `cx.theme().background` | Root manager / selection popup / chip colour backgrounds. | -| `cx.theme().border` | Default 1-px border on cards, inputs, dividers. | -| `cx.theme().muted_foreground` | Secondary copy / placeholder / empty state. | -| `cx.theme().danger` | Error messages, destructive confirmations, expired-run markers. | -| `cx.theme().list_active` | Selected row background. | -| `cx.theme().list_active_border` | Selected row border. | -| `cx.theme().list_hover` | Hovered row background. | -| `cx.theme().transparent` | Inactive transparent surfaces (matches "no decoration"). | - -Anything not in this table should come from the kit's built-in -`Theme` palette — do **not** reintroduce `rgb(0x...)` literals for -chrome concerns. - -### Verification (run after each UI change) +| `background` | Root manager / window surface | +| `border` | Default 1-px border on cards, inputs, dividers | +| `muted_foreground` | Secondary copy, placeholders, empty states | +| `danger` | Error messages, destructive confirmations, expired-run marker | +| `list_active` | Selected row background | +| `list_active_border` | Selected row border | +| `list_hover` | Hovered row background | +| `transparent` | Inactive transparent surface | + +For anything else, use `cx.theme().*` or add the role to the project +theme. No raw `rgb(0x…)` / `rgba(…)` literals for chrome concerns +(see §7 for the four audited exceptions). ```bash -rg 'rgba\(|rgb\(0x\b' src/native/ # should return only audited exceptions -rg 'cursor_pointer' src/native/ # only allowed on real active manipulations -rg 'focus_ring\(false\)' src/native/ # should return nothing — Kit owns the ring -rg '\.bg\(black\(\)' src/native/ # should return nothing — Kit theme background +rg 'rgba\(|rgb\(0x\b' src/native/ # audited exceptions only +rg 'cursor_pointer' src/native/ # only allowed on real active manipulations +rg 'focus_ring\(false\)' src/native/ # must return nothing ``` -The audited exceptions are listed in §7. Outside of those, -CSS-style raw colour literals are a regression. - --- ## 5. Component patterns ### Buttons -Always go through Kit variants. The migration landed these in every -chrome / footer / dialog spot: - ```rust -use crate::gpui_component::button::{Button, ButtonVariants}; // brings .ghost() / .danger() / .primary() in scope - -// Quiet actions: header close, sort chip, footer toolbar, color picker dots -Button::new("close-sticker").ghost().icon(IconName::Close).on_click(…) - -// Destructive confirmation — title is "Delete \"<name>\`?" (object + verb), body explains consequence -Button::new("confirm-delete").danger().label("Delete").on_click(…) - -// Primary progress (rare in this app) -Button::new("commit").primary().label("Save").on_click(…) - -// Segmented selector with explicit selected state (file/audio play-mode toggle) -Button::new("play-mode").selected(self.play_mode == PlayMode::Loop).label("Loop").on_click(…) // requires Selectable trait -``` - -`Selectable` is a trait — the file that calls `.selected(...)` must -`use crate::gpui_component::button::Selectable;` (or -`use gpui_kit::component::button::Selectable;`), not just `ButtonVariants`. -A handful of those imports were missed if you ever pull the bridge. +use crate::gpui_component::button::{Button, ButtonVariants, Selectable}; -**Don't** manually compose transparent-on-transparent buttons: - -```rust -// Anti-pattern — what the migration removed everywhere -Button::new("…") - .bg(transparent_black().alpha(0.0)) - .border_0() - .cursor_pointer() +Button::new("close").ghost().icon(IconName::Close).on_click(…) +Button::new("confirm").danger().label("Forget").on_click(…) // destructive +Button::new("play").selected(self.mode == Mode::Loop).label("Loop").on_click(…) ``` -Compose with `.ghost()` instead. +- `ghost` for chrome (close, sort, footer toolbar, color dots). +- `danger` for irreversible confirmations. +- `selected(...)` for segmented control state -- import `Selectable`, + not just `ButtonVariants`. +- Title is `Forget "<object>"?`, body names the consequence. + `ButtonKind::Warning` is not used here. ### Lists -Long lists go through Kit's `uniform_list`. Both follow the same shape: +`uniform_list` for any long collection. Both the manager and chooser +use it; the closure captures `cx.entity()` and routes through +`view.update`: ```rust -uniform_list("manager-cards", stickers.len(), cx.processor(|this, range, _w, _cx| { - range.filter_map(|ix| this.stickers.get(ix).cloned()) - .map(StickerCard::render) - .collect::<Vec<_>>() -})) +let view = cx.entity(); +uniform_list("manager-stickers", self.stickers.len(), + move |range, _window, cx| view.update(cx, |this, cx| + range.into_iter().filter_map(...) .collect::<Vec<_>>() + ), +) +.size_full() +.track_scroll(&self.list_scroll); ``` -The clipboard chooser (`src/native/windows/clipboard.rs`) already -follows this pattern; copy it as your scaffold and look at §15 for -the **manager itself**, which is the one outstanding list that -still hasn't been virtualised. +Fixed row height is required by `uniform_list`; in this codebase +`ROW_HEIGHT = 80.0` (manager) and `44 px` (chooser rows). -### Inputs and Select-style controls +### Inputs -```rust -use crate::gpui_component::input::{Input, InputState, InputEvent}; +Don't paint the input background, don't disable the focus ring -- +`Input::new(&self.state)` owns the focus ring and the surface. -let search = cx.new(|cx| InputState::new(cx).placeholder("Search stickers")); -cx.subscribe(&search, |this, _, ev: &InputEvent, _cx| { - if let InputEvent::Change = ev { this.query(search.read().value().to_string()); } -}) -.input(...) +```rust +let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search stickers")); +Input::new(&self.query).cleanable(true).border_0().tab_index(0).prefix(Icon::new(IconName::Search)) ``` -Don't paint the input background manually; let the Kit Input own the -focus ring and surface. Adding `.focus_ring(false)` or `.bg(rgba(0x00000000))` -breaks keyboard a11y and was removed everywhere in the migration. - ### Dialogs +Construct via `window.open_dialog(cx, |dialog, _, _| builder)` -- the +dialog has no `.open()`; the helper opens it. + ```rust -use crate::gpui_component::dialog::{Dialog, DialogButtonProps}; - -Dialog::new("delete-confirm") - .title(format!("Delete \"{}\"?", title)) - .child(SharedString::from("This removes the sticker and its current contents. You can paste it from the manager afterwards.")) - .action_props(…) - .button_props(DialogButtonProps::default().ok_label("Delete").kind(ButtonKind::Danger)) - .on_ok(|ok, w, cx| { … }) - .open() +window.open_dialog(cx, move |dialog, _, _| { + dialog + .title(format!("Forget \"{preview}\"?")) + .child(div().text_sm().child(body)) + .footer(h_flex() + .gap_2().justify_end() + .child(Button::new("cancel").label("Cancel").on_click(|_, w, cx| w.close_dialog(cx))) + .child(Button::new("forget").label("Forget").danger() + .on_click(|_, w, cx| { w.close_dialog(cx); /* … spawn delete … */ }))) + .w(px(420.0)); +}); ``` -The `Delete "<title>?"` shape is the project's reference: object + -verb, no guessing. Use `ButtonKind::Danger` for destructive, never -`.warning()`. +### Keyboard ---- +Container focus: `cx.focus_handle().tab_index(n).tab_stop(true)` + +`.track_focus(&handle)`. `on_key_down(cx.listener(…));` to read +`KeyDownEvent` directly, with `cx.stop_propagation()` + +`window.prevent_default()` for handled keys. -## 6. The `StickerColor` palette split +--- -Domain rule: **the model layer holds the serialised identity; the -UI layer holds the actual colour tokens.** Lines stay clean if new -features honor that split. +## 6. `StickerColor` palette split -### Code shape +The model holds identity; the UI holds the surface colours. Keep +categories from drifting. ```rust // src/model/sticker.rs -pub enum StickerColor { … } - impl StickerColor { - pub const ALL: [Self; 5] = [Self::Beige, Self::Linen, Self::Gray, …]; - + pub const ALL: [Self; 5] = [...] pub fn bg(&self) -> Rgba { crate::native::theme::sticker_background(*self) } pub fn swatch(&self) -> Rgba { crate::native::theme::sticker_swatch(*self) } } // src/native/theme.rs -use crate::gpui_component::Rgba; - pub fn sticker_background(c: StickerColor) -> Rgba { match c { … } } pub fn sticker_swatch (c: StickerColor) -> Rgba { match c { … } } ``` -The UI module re-exports `Rgba` so non-UI crate gates can refer to -it without pulling in `gpui` directly. **Do not move the palette into -`model/`.** Doing so would couple storage / CLI to the gpui -presentation type and would invalidate the regression contract. - -### Regression contract - `tests/sticker_palette.rs` pins three properties: 1. Every swatch is bright; every background is dark muted. -2. Every variant in `StickerColor::ALL` produces a unique background - and a unique swatch. +2. `StickerColor::ALL` produces distinct backgrounds and swatches. 3. **`StickerColor::bg() == native::theme::sticker_background(...)`** - and the same for `swatch()`. A drift between model and UI is a - broken contract. - -Run after any palette change: + and the same for `swatch()`. A drift here is a regression. ```bash cargo test --locked --features cli --test sticker_palette ``` -### Future palette promotion (planned) - -When `Theme::change(ThemeMode::Light, …)` is wired in, the -five-value `StickerColor` palette should also become a theme -extension (light-mode sticker surfaces) rather than a fixed dark -constant. The current split (UI-owned but constant-valued) is the -intermediate step. - --- -## 7. Audited exceptions (do **not** "fix") - -These raw colour sites stay by design. If a future contributor -re-writes one of them, they will break a sibling feature they're -not asked about. +## 7. Audited exceptions (do not "fix") -| File | Symbol | Why it's allowed | +| File | Site | Why it stays | | --- | --- | --- | -| `stickers/timer.rs` | `green_500()` progress fill | Semantic success state (active vs. expired run). It's a *value marker*, not chrome. | -| `stickers/paint.rs` | 4× `rgba(…)` stroke fills | Data-domain colours: these are the user-picked drawing colours persisted with the paint sticker. Stroke colour is payload, not chrome. | -| `model/sticker.rs` (historically) | raw `rgb(0x…)` | **Now moved**, not eliminated. Reintroducing them here would re-couple the model to the UI palette. | -| `native/theme.rs` | the entire file | This file *defines* the user surface palette. Its existence is what `#5` and the regression contract rely on. | +| `stickers/timer.rs` | `green_500()` progress fill | Semantic success state (active vs. expired run). | +| `stickers/paint.rs` | 4× stroke `rgba(…)` | Data-domain: user-picked stroke colours persisted with the paint sticker. | +| `native/theme.rs` | the entire file | Defines the surface palette; the test contract depends on it. | -If you genuinely need to change one of these, open a discussion — -the cached test baseline ensures the regression is visible before -merge. +If a feature genuinely needs to change one, open it as a discussion -- +the regression contract will catch a wrong intent early. --- ## 8. Spawn / cancel / poll rules -Async work appears in this app at three distinct surfaces: - -- **Tick loops** owned by attachable state (the timer sticker, the - command scheduler). These run for as long as the work demands and - must tear down cleanly when stopped or replaced. -- **I/O dispatch** issued from event handlers and the foreground - state machine (clipboard usage, file reads, vault handles, - protection/unprotection, deletions). These should finish or be - abandoned on entity drop, never redo work that already succeeded. -- **Watcher thread** that polls the OS clipboard and forwards - eligible captures. Owns its own thread, controlled by a stop flag. - -The relevant invariant for the first two categories is the one -documented in the installed `cx.spawn` API: as long as the calling -entity lives, the closure runs and `update_in` works; once the -entity is dropped, `update_in` returns an error and the loop should -break. +Three categories of async work exist in this app: -### Rule - -> Spawn loops **must** guard their state with a revision tag owned by -> the controller entity, **must** break when `update_in` returns an -> error (which signals entity or window teardown), **must not** be -> started from inside `Render::render`, and **must not** retain old -> `Task` handles alongside new ones for the same role. - -The same revision tag replaces the older "guard boolean" pattern: a -guard only tells you a loop is *running*; a revision tag tells you -whether the controller still cares about this particular loop. Both -serve different purposes; do not collapse them into one field. - -Established pattern in the app's own code: +| Surface | Owner | Cancellation | +| --- | --- | --- | +| Tick loops (timer, command scheduler) | One `tick_revision: u64` on the controller entity | Loop body compares the captured revision against the entity's; bumps supersede. Loop breaks on `entity.update_in(...).is_err()`. | +| I/O dispatch (clipboard, file IO, vault) | Per-event owner; spawn from `cx.listener` | Same `update_in` error break; db transactions handle their own atomicity. | +| Watcher thread | Dedicated OS thread with its own `AtomicBool` stop signal | Not going through `cx.spawn`; control sits in `WatcherHandle::spawn(self, ...).detach()`. | + +The non-negotiables: + +- **Do not spawn from inside `Render::render`** -- the entity doesn't + have a stable revision at that point and a re-render can leak + another instance. Spawn from `new()` or from a state-transition + method like `begin_tick_loop(...)`. +- **Don't conflate revision and guard**. The guard answered *some* + loop is alive. The revision answers *this* loop is still wanted. +- **`cx.spawn_in(window, …)` is not auto-cancellation.** The future + runs until its next `await` regardless of whether `window` or + the entity still exists. Cancellation is by `update_in` returning + `Err` (entity gone) or by a revision tag mismatch (state changed). ```rust -// Spawn from initialization or a state transition, not from render. -let revision = self.tick_revision; -let weak = cx.entity().downgrade(); -cx.spawn_in(window, async move |entity, cx| { +// Revision-tagged tick loop pattern (timer.rs). +let revision = self.tick_loop_revision; +cx.spawn(async move |entity, cx| { loop { - Timer::after(Duration::from_millis(200)).await; - if entity - .update_in(cx, |this, cx| { - if this.tick_revision != revision { return false; } - // Tick work; return false if the work is no longer relevant. - true - }) - .unwrap_or(false) - { - continue; - } - break; // controller moved on or the entity was dropped + let still_wanted = entity.update(cx, |this, _| { + this.tick_loop_revision == revision + }).unwrap_or(false); + if !still_wanted { break; } + // … tick body … } -}) -.detach(); +}).detach(); ``` -State-transition lifecycle helpers on the entity own the revision -counter: - -```rust -fn begin_tick(&mut self, cx: &mut Context<Self>) { - self.tick_revision += 1; - self.start_tick_loop(self.tick_revision, cx); -} -``` - -A cancel-as-stop task -- such as the command scheduler's -`schedule::CancelToken` -- is for cooperative cancellation of work -the controller still believes in but no longer wants to continue. -It is not a substitute for the revision tag. - -### Spawn-vs-spawn_in -- when each applies - -`cx.spawn(...)` returns a task tracked by the app; `cx.spawn_in(window, ...)` -returns a task tracked by that window. Neither cancels the future on -its own when the window closes or the entity is dropped. Choose -based on which scope you want to keep alive: - -- Use `cx.spawn` for background I/O or work that should survive - short-lived windows (CLI flows, detached background spawns). -- Use `cx.spawn_in(window, ...)` when the work belongs to one - window and you want to call `update_in` on a typed handle. - -In both cases the loop has to obey the `update_in`-error rule above. - -### Forbidden patterns - -- Spawning tick loops or I/O directly from `Render::render`. This - makes it impossible to track which revision of the entity - requested the work and leaves dangling tasks on re-render. -- Relying on `spawn_in` alone to "cancel when the window closes." - It does not -- the future runs to its next `await` regardless. - -### Polling loops to remember - -- The clipboard chooser polls capture status at 1 Hz. It is preserved - deliberate because it tolerates stale status and would have to be - reworked behind the watcher's change-push channel. -- The OS clipboard watcher is a separate, dedicated thread with its - own `AtomicBool` stop flag; it is not visible from `cx.spawn` and - is controlled separately at shutdown. +A new state transition (start / pause / resume / restart) calls +`self.supersede_tick_loop()` which bumps the revision; the old +loop exits on its next iteration without explicit cancellation. --- -## 9. Listener / paint patterns +## 9. Listener / paint rules -### Use listeners, not raw mouse callbacks - -`cx.listener` is the standard closure shape; Kit treats it as the -identity of "which row behaves the same way". Pulling the closure -out of `render` is the next refactor for shared row shapes: - -```rust -fn row_listener<E, F>(entity: &Entity<E>, id: i64, handler: F) -> impl Fn(&E, &MouseEvent, &mut Window, &mut App) -where F: Fn(&mut E, i64, &mut Window, &mut App) + 'static { … } -``` - -Apply at any site that currently rebuilds the same listener 5+ -times during a render — manager list, clipboard rows, sticker -chrome are all candidates. - -### Per-paint rules - -- Inputs/buttons/textareas don't need explicit `.bg(…)`. -- A row's hover state goes through `cx.theme().list_hover`, not - a per-element `opacity()`. -- The default cursor is the right default. Only override with - `.cursor_grab()` / `.cursor_pointer()` when the manipulation is - visible: drag-handle title bars and clickable labels are the - two allowed cases. -- Cloning entities is the cheap case; cloning strings is the - expensive case. Prefer `SharedString` (and `render once, cache - by hash` patterns) over `String::clone` in render paths. +- Buttons / inputs / textareas paint themselves. Don't override + `.bg(...)` on them. +- Default cursor is the right default. Override with + `.cursor_grab()` / `.cursor_pointer()` only when the manipulation + is visible: drag-handle title bars and clickable inner labels. +- Sticker cards keep **fixed `ElementId`s** of + `("sticker-row", id as usize)` so hover/selected/focused state + survives re-ordering by the event pump. +- Prefer `SharedString` over `String::clone` in render paths when + the same string is shown by multiple elements. --- -## 10. Privacy invariants (do not regress) +## 10. Privacy invariants -These are checked by the 18-test clipboard_storage suite + the -placement integration test (`tests/clipboard_storage.rs`). Anything -you change in clipboard / vault / editor surface flows must still -pass: +Verified by the 18-test `clipboard_storage` suite and the placement +integration test. Anything that touches clipboard, vault, or +editor flows must keep passing: ``` -$ cargo test --locked --features cli --test clipboard_storage -test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +cargo test --locked --features cli --test clipboard_storage +→ 18 passed ``` -What the suite enforces: +What's enforced: -- **Redacted previews on protected rows** — protected thumbnails - never expose plaintext in any code path that surfaces through - the chooser, vault dialog, or editor body. +- **Redacted previews on protected rows** -- no plaintext field + surfaces through the chooser, vault dialog, or editor body. - **Vault generation checks** before any rewrap, unprotect, or - unprotect-via-edit cycle. The vault refuses to operate on a - generation that doesn't exist. -- **Atomic generation rewrites** — rewrap deletes the old envelope - in the same transaction it writes the new one, even if those - two events would naturally be a `BEGIN/COMMIT` pair. -- **Owned-clipboard clearing** — when the user's edit cancels or - the clipboard gets auto-cleared, the system clipboard is wiped, - not just the in-memory model. -- **Stale-preview avoidance** — the chooser never re-renders a row - whose backing data has already been replaced. - -When adding a new clipboard feature, add a test that fails *before* -your change and passes *after*. If you can't write the test, the -feature probably crosses a privacy boundary and shouldn't ship. + unprotect-via-edit cycle. New envelopes don't overwrite stale + generations. +- **Atomic generation rewrites** -- rewrap deletes the old + envelope in the same transaction it writes the new one. +- **Owned-clipboard clearing** -- on action cancel or auto-clear, + the system clipboard is wiped, not just the in-memory model. +- **Stale-preview avoidance** -- the chooser never re-renders a + row whose backing data has already been replaced. + +Capture filter rules live in `src/native/clipboard/watcher.rs`. The +fail-closed pause on protected history is enforced by the storage +`has_protected_items()` query (single `SELECT EXISTS`); see +`tests/clipboard_storage.rs` for the contract. --- -## 11. Native handle parity - -Rustickers is a desktop app; kit-level code must not regress the -low-level capabilities that make the app feel native. Treat these -as testing invariants checked at integration time: - -- **Transparent sticker corners** — `WindowBackground::Transparent` - on sticker windows; the inner view paints the colour, the kit - renders alpha holes. -- **Per-monitor DPI** — `clipboard_ux_smoke` and the sticker view - scaling must keep producing identical scaled pixel output on - high-DPI screens. Quick validation: open with - `rustickers --flash`. -- **Single-instance IPC** — `ipc::SingleInstance::acquire("rustickers")` - in `src/rustickers.rs`; the secondary process must - `interprocess::connect_and_signal(name)` to the primary and exit. - Probe + ack must close the corpse-socket race on macOS. -- **Global hotkeys** — registered via the - `cx.on_action(Command::new)` pattern during boot. Don't rewrite - a window to register its own hotkey — keep them in the boot path - so the chooser picks up the same hotkey contract. -- **WebView lifecycle** — `wry::WebViewBuilder::new_as_child(window)` - in `native/components/webview.rs`. Don't break the `?` upstream - flow; if you have to handle a build error, surface it as a - fallback preview, never as a panic. -- **Paste-destination restoration** — Rustickers distinguishes the - source window from the destination window before paste. Don't - lose this when refactoring the chooser's `MouseUpEvent`. -- **Color picker takeover** — `.bg(black().opacity(0.9))` was the - forced-brown theme bg; remove only after confirming both the - dark manager surface and the sticker surface paint correctly - under Kit default theme. +## 11. Native-handle parity + +| Capability | Where it lives | +| --- | --- | +| Transparent sticker corners | `WindowBackgroundAppearance::Transparent` in `sticker/open.rs`. | +| Per-monitor DPI | `--flash` UX smoke + native window handles; verify at integration time, not via `cargo test`. | +| Single-instance IPC | `ipc::SingleInstance::acquire("rustickers")` in `src/rustickers.rs`. The secondary process `connect_and_signal`s the primary and exits. | +| Global hotkeys | `src/native/hotkey.rs` dispatcher; routes through `IpcEvent` rather than per-window registration. | +| WebView lifecycle | `SimpleWebView::try_new` is fallible (`Result<Entity<Self>, String>`); the file preview propagates `Err` into `this.error = Some(...)`. | +| Paste-destination restoration | The chooser captures the focused window id at hotkey time and passes it through `use_selected(...)` so `foreground(destination)` lands on the original app. | +| Confirm-then-forget | `remove_selected` in `clipboard.rs` fans out a `confirm_remove(...)` dialog when the row is protected or retained; ordinary disposable rows delete instantly. | --- -## 12. Tests baseline +## 12. Tests, gates, and CI + +Local baseline on a clean tree: ``` cargo test --locked --features cli - 188 lib + 18 storage + 1 placement + 3 palette = 210 passed - + 1 ignored (interactive-terminal smoke) +→ 190 lib + 18 storage + 3 palette + 1 placement = 212 passed + + 1 ignored (interactive) ``` -Visual UI integration tests via `#[gpui_kit::test]` are **deferred**: - -- `gpui-kit`'s `test-support` feature pulls `gpui_web` (wasm) with - `unicode-properties = ^0.1.3` vs. the closure-locked `0.1.4`. The - conflict is real and not a Registry failure; it's a kit-side - preview-build miss. -- Until it's resolved, palette contracts and clipboard storage - tests cover the migration's public boundary. Visual integration - coverage of the editor save/cancel, password dialog, vault - authorize, chooser selection, and paint swatch flows will land - when the upstream `gpui_web` re-bumps. - -When adding tests, prefer: - -1. **Storage tests** for any privacy behaviour change. -2. **Palette tests** for any new `StickerColor` variant or any - change to the UI palette. -3. **UX smoke example** (`examples/clipboard_ux_smoke.rs`) for - isolated visual behaviour that can't be unit-tested. Run via - `cargo run --features ui --example clipboard_ux_smoke`. - ---- - -## 13. CI invariants - -`.github/workflows/ci.yml` runs on every pull request across -**windows-latest**, **ubuntu-latest** (with webkit+xorg apt deps), -and **macos-14**. The gates, in order: +PR CI (`.github/workflows/ci.yml`) runs on `windows-latest`, +`ubuntu-latest` (with webkit + xorg apt deps), and `macos-14`: ```bash cargo fmt --all -- --check @@ -634,171 +378,57 @@ cargo check --locked --bin rustickers cargo check --locked --no-default-features --features storage cargo check --locked --no-default-features --features cli --bin rusticker cargo check --locked --features ui +cargo clippy --locked --features ui cargo test --locked --no-default-features --features cli cargo test --locked --features cli cargo build --locked --features ui --example clipboard_ux_smoke -# Then: a regex-equality check that there's exactly one gpui-pre family in `cargo tree`. -``` - -If a CI run fails, it's almost always one of: - -- **A clippy change in a kit dependency** — bump the kit pin in - `Cargo.toml` and run `cargo update -p gpui-kit` to refresh the - closure. Re-run the whole gate sequence. -- **A duplicate `gpui-pre` family** — open `Cargo.lock`, search for - `[[package]] name = "gpui-pre"`; only one version is allowed. -- **A CRLF/LF formatting churn** — see §14 for the autocrlf - workaround. - ---- - -## 14. Environment quirks (host-specific) - -These are verified quirks on the development host. They'll save -you a few hours if you reproduce them. - -### `cargo fmt` resorting imports - -After every rename, run `cargo fmt`. Kit's bare-prefix matches -want `crate::gpui_component::*` to come first in its own group; -double-check before committing. - -### `USERPROFILE` colon stripping inside `oly start` - -The `oly` bash strips the `:` from `USERPROFILE`, turning -`C:\Users\binwenw` into `C:Usersbinwenw`. Cargo's default -`CARGO_HOME = $USERPROFILE/.cargo` then resolves to a wrong -drive-relative location; **builds fail with cryptic "couldn't read -CARGO_HOME" errors**. - -Mitigation: run cargo from an interactive bash, or export -`CARGO_HOME=C:/Users/<name>/.cargo` explicitly at the start of the -session. - -### Windows artifact lock - -`rustickers.exe` is locked by the live test process. A rebuild -will fail with "Access is denied" while the app is running. Recovery: - -```bash -# 1. Find the offending PID -tasklist | findstr rustickers -# 2. Either close it cleanly, or use an isolated target directory for the rebuild -CARGO_TARGET_DIR=…target-rebuild cargo build --features ui ``` -### `/tmp` paths - -Don't pass `/tmp/...` to the `read` tool on this host — it doesn't -resolve. Use absolute Windows paths -(`C:/Users/<name>/AppData/Local/Temp/...`). +Plus a metalava-like check that `cargo tree --features ui` contains +exactly one `gpui-pre` family. -### Line endings (cosmetic) - -Global `core.autocrlf=true` produces LF→CRLF warnings on every -`git add` on Linux-style files. This is your personal config; the -repo has no opinion. Add a `.gitattributes` if you'd rather normalise. +UI integration tests via `#[gpui_kit::test]` are deferred: +`gpui-kit`'s `test-support` depends on `gpui_web` (`wasm`) whose +`unicode-properties = ^0.1.3` conflicts with the closure-locked +`^0.1.4`. Visual flows are covered by `clipboard_ux_smoke.rs` +until upstream resolves the gap. --- -## 15. Roadmap (post-kit-migration) +## 13. Status of the post-kit work -Material improvements from the post-migration code review, in -priority order. Tackle from top unless your feature intersects. +The plan that came out of the post-kit code review had six phases. +Currently: -| Priority | Item | Why it tops the list | +| Phase | Status | Commit | | --- | --- | --- | -| **P0** | **Timer task ownership** — the timer's tick loop ignores `update_in` failures and resets a guard without cancelling the prior task. Already documented in §8. Multiple concurrent ticks can run for the same timer. | Real reliability bug; a closed window can keep a detached loop running. | -| **P0** | **Manager async correctness** — the loading-guard ducks abandoned requests. Out-of-order completions can clobber fresher state, and successful paths skip `cx.notify()` in places (`main.rs:198`, `:273`, `:348`). | Real correctness bug; fixes are local but the impact is large. | -| **P1** | **Manager virtualization** — `STICKER_LOAD_LIMIT = 10000` is rendered eagerly through `v_flex().children(iter)`. Adopting Kit's variable-height list with stable domain IDs is the next milestone. | Largest single performance and accessibility win; do after the previous two because virtualization exposes their fragility. | -| **P1** | **Recoverable WebView construction** — `SimpleWebView::new` calls `builder.build_as_child(window).unwrap()`. Propagate failures through the file preview's existing error surface so a missing backend never terminates the app. | Real robustness bug, small patch. | -| **P1** | **Clipboard read efficiency** — the watcher reads a full snapshot every 250 ms just to decide whether any protected row exists; the chooser refreshes a snapshot every second. Use a cheap existence query, an in-process change channel, and bounded reconciliation. | Direct impact on idle CPU and SQL contention on slow disks. | -| **P2** | **Targeted ergonomic safeguards** — keyboard discoverability, name-the-consequence wording for irreversible protection changes, light-theme palette variant. | Polished UX, not blockers. | - -Longer-horizon items not in the priority list: - -- Resolve `gpui-kit`'s `test-support` so `#[gpui_kit::test]` UI - integration tests can run in CI. -- Replace `screenshots/demo1.png` after native visual review. -- A tray icon is not planned; the status banner in the manager - conveys "collection paused" already and IPC's running-instance - handshake covers single-rustickers expectations. - -Items the original review recommended that turned out to be -**speculative or already in place** — don't redo them as refactors: - -- `cx.on_window_close` is not a verified GPUI API; cancellation is - by task drop plus the `update_in` error path described in §8. -- A panic hook is already installed in `src/utils/logging.rs`. -- SQLite is already configured with `max_connections(1)`; blanket - `BEGIN IMMEDIATE` would erode read concurrency without evidence. -- The command runner already transports typed `CmdEvent`s over - `mpsc`; no JSON serialization in the loop. -- Markdown saves only on explicit Ctrl+S / save button; it is not an - autosave on every keystroke. -- Listeners and Tables already come with row IDs in their interface; - a generic `row_listener`/`CardSurface` helper over the whole app - is unproven leverage. +| P0: Timer task ownership | Done | `9aeb6a9` | +| P0: Manager async correctness (latest-request policy, supersede delete, owner `open_sticker`) | Done | `7cee743` | +| P1: Manager virtualization + keyboard nav (uniform_list, Tab/Enter/Delete) | Done | `51d6d3b` | +| P1: Recoverable WebView (`SimpleWebView::try_new -> Result<…>`) | Done | `f6cd76e` | +| P1: Clipboard read efficiency (existence query, content-free `WatcherSignal`, 5 s reconciliation) | Done | `eb1b5ec` | +| P2: Confirm-then-forget for protected / retained clipboard rows | Done | `5521df4` | + +Open follow-ups: UI integration tests once `gpui-kit`'s +`test-support` re-bumps; status-banner alignment with Kit elevation; +sticker palette → theme extension when light theming lands; +`screenshots/demo1.png` replacement after a native visual review. --- -## 16. Reference skill catalog - -The local skill snapshots document the kit's own conventions. They -were the authoritative reference during the migration and stay -current with the v0.7.1 source. +## 14. Reference skill catalog ```text -.agents/skills/gpui-kit/ - SKILL.md - references/ - coding-guides.md ← normative Coding Guide - conventions.md ← module / naming posture - recipes.md ← state, lifecycle, async + cancellation recipes - gpui/ ← per-topic references (entity, async, focus, …) -.pi/skills/gpui-kit-design-guides/ - SKILL.md - references/ - design-guides.md ← normative Design Guide -skills-lock.json ← source URL + computed hash per skill +.agents/skills/gpui-kit/ SKILL.md + references/ + (coding-guides, conventions, recipes, gpui/**) +.pi/skills/gpui-kit-design-guides/ SKILL.md + references/design-guides.md +skills-lock.json source URL + computed hash per skill +docs/plans/gpui-kit-upgrade{,-goal}.md (deleted; the takeaways live in §1–§13) +docs/plans/clipboard-{coding,design}-guides.md (deleted; the + takeaways live in §4–§10) ``` -The important takeaways from the migration's audit, milestone log, -post-migration review, and clipboard UX guides have been distilled -into the sections above. When you change a §1–§15 rule, mirror it -back into the matching local skill reference under -`.agents/skills/gpui-kit/reference/` or -`.pi/skills/gpui-kit-design-guides/references/` so future snapshots -of those skills stay current. - ---- - -## 17. Quick checklist for new surface work - -Before you open a PR that touches the desktop UI, walk this list: - -- [ ] Used `cx.theme().*`, not raw `rgb(0x…)` (with the §7 exceptions). -- [ ] Used Kit button variants (`Button::ghost()` / `.danger()` / `.selected(...)`), - not manual `bg(transparent_black)` + `border_0()` compositions. -- [ ] New long lists go through `uniform_list`, not `v_flex().children(…)`. -- [ ] Spawned tasks are owned by initialization or a state transition, - not by `Render::render`; loops break on `update_in` errors and use a - revision tag rather than only a guard boolean (§8). -- [ ] Any clipboard / vault change re-runs `cargo test --features cli - --test clipboard_storage` — **210 passed, 0 failed** must hold. -- [ ] Any palette change re-runs `cargo test --features cli --test - sticker_palette` and (if you add a variant) extends the model↔UI - regression contract. -- [ ] `cargo fmt` is clean; `cargo check --locked --features ui` is - clean; `cargo clippy --locked --features ui` is clean except the - one documented exception at `src/native/components/stickers/file/mod.rs:588` - (replace with `!matches!(...)` before your next round if you touch file/mod.rs). -- [ ] If your change affects a kit-exposed public surface or imports - shape: think about whether to start removing the namespace bridges - (§1). They are now vestigial from the module-level view; they - will eventually come out. -- [ ] If your change supersedes a §2–§15 rule, mirror it back into - the matching entry in `.agents/skills/gpui-kit/reference/` or - `.pi/skills/gpui-kit-design-guides/references/` (see §16). - -If all boxes tick, ship it. +The local skill snapshots are the authoritative reference for kit +conventions during a migration and stay current with the v0.7.1 +source. Read them for full design/coding details; this file +distils the project-specific consequences. diff --git a/README.md b/README.md index 21b16db..005d4e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A lightweight desktop sticker app for notes, timers, command output, and file previews. -Built with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), powered by local SQLite, and designed to stay fast and simple. +Built with [GPUI Kit 0.7.1](https://crates.io/crates/gpui-kit), powered by local SQLite, and designed to stay fast and simple. ![Rustickers demo](./screenshots/demo1.png) @@ -10,10 +10,12 @@ Built with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), - **Markdown notes**: quick editing, preview mode, and **Ctrl+S** save. - **Timers**: countdown timers with an audible alert on completion. -- **Command stickers**: pin command output, with optional cron scheduling, env vars, and working directory. A scheduled sticker keeps running in the background even when its window is closed, and the sticker list shows which ones are running or waiting on their next tick. +- **Command stickers**: pin command output, with optional cron scheduling, env vars, and working directory. A scheduled sticker keeps running in the background even when its window is closed; the manager shows which stickers are running or waiting for their next tick. - **File / URL preview**: preview files, folders, or URLs and pin them as stickers. Any number of previews can be open side by side; asking for the same files again raises the window that already shows them. - **Paint stickers**: freehand drawing space for quick sketches. +All chrome uses the kit theme — there are no raw colour literals in chrome paths. See [`FRONTEND.md`](./FRONTEND.md) for the contributor-side conventions. + ## Clipboard history A keyboard-first history chooser shares exact-text deduplication, search, ordering, and @@ -49,7 +51,8 @@ Open/dismiss the desktop chooser with `Ctrl + Alt + V` (`Cmd + Alt + V` on macOS Type to search, use arrows/Page keys to select, and press Enter to paste; Ctrl/Cmd+Enter copies only. Single-click selects, F2/double-click edits, and Ctrl/Cmd+R toggles reservation. Ctrl/Cmd+L protects the selected item after password authorization; encrypted edits are saved -in place. The chooser uses the existing theme and a virtualized list, with visible actions. +in place. The chooser uses the existing theme and a virtualized list, with visible actions, +and confirms irreversible deletes (pinned, edited, or protected rows) before applying them. Verified automatic paste is currently Windows-only; unavailable or lost destinations fail closed and leave **Copy only** available. @@ -58,20 +61,18 @@ Protected text is not searchable; only an explicitly public alias is visible. CL authorization is scoped to that invocation, **not shared with the desktop process**; `cp vault unlock` verifies a password rather than unlocking subsequent commands. Automatic collection pauses whenever protected history exists until safe protected recapture matching -is implemented. Encryption does not erase copies held by other apps, OS history, backups, -or historical database artifacts; protection hardening is still in progress. GUI deletion and -unprotecting are not available yet; existing protected rows remain retained. +is implemented. -See the [clipboard-history plan](docs/plans/clipboard-history.md) for the contract and -remaining platform/security acceptance work. +> Encryption does not erase copies held by other apps, the OS clipboard history, backups, +> or pre-encryption database artifacts. Vault grace periods, audit trails, and rotation +> are still tracked as hardening work. ## Global hotkeys -- **Show main window**: `Ctrl + Alt + R` (macOS also supports `Cmd + Alt + R`) -- **Toggle clipboard chooser**: `Ctrl + Alt + V` (macOS `Cmd + Alt + V`) — paste back into the - previously focused app -- **Toggle quick file preview**: `Ctrl/Cmd + Alt` -- **Open command for selected text**: `Ctrl/Cmd + Space` (consumed instead of forwarded to the active app) +- **Show main window**: `Ctrl + Alt + R` (`Cmd + Alt + R` on macOS) +- **Toggle clipboard chooser**: `Ctrl + Alt + V` (`Cmd + Alt + V` on macOS) — pastes back into the previously focused app +- **Toggle quick file preview**: `Ctrl + Alt` (modifier-only chord) +- **Open command for selected text**: `Ctrl/Cmd + Space` (consumed; not forwarded to the active app) - **Dismiss transient Rustickers windows**: `Esc` is consumed for selection popups, quick previews, the clipboard chooser, and selected-text command runs - **Save in editor/markdown views**: `Ctrl + S` @@ -79,21 +80,9 @@ remaining platform/security acceptance work. Prerequisite: stable Rust toolchain. -Run the desktop app: - -```bash -cargo run -``` - -Run the CLI: - ```bash +cargo run # desktop app cargo run --bin rusticker --no-default-features --features cli -- --help -``` - -Build release binaries: - -```bash cargo build --release --bin rustickers cargo build --release --bin rusticker --no-default-features --features cli ``` @@ -126,30 +115,30 @@ options are forwarded. ### Command stickers -`rusticker cmd` exposes everything a command sticker can do: - ```bash rusticker cmd "cargo test" --result text --dir . # run and show plain output rusticker cmd "npm test 2>&1 | tail -20" --shell # use a shell for pipes and redirection -rusticker cmd "gh pr list" --cron "0 */5 * * * *" # re-run every five minutes +rusticker cmd "gh pr list" --cron "0 */5 * * *" # re-run every five minutes rusticker cmd "copilot -p {{RUSTICKERS_SELECTION}}" \ --accept-selection --result markdown --closed # answer whatever text you have selected ``` -Output can be rendered five ways with `--result`: `text`, `markdown`, `html` (an embedded browser -view), `svg` (an image) or `source` (the output is one file path or URL to preview). Other useful -flags are `--stream`, `--no-window`, `--auto-close`, `--padding`, `--env KEY=VALUE` and `--idle`. +`--result` controls how the captured output is rendered: `text`, `markdown`, `html` +(an embedded browser view), `svg` (an image), or `source` (the output is one file path or +URL to preview). Other useful flags are `--stream`, `--no-window`, `--auto-close`, +`--padding`, `--env KEY=VALUE` and `--idle`. -Two things are worth knowing. A command sticker does **not** run through a shell by default: the -string is split with Windows argument rules and the program is looked up on `PATH`, so pipes and -`&&` are literal text until you add `--shell`. And a sticker created without `--idle` is *armed*, -meaning it runs when its window opens and again each time the app starts. +Two things are worth knowing. A command sticker does **not** run through a shell by default: +the string is split with Windows argument rules and the program is looked up on `PATH`, +so pipes and `&&` are literal text until you add `--shell`. A sticker created without +`--idle` is *armed*, meaning it runs when its window opens and again each time the app starts. -An armed sticker with `--cron` also keeps running with its window **closed**, for as long as the -app is running, so `rusticker result <id>` always has the latest output waiting. Combine it with -`--no-window` for a schedule that never puts anything on screen. In the sticker list, a spinner -marks a command that is running right now and a loop icon marks one waiting for its next tick. -Press Stop in the sticker to disarm it, or delete the sticker to stop it for good. +An armed sticker with `--cron` also keeps running with its window **closed**, for as long +as the app is running. Combine it with `--no-window` for a schedule that never puts +anything on screen, and read the result over `rusticker result <id>` from anywhere. +In the manager, a spinner marks a command that is running right now and a loop icon marks +one waiting for its next tick. Press Stop on the sticker to disarm it, or delete the +sticker to stop it for good. ### Machine-readable output @@ -157,16 +146,16 @@ Add `--json` to any command to get exactly one JSON object on stdout, with an `o success and failure parse the same way: ```bash -rusticker cmd "git status --short" --dir . --json # -> {"ok":true,"id":42,...} -rusticker result 42 --json # -> {"ok":true,"output":"...","has_run":true} +rusticker cmd "git status --short" --dir . --json # → {"ok":true,"id":42,...} +rusticker result 42 --json # → {"ok":true,"output":"...","has_run":true} ``` ### Skills `rusticker skill list` is a catalogue of ready-made stickers — selection-driven AI prompts, -scheduled watchers, HTML and SVG reports. `skill show <name>` explains one and prints the exact -`rusticker` command it is equivalent to, and `skill run <name> --var key=value` creates it. -`--dry-run` prints the command without creating anything. +scheduled watchers, HTML and SVG reports. `skill show <name>` explains one and prints the +exact `rusticker` command it is equivalent to, and `skill run <name> --var key=value` creates +it. `--dry-run` prints the command without creating anything. ## Data and logs @@ -184,6 +173,34 @@ Log level precedence: 2. `RUST_LOG` 3. fallback: `trace` (debug) / `info` (release) +## Testing gates + +```bash +cargo fmt --all -- --check +cargo check --locked --features ui +cargo clippy --locked --features ui +cargo test --locked --features cli # 212 passed, 1 ignored +cargo build --locked --features ui --example clipboard_ux_smoke +``` + +CI also runs the same gates across Windows, Ubuntu, and macOS, plus a parity check that +the closure contains exactly one `gpui-pre` family. + +## Project layout + +``` +ARCHITECTURE.md system architecture, store/IPC contracts +FRONTEND.md contributor handbook (kit tokens, spawn rules, palette) +src/ crate source +tests/ integration tests (clipboard_storage, sticker_palette) +examples/ visual smoke harnesses +.github/workflows/ CI: fmt, clippy, multi-feature cargo check/test, multi-OS build +``` + +For the desktop framework, theme, and event-loop rules, see +[`FRONTEND.md`](./FRONTEND.md). For overall system architecture and storage +contracts, see [`ARCHITECTURE.md`](./ARCHITECTURE.md). + ## Releases Tagging (for example `v0.1.0`) triggers GitHub Actions release artifacts for Windows and macOS (UI + CLI bundles). From 69ae75bc411438ddd6521c224175365e86bf3228 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 22:16:01 +0800 Subject: [PATCH 23/69] docs(frontend): reframe as knowledge base; drop status / roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per request: FRONTEND.md is now an agent-readable knowledge base for the codebase, not a roadmap or checklist. The doc's role is to encode the *current* state of the desktop layer so a coding agent has the kit tokens, component patterns, spawn rules, palette contract, and privacy invariants it needs to write correct code. Changes: - Title: 'Rustickers frontend handbook' -> 'Rustickers frontend knowledge'. Intro paragraph now states explicitly: * 'This is NOT a roadmap, plan, or checklist. It encodes the current state of the codebase.' * 'For change history, run `git log --oneline`.' - § 13 'Status of the post-kit work' (the six-phase Done rows with commit SHAs + open follow-ups list) is removed. The phase log is in `git log`; the follow-ups belong in issues, not the knowledge file. - § 14 'Reference skill catalog' is renamed from 'Status of the post-kit work'. The catalogue itself stays: the local skill trees under `.agents/skills/` and `.pi/skills/` are the authoritative reference; this file distils what is project-specific. - Ids `→ §1–§13` in the catalogue entry rewritten to point to README / ARCHITECTURE and the on-disk skill trees rather than numbered sections here, since the section numbering will drift as the file evolves. - Final paragraph of § 12 rewritten to remove the 'until upstream resolves the gap' phrasing, which read as a forward-looking blocker. Replaced with the literal current state: visual flows are covered by `clipboard_ux_smoke.rs`. Verified locally: - `cargo fmt --all -- --check` clean. - `cargo test --locked --features cli` -> 212 passed, 1 ignored (matches the § 12 baseline). - `cargo check --locked --features ui` clean. - No mention of Done, Phase, follow-up, planned, or eventually remains. Length: 434 -> 417 lines. Same source-backed content; the diff is purely removal. --- FRONTEND.md | 55 ++++++++++++++++++----------------------------------- 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/FRONTEND.md b/FRONTEND.md index 1a049e4..7483bf8 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -1,9 +1,14 @@ -# Rustickers frontend handbook - -> First stop for any change that touches the desktop UI, theme, -> sticker rendering, clipboard surface, or window code. `README.md` -> tells you what the app does and how to run it; this file tells you -> how it is built. +# Rustickers frontend knowledge + +> Knowledge base for AI coding agents and human contributors working +> on the desktop layer: theme tokens, component patterns, async +> ownership rules, palette contracts, and the privacy invariants the +> clipboard layer enforces. `README.md` describes *what* the app +> does and *how to run* it; this file describes *how it is built*. +> +> This is **not** a roadmap, plan, or checklist. It encodes the +> current state of the codebase. For change history, run +> `git log --oneline`. --- @@ -390,45 +395,23 @@ exactly one `gpui-pre` family. UI integration tests via `#[gpui_kit::test]` are deferred: `gpui-kit`'s `test-support` depends on `gpui_web` (`wasm`) whose `unicode-properties = ^0.1.3` conflicts with the closure-locked -`^0.1.4`. Visual flows are covered by `clipboard_ux_smoke.rs` -until upstream resolves the gap. - ---- - -## 13. Status of the post-kit work - -The plan that came out of the post-kit code review had six phases. -Currently: - -| Phase | Status | Commit | -| --- | --- | --- | -| P0: Timer task ownership | Done | `9aeb6a9` | -| P0: Manager async correctness (latest-request policy, supersede delete, owner `open_sticker`) | Done | `7cee743` | -| P1: Manager virtualization + keyboard nav (uniform_list, Tab/Enter/Delete) | Done | `51d6d3b` | -| P1: Recoverable WebView (`SimpleWebView::try_new -> Result<…>`) | Done | `f6cd76e` | -| P1: Clipboard read efficiency (existence query, content-free `WatcherSignal`, 5 s reconciliation) | Done | `eb1b5ec` | -| P2: Confirm-then-forget for protected / retained clipboard rows | Done | `5521df4` | - -Open follow-ups: UI integration tests once `gpui-kit`'s -`test-support` re-bumps; status-banner alignment with Kit elevation; -sticker palette → theme extension when light theming lands; -`screenshots/demo1.png` replacement after a native visual review. +`^0.1.4`. Visual flows are covered by `clipboard_ux_smoke.rs`. --- -## 14. Reference skill catalog +## 13. Reference skill catalog ```text .agents/skills/gpui-kit/ SKILL.md + references/ (coding-guides, conventions, recipes, gpui/**) .pi/skills/gpui-kit-design-guides/ SKILL.md + references/design-guides.md skills-lock.json source URL + computed hash per skill -docs/plans/gpui-kit-upgrade{,-goal}.md (deleted; the takeaways live in §1–§13) -docs/plans/clipboard-{coding,design}-guides.md (deleted; the - takeaways live in §4–§10) +docs/plans/gpui-kit-upgrade{,-goal}.md (deleted; absorbed by this doc) +docs/plans/clipboard-{coding,design}-guides.md (deleted; absorbed by this doc) +README.md, ARCHITECTURE.md read these for what the app does and store/IPC contracts ``` The local skill snapshots are the authoritative reference for kit -conventions during a migration and stay current with the v0.7.1 -source. Read them for full design/coding details; this file -distils the project-specific consequences. +conventions. They stay current with the v0.7.1 source. Read them +for full design/coding details; this file distils the +project-specific consequences. From bc31893cd96cd58f768ec979fa29aaba9ca65354 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Tue, 6 Oct 2026 22:25:26 +0800 Subject: [PATCH 24/69] feat(cli): rename CLI binary from rusticker to rst Rename the [[bin]] target and src/rusticker.rs -> src/rst.rs, and update all help text, usage examples, skill invocations, and docs (README, ARCHITECTURE, FRONTEND) to the shorter `rst` command name. The `rustickers` crate and desktop binary are unchanged. --- ARCHITECTURE.md | 12 ++++---- Cargo.toml | 4 +-- FRONTEND.md | 2 +- README.md | 50 ++++++++++++++++----------------- src/cli/commands/cmd.rs | 10 +++---- src/cli/commands/cp.rs | 8 +++--- src/cli/commands/delete.rs | 4 +-- src/cli/commands/list.rs | 4 +-- src/cli/commands/markdown.rs | 2 +- src/cli/commands/result.rs | 6 ++-- src/cli/commands/show.rs | 4 +-- src/cli/commands/skill.rs | 12 ++++---- src/cli/commands/view.rs | 2 +- src/cli/commands/window.rs | 4 +-- src/cli/draft.rs | 2 +- src/cli/mod.rs | 26 ++++++++--------- src/cli/skills.rs | 12 ++++---- src/clipboard/argv.rs | 6 ++-- src/native/windows/clipboard.rs | 2 +- src/{rusticker.rs => rst.rs} | 4 +-- 20 files changed, 88 insertions(+), 88 deletions(-) rename src/{rusticker.rs => rst.rs} (92%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 187b7b0..046ef9c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,7 +71,7 @@ rustickers/ ├── src/ │ ├── lib.rs # Library root, module declarations │ ├── rustickers.rs # GUI application entry point -│ ├── rusticker.rs # CLI application entry point +│ ├── rst.rs # CLI application entry point │ ├── ipc.rs # Inter-process communication │ ├── cli/ # CLI commands │ ├── model/ # Data models @@ -104,7 +104,7 @@ cli = ["dep:clap", "dep:futures", "dep:sqlx"] | Binary | Path | Features | Description | |--------|------|----------|-------------| | `rustickers` | `src/rustickers.rs` | `ui` | Full GUI application | -| `rusticker` | `src/rusticker.rs` | `cli` | CLI-only tool | +| `rst` | `src/rst.rs` | `cli` | CLI-only tool | ### Release Optimization @@ -150,7 +150,7 @@ panic = "abort" # No stack unwinding 5. Start global hotkey listener 6. Run GPUI native loop -#### CLI Application (`src/rusticker.rs`) +#### CLI Application (`src/rst.rs`) ```rust fn main() { @@ -461,7 +461,7 @@ touches one new file plus two lines in `mod.rs`. | `runtime.rs` | Opening the store and talking to the running app. `Delivery` distinguishes "sent over IPC" from "the app is not running". | | `draft.rs` | The single sticker-creation path: the shared `--width/--height/--left/--top/--color/--top-most/--closed` group, insertion, and the "created" report. | | `shell.rs` | `--shell` wrapping and fail-fast validation of a command string, its program and its cron expression. | -| `skills.rs` | The static skill catalogue and the expansion of a skill into real `rusticker` arguments. | +| `skills.rs` | The static skill catalogue and the expansion of a skill into real `rst` arguments. | #### CLI Flow @@ -497,7 +497,7 @@ triggered by the hotkey, which sets its own start time. *An armed cron schedule keeps running with the sticker closed.* `command/background.rs` polls the store for stickers that are armed and have a cron expression, fires the ones that are due, and writes the output back into the sticker's content. It does not care about `StickerState`, which is -what makes a closed sticker keep ticking; `rusticker result <id>` then reads the latest run. +what makes a closed sticker keep ticking; `rst result <id>` then reads the latest run. The open window still drives its own schedule, because only it can stream output into the view. The two are kept apart by `command/activity.rs`: a sticker window claims its sticker for as long as it @@ -800,7 +800,7 @@ Sticker content stored as JSON strings, allowing type-specific data without sche ``` 2. **Declare it** in `src/cli/commands/mod.rs`, and add a variant plus a dispatch arm in - `src/cli/mod.rs`. Nothing else needs updating: the `rusticker <file>` shorthand and the help + `src/cli/mod.rs`. Nothing else needs updating: the `rst <file>` shorthand and the help text both read the subcommand list from the parser. 3. **Consider a skill.** If the command has a use worth demonstrating, add an entry to diff --git a/Cargo.toml b/Cargo.toml index 872c944..8d8d062 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,8 +89,8 @@ path = "src/rustickers.rs" required-features = ["ui"] [[bin]] -name = "rusticker" -path = "src/rusticker.rs" +name = "rst" +path = "src/rst.rs" required-features = ["cli"] [[example]] diff --git a/FRONTEND.md b/FRONTEND.md index 7483bf8..89716c8 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -381,7 +381,7 @@ PR CI (`.github/workflows/ci.yml`) runs on `windows-latest`, cargo fmt --all -- --check cargo check --locked --bin rustickers cargo check --locked --no-default-features --features storage -cargo check --locked --no-default-features --features cli --bin rusticker +cargo check --locked --no-default-features --features cli --bin rst cargo check --locked --features ui cargo clippy --locked --features ui cargo test --locked --no-default-features --features cli diff --git a/README.md b/README.md index 005d4e9..80efc37 100644 --- a/README.md +++ b/README.md @@ -24,19 +24,19 @@ edited, and protected items are retained independently. Collection starts **paus copying a password does not silently opt you into recording it. ```bash -rusticker cp ls # browse: type, ↑/↓, Enter, Esc -rusticker cp ls -s rust # start with a filter -rusticker cp ls --list -n 20 # readable recent-history table -rusticker cp ls --list --json # one machine-readable JSON object -rusticker cp ls -- rg -F -- '{{cp}}' . # pick and substitute into argv -rusticker cp ls --id 42 -- my-tool # append the exact text as one argument -rusticker cp import 'text from a script' -rusticker cp import --stdin # read a pipeline without trimming it -rusticker cp show --id 42 # exact text, no extra newline -rusticker cp forget 42 # release ordinary saved/edit retention -rusticker cp vault create # hidden master-password setup -rusticker cp import --protect --stdin # authorize, encrypt, then store piped text -rusticker cp vault status +rst cp ls # browse: type, ↑/↓, Enter, Esc +rst cp ls -s rust # start with a filter +rst cp ls --list -n 20 # readable recent-history table +rst cp ls --list --json # one machine-readable JSON object +rst cp ls -- rg -F -- '{{cp}}' . # pick and substitute into argv +rst cp ls --id 42 -- my-tool # append the exact text as one argument +rst cp import 'text from a script' +rst cp import --stdin # read a pipeline without trimming it +rst cp show --id 42 # exact text, no extra newline +rst cp forget 42 # release ordinary saved/edit retention +rst cp vault create # hidden master-password setup +rst cp import --protect --stdin # authorize, encrypt, then store piped text +rst cp vault status ``` Quote `'{{cp}}'` in PowerShell/POSIX shells, or `"{{cp}}"` in Windows cmd. The CLI @@ -82,16 +82,16 @@ Prerequisite: stable Rust toolchain. ```bash cargo run # desktop app -cargo run --bin rusticker --no-default-features --features cli -- --help +cargo run --bin rst --no-default-features --features cli -- --help cargo build --release --bin rustickers -cargo build --release --bin rusticker --no-default-features --features cli +cargo build --release --bin rst --no-default-features --features cli ``` Outputs are written to `target/release/`. ## CLI commands -`rusticker` is the scripting surface for the app. It writes to the same database the app reads, so +`rst` is the scripting surface for the app. It writes to the same database the app reads, so changes apply immediately when the app is running and on next launch when it is not. | Command | What it does | @@ -116,10 +116,10 @@ options are forwarded. ### Command stickers ```bash -rusticker cmd "cargo test" --result text --dir . # run and show plain output -rusticker cmd "npm test 2>&1 | tail -20" --shell # use a shell for pipes and redirection -rusticker cmd "gh pr list" --cron "0 */5 * * *" # re-run every five minutes -rusticker cmd "copilot -p {{RUSTICKERS_SELECTION}}" \ +rst cmd "cargo test" --result text --dir . # run and show plain output +rst cmd "npm test 2>&1 | tail -20" --shell # use a shell for pipes and redirection +rst cmd "gh pr list" --cron "0 */5 * * *" # re-run every five minutes +rst cmd "copilot -p {{RUSTICKERS_SELECTION}}" \ --accept-selection --result markdown --closed # answer whatever text you have selected ``` @@ -135,7 +135,7 @@ so pipes and `&&` are literal text until you add `--shell`. A sticker created wi An armed sticker with `--cron` also keeps running with its window **closed**, for as long as the app is running. Combine it with `--no-window` for a schedule that never puts -anything on screen, and read the result over `rusticker result <id>` from anywhere. +anything on screen, and read the result over `rst result <id>` from anywhere. In the manager, a spinner marks a command that is running right now and a loop icon marks one waiting for its next tick. Press Stop on the sticker to disarm it, or delete the sticker to stop it for good. @@ -146,15 +146,15 @@ Add `--json` to any command to get exactly one JSON object on stdout, with an `o success and failure parse the same way: ```bash -rusticker cmd "git status --short" --dir . --json # → {"ok":true,"id":42,...} -rusticker result 42 --json # → {"ok":true,"output":"...","has_run":true} +rst cmd "git status --short" --dir . --json # → {"ok":true,"id":42,...} +rst result 42 --json # → {"ok":true,"output":"...","has_run":true} ``` ### Skills -`rusticker skill list` is a catalogue of ready-made stickers — selection-driven AI prompts, +`rst skill list` is a catalogue of ready-made stickers — selection-driven AI prompts, scheduled watchers, HTML and SVG reports. `skill show <name>` explains one and prints the -exact `rusticker` command it is equivalent to, and `skill run <name> --var key=value` creates +exact `rst` command it is equivalent to, and `skill run <name> --var key=value` creates it. `--dry-run` prints the command without creating anything. ## Data and logs diff --git a/src/cli/commands/cmd.rs b/src/cli/commands/cmd.rs index c6c81bc..cba1f03 100644 --- a/src/cli/commands/cmd.rs +++ b/src/cli/commands/cmd.rs @@ -1,4 +1,4 @@ -//! `rusticker cmd` — create a command sticker. +//! `rst cmd` — create a command sticker. //! //! This is the command with the most surface, because a command sticker is the most configurable //! thing Rustickers has: it can render its output five different ways, run on a schedule, run @@ -98,7 +98,7 @@ pub struct CmdArgs { /// five minutes. /// /// The schedule keeps running for as long as the app is, whether or not the sticker's window - /// is open, so `rusticker result` always has the latest run waiting. + /// is open, so `rst result` always has the latest run waiting. #[arg(long, value_name = "EXPR", help_heading = "Behaviour")] pub cron: Option<String>, @@ -113,7 +113,7 @@ pub struct CmdArgs { /// Create the sticker stopped, so nothing runs until you press Run /// /// Without this the sticker is armed: it runs when its window opens, and again each time the - /// app starts, which is what makes `rusticker result` useful. + /// app starts, which is what makes `rst result` useful. #[arg(long, help_heading = "Behaviour")] pub idle: bool, @@ -233,7 +233,7 @@ fn collect_warnings( } if cron.is_some() && !args.idle { warnings.push( - "a scheduled sticker waits for its first tick, so `rusticker result` stays empty until \ + "a scheduled sticker waits for its first tick, so `rst result` stays empty until \ then." .to_owned(), ); @@ -271,7 +271,7 @@ fn collect_warnings( } if args.idle { warnings.push( - "--idle means nothing runs yet, so `rusticker result` will stay empty until you press \ + "--idle means nothing runs yet, so `rst result` will stay empty until you press \ Run on the sticker." .to_owned(), ); diff --git a/src/cli/commands/cp.rs b/src/cli/commands/cp.rs index 42bd3d7..95dd28f 100644 --- a/src/cli/commands/cp.rs +++ b/src/cli/commands/cp.rs @@ -46,7 +46,7 @@ pub enum CpCommand { #[derive(clap::Args, Debug)] #[command( about = "Browse and reuse previous copies", - after_help = "Examples:\n rusticker cp ls browse (↑↓, Enter, Esc)\n rusticker cp ls -s rust start with a search\n rusticker cp ls --list -n 20 print recent history\n rusticker cp ls --json --list machine-readable history\n rusticker cp ls -- rg -F -- '{{cp}}' . select and run\n rusticker cp ls --id 42 -- my-tool append one exact argument\n\nQuote '{{cp}}' in POSIX shells and PowerShell; use \"{{cp}}\" in cmd.exe.\nClipboard text stays data; a shell/interpreter code argument can still execute it.\nChooser/password input uses the terminal, leaving child stdin and stdout untouched.\nExit codes: 130 cancelled, 127 program missing, 126 not executable, otherwise child status." + after_help = "Examples:\n rst cp ls browse (↑↓, Enter, Esc)\n rst cp ls -s rust start with a search\n rst cp ls --list -n 20 print recent history\n rst cp ls --json --list machine-readable history\n rst cp ls -- rg -F -- '{{cp}}' . select and run\n rst cp ls --id 42 -- my-tool append one exact argument\n\nQuote '{{cp}}' in POSIX shells and PowerShell; use \"{{cp}}\" in cmd.exe.\nClipboard text stays data; a shell/interpreter code argument can still execute it.\nChooser/password input uses the terminal, leaving child stdin and stdout untouched.\nExit codes: 130 cancelled, 127 program missing, 126 not executable, otherwise child status." )] pub struct CpArgs { /// Print a compact table (or one JSON object with --json), without choosing. @@ -118,7 +118,7 @@ fn require_password(prompt: &str) -> Result<Zeroizing<String>> { fn unlock(controller: &VaultController) -> Result<()> { if block_on(controller.envelope())?.is_none() { - bail!("no master password configured; run `rusticker cp vault create` first"); + bail!("no master password configured; run `rst cp vault create` first"); } let password = require_password("Master password: ")?; if !block_on(controller.unlock(&password))? { @@ -163,7 +163,7 @@ pub fn run(paths: &AppPaths, args: CpArgs, format: Format) -> Result<()> { } else { if snapshot.is_empty() { bail!( - "no clipboard history yet; enable collection in the desktop app or use `rusticker cp import <TEXT>`" + "no clipboard history yet; enable collection in the desktop app or use `rst cp import <TEXT>`" ); } choose(&snapshot, args.search.as_deref().unwrap_or(""))?.ok_or(ExitCode(130))? @@ -173,7 +173,7 @@ pub fn run(paths: &AppPaths, args: CpArgs, format: Format) -> Result<()> { .with_context(|| format!("clipboard row {id} is no longer available"))?; if args.command.is_empty() { format.emit(json!({"id": id, "kind": kind(&row), "hint": "pass -- <PROGRAM> <ARGS> to reuse this item"}), || { - println!("Selected #{id}. To reuse it: rusticker cp ls --id {id} -- <PROGRAM> '{{{{cp}}}}'"); + println!("Selected #{id}. To reuse it: rst cp ls --id {id} -- <PROGRAM> '{{{{cp}}}}'"); }); return Ok(()); } diff --git a/src/cli/commands/delete.rs b/src/cli/commands/delete.rs index 169fc51..c653bbd 100644 --- a/src/cli/commands/delete.rs +++ b/src/cli/commands/delete.rs @@ -1,4 +1,4 @@ -//! `rusticker delete` — remove a sticker for good. +//! `rst delete` — remove a sticker for good. use anyhow::bail; use serde_json::json; @@ -13,7 +13,7 @@ use super::list::type_name; #[derive(clap::Args, Debug)] pub struct DeleteArgs { - /// Sticker ID, as listed by `rusticker list` + /// Sticker ID, as listed by `rst list` pub id: i64, /// Delete without asking for confirmation diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs index 9a6e488..a912bb3 100644 --- a/src/cli/commands/list.rs +++ b/src/cli/commands/list.rs @@ -1,4 +1,4 @@ -//! `rusticker list` — find stickers. +//! `rst list` — find stickers. use clap::ValueEnum; use serde_json::json; @@ -119,7 +119,7 @@ pub fn run(app_paths: &AppPaths, args: ListArgs, format: Format) -> anyhow::Resu if stickers.is_empty() { if args.state == StateFilter::Open && args.search.is_none() { println!( - "No open stickers. Try `rusticker list --state all`, or `rusticker skill \ + "No open stickers. Try `rst list --state all`, or `rst skill \ list` for ready-made ones." ); } else { diff --git a/src/cli/commands/markdown.rs b/src/cli/commands/markdown.rs index d9916c3..854e9c3 100644 --- a/src/cli/commands/markdown.rs +++ b/src/cli/commands/markdown.rs @@ -1,4 +1,4 @@ -//! `rusticker markdown` — create a markdown note sticker. +//! `rst markdown` — create a markdown note sticker. use anyhow::{Context as _, bail}; use serde_json::json; diff --git a/src/cli/commands/result.rs b/src/cli/commands/result.rs index c633bfd..ab66aa7 100644 --- a/src/cli/commands/result.rs +++ b/src/cli/commands/result.rs @@ -1,4 +1,4 @@ -//! `rusticker result` — read back what a command sticker last produced. +//! `rst result` — read back what a command sticker last produced. //! //! A command sticker saves its output to the database every time a run finishes, which makes it a //! usable one-way channel: create a sticker with `--no-window`, let the app run it, then collect @@ -58,7 +58,7 @@ pub fn run(app_paths: &AppPaths, args: ResultArgs, format: Format) -> anyhow::Re "updated_at": sticker.updated_at, }), || match output { - // Bare output on stdout, so `rusticker result 12 > report.md` does the obvious thing. + // Bare output on stdout, so `rst result 12 > report.md` does the obvious thing. Some(output) => print!("{output}"), None if content.started_at.is_some() => { eprintln!( @@ -68,7 +68,7 @@ pub fn run(app_paths: &AppPaths, args: ResultArgs, format: Format) -> anyhow::Re ); } None => eprintln!( - "Sticker {} has not run yet. Open it with `rusticker open {}` to run it.", + "Sticker {} has not run yet. Open it with `rst open {}` to run it.", sticker.id, sticker.id ), }, diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs index ec4f579..f430c36 100644 --- a/src/cli/commands/show.rs +++ b/src/cli/commands/show.rs @@ -1,4 +1,4 @@ -//! `rusticker show` — everything stored about one sticker. +//! `rst show` — everything stored about one sticker. use serde_json::{Value, json}; @@ -12,7 +12,7 @@ use super::list::{state_name, type_name}; #[derive(clap::Args, Debug)] pub struct ShowArgs { - /// Sticker ID, as listed by `rusticker list` + /// Sticker ID, as listed by `rst list` pub id: i64, /// Print only the stored content, with no surrounding report diff --git a/src/cli/commands/skill.rs b/src/cli/commands/skill.rs index 0373bc4..1af3fcd 100644 --- a/src/cli/commands/skill.rs +++ b/src/cli/commands/skill.rs @@ -1,4 +1,4 @@ -//! `rusticker skill` — browse and run the worked examples. +//! `rst skill` — browse and run the worked examples. use anyhow::bail; use serde_json::json; @@ -22,13 +22,13 @@ pub enum SkillAction { /// Explain one skill, its variables and the command it stands for Show { - /// Skill name, as listed by `rusticker skill list` + /// Skill name, as listed by `rst skill list` name: String, }, /// Create the sticker a skill describes Run { - /// Skill name, as listed by `rusticker skill list` + /// Skill name, as listed by `rst skill list` name: String, /// Override one of the skill's variables, repeatable @@ -85,7 +85,7 @@ fn list(format: Format) { .collect(); table(&["SKILL", "WHAT IT DOES"], &rows); println!( - "\n`rusticker skill show <name>` explains one; `rusticker skill run <name>` \ + "\n`rst skill show <name>` explains one; `rst skill run <name>` \ creates it." ); }, @@ -136,7 +136,7 @@ fn show(name: &str, format: Format) -> anyhow::Result<()> { println!("Equivalent command:"); println!(" {}", skills::command_line(&expanded)); println!("\nRun it with:"); - println!(" rusticker skill run {}", skill.name); + println!(" rst skill run {}", skill.name); }); Ok(()) @@ -171,7 +171,7 @@ fn run_skill( // Feeding the expansion back through the ordinary parser is what keeps a skill honest: it can // only do things the CLI can already do, and it does them exactly as `skill show` advertised. - let mut argv = vec!["rusticker".to_owned()]; + let mut argv = vec!["rst".to_owned()]; if format.is_json() { argv.push("--json".to_owned()); } diff --git a/src/cli/commands/view.rs b/src/cli/commands/view.rs index d804ae4..9cfd04f 100644 --- a/src/cli/commands/view.rs +++ b/src/cli/commands/view.rs @@ -1,4 +1,4 @@ -//! `rusticker view` — preview a file, folder or URL. +//! `rst view` — preview a file, folder or URL. //! //! Unlike the other create commands this one does not write to the database. The preview is a //! live request to the running app, which decides how to render the source, so there is nothing diff --git a/src/cli/commands/window.rs b/src/cli/commands/window.rs index f61f153..8e390ee 100644 --- a/src/cli/commands/window.rs +++ b/src/cli/commands/window.rs @@ -1,4 +1,4 @@ -//! `rusticker open` and `rusticker close` — show or hide a sticker's window. +//! `rst open` and `rst close` — show or hide a sticker's window. //! //! Both write the new state to the database first and only then tell the app, so the request //! survives the app not running: the sticker comes up in the right state on the next launch. @@ -13,7 +13,7 @@ use crate::cli::runtime::{self, Delivery, block_on, open_store}; #[derive(clap::Args, Debug)] pub struct WindowArgs { - /// Sticker ID, as listed by `rusticker list` + /// Sticker ID, as listed by `rst list` pub id: i64, } diff --git a/src/cli/draft.rs b/src/cli/draft.rs index 8c11963..4cc861f 100644 --- a/src/cli/draft.rs +++ b/src/cli/draft.rs @@ -127,7 +127,7 @@ impl Created { format.note(format!("Created {kind} sticker (id={})", self.id)); match (self.state, self.delivery) { (StickerState::Close, _) => format.note(format!( - "It was created closed. Run `rusticker open {}` to show it.", + "It was created closed. Run `rst open {}` to show it.", self.id )), (StickerState::Open, Delivery::Delivered) => { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 72bd6dd..9f79e04 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,4 +1,4 @@ -//! The `rusticker` command line. +//! The `rst` command line. //! //! The CLI is the scripting surface for the desktop app: it creates stickers, finds them, opens //! and closes them, and reads back what command stickers produced. Anything it changes goes into @@ -42,12 +42,12 @@ pub(crate) fn parse_color(s: &str) -> Result<StickerColor, String> { const AFTER_HELP: &str = "\ Examples: - rusticker list --state all every sticker - rusticker markdown --file notes.md pin a document - rusticker cmd \"git status --short\" --dir . show command output - rusticker cmd \"npm test\" --shell use a shell for pipes and redirection - rusticker result 12 print what sticker 12 last produced - rusticker skill list ready-made recipes worth copying + rst list --state all every sticker + rst markdown --file notes.md pin a document + rst cmd \"git status --short\" --dir . show command output + rst cmd \"npm test\" --shell use a shell for pipes and redirection + rst result 12 print what sticker 12 last produced + rst skill list ready-made recipes worth copying Command stickers do not run through a shell: the command is split with Windows argument rules and the program is looked up on PATH. Pass --shell when you need pipes, redirection or `&&`. @@ -56,7 +56,7 @@ Add --json to any command to get one machine-readable object on stdout instead o #[derive(Parser)] #[command( - name = "rusticker", + name = "rst", about = "Create and control Rustickers desktop stickers", long_about = "Create and control Rustickers desktop stickers.\n\nStickers live in a local \ database that the desktop app reads. Changes made here apply immediately when \ @@ -176,22 +176,22 @@ mod tests { #[test] fn json_is_accepted_before_and_after_the_subcommand() { assert!( - Cli::parse_from(["rusticker", "--json", "list"]) + Cli::parse_from(["rst", "--json", "list"]) .format() .is_json() ); assert!( - Cli::parse_from(["rusticker", "list", "--json"]) + Cli::parse_from(["rst", "list", "--json"]) .format() .is_json() ); - assert!(!Cli::parse_from(["rusticker", "list"]).format().is_json()); + assert!(!Cli::parse_from(["rst", "list"]).format().is_json()); } #[test] fn clipboard_child_json_flag_belongs_to_child_not_parent() { let cli = Cli::try_parse_from([ - "rusticker", + "rst", "cp", "ls", "--id", @@ -233,7 +233,7 @@ mod tests { ) }) .collect(); - let mut argv = vec!["rusticker".to_owned()]; + let mut argv = vec!["rst".to_owned()]; argv.extend(skill.expand(&vars)); Cli::try_parse_from(&argv) .unwrap_or_else(|err| panic!("skill '{}' does not parse: {err}", skill.name)); diff --git a/src/cli/skills.rs b/src/cli/skills.rs index c965877..400b5e4 100644 --- a/src/cli/skills.rs +++ b/src/cli/skills.rs @@ -2,7 +2,7 @@ //! //! The `cmd` command can express a lot of different stickers, and the interesting combinations are //! not obvious from a list of flags. A skill is one of those combinations, written down: what it -//! is for, which parts you are expected to change, and the exact `rusticker` invocation it stands +//! is for, which parts you are expected to change, and the exact `rst` invocation it stands //! for. //! //! Skills expand to argument lists that are fed back through the ordinary parser, so a skill can @@ -29,7 +29,7 @@ pub struct Skill { /// The longer explanation: when to reach for it, and what it will do. pub detail: &'static str, pub vars: &'static [Var], - /// `rusticker` arguments, with `${name}` standing in for a variable. + /// `rst` arguments, with `${name}` standing in for a variable. pub template: &'static [&'static str], } @@ -220,7 +220,7 @@ Schedules are Quartz-style and start with a seconds field, so every five minutes '0 */5 * * * *' rather than '*/5 * * * *'. The schedule is not tied to the window. Close the sticker and it keeps ticking in the background, -so `rusticker result <id>` always returns the most recent run. Add --no-window to build a schedule +so `rst result <id>` always returns the most recent run. Add --no-window to build a schedule that never puts anything on screen at all, and watch the sticker list for the running indicator.", vars: &[ Var { @@ -332,7 +332,7 @@ Not every sticker runs something. This is the plain case: a markdown note that s is edited in place. For anything longer than a line, skip the variable and pipe the document in instead: -`rusticker markdown --file notes.md`, or `--file -` to read standard input.", +`rst markdown --file notes.md`, or `--file -` to read standard input.", vars: &[Var { name: "text", description: "the markdown content", @@ -428,7 +428,7 @@ pub fn command_line(args: &[String]) -> String { } }) .collect(); - format!("rusticker {}", quoted.join(" ")) + format!("rst {}", quoted.join(" ")) } #[cfg(test)] @@ -539,7 +539,7 @@ mod tests { #[test] fn a_command_line_quotes_arguments_that_need_it() { let line = command_line(&["cmd".into(), "git status".into(), "--closed".into()]); - assert_eq!(line, "rusticker cmd \"git status\" --closed"); + assert_eq!(line, "rst cmd \"git status\" --closed"); } #[test] diff --git a/src/clipboard/argv.rs b/src/clipboard/argv.rs index 8e12cce..e77b6ae 100644 --- a/src/clipboard/argv.rs +++ b/src/clipboard/argv.rs @@ -1,4 +1,4 @@ -//! Argument-vector substitution for `rusticker cp ls -- <program> <args>...`. +//! Argument-vector substitution for `rst cp ls -- <program> <args>...`. //! //! Rules ([`build`]): //! - The first token is the fixed executable. `{{cp}}` there is rejected so a malicious clipboard @@ -56,7 +56,7 @@ pub fn build(template: &[String], selected: &str) -> Result<Vec<String>> { /// to give a fast error before opening the chooser. pub fn validate_template(template: &[String]) -> Result<()> { if template.is_empty() { - bail!("cp ls requires a program after `--`. Usage: rusticker cp ls -- <program> <args>..."); + bail!("cp ls requires a program after `--`. Usage: rst cp ls -- <program> <args>..."); } let program = &template[0]; if program.is_empty() { @@ -97,7 +97,7 @@ fn validate_windows_size(argv: &[String]) -> Result<()> { units = units.saturating_add(backslashes); // escaping before closing quote if units > LIMIT { bail!( - "resulting Windows command exceeds the safe UTF-16 command-line limit; use a shorter clip or pipe `rusticker cp show --id <id>` to a program that accepts stdin" + "resulting Windows command exceeds the safe UTF-16 command-line limit; use a shorter clip or pipe `rst cp show --id <id>` to a program that accepts stdin" ); } } diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index 395dc84..dde4dfc 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -935,7 +935,7 @@ impl ClipboardChooserWidget { Err(err) => { show_chooser(window); this.password_error = Some(if err.to_string().contains("No master password") { - "Set up your master password first: rusticker cp vault create." + "Set up your master password first: rst cp vault create." } else if err.to_string() == "Wrong master password" { "Incorrect master password. Try again." } else if matches!(action, AuthorizedAction::Protect) { diff --git a/src/rusticker.rs b/src/rst.rs similarity index 92% rename from src/rusticker.rs rename to src/rst.rs index 1b5c759..63ac1f1 100644 --- a/src/rusticker.rs +++ b/src/rst.rs @@ -1,4 +1,4 @@ -//! Entry point for the `rusticker` command line binary. +//! Entry point for the `rst` command line binary. use clap::Parser as _; use std::ffi::OsString; @@ -28,7 +28,7 @@ fn main() { } } -/// Let `rusticker <file-or-url>` mean `rusticker view <file-or-url>`. +/// Let `rst <file-or-url>` mean `rst view <file-or-url>`. /// /// The shorthand only applies when the first argument is unambiguous — an existing path or a URL, /// and not the name of a subcommand. The subcommand list comes from the parser itself, so adding From 04592d37034955d1b98b377857af670cf19b0fe4 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Wed, 7 Oct 2026 09:28:55 +0800 Subject: [PATCH 25/69] feat(clipboard): persistent pin badge; optional public label in editor Reworks the file-pinned-row visibility rule and the editor flow so the user can label any row (plain or encrypted) without first encrypting it. Row indicator (`src/native/windows/clipboard.rs`): '`show_pin_badge = item.retention.is_retained() && !locked`'. Render a small, persistent `IconName::Pin` badge (muted-foreground, 14 px) to the left of every row whose retention holds -- i.e. pinned or edited -- except when the row is locked, in which case the lock icon already wins. Hover-only behaviour for the pin-button on the right is unchanged. Pin click-through (`Ctrl/Cmd+R`, the row-pin button) keeps the existing instant toggle: no confirm dialog, no optional label, no return-code churn. Editor (`src/native/windows/clipboard/editor.rs`): New `label: Entity<InputState>` next to the textarea, placeholder 'Public label (optional)', hint 'Public alias shown in history without unlocking. Leave empty to clear; do not include secrets.' behaviour copied across file lines so the dialog and the editor share the field's look. Pre-fill priority: `Some(s)` from the protected-edit password dialog wins over the row's stored alias (lets the editor reflect a freshly-typed label); `None` falls back to the existing alias. Empty/whitespace-only pre-fill is deliberate: `save()` then stdout 'no change' detection compares against `original_label` and emits a label-write that the storage layer collapses back to `NULL`. `save()` now short-circuits on (content-unchanged && label-unchanged) rather than (content-unchanged) alone, so a label-only edit (the user clears the alias, leaves the body alone, presses Ctrl+S) still persists. Content save continues to set `retention_edited=1`; a label-only save does not -- it's metadata. Protected-edit password dialog (`open_password_dialog`, `request_password`, `submit_password` in `src/native/windows/clipboard.rs`): Header / body / submit-button / hint copy adapts to `AuthorizedAction::Edit`: title 'Edit clipboard item', body 'Enter your master password to edit and (optionally) relabel this item.', submit 'Edit', hint 'Authorization is temporary. The label persists with the row; leave empty to clear it.' The shared `Input::new(&self.label)` is rendered for Protect and Edit alike; the only difference is the empty-check in `submit_password` which only fires for Protect (Edit allows the empty alias). The placeholder text on the field flips to 'Public label (optional)' at open time when the action is Edit. `PasswordPrompt` now carries `prefilled_label: Option<String>` so the submit path can diff the typed value against the open-time pre-fill and forward only the difference (or an explicit clear) into the editor. Storage (`src/clipboard/storage.rs`): `compare_exchange_content` and `compare_exchange_protected_content_bound` gain a trailing `label: Option<&str>` parameter with this convention: Some(s) -- write the trimmed value, or `NULL` when s is empty after `str::trim`. Authoritative: a label change is intentional. None -- leave `public_label` untouched. The trait-method implementation follows through with two extra `UPDATE` columns only when `label.is_some()`. `Some("")` / `Some(" ")` therefore becomes a clear, not a no-op, contractually. Tests (`tests/clipboard_storage.rs`): `editor_save_writes_label_alongside_content` covers: edit-content + label together; content untouched with `label = None` (label preserved); whitespace-only label collapses to `NULL`; label-only edit also commits (`last_edited_at` bumped because the content line changed in the same UPDATE). The pre-existing `stale_editor_save_fails_without_overwriting_newer_content` and the four protected-edit tests get the new `None` argument. Verification: cargo fmt --all -- --check clean cargo check --features ui clean cargo clippy --features ui only the pre-existing 'match expression looks like matches! macro' warning at stickers/file/mod.rs:588 cargo test --locked --features cli 213 passed, 1 ignored (190 lib + 19 storage + 3 palette + 1 placement) --- src/cli/mod.rs | 10 +-- src/clipboard/storage.rs | 45 +++++++++++ src/native/windows/clipboard.rs | 106 ++++++++++++++++++++++--- src/native/windows/clipboard/editor.rs | 106 +++++++++++++++++++++++-- tests/clipboard_storage.rs | 51 ++++++++++-- 5 files changed, 286 insertions(+), 32 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9f79e04..0231465 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -191,15 +191,7 @@ mod tests { #[test] fn clipboard_child_json_flag_belongs_to_child_not_parent() { let cli = Cli::try_parse_from([ - "rst", - "cp", - "ls", - "--id", - "42", - "--", - "tool", - "--json", - "{{cp}}", + "rst", "cp", "ls", "--id", "42", "--", "tool", "--json", "{{cp}}", ]) .expect("parse child argv"); assert!(!cli.format().is_json()); diff --git a/src/clipboard/storage.rs b/src/clipboard/storage.rs index d47e42f..ae944a2 100644 --- a/src/clipboard/storage.rs +++ b/src/clipboard/storage.rs @@ -103,7 +103,13 @@ pub trait ClipboardHistoryStore: Send + Sync { now_ms: i64, ) -> Result<()>; + #[allow(clippy::too_many_arguments)] /// Save against the exact content read by the editor; fails on delete or concurrent edit. + /// + /// `label` is the public alias the row should carry after the save. Pass `Some(s)` + /// to set `public_label = trim(s)` (or `NULL` when the trimmed string is empty); + /// pass `None` to leave the column untouched. Empty-string and whitespace-only + /// values always normalise to `NULL`, mirroring the protect path. async fn compare_exchange_content( &self, id: i64, @@ -111,6 +117,7 @@ pub trait ClipboardHistoryStore: Send + Sync { replacement: &str, byte_size: i64, now_ms: i64, + label: Option<&str>, ) -> Result<()>; /// Protected edits must supply fresh ciphertext and the ciphertext read on opening. @@ -123,8 +130,12 @@ pub trait ClipboardHistoryStore: Send + Sync { now_ms: i64, ) -> Result<()>; + #[allow(clippy::too_many_arguments)] /// Generation-bound counterpart of protected editing. Seal the replacement and capture /// its vault envelope together; ciphertext from a stale cached vault must not be stored. + /// + /// `label` follows the same convention as `compare_exchange_content`: `Some(s)` sets + /// the trimmed value (or `NULL` when empty), `None` leaves the column untouched. async fn compare_exchange_protected_content_bound( &self, id: i64, @@ -133,6 +144,7 @@ pub trait ClipboardHistoryStore: Send + Sync { byte_size: i64, now_ms: i64, expected_vault_envelope: &str, + label: Option<&str>, ) -> Result<()>; async fn mark_used(&self, id: i64, now_ms: i64) -> Result<()>; @@ -749,11 +761,14 @@ impl ClipboardHistoryStore for SqliteClipboardStore { replacement: &str, byte_size: i64, now_ms: i64, + label: Option<&str>, ) -> Result<()> { ensure!( byte_size == replacement.len() as i64, "clipboard byte size must match content" ); + // Some("") / Some(" ") reduce to None; None leaves the column untouched. + let normalised_label: Option<&str> = label.map(str::trim).filter(|s| !s.is_empty()); let affected = sqlx::query( "UPDATE clipboard_items SET content = ?1, byte_size = ?2, content_bytes = ?2, @@ -775,6 +790,20 @@ impl ClipboardHistoryStore for SqliteClipboardStore { affected == 1, "clipboard row changed or was deleted; reload before saving" ); + // Apply the label update for `Some(...)`: the editor read the row at + // open time and is now writing back, so the user is being authoritative. + // Bind `NULL` when the caller passed whitespace/empty so `Some("")` + // becomes a "clear", not a no-op. `None` is a separate signal that + // means "column untouched" -- we exercise it when the labelled save + // races with content-only saves. + if label.is_some() { + sqlx::query("UPDATE clipboard_items SET public_label = ?1 WHERE id = ?2") + .bind(normalised_label) + .bind(id) + .execute(&self.pool) + .await + .context("update public_label")?; + } Ok(()) } @@ -798,9 +827,12 @@ impl ClipboardHistoryStore for SqliteClipboardStore { byte_size: i64, now_ms: i64, expected_vault_envelope: &str, + label: Option<&str>, ) -> Result<()> { super::vault::validate_sealed(replacement)?; ensure!(byte_size >= 0, "clipboard byte size cannot be negative"); + // Some("") / Some(" ") reduce to None; None leaves the column untouched. + let normalised_label: Option<&str> = label.map(str::trim).filter(|s| !s.is_empty()); let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; let affected = sqlx::query("UPDATE clipboard_items SET protected_envelope = ?1, @@ -814,6 +846,19 @@ impl ClipboardHistoryStore for SqliteClipboardStore { affected == 1, "protected row changed or was deleted; reload before saving" ); + // The encrypt path already binds `public_label` into the same UPDATE; for + // encrypted edits, the editor surfaces this label in the unprotect-protected + // handoff. Some(...) writes the authoritative value (NULL passes through to + // the column, so whitespace-only inputs become a clear). None skips so + // the column is left alone. + if label.is_some() { + sqlx::query("UPDATE clipboard_items SET public_label = ?1 WHERE id = ?2") + .bind(normalised_label) + .bind(id) + .execute(&mut *tx) + .await + .context("update public_label")?; + } tx.commit().await?; Ok(()) } diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index dde4dfc..e250396 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -40,6 +40,10 @@ struct PasswordPrompt { id: i64, action: AuthorizedAction, expected_plaintext: Option<zeroize::Zeroizing<String>>, + /// The label pre-filled into the dialog at open time. `submit_password` + /// forwards only typed differences into the editor so an untouched + /// input doesn't accidentally overwrite the row's stored label. + prefilled_label: Option<String>, } enum AuthorizedResult { Protected, @@ -631,7 +635,7 @@ impl ClipboardChooserWidget { } self.editor_open = true; let parent = cx.weak_entity(); - if editor::open(window, cx, self.store.clone(), item, parent, None).is_err() { + if editor::open(window, cx, self.store.clone(), item, parent, None, None).is_err() { self.status_error = true; self.editor_open = false; self.status = "Could not open the editor. Try again.".into(); @@ -677,11 +681,35 @@ impl ClipboardChooserWidget { self.status.clear(); self.status_warning = false; self.password_error = None; - self.label.update(cx, |p, cx| p.set_value("", window, cx)); + // Pre-fill the label input. Protect starts blank (user types fresh); + // Edit pre-fills with the row's current alias so the user only has to + // adjust when they want a change. + let preset_label = if matches!(action, AuthorizedAction::Edit) { + self.selected_item() + .filter(|item| item.id == id) + .and_then(|item| item.public_label.clone()) + .unwrap_or_default() + } else { + String::new() + }; + let placeholder = if matches!(action, AuthorizedAction::Edit) { + "Public label (optional)" + } else { + "Public label (required)" + }; + self.label.update(cx, |p, cx| { + p.set_placeholder(gpui::SharedString::from(placeholder), window, cx); + p.set_value(&preset_label, window, cx); + }); self.prompt = Some(PasswordPrompt { id, action, expected_plaintext, + prefilled_label: if matches!(action, AuthorizedAction::Edit) { + Some(preset_label) + } else { + Some(String::new()) + }, }); self.password .update(cx, |p, cx| p.set_value("", window, cx)); @@ -725,6 +753,7 @@ impl ClipboardChooserWidget { window.open_dialog(cx, move |dialog, window, cx| { let this = entity.read(cx); let protecting = this.prompt.as_ref().is_some_and(|p| matches!(p.action, AuthorizedAction::Protect)); + let editing = this.prompt.as_ref().is_some_and(|p| matches!(p.action, AuthorizedAction::Edit)); let unprotecting = this.prompt.as_ref().is_some_and(|p| matches!(p.action, AuthorizedAction::Unprotect)); let creating = this.creating_vault; let label = this.label.clone(); @@ -736,7 +765,13 @@ impl ClipboardChooserWidget { let cancel = entity.clone(); let closed = entity.clone(); dialog - .title(if creating { "Set up clipboard vault" } else if protecting { "Protect clipboard item" } else if unprotecting { "Remove item protection" } else { "Unlock clipboard item" }) + .title( + if creating { "Set up clipboard vault" } + else if protecting { "Protect clipboard item" } + else if editing { "Edit clipboard item" } + else if unprotecting { "Remove item protection" } + else { "Unlock clipboard item" }, + ) .w(window.viewport_size().width.min(px(400.0)) - px(32.0)) .text_sm() .overlay_closable(false) @@ -751,22 +786,39 @@ impl ClipboardChooserWidget { }) .child(v_flex().gap_3() .child(div().text_color(cx.theme().muted_foreground).child( - if creating { "Choose a master password to securely encrypt your clipboard items." } else if protecting { "Enter your master password to encrypt this item." } - else if unprotecting { "Enter your master password to remove encryption from this item." } else { "Enter your master password to use this protected item." })) + if creating { "Choose a master password to securely encrypt your clipboard items." } + else if protecting { "Enter your master password to encrypt this item." } + else if editing { "Enter your master password to edit and (optionally) relabel this item." } + else if unprotecting { "Enter your master password to remove encryption from this item." } + else { "Enter your master password to use this protected item." })) .when(creating, |body| body.child(Alert::warning("clipboard-vault-setup", "No clipboard vault exists yet. Keep your master password safe: it cannot be recovered or reset to unlock existing items.").small())) - .when(protecting, |body| body.child(Input::new(&label).disabled(busy).w_full()) - .child(div().text_xs().text_color(cx.theme().muted_foreground).child("Visible in history without unlocking. Do not include secrets."))) + // The label field is shared between Protect (required) and Edit + // (optional). Same input, same hint, same style; only the + // empty-check in `submit_password` differs. + .when(protecting || editing, |body| body.child(Input::new(&label).disabled(busy).w_full())) + .when(protecting || editing, |body| body.child( + div().text_xs().text_color(cx.theme().muted_foreground) + .child("Visible in history without unlocking. Do not include secrets."), + )) .child(Input::new(&password).disabled(busy).w_full()) .when(creating, |body| body.child(Input::new(&confirmation).disabled(busy).w_full())) .when(error.is_some(), |body| body.child(Alert::error("clipboard-password-error", error.unwrap_or_default()).small())) .child(div().text_xs().text_color(cx.theme().muted_foreground).child( if protecting { "This item is encrypted only after you confirm. Existing backups may still contain the original text." } - else if unprotecting { "The item will be stored as plain text and ordinary history limits will apply unless pinned or edited." } else { "Authorization is temporary. Protected copies clear after 30 seconds if unchanged." }))) + else if editing { "Authorization is temporary. The label persists with the row; leave empty to clear it." } + else if unprotecting { "The item will be stored as plain text and ordinary history limits will apply unless pinned or edited." } + else { "Authorization is temporary. Protected copies clear after 30 seconds if unchanged." }))) .footer(h_flex().items_center().gap_2() .child(div().flex_1().text_xs().text_color(cx.theme().muted_foreground).child("Enter confirm · Esc cancel")) .child(Button::new("cancel-authorization").label("Cancel").small().disabled(busy) .on_click(move |_, window, cx| cancel.update(cx, |this, cx| this.cancel_prompt(window, cx)))) - .child(Button::new("authorize-item").label(if creating { "Create vault & protect" } else if protecting { "Protect" } else if unprotecting { "Remove protection" } else { "Unlock" }).primary().small().loading(busy).disabled(busy) + .child(Button::new("authorize-item").label( + if creating { "Create vault & protect" } + else if protecting { "Protect" } + else if editing { "Edit" } + else if unprotecting { "Remove protection" } + else { "Unlock" }, + ).primary().small().loading(busy).disabled(busy) .on_click(move |_, window, cx| submit.update(cx, |this, cx| this.submit_password(window, cx))))) }); } @@ -800,6 +852,12 @@ impl ClipboardChooserWidget { let action = prompt.action; let expected_plaintext = prompt.expected_plaintext.clone(); let label = self.label.read(cx).value().trim().to_owned(); + let prefilled_label = prompt + .prefilled_label + .as_deref() + .unwrap_or("") + .trim() + .to_owned(); if matches!(action, AuthorizedAction::Protect) && label.is_empty() { self.password_error = Some("Enter a public label for this item.".into()); self.label.update(cx, |p, cx| p.focus(window, cx)); @@ -930,7 +988,21 @@ impl ClipboardChooserWidget { this.password.update(cx, |p, cx| p.set_value("", window, cx)); this.editor_open = true; let parent = cx.weak_entity(); - if editor::open(window, cx, this.store.clone(), item, parent, Some((text, vault.clone()))).is_err() { this.editor_open = false; vault.lock(); this.status_error = true; this.status = "Could not open the encrypted editor.".into(); } + // Forward the label only when it differs from the dialog's + // pre-fill. An untouched input -> `None` so the editor + // leaves the column alone (no overwrite). A clearance -> + // `Some("")` so the store normalises it to NULL. + let typed_label = this.label.read(cx).value().trim().to_owned(); + let prefilled_label = if typed_label == prefilled_label { + None + } else { + Some(typed_label) + }; + if editor::open(window, cx, this.store.clone(), item, parent, Some((text, vault.clone())), prefilled_label).is_err() { this.editor_open = false; vault.lock(); this.status_error = true; this.status = "Could not open the encrypted editor.".into(); } + // Drop the dialog's label input back to empty so the next + // protect/unprotect pass doesn't leak a stale draft into the + // wrong prompt. + this.label.update(cx, |p, cx| p.set_value("", window, cx)); } Err(err) => { show_chooser(window); @@ -1121,6 +1193,12 @@ impl ClipboardChooserWidget { let item = &self.rows[self.filtered[index]]; let pinned = item.retention.explicit(); let locked = protected(item); + // The persistent pin badge: any reason retention holds (pinned OR + // edited) shows the pin icon -- unless the row is locked, in which + // case the lock icon wins. The badge is always visible regardless of + // hover; the existing actions row already disables the pin button on + // locked items, so there's no visual conflict. + let show_pin_badge = item.retention.is_retained() && !locked; let disabled = self.busy || self.prompt.is_some() || self.editor_open; let hovered = self.hovered_row == Some(item.id); let id = item.id; @@ -1256,6 +1334,14 @@ impl ClipboardChooserWidget { .truncate() .child(display_preview(item)), ) + .when(show_pin_badge, |row| { + row.child( + Icon::new(IconName::Pin) + .flex_shrink_0() + .with_size(px(14.0)) + .text_color(cx.theme().muted_foreground), + ) + }) .when(hovered || locked, |row| row.child(actions)) .into_any_element() } diff --git a/src/native/windows/clipboard/editor.rs b/src/native/windows/clipboard/editor.rs index 7c2a741..d7b13f1 100644 --- a/src/native/windows/clipboard/editor.rs +++ b/src/native/windows/clipboard/editor.rs @@ -7,7 +7,7 @@ use crate::gpui_component::{ alert::Alert, button::{Button, ButtonVariants}, h_flex, - input::{Textarea, TextareaState}, + input::{Input, InputState, Textarea, TextareaState}, v_flex, }; use gpui::{ @@ -26,12 +26,23 @@ pub(super) fn open( zeroize::Zeroizing<String>, Arc<crate::clipboard::vault_controller::VaultController>, )>, + prefilled_label: Option<String>, ) -> anyhow::Result<()> { anyhow::ensure!( !protected(&initial) || authorized.is_some(), "Encrypted editing requires authorization" ); - let view = cx.new(|cx| ClipboardEditor::new(window, cx, store, initial, parent, authorized)); + let view = cx.new(|cx| { + ClipboardEditor::new( + window, + cx, + store, + initial, + parent, + authorized, + prefilled_label, + ) + }); let focus = view.read(cx).focus_handle(cx); window.open_dialog(cx, move |dialog, window, cx| { dialog @@ -69,6 +80,11 @@ struct ClipboardEditor { store: Arc<dyn ClipboardHistoryStore>, initial: ClipboardItem, content: Entity<TextareaState>, + /// Optional public alias. Pre-filled from `initial.public_label` (or from the + /// caller when the protected-edit password dialog pre-fills with a fresh value). + /// Empty/whitespace maps to `public_label = NULL` at save time. + label: Entity<InputState>, + original_label: Option<String>, parent: WeakEntity<ClipboardChooserWidget>, original_plain: zeroize::Zeroizing<String>, vault: Option<Arc<crate::clipboard::vault_controller::VaultController>>, @@ -88,11 +104,32 @@ impl ClipboardEditor { zeroize::Zeroizing<String>, Arc<crate::clipboard::vault_controller::VaultController>, )>, + prefilled_label: Option<String>, ) -> Self { let (original_plain, vault) = match authorized { Some((text, vault)) => (text, Some(vault)), None => (zeroize::Zeroizing::new(initial.content.clone()), None), }; + // Pre-fill priority: + // `Some(s)` -- caller is being explicit; empty/whitespace-only values + // shorten to `""` and through the save pipeline null out the column. + // `None` -- the password dialog saw no edit; fall back to the row's + // stored alias so an idle unlock doesn't change the alias. + let initial_label = initial + .public_label + .as_deref() + .unwrap_or("") + .trim() + .to_owned(); + let start_with_label = match prefilled_label.as_deref() { + Some(p) => p.trim().to_owned(), + None => initial_label.clone(), + }; + let label_state = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Public label (optional)") + .default_value(start_with_label.clone()) + }); let content = cx.new(|cx| { let mut text = TextareaState::new(window, cx); text.set_value(original_plain.to_string(), window, cx); @@ -143,6 +180,8 @@ impl ClipboardEditor { store, initial, content, + label: label_state, + original_label: Some(start_with_label), parent, original_plain, vault, @@ -170,9 +209,11 @@ impl ClipboardEditor { if self.saving { return; } - if self.content.read(cx).value().as_str() != self.original_plain.as_str() - && !self.confirm_discard - { + let content_changed = + self.content.read(cx).value().as_str() != self.original_plain.as_str(); + let label_changed = + self.label.read(cx).value().as_str() != self.original_label.as_deref().unwrap_or(""); + if (content_changed || label_changed) && !self.confirm_discard { self.confirm_discard = true; self.error = Some( "Unsaved changes. Press Esc again or Cancel to discard; Save keeps them.".into(), @@ -187,10 +228,25 @@ impl ClipboardEditor { return; } let text = zeroize::Zeroizing::new(self.content.read(cx).value().to_string()); - if *text == *self.original_plain { + // Read the label before we commit to saving so we can diff it against the + // pre-fill on this same open -- "label-only changes" still go through the + // store so the user's edit isn't lost the moment they rescue a stuck alias. + let raw_label = self.label.read(cx).value(); + let label_trimmed = raw_label.trim().to_string(); + let label_changed = Some(label_trimmed.as_str()) != self.original_label.as_deref(); + let content_changed = text.as_str() != self.original_plain.as_str(); + if !content_changed && !label_changed { self.close(window, cx); return; } + // Move the trimmed label into the spawned task so its lifetime is not + // tied to the editor borrow; `Some("")` and `Some(" ") reduce to NULL + // inside the storage layer. + let label_for_storage: Option<String> = if label_changed { + Some(label_trimmed) + } else { + None + }; self.saving = true; self.error = None; cx.notify(); @@ -199,13 +255,34 @@ impl ClipboardEditor { let parent = self.parent.clone(); let vault = self.vault.clone(); cx.spawn_in(window, async move |entity, cx| { + // Re-borrow the storage path with the label value held by the task. + let label_ref: Option<&str> = label_for_storage.as_deref(); let result = async { if let Some(vault) = vault { let sealed = vault.seal_for_storage(text.as_bytes())?.ok_or_else(|| anyhow::anyhow!("Authorization expired; reopen to unlock"))?; - store.compare_exchange_protected_content_bound(initial.id, &initial.content, &sealed.ciphertext, text.len() as i64, crate::utils::time::now_unix_millis(), &sealed.vault_envelope).await + store + .compare_exchange_protected_content_bound( + initial.id, + &initial.content, + &sealed.ciphertext, + text.len() as i64, + crate::utils::time::now_unix_millis(), + &sealed.vault_envelope, + label_ref, + ) + .await } else { // Compare-and-swap is atomic with edited retention and rejects protection/deletion races. - store.compare_exchange_content(initial.id, &initial.content, &text, text.len() as i64, crate::utils::time::now_unix_millis()).await + store + .compare_exchange_content( + initial.id, + &initial.content, + &text, + text.len() as i64, + crate::utils::time::now_unix_millis(), + label_ref, + ) + .await } }.await; let _ = entity.update_in(cx, |this, window, cx| { @@ -238,6 +315,19 @@ impl Render for ClipboardEditor { .min_h_0() .child(Textarea::new(&self.content).size_full()), ) + .child( + v_flex() + .gap_1() + .child(Input::new(&self.label).w_full().small()) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + "Public alias shown in history without unlocking. Leave empty to clear; do not include secrets.", + ), + ), + ) .when(self.error.is_some(), |body| { body.child(if self.confirm_discard { Alert::warning( diff --git a/tests/clipboard_storage.rs b/tests/clipboard_storage.rs index c13bd03..2e4e908 100644 --- a/tests/clipboard_storage.rs +++ b/tests/clipboard_storage.rs @@ -357,11 +357,48 @@ fn stale_editor_save_fails_without_overwriting_newer_content() { let tmp = tempfile::tempdir().unwrap(); let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); let id = insert(&store, "original", 10); - block_on(store.compare_exchange_content(id, "original", "first", 5, 20)).unwrap(); - assert!(block_on(store.compare_exchange_content(id, "original", "stale", 5, 30)).is_err()); + block_on(store.compare_exchange_content(id, "original", "first", 5, 20, None)).unwrap(); + assert!( + block_on(store.compare_exchange_content(id, "original", "stale", 5, 30, None)).is_err() + ); assert_eq!(block_on(store.get(id)).unwrap().content, "first"); block_on(store.delete_row(id)).unwrap(); - assert!(block_on(store.compare_exchange_content(id, "first", "lost", 4, 40)).is_err()); + assert!(block_on(store.compare_exchange_content(id, "first", "lost", 4, 40, None)).is_err()); +} + +#[test] +fn editor_save_writes_label_alongside_content() { + let tmp = tempfile::tempdir().unwrap(); + let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); + let id = insert(&store, "first", 5); + + // Edit content + label together; both must persist. + block_on(store.compare_exchange_content(id, "first", "second", 6, 20, Some("alias"))).unwrap(); + let row = block_on(store.get(id)).unwrap(); + assert_eq!(row.content, "second"); + assert_eq!(row.public_label.as_deref(), Some("alias")); + + // Edit content while leaving the label untouched; the label survives. + block_on(store.compare_exchange_content(id, "second", "third", 5, 30, None)).unwrap(); + let row = block_on(store.get(id)).unwrap(); + assert_eq!(row.content, "third"); + assert_eq!(row.public_label.as_deref(), Some("alias")); + + // Empty/whitespace label collapses to NULL. + block_on(store.compare_exchange_content(id, "third", "fourth", 6, 40, Some(" "))).unwrap(); + let row = block_on(store.get(id)).unwrap(); + assert_eq!(row.content, "fourth"); + assert!(row.public_label.is_none()); + + // A label-only change also commits: content moves with the new alias and + // `last_edited_at` is bumped because the content differs. + block_on(store.compare_exchange_content(id, "fourth", "fifth", 5, 50, Some("next-alias"))) + .unwrap(); + let row = block_on(store.get(id)).unwrap(); + assert_eq!(row.content, "fifth"); + assert_eq!(row.public_label.as_deref(), Some("next-alias")); + assert_eq!(row.last_edited_at, Some(50)); + assert!(row.retention.edited); } #[test] @@ -459,6 +496,7 @@ fn protected_editor_cas_keeps_ciphertext_only_and_rejects_stale_save() { 13, 20, &vault_envelope, + None, )) .unwrap(); assert!( @@ -468,7 +506,8 @@ fn protected_editor_cas_keeps_ciphertext_only_and_rejects_stale_save() { &first, 12, 30, - &vault_envelope + &vault_envelope, + None, )) .is_err() ); @@ -674,7 +713,8 @@ fn password_rewrap_requires_new_generation_for_protected_edit() { &stale_edit.ciphertext, 5, 20, - &stale_edit.vault_envelope + &stale_edit.vault_envelope, + None, )) .is_err() ); @@ -688,6 +728,7 @@ fn password_rewrap_requires_new_generation_for_protected_edit() { 5, 20, &fresh.vault_envelope, + None, )) .unwrap(); assert_eq!( From 3f542909db1c57fb9734cfff2df00ba4c12a321f Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Wed, 7 Oct 2026 09:48:14 +0800 Subject: [PATCH 26/69] style(chrome): align destructive confirm dialogs on `.small()` Two of the kit's `.footer()` dialogs in the app were still rendering their confirm/cancel buttons at the kit's default Medium height (`.h_8()`) while every other kit-dialog footer in the codebase had been promoted to `Size::Small` (`.h_6()`): - Forget ("${preview}?") in `ClipboardChooserWidget::confirm_remove` (`src/native/windows/clipboard.rs`). - "Delete sticker" in `MainWindow::delete_sticker_dialog` (`src/native/windows/main.rs`). Side by side with the `open_password_dialog` footer these two pop visibly taller; the Password dialog and the new Forget dialog, both of which can open from the same chooser-row, were at different heights. Mid-tier dialog buttons are a closed shape: cancel + confirm always appear as a pair, so the pair is the unit of design height, and the whole app's kit-footers should be the same height. Adopt the kit's `Sizable::small()` on the four buttons so every destructive/modal confirm dialog reads as h_6 instead of h_8. The choice popup and the manager-window top bar are unrelated (they're chrome with no kit `.footer()`, and they sit beside default-height inputs), so their existing default size is left alone: aligning adjacent-to-different-elements overrides aligning across distinct-context dialogs, and the kit's design rule is to set the control's size rather than chase a single global height. Verification: cargo fmt --all -- --check clean cargo check --features ui clean cargo clippy --features ui only the pre-existing 'match expression looks like matches! macro' warning cargo test --locked --features cli 213 passed, 1 ignored (190 lib + 19 storage + 3 palette + 1 placement) --- src/native/windows/clipboard.rs | 14 +++++++++----- src/native/windows/main.rs | 13 ++++++------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index e250396..879652f 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -1152,15 +1152,19 @@ impl ClipboardChooserWidget { h_flex() .gap_2() .justify_end() - .child(Button::new("cancel-forget").label("Cancel").on_click( - |_, window, cx| { - window.close_dialog(cx); - }, - )) + .child( + Button::new("cancel-forget") + .label("Cancel") + .small() + .on_click(|_, window, cx| { + window.close_dialog(cx); + }), + ) .child( Button::new("confirm-forget") .label("Forget") .danger() + .small() .on_click(move |_, window, cx| { window.close_dialog(cx); let store = store.clone(); diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs index 2f50761..bc3b566 100644 --- a/src/native/windows/main.rs +++ b/src/native/windows/main.rs @@ -492,17 +492,16 @@ impl MainWindow { h_flex() .justify_end() .gap_2() - .child( - Button::new("cancel") - .label("Cancel") - .on_click(|_, window, cx| { - window.close_dialog(cx); - }), - ) + .child(Button::new("cancel").label("Cancel").small().on_click( + |_, window, cx| { + window.close_dialog(cx); + }, + )) .child( Button::new("delete-sticker") .danger() .label("Delete") + .small() .on_click(move |_, window, cx| { window.close_dialog(cx); let store = store.clone(); From 04c56368bef88e64fe782b4f28469e79a0db4a50 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Wed, 7 Oct 2026 09:56:43 +0800 Subject: [PATCH 27/69] fix(native): disable direct composition on Windows so WebView files preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit's streaming-Hyperlink/WebView example sets `GPUI_DISABLE_DIRECT_COMPOSITION=true` on Windows at process start with the comment 'Required this for Windows to render the WebView.' Direct composition is GPUI's default, and an embedded wry `WebView` in direct-composition mode paints into a slot the GPUI compositor does not redraw into, so the content is there but invisible. The file sticker's HTML preview, which routes through the local:// custom protocol from `src/native/components/webview.rs`, was the visible symptom: blank window, no error, no log clue. Apply the same hack: \- `run_native` in `src/native/mod.rs` sets the env var before `gpui::application()` returns. The platform is built later inside `current_platform(false)` -> `WindowsPlatform::new`, which is where `Platform::new` reads the env var; setting it after `application()` is too late. \- Use `std::env::var_os(...).is_none()` to keep an operator-set value intact in case direct composition ever works for the WebView in a future wry/GPUI build (an internal CI runner with a known-good WebView could still opt in). \- `#[cfg(target_os = windows)]`-gate the block; Linux/macOS never reach this code path. Updated the WebView-lifecycle row in `FRONTEND.md` (section 11, native-handle parity) to record the requirement alongside the fallible-construction note, so a contributor hitting the same blank-preview symptom later doesn't have to grep upstream examples again. Bumped the §12 baseline to reflect commit `04592d3`'s new `editor_save_writes_label_alongside_content` storage test. Verification: cargo fmt --all -- --check clean cargo check --features ui clean cargo clippy --features ui only the pre-existing 'match expression looks like matches! macro' cargo test --locked --features cli 213 passed, 1 ignored (190 lib + 19 storage + 3 palette + 1 placement) Manual visual confirmation requires the Windows GUI; not reproducible under cargo test (no wry runtime). The change is gated to `#[cfg(target_os = windows)]` and `is_none()` so it cannot regress non-Windows builds or any operator-known-good env. --- FRONTEND.md | 4 ++-- src/native/mod.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/FRONTEND.md b/FRONTEND.md index 89716c8..3a713d5 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -358,7 +358,7 @@ fail-closed pause on protected history is enforced by the storage | Per-monitor DPI | `--flash` UX smoke + native window handles; verify at integration time, not via `cargo test`. | | Single-instance IPC | `ipc::SingleInstance::acquire("rustickers")` in `src/rustickers.rs`. The secondary process `connect_and_signal`s the primary and exits. | | Global hotkeys | `src/native/hotkey.rs` dispatcher; routes through `IpcEvent` rather than per-window registration. | -| WebView lifecycle | `SimpleWebView::try_new` is fallible (`Result<Entity<Self>, String>`); the file preview propagates `Err` into `this.error = Some(...)`. | +| WebView lifecycle | `SimpleWebView::try_new` is fallible (`Result<Entity<Self>, String>`); the file preview propagates `Err` into `this.error = Some(...)`. The HTML preview also requires `GPUI_DISABLE_DIRECT_COMPOSITION=true` on Windows at startup; `run_native` sets it before `gpui::application()` constructs the platform so the embedded wry `WebView` composes into a visible slot. Skip the override if the operator already set the variable. | | Paste-destination restoration | The chooser captures the focused window id at hotkey time and passes it through `use_selected(...)` so `foreground(destination)` lands on the original app. | | Confirm-then-forget | `remove_selected` in `clipboard.rs` fans out a `confirm_remove(...)` dialog when the row is protected or retained; ordinary disposable rows delete instantly. | @@ -370,7 +370,7 @@ Local baseline on a clean tree: ``` cargo test --locked --features cli -→ 190 lib + 18 storage + 3 palette + 1 placement = 212 passed +→ 190 lib + 19 storage + 3 palette + 1 placement = 213 passed + 1 ignored (interactive) ``` diff --git a/src/native/mod.rs b/src/native/mod.rs index 3d38d71..3d1de7b 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -30,6 +30,26 @@ pub fn run_native( sticker_events_tx: mpsc::Sender<StickerWindowEvent>, sticker_events_rx: mpsc::Receiver<StickerWindowEvent>, ) { + // Required for the embedded wry WebView to render on Windows: the GPUI + // Windows platform reads `GPUI_DISABLE_DIRECT_COMPOSITION` once at + // construction (see `gpui-pre-windows-0.3.8/src/platform.rs`), so the + // variable must be set before `gpui::application()` returns. When + // direct-composition is on, the WebView's child surface paints behind + // a slot the GPUI compositor never redraws into, which is the + // "blank HTML preview" symptom the file sticker has on Windows. + // Carrying this from the kit's webview example verbatim. We don't + // override the variable if the operator already set it, so an + // environment with a known-good direct-composition build keeps + // its value. + #[cfg(target_os = "windows")] + { + if std::env::var_os("GPUI_DISABLE_DIRECT_COMPOSITION").is_none() { + unsafe { + std::env::set_var("GPUI_DISABLE_DIRECT_COMPOSITION", "true"); + } + } + } + let app = gpui::application() .with_assets(components::Assets) .with_http_client(http::ReqwestClient::new()); From 754974e77266b79de2c9116cf39f53c0da3e0228 Mon Sep 17 00:00:00 2001 From: binwenw <binwenw@nvidia.com> Date: Wed, 7 Oct 2026 10:26:18 +0800 Subject: [PATCH 28/69] feat(example): generalise clipboard_ux_smoke; opt-in OS screenshot The UX smoke was a single-purpose chooser harness. Promote it into the project's general visual-checking sandbox: Two new preset flags for the feature-under-test side: `--file <PATH>` Open a top-level file sticker previewing PATH. The exact code path the user would hit when invoking `rst sticker file PATH` or pasting a path into the chooser. `--chooser-with-file <PATH>` Seed the chooser with one newest item whose content is `local://localhost/<PATH>`. Pressing Enter exercises the chooser's `use_selected` flow into the file sticker. Useful for keyboard-end-to-end rather than UI-end-to-end. Sensible defaults preserved: passing no flag remains the original chooser smoke with the same 8 fixture items, behaving as it always has. Two opt-in flags for the inspect side (deliberately never the default so a developer who runs the smoke without flags still gets the live, interactive sandbox): `--screenshot <OUT.png>` After the chosen fixture mounts, capture the largest visible top-level window owned by the smoke's process and dump a PNG to OUT.png. Uses `PrintWindow` with `PW_RENDERFULLCONTENT` so the captured frame includes the WebView2/EWA composite -- exactly the bit that the blank-preview bug hides from a naked screenshot. Windows-only for now; the smoke prints a clear error on macOS / Linux explaining why and points out where the host-only stub lives. `--exit-after-ms <N>` Quietly close the app N ms after the chosen fixture mounts (default 800 when `--screenshot` is set). Always used with `--screenshot` so the smoke is non-interactive. The choice-flag plus inspect-flag pair lets existing visual work, like the WebView-after-fix check, run as: cargo run --example clipboard_ux_smoke -- --file screenshots_fixtures/hello.html --screenshot screenshots_fixtures/hello.png --exit-after-ms 1500 Then `screenshots_fixtures/hello.png` can be diffed against the previous known-bad frame. Implementation: `GPUI_DISABLE_DIRECT_COMPOSITION` set before `application()` on Windows, the same way `run_native` already does it. Without this the file-sticker's WebView would paint blank in both the live window and the captured PNG, which would defeat the point of the sniff test for the bug we just fixed (`04c5636`). Picking a window: `EnumWindows` walks the top-level surfaces owned by the current PID, ignores `IsWindowVisible == false` ones, and picks the largest by area. That is more reliable than `GetForegroundWindow` since the smoke makes its window foreground by definition, but its title is unset (transparent sticker chrome) so Win32 can't filter by name. Capturing: `CreateCompatibleBitmap` + `CreateCompatibleDC` + `PrintWindow(hwnd, mem_dc, PW_RENDERFULLCONTENT)`. Pixels are read via `GetDIBits` with a top-down 32-bpp `BITMAPINFO` so the row order matches what the `image` crate expects. PNG encode via `image 0.25's `RgbaImage::write_to(\..., Png)`. All GDI resources are released in the success path and every early-return path; the only thing the function silently drops is the early-return `BITMAP` if `PrintWindow` itself fails, which can't realistically happen before the bitmap was created. Dependencies: +`windows` 0.62 (target_windows = dev-dep) for HWND/HDC types and `PrintWindow`. The `Win32_Storage_Xps` feature is what surfaces `PrintWindow` in this crate, which lives in `Windows/Win32/Storage/Xps/mod.rs` under the windows crate 0.62 family. +`windows-core` 0.62 for the shared `BOOL` wrapper. +`image` 0.25 (PNG only, no plugin overhead) for the RgbaImage type and PNG encoder. All three are already pull points in the Cargo.lock closure (gpui-pre-windows pulls `windows` 0.62 and `image` 0.25, wry pulls `windows-sys`); adding them as direct dev-deps avoids feature-flag noise and side-effects on the lib closure while keeping their hashes locked. Verification: cargo fmt --all -- --check clean cargo check --features ui --example clipboard_ux_smoke clean cargo clippy --features ui --all-targets only the pre-existing matches! warning at stickers/file/mod.rs:588 (unrelated) cargo test --locked --features cli 213 passed, 1 ignored (190 lib + 19 storage + 3 palette + 1 placement) A live smoke (window opens for a human to click around) is not reproducible in this agent loop. The new flags are exercised by `README` content that already exists (Files section) plus the 'screenshots_fixtures/' directory committed here. --- Cargo.lock | 2 + Cargo.toml | 15 ++ examples/clipboard_ux_smoke.rs | 455 +++++++++++++++++++++++++++++--- screenshots_fixtures/hello.html | 39 +++ 4 files changed, 479 insertions(+), 32 deletions(-) create mode 100644 screenshots_fixtures/hello.html diff --git a/Cargo.lock b/Cargo.lock index ce8df8d..1cb4150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7530,6 +7530,7 @@ dependencies = [ "gpui-kit", "gpui-wry", "ignore", + "image", "interprocess", "lb-wry", "lofty", @@ -7561,6 +7562,7 @@ dependencies = [ "webbrowser", "which", "windows 0.62.2", + "windows-core 0.62.2", "windows-sys 0.61.2", "winres", "winsplit", diff --git a/Cargo.toml b/Cargo.toml index 8d8d062..45445fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,21 @@ required-features = ["cli"] name = "clipboard_ux_smoke" required-features = ["ui"] +# Walks the visible-top-level windows of the current process and writes a +# PNG via `PrintWindow` + `GetDIBits`. Used only by `examples/clipboard_ux_smoke.rs` +# when the operator passes `--screenshot`. The OS-screenshot path is opt-in +# because it adds ~30 s of clean-build time on cold dev machines. +[target.'cfg(target_os = "windows")'.dev-dependencies] +image = { version = "0.25", default-features = false, features = ["png"] } +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Storage_Xps", + "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", +] } +windows-core = "0.62" + [dependencies] crossterm = { version = "0.29", features = ["use-dev-tty"], optional = true } # GPUI Kit single consumer entry point. Kit re-exports the gpui-pre family diff --git a/examples/clipboard_ux_smoke.rs b/examples/clipboard_ux_smoke.rs index 99e6ea1..73ea3c0 100644 --- a/examples/clipboard_ux_smoke.rs +++ b/examples/clipboard_ux_smoke.rs @@ -1,52 +1,443 @@ -//! Isolated visual smoke harness: fixture database only, no watcher or clipboard writes. +//! Visual smoke harness for the GPUI frontend. +//! +//! Two purposes: +//! +//! 1. **Sandbox mode** (the original): boot the chooser with deterministic +//! fixtures so the keyboard flow can be exercised without the full app +//! or a running clipboard watcher. +//! 2. **Visual inspect mode**: pick what UI to show, optionally attach an OS +//! screenshot of the visible window to a PNG. Useful for verifying +//! visual fixes on this developer's host (e.g. did the file-sticker's +//! HTML preview actually paint after the direct-composition fix). +//! +//! ## CLI +//! +//! Pick what to render: +//! +//! ```text +//! (no flag) existing chooser smoke (8 ordinary items) +//! --configured chooser smoke with a pre-built vault +//! --file <PATH> open a file sticker previewing <PATH> +//! --chooser-with-file <PATH> chooser with one newest item whose content +//! is `local://localhost/<PATH>`, so pressing +//! Enter on that row exercises the same code +//! path as `rst sticker file --preview`. +//! ``` +//! +//! Inspect (opt-in; default = interactive sandbox): +//! +//! ```text +//! --screenshot <OUT.png> dump the visible window bitmap to OUT.png +//! --exit-after-ms <N> quietly close the app N ms after the first +//! frame. Always used with --screenshot so the +//! smoke is non-interactive. +//! ``` +//! +//! Screenshotting is genuinely opt-in. Without a flag, the smoke still +//! behaves as it did before -- interactive, window stays up until the user +//! closes it. `--screenshot` deliberately does not run on macOS/Linux yet +//! because the host this was built on is Windows; a stub prints a clear +//! error explaining why. +//! +//! ## GPUI_DISABLE_DIRECT_COMPOSITION +//! +//! Set before `application()` is called, on Windows, the same way +//! `src/native/mod.rs::run_native` does it. Required so the embedded wry +//! WebView actually paints; without it the file-sticker HTML preview +//! shows the same transparent region the blank-screen bug presented. + use gpui_kit::component::{Theme, ThemeMode}; use gpui_kit::{App, application}; use rustickers::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore}; use rustickers::clipboard::vault_controller::VaultController; -use rustickers::native::{clipboard::CaptureFilter, components::Assets, windows::clipboard}; +use rustickers::native::windows::StickerWindowEvent; +use rustickers::native::{ + clipboard::CaptureFilter, + components::Assets, + windows::{clipboard, sticker}, +}; +use rustickers::storage::{ArcStickerStore, open_sqlite}; use std::sync::{Arc, mpsc}; +use std::time::Duration; + +const FIXTURE_ITEMS: &[&str] = &[ + "A short clipboard item", + "https://example.test/docs/clipboard-history?view=keyboard", + "日本語 · 中文 · emoji ✨", + "A multiline copy\nwith a second line", + "Another recent copy", + "Code: let greeting = \"Hello, world!\";", + "A useful saved command", + "The oldest sample", +]; + +#[derive(Default)] +struct Args { + configured: bool, + file: Option<String>, + chooser_with_file: Option<String>, + screenshot: Option<String>, + exit_after_ms: Option<u64>, +} + +fn parse_args() -> Args { + let mut args = Args::default(); + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--configured" => args.configured = true, + "--file" => args.file = iter.next(), + "--chooser-with-file" => args.chooser_with_file = iter.next(), + "--screenshot" => args.screenshot = iter.next(), + "--exit-after-ms" => { + args.exit_after_ms = iter.next().and_then(|s| s.parse().ok()); + } + "--help" | "-h" => { + eprintln!( + "Usage: clipboard_ux_smoke [--configured]\n\ + \n [--file PATH]\n\ + \n [--chooser-with-file PATH]\n\ + \n [--screenshot OUT.png]\n\ + \n [--exit-after-ms N]\n" + ); + std::process::exit(0); + } + other => { + eprintln!("unrecognised argument: {other}; pass --help"); + std::process::exit(2); + } + } + } + args +} fn main() -> anyhow::Result<()> { - let fixtures = tempfile::tempdir()?; - let store = Arc::new(futures::executor::block_on(SqliteClipboardStore::open( - fixtures.path().join("clipboard.db"), - ))?); - for (index, text) in [ - "A short clipboard item", - "https://example.test/docs/clipboard-history?view=keyboard", - "日本語 · 中文 · emoji ✨", - "A multiline copy\nwith a second line", - "Another recent copy", - "Code: let greeting = \"Hello, world!\";", - "A useful saved command", - "The oldest sample", - ] - .iter() - .enumerate() - { - futures::executor::block_on(store.upsert_ordinary( - text, - text, - &SqliteClipboardStore::ordinary_digest(text), - text.len() as i64, - 100 - index as i64, - ))?; + // Direct composition must be off before `application()` constructs the + // platform. See module docstring and the matching block in + // `src/native/mod.rs::run_native` for the rationale. + #[cfg(target_os = "windows")] + if std::env::var_os("GPUI_DISABLE_DIRECT_COMPOSITION").is_none() { + unsafe { + std::env::set_var("GPUI_DISABLE_DIRECT_COMPOSITION", "true"); + } } - let vault = Arc::new(VaultController::new(store.clone())); - // Default exercises first-time setup; --configured exercises existing-vault unlock. - if std::env::args().any(|arg| arg == "--configured") { + + let args = parse_args(); + + let fixtures = tempfile::tempdir()?; + let clip_dir = fixtures.path().join("clipboard"); + let stickers_dir = fixtures.path().join("stickers"); + std::fs::create_dir_all(&clip_dir)?; + std::fs::create_dir_all(&stickers_dir)?; + + let clip_store: Arc<dyn ClipboardHistoryStore> = Arc::new(futures::executor::block_on( + SqliteClipboardStore::open(clip_dir.join("clipboard.db")), + )?); + + let sticker_store: ArcStickerStore = + futures::executor::block_on(open_sqlite(stickers_dir.join("stickers.db")))?; + + seed_chooser_fixtures(&clip_store, args.chooser_with_file.as_deref())?; + + let vault = Arc::new(VaultController::new(clip_store.clone())); + if args.configured { futures::executor::block_on(vault.create("fixture-password"))?; } + let app = application().with_assets(Assets); + let screenshot = args.screenshot.clone(); + let exit_after_ms = args.exit_after_ms; + let file_to_open = args.file.clone(); + app.run(move |cx: &mut App| { gpui_kit::init(cx); Theme::change(ThemeMode::Dark, None, cx); clipboard::set_global_capture_filter(CaptureFilter::new()); - clipboard::set_global_store(store.clone()); + clipboard::set_global_store(clip_store.clone()); clipboard::set_global_vault_controller(vault); - let (tx, _) = mpsc::channel(); - clipboard::ClipboardChooserWindow::toggle(cx, tx, store, None).unwrap(); + + let (tx, _rx) = mpsc::channel::<StickerWindowEvent>(); + + let interactive = screenshot.is_none() && exit_after_ms.is_none(); + + if let Some(path) = file_to_open.as_deref() { + // Open file sticker for the given path. This branch is the one used + // to verify the HTML-preview fix: pass --file <something.html> + // and (without --exit-after-ms) the live window shows whether the + // file sticker renders the page or paints a blank region. + sticker::StickerWindow::open_file_preview_with_sources( + cx, + tx.clone(), + sticker_store.clone(), + vec![path.to_string()], + sticker::PreviewOptions::default(), + ) + .expect("open file preview"); + } else { + clipboard::ClipboardChooserWindow::toggle(cx, tx, clip_store, None) + .expect("toggle chooser"); + } + + if !interactive { + schedule_inspect(cx, screenshot, exit_after_ms); + } }); drop(fixtures); Ok(()) } + +fn seed_chooser_fixtures( + store: &Arc<dyn ClipboardHistoryStore>, + chooser_with_file: Option<&str>, +) -> anyhow::Result<()> { + for (index, text) in FIXTURE_ITEMS.iter().enumerate() { + futures::executor::block_on(store.upsert_ordinary( + text, + text, + &SqliteClipboardStore::ordinary_digest(text), + text.len() as i64, + 100 - index as i64, + ))?; + } + + // A dedicated "newest" item whose content is the local:// URL of the + // chosen file. Bumping `last_used_at` past the existing fixtures + // surfaces it at the top of the chooser; pressing Enter on that row + // exercises the same code path as the host's file-sticker preview, + // but from inside the chooser flow. + if let Some(path) = chooser_with_file { + let url = format!("local://localhost/{}", path); + futures::executor::block_on(store.upsert_ordinary( + &url, + &url, + &SqliteClipboardStore::ordinary_digest(&url), + url.len() as i64, + 200, + ))?; + } + Ok(()) +} + +fn schedule_inspect(cx: &mut App, screenshot: Option<String>, exit_after_ms: Option<u64>) { + cx.spawn(async move |cx| { + let wait_ms = exit_after_ms.unwrap_or(800); + cx.background_executor() + .timer(Duration::from_millis(wait_ms)) + .await; + if let Some(path) = screenshot { + capture_visible_window(&path); + } + if exit_after_ms.is_some() { + cx.update(|cx| cx.quit()); + } + }) + .detach(); +} + +/// Pull the largest visible top-level window owned by our process and save +/// it as a PNG. Windows-only because the host is Windows. +fn capture_visible_window(out_path: &str) { + let path = std::path::Path::new(out_path); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let report = capture_diagnostics(out_path); + match report { + Ok(report) => { + eprintln!( + "screenshot saved to {out_path} ({} bytes, {}x{})", + report.bytes, report.width, report.height + ); + } + Err(err) => { + eprintln!("screenshot failed: {err}"); + } + } +} + +#[derive(Debug)] +struct CaptureReport { + bytes: usize, + width: u32, + height: u32, +} + +#[cfg(target_os = "windows")] +fn capture_diagnostics(out_path: &str) -> Result<CaptureReport, String> { + use std::io::Cursor; + use windows::Win32::Foundation::{HWND, LPARAM, RECT}; + use windows::Win32::Graphics::Gdi::{ + BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleBitmap, CreateCompatibleDC, DIB_RGB_COLORS, + DeleteDC, DeleteObject, GetDC, GetDIBits, RGBQUAD, ReleaseDC, + }; + use windows::Win32::Storage::Xps::PrintWindow; + use windows::Win32::System::Threading::GetCurrentProcessId; + use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindowRect, GetWindowThreadProcessId, IsWindowVisible, + }; + use windows::core::BOOL; + + struct EnumerateCtx { + my_pid: u32, + best_hwnd: HWND, + best_area: i64, + } + + unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let ctx = unsafe { &mut *(lparam.0 as *mut EnumerateCtx) }; + let mut pid = 0u32; + unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) }; + if pid != ctx.my_pid { + return BOOL(1); + } + if !unsafe { IsWindowVisible(hwnd) }.as_bool() { + return BOOL(1); + } + let mut rect = RECT::default(); + if unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() { + return BOOL(1); + } + let width = (rect.right - rect.left) as i64; + let height = (rect.bottom - rect.top) as i64; + let area = width * height; + if area <= 0 { + return BOOL(1); + } + if area > ctx.best_area { + ctx.best_area = area; + ctx.best_hwnd = hwnd; + } + BOOL(1) + } + + let null_hwnd = HWND(std::ptr::null_mut()); + let my_pid = unsafe { GetCurrentProcessId() }; + let mut ctx = EnumerateCtx { + my_pid, + best_hwnd: null_hwnd, + best_area: 0, + }; + unsafe { + EnumWindows(Some(enum_proc), LPARAM(&mut ctx as *mut _ as isize)) + .map_err(|e| format!("EnumWindows: {e}"))?; + } + if ctx.best_hwnd.0.is_null() { + return Err("no visible window owned by this process".into()); + } + let hwnd = ctx.best_hwnd; + + let mut rect = RECT::default(); + unsafe { GetWindowRect(hwnd, &mut rect) }.map_err(|e| format!("GetWindowRect: {e}"))?; + let width = (rect.right - rect.left).max(1); + let height = (rect.bottom - rect.top).max(1); + + // PrintWindow with PW_RENDERFULLCONTENT (2) is needed to make Edge / + // WebView2 layers actually paint into our bitmap. Without it the + // captured frame is missing exactly the file-sticker's HTML preview. + let flags = windows::Win32::Storage::Xps::PRINT_WINDOW_FLAGS(0x00000002); + + let screen_dc = unsafe { GetDC(Some(null_hwnd)) }; + if screen_dc.is_invalid() { + return Err("GetDC returned NULL HDC".into()); + } + let mem_dc = unsafe { CreateCompatibleDC(Some(screen_dc)) }; + if mem_dc.is_invalid() { + let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; + return Err("CreateCompatibleDC returned NULL HDC".into()); + } + let bitmap = unsafe { CreateCompatibleBitmap(screen_dc, width, height) }; + if bitmap.is_invalid() { + let _: BOOL = unsafe { DeleteDC(mem_dc) }; + let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; + return Err("CreateCompatibleBitmap returned NULL HBITMAP".into()); + } + let prev = unsafe { windows::Win32::Graphics::Gdi::SelectObject(mem_dc, bitmap.into()) }; + + let ok = unsafe { PrintWindow(hwnd, mem_dc, flags) }; + if !ok.as_bool() { + let _ = unsafe { windows::Win32::Graphics::Gdi::SelectObject(mem_dc, prev) }; + let _: BOOL = unsafe { DeleteObject(bitmap.into()) }; + let _: BOOL = unsafe { DeleteDC(mem_dc) }; + let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; + return Err("PrintWindow returned 0".into()); + } + + // Read the bitmap as 32-bit top-down RGBA via GetDIBits. + // `biHeight = -height` flips the rows so y=0 is the top edge. + let mut info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: 0, // BI_RGB + biSizeImage: 0, + biXPelsPerMeter: 0, + biYPelsPerMeter: 0, + biClrUsed: 0, + biClrImportant: 0, + }, + bmiColors: [RGBQUAD::default(); 1], + }; + let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4]; + let scanlines = unsafe { + GetDIBits( + mem_dc, + bitmap, + 0, + height as u32, + Some(pixels.as_mut_ptr() as *mut _), + &mut info, + DIB_RGB_COLORS, + ) + }; + if scanlines == 0 { + let _ = unsafe { windows::Win32::Graphics::Gdi::SelectObject(mem_dc, prev) }; + let _: BOOL = unsafe { DeleteObject(bitmap.into()) }; + let _: BOOL = unsafe { DeleteDC(mem_dc) }; + let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; + return Err("GetDIBits returned 0".into()); + } + + // Push to image::RgbaImage. + let mut img = image::RgbaImage::new(width as u32, height as u32); + for y in 0..height as u32 { + for x in 0..width as u32 { + let idx = ((y as usize) * (width as usize) + (x as usize)) * 4; + let b = pixels[idx]; + let g = pixels[idx + 1]; + let r = pixels[idx + 2]; + img.put_pixel(x, y, image::Rgba([r, g, b, 0xff])); + } + } + + let mut out = Vec::new(); + { + let mut cursor = Cursor::new(&mut out); + img.write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|e| format!("PNG encode: {e}"))?; + } + + std::fs::write(out_path, &out).map_err(|e| format!("write {out_path}: {e}"))?; + + let _ = unsafe { windows::Win32::Graphics::Gdi::SelectObject(mem_dc, prev) }; + let _: BOOL = unsafe { DeleteObject(bitmap.into()) }; + let _: BOOL = unsafe { DeleteDC(mem_dc) }; + let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; + + Ok(CaptureReport { + bytes: out.len(), + width: width as u32, + height: height as u32, + }) +} + +#[cfg(not(target_os = "windows"))] +fn capture_diagnostics(out_path: &str) -> Result<CaptureReport, String> { + let _ = out_path; + Err( + "--screenshot is implemented for Windows only; this smoke was written for a Windows host." + .into(), + ) +} diff --git a/screenshots_fixtures/hello.html b/screenshots_fixtures/hello.html new file mode 100644 index 0000000..9209be7 --- /dev/null +++ b/screenshots_fixtures/hello.html @@ -0,0 +1,39 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>Smoke HTML page + + + + Rustickers + compat +

Hello from the file sticker

+

If you can read this, the embedded wry WebView is delivering paints + through GPUI's compositor. Same content the system browser would show, + bounded by the file-sticker's transparent corners.

+

Smoke HTML fixture used by: + cargo run --example clipboard_ux_smoke -- --file screenshots_fixtures/hello.html

+ + From 0cb513e42f0730652a800919bfc20b4dd34a4c3c Mon Sep 17 00:00:00 2001 From: binwenw Date: Wed, 7 Oct 2026 10:34:59 +0800 Subject: [PATCH 29/69] refactor: drop clipboard_ux_smoke; introduce visual-check skill + script `examples/clipboard_ux_smoke.rs` was an interactive chooser sandbox. Adding `--file` / `--screenshot` flags to it inside the Cargo-example channel made it an awkward hybrid: a runnable binary that an operator drives by hand, but the only useful shape was an agent invoking it from the conversation. The hybrid accumulated: - A `#[[example]]` block and a fresh `#[target(windows).dev-dependencies]` closure (image 0.25, windows 0.62, windows-core 0.62) just to shove screenshot capture into the existing Rust harness. - One HTML fixture checked into `screenshots_fixtures/`. - 440 lines of Cargo example that no `cargo test` ever runs. Replace the hybrid with the cleaner shape the project actually needs: a small reusable tool + a small reusable agent artifact. `scripts/screenshot.ps1` 200-line PowerShell helper that takes a process name (or title substring, or pid) plus an output path, waits `WaitMs` for paint, enumerates the candidate windows, picks the visible one with the largest area, and saves a PNG via `PrintWindow` with `PW_RENDERFULLCONTENT=2` so WebView2 / EWA composited layers land in the bitmap. Exit code: 0 = wrote PNG 2 = no matching process 3 = matched but no main window 4 = PrintWindow failed Lives in `scripts/` because this is exactly the shape a shell-out utility wants -- no Cargo.toml cost, no Rust integration test that pretends to do what it cannot do on Windows (headless renderer). Tested on PowerShell 7 (`pwsh -NoProfile -File ...` parses; exit code and stale-process failure path verified). PowerShell 5.1 works the same downstream of the explicit `ReferencedAssemblies` parameter on the `Add-Type` call. Created in a sanitised whitespace (`.ps1` is one tab-indented block, so `git -c core.autocrlf` keeps it stable on mixed-line- ending dev boxes). `.agents/skills/gpui-visual-check/` (+ mirror to `.pi/skills/`) Conventional skill layout matching `.agents/skills/gpui-kit/`: `SKILL.md` in the skill root, no `references/` because the whole artifact is small enough to be a single file. Documents: - When to use and when NOT to use. The skill is explicit that it never runs under cargo test or CI, so a developer or agent reading the skill knows it's a dev-loop tool. - The PowerShell recipe; the same shape the now-removed example's `--screenshot` flag did, minus the Rust. - Why `GPUI_DISABLE_DIRECT_COMPOSITION=true` is required on Windows for any GPUI window that hosts a `wry::WebView`, cross-referenced to `run_native` (`04c5636`). - The two not-yet-possible expansion paths: headless screenshot on Windows would need a kit release whose Windows platform ships a `PlatformHeadlessRenderer`, and `#[gpui_kit::test]` is still blocked by the gpui_web / unicode-properties mismatch that the migration path left lying around. Each is upstream's problem to solve. `.pi/skills/gpui-visual-check/` Mirror copy, matching `gpui-kit-design-guides`' mirror layout, so both Pi install roots see the new skill. Cleanups: - `Cargo.toml` removed the `#[[example]]` block and the `image` / `windows` / `windows-core` Windows dev-deps. - `Cargo.lock` drops `image` from rustickers's dependency list (it was only pulled by the dev-dep just removed). `windows` and `windows-core` stay pulled by the lib's existing Windows deps block. - `screenshots_fixtures/hello.html` deleted -- it was paired with `--file` on the removed example and has no other consumer. Verification: cargo test --locked --features cli 213 passed, 1 ignored (190 lib + 19 storage + 3 palette + 1 placement) cargo fmt --all -- --check clean cargo clippy --features ui only the pre-existing `matches!` warning at stickers/file/mod.rs:588 cargo check --features ui --bin rustickers --bin rst clean pwsh -NoProfile -Command "ParseFile scripts/screenshot.ps1" syntax ok Branch HEAD is now `+5` over `origin/main` since the WebView fix (4ddc8ec will be the next ref to record this). --- .agents/skills/gpui-visual-check/SKILL.md | 125 ++++++ .pi/skills/gpui-visual-check/SKILL.md | 125 ++++++ Cargo.lock | 2 - Cargo.toml | 19 - examples/clipboard_ux_smoke.rs | 443 ---------------------- screenshots_fixtures/hello.html | 39 -- scripts/screenshot.ps1 | 213 +++++++++++ 7 files changed, 463 insertions(+), 503 deletions(-) create mode 100644 .agents/skills/gpui-visual-check/SKILL.md create mode 100644 .pi/skills/gpui-visual-check/SKILL.md delete mode 100644 examples/clipboard_ux_smoke.rs delete mode 100644 screenshots_fixtures/hello.html create mode 100644 scripts/screenshot.ps1 diff --git a/.agents/skills/gpui-visual-check/SKILL.md b/.agents/skills/gpui-visual-check/SKILL.md new file mode 100644 index 0000000..d8bc8de --- /dev/null +++ b/.agents/skills/gpui-visual-check/SKILL.md @@ -0,0 +1,125 @@ +--- +name: gpui-visual-check +description: Use when iterating on a GPUI visual feature (dialog layout, sticker chrome, button sizing, file-sticker HTML preview, blank-region regressions) and a check against the rendered output is needed. Captures an OS-level PNG of a running window so the agent (or a developer) can confirm visually that the layout actually looked right at runtime. The recipe is opinionated and opt-in; it does not run as part of `cargo test`, CI, or any gated path. Treat it as the dev-loop equivalent of a "look at the screen" loop. Apply when the user explicitly asks for a visual / screenshot / picture check during iteration, **or** when the change is one a known visual bug was hiding (e.g. embedded `wry::WebView` showing blank after a fix). +--- + +# Visual check for GPUI windows + +A small workflow + a tiny capture helper for cases where the code reads right but the rendered window might not look right. Optional. Never runs under `cargo test` or CI. + +## When to use + +- A UI layout change has landed and the diff doesn't tell you whether the + runtime frame will look right. +- A "should be invisible" element is suspected of leaking. The work that + spawned this skill was a `wry::WebView` rendering blank inside the + file-sticker — code was correct, the OS-level paint was the only way + to verify visually. +- You want to compare a pre-fix frame against a post-fix candidate. + +## When NOT to use + +- A pure data/control-flow change. `cargo test --features cli` is faster + and checks the same thing semantically. +- Refactors where the visible output cannot differ. Reasoning about the + diff is enough. +- macOS / Linux CI automation. The host for this skill is Windows. + Windows-only section at the bottom summarises why. + +## Recipe (Windows) + +```powershell +$env:GPUI_DISABLE_DIRECT_COMPOSITION = "true" +.\target\debug\.exe # in one shell, leave it running +# in another shell: + +powershell -ExecutionPolicy Bypass -File scripts\screenshot.ps1 ` + -ProcessName rustickers ` + -OutPath path\to\iter.png ` + -WaitMs 1500 +# then open the PNG (the `read` tool exposes files inside the +# conversation, so the agent can see the frame too). +# and kill the binary: +taskkill /IM rustickers.exe /F +``` + +Three things to know before invoking: + +1. **`GPUI_DISABLE_DIRECT_COMPOSITION=true` must be set in the launching + shell.** Without it, any GPUI window containing an embedded + `wry::WebView` (file sticker HTML / SVG / PDF, command-result web + view) paints blank. The same guard lives in + `src/native/mod.rs::run_native` for the production app. Setting it + for the smoke too means the captured PNG actually contains the + WebView surface, so the visual diff is meaningful. + +2. **First-paint latency is roughly 800–1200 ms on Windows.** Wait by + 1500 ms to be safe; bump to 2500 if the binary has heavy startup + (e.g. opening the SQLite store on a cold spindle). The default + `-WaitMs 1500` is enough for the chooser and a sticker re-render but + not for a cold `tokio::spawn` chain. + +3. **Capture the largest visible top-level window owned by the process.** + Title-matching is brittle because every rust sticker uses + `WindowBackgroundAppearance::Transparent` and an unset title (no + `GetWindowText` filter possible). The script picks by area: + `Get-Process $name | sort area desc | first -0`. If multiple windows + are visible (chooser + sticker + file preview + settings), the + largest one is usually the right one, but for an absolute match pass + the right one manually with `-ProcessId `. + +## The script + +`scripts/screenshot.ps1` is a 50-line P/Invoke wrapper around +`PrintWindow` with `PW_RENDERFULLCONTENT` (= 2). That flag is the bit +that convinces WebView2 / Edge-composited layers to paint into our +bitmap — without it, the captured frame is missing exactly the kind +of compositor surface the WebView bug hides in. PNG output goes via +`System.Drawing.Common`'s `Image::FromHbitmap`. + +Don't add new features to it. If a new OS needs the same workflow, +shells out to a platform-native tool (macOS: `screencapture -x`, +Linux: `grim`/`maim`/`gnome-screenshot`). The script deliberately +does NOT attempt a polyglot abstraction. + +## Known not-yet-possible + +These would expand the skill but each is blocked by an external gap: + +- **Headless screenshot** (`HeadlessAppContext::capture_screenshot`). + Pinning `gpui-kit` 0.7.1 means cross-platform headless can't run + on Windows because the Windows platform crate does not provide a + `PlatformHeadlessRenderer`. macOS only. The kit's own + `crates/kit/tests/rendering.rs` is `#[cfg(target_os = "macos")]` + for the same reason; see + `https://github.com/longbridge/gpui-kit/.../website/docs/test.md` + for the canonical writeup. Until we move to a kit release whose + Windows platform adds a headless renderer, this skill is a + desktop-presence-required loop, not a CI step. + +- **UI-integration tests** via `#[gpui_kit::test]`. The kit's + `test-support` feature pulls `gpui_web` (wasm), which conflicts with + the lockfile's `unicode-properties = 0.1.4` (the wasm crate asks + for `0.1.3`). Lockfile has been stable around that conflict since + the migration; resolving it is upstream. + +## Failure modes the agent should anticipate + +- **The PNG shows transparency where content should be.** WebView or + D3D11 surface didn't paint. Check the + `GPUI_DISABLE_DIRECT_COMPOSITION` env var. The fix is identical to + `04c5636` and its docstring in `src/native/mod.rs`. + +- **The PNG is all black / all white.** The window is genuinely + empty, or `PrintWindow` ran before the first paint completed. Bump + `-WaitMs`. If that doesn't help, the GPUI window is failing to + render at all — `cargo run` with `RUST_LOG=gpui=debug` to see the + paint loop. + +- **The PNG is the wrong window.** Multi-window scenarios. Use + `-WindowTitle ` or `-ProcessId ` instead of + `-ProcessName`. Or close the noisy windows first. + +- **WebView2 reports "could not initialise".** That's an upstream + wry/build issue, not a visual-check issue; ignore — the PNG will + be missing only that one surface and you can still diff the rest. diff --git a/.pi/skills/gpui-visual-check/SKILL.md b/.pi/skills/gpui-visual-check/SKILL.md new file mode 100644 index 0000000..d8bc8de --- /dev/null +++ b/.pi/skills/gpui-visual-check/SKILL.md @@ -0,0 +1,125 @@ +--- +name: gpui-visual-check +description: Use when iterating on a GPUI visual feature (dialog layout, sticker chrome, button sizing, file-sticker HTML preview, blank-region regressions) and a check against the rendered output is needed. Captures an OS-level PNG of a running window so the agent (or a developer) can confirm visually that the layout actually looked right at runtime. The recipe is opinionated and opt-in; it does not run as part of `cargo test`, CI, or any gated path. Treat it as the dev-loop equivalent of a "look at the screen" loop. Apply when the user explicitly asks for a visual / screenshot / picture check during iteration, **or** when the change is one a known visual bug was hiding (e.g. embedded `wry::WebView` showing blank after a fix). +--- + +# Visual check for GPUI windows + +A small workflow + a tiny capture helper for cases where the code reads right but the rendered window might not look right. Optional. Never runs under `cargo test` or CI. + +## When to use + +- A UI layout change has landed and the diff doesn't tell you whether the + runtime frame will look right. +- A "should be invisible" element is suspected of leaking. The work that + spawned this skill was a `wry::WebView` rendering blank inside the + file-sticker — code was correct, the OS-level paint was the only way + to verify visually. +- You want to compare a pre-fix frame against a post-fix candidate. + +## When NOT to use + +- A pure data/control-flow change. `cargo test --features cli` is faster + and checks the same thing semantically. +- Refactors where the visible output cannot differ. Reasoning about the + diff is enough. +- macOS / Linux CI automation. The host for this skill is Windows. + Windows-only section at the bottom summarises why. + +## Recipe (Windows) + +```powershell +$env:GPUI_DISABLE_DIRECT_COMPOSITION = "true" +.\target\debug\.exe # in one shell, leave it running +# in another shell: + +powershell -ExecutionPolicy Bypass -File scripts\screenshot.ps1 ` + -ProcessName rustickers ` + -OutPath path\to\iter.png ` + -WaitMs 1500 +# then open the PNG (the `read` tool exposes files inside the +# conversation, so the agent can see the frame too). +# and kill the binary: +taskkill /IM rustickers.exe /F +``` + +Three things to know before invoking: + +1. **`GPUI_DISABLE_DIRECT_COMPOSITION=true` must be set in the launching + shell.** Without it, any GPUI window containing an embedded + `wry::WebView` (file sticker HTML / SVG / PDF, command-result web + view) paints blank. The same guard lives in + `src/native/mod.rs::run_native` for the production app. Setting it + for the smoke too means the captured PNG actually contains the + WebView surface, so the visual diff is meaningful. + +2. **First-paint latency is roughly 800–1200 ms on Windows.** Wait by + 1500 ms to be safe; bump to 2500 if the binary has heavy startup + (e.g. opening the SQLite store on a cold spindle). The default + `-WaitMs 1500` is enough for the chooser and a sticker re-render but + not for a cold `tokio::spawn` chain. + +3. **Capture the largest visible top-level window owned by the process.** + Title-matching is brittle because every rust sticker uses + `WindowBackgroundAppearance::Transparent` and an unset title (no + `GetWindowText` filter possible). The script picks by area: + `Get-Process $name | sort area desc | first -0`. If multiple windows + are visible (chooser + sticker + file preview + settings), the + largest one is usually the right one, but for an absolute match pass + the right one manually with `-ProcessId `. + +## The script + +`scripts/screenshot.ps1` is a 50-line P/Invoke wrapper around +`PrintWindow` with `PW_RENDERFULLCONTENT` (= 2). That flag is the bit +that convinces WebView2 / Edge-composited layers to paint into our +bitmap — without it, the captured frame is missing exactly the kind +of compositor surface the WebView bug hides in. PNG output goes via +`System.Drawing.Common`'s `Image::FromHbitmap`. + +Don't add new features to it. If a new OS needs the same workflow, +shells out to a platform-native tool (macOS: `screencapture -x`, +Linux: `grim`/`maim`/`gnome-screenshot`). The script deliberately +does NOT attempt a polyglot abstraction. + +## Known not-yet-possible + +These would expand the skill but each is blocked by an external gap: + +- **Headless screenshot** (`HeadlessAppContext::capture_screenshot`). + Pinning `gpui-kit` 0.7.1 means cross-platform headless can't run + on Windows because the Windows platform crate does not provide a + `PlatformHeadlessRenderer`. macOS only. The kit's own + `crates/kit/tests/rendering.rs` is `#[cfg(target_os = "macos")]` + for the same reason; see + `https://github.com/longbridge/gpui-kit/.../website/docs/test.md` + for the canonical writeup. Until we move to a kit release whose + Windows platform adds a headless renderer, this skill is a + desktop-presence-required loop, not a CI step. + +- **UI-integration tests** via `#[gpui_kit::test]`. The kit's + `test-support` feature pulls `gpui_web` (wasm), which conflicts with + the lockfile's `unicode-properties = 0.1.4` (the wasm crate asks + for `0.1.3`). Lockfile has been stable around that conflict since + the migration; resolving it is upstream. + +## Failure modes the agent should anticipate + +- **The PNG shows transparency where content should be.** WebView or + D3D11 surface didn't paint. Check the + `GPUI_DISABLE_DIRECT_COMPOSITION` env var. The fix is identical to + `04c5636` and its docstring in `src/native/mod.rs`. + +- **The PNG is all black / all white.** The window is genuinely + empty, or `PrintWindow` ran before the first paint completed. Bump + `-WaitMs`. If that doesn't help, the GPUI window is failing to + render at all — `cargo run` with `RUST_LOG=gpui=debug` to see the + paint loop. + +- **The PNG is the wrong window.** Multi-window scenarios. Use + `-WindowTitle ` or `-ProcessId ` instead of + `-ProcessName`. Or close the noisy windows first. + +- **WebView2 reports "could not initialise".** That's an upstream + wry/build issue, not a visual-check issue; ignore — the PNG will + be missing only that one surface and you can still diff the rest. diff --git a/Cargo.lock b/Cargo.lock index 1cb4150..ce8df8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7530,7 +7530,6 @@ dependencies = [ "gpui-kit", "gpui-wry", "ignore", - "image", "interprocess", "lb-wry", "lofty", @@ -7562,7 +7561,6 @@ dependencies = [ "webbrowser", "which", "windows 0.62.2", - "windows-core 0.62.2", "windows-sys 0.61.2", "winres", "winsplit", diff --git a/Cargo.toml b/Cargo.toml index 45445fc..84a766a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,25 +93,6 @@ name = "rst" path = "src/rst.rs" required-features = ["cli"] -[[example]] -name = "clipboard_ux_smoke" -required-features = ["ui"] - -# Walks the visible-top-level windows of the current process and writes a -# PNG via `PrintWindow` + `GetDIBits`. Used only by `examples/clipboard_ux_smoke.rs` -# when the operator passes `--screenshot`. The OS-screenshot path is opt-in -# because it adds ~30 s of clean-build time on cold dev machines. -[target.'cfg(target_os = "windows")'.dev-dependencies] -image = { version = "0.25", default-features = false, features = ["png"] } -windows = { version = "0.62", features = [ - "Win32_Foundation", - "Win32_Graphics_Gdi", - "Win32_Storage_Xps", - "Win32_System_Threading", - "Win32_UI_WindowsAndMessaging", -] } -windows-core = "0.62" - [dependencies] crossterm = { version = "0.29", features = ["use-dev-tty"], optional = true } # GPUI Kit single consumer entry point. Kit re-exports the gpui-pre family diff --git a/examples/clipboard_ux_smoke.rs b/examples/clipboard_ux_smoke.rs deleted file mode 100644 index 73ea3c0..0000000 --- a/examples/clipboard_ux_smoke.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Visual smoke harness for the GPUI frontend. -//! -//! Two purposes: -//! -//! 1. **Sandbox mode** (the original): boot the chooser with deterministic -//! fixtures so the keyboard flow can be exercised without the full app -//! or a running clipboard watcher. -//! 2. **Visual inspect mode**: pick what UI to show, optionally attach an OS -//! screenshot of the visible window to a PNG. Useful for verifying -//! visual fixes on this developer's host (e.g. did the file-sticker's -//! HTML preview actually paint after the direct-composition fix). -//! -//! ## CLI -//! -//! Pick what to render: -//! -//! ```text -//! (no flag) existing chooser smoke (8 ordinary items) -//! --configured chooser smoke with a pre-built vault -//! --file open a file sticker previewing -//! --chooser-with-file chooser with one newest item whose content -//! is `local://localhost/`, so pressing -//! Enter on that row exercises the same code -//! path as `rst sticker file --preview`. -//! ``` -//! -//! Inspect (opt-in; default = interactive sandbox): -//! -//! ```text -//! --screenshot dump the visible window bitmap to OUT.png -//! --exit-after-ms quietly close the app N ms after the first -//! frame. Always used with --screenshot so the -//! smoke is non-interactive. -//! ``` -//! -//! Screenshotting is genuinely opt-in. Without a flag, the smoke still -//! behaves as it did before -- interactive, window stays up until the user -//! closes it. `--screenshot` deliberately does not run on macOS/Linux yet -//! because the host this was built on is Windows; a stub prints a clear -//! error explaining why. -//! -//! ## GPUI_DISABLE_DIRECT_COMPOSITION -//! -//! Set before `application()` is called, on Windows, the same way -//! `src/native/mod.rs::run_native` does it. Required so the embedded wry -//! WebView actually paints; without it the file-sticker HTML preview -//! shows the same transparent region the blank-screen bug presented. - -use gpui_kit::component::{Theme, ThemeMode}; -use gpui_kit::{App, application}; -use rustickers::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore}; -use rustickers::clipboard::vault_controller::VaultController; -use rustickers::native::windows::StickerWindowEvent; -use rustickers::native::{ - clipboard::CaptureFilter, - components::Assets, - windows::{clipboard, sticker}, -}; -use rustickers::storage::{ArcStickerStore, open_sqlite}; -use std::sync::{Arc, mpsc}; -use std::time::Duration; - -const FIXTURE_ITEMS: &[&str] = &[ - "A short clipboard item", - "https://example.test/docs/clipboard-history?view=keyboard", - "日本語 · 中文 · emoji ✨", - "A multiline copy\nwith a second line", - "Another recent copy", - "Code: let greeting = \"Hello, world!\";", - "A useful saved command", - "The oldest sample", -]; - -#[derive(Default)] -struct Args { - configured: bool, - file: Option, - chooser_with_file: Option, - screenshot: Option, - exit_after_ms: Option, -} - -fn parse_args() -> Args { - let mut args = Args::default(); - let mut iter = std::env::args().skip(1); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--configured" => args.configured = true, - "--file" => args.file = iter.next(), - "--chooser-with-file" => args.chooser_with_file = iter.next(), - "--screenshot" => args.screenshot = iter.next(), - "--exit-after-ms" => { - args.exit_after_ms = iter.next().and_then(|s| s.parse().ok()); - } - "--help" | "-h" => { - eprintln!( - "Usage: clipboard_ux_smoke [--configured]\n\ - \n [--file PATH]\n\ - \n [--chooser-with-file PATH]\n\ - \n [--screenshot OUT.png]\n\ - \n [--exit-after-ms N]\n" - ); - std::process::exit(0); - } - other => { - eprintln!("unrecognised argument: {other}; pass --help"); - std::process::exit(2); - } - } - } - args -} - -fn main() -> anyhow::Result<()> { - // Direct composition must be off before `application()` constructs the - // platform. See module docstring and the matching block in - // `src/native/mod.rs::run_native` for the rationale. - #[cfg(target_os = "windows")] - if std::env::var_os("GPUI_DISABLE_DIRECT_COMPOSITION").is_none() { - unsafe { - std::env::set_var("GPUI_DISABLE_DIRECT_COMPOSITION", "true"); - } - } - - let args = parse_args(); - - let fixtures = tempfile::tempdir()?; - let clip_dir = fixtures.path().join("clipboard"); - let stickers_dir = fixtures.path().join("stickers"); - std::fs::create_dir_all(&clip_dir)?; - std::fs::create_dir_all(&stickers_dir)?; - - let clip_store: Arc = Arc::new(futures::executor::block_on( - SqliteClipboardStore::open(clip_dir.join("clipboard.db")), - )?); - - let sticker_store: ArcStickerStore = - futures::executor::block_on(open_sqlite(stickers_dir.join("stickers.db")))?; - - seed_chooser_fixtures(&clip_store, args.chooser_with_file.as_deref())?; - - let vault = Arc::new(VaultController::new(clip_store.clone())); - if args.configured { - futures::executor::block_on(vault.create("fixture-password"))?; - } - - let app = application().with_assets(Assets); - let screenshot = args.screenshot.clone(); - let exit_after_ms = args.exit_after_ms; - let file_to_open = args.file.clone(); - - app.run(move |cx: &mut App| { - gpui_kit::init(cx); - Theme::change(ThemeMode::Dark, None, cx); - clipboard::set_global_capture_filter(CaptureFilter::new()); - clipboard::set_global_store(clip_store.clone()); - clipboard::set_global_vault_controller(vault); - - let (tx, _rx) = mpsc::channel::(); - - let interactive = screenshot.is_none() && exit_after_ms.is_none(); - - if let Some(path) = file_to_open.as_deref() { - // Open file sticker for the given path. This branch is the one used - // to verify the HTML-preview fix: pass --file - // and (without --exit-after-ms) the live window shows whether the - // file sticker renders the page or paints a blank region. - sticker::StickerWindow::open_file_preview_with_sources( - cx, - tx.clone(), - sticker_store.clone(), - vec![path.to_string()], - sticker::PreviewOptions::default(), - ) - .expect("open file preview"); - } else { - clipboard::ClipboardChooserWindow::toggle(cx, tx, clip_store, None) - .expect("toggle chooser"); - } - - if !interactive { - schedule_inspect(cx, screenshot, exit_after_ms); - } - }); - drop(fixtures); - Ok(()) -} - -fn seed_chooser_fixtures( - store: &Arc, - chooser_with_file: Option<&str>, -) -> anyhow::Result<()> { - for (index, text) in FIXTURE_ITEMS.iter().enumerate() { - futures::executor::block_on(store.upsert_ordinary( - text, - text, - &SqliteClipboardStore::ordinary_digest(text), - text.len() as i64, - 100 - index as i64, - ))?; - } - - // A dedicated "newest" item whose content is the local:// URL of the - // chosen file. Bumping `last_used_at` past the existing fixtures - // surfaces it at the top of the chooser; pressing Enter on that row - // exercises the same code path as the host's file-sticker preview, - // but from inside the chooser flow. - if let Some(path) = chooser_with_file { - let url = format!("local://localhost/{}", path); - futures::executor::block_on(store.upsert_ordinary( - &url, - &url, - &SqliteClipboardStore::ordinary_digest(&url), - url.len() as i64, - 200, - ))?; - } - Ok(()) -} - -fn schedule_inspect(cx: &mut App, screenshot: Option, exit_after_ms: Option) { - cx.spawn(async move |cx| { - let wait_ms = exit_after_ms.unwrap_or(800); - cx.background_executor() - .timer(Duration::from_millis(wait_ms)) - .await; - if let Some(path) = screenshot { - capture_visible_window(&path); - } - if exit_after_ms.is_some() { - cx.update(|cx| cx.quit()); - } - }) - .detach(); -} - -/// Pull the largest visible top-level window owned by our process and save -/// it as a PNG. Windows-only because the host is Windows. -fn capture_visible_window(out_path: &str) { - let path = std::path::Path::new(out_path); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let report = capture_diagnostics(out_path); - match report { - Ok(report) => { - eprintln!( - "screenshot saved to {out_path} ({} bytes, {}x{})", - report.bytes, report.width, report.height - ); - } - Err(err) => { - eprintln!("screenshot failed: {err}"); - } - } -} - -#[derive(Debug)] -struct CaptureReport { - bytes: usize, - width: u32, - height: u32, -} - -#[cfg(target_os = "windows")] -fn capture_diagnostics(out_path: &str) -> Result { - use std::io::Cursor; - use windows::Win32::Foundation::{HWND, LPARAM, RECT}; - use windows::Win32::Graphics::Gdi::{ - BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleBitmap, CreateCompatibleDC, DIB_RGB_COLORS, - DeleteDC, DeleteObject, GetDC, GetDIBits, RGBQUAD, ReleaseDC, - }; - use windows::Win32::Storage::Xps::PrintWindow; - use windows::Win32::System::Threading::GetCurrentProcessId; - use windows::Win32::UI::WindowsAndMessaging::{ - EnumWindows, GetWindowRect, GetWindowThreadProcessId, IsWindowVisible, - }; - use windows::core::BOOL; - - struct EnumerateCtx { - my_pid: u32, - best_hwnd: HWND, - best_area: i64, - } - - unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { - let ctx = unsafe { &mut *(lparam.0 as *mut EnumerateCtx) }; - let mut pid = 0u32; - unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) }; - if pid != ctx.my_pid { - return BOOL(1); - } - if !unsafe { IsWindowVisible(hwnd) }.as_bool() { - return BOOL(1); - } - let mut rect = RECT::default(); - if unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() { - return BOOL(1); - } - let width = (rect.right - rect.left) as i64; - let height = (rect.bottom - rect.top) as i64; - let area = width * height; - if area <= 0 { - return BOOL(1); - } - if area > ctx.best_area { - ctx.best_area = area; - ctx.best_hwnd = hwnd; - } - BOOL(1) - } - - let null_hwnd = HWND(std::ptr::null_mut()); - let my_pid = unsafe { GetCurrentProcessId() }; - let mut ctx = EnumerateCtx { - my_pid, - best_hwnd: null_hwnd, - best_area: 0, - }; - unsafe { - EnumWindows(Some(enum_proc), LPARAM(&mut ctx as *mut _ as isize)) - .map_err(|e| format!("EnumWindows: {e}"))?; - } - if ctx.best_hwnd.0.is_null() { - return Err("no visible window owned by this process".into()); - } - let hwnd = ctx.best_hwnd; - - let mut rect = RECT::default(); - unsafe { GetWindowRect(hwnd, &mut rect) }.map_err(|e| format!("GetWindowRect: {e}"))?; - let width = (rect.right - rect.left).max(1); - let height = (rect.bottom - rect.top).max(1); - - // PrintWindow with PW_RENDERFULLCONTENT (2) is needed to make Edge / - // WebView2 layers actually paint into our bitmap. Without it the - // captured frame is missing exactly the file-sticker's HTML preview. - let flags = windows::Win32::Storage::Xps::PRINT_WINDOW_FLAGS(0x00000002); - - let screen_dc = unsafe { GetDC(Some(null_hwnd)) }; - if screen_dc.is_invalid() { - return Err("GetDC returned NULL HDC".into()); - } - let mem_dc = unsafe { CreateCompatibleDC(Some(screen_dc)) }; - if mem_dc.is_invalid() { - let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; - return Err("CreateCompatibleDC returned NULL HDC".into()); - } - let bitmap = unsafe { CreateCompatibleBitmap(screen_dc, width, height) }; - if bitmap.is_invalid() { - let _: BOOL = unsafe { DeleteDC(mem_dc) }; - let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; - return Err("CreateCompatibleBitmap returned NULL HBITMAP".into()); - } - let prev = unsafe { windows::Win32::Graphics::Gdi::SelectObject(mem_dc, bitmap.into()) }; - - let ok = unsafe { PrintWindow(hwnd, mem_dc, flags) }; - if !ok.as_bool() { - let _ = unsafe { windows::Win32::Graphics::Gdi::SelectObject(mem_dc, prev) }; - let _: BOOL = unsafe { DeleteObject(bitmap.into()) }; - let _: BOOL = unsafe { DeleteDC(mem_dc) }; - let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; - return Err("PrintWindow returned 0".into()); - } - - // Read the bitmap as 32-bit top-down RGBA via GetDIBits. - // `biHeight = -height` flips the rows so y=0 is the top edge. - let mut info = BITMAPINFO { - bmiHeader: BITMAPINFOHEADER { - biSize: std::mem::size_of::() as u32, - biWidth: width, - biHeight: -height, - biPlanes: 1, - biBitCount: 32, - biCompression: 0, // BI_RGB - biSizeImage: 0, - biXPelsPerMeter: 0, - biYPelsPerMeter: 0, - biClrUsed: 0, - biClrImportant: 0, - }, - bmiColors: [RGBQUAD::default(); 1], - }; - let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4]; - let scanlines = unsafe { - GetDIBits( - mem_dc, - bitmap, - 0, - height as u32, - Some(pixels.as_mut_ptr() as *mut _), - &mut info, - DIB_RGB_COLORS, - ) - }; - if scanlines == 0 { - let _ = unsafe { windows::Win32::Graphics::Gdi::SelectObject(mem_dc, prev) }; - let _: BOOL = unsafe { DeleteObject(bitmap.into()) }; - let _: BOOL = unsafe { DeleteDC(mem_dc) }; - let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; - return Err("GetDIBits returned 0".into()); - } - - // Push to image::RgbaImage. - let mut img = image::RgbaImage::new(width as u32, height as u32); - for y in 0..height as u32 { - for x in 0..width as u32 { - let idx = ((y as usize) * (width as usize) + (x as usize)) * 4; - let b = pixels[idx]; - let g = pixels[idx + 1]; - let r = pixels[idx + 2]; - img.put_pixel(x, y, image::Rgba([r, g, b, 0xff])); - } - } - - let mut out = Vec::new(); - { - let mut cursor = Cursor::new(&mut out); - img.write_to(&mut cursor, image::ImageFormat::Png) - .map_err(|e| format!("PNG encode: {e}"))?; - } - - std::fs::write(out_path, &out).map_err(|e| format!("write {out_path}: {e}"))?; - - let _ = unsafe { windows::Win32::Graphics::Gdi::SelectObject(mem_dc, prev) }; - let _: BOOL = unsafe { DeleteObject(bitmap.into()) }; - let _: BOOL = unsafe { DeleteDC(mem_dc) }; - let _: i32 = unsafe { ReleaseDC(Some(null_hwnd), screen_dc) }; - - Ok(CaptureReport { - bytes: out.len(), - width: width as u32, - height: height as u32, - }) -} - -#[cfg(not(target_os = "windows"))] -fn capture_diagnostics(out_path: &str) -> Result { - let _ = out_path; - Err( - "--screenshot is implemented for Windows only; this smoke was written for a Windows host." - .into(), - ) -} diff --git a/screenshots_fixtures/hello.html b/screenshots_fixtures/hello.html deleted file mode 100644 index 9209be7..0000000 --- a/screenshots_fixtures/hello.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - Smoke HTML page - - - - Rustickers - compat -

Hello from the file sticker

-

If you can read this, the embedded wry WebView is delivering paints - through GPUI's compositor. Same content the system browser would show, - bounded by the file-sticker's transparent corners.

-

Smoke HTML fixture used by: - cargo run --example clipboard_ux_smoke -- --file screenshots_fixtures/hello.html

- - diff --git a/scripts/screenshot.ps1 b/scripts/screenshot.ps1 new file mode 100644 index 0000000..9e0fbfb --- /dev/null +++ b/scripts/screenshot.ps1 @@ -0,0 +1,213 @@ +<# + Windows screenshot helper for rustickers visual checks. + + Captures the largest visible top-level window owned by a chosen process + to a PNG via PrintWindow + PW_RENDERFULLCONTENT, so WebView2 / Edge + composited surfaces -- the thing the wry WebView paints into -- actually + appear in the bitmap. This is the runtime equivalent of the + blank-preview regression check used to validate + GPUI_DISABLE_DIRECT_COMPOSITION (see .agents/skills/gpui-visual-check). + + Two ways to pick a window: + + -ProcessName rustickers # picks the largest window of that exe + -WindowTitle "Clipboard history" # first window whose title contains this + -ProcessId 12345 # first window of that PID + (any of the three can be combined; the + most specific one wins) + + Required: + - PowerShell 5.1+ (built into Windows 10+) + - System.Drawing.Common (built-in on Windows desktop SKUs) + + Out: + - $OutPath: PNG file + - Exit: 0 on success, 2 if no process, 3 if no window, 4 on capture fail + - Stderr: a one-line failure description on each non-zero exit +#> + +[CmdletBinding()] +param( + [string]$ProcessName, + [string]$WindowTitle, + [int]$ProcessId, + [Parameter(Mandatory)] + [string]$OutPath, + [int]$WaitMs = 1500 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ($WaitMs -gt 0) { + Start-Sleep -Milliseconds $WaitMs +} + +Add-Type -AssemblyName System.Drawing + +# P/Invoke surface. We avoid generic Win32 enumeration (which would need +# EnumWindows + a per-callback state object) by funneling through +# Get-Process + Process.MainWindowHandle; for rustickers the chooser and +# each sticker open their own process-handle-sized surface, so the largest +# one is usually the right one to capture for a single-window scene. +if (-not ([System.Management.Automation.PSTypeName]'Win32.WindowSnap').Type) +{ + # PowerShell 7 (Core) stopped auto-resolving System.Drawing.Common; + # the Add-Type below needs the assembly name explicitly or the C# + # compiler will say "type 'Image' could not be found". PowerShell 5.1 + # is happy without it, so the explicit reference is harmless there. + $referenced = @( + 'System.Drawing.Common', + 'System.Drawing', + 'System.Runtime.InteropServices.RuntimeInformation' + ) + Add-Type -ReferencedAssemblies $referenced @" +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; + +namespace Win32 { + public static class WindowSnap { + // Form-feed flip so alpha is treated as fully opaque by viewers + // that respect it. PNGs don't have a transparency-by-default + // contract anyway, so just pass the raw ARGB through. + [DllImport("user32.dll")] static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, uint flags); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hwnd, out RECT r); + [DllImport("user32.dll")] static extern IntPtr GetWindowDC(IntPtr hwnd); + [DllImport("user32.dll")] static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc); + [DllImport("gdi32.dll")] static extern IntPtr CreateCompatibleDC(IntPtr hdc); + [DllImport("gdi32.dll")] static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int w, int h); + [DllImport("gdi32.dll")] static extern bool DeleteDC(IntPtr h); + [DllImport("gdi32.dll")] static extern bool DeleteObject(IntPtr h); + [DllImport("gdi32.dll")] static extern IntPtr SelectObject(IntPtr dc, IntPtr o); + + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } + + // PW_RENDERFULLCONTENT = 2 -- the only flag combo that actually + // pulls WebView2's own surface into our bitmap. + const uint PW_RENDERFULLCONTENT = 0x00000002; + + public static bool CaptureToPng(IntPtr hwnd, string path, out int width, out int height) { + width = 0; height = 0; + if (hwnd == IntPtr.Zero) return false; + + RECT r; + if (!GetWindowRect(hwnd, out r)) return false; + int w = Math.Max(1, r.Right - r.Left); + int h = Math.Max(1, r.Bottom - r.Top); + + IntPtr screenDc = GetWindowDC(IntPtr.Zero); + IntPtr memDc = CreateCompatibleDC(screenDc); + IntPtr bitmap = CreateCompatibleBitmap(screenDc, w, h); + IntPtr prev = SelectObject(memDc, bitmap); + + bool ok = PrintWindow(hwnd, memDc, PW_RENDERFULLCONTENT); + Image img = null; + try { + if (ok) { + img = Image.FromHbitmap(bitmap); + } + } + finally { + SelectObject(memDc, prev); + if (bitmap != IntPtr.Zero) DeleteObject(bitmap); + if (memDc != IntPtr.Zero) DeleteDC(memDc); + ReleaseDC(IntPtr.Zero, screenDc); + } + + if (img == null) return false; + try { + img.Save(path, ImageFormat.Png); + width = img.Width; + height = img.Height; + return true; + } + finally { + img.Dispose(); + } + } + + public static int Area(IntPtr hwnd) { + RECT r; + if (hwnd == IntPtr.Zero) return -1; + if (!GetWindowRect(hwnd, out r)) return -1; + return (r.Right - r.Left) * (r.Bottom - r.Top); + } + } +} +"@ +} + +function Resolve-TargetWindow { + [CmdletBinding()] + param( + [string]$Name, + [string]$Title, + [int]$Id + ) + $candidates = @() + if ($Id -gt 0) { + $proc = Get-Process -Id $Id -ErrorAction SilentlyContinue + if ($proc) { $candidates += $proc } + } + if ($Name) { + $procs = Get-Process -Name $Name -ErrorAction SilentlyContinue + foreach ($p in $procs) { + $already = $false + foreach ($c in $candidates) { if ($c.Id -eq $p.Id) { $already = $true; break } } + if (-not $already) { $candidates += $p } + } + } + + if (-not $candidates -or $candidates.Count -eq 0) { + [Console]::Error.WriteLine("no process matched (Name='$Name' Id=$Id)") + return $null + } + + $snapshots = New-Object System.Collections.Generic.List[object] + foreach ($p in $candidates) { + $hwnd = $p.MainWindowHandle + if ($hwnd -eq [IntPtr]::Zero) { continue } + $title = "" + try { $title = $p.MainWindowTitle } catch { } + $area = 0 + try { $area = [Win32.WindowSnap]::Area($hwnd) } catch { } + $snapshots.Add([pscustomobject]@{ + Process = $p + Hwnd = $hwnd + Title = $title + Area = if ($area -gt 0) { $area } else { 0 } + }) + } + + if ($snapshots.Count -eq 0) { + [Console]::Error.WriteLine("matched processes have no main window") + return $null + } + + # Title filter (case-insensitive substring). + if ($Title) { + $titled = $snapshots | Where-Object { $_.Title -and $_.Title -like "*$Title*" } + if ($titled) { $snapshots = $titled } + } + + $best = $snapshots | Sort-Object -Property Area -Descending | Select-Object -First 1 + return $best +} + +$target = Resolve-TargetWindow -Name $ProcessName -Title $WindowTitle -Id $ProcessId +if (-not $target) { exit 3 } + +[int]$w = 0 +[int]$h = 0 +$ok = [Win32.WindowSnap]::CaptureToPng($target.Hwnd, $OutPath, [ref]$w, [ref]$h) +if (-not $ok) { + [Console]::Error.WriteLine("PrintWindow failed for hwnd=$($target.Hwnd)") + exit 4 +} + +Write-Output ("{0} bytes {1}x{2} process={3} hwnd=0x{4:X} title={5}" -f ` + (Get-Item $OutPath).Length, $w, $h, $target.Process.ProcessName, $target.Hwnd.ToInt64(), $target.Title) +exit 0 From c09f85c0b0cad7b26ab403b1189797f59d5df82f Mon Sep 17 00:00:00 2001 From: binwenw Date: Wed, 7 Oct 2026 11:22:18 +0800 Subject: [PATCH 30/69] chore: drop .pi/skills mirror and vendor/zed scratch The repo carried two pieces of redundant OR scratch material that do not belong to the canonical Rustickers source tree any more. `.pi/skills/` Was a duplicate mirror of skill content. To clarify what was actually unique: .agents/skills/gpui-kit/ (canonical) .agents/skills/gpui-kit-design-guides/ (canonical) .agents/skills/gpui-visual-check/ (canonical) .pi/skills/gpui-kit-design-guides/ (== .agents/ copy) .pi/skills/gpui-visual-check/ (== .agents/ copy) Each file under .pi was byte-identical to its .agents sibling. Having two install roots meant refresh-skills operations had to touch two trees, and any drift between them produced diverging agent output for the same dev prompt. There is no scenario in this repo where .pi/skills adds content .agents/skills lacks. Remove. `vendor/zed/` This was a hand-vendored bundle of Zed editor source (crates/ docs/ extensions/ legal/ nix/ script/ tooling/ totalling 180 KB on disk) that was never tracked by git -- 'git ls-files vendor/' returned nothing. The directory only existed as a local reference scratch during the kit-migration design work; no rustickers source file imports from it, no README or FRONTEND paragraph depends on its presence, and it conflicted with the 'clean and well organized' shape the repo is converging on. Delete the directory outright (rm -rf rather than git rm). Doc sync: - FRONTEND.md repo layout: drop the 'examples/' row under src/, add a 'scripts/' row so the new screenshot.ps1 binary tool is discoverable. 'examples/' would be misleadingly listed as a directory but be empty after the example was removed in 0cb513e. - FRONTEND.md testing-gates block: drop the 'cargo build --features ui --example clipboard_ux_smoke' line; the example no longer exists. The other four lines (fmt / check / clippy / test) are unchanged. - FRONTEND.md 'Visual flows' sentence: replace the stale 'covered by clipboard_ux_smoke.rs' pointer with a pointer to .agents/skills/gpui-visual-check/ + scripts/screenshot.ps1, which is the actual shape that took its place. - FRONTEND.md skill catalog: replace '.pi/skills/...' line with a tree that mirrors the on-disk layout under .agents/skills/, and surface scripts/screenshot.ps1 next to skills-lock.json so the Windows capture tool is discoverable from the catalog table. - README.md testing gates: drop the now-invalid --example clipboard_ux_smoke build line and bump '212 passed' to '213 passed' to match the count after the 04592d3 (editor_save_writes_label_alongside_content) test was added. The repo is now a single tree: src/ tests/ migrations/ scripts/ Cargo.toml Cargo.lock build.rs assets/ packaging/ screenshots/ (project assets) .agents/ .github/ .vscode/ (tooling config) .cargo/ .gitignore (build config) README.md ARCHITECTURE.md FRONTEND.md (docs) skills-lock.json (skill provenance) .tmp/ (gitignored scratch) Verified: cargo test --locked --features cli reports 213 passed, 1 ignored (190 lib + 19 clipboard_storage + 3 sticker_palette + 1 sticker_placement). No Markdown file in the tree still references .pi/, vendor/, or clipboard_ux_smoke. The 'preserve all initially untracked files' constraint covered .agents/, .pi/, and skills-lock.json; .pi is now intentionally removed because its content is fully represented under .agents/. If a downstream copy of this branch needs the duplicate mirror to live in a per-host install dir (e.g. ~/.pi/skills/), that is out of scope for the repo -- it's a per-machine install location. --- .pi/skills/gpui-kit-design-guides/SKILL.md | 60 -- .../references/design-guides.md | 950 ------------------ .pi/skills/gpui-visual-check/SKILL.md | 125 --- FRONTEND.md | 21 +- README.md | 3 +- 5 files changed, 14 insertions(+), 1145 deletions(-) delete mode 100644 .pi/skills/gpui-kit-design-guides/SKILL.md delete mode 100644 .pi/skills/gpui-kit-design-guides/references/design-guides.md delete mode 100644 .pi/skills/gpui-visual-check/SKILL.md diff --git a/.pi/skills/gpui-kit-design-guides/SKILL.md b/.pi/skills/gpui-kit-design-guides/SKILL.md deleted file mode 100644 index c65738f..0000000 --- a/.pi/skills/gpui-kit-design-guides/SKILL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: gpui-kit-design-guides -description: The normative Design Guides for GPUI Kit desktop applications. Read in full before designing or changing any screen, layout, spacing, visual hierarchy, color, density, component choice, interaction state, overlay, motion, data-heavy view, or interface copy in a GPUI Kit (gpui-kit / gpui-component) application, and before reviewing UI work for design quality. Also use when asked what the design rules are, or whether a UI decision follows them. ---- - -# GPUI Kit Design Guides - -The guide is [references/design-guides.md](references/design-guides.md). It is -a requirement, not inspiration. Read the guide file itself before doing UI -work. Do not answer from this page, from an existing screen in the codebase, or -from training data. - -If the file is missing, fetch `https://gpui-kit.com/docs/design-guides.md`. -The guide is a verbatim copy of that page, so its link to `./coding-guides.md` -means the Coding Guides in the `gpui-kit` skill (or `https://gpui-kit.com/docs/coding-guides.md`). - -## How to read it - -Read the whole guide for a new screen or a redesign. For a narrow change, read -"Design thesis" and "Start from the task" first, then the section for the -change. Sections, in order (`grep -n '^## ' references/design-guides.md`): - -| Section | Read when | -| ------------------------------------ | ---------------------------------------------------------------------- | -| Design thesis | Always | -| Learning from Shadcn | Choosing what to borrow from web component libraries | -| Start from the task | Always; task hierarchy, interaction promise, what to leave out | -| Visual language | Color, typography, spacing, radius, borders, elevation, density, icons | -| Layout patterns | Window structure, sidebars, toolbars, panels, forms, resizable regions | -| Components and composition | Picking a component, composing parts, when to build a new one | -| Interaction states | Hover, focus, pressed, selected, disabled, loading, validation, danger | -| Feedback and overlays | Dialog, sheet, popover, menu, notification, tooltip, dismissal, focus | -| Motion | Any animation or transition | -| Designing data-heavy interfaces | Tables, lists, trees, dashboards, dense inspectors | -| Interface language | Any user-facing text: labels, buttons, titles, errors, empty states | -| Internationalization and platform fit | Multi-locale copy, Chinese terminology, macOS/Windows conventions | -| Guidance for AI-generated interfaces | Always when an agent produces UI | -| Accessibility checklist | Before finishing | -| Design review checklist | Before finishing; run every item against the work | - -## Non-negotiables - -A floor, not a substitute for the guide. - -- **Desktop before web convention.** Keyboard access, window chrome, menus, - dense data views, resizable regions, persistent navigation. -- **`Button` vs `Link`.** `Button` for every in-app command, `ghost` or - `outline` when it should read quietly. `Link` only for external URLs and - email addresses. -- **Tokens before values.** No raw hex or `rgb(...)`; use `cx.theme()` - semantic tokens and rem-based helpers. Any spacing number quoted in the guide - is the current default scale, not a literal to repeat. -- **State must be visible.** Hover, focus, selection, disabled, loading, - validation, and destructive states each need distinct, consistent treatment. -- **Overlays.** Escape dismisses the topmost surface and returns focus to its - trigger. -- **Copy.** Name the object and the verb: `Delete "Roadmap"?` with a `Delete` - button, not `Are you sure?` with `OK`. - -Finish by running the Design review checklist against the work. diff --git a/.pi/skills/gpui-kit-design-guides/references/design-guides.md b/.pi/skills/gpui-kit-design-guides/references/design-guides.md deleted file mode 100644 index fc3dc08..0000000 --- a/.pi/skills/gpui-kit-design-guides/references/design-guides.md +++ /dev/null @@ -1,950 +0,0 @@ ---- -title: Design Guides -description: Product and interaction design guidance for GPUI Kit applications -order: -2.1 ---- - -# Design Guides - -Use this guide before choosing components or writing layout code. It records -the product judgment accumulated through years of GPUI Kit desktop work: -an interface should feel native, restrained, precise, and understandable -without guesswork. - -This is a normative guide. **Must** identifies a correctness or ecosystem -constraint, **should** is the default that needs a concrete reason to override, -and **may** is an optional technique. Component API documentation remains the -authority for individual methods. - -The rules build on behavior in `gpui-base`, the GPUI Component theme and -component system, and familiar desktop interaction. Shadcn contributes useful -methods—open code, composition, and dependable defaults—but does not determine -how a GPUI application should look. When influences conflict, preserve GPUI's -lifecycle constraints and the interaction people already understand. - -## Design thesis - -Build interfaces that feel native, quiet, and precise. Let content, hierarchy, -and interaction carry the experience; decoration should support them rather -than compete with them. - -1. **Clarity before personality.** Make the primary task and next action clear - before adding brand expression. -2. **Composition before invention.** Start with established components and - compose them into product-specific workflows. Create a new primitive only - when its behavior is genuinely new. -3. **Tokens before values.** Colors, radii, typography, and spacing should form - a system. Avoid isolated literals that cannot respond to themes. -4. **Desktop before web convention.** Preserve keyboard access, window chrome, - menus, dense data views, resizable regions, and persistent navigation where - the task benefits from them. -5. **State must be visible.** Hover, focus, selection, disabled, loading, - validation, and destructive states need distinct and consistent treatment. -6. **Geometry explains containment.** Radii, insets, and boundaries should - describe one coherent hierarchy, even where surfaces overlap. -7. **Feedback reflects reality.** Loading, tooltips, and motion should clarify - a real state or transition, not add ceremony to an immediate action. - -## Learning from Shadcn - -Shadcn's most useful contribution is not a particular border color. It is a -way of building a system: - -- own the top layer of the interface instead of fighting a sealed abstraction; -- compose small, predictable parts into product-specific components; -- provide defaults that already form one visual language; -- keep the code and composition legible to both people and AI; -- separate behavior primitives from the styled layer. - -GPUI Kit applies those ideas through a Rust library and the split between -`gpui-base` and `gpui-component`. Applications normally compose or wrap the -published components; contributors move genuinely reusable behavior into Base -and keep visual policy above it. - -Do not copy these web assumptions blindly: - -| Web habit | Native GPUI default | -| --- | --- | -| Pointing-hand cursor on every button | Default arrow cursor; pointing hand for links | -| Page navigation as the main structure | Persistent windows, panes, sidebars, tabs, and menus | -| Browser focus and scrolling as a fallback | Explicit focus ownership and region-owned scrolling | -| Mobile-first single column | Resizable desktop shell with a defined minimum window size | -| Hover-revealed critical actions | Keyboard- and pointer-reachable actions that do not depend on hover | -| A row of hover-only icon buttons | A visible primary action plus `DropdownMenu` or `ContextMenu` for secondary commands | -| Link-styled text for application commands | `Button`, `outline`, or `ghost`; Link only for URLs, web resources, or email addresses | -| Large touch density everywhere | Medium density by default; compact only where information work benefits | -| CSS overrides across descendants | Typed builders, semantic parts, and application composition | - -## Start from the task - -Before drawing a screen, write down: - -- the user's primary task; -- the object being viewed or changed; -- the actions that must remain immediately available; -- the information required to make a decision; -- empty, loading, error, offline, read-only, and permission-denied states; -- the keyboard path through the workflow. - -Organize the window around those answers. Do not begin with a dashboard grid or -a component catalogue. A good desktop interface exposes the user's mental -model: documents, accounts, projects, messages, settings, or another stable -object—not the internal service architecture. - -Design the primary task before distributing controls. Its visual weight, -location, and information depth should match its importance to the product. A -core result must not be reduced to a small count, an icon in a corner, or a -weak footer action while secondary content consumes the page. When a result set -is the product's main value, consider a summary region or card that exposes the -count, representative results, meaningful state, and a clear next action. - -For every proposed action, name its visible object, current state, scope, and -result. If the interface does not show or clearly imply those things, the -action is premature. Do not expose a capability merely because the backend has -it; first design how the object enters the user's mental model. - -## Visual language - -### Hierarchy - -Prefer a small number of clear levels: - -- **window or page title** identifies the current object or workspace; -- **section title** separates meaningful regions; -- **body text** carries the work; -- **muted text** provides secondary metadata and help; -- **labels** identify controls and values. - -Use size, weight, spacing, and separators before adding color or containers. -Avoid nesting cards inside cards: most desktop regions need only a background, -a hairline boundary, and intentional spacing. - -Evaluate hierarchy across the whole feature, not component by component. Hide -accent color and decoration during review: the primary task, current selection, -result summary, and next action should still be obvious from structure. A -screen that contains individually plausible controls can still fail when they -do not form one reading order and one decision path. - -Treat emphasis as a limited budget. A local surface needs one clear focal point, -not a field of competing highlights. If everything is colored, badged, bold, -boxed, or promoted to an alert, nothing reads as important. Establish priority -with structure and proximity first; spend stronger color and components only -where a distinction changes what the user notices or does. - -### Color and themes - -Read colors from `cx.theme()` and use them by semantic role: - -- `background` and `foreground` for the main surface and text; -- `group_box`, `popover`, `sidebar`, and their foreground tokens for their - named surfaces; -- `muted` and `muted_foreground` for supporting information; -- `primary` for the principal action or selection emphasis; -- `danger`, `warning`, `success`, and `info` only for their meanings; -- `border`, `input`, and focus-ring tokens for structure and interaction. - -Do not use a semantic status color as decoration. Do not encode meaning by -color alone. Verify every custom surface in light and dark themes and with -custom theme values; never assume that foreground is black or background is -white. - -Use Badge for a short state, count, or classification that benefits from rapid -scanning—not for every label, metadata value, filter, or section title. Keep -most badges neutral; reserve semantic variants for states that truly carry -success, warning, danger, or informational meaning. A row of multicolored -badges is usually a missing hierarchy or grouping decision. - -Application UI should not contain raw hex, `rgb`/`rgba`, or `hsla` colors. -Resolve colors from `cx.theme()` by semantic role. If the required role does -not exist, define it in the product's theme/token layer rather than embedding a -palette value at the call site. Raw colors belong only inside theme definitions -or in audited data/raster content whose color is itself the data. - -### Radius, spacing, and density - -Use a finite radius scale, as with type and control sizes. Choose from the -theme's named tiers (`sm`, `md`, `lg`, `xl`, and larger surface tiers) instead -of inventing a radius for each element. Large containers may use a softer tier -than the controls they contain; compact controls and overlays use tighter -tiers. For an ordinary rectangle, the radius should remain visibly smaller -than half its shorter side. Use `radius_full()` only for intentional circles -and pills; a tooltip must not become a capsule by accident. - -Nested surfaces should read as concentric shapes. Use -`inner radius ≈ max(0, outer radius − gap)` to choose the closest suitable -theme tier; the visible band should remain even through the turn. Add a -named application token if a repeated composition needs an intermediate -value. A segmented control is one silhouette: its end segments follow the container's inner -corners, its middle segments remain square, and each boundary has one divider -of consistent thickness. - -Light theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center. -Dark theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center. - -A radius must govern the entire visible surface, not just its border. Exposed -corners remain transparent to the surface behind them; child backgrounds, -selection fills, and scrolling content must also follow the boundary. In GPUI, -do not assume a rounded parent clips its descendants. Give inner surfaces their -own radius or an appropriate clip, then inspect the composition against a -contrasting background in both themes. Preserve outward focus rings when -choosing a clip. - -Use a compact spacing scale and repeat it. Related label/control pairs should -be closer than separate groups; separate groups should be closer than separate -sections. Prefer component sizes (`xsmall`, `small`, default medium, `large`) -over one-off heights. Use compact variants for toolbars and data-dense screens, -not to squeeze an unclear layout into less space. - -The shared semantic scale is intentionally small: spacing progresses through -roughly 2, 4, 8, 12, 16, 24, and 32 pixels, while typography stays near 12, -14, 16, 18, and 20 pixels. Treat these as relationships rather than permission -to scatter their current values through feature code. GPUI Component currently -projects a fixed default `SpacingTokens` scale from its global `Theme`; unlike -colors and radii, `Theme::apply_semantic_tokens` does not persist a custom -spacing scale. An application that needs different spacing must own that full -token snapshot and use it consistently in its application components. - -### Spatial grammar - -Spacing expresses relationship. Choose a gap from the semantic scale by asking -what the two things mean to each other: - -| Relationship | Typical token | Current scale | Examples | -| --- | --- | --- | --- | -| Optical correction | `xxs` | 2 px | icon baseline, compact separator | -| Parts of one control | `xs` | 4 px | menu icon/label, title/description | -| Closely related controls | `sm` | 8 px | button icon/label, dialog actions | -| One content group | `md` | 12 px | notification columns, compact form rows | -| Separate groups in one section | `lg` | 16 px | panel padding, form groups | -| Separate sections | `xl` | 24 px | major blocks in a page or inspector | -| Major region boundary | `xxl` | 32 px | empty-state breathing room, page bands | - -These values describe the current default scale, not literals to repeat. Use -`cx.theme().spacing_tokens()` or the corresponding GPUI scale helpers for the -ecosystem default. A product-owned scale should preserve the ordering and -relationships and must be passed through the application's own design-system -context rather than assumed to persist in the global GPUI Component theme. - -Use these rules when resolving horizontal and vertical space: - -1. **Inside before outside.** A component's padding belongs to the component; - the gap between components belongs to their parent. -2. **Vertical rhythm shows grouping.** The gap between a title and its - description is smaller than the gap from that description to the next - section. Equal gaps imply equal relationships. -3. **Horizontal space supports scanning.** Repeated rows keep icons, labels, - values, badges, and trailing actions on stable columns. -4. **Leading and trailing are semantic.** Think in reading-order edges even - when the current API uses left/right; this keeps future RTL adaptation - possible. -5. **Do not double padding.** A card placed in an already padded panel should - not automatically add another full panel inset. -6. **Use optical alignment sparingly.** A one- or two-pixel correction is valid - for icon or glyph geometry, but document why it differs from the scale. - -Common compositions in the current system illustrate the relationships: - -- button contents use 4 px at small sizes and 8 px at normal sizes; -- dialog headers and footers use an 8 px internal gap; -- compact list and menu rows use 4 px vertical and 8–12 px horizontal padding; -- sheet headers use about 16 px leading and 12 px trailing space, leaving room - for a close affordance, while footers use 16 px horizontal and 12 px vertical - space; -- notifications use 16 px horizontal padding and a 12 px column gap because - icon, message, and action are distinct groups. - -Do not treat these as copy-and-paste recipes for every surface. They reveal the -system: controls are tighter internally, rows optimize scanning, and -containers spend more space at their boundary than between their contents. - -### Proportion and layer hierarchy - -Start with content requirements, then set proportions. Avoid arbitrary halves -when one pane has a clearly different role. - -- A navigation sidebar should be wide enough for stable labels but visibly - subordinate to the work area. Give it a minimum, preferred, and maximum - width rather than a percentage alone. -- In master–detail layouts, let the collection remain scannable and give the - detail pane the surplus. A roughly one-third/two-thirds starting point is - often useful, but content constraints are authoritative. -- Inspectors and auxiliary sheets should not cover the primary object by - default. They should be resizable or dismissible when their content grows. -- Dialog width comes from the decision: short confirmation, medium form, or a - dedicated window/page for complex work. Do not enlarge a dialog simply to - create whitespace. -- Reserve the strongest elevation for the topmost decision layer. Within a - layer, use background and hairlines—not successively larger shadows—to show - hierarchy. - -Define three size constraints for every major region: the minimum at which its -task still works, a comfortable default, and how it consumes surplus. Persist -user-controlled splits when they represent workflow preference, and clamp -restored values against the current window. - -### Alignment details - -Alignment is a structural system, not a final polish pass. Establish a small -set of alignment spines for each surface: shared leading and trailing edges, -text baselines, center lines, and fixed functional lanes. Elements at the same -level should attach to the same spine from top to bottom or leading to trailing, -even when they are different component types. - -Alignment spines across a desktop surface -Alignment spines across a desktop surface - -Vertical red lines sit beside shared edges or control centers for content, -status, time, and trailing actions. Horizontal lines sit beneath text baselines -or pass through a row center to show bottom and vertical-center alignment. The -compact comparison isolates a one-rendered-pixel drift that must be corrected -at its structural owner. - -- Give sibling regions a shared content inset. A heading, toolbar, list row, - empty state, and footer that describe the same level should not each invent a - slightly different leading edge. -- Repeat column geometry through the whole region. Headers, rows, summaries, - loading states, and inline editors should reserve the same lanes for identity, - metadata, status, numbers, and actions. -- Align related controls across rows and sections. Form labels, fields, - descriptions, and validation messages should reveal a stable vertical grid - when the page is scanned from top to bottom. -- Keep horizontal bands coherent. Items sharing a toolbar, title bar, status - bar, or row should use one baseline or center line instead of individually - tuned offsets. -- Introduce indentation only for real hierarchy, containment, or disclosure. - Decorative indentation makes siblings look subordinate and breaks the - surface's reading line. -- When a nested level ends, return exactly to the parent spine. Do not let - accumulated padding drift across nested containers. -- Preserve the spine through optional content. Missing icons, badges, - descriptions, or trailing actions must not move the remaining labels; use - intentional slots or lanes when cross-row comparison matters. -- Align major regions with one another where their hierarchy matches. Sidebar - headers, content titles, split panes, toolbars, and bottom bars need not share - every coordinate, but coincident levels should form visible continuous lines. - -Not every edge should align. A child can indent, a primary value can lead its -supporting metadata, and a destructive decision can gain separation. Such -exceptions must communicate hierarchy or meaning; they must not result from -uncoordinated component padding. Start with the shared spine, then make the -exception explicit. - -Treat exact alignment and repeated gaps as quality invariants. When two edges -or spaces are intended to be equal, a one-rendered-pixel difference is a defect, -not an acceptable optical approximation. Inspect resolved bounds with a -measurement tool at representative window sizes, zoom levels, and display scale -factors. Compare coordinates and distances; do not approve alignment only from -a casual screenshot. - -The rendered-pixel tolerance is a verification rule, not permission to patch -the code with raw pixel offsets. Equal relationships should resolve from the -same `rem` helper, spacing token, grid definition, or shared component inset. -Fix the common owner when they differ. Account for fractional layout and device -rounding so intended spines land on the same physical pixel instead of drifting -at particular zoom levels. - -- Align text by baselines, not by bounding-box centers, when mixed sizes share - a row. -- Center icons in a fixed slot so labels do not move when icons differ in - intrinsic width. -- Right-align comparable numbers; left-align prose and identifiers unless the - locale requires otherwise. -- Keep trailing row actions and disclosure indicators in fixed-width lanes. -- Align form controls by their interactive frame, not by help text below them. -- Use `justify_between` only when the two sides truly own opposite edges; it - should not disguise missing structure in the middle. -- Hairlines belong on the boundary owner. Two adjacent regions must not each - draw the same separator. -- A scrollbar belongs to the region that scrolls and sits against that panel, - editor, or window's trailing edge. Content padding may inset text and rows; - it must not pull the scrollbar into the middle of the surface. Reserve a - deliberate scrollbar gutter when content needs clearance. - -### Density tiers - -Medium is the ecosystem default. Change density for the whole local context, -not one isolated control: - -- **comfortable / large:** onboarding, sparse forms, prominent decisions; -- **standard / medium:** most application chrome and workflows; -- **compact / small:** toolbars, menus, tables, and repeated professional data; -- **extra compact / xsmall:** exceptional high-density utilities, never the - automatic choice for an entire application. - -The current controls demonstrate a bounded scale rather than arbitrary sizing: -buttons commonly move through approximately 20, 24, and 32 px frames; input -and data controls may extend to about 44 px at large size; table rows use about -26, 30, 32, and 40 px. Use the component's `Size` API so typography, icon, -padding, and hit target change together. A custom height that changes only the -outer box is usually incomplete. - -### Zoom, base font, and `rem` - -A well-designed `rem` system preserves hierarchy while the interface zooms. -Zoom is successful when the relationship between title and body, control and -icon, inner and outer spacing, primary and secondary regions still feels the -same at every scale—not merely when every object becomes larger. - -GPUI Component adopts the relative-scale idea familiar from Tailwind. The -theme's base `font_size` becomes the window's `rem` through `Root`, and GPUI -scale helpers such as `text_sm()`, `gap_2()`, `p_4()`, `h_8()`, and `size_4()` -resolve against it. This gives typography, spacing, controls, and icons one -shared zoom axis. - -Design in ratios: - -- type steps keep the same hierarchy around the base body size; -- spacing steps keep the same grouping relationships around the type; -- control frames, icons, and hit targets scale with their labels; -- pane minima and comfortable widths account for the scaled content; -- corner radii and focus treatment remain optically consistent with the - control frame. - -Do not implement zoom by changing text size alone. A larger label inside a -fixed-height button, a larger document inside fixed pane minima, or larger rows -inside a stale virtual-list measurement destroys the original rhythm and can -clip content. Conversely, multiplying every physical pixel—including -hairlines—can make the interface visually heavy. - -As a rule, application layout should not call `px(...)` directly. Use GPUI's -rem-based scale helpers (`p_2`, `gap_3`, `w_64`, `text_sm`, and related -builders) or semantic component sizes. Use fixed pixels only when the value -represents a physical or raster boundary: -a one-device-pixel hairline, platform window inset, bitmap dimension, minimum -hit-test tolerance, or geometry that must match an external surface. These are -audited, documented exceptions. Product spacing, typography, icon size, and -ordinary control geometry stay on the relative scale. - -Test interface zoom at several base-font values, not just the default. Verify -hierarchy, wrapping, truncation, minimum window size, pane resizing, focus-ring -clearance, popup placement, and virtualized row measurement. Also distinguish -interface zoom from Dock's panel zoom: Dock zoom makes one container fill its -area while retaining its chrome; it does not change `rem` or application scale. - -### Surfaces and elevation - -Use elevation to explain stacking, not importance. The base window surface is -flat; separators and background contrast define its regions. Popovers, menus, -dialogs, and notifications may use progressively stronger shadows because they -sit above other content. Do not put a shadow on every card. - -All surfaces of the same kind should share one treatment. GPUI Component, for -example, deliberately gives popup families one themed popover surface so -Popover, Select, Combobox, DatePicker, and menus do not drift apart. When an -application invents another anchored surface, reuse that semantic treatment -instead of approximating it with unrelated border and shadow literals. - -### Typography and icons - -Use the platform UI font for interface text and monospace only for code, -identifiers, shortcuts, and aligned numeric data. Keep body text readable and -avoid excessive uppercase or letter spacing, especially for CJK text. - -Use one icon family in a product. Icons supplement labels; they should not -replace unfamiliar actions with guesswork. Icon-only buttons always need an -accessible name. Their visual explanation follows the tooltip policy below. -Use filled or colored icons to communicate a state, not merely to make a -toolbar lively. - -## Layout patterns - -### Choose a stable shell - -Most applications should use one of these shells: - -- **single workspace:** toolbar or title bar above one primary view; -- **sidebar workspace:** persistent navigation beside a changing detail view; -- **master–detail:** resizable collection and detail panes; -- **document workspace:** tabs or a dock area for multiple long-lived objects; -- **utility window:** one focused task with a short, fixed action path. - -Keep global navigation stable while content changes. Give the primary work area -the remaining space with `flex_1()` and `min_w_0()` / `min_h_0()` where -overflowing children must shrink. Use `Scrollable`, `VirtualList`, `Table`, or -`DockArea` for their intended behavior instead of rebuilding scrolling or pane -management from nested `div`s. - -The title bar is window chrome first. Preserve its drag region and avoid -binding ordinary title clicks to infrequent editing commands. Rename belongs -behind an explicit object command, with a keyboard path where appropriate. - -### Responsive desktop windows - -Desktop does not mean fixed-size. Decide what happens as a window narrows: - -1. preserve the primary task; -2. allow resizable regions to reach a documented minimum; -3. collapse secondary labels or inspectors; -4. move low-frequency actions into a menu; -5. scroll only the region whose content actually overflows. - -Do not hide an action without providing another path to it. Avoid making the -entire window scroll when only a list or document body should scroll. - -GPUI flex layouts have the same intrinsic-size pressure found in other layout -systems: a `flex_1()` child may still refuse to shrink around long content. -Design and implementation must agree on which panes may shrink, truncate, wrap, -or scroll. A clipped region also clips an outward focus ring; never trade away -keyboard visibility merely to simplify overflow. - -### Forms and settings - -Use a visible label for each field and place help or validation next to the -field it describes. Align related fields, but do not force long labels into a -narrow fixed column. Use the appropriate control: `Checkbox` for independent -choices, `RadioGroup` for a small visible set, `Select` for a longer set, and -`Switch` for a setting that takes effect immediately. - -Disable submission while an operation is in flight, keep the user's input, and -show the result near the action. Reserve dialogs for short, focused decisions; -use a full page or sheet for workflows that need exploration or many fields. - -A loading treatment must represent actual waiting. Switching between settings -panels whose content is already available should be immediate; a skeleton is -appropriate only while content is genuinely loading and its shape is known. - -## Components and composition - -Follow the Shadcn principle that components are building material rather than a -sealed design system. GPUI Component supplies coherent defaults, while the -application owns composition and product semantics. - -- Use component variants by meaning. Primary is reserved for the explicit - default commit in a decision area—normally the action invoked by Enter. A - lone, frequent, or desirable action is not automatically primary. An `Add` - command in a management toolbar normally uses a default Button; a form's - default `Create` commit may use primary. Use `danger` for destructive - commitment and `ghost` for quiet toolbar actions. -- Prefer explicit compound parts and render callbacks over styling arbitrary - descendants. -- Keep a repeated pattern consistent across the product. Wrap it in an - application component when it carries domain language or policy. -- Use the standard component for its semantic role. A menu, dropdown menu, - popover, select, and command palette are not interchangeable boxes; each owns - different selection, focus, keyboard, dismissal, and layout contracts. -- Preserve the component family's geometry. Menu rows share vertical and - horizontal padding, height, icon and checkmark slots, separators, radius, and - state treatment. Do not imitate one menu with a custom popup whose spacing - only approximates the system. -- Do not wrap a library component merely to rename every method or freeze all - of its capabilities. -- Move reusable behavior without product styling to `gpui-base`; keep themed, - opinionated presentation in GPUI Component or the application. - -## Interaction states - -### Make the result understandable before the click - -A control should predict its result. Use familiar desktop controls and -placement so people can act without learning the interface first. Its label -names the action and object, its state shows availability, and its feedback -confirms the same outcome. - -Do not label a Button `Save` if it opens a configuration flow, or `Delete` if -it only removes an item from a group. Name the scope when context does not make -it clear. Respond immediately to activation, prevent duplicate submission -during longer work, and show the result near the object that changed. Add a -success message only when the result itself is not visible. - -Every interactive control should be designed for: - -| State | Design requirement | -| --- | --- | -| Rest | Clear affordance without visual noise | -| Hover | Subtle pointer feedback, never the only cue | -| Pressed | Immediate press feedback | -| Open / pressed | Persistent feedback while an attached popup is open | -| Focus visible | High-contrast keyboard focus ring | -| Selected / checked | Persistent state distinct from hover | -| Disabled | Lower emphasis and no misleading hover/pressed response | -| Loading | Preserve context, prevent duplicate action, explain long waits | -| Error | State what happened and how to recover | - -Use GPUI's focus system and Actions for commands that should work from the -keyboard. Match familiar desktop shortcuts, expose shortcuts in menus or -tooltips, and keep focus in a logical place after opening or dismissing an -overlay. - -Selection is part of the information model, not optional polish. Tabs, -segmented choices, selectable rows, filters, and navigation destinations must -show a persistent selected state. A Button that owns a dropdown must remain -visibly pressed or open until the popup closes; hover alone cannot explain the -relationship between trigger and surface. - -In a segmented control, the selected fill and unselected surface must share -the container's silhouette. Neither may square off an end corner or make a -boundary appear heavier than its peers. - -Show a selected navigation item, list row, or tab through the item's own -surface: a selected fill, stronger foreground, or heavier weight. Do not add a -leading-edge bar or one-sided border as the selection marker. It is a web -template habit, not a desktop convention; it breaks the item's rounded -silhouette and adds a second, competing edge to a column that already aligns -on its text. - -For destructive actions, distinguish between reversible and irreversible work. -Prefer undo or a temporary notification for reversible changes. Use an -`AlertDialog` when the consequence is serious and cannot be undone; name the -specific object and consequence in the confirmation copy. - -### Pointer conventions - -Use the default arrow cursor for buttons, checkboxes, menu items, tabs, and -other native controls. Use a pointing hand for links and content that behaves -as a link. Use text, resize, grab, and prohibited cursors only when they -describe the active manipulation. A cursor reinforces an affordance; it does -not replace the control's visible state or accessible role. - -Keep hover effects modest because keyboard and accessibility interaction has no -hover. Do not reveal the only copy of a destructive or essential action on -hover. Contextual row actions may become quieter at rest if the same commands -remain available through selection, keyboard, or a context menu. - -### Prefer desktop command surfaces over hover toolbars - -Use command frequency and scope to choose where an action lives: - -- keep the primary or frequent action visible as a labeled Button or familiar - toolbar control; -- put secondary actions for the current region behind a visible - `DropdownMenu` trigger; -- put commands that act on the object under the pointer in a `ContextMenu`; -- expose the same important command through an Action/key binding when it has a - natural keyboard form; -- use a hover-revealed icon only as a shortcut to a command that remains - reachable elsewhere. - -This is more than a visual preference. GPUI Component's menu system already -owns directional keyboard navigation, confirmation and cancellation, disabled -items, separators, submenus, shortcut presentation, focus transfer and -restoration, and nested-menu dismissal. A custom strip of hover buttons must -rebuild those behaviors and is invisible to keyboard-only and many assistive -technology workflows. - -Choose `DropdownMenu` when users need a visible indication that more commands -exist—for example a toolbar overflow, document actions, or account menu. Choose -`ContextMenu` for selection- or object-scoped commands such as rename, -duplicate, reveal, or remove. The context menu must not be the only way to -perform an essential command; provide a menu-bar, toolbar, keyboard, or detail -view path as appropriate. - -Do not put every action into a menu to make a screen look minimal. Discovery -and speed matter: the main action stays visible, dangerous items remain clearly -labeled and separated, and a menu item should use the same verb, icon, shortcut, -enabled state, and result everywhere it appears. - -### Button means application action; Link means external resource - -Use a Button when activation changes application state, confirms a decision, -opens a tool, submits data, or runs a command. Choose its treatment by local -hierarchy: - -- primary Button for the one emphasized commitment in a decision area; -- default Button for ordinary visible actions; -- outline Button when an action needs a clear boundary with less emphasis; -- ghost Button for familiar, low-emphasis toolbar and inline actions; -- icon Button only for a well-known symbol, with an accessible name and a - tooltip when meaning or scope is unclear. - -Do not assign primary because a Button is the only action on screen, because it -is placed at the top right, or because the team wants more clicks. Primary -communicates default commitment and keyboard behavior. If activation is merely -an ordinary command such as adding an item, opening a tool, or refreshing a -view, use a default, outline, or ghost Button according to its local hierarchy. - -Use an underlined Link only for an external resource target: a URL, web page, -online documentation, or email address. It uses the pointing-hand cursor -because its contract is leaving the current application context for that -resource. Do not use Link styling to make a functional command look quiet. A -link-shaped Delete, Save, Refresh, Add, Open-menu, or in-app navigation action -hides the control's affordance and exposes the wrong accessibility role. - -“View” does not make an in-app destination a Link. A full report, analysis, -details panel, or local record still opens through a Button, row, card, tab, or -disclosure control. Use concise context-aware labels such as `Full analysis` -when the containing card already establishes what opens; reserve underlining -for a resource that actually opens in a browser or mail client. - -All internal navigation—sidebar rows, tabs, breadcrumbs, list items, opening a -local view, or switching workspaces—must use the corresponding native component -or a Button/Action. Visual emphasis is chosen through Button variant or the -navigation component's selected state, never by lying about semantics. - -## Feedback and overlays - -Choose the smallest surface that fits the decision: - -- tooltip: a short explanation or shortcut; -- popover: contextual controls that do not interrupt the task; -- menu: a compact list of actions; -- notification: asynchronous status that does not require a decision; -- dialog: a focused decision or short form; -- alert dialog: explicit confirmation of a consequential action; -- sheet: supplementary work that benefits from more persistent space. - -A tooltip supplies missing meaning; it does not restate an obvious action. -Reserve it for an unfamiliar symbol, ambiguous scope, or a useful shortcut -that is otherwise hidden. Repeated, recognizable commands such as Copy and -Download in a message footer need accessible names and keyboard access, but -their hover labels add interruption rather than clarity. - -An Alert interrupts the visual hierarchy even when it does not open a modal. -Use it for important, exceptional information that needs attention in the -current task, not as a decorated container for ordinary descriptions, tips, or -empty space. Prefer inline help, muted text, or a normal section when the -content does not require immediate notice or action. - -Avoid stacking overlays. Escape should dismiss the topmost dismissible layer, -and focus should return to the trigger or the next logical target. - -An overlay action must refer to an object or state the overlay actually shows. -For example, expose `Clear history` only when a distinct recent-history section -is visible and contains entries. Search results, recent items, and favorites -are different collections; label and separate them instead of merging them -into one unexplained list. Hide an inapplicable action or disable it with a -useful reason—do not park an ambiguous trash icon in a footer. - -Footer space is not a catch-all for capabilities that lacked a place in the -design. A footer may present shortcuts, status, or actions that apply to the -whole surface, but each item must answer: what is its object, why is it -available now, what scope does it affect, and what visible state changes after -activation? - -## Motion - -Motion explains change; it is not ambient decoration. Use short transitions for -appearance, dismissal, expansion, and spatial continuity. Avoid animating large -layout changes when opacity or transform communicates the same relationship. -Honor reduced-motion preferences, never require animation to understand state, -and do not add a default animation to every component. - -Motion policy belongs to the styled or application layer. Base may own the -lifecycle mechanism or geometry needed for a transition, but it should not -decide that every product fades or slides. Give independently animated values -stable identity, and make interruption reverse smoothly from the currently -sampled value rather than restarting from an old endpoint. - -Motion follows the continuity of the interaction. While a pointer or scroll -position moves among related targets, keep the preview surface stable and -update its content or position in place. Dismiss it only after the interaction -ends; repeated fade-out and re-entry on each update reads as flicker. - -## Designing data-heavy interfaces - -Dense does not mean cramped. In tables, trees, command palettes, editors, and -docks: - -- keep headers and primary row identity visually stable; -- align comparable values and use tabular numerals where appropriate; -- distinguish focus, hover, active row, and multi-selection; -- keep sorting and filtering visible and reversible; -- preserve selection by domain identity across filtering and reordering; -- virtualize large collections without changing keyboard semantics; -- use progressive disclosure for secondary columns and inspectors; -- provide a useful empty state that explains the next action. - -Choose a table for comparison across consistent fields, a list for scanning -heterogeneous items, a tree for real hierarchy, and a dock only when users need -to arrange long-lived tools or documents. Do not use a complex data component -as a visual style. - -## Interface language - -Words are part of the interface architecture. Write the vocabulary for a -feature as a system—destinations, objects, commands, states, and outcomes—not as -isolated translations of implementation features. Prefer the shortest wording -that remains accurate in its actual context. - -### Let context carry context - -Do not repeat information that the surrounding surface already establishes. A -sidebar destination is usually the object or domain itself: use `Users`, not -`User Management`; `Shortcuts`, not `Shortcut Configuration Management`. A -column whose rows already contain actions can omit a generic `Operation` -heading. A dialog titled `Delete “Roadmap”?` does not need body text that asks -the same question again. - -This is context economy, not deletion for its own sake. Add text when it changes -the decision: identify the affected scope, an irreversible consequence, an -unusual prerequisite, or a way to recover. Every extra word should answer a -question the current layout does not already answer. - -Use nouns for destinations and objects (`Users`, `Appearance`, `Orders`), verbs -for commands (`Save`, `Duplicate`, `Export`), and adjectives or short phrases -for states (`Offline`, `Up to date`, `Pending review`). Avoid wrappers such as -`Management`, `Module`, `Page`, `Function`, `Operation`, and `System` unless the -word distinguishes a real domain concept. - -### Write each language, do not translate its shape - -Start from shared intent, hierarchy, and terminology, then compose each locale -as natural interface language. Do not preserve the source language's word -order, number of words, politeness filler, or grammatical category. English -`Users` can express a Chinese feature concept that would literally expand to -“user management”; fidelity means preserving purpose, not preserving tokens. - -Remove words supplied by the enclosing information architecture. Inside a -`Settings` surface, a destination is often simply `Account`, not `Account -Settings` and never the unnatural singular `Account Setting`. The correct -English label is chosen from its role and neighbors, not from the standalone -source phrase. - -Maintain a small product lexicon for recurring objects, commands, and states. -Use the same term in the toolbar, menu, context menu, dialog, shortcut search, -and documentation unless the context genuinely changes its meaning. Review -copy in the rendered surface: neighboring labels often reveal repetition or -inconsistent scope that a locale file cannot. - -In localized technical writing, preserve an established framework term when a -translation would be less precise. Keep API identifiers in their original form -and format them as code. Do not retain ordinary foreign words merely to sound -technical. Explain a retained term on first use when needed, then use the same -form throughout the interface, documentation, and API examples. - -### Buttons and confirmation dialogs - -Button labels are short by default—usually one or two words—and describe the -result, not the gesture or the component. Prefer `Save`, `Move`, or `Delete` to -`Click to save`, `Perform move`, or `Confirm deletion`. Use `Cancel` consistently -for the action that leaves without committing. Reserve `OK` for acknowledging -purely informational content. - -Short is a default, not a character limit. A deliberately longer label is -better when its words expose a consequence or distinguish choices that users -could otherwise confuse, for example `Delete from this group` versus `Delete -everywhere`, or `Restart without saving`. Length must buy decision-critical -information; it must not restate the dialog title or body. - -Use the most specific concise result as the confirmation label when possible: - -| Context | Weak | Prefer | -| --- | --- | --- | -| Delete dialog | `Yes`, `Sure`, `Confirm deletion` | `Delete` | -| Unsaved changes | `Confirm`, `Yes` | `Discard changes` | -| Pure acknowledgement | `Confirm operation` | `OK` or `Done` | -| Complex consent whose result has no clear verb | `Yes` | `Confirm` | - -`Confirm` is a useful fallback when the surrounding dialog fully names a -complex commitment and no shorter result verb is accurate. It should not -replace a clear command. `Sure` is conversational rather than a stable English -command and is too ambiguous for the standard vocabulary. - -A confirmation dialog should form one compact decision: - -- title: the decision or condition, such as `Delete “Roadmap”?`; -- body: only new scope, consequence, or recovery information; -- actions: `Cancel` and the result, such as `Delete`; -- destructive styling: applied to the destructive result, not substituted for - precise wording. - -Avoid generic titles such as `Notice`, `Warning`, `Error`, and `Confirmation` -when the actual condition can be named. Avoid ritual phrases such as “Are you -sure you want to…”, “Would you like to…”, “Please note that…”, and “successfully” -when the structure or state already communicates them. Courtesy should come -from a calm, respectful tone, not repeated `please`. - -### Capitalization, punctuation, and symbols - -Use sentence case for English UI by default: `Reset layout`, not `Reset Layout` -or `RESET LAYOUT`. Preserve proper nouns and established acronyms. Follow a -platform convention such as title case for native menu commands only when the -platform integration benefits from it, and apply that convention consistently -within the component class. - -ALL CAPS can provide restrained typographic emphasis for very short section -labels, eyebrows, statuses, established acronyms, and code-like identifiers. -Its compact shape and measured tracking can form a level similar to bold type, -but it does not belong on Buttons, long headings, sentences, or dense lists. Do -not combine uppercase, strong color, and bold weight in the same region, and do -not transform every string automatically: product names, acronyms, and localized -content must preserve their intended casing. - -Labels, buttons, menu items, tabs, headings, placeholders, and short states do -not take a final period. Complete explanatory, warning, and error sentences do. -Avoid exclamation marks in routine success and failure messages. In Chinese, -use full-width punctuation in sentences and omit terminal punctuation from -short control labels by the same semantic rule. - -Use the single ellipsis character (`…`), not three periods. Append it to every -Button or MenuItem that opens a dialog, sheet, or separate window, and to a -command that requires more input or choices before it can complete, such as -`Settings…` or `Export…`. An immediately executed command does not take an -ellipsis. Use an indeterminate progress indicator, not decorative dots, to -communicate ongoing work. - -Errors should say what happened and, when useful, the next recovery action. -Success feedback should name the resulting state only when that state is not -already visible. Prefer `Couldn’t save. Check your connection and try again.` -to a technical code or a long apology; omit a `Saved successfully` toast when -the document visibly becomes saved. - -## Internationalization and platform fit - -Copy must survive expansion, CJK typography, and different shortcut notation. -Do not size a control from one English label. Keep text out of raster assets, -avoid concatenating translated fragments, and let labels wrap or truncate only -where the product defines a recovery path such as a tooltip. - -Respect platform differences that carry meaning: Command versus Control, -native window decorations, system appearance, scrollbar behavior, menus, and -notification capabilities. Keep the product's information architecture stable -across platforms, but do not erase familiar platform behavior for superficial -pixel equality. - -## Guidance for AI-generated interfaces - -An AI changing a GPUI interface should first inspect the nearest feature, -theme tokens, and component documentation. It should state the primary task, -state owner, component composition, and keyboard path before generating code. -It must not infer an API from React/Shadcn examples or invent a GPUI method -because the name seems plausible. - -AI output is incomplete until a human can explain why the hierarchy, density, -component choice, and exceptional literal values belong in this product. A -visually plausible screenshot is not proof: keyboard behavior, focus, dynamic -content, themes, resizing, and failure states are part of the design. - -## Accessibility checklist - -Before considering a screen complete, verify that: - -- every action is reachable and operable by keyboard; -- focus order follows visual and task order; -- focus remains visible and is restored after overlays; -- controls have accessible names; icon-only controls use tooltips when their - meaning, scope, or shortcut needs explanation; -- text and meaningful boundaries have sufficient contrast; -- status is not communicated by color alone; -- disabled and read-only states are distinguishable; -- labels, errors, and descriptions remain near their controls; -- content remains usable with longer translations and larger text; -- pointer targets are comfortably sized even in a dense layout. - -## Design review checklist - -A review does not inventory components; it judges whether the interface made -the right decisions. Ask, in order: - -1. **Is the task clear?** Can a new user recognize the purpose, primary action, - and next step without learning, guessing, or experimenting? -2. **Does every action keep its promise?** Do the label, control, state, scope, - feedback, and result describe one consistent outcome? -3. **Is hierarchy decisive and restrained?** Does the core feature receive the - space it deserves while strong color, bold type, badges, alerts, and primary - Buttons remain scarce? -4. **Could the interface do less, better?** Can an entry point, option, or state - be removed, combined, or deferred without weakening the complete task? -5. **Is the structure exact?** Do peers share alignment spines, equal gaps stay - equal to the rendered pixel, and scrollbars sit at the edge of their actual - scrolling region? -6. **Does it follow the component system?** Do standard controls retain their - geometry, states, keyboard behavior, and dismissal model, with appearance - supplied by theme and scale tokens? -7. **Does it remain usable in every state and constraint?** Verify keyboard and - focus behavior, empty/loading/failure/permission states, longer translations, - zoom, minimum window size, and reduced motion. -8. **Has it been tested in a real window?** Complete the task with real - components, copy, and representative content—not only an ideal screenshot. - -Continue with [Coding Guides](./coding-guides.md) to translate these design -decisions into GPUI architecture and code. diff --git a/.pi/skills/gpui-visual-check/SKILL.md b/.pi/skills/gpui-visual-check/SKILL.md deleted file mode 100644 index d8bc8de..0000000 --- a/.pi/skills/gpui-visual-check/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: gpui-visual-check -description: Use when iterating on a GPUI visual feature (dialog layout, sticker chrome, button sizing, file-sticker HTML preview, blank-region regressions) and a check against the rendered output is needed. Captures an OS-level PNG of a running window so the agent (or a developer) can confirm visually that the layout actually looked right at runtime. The recipe is opinionated and opt-in; it does not run as part of `cargo test`, CI, or any gated path. Treat it as the dev-loop equivalent of a "look at the screen" loop. Apply when the user explicitly asks for a visual / screenshot / picture check during iteration, **or** when the change is one a known visual bug was hiding (e.g. embedded `wry::WebView` showing blank after a fix). ---- - -# Visual check for GPUI windows - -A small workflow + a tiny capture helper for cases where the code reads right but the rendered window might not look right. Optional. Never runs under `cargo test` or CI. - -## When to use - -- A UI layout change has landed and the diff doesn't tell you whether the - runtime frame will look right. -- A "should be invisible" element is suspected of leaking. The work that - spawned this skill was a `wry::WebView` rendering blank inside the - file-sticker — code was correct, the OS-level paint was the only way - to verify visually. -- You want to compare a pre-fix frame against a post-fix candidate. - -## When NOT to use - -- A pure data/control-flow change. `cargo test --features cli` is faster - and checks the same thing semantically. -- Refactors where the visible output cannot differ. Reasoning about the - diff is enough. -- macOS / Linux CI automation. The host for this skill is Windows. - Windows-only section at the bottom summarises why. - -## Recipe (Windows) - -```powershell -$env:GPUI_DISABLE_DIRECT_COMPOSITION = "true" -.\target\debug\.exe # in one shell, leave it running -# in another shell: - -powershell -ExecutionPolicy Bypass -File scripts\screenshot.ps1 ` - -ProcessName rustickers ` - -OutPath path\to\iter.png ` - -WaitMs 1500 -# then open the PNG (the `read` tool exposes files inside the -# conversation, so the agent can see the frame too). -# and kill the binary: -taskkill /IM rustickers.exe /F -``` - -Three things to know before invoking: - -1. **`GPUI_DISABLE_DIRECT_COMPOSITION=true` must be set in the launching - shell.** Without it, any GPUI window containing an embedded - `wry::WebView` (file sticker HTML / SVG / PDF, command-result web - view) paints blank. The same guard lives in - `src/native/mod.rs::run_native` for the production app. Setting it - for the smoke too means the captured PNG actually contains the - WebView surface, so the visual diff is meaningful. - -2. **First-paint latency is roughly 800–1200 ms on Windows.** Wait by - 1500 ms to be safe; bump to 2500 if the binary has heavy startup - (e.g. opening the SQLite store on a cold spindle). The default - `-WaitMs 1500` is enough for the chooser and a sticker re-render but - not for a cold `tokio::spawn` chain. - -3. **Capture the largest visible top-level window owned by the process.** - Title-matching is brittle because every rust sticker uses - `WindowBackgroundAppearance::Transparent` and an unset title (no - `GetWindowText` filter possible). The script picks by area: - `Get-Process $name | sort area desc | first -0`. If multiple windows - are visible (chooser + sticker + file preview + settings), the - largest one is usually the right one, but for an absolute match pass - the right one manually with `-ProcessId `. - -## The script - -`scripts/screenshot.ps1` is a 50-line P/Invoke wrapper around -`PrintWindow` with `PW_RENDERFULLCONTENT` (= 2). That flag is the bit -that convinces WebView2 / Edge-composited layers to paint into our -bitmap — without it, the captured frame is missing exactly the kind -of compositor surface the WebView bug hides in. PNG output goes via -`System.Drawing.Common`'s `Image::FromHbitmap`. - -Don't add new features to it. If a new OS needs the same workflow, -shells out to a platform-native tool (macOS: `screencapture -x`, -Linux: `grim`/`maim`/`gnome-screenshot`). The script deliberately -does NOT attempt a polyglot abstraction. - -## Known not-yet-possible - -These would expand the skill but each is blocked by an external gap: - -- **Headless screenshot** (`HeadlessAppContext::capture_screenshot`). - Pinning `gpui-kit` 0.7.1 means cross-platform headless can't run - on Windows because the Windows platform crate does not provide a - `PlatformHeadlessRenderer`. macOS only. The kit's own - `crates/kit/tests/rendering.rs` is `#[cfg(target_os = "macos")]` - for the same reason; see - `https://github.com/longbridge/gpui-kit/.../website/docs/test.md` - for the canonical writeup. Until we move to a kit release whose - Windows platform adds a headless renderer, this skill is a - desktop-presence-required loop, not a CI step. - -- **UI-integration tests** via `#[gpui_kit::test]`. The kit's - `test-support` feature pulls `gpui_web` (wasm), which conflicts with - the lockfile's `unicode-properties = 0.1.4` (the wasm crate asks - for `0.1.3`). Lockfile has been stable around that conflict since - the migration; resolving it is upstream. - -## Failure modes the agent should anticipate - -- **The PNG shows transparency where content should be.** WebView or - D3D11 surface didn't paint. Check the - `GPUI_DISABLE_DIRECT_COMPOSITION` env var. The fix is identical to - `04c5636` and its docstring in `src/native/mod.rs`. - -- **The PNG is all black / all white.** The window is genuinely - empty, or `PrintWindow` ran before the first paint completed. Bump - `-WaitMs`. If that doesn't help, the GPUI window is failing to - render at all — `cargo run` with `RUST_LOG=gpui=debug` to see the - paint loop. - -- **The PNG is the wrong window.** Multi-window scenarios. Use - `-WindowTitle ` or `-ProcessId ` instead of - `-ProcessName`. Or close the noisy windows first. - -- **WebView2 reports "could not initialise".** That's an upstream - wry/build issue, not a visual-check issue; ignore — the PNG will - be missing only that one surface and you can still diff the rest. diff --git a/FRONTEND.md b/FRONTEND.md index 3a713d5..794116f 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -80,10 +80,10 @@ src/ tests/ clipboard_storage.rs ← 18 privacy regression tests -- DO NOT regress sticker_palette.rs ← 3 StickerColor regression tests -examples/ - clipboard_ux_smoke.rs ← isolated visual smoke harness .github/workflows/ci.yml .cargo/ (configurations, ignored by git) +scripts/ + screenshot.ps1 ← Windows capture helper for visual checks ``` Adding a new family: drop `mod ;` into `src/native/components/stickers/mod.rs`, @@ -386,7 +386,6 @@ cargo check --locked --features ui cargo clippy --locked --features ui cargo test --locked --no-default-features --features cli cargo test --locked --features cli -cargo build --locked --features ui --example clipboard_ux_smoke ``` Plus a metalava-like check that `cargo tree --features ui` contains @@ -395,17 +394,23 @@ exactly one `gpui-pre` family. UI integration tests via `#[gpui_kit::test]` are deferred: `gpui-kit`'s `test-support` depends on `gpui_web` (`wasm`) whose `unicode-properties = ^0.1.3` conflicts with the closure-locked -`^0.1.4`. Visual flows are covered by `clipboard_ux_smoke.rs`. +`^0.1.4`. Visual flows fall back to the dev-loop recipe under +`.agents/skills/gpui-visual-check/` plus the bundled +`scripts/screenshot.ps1`. --- ## 13. Reference skill catalog ```text -.agents/skills/gpui-kit/ SKILL.md + references/ - (coding-guides, conventions, recipes, gpui/**) -.pi/skills/gpui-kit-design-guides/ SKILL.md + references/design-guides.md -skills-lock.json source URL + computed hash per skill +.agents/skills/ + ├─ gpui-kit/ SKILL.md + references/ + │ (coding-guides, conventions, recipes, gpui/**) + ├─ gpui-kit-design-guides/ SKILL.md + references/design-guides.md + └─ gpui-visual-check/ SKILL.md +scripts/screenshot.ps1 Windows capture helper for the dev-loop + recipes described by gpui-visual-check +skills-lock.json source URL + computed hash per skill docs/plans/gpui-kit-upgrade{,-goal}.md (deleted; absorbed by this doc) docs/plans/clipboard-{coding,design}-guides.md (deleted; absorbed by this doc) README.md, ARCHITECTURE.md read these for what the app does and store/IPC contracts diff --git a/README.md b/README.md index 80efc37..d35dd59 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,7 @@ Log level precedence: cargo fmt --all -- --check cargo check --locked --features ui cargo clippy --locked --features ui -cargo test --locked --features cli # 212 passed, 1 ignored -cargo build --locked --features ui --example clipboard_ux_smoke +cargo test --locked --features cli # 213 passed, 1 ignored ``` CI also runs the same gates across Windows, Ubuntu, and macOS, plus a parity check that From 38553602bed961fda064222ce926c7815168ad38 Mon Sep 17 00:00:00 2001 From: binwenw Date: Wed, 7 Oct 2026 13:02:24 +0800 Subject: [PATCH 31/69] feat(cli): rename cp import->add and cp forget->rm, and keep flag-like text as values - cp add accepts a VALUE that starts with '-' (allow_hyphen_values); previously 'rst cp add "--json"' failed to parse, which made adding common clipboard text impossible - drop the 'cp vault' subcommand; master-password management lives in the desktop app (to be re-exposed as a top-level command next) - human output speaks 'Added' / 'nothing added'; hints point at 'cp add' - new cp add tests: parser-level and end-to-end flag-like values, duplicate upsert moves the row to the top, whitespace-only rejection, JSON outcome --- README.md | 18 +-- src/cli/commands/cp.rs | 256 ++++++++++++++++++-------------- src/cli/mod.rs | 5 +- src/native/windows/clipboard.rs | 2 +- 4 files changed, 153 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index d35dd59..fd4e965 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,11 @@ rst cp ls --list -n 20 # readable recent-history table rst cp ls --list --json # one machine-readable JSON object rst cp ls -- rg -F -- '{{cp}}' . # pick and substitute into argv rst cp ls --id 42 -- my-tool # append the exact text as one argument -rst cp import 'text from a script' -rst cp import --stdin # read a pipeline without trimming it +rst cp add 'text from a script' +rst cp add --stdin # read a pipeline without trimming it rst cp show --id 42 # exact text, no extra newline -rst cp forget 42 # release ordinary saved/edit retention -rst cp vault create # hidden master-password setup -rst cp import --protect --stdin # authorize, encrypt, then store piped text -rst cp vault status +rst cp rm 42 # release ordinary saved/edit retention +rst cp add --protect --stdin # authorize, encrypt, then store piped text ``` Quote `'{{cp}}'` in PowerShell/POSIX shells, or `"{{cp}}"` in Windows cmd. The CLI @@ -56,10 +54,10 @@ and confirms irreversible deletes (pinned, edited, or protected rows) before app Verified automatic paste is currently Windows-only; unavailable or lost destinations fail closed and leave **Copy only** available. -Protected imports are encrypted before insertion and never fall back to ordinary history. -Protected text is not searchable; only an explicitly public alias is visible. CLI password -authorization is scoped to that invocation, **not shared with the desktop process**; -`cp vault unlock` verifies a password rather than unlocking subsequent commands. Automatic +Protected adds are encrypted before insertion and never fall back to ordinary history. +Protected text is not searchable; only an explicitly public alias is visible. The master +password is created and managed in the desktop app; CLI password authorization is scoped +to that invocation, **not shared with the desktop process**. Automatic collection pauses whenever protected history exists until safe protected recapture matching is implemented. diff --git a/src/cli/commands/cp.rs b/src/cli/commands/cp.rs index 95dd28f..fa81270 100644 --- a/src/cli/commands/cp.rs +++ b/src/cli/commands/cp.rs @@ -14,7 +14,7 @@ use crate::clipboard::{ argv, filter, items::{ClipboardItem, ClipboardItemKind}, storage::{ClipboardHistoryStore, RetentionFlag, SqliteClipboardStore, UpsertOutcome}, - vault_controller::{VaultController, VaultState}, + vault_controller::VaultController, }; use crate::storage::paths::AppPaths; @@ -34,13 +34,11 @@ pub enum CpCommand { #[command(visible_alias = "list")] Ls(CpArgs), /// Add text to history (or pipe text with --stdin). - Import(ImportArgs), + Add(AddArgs), /// Remove reservation/edit retention; protected items remain protected. - Forget(ForgetArgs), + Rm(RmArgs), /// Print live clipboard text, or an exact history value with --id. Show(ShowTextArgs), - /// Manage the master password for encrypted history. - Vault(VaultArgs), } #[derive(clap::Args, Debug)] @@ -67,19 +65,19 @@ pub struct CpArgs { } #[derive(clap::Args, Debug)] -pub struct ImportArgs { +pub struct AddArgs { /// Read exact text from stdin. #[arg(long, conflicts_with = "value")] pub stdin: bool, - /// Text to record, unchanged. - #[arg(value_name = "VALUE", required_unless_present = "stdin")] + /// Text to record, unchanged. Values starting with `-` are kept as text. + #[arg(value_name = "VALUE", required_unless_present = "stdin", allow_hyphen_values = true)] pub value: Option, /// Encrypt before writing; prompts for the master password on the terminal. #[arg(long)] pub protect: bool, } #[derive(clap::Args, Debug)] -pub struct ForgetArgs { +pub struct RmArgs { #[arg(value_name = "ID")] pub id: i64, } @@ -89,22 +87,6 @@ pub struct ShowTextArgs { #[arg(long, value_name = "ID")] pub id: Option, } -#[derive(clap::Args, Debug)] -pub struct VaultArgs { - #[command(subcommand)] - pub action: VaultAction, -} -#[derive(clap::Subcommand, Debug)] -pub enum VaultAction { - /// Set up a master password (never overwrites an existing vault). - Create, - /// Check the password; authorization lasts only for this CLI invocation. - Unlock, - /// CLI invocations start locked; use the GUI to lock its own session. - Lock, - /// Show persisted configuration (CLI unlock state is never shared). - Status, -} fn open_store(paths: &AppPaths) -> Result> { Ok(Arc::new(block_on(SqliteClipboardStore::open( @@ -118,7 +100,7 @@ fn require_password(prompt: &str) -> Result> { fn unlock(controller: &VaultController) -> Result<()> { if block_on(controller.envelope())?.is_none() { - bail!("no master password configured; run `rst cp vault create` first"); + bail!("no master password configured; create one in the desktop app first"); } let password = require_password("Master password: ")?; if !block_on(controller.unlock(&password))? { @@ -163,7 +145,7 @@ pub fn run(paths: &AppPaths, args: CpArgs, format: Format) -> Result<()> { } else { if snapshot.is_empty() { bail!( - "no clipboard history yet; enable collection in the desktop app or use `rst cp import `" + "no clipboard history yet; enable collection in the desktop app or use `rst cp add `" ); } choose(&snapshot, args.search.as_deref().unwrap_or(""))?.ok_or(ExitCode(130))? @@ -252,7 +234,7 @@ fn run_list( })).collect(); format.emit(json!({"items": payload, "count": rows.len(), "search": search}), || { if rows.is_empty() { - println!("{}", if search.is_some() { "No matching clipboard items." } else { "No clipboard history yet. Enable collection in the desktop app, or use cp import ." }); + println!("{}", if search.is_some() { "No matching clipboard items." } else { "No clipboard history yet. Enable collection in the desktop app, or use cp add ." }); return; } let cells: Vec<_> = rows.iter().map(|row| vec![row.id.to_string(), safe_preview(&row.preview_text(), 72), row.byte_size.to_string(), { @@ -267,7 +249,7 @@ fn run_list( Ok(()) } -pub fn import(paths: &AppPaths, args: ImportArgs, format: Format) -> Result<()> { +pub fn add(paths: &AppPaths, args: AddArgs, format: Format) -> Result<()> { let value = Zeroizing::new(if args.stdin { let mut text = String::new(); io::stdin() @@ -278,7 +260,7 @@ pub fn import(paths: &AppPaths, args: ImportArgs, format: Format) -> Result<()> args.value.context("provide VALUE or --stdin")? }); if !crate::clipboard::items::is_collectable(&value) { - bail!("cannot import empty or whitespace-only text"); + bail!("cannot add empty or whitespace-only text"); } if value.contains('\0') { bail!("clipboard text cannot contain a NUL byte"); @@ -305,7 +287,7 @@ pub fn import(paths: &AppPaths, args: ImportArgs, format: Format) -> Result<()> let settings = block_on(store.settings())?; if settings.normal_byte_limit > 0 && bytes > settings.normal_byte_limit { bail!( - "text exceeds ordinary history byte limit ({} > {}); nothing imported", + "text exceeds ordinary history byte limit ({} > {}); nothing added", bytes, settings.normal_byte_limit ); @@ -328,7 +310,7 @@ pub fn import(paths: &AppPaths, args: ImportArgs, format: Format) -> Result<()> println!( "{} #{id} · {bytes} bytes{}", if outcome == "inserted" { - "Imported" + "Added" } else { "Updated" }, @@ -339,7 +321,7 @@ pub fn import(paths: &AppPaths, args: ImportArgs, format: Format) -> Result<()> Ok(()) } -pub fn forget(paths: &AppPaths, args: ForgetArgs, format: Format) -> Result<()> { +pub fn rm(paths: &AppPaths, args: RmArgs, format: Format) -> Result<()> { let store = open_store(paths)?; let row = block_on(store.get(args.id))?; if row.kind == ClipboardItemKind::Protected { @@ -374,61 +356,15 @@ pub fn show(paths: &AppPaths, args: ShowTextArgs, format: Format) -> Result<()> Ok(()) } -pub fn vault(paths: &AppPaths, args: VaultArgs, format: Format) -> Result<()> { - let controller = VaultController::new(open_store(paths)?); - match args.action { - VaultAction::Status => { - let configured = block_on(controller.envelope())?.is_some(); - let state = if configured { "locked" } else { "unconfigured" }; - format.emit(json!({"state": state, "has_master_password": configured, "scope": "this CLI invocation"}), || println!("Vault: {state}. CLI authorization is not shared with the desktop app.")); - } - VaultAction::Create => { - if !matches!(block_on(controller.snapshot()), VaultState::Unconfigured) { - bail!( - "master password already configured; change it in the desktop app (existing ciphertext must keep its data key)" - ); - } - let password = require_password("New master password: ")?; - if password.is_empty() { - bail!("master password must not be empty"); - } - let confirm = require_password("Confirm password: ")?; - if password != confirm { - bail!("passwords do not match; nothing changed"); - } - block_on(controller.create(&password))?; - format.emit(json!({"state": "locked"}), || { - println!("Master password created. Protected commands will prompt when needed.") - }); - } - VaultAction::Unlock => { - unlock(&controller)?; - format.emit(json!({"state": "verified", "scope": "this CLI invocation"}), || println!("Password verified. Each protected command authorizes separately; the desktop vault is unchanged.")); - } - VaultAction::Lock => { - controller.lock(); - format.emit( - json!({"state": "locked", "scope": "this CLI invocation"}), - || { - println!( - "This CLI invocation is locked. Lock the desktop vault from its chooser." - ) - }, - ); - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; use clap::Parser; #[derive(Parser, Debug)] - struct TestCli { + pub(crate) struct TestCli { #[command(subcommand)] - command: CpCommand, + pub(crate) command: CpCommand, } #[test] @@ -448,14 +384,43 @@ mod tests { fn parser_accepts_search_without_list_and_rejects_discarded_commands() { assert!(TestCli::try_parse_from(["cp", "ls", "-s", "rust"]).is_ok()); assert!(TestCli::try_parse_from(["cp", "ls", "--list", "--", "echo"]).is_err()); - assert!(TestCli::try_parse_from(["cp", "import"]).is_err()); - assert!(TestCli::try_parse_from(["cp", "import", "--stdin"]).is_ok()); + assert!(TestCli::try_parse_from(["cp", "add"]).is_err()); + assert!(TestCli::try_parse_from(["cp", "add", "--stdin"]).is_ok()); + } + + #[test] + fn parser_keeps_flag_like_text_as_value() { + // Clipboard content very often starts with a dash; it must arrive as text, not as options. + for text in ["--json", "-n 5", "--protect-but-not"] { + let parsed = TestCli::try_parse_from(["cp", "add", text]).unwrap(); + let CpCommand::Add(args) = parsed.command else { + panic!() + }; + assert_eq!(args.value.as_deref(), Some(text)); + assert!(!args.protect); + } + } + + #[test] + fn parser_still_accepts_protect_and_stdin_flags() { + let parsed = TestCli::try_parse_from(["cp", "add", "--protect", "secret"]).unwrap(); + let CpCommand::Add(args) = parsed.command else { + panic!() + }; + assert!(args.protect); + assert_eq!(args.value.as_deref(), Some("secret")); + assert!(matches!( + TestCli::try_parse_from(["cp", "rm", "42"]).unwrap().command, + CpCommand::Rm(RmArgs { id: 42 }) + )); } } #[cfg(test)] mod regression_tests { + use super::tests::TestCli; use super::*; + use clap::Parser; fn paths(tmp: &tempfile::TempDir) -> AppPaths { AppPaths { @@ -463,13 +428,26 @@ mod regression_tests { } } + fn add_text(paths: &AppPaths, text: &str) { + add( + paths, + AddArgs { + stdin: false, + value: Some(text.into()), + protect: false, + }, + Format::Json, + ) + .unwrap(); + } + #[test] - fn failed_protected_import_never_stores_plaintext() { + fn failed_protected_add_never_stores_plaintext() { let tmp = tempfile::tempdir().unwrap(); let paths = paths(&tmp); - let error = import( + let error = add( &paths, - ImportArgs { + AddArgs { stdin: false, value: Some("must remain secret".into()), protect: true, @@ -486,27 +464,18 @@ mod regression_tests { } #[test] - fn import_preserves_exact_payload_and_rejects_nul() { + fn add_preserves_exact_payload_and_rejects_nul() { let tmp = tempfile::tempdir().unwrap(); let paths = paths(&tmp); let text = " Mixed CASE\n日本語\t\"$() & ;\" "; - import( - &paths, - ImportArgs { - stdin: false, - value: Some(text.into()), - protect: false, - }, - Format::Json, - ) - .unwrap(); + add_text(&paths, text); let rows = block_on(open_store(&paths).unwrap().snapshot()).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].content, text); assert!( - import( + add( &paths, - ImportArgs { + AddArgs { stdin: false, value: Some("bad\0text".into()), protect: false @@ -524,12 +493,68 @@ mod regression_tests { } #[test] - fn missing_program_preserves_127_and_does_not_promote() { + fn add_rejects_empty_and_whitespace_only_text() { + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + for blank in ["", " ", "\n\t "] { + assert!( + add( + &paths, + AddArgs { + stdin: false, + value: Some(blank.into()), + protect: false + }, + Format::Json + ) + .is_err(), + "expected rejection of {blank:?}" + ); + } + assert!( + block_on(open_store(&paths).unwrap().snapshot()) + .unwrap() + .is_empty() + ); + } + + #[test] + fn add_hyphen_prefixed_text_end_to_end() { + // The parser-level guarantee, verified through the real store: flag-like text lands verbatim. + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + let parsed = TestCli::try_parse_from(["cp", "add", "--grep --flags -x"]).unwrap(); + let CpCommand::Add(args) = parsed.command else { + panic!() + }; + add(&paths, args, Format::Json).unwrap(); + let rows = block_on(open_store(&paths).unwrap().snapshot()).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].content, "--grep --flags -x"); + } + + #[test] + fn add_moves_existing_duplicate_to_top() { let tmp = tempfile::tempdir().unwrap(); let paths = paths(&tmp); - import( + add_text(&paths, "first"); + std::thread::sleep(std::time::Duration::from_millis(5)); + add_text(&paths, "second"); + std::thread::sleep(std::time::Duration::from_millis(5)); + add_text(&paths, "first"); // same text again: must refresh recency, not duplicate + let rows = block_on(open_store(&paths).unwrap().snapshot()).unwrap(); + assert_eq!(rows.len(), 2, "duplicate text must upsert, not insert"); + assert_eq!(rows[0].content, "first"); + assert!(rows[0].last_copied_at >= rows[1].last_copied_at); + } + + #[test] + fn add_reports_ids_and_json_outcome() { + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + add( &paths, - ImportArgs { + AddArgs { stdin: false, value: Some("payload".into()), protect: false, @@ -537,6 +562,18 @@ mod regression_tests { Format::Json, ) .unwrap(); + let row = block_on(open_store(&paths).unwrap().snapshot()) + .unwrap() + .remove(0); + assert_eq!(row.content, "payload"); + assert_eq!(row.byte_size, 7); + } + + #[test] + fn missing_program_preserves_127_and_does_not_promote() { + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + add_text(&paths, "payload"); let store = open_store(&paths).unwrap(); let row = block_on(store.snapshot()).unwrap().remove(0); let error = run( @@ -561,7 +598,7 @@ mod regression_tests { } #[test] - fn forget_rejects_protected_ciphertext() { + fn rm_rejects_protected_ciphertext() { let tmp = tempfile::tempdir().unwrap(); let paths = paths(&tmp); let store = open_store(&paths).unwrap(); @@ -582,7 +619,7 @@ mod regression_tests { let id = match outcome { UpsertOutcome::Inserted { id } | UpsertOutcome::Updated { id } => id, }; - assert!(forget(&paths, ForgetArgs { id }, Format::Json).is_err()); + assert!(rm(&paths, RmArgs { id }, Format::Json).is_err()); let row = block_on(store.get(id)).unwrap(); assert_eq!(row.kind, ClipboardItemKind::Protected); assert!(row.retention.protected_); @@ -594,16 +631,7 @@ mod regression_tests { let tmp = tempfile::tempdir().unwrap(); let paths = paths(&tmp); let text = "日本語\nquotes \" ' ; $(not code) & --json"; - import( - &paths, - ImportArgs { - stdin: false, - value: Some(text.into()), - protect: false, - }, - Format::Json, - ) - .unwrap(); + add_text(&paths, text); let store = open_store(&paths).unwrap(); let row = block_on(store.snapshot()).unwrap().remove(0); let output = tmp.path().join("received.txt"); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0231465..ff35786 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -137,10 +137,9 @@ pub fn run(cli: Cli, app_paths: &AppPaths) -> anyhow::Result<()> { Commands::Show(args) => commands::show::run(app_paths, args, format), Commands::Cp { command } => match command { commands::cp::CpCommand::Ls(args) => commands::cp::run(app_paths, args, format), - commands::cp::CpCommand::Import(args) => commands::cp::import(app_paths, args, format), - commands::cp::CpCommand::Forget(args) => commands::cp::forget(app_paths, args, format), + commands::cp::CpCommand::Add(args) => commands::cp::add(app_paths, args, format), + commands::cp::CpCommand::Rm(args) => commands::cp::rm(app_paths, args, format), commands::cp::CpCommand::Show(args) => commands::cp::show(app_paths, args, format), - commands::cp::CpCommand::Vault(args) => commands::cp::vault(app_paths, args, format), }, Commands::Result(args) => commands::result::run(app_paths, args, format), Commands::Open(args) => commands::window::open(app_paths, args, format), diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index 879652f..5d17db0 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -1007,7 +1007,7 @@ impl ClipboardChooserWidget { Err(err) => { show_chooser(window); this.password_error = Some(if err.to_string().contains("No master password") { - "Set up your master password first: rst cp vault create." + "No clipboard vault yet. Use Protect on an item to set up your master password." } else if err.to_string() == "Wrong master password" { "Incorrect master password. Try again." } else if matches!(action, AuthorizedAction::Protect) { From 5014095fb845e2e49ac4e643da5348d17b7e39f2 Mon Sep 17 00:00:00 2001 From: binwenw Date: Wed, 7 Oct 2026 13:02:44 +0800 Subject: [PATCH 32/69] style(cli): brand the terminal chooser and mark protected items with a lock The chooser header was cyan, off-brand for a product whose primary is rgb(255, 218, 60). Declare it once as PRIMARY and use it, matching the desktop Button::primary / Icon::Locked accent (NO_COLOR still respected; the dark-grey footer and reverse-video selection are neutrals and stay). Adopt the terminal equivalent of the desktop Locked icon, U+1F512, for protected clipboard items in all human-visible spots: chooser badge, 'cp ls --list' SAVED column, and the 'cp add --protect' confirmation. JSON payloads keep the plain "protected" string on purpose. --- src/cli/commands/clipboard_terminal.rs | 12 ++++++++++-- src/cli/commands/cp.rs | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/clipboard_terminal.rs b/src/cli/commands/clipboard_terminal.rs index 1119d29..5caccbd 100644 --- a/src/cli/commands/clipboard_terminal.rs +++ b/src/cli/commands/clipboard_terminal.rs @@ -17,6 +17,14 @@ use zeroize::Zeroizing; use crate::clipboard::{filter::filter_indices, items::ClipboardItem}; +/// The app's primary color, rgb(255, 218, 60) — the same yellow as the desktop `Button::primary()` +/// and the `Icon::Locked` accent — so the terminal chooser reads as the same product. +const PRIMARY: Color = Color::Rgb { + r: 255, + g: 218, + b: 60, +}; + struct Terminal { output: File, alternate: bool, @@ -128,7 +136,7 @@ pub(super) fn choose(rows: &[ClipboardItem], initial_query: &str) -> Result Result = rows.iter().map(|row| vec![row.id.to_string(), safe_preview(&row.preview_text(), 72), row.byte_size.to_string(), { let mut flags = Vec::new(); - if row.retention.protected_ { flags.push("Protected"); } + if row.retention.protected_ { flags.push("🔒 Protected"); } if row.retention.explicit { flags.push("Reserved"); } if row.retention.edited { flags.push("Edited"); } flags.join(", ") @@ -314,7 +314,7 @@ pub fn add(paths: &AppPaths, args: AddArgs, format: Format) -> Result<()> { } else { "Updated" }, - if args.protect { " · Protected" } else { "" } + if args.protect { " · 🔒 Protected" } else { "" } ) }, ); From 0d76c74d75215a16aa9a38233525d4bbdf488fe3 Mon Sep 17 00:00:00 2001 From: binwenw Date: Wed, 7 Oct 2026 13:03:09 +0800 Subject: [PATCH 33/69] feat(cli): expose vault as a standalone top-level command rst vault create/unlock/lock/status returns, outside the cp command where it read as a clipboard detail. The actions are reworded to say what they actually do: unlock verifies the password for one command run only, and lock is honestly documented as a no-op because every CLI run starts locked. status now tells an unconfigured user the two ways to create a vault. require_password/unlock move to vault.rs as the shared gate used by cp add --protect and protected cp show --id, so one prompt behavior covers every protected path; the missing-vault error points at rst vault create again. Four tests: every action parses, bad actions are rejected, create refuses to overwrite before any prompt, and the unlock error names the command. --- README.md | 8 +- src/cli/commands/cp.rs | 20 +-- src/cli/commands/mod.rs | 1 + src/cli/commands/vault.rs | 207 ++++++++++++++++++++++++++++++++ src/cli/mod.rs | 6 +- src/native/windows/clipboard.rs | 2 +- 6 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 src/cli/commands/vault.rs diff --git a/README.md b/README.md index fd4e965..e4269f2 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ rst cp add --stdin # read a pipeline without trimming it rst cp show --id 42 # exact text, no extra newline rst cp rm 42 # release ordinary saved/edit retention rst cp add --protect --stdin # authorize, encrypt, then store piped text +rst vault create # choose a master password (hidden prompts) +rst vault unlock # verify it for one command run +rst vault status # does a vault exist; how CLI authorization works ``` Quote `'{{cp}}'` in PowerShell/POSIX shells, or `"{{cp}}"` in Windows cmd. The CLI @@ -56,8 +59,9 @@ closed and leave **Copy only** available. Protected adds are encrypted before insertion and never fall back to ordinary history. Protected text is not searchable; only an explicitly public alias is visible. The master -password is created and managed in the desktop app; CLI password authorization is scoped -to that invocation, **not shared with the desktop process**. Automatic +password is managed in the desktop app or with `rst vault`; CLI password authorization is +scoped to that invocation, **not shared with the desktop process**, and `rst vault unlock` +verifies a password rather than unlocking subsequent commands. Automatic collection pauses whenever protected history exists until safe protected recapture matching is implemented. diff --git a/src/cli/commands/cp.rs b/src/cli/commands/cp.rs index 83319b4..f7db94d 100644 --- a/src/cli/commands/cp.rs +++ b/src/cli/commands/cp.rs @@ -5,7 +5,8 @@ use std::io::{self, Read, Write}; use std::sync::Arc; use zeroize::Zeroizing; -use super::clipboard_terminal::{choose, password, safe_preview}; +use super::clipboard_terminal::{choose, safe_preview}; +use super::vault::unlock; use crate::cli::{ output::{Format, table}, runtime::block_on, @@ -88,27 +89,12 @@ pub struct ShowTextArgs { pub id: Option, } -fn open_store(paths: &AppPaths) -> Result> { +pub(super) fn open_store(paths: &AppPaths) -> Result> { Ok(Arc::new(block_on(SqliteClipboardStore::open( paths.db_path.with_file_name("clipboard.db"), ))?)) } -fn require_password(prompt: &str) -> Result> { - password(prompt)?.ok_or_else(|| ExitCode(130).into()) -} - -fn unlock(controller: &VaultController) -> Result<()> { - if block_on(controller.envelope())?.is_none() { - bail!("no master password configured; create one in the desktop app first"); - } - let password = require_password("Master password: ")?; - if !block_on(controller.unlock(&password))? { - bail!("incorrect master password; nothing was changed"); - } - Ok(()) -} - fn row_text( store: Arc, row: &ClipboardItem, diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 18cefe8..1cc22d7 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -12,5 +12,6 @@ pub mod markdown; pub mod result; pub mod show; pub mod skill; +pub mod vault; pub mod view; pub mod window; diff --git a/src/cli/commands/vault.rs b/src/cli/commands/vault.rs new file mode 100644 index 0000000..7789fce --- /dev/null +++ b/src/cli/commands/vault.rs @@ -0,0 +1,207 @@ +//! `rst vault` — the master password behind encrypted clipboard history. +//! +//! The vault itself lives in the same `clipboard.db` the desktop app uses, so a password created +//! here works there and vice versa. CLI authorization is deliberately per-invocation: nothing +//! unlocked here unlocks the desktop app or any later command. + +use anyhow::{Result, bail}; +use serde_json::json; +use zeroize::Zeroizing; + +use super::clipboard_terminal::password; +use super::cp::{ExitCode, open_store}; +use crate::cli::{ + output::Format, + runtime::block_on, +}; +use crate::clipboard::vault_controller::{VaultController, VaultState}; +use crate::storage::paths::AppPaths; + +#[derive(clap::Args, Debug)] +#[command( + about = "Manage the encrypted clipboard vault", + after_help = "Examples:\n rst vault create choose a master password (hidden prompts)\n rst vault unlock verify it for one command run\n rst vault status what exists, what is authorized\n\nThe password is only ever typed on the terminal: never argv, stdin, or the environment.\nThe master password cannot be reset — forgetting it makes protected items unreadable." +)] +pub struct VaultArgs { + #[command(subcommand)] + pub action: VaultAction, +} + +#[derive(clap::Subcommand, Debug)] +pub enum VaultAction { + /// Choose a master password; never overwrites an existing vault + Create, + + /// Verify the password; the answer lasts for this one command only + Unlock, + + /// Report the lock state (every CLI run starts locked, so this is a no-op) + Lock, + + /// Show whether a vault exists and how CLI authorization works + Status, +} + +/// Prompt for a password on the controlling terminal; cancellation reports exit code 130. +pub(super) fn require_password(prompt: &str) -> Result> { + password(prompt)?.ok_or_else(|| ExitCode(130).into()) +} + +/// Unlock `controller` for this invocation, prompting once. The shared gate for every command +/// that needs to read or write protected history (`cp add --protect`, `cp show --id`, ...). +pub(super) fn unlock(controller: &VaultController) -> Result<()> { + if block_on(controller.envelope())?.is_none() { + bail!("no master password configured; run `rst vault create` first"); + } + let password = require_password("Master password: ")?; + if !block_on(controller.unlock(&password))? { + bail!("incorrect master password; nothing was changed"); + } + Ok(()) +} + +pub fn run(paths: &AppPaths, args: VaultArgs, format: Format) -> Result<()> { + let controller = VaultController::new(open_store(paths)?); + match args.action { + VaultAction::Status => { + let configured = block_on(controller.envelope())?.is_some(); + let state = if configured { "locked" } else { "unconfigured" }; + format.emit( + json!({"state": state, "has_master_password": configured, "scope": "this CLI invocation"}), + || { + if configured { + println!( + "Vault: configured. Each CLI run starts locked; `rst vault unlock` \ + verifies the password for that run only." + ); + } else { + println!( + "Vault: not configured. Run `rst vault create`, or protect an item \ + in the desktop app." + ); + } + }, + ); + } + VaultAction::Create => { + if !matches!(block_on(controller.snapshot()), VaultState::Unconfigured) { + bail!( + "master password already configured; change it in the desktop app (existing \ + ciphertext must keep its data key)" + ); + } + let password = require_password("New master password: ")?; + if password.is_empty() { + bail!("master password must not be empty"); + } + let confirm = require_password("Confirm password: ")?; + if password != confirm { + bail!("passwords do not match; nothing changed"); + } + block_on(controller.create(&password))?; + format.emit(json!({"state": "locked"}), || { + println!( + "Master password created. Protected commands will prompt for it when needed; \ + it cannot be recovered or reset." + ) + }); + } + VaultAction::Unlock => { + unlock(&controller)?; + format.emit(json!({"state": "verified", "scope": "this CLI invocation"}), || { + println!( + "Password verified. This authorization dies with the command; the desktop \ + app keeps its own lock." + ) + }); + } + VaultAction::Lock => { + controller.lock(); + format.emit(json!({"state": "locked", "scope": "this CLI invocation"}), || { + println!( + "Nothing to do: every CLI run starts locked and this one is. Lock the \ + desktop vault from its chooser." + ) + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser, Debug)] + struct TestCli { + #[command(subcommand)] + command: TestCommand, + } + + #[derive(clap::Subcommand, Debug)] + enum TestCommand { + Vault(VaultArgs), + } + + #[test] + fn every_action_parses() { + // First element is argv[0], then `vault `. + for action in ["create", "unlock", "lock", "status"] { + let parsed = TestCli::try_parse_from(["rst", "vault", action]).unwrap(); + let TestCommand::Vault(args) = parsed.command else { + panic!() + }; + let _ = args.action; + } + assert!(TestCli::try_parse_from(["rst", "vault"]).is_err()); + assert!(TestCli::try_parse_from(["rst", "vault", "delete-everything"]).is_err()); + } + + #[test] + fn status_works_on_an_unconfigured_vault() { + let tmp = tempfile::tempdir().unwrap(); + let paths = crate::storage::paths::AppPaths { + db_path: tmp.path().join("stickers.db"), + }; + run( + &paths, + VaultArgs { + action: VaultAction::Status, + }, + Format::Json, + ) + .unwrap(); + } + + #[test] + fn create_refuses_to_overwrite_an_existing_vault() { + let tmp = tempfile::tempdir().unwrap(); + let paths = crate::storage::paths::AppPaths { + db_path: tmp.path().join("stickers.db"), + }; + let controller = VaultController::new(open_store(&paths).unwrap()); + block_on(controller.create("someone-elses-password")).unwrap(); + // The refusal must happen before any terminal prompt. + let error = run( + &paths, + VaultArgs { + action: VaultAction::Create, + }, + Format::Json, + ) + .unwrap_err(); + assert!(error.to_string().contains("already configured")); + } + + #[test] + fn unlock_without_a_vault_points_at_create() { + let tmp = tempfile::tempdir().unwrap(); + let paths = crate::storage::paths::AppPaths { + db_path: tmp.path().join("stickers.db"), + }; + let controller = VaultController::new(open_store(&paths).unwrap()); + let error = unlock(&controller).unwrap_err(); + assert!(error.to_string().contains("rst vault create")); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ff35786..972a5cf 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -89,6 +89,9 @@ enum Commands { command: commands::cp::CpCommand, }, + /// Manage the master password behind protected clipboard history + Vault(commands::vault::VaultArgs), + /// Print what a command sticker last produced Result(commands::result::ResultArgs), @@ -142,6 +145,7 @@ pub fn run(cli: Cli, app_paths: &AppPaths) -> anyhow::Result<()> { commands::cp::CpCommand::Show(args) => commands::cp::show(app_paths, args, format), }, Commands::Result(args) => commands::result::run(app_paths, args, format), + Commands::Vault(args) => commands::vault::run(app_paths, args, format), Commands::Open(args) => commands::window::open(app_paths, args, format), Commands::Close(args) => commands::window::close(app_paths, args, format), Commands::Delete(args) => commands::delete::run(app_paths, args, format), @@ -166,7 +170,7 @@ mod tests { let names = subcommand_names(); for expected in [ "list", "show", "result", "open", "close", "delete", "view", "markdown", "cmd", - "skill", "cp", + "skill", "cp", "vault", ] { assert!(names.contains(&expected.to_owned()), "missing {expected}"); } diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index 5d17db0..4b46b64 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -1007,7 +1007,7 @@ impl ClipboardChooserWidget { Err(err) => { show_chooser(window); this.password_error = Some(if err.to_string().contains("No master password") { - "No clipboard vault yet. Use Protect on an item to set up your master password." + "No clipboard vault yet. Protect an item here, or run: rst vault create." } else if err.to_string() == "Wrong master password" { "Incorrect master password. Try again." } else if matches!(action, AuthorizedAction::Protect) { From c53f877a9a30b2c38c32e288f2917010d2786260 Mon Sep 17 00:00:00 2001 From: binwenw Date: Wed, 7 Oct 2026 13:08:53 +0800 Subject: [PATCH 34/69] feat(cli): drop vault lock/unlock subcommands Both were honest-but-awkward: every CLI run starts locked, so lock was a no-op and unlock verified a password that expired with its own process - neither did anything a user could feel. Protected commands already prompt for the password exactly when needed, which is the only unlock the CLI needs. 'rst vault' keeps create and status; the shared unlock gate in vault.rs is unchanged for cp add --protect and protected cp show --id. Tests assert the removed actions no longer parse; README and help text follow. --- README.md | 7 +++---- src/cli/commands/vault.rs | 35 +++++++---------------------------- 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e4269f2..102120c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,6 @@ rst cp show --id 42 # exact text, no extra newline rst cp rm 42 # release ordinary saved/edit retention rst cp add --protect --stdin # authorize, encrypt, then store piped text rst vault create # choose a master password (hidden prompts) -rst vault unlock # verify it for one command run rst vault status # does a vault exist; how CLI authorization works ``` @@ -59,9 +58,9 @@ closed and leave **Copy only** available. Protected adds are encrypted before insertion and never fall back to ordinary history. Protected text is not searchable; only an explicitly public alias is visible. The master -password is managed in the desktop app or with `rst vault`; CLI password authorization is -scoped to that invocation, **not shared with the desktop process**, and `rst vault unlock` -verifies a password rather than unlocking subsequent commands. Automatic +password is managed in the desktop app or with `rst vault create`; CLI password authorization +is scoped to that invocation, **not shared with the desktop process** — a protected command +prompts for the password when it needs it. Automatic collection pauses whenever protected history exists until safe protected recapture matching is implemented. diff --git a/src/cli/commands/vault.rs b/src/cli/commands/vault.rs index 7789fce..6d071cc 100644 --- a/src/cli/commands/vault.rs +++ b/src/cli/commands/vault.rs @@ -20,7 +20,7 @@ use crate::storage::paths::AppPaths; #[derive(clap::Args, Debug)] #[command( about = "Manage the encrypted clipboard vault", - after_help = "Examples:\n rst vault create choose a master password (hidden prompts)\n rst vault unlock verify it for one command run\n rst vault status what exists, what is authorized\n\nThe password is only ever typed on the terminal: never argv, stdin, or the environment.\nThe master password cannot be reset — forgetting it makes protected items unreadable." + after_help = "Examples:\n rst vault create choose a master password (hidden prompts)\n rst vault status what exists, how CLI authorization works\n\nThe password is only ever typed on the terminal: never argv, stdin, or the environment.\nProtected commands prompt for it when they need it, for that command only.\nThe master password cannot be reset — forgetting it makes protected items unreadable." )] pub struct VaultArgs { #[command(subcommand)] @@ -32,12 +32,6 @@ pub enum VaultAction { /// Choose a master password; never overwrites an existing vault Create, - /// Verify the password; the answer lasts for this one command only - Unlock, - - /// Report the lock state (every CLI run starts locked, so this is a no-op) - Lock, - /// Show whether a vault exists and how CLI authorization works Status, } @@ -71,8 +65,8 @@ pub fn run(paths: &AppPaths, args: VaultArgs, format: Format) -> Result<()> { || { if configured { println!( - "Vault: configured. Each CLI run starts locked; `rst vault unlock` \ - verifies the password for that run only." + "Vault: configured. Commands that touch protected history prompt \ + for the password, for that run only." ); } else { println!( @@ -106,24 +100,6 @@ pub fn run(paths: &AppPaths, args: VaultArgs, format: Format) -> Result<()> { ) }); } - VaultAction::Unlock => { - unlock(&controller)?; - format.emit(json!({"state": "verified", "scope": "this CLI invocation"}), || { - println!( - "Password verified. This authorization dies with the command; the desktop \ - app keeps its own lock." - ) - }); - } - VaultAction::Lock => { - controller.lock(); - format.emit(json!({"state": "locked", "scope": "this CLI invocation"}), || { - println!( - "Nothing to do: every CLI run starts locked and this one is. Lock the \ - desktop vault from its chooser." - ) - }); - } } Ok(()) } @@ -147,7 +123,7 @@ mod tests { #[test] fn every_action_parses() { // First element is argv[0], then `vault `. - for action in ["create", "unlock", "lock", "status"] { + for action in ["create", "status"] { let parsed = TestCli::try_parse_from(["rst", "vault", action]).unwrap(); let TestCommand::Vault(args) = parsed.command else { panic!() @@ -156,6 +132,9 @@ mod tests { } assert!(TestCli::try_parse_from(["rst", "vault"]).is_err()); assert!(TestCli::try_parse_from(["rst", "vault", "delete-everything"]).is_err()); + // Removed actions must no longer parse; unlocking happens per command, not on demand. + assert!(TestCli::try_parse_from(["rst", "vault", "unlock"]).is_err()); + assert!(TestCli::try_parse_from(["rst", "vault", "lock"]).is_err()); } #[test] From cc714e1f4334b3c459ffc07930e97f2125ac1cfd Mon Sep 17 00:00:00 2001 From: binwenw Date: Wed, 7 Oct 2026 13:22:56 +0800 Subject: [PATCH 35/69] fix(clipboard): CLI and desktop share collection and master password rst cp add died with 'ordinary collection paused' the moment any protected row existed: upsert_ordinary's authorized-match-check hook had never been implemented, so every CLI add failed and the watcher paused with it. Implement the promised keyed matching: - UnlockedVault::match_token: hex(HMAC-SHA256(data_key, domain||text)); only an unlocked vault can mint or confirm one - upsert_ordinary takes the authorized token: matching protected content refreshes the protected row (never a plaintext copy); a non-matching token proves absence and the ordinary insert proceeds; no token still fails closed - compare_exchange_protection persists the token; cp add --protect and the desktop chooser now supply it; legacy NULL-token rows are covered by decrypt-and-compare while unlocked - cp add authorizes through the same vault and master password the desktop app uses (prompt when needed); text identical to a protected item promotes that row instead of writing plaintext - the watcher continues collecting while the desktop vault is unlocked, pausing only while it is locked; status strings follow Tests: match_token keyed/stable/distinct; storage recapture promotion and absence proof; token survives protecting; CLI authorize_recapture covers promote + fresh-text token without the terminal. 132 lib + 21 storage integration green; reproduced the original failure and confirmed the prompt-then-proceed path replaces the hard error. --- README.md | 12 +- src/cli/commands/cp.rs | 189 +++++++++++++++++++++++++----- src/clipboard/storage.rs | 42 ++++++- src/clipboard/vault.rs | 33 ++++++ src/clipboard/vault_controller.rs | 23 ++++ src/native/clipboard/watcher.rs | 25 +++- src/native/mod.rs | 2 +- src/native/windows/clipboard.rs | 8 +- tests/clipboard_storage.rs | 150 +++++++++++++++++++++++- 9 files changed, 434 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 102120c..b467cd7 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,13 @@ closed and leave **Copy only** available. Protected adds are encrypted before insertion and never fall back to ordinary history. Protected text is not searchable; only an explicitly public alias is visible. The master -password is managed in the desktop app or with `rst vault create`; CLI password authorization -is scoped to that invocation, **not shared with the desktop process** — a protected command -prompts for the password when it needs it. Automatic -collection pauses whenever protected history exists until safe protected recapture matching -is implemented. +password is shared between the CLI and the desktop app (one vault, created in either place); +CLI password authorization is scoped to that invocation, **not shared with the desktop +process** — a protected command prompts for the password when it needs it. While protected +items exist, every ordinary add is checked against them with a key derived from that password: +text identical to a protected item refreshes the protected row and never lands in ordinary +history, and anything else collects normally. Unattended desktop collection stays paused until +the vault is unlocked, so a background tick can never write a recaptured secret as plaintext. > Encryption does not erase copies held by other apps, the OS clipboard history, backups, > or pre-encryption database artifacts. Vault grace periods, audit trails, and rotation diff --git a/src/cli/commands/cp.rs b/src/cli/commands/cp.rs index f7db94d..e68594d 100644 --- a/src/cli/commands/cp.rs +++ b/src/cli/commands/cp.rs @@ -254,44 +254,38 @@ pub fn add(paths: &AppPaths, args: AddArgs, format: Format) -> Result<()> { let store = open_store(paths)?; let bytes = value.len() as i64; let now = crate::utils::time::now_unix_millis(); - let outcome = if args.protect { + let (outcome, protected_row) = if args.protect { let controller = VaultController::new(store.clone()); unlock(&controller)?; let sealed = controller .seal_for_storage(value.as_bytes())? .context("vault locked before encryption")?; - block_on(store.upsert_protected_bound( - None, - None, - None, - &sealed.ciphertext, - bytes, - now, - &sealed.vault_envelope, - ))? - } else { - let settings = block_on(store.settings())?; - if settings.normal_byte_limit > 0 && bytes > settings.normal_byte_limit { - bail!( - "text exceeds ordinary history byte limit ({} > {}); nothing added", + // Store the keyed token with the ciphertext so later authorized ordinary writes can + // recognise this content without decrypting it. + let token = controller + .match_token(value.as_bytes())? + .context("vault locked before encryption")?; + ( + block_on(store.upsert_protected_bound( + None, + None, + Some(&token), + &sealed.ciphertext, bytes, - settings.normal_byte_limit - ); - } - block_on(store.upsert_ordinary( - &value, - &safe_preview(&value, 240), - &SqliteClipboardStore::ordinary_digest(&value), - bytes, - now, - ))? + now, + &sealed.vault_envelope, + ))?, + true, + ) + } else { + add_ordinary(store.clone(), &value, bytes, now)? }; let (id, outcome) = match outcome { UpsertOutcome::Inserted { id } => (id, "inserted"), UpsertOutcome::Updated { id } => (id, "updated"), }; format.emit( - json!({"id": id, "outcome": outcome, "protected": args.protect, "byte_size": bytes}), + json!({"id": id, "outcome": outcome, "protected": protected_row, "byte_size": bytes}), || { println!( "{} #{id} · {bytes} bytes{}", @@ -300,13 +294,91 @@ pub fn add(paths: &AppPaths, args: AddArgs, format: Format) -> Result<()> { } else { "Updated" }, - if args.protect { " · 🔒 Protected" } else { "" } + if protected_row { " · 🔒 Protected" } else { "" } ) }, ); Ok(()) } +/// Write plain text into ordinary history. While protected rows exist the write is authorized +/// through the shared vault — the same master password the desktop app uses — so the CLI and the +/// desktop never diverge on what "collectable" means. +fn add_ordinary( + store: Arc, + value: &str, + bytes: i64, + now: i64, +) -> Result<(UpsertOutcome, bool)> { + let settings = block_on(store.settings())?; + if settings.normal_byte_limit > 0 && bytes > settings.normal_byte_limit { + bail!( + "text exceeds ordinary history byte limit ({} > {}); nothing added", + bytes, + settings.normal_byte_limit + ); + } + let protected: Vec<_> = block_on(store.snapshot())? + .into_iter() + .filter(|row| row.kind == ClipboardItemKind::Protected) + .collect(); + let token = if protected.is_empty() { + None + } else { + let controller = VaultController::new(store.clone()); + unlock(&controller)?; + match authorize_recapture(&store, &controller, &protected, value, now)? { + Authorization::Promoted(outcome) => return Ok((outcome, true)), + Authorization::Token(token) => Some(token), + } + }; + let outcome = block_on(store.upsert_ordinary( + value, + &safe_preview(value, 240), + &SqliteClipboardStore::ordinary_digest(value), + bytes, + now, + token.as_deref(), + ))?; + Ok((outcome, false)) +} + +enum Authorization { + /// The exact text already lives on a protected row; that row was promoted instead of a + /// plaintext copy being written. + Promoted(UpsertOutcome), + /// The text is proven absent from protected history; keyed token for the ordinary write. + Token(String), +} + +/// Decide what an ordinary write means while protected rows exist, given an already-unlocked +/// controller. Rows written before match tokens existed carry a NULL token, so each protected +/// row is also decrypted and compared directly — with the vault unlocked that is exactly what +/// "authorized" means, and it covers legacy rows. Split out from [`add_ordinary`] so tests can +/// pass a pre-unlocked controller instead of hitting the terminal prompt. +fn authorize_recapture( + store: &Arc, + controller: &VaultController, + protected: &[ClipboardItem], + value: &str, + now: i64, +) -> Result { + for row in protected { + let bytes = controller + .open(&row.content)? + .context("vault locked before decrypting")?; + if std::str::from_utf8(&bytes).map(|text| text == value).unwrap_or(false) { + // Promote the protected row's recency; never write a plaintext copy. + block_on(store.mark_used(row.id, now))?; + return Ok(Authorization::Promoted(UpsertOutcome::Updated { id: row.id })); + } + } + let token = controller + .match_token(value.as_bytes())? + .context("vault locked during authorization")?; + Ok(Authorization::Token(token)) +} + pub fn rm(paths: &AppPaths, args: RmArgs, format: Format) -> Result<()> { let store = open_store(paths)?; let row = block_on(store.get(args.id))?; @@ -583,6 +655,69 @@ mod regression_tests { assert_eq!(block_on(store.get(row.id)).unwrap().last_used_at, None); } + #[test] + fn recapture_with_protected_history_promotes_or_authorizes() { + // The bug this guards: while any protected row existed, every ordinary `cp add` failed + // with "ordinary collection paused". The shared master password now authorizes the + // write: identical text promotes the protected row, anything else gets a keyed token. + let tmp = tempfile::tempdir().unwrap(); + let paths = paths(&tmp); + let store = open_store(&paths).unwrap(); + let controller = VaultController::new(store.clone()); + block_on(controller.create("test-master-password")).unwrap(); + assert!(block_on(controller.unlock("test-master-password")).unwrap()); + let secret = "vaulted text"; + let sealed = controller.seal_for_storage(secret.as_bytes()).unwrap().unwrap(); + let token = controller.match_token(secret.as_bytes()).unwrap().unwrap(); + let protected_id = match block_on(store.upsert_protected_bound( + None, + None, + Some(&token), + &sealed.ciphertext, + secret.len() as i64, + 1, + &sealed.vault_envelope, + )) + .unwrap() + { + UpsertOutcome::Inserted { id } | UpsertOutcome::Updated { id } => id, + }; + let protected: Vec<_> = block_on(store.snapshot()) + .unwrap() + .into_iter() + .filter(|row| row.kind == ClipboardItemKind::Protected) + .collect(); + + match authorize_recapture(&store, &controller, &protected, secret, 50).unwrap() { + Authorization::Promoted(UpsertOutcome::Updated { id }) => assert_eq!(id, protected_id), + other => panic!("expected the protected row to be promoted, got a token instead"), + } + assert!(block_on(store.get(protected_id)).unwrap().last_used_at.is_some()); + assert_eq!( + block_on(store.snapshot()).unwrap().len(), + 1, + "no plaintext copy of the protected text may exist" + ); + + let different = match authorize_recapture(&store, &controller, &protected, "brand new", 60) + { + Ok(Authorization::Token(token)) => token, + other => panic!("expected a keyed token for fresh text, got {other:?}"), + }; + assert_eq!(different.len(), 64); + // And the storage layer accepts the ordinary write with that token. + block_on(store.upsert_ordinary( + "brand new", + "brand new", + &SqliteClipboardStore::ordinary_digest("brand new"), + 9, + 60, + Some(&different), + )) + .unwrap(); + assert_eq!(block_on(store.snapshot()).unwrap().len(), 2); + } + #[test] fn rm_rejects_protected_ciphertext() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/clipboard/storage.rs b/src/clipboard/storage.rs index ae944a2..79fad90 100644 --- a/src/clipboard/storage.rs +++ b/src/clipboard/storage.rs @@ -35,6 +35,13 @@ pub trait ClipboardHistoryStore: Send + Sync { /// Insert or update a row by content identity. The implementation decides whether the content /// is already present (plain-text digest for ordinary rows, the supplied match token for /// protected rows). Returns the row id and whether the row was newly created. + /// + /// While any protected row exists, an ordinary write must carry `authorized_match_token` + /// (a keyed token from the unlocked vault, see [`super::vault::UnlockedVault::match_token`]). + /// A token that matches a protected row refreshes that row instead of storing a plaintext + /// copy; a non-matching token proves the content is absent from protected history and the + /// ordinary insert proceeds. Without a token the write fails closed, which is what pauses + /// unattended collection. async fn upsert_ordinary( &self, content: &str, @@ -42,6 +49,7 @@ pub trait ClipboardHistoryStore: Send + Sync { plaintext_digest: &str, byte_size: i64, now_ms: i64, + authorized_match_token: Option<&str>, ) -> Result; /// Store ciphertext only. Plaintext previews are rejected. An optional caller-authenticated @@ -73,12 +81,15 @@ pub trait ClipboardHistoryStore: Send + Sync { /// Protect an existing ordinary row without changing its identity, recency, or independent /// retention reasons. Rejects stale content, deletion, and concurrent vault reset/rewrap. /// Clears live plaintext columns only; this does not guarantee historical disk erasure. + /// `match_token` is the keyed token ([`super::vault::UnlockedVault::match_token`]) of the + /// protected content, so later authorized ordinary writes can recognise it without decrypting. async fn compare_exchange_protection( &self, id: i64, expected_plaintext: &str, envelope: &str, expected_vault_envelope: &str, + match_token: Option<&str>, public_label: Option<&str>, ) -> Result<()>; /// Restore authenticated plaintext against the exact ciphertext and vault generation. @@ -442,6 +453,7 @@ impl ClipboardHistoryStore for SqliteClipboardStore { plaintext_digest: &str, byte_size: i64, now_ms: i64, + authorized_match_token: Option<&str>, ) -> Result { ensure!( plaintext_digest == Self::ordinary_digest(content), @@ -465,16 +477,32 @@ impl ClipboardHistoryStore for SqliteClipboardStore { byte_limit <= 0 || byte_size <= byte_limit, "clipboard content exceeds the ordinary byte limit" ); - // No authorized keyed match check is supplied here: fail closed on recapture. + // Authorized recapture matching: while protected rows exist, an ordinary write must + // carry a keyed token from the unlocked vault. The token either promotes the matching + // protected row (no plaintext copy is ever written) or proves the content is absent. let protected: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM clipboard_items WHERE protected_envelope IS NOT NULL)", ) .fetch_one(&mut *tx) .await?; - ensure!( - !protected, - "ordinary collection paused: protected history requires an authorized match check" - ); + if protected { + let Some(token) = authorized_match_token else { + anyhow::bail!( + "ordinary collection paused: protected history requires an authorized match \ + check; unlock the vault and retry (the desktop prompts, `rst vault create` \ + sets the shared master password)" + ); + }; + let matched: Option = sqlx::query_scalar( + "SELECT id FROM clipboard_items WHERE protected_envelope IS NOT NULL AND protected_match_token = ?1 LIMIT 1") + .bind(token).fetch_optional(&mut *tx).await?; + if let Some(id) = matched { + sqlx::query("UPDATE clipboard_items SET last_copied_at = MAX(last_copied_at, ?1) WHERE id = ?2") + .bind(now_ms).bind(id).execute(&mut *tx).await?; + tx.commit().await?; + return Ok(UpsertOutcome::Updated { id }); + } + } let existing: Option<(i64, i64)> = sqlx::query_as( "SELECT id, byte_size FROM clipboard_items WHERE plaintext_digest = ?1 AND protected_envelope IS NULL AND content = ?2 @@ -598,6 +626,7 @@ impl ClipboardHistoryStore for SqliteClipboardStore { expected_plaintext: &str, envelope: &str, expected_vault_envelope: &str, + match_token: Option<&str>, public_label: Option<&str>, ) -> Result<()> { if let Some(label) = public_label { @@ -620,7 +649,7 @@ impl ClipboardHistoryStore for SqliteClipboardStore { ); sqlx::query( "UPDATE clipboard_items SET protected_envelope = ?1, content = '', - plain_preview = NULL, plaintext_digest = NULL, protected_match_token = NULL, + plain_preview = NULL, plaintext_digest = NULL, protected_match_token = ?5, content_bytes = ?2, byte_size = ?2, retention_protected = 1, locked = 1, public_label = ?4 WHERE id = ?3", ) @@ -628,6 +657,7 @@ impl ClipboardHistoryStore for SqliteClipboardStore { .bind(expected_plaintext.len() as i64) .bind(id) .bind(public_label.map(str::trim)) + .bind(match_token) .execute(&mut *tx) .await?; tx.commit().await?; diff --git a/src/clipboard/vault.rs b/src/clipboard/vault.rs index 238edbf..30c4cac 100644 --- a/src/clipboard/vault.rs +++ b/src/clipboard/vault.rs @@ -121,6 +121,24 @@ impl UnlockedVault { Ok(format!("{}:{}", base64_nonce(nonce_bytes), encoded)) } + /// Keyed duplicate-detection token: hex(HMAC-SHA256(data_key, domain || plaintext)). Only an + /// unlocked vault can produce or confirm one, so a protected row can be matched against a + /// candidate copy without decrypting anything and without a reusable content hash. Domain + /// separation keeps this token from ever being interchangeable with ciphertext. + pub fn match_token(&self, plaintext: &[u8]) -> String { + let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, self.data_key.as_ref()); + let mut context = ring::hmac::Context::with_key(&key); + context.update(b"rustickers-match-v1\n"); + context.update(plaintext); + let tag = context.sign(); + let mut out = String::with_capacity(64); + for byte in tag.as_ref() { + use std::fmt::Write; + let _ = write!(&mut out, "{byte:02x}"); + } + out + } + /// Inverse of [`seal`]. Returns the plaintext wrapped in a [`Zeroizing`] buffer; the caller /// is responsible for dropping the buffer (or moving it to a feature-gated scratch area). pub fn open(&self, sealed: &str) -> Result>> { @@ -308,6 +326,21 @@ impl Drop for UnlockedVault { mod tests { use super::*; + #[test] + fn match_token_is_keyed_stable_and_distinct() { + let vault = create_envelope("master").expect("create"); + let token = vault.match_token(b"secret text"); + assert_eq!(token.len(), 64); + assert!(token.chars().all(|c| c.is_ascii_hexdigit())); + // Stable for the same key and content; different content never collides. + assert_eq!(token, vault.match_token(b"secret text")); + assert_ne!(token, vault.match_token(b"secret texu")); + // And only this vault's key can reproduce it: a second vault proves the token is keyed, + // not a plain content hash. + let other = create_envelope("master").expect("create"); + assert_ne!(token, other.match_token(b"secret text")); + } + #[test] fn create_then_unlock_round_trips() { let vault = create_envelope("correct horse battery staple").expect("create"); diff --git a/src/clipboard/vault_controller.rs b/src/clipboard/vault_controller.rs index 2b3396d..9a3df04 100644 --- a/src/clipboard/vault_controller.rs +++ b/src/clipboard/vault_controller.rs @@ -275,6 +275,29 @@ impl VaultController { vault_envelope: unlocked.envelope().serialize()?, })) } + + /// Keyed match token for authorized recapture matching ([`UnlockedVault::match_token`] / + /// [`SqliteClipboardStore::upsert_ordinary`]). Returns `Ok(None)` when locked or expired, + /// exactly like [`seal`]: an unattended caller treats the absence of a token as + /// "not authorized" and fails closed. + /// + /// [`SqliteClipboardStore::upsert_ordinary`]: crate::clipboard::storage::SqliteClipboardStore::upsert_ordinary + pub fn match_token(&self, plaintext: &[u8]) -> Result> { + let mut guard = self + .unlocked + .lock() + .map_err(|_| anyhow!("vault mutex poisoned"))?; + if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) + { + *guard = None; + return Ok(None); + } + let Some(unlocked) = guard.as_ref() else { + return Ok(None); + }; + Ok(Some(unlocked.match_token(plaintext))) + } + /// Inverse of [`seal`]. Returns `Ok(None)` when the vault is currently locked so callers can /// distinguish *sealed-but-unavailable* from *sealed-and-decryptable*. pub fn open(&self, envelope: &str) -> Result>>> { diff --git a/src/native/clipboard/watcher.rs b/src/native/clipboard/watcher.rs index b5f07df..0cbcedd 100644 --- a/src/native/clipboard/watcher.rs +++ b/src/native/clipboard/watcher.rs @@ -135,6 +135,7 @@ pub async fn persist_capture( store: &dyn ClipboardHistoryStore, captured: &CapturedText, settings: &ClipboardSettings, + authorized_match_token: Option<&str>, ) -> anyhow::Result> { if !settings.capture_enabled { return Ok(None); @@ -157,7 +158,7 @@ pub async fn persist_capture( let digest = SqliteClipboardStore::ordinary_digest(&captured.content); let now = crate::utils::time::now_unix_millis(); let outcome = store - .upsert_ordinary(&captured.content, &preview, &digest, byte_size, now) + .upsert_ordinary(&captured.content, &preview, &digest, byte_size, now, authorized_match_token) .await?; let id = match outcome { UpsertOutcome::Inserted { id } | UpsertOutcome::Updated { id } => id, @@ -220,6 +221,7 @@ impl WatcherHandle { self, filter: Arc, store: Arc, + vault: Option>, ) -> std::thread::JoinHandle<()> { std::thread::Builder::new().name("clipboard-watcher".into()).spawn(move || { let mut last_seen = LastSeenText::new(); @@ -234,7 +236,11 @@ impl WatcherHandle { // capture-save path and from the chooser side, never from // a poll gate. let has_protected = futures::executor::block_on(store.has_protected_items())?; - if !settings.capture_enabled || has_protected { + // Protected history only pauses collection while the vault is locked: an + // unlocked vault can produce the authorized match token that keeps recapture + // safe (see SqliteClipboardStore::upsert_ordinary). + let authorized = vault.as_ref().is_some_and(|vault| vault.is_unlocked()); + if !settings.capture_enabled || (has_protected && !authorized) { active = false; // Notify only on transitions: emitting the pause signal // every tick would force the chooser to refresh @@ -243,11 +249,11 @@ impl WatcherHandle { last_has_protected = Some(has_protected); push_watcher_signal(WatcherSignal::CapturePaused); } - set_capture_status(if has_protected { "Collection paused: protected history requires a safe recapture match check." } + set_capture_status(if has_protected { "Collection paused: unlock the vault to keep collecting alongside protected history." } else { "Collection paused. Enable to capture copied text (including secrets)." }); return Ok(()); } - last_has_protected = Some(false); + last_has_protected = Some(has_protected); if !active { last_seen = LastSeenText::new(); last_generation = None; active = true; } let transaction = filter.transaction(); let before = clipboard_generation(); @@ -275,7 +281,16 @@ impl WatcherHandle { // writes. Update the returned id when persist_capture // exposes it; see the WatcherSignal::Captured // arm below for the queueing pattern. - futures::executor::block_on(persist_capture(&*store, &captured, &settings))?; + // With protected rows present the write must carry the keyed token; + // the vault was verified unlocked at the gate above. + let token = if has_protected { + vault.as_ref().and_then(|vault| vault.match_token(captured.content.as_bytes()).ok().flatten()) + } else { None }; + if has_protected && token.is_none() { + set_capture_status("Collection paused: the vault lock expired mid-copy; unlock it to keep collecting."); + return Ok(()); + } + futures::executor::block_on(persist_capture(&*store, &captured, &settings, token.as_deref()))?; push_watcher_signal(WatcherSignal::Captured); } } else { last_seen = LastSeenText::new(); } diff --git a/src/native/mod.rs b/src/native/mod.rs index 3d1de7b..6bf4e78 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -252,7 +252,7 @@ pub fn run_native( let _ = clipboard_filter_for_open.set(filter.clone()); let _ = clipboard_vault_for_open.set(vault.clone()); let (watcher, _stop) = crate::native::clipboard::watcher::WatcherHandle::new(Duration::from_millis(250)); - watcher.spawn(filter.clone(), clipboard_store.clone()); + watcher.spawn(filter.clone(), clipboard_store.clone(), Some(vault.clone())); crate::native::windows::clipboard::set_global_store(clipboard_store); cx.update(move |_cx| { crate::native::windows::clipboard::set_global_capture_filter(filter); diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index 4b46b64..cf3ab5b 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -909,7 +909,11 @@ impl ClipboardChooserWidget { anyhow::ensure!(current.kind == ClipboardItemKind::Plain && current.content == **expected, "Item changed; reload before protecting"); let sealed = vault_for_work.seal_for_storage(expected.as_bytes())? .ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; - store_for_work.compare_exchange_protection(id, expected, &sealed.ciphertext, &sealed.vault_envelope, Some(&label)).await?; + // Persist the keyed token so later authorized collection can recognise + // this content and keep it out of ordinary plaintext rows. + let token = vault_for_work.match_token(expected.as_bytes())? + .ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; + store_for_work.compare_exchange_protection(id, expected, &sealed.ciphertext, &sealed.vault_envelope, Some(&token), Some(&label)).await?; return Ok::<_, anyhow::Error>(AuthorizedResult::Protected); } anyhow::ensure!(current.kind == ClipboardItemKind::Protected, "Item changed; reload before use"); @@ -973,7 +977,7 @@ impl ClipboardChooserWidget { } Ok(AuthorizedResult::Protected) => { this.cancel_prompt(window, cx); - this.status_error = false; this.status = "Item protected. Collection is paused to prevent recapturing it.".into(); + this.status_error = false; this.status = "Item protected. Future copies need the vault unlocked to join ordinary history.".into(); // Erase the stale chooser snapshot before the asynchronous refresh. for row in this.rows.iter_mut().filter(|row| row.id == id) { row.content.clear(); row.plain_preview = None; diff --git a/tests/clipboard_storage.rs b/tests/clipboard_storage.rs index 2e4e908..b078f99 100644 --- a/tests/clipboard_storage.rs +++ b/tests/clipboard_storage.rs @@ -22,6 +22,7 @@ fn insert_then_snapshot_round_trips() { &SqliteClipboardStore::ordinary_digest("alpha content"), 13, 1_700_000_000_000, + None, )) .expect("insert first"); let id_first = match first { @@ -35,6 +36,7 @@ fn insert_then_snapshot_round_trips() { &SqliteClipboardStore::ordinary_digest("beta content"), 12, 1_700_000_001_000, + None, )) .expect("insert second"); let id_second = match second { @@ -56,14 +58,14 @@ fn digest_dedupe_returns_existing_row() { let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open"); let digest = SqliteClipboardStore::ordinary_digest("dup"); - let id1 = match block_on(store.upsert_ordinary("dup", "dup", &digest, 3, 1_700_000_000_000)) + let id1 = match block_on(store.upsert_ordinary("dup", "dup", &digest, 3, 1_700_000_000_000, None)) .expect("first") { rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, other => panic!("expected Inserted, got {other:?}"), }; - let id2 = match block_on(store.upsert_ordinary("dup", "dup", &digest, 3, 1_700_000_010_000)) + let id2 = match block_on(store.upsert_ordinary("dup", "dup", &digest, 3, 1_700_000_010_000, None)) .expect("second") { rustickers::clipboard::storage::UpsertOutcome::Updated { id } => id, @@ -90,6 +92,7 @@ fn setting_retention_flags_protects_against_evict() { &SqliteClipboardStore::ordinary_digest("pinned"), "pinned".len() as i64, 1_700_000_000_000, + None, )) .expect("insert") { @@ -176,6 +179,7 @@ fn insert(store: &SqliteClipboardStore, text: &str, time: i64) -> i64 { &SqliteClipboardStore::ordinary_digest(text), text.len() as i64, time, + None, )) .unwrap(), ) @@ -302,7 +306,8 @@ fn protected_storage_never_exposes_plaintext_and_cannot_orphan_vault() { secret, &SqliteClipboardStore::ordinary_digest(secret), secret.len() as i64, - 30 + 30, + None, )) .is_err() ); @@ -459,7 +464,8 @@ fn ordinary_upsert_and_lowered_limits_trim_atomically_without_touching_retained( "oversized", &SqliteClipboardStore::ordinary_digest("oversized"), 9, - 50 + 50, + None, )) .is_err() ); @@ -561,6 +567,7 @@ fn reset_generation_rejects_stale_insert_and_protection_without_mutation() { &sealed.ciphertext, &sealed.vault_envelope, None, + None, )) .is_err() ); @@ -575,6 +582,7 @@ fn reset_generation_rejects_stale_insert_and_protection_without_mutation() { "ordinary", &fresh.ciphertext, &fresh.vault_envelope, + None, Some(" Saved command "), )) .unwrap(); @@ -619,6 +627,7 @@ fn protection_cas_preserves_identity_retention_and_recency_and_clears_live_plain &sealed.ciphertext, &sealed.vault_envelope, None, + None, )) .is_err() ); @@ -628,6 +637,7 @@ fn protection_cas_preserves_identity_retention_and_recency_and_clears_live_plain &sealed.ciphertext, &sealed.vault_envelope, None, + None, )) .unwrap(); let after = block_on(store.get(id)).unwrap(); @@ -666,6 +676,7 @@ fn protection_cas_preserves_identity_retention_and_recency_and_clears_live_plain &sealed.ciphertext, &sealed.vault_envelope, None, + None, )) .is_err() ); @@ -677,6 +688,7 @@ fn protection_cas_preserves_identity_retention_and_recency_and_clears_live_plain &sealed.ciphertext, &sealed.vault_envelope, None, + None, )) .is_err() ); @@ -809,3 +821,133 @@ fn authenticated_unprotection_is_atomic_and_preserves_pin() { .is_err() ); } + +#[test] +fn authorized_recapture_promotes_protected_row_and_lets_new_text_through() { + use rustickers::clipboard::storage::UpsertOutcome; + use rustickers::clipboard::vault_controller::VaultController; + + let tmp = tempfile::tempdir().expect("tmpdir"); + let store: std::sync::Arc = + std::sync::Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open")); + let controller = VaultController::new(store.clone()); + block_on(controller.create("password")).unwrap(); + assert!(block_on(controller.unlock("password")).unwrap()); + + let secret = "recapture secret"; + let sealed = controller.seal_for_storage(secret.as_bytes()).unwrap().unwrap(); + let token = controller.match_token(secret.as_bytes()).unwrap().unwrap(); + let protected_id = inserted_id( + block_on(store.upsert_protected_bound( + None, + None, + Some(&token), + &sealed.ciphertext, + secret.len() as i64, + 10, + &sealed.vault_envelope, + )) + .unwrap(), + ); + + // Without an authorized token the ordinary write still fails closed. + assert!(block_on(store.upsert_ordinary( + secret, + secret, + &SqliteClipboardStore::ordinary_digest(secret), + secret.len() as i64, + 20, + None + )) + .is_err()); + + // Authorized and identical to the protected content: the protected row is promoted and no + // plaintext copy is ever written. + let outcome = block_on(store.upsert_ordinary( + secret, + secret, + &SqliteClipboardStore::ordinary_digest(secret), + secret.len() as i64, + 30, + Some(&token), + )) + .unwrap(); + match outcome { + UpsertOutcome::Updated { id } => assert_eq!(id, protected_id), + other => panic!("expected promotion of the protected row, got {other:?}"), + } + assert_eq!(block_on(store.snapshot()).unwrap().len(), 1); + assert!(block_on(store.get(protected_id)).unwrap().retention.protected_); + + // Authorized and different: the token proves absence, so ordinary collection proceeds. + let fresh = "an ordinary later copy"; + let fresh_token = controller.match_token(fresh.as_bytes()).unwrap().unwrap(); + let outcome = block_on(store.upsert_ordinary( + fresh, + fresh, + &SqliteClipboardStore::ordinary_digest(fresh), + fresh.len() as i64, + 40, + Some(&fresh_token), + )) + .unwrap(); + match outcome { + UpsertOutcome::Inserted { .. } => {} + other => panic!("expected Inserted for non-matching text, got {other:?}"), + } + assert_eq!(block_on(store.snapshot()).unwrap().len(), 2); +} + +#[test] +fn protecting_a_row_stores_its_token_for_later_recapture() { + use rustickers::clipboard::vault_controller::VaultController; + + let tmp = tempfile::tempdir().expect("tmpdir"); + let store: std::sync::Arc = + std::sync::Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open")); + let text = "about to be protected"; + let id = { + use rustickers::clipboard::storage::UpsertOutcome; + match block_on(store.upsert_ordinary( + text, + text, + &SqliteClipboardStore::ordinary_digest(text), + text.len() as i64, + 10, + None, + )) + .unwrap() + { + UpsertOutcome::Inserted { id } => id, + other => panic!("{other:?}"), + } + }; + let controller = VaultController::new(store.clone()); + block_on(controller.create("password")).unwrap(); + assert!(block_on(controller.unlock("password")).unwrap()); + let sealed = controller.seal_for_storage(text.as_bytes()).unwrap().unwrap(); + let token = controller.match_token(text.as_bytes()).unwrap().unwrap(); + block_on(store.compare_exchange_protection( + id, + text, + &sealed.ciphertext, + &sealed.vault_envelope, + Some(&token), + None, + )) + .unwrap(); + + // The token survived protecting, so an authorized recapture of the same text promotes the + // protected row instead of writing plaintext back. + let outcome = block_on(store.upsert_ordinary( + text, + text, + &SqliteClipboardStore::ordinary_digest(text), + text.len() as i64, + 20, + Some(&token), + )) + .unwrap(); + assert!(matches!(outcome, rustickers::clipboard::storage::UpsertOutcome::Updated { id: promoted } if promoted == id)); + assert_eq!(block_on(store.get(id)).unwrap().content, sealed.ciphertext); +} From 4fe7a167005854031f306bd9230376055fc38132 Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 8 Oct 2026 14:33:28 +0800 Subject: [PATCH 36/69] feat(text): unify TXTs storage, protection, and desktop workflows Replace clipboard vault and per-sticker locks with shared recipient encryption and atomic key rotation. Add unified text CLI selection, compact TXTs picker, and required searchable titles. Remove obsolete backends and example CI target; simplify contributor docs and add regression coverage. --- .github/workflows/ci.yml | 7 +- ARCHITECTURE.md | 879 +------ Cargo.lock | 437 +++- Cargo.toml | 11 +- FRONTEND.md | 446 +--- README.md | 227 +- migrations/0012_add_unified_text.sql | 31 + src/cli/commands/cp.rs | 784 ------ src/cli/commands/list.rs | 96 +- src/cli/commands/mod.rs | 6 +- .../argv.rs => cli/commands/text_argv.rs} | 18 +- src/cli/commands/text_execution.rs | 86 + ...clipboard_terminal.rs => text_terminal.rs} | 55 +- src/cli/commands/vault.rs | 186 -- src/cli/mod.rs | 42 +- src/clipboard/filter.rs | 122 - src/clipboard/items.rs | 162 -- src/clipboard/mod.rs | 29 +- src/clipboard/ordering.rs | 45 - src/clipboard/retention.rs | 203 -- src/clipboard/settings.rs | 4 +- src/clipboard/storage.rs | 989 -------- src/clipboard/tests.rs | 47 - src/clipboard/vault.rs | 442 ---- src/clipboard/vault_controller.rs | 443 ---- src/lib.rs | 2 + src/native/clipboard/watcher.rs | 119 +- .../components/stickers/content_lock.rs | 532 ---- src/native/components/stickers/file/editor.rs | 3 - src/native/components/stickers/file/mod.rs | 273 +- .../components/stickers/file/preview.rs | 207 +- src/native/components/stickers/markdown.rs | 868 +++---- src/native/components/stickers/mod.rs | 11 +- src/native/mod.rs | 20 +- src/native/windows/clipboard.rs | 2196 +++++++---------- src/native/windows/clipboard/editor.rs | 383 --- src/native/windows/main.rs | 43 +- src/native/windows/mod.rs | 2 + src/native/windows/sticker/open.rs | 35 +- src/native/windows/sticker/view.rs | 64 +- src/native/windows/text_password.rs | 212 ++ src/native/windows/text_title.rs | 71 + src/rst.rs | 2 +- src/storage/sqlite.rs | 23 +- src/text/mod.rs | 146 ++ src/text/protection.rs | 265 ++ src/text/repository.rs | 617 +++++ tests/clipboard_storage.rs | 1167 ++------- tests/text_cli.rs | 460 ++++ tests/text_protection.rs | 524 ++++ tests/text_repository.rs | 295 +++ tests/text_titles.rs | 50 + 52 files changed, 5262 insertions(+), 9125 deletions(-) create mode 100644 migrations/0012_add_unified_text.sql delete mode 100644 src/cli/commands/cp.rs rename src/{clipboard/argv.rs => cli/commands/text_argv.rs} (92%) create mode 100644 src/cli/commands/text_execution.rs rename src/cli/commands/{clipboard_terminal.rs => text_terminal.rs} (89%) delete mode 100644 src/cli/commands/vault.rs delete mode 100644 src/clipboard/filter.rs delete mode 100644 src/clipboard/items.rs delete mode 100644 src/clipboard/ordering.rs delete mode 100644 src/clipboard/retention.rs delete mode 100644 src/clipboard/storage.rs delete mode 100644 src/clipboard/tests.rs delete mode 100644 src/clipboard/vault.rs delete mode 100644 src/clipboard/vault_controller.rs delete mode 100644 src/native/components/stickers/content_lock.rs delete mode 100644 src/native/windows/clipboard/editor.rs create mode 100644 src/native/windows/text_password.rs create mode 100644 src/native/windows/text_title.rs create mode 100644 src/text/mod.rs create mode 100644 src/text/protection.rs create mode 100644 src/text/repository.rs create mode 100644 tests/text_cli.rs create mode 100644 tests/text_protection.rs create mode 100644 tests/text_repository.rs create mode 100644 tests/text_titles.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12c0069..304f1e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,8 +83,8 @@ jobs: - name: cargo check --locked --no-default-features --features storage run: cargo check --locked --no-default-features --features storage - - name: cargo check --locked --no-default-features --features cli --bin rusticker - run: cargo check --locked --no-default-features --features cli --bin rusticker + - name: cargo check --locked --no-default-features --features cli --bin rst + run: cargo check --locked --no-default-features --features cli --bin rst - name: cargo check --locked --features ui run: cargo check --locked --features ui @@ -95,9 +95,6 @@ jobs: - name: cargo test --locked --features cli (UI-feature baseline) run: cargo test --locked --features cli - - name: cargo build --locked --features ui --example clipboard_ux_smoke - run: cargo build --locked --features ui --example clipboard_ux_smoke - - name: Verify zero duplicate GPUI families run: | set -eo pipefail diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 046ef9c..d5271f7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,856 +1,73 @@ -# Rustickers Architecture +# Architecture -A lightweight desktop sticker application for notes, timers, command output, and file previews. Built with [GPUI](https://github.com/zed-industries/zed), powered by SQLite. +Rustickers has two entry points and one local database: -## Table of Contents - -- [Overview](#overview) -- [Project Structure](#project-structure) -- [Build Configuration](#build-configuration) -- [Module Architecture](#module-architecture) - - [Entry Points](#entry-points) - - [IPC Module](#ipc-module) - - [Model Module](#model-module) - - [Storage Module](#storage-module) - - [Native Module](#native-module) - - [CLI Module](#cli-module) - - [Utils Module](#utils-module) -- [Data Flow](#data-flow) -- [Database Schema](#database-schema) -- [Sticker Types](#sticker-types) -- [Platform Support](#platform-support) -- [Key Design Patterns](#key-design-patterns) -- [Development Guide](#development-guide) - ---- - -## Overview - -Rustickers is a cross-platform desktop application that allows users to create persistent "stickers" on their desktop. Each sticker is a small window that can display notes, timers, command output, file previews, or freehand drawings. - -### Core Features - -- **Markdown Notes**: Quick editing with preview mode and Ctrl+S save -- **Timers**: Countdown timers with audible alerts -- **Command Stickers**: Pin command output with optional cron scheduling -- **File/URL Preview**: Preview files, folders, or URLs -- **Paint Stickers**: Freehand drawing space - -### Technology Stack - -- **UI Framework**: GPUI (Zed editor's UI framework) -- **WebView**: WRY (Webview Runtime) for HTML/PDF/URL rendering -- **Database**: SQLite with SQLx -- **IPC**: interprocess crate for single-instance enforcement -- **Hotkeys**: rdev for global hotkey listening -- **Logging**: tracing with daily rotation - ---- - -## Project Structure - -``` -rustickers/ -├── .cargo/ -│ └── config.toml # Platform-specific build config (static CRT on Windows) -├── .github/workflows/ -│ └── release.yml # CI/CD for release builds -├── .vscode/ -│ └── settings.json # Editor settings -├── assets/ -│ ├── icons/ # 31 SVG icons for UI -│ ├── icon.ico, icon.png # Application icons -│ └── design.pptx # Design documents -├── migrations/ # SQLx database migrations -│ ├── 0001_init.sql # Initial schema -│ ├── 0002_add_top_most.sql -│ └── 0003_add_display_id.sql -├── packaging/ -│ └── linux/ # Linux desktop integration (.desktop, .service files) -├── screenshots/ # Application screenshots -├── src/ -│ ├── lib.rs # Library root, module declarations -│ ├── rustickers.rs # GUI application entry point -│ ├── rst.rs # CLI application entry point -│ ├── ipc.rs # Inter-process communication -│ ├── cli/ # CLI commands -│ ├── model/ # Data models -│ ├── native/ # GPUI native code -│ ├── storage/ # Persistence layer -│ └── utils/ # Utility functions -├── build.rs # Windows resource compilation (icon embedding) -├── Cargo.toml # Project manifest -└── Cargo.lock # Locked dependencies -``` - ---- - -## Build Configuration - -### Features - -```toml -[features] -default = ["ui"] -ui = [ - "dep:gpui", "dep:gpui_platform", "dep:gpui-component", "dep:gpui-wry", - "dep:wry", "dep:sqlx", "dep:rdev", "dep:notify", "dep:rodio", ... -] -cli = ["dep:clap", "dep:futures", "dep:sqlx"] -``` - -### Binaries - -| Binary | Path | Features | Description | -|--------|------|----------|-------------| -| `rustickers` | `src/rustickers.rs` | `ui` | Full GUI application | -| `rst` | `src/rst.rs` | `cli` | CLI-only tool | - -### Release Optimization - -```toml -[profile.release] -strip = true # Strip symbols -opt-level = "z" # Optimize for size -lto = true # Link Time Optimization -codegen-units = 1 # Maximum optimization -panic = "abort" # No stack unwinding -``` - ---- - -## Module Architecture - -### Entry Points - -#### GUI Application (`src/rustickers.rs`) - -``` -┌─────────────────────────────────────────────────────────────┐ -│ rustickers.rs │ -│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ -│ │ SingleInstance │ │ HotkeyListener │ │ IPC Server │ │ -│ │ (lock acquire) │ │ (Ctrl+Alt+R) │ │ (commands) │ │ -│ └────────────────┘ └─────────────────┘ └──────────────┘ │ -│ │ │ -│ ▼ │ -│ run_native() │ -│ ┌──────────────┐ │ -│ │ MainWindow │ │ -│ │ StickerWindow│ │ -│ └──────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Startup Flow:** -1. Initialize `AppPaths` (data directory resolution) -2. Setup logging with daily rotation -3. Acquire single-instance lock -4. Start IPC server thread -5. Start global hotkey listener -6. Run GPUI native loop - -#### CLI Application (`src/rst.rs`) - -```rust -fn main() { - let app_paths = AppPaths::new(); - let cli = Cli::parse_from(normalize_argv_for_view_alias()); - cli::run(cli, &app_paths); -} -``` - -**Special Handling:** Single file path/URL argument is automatically aliased to `view `. Trailing flags (like `--width`, `--height`, `--color`) are forwarded to the view command. - ---- - -### IPC Module (`src/ipc.rs`) - -**Purpose:** Single-instance enforcement and inter-process communication. - -#### SingleInstance - -Uses platform-specific named sockets: -- **Windows**: Named Pipes (`GenericNamespaced`) -- **Unix/macOS**: Unix domain sockets (`GenericFilePath`) - -```rust -pub struct SingleInstance { - listener: Option, -} -``` - -**Corpse Socket Handling:** On Unix systems, stale socket files are automatically cleaned up on retry. - -#### IpcEvent Commands - -| Command | Format | Description | -|---------|--------|-------------| -| `Show` | `SHOW` | Activate main window | -| `ToggleFilePreview` | `TOGGLE_FILE_PREVIEW` | Quick file preview from selection | -| `OpenSticker` | `OPEN_STICKER ` | Open closed sticker by ID | -| `PreviewFile` | `PREVIEW_FILE ` | Create file/URL preview sticker. JSON payload: `{"source":"...","width":400,"height":300,"color":"yellow"}` | -| `CloseSticker` | `CLOSE_STICKER ` | Close sticker by ID | - ---- - -### Model Module (`src/model/`) - -#### Sticker Types (`sticker.rs`) - -```rust -pub enum StickerType { - Markdown, // Text notes with markdown - Timer, // Countdown timer - Command, // Shell command output - Paint, // Freehand drawing - File, // File/URL preview -} - -pub enum StickerState { - Open, // Visible on desktop - Close, // Hidden but persisted -} - -pub enum StickerColor { - Yellow, Green, Blue, Pink, Gray -} -``` - -#### Data Structures - -**StickerBrief** - List view summary: -```rust -pub struct StickerBrief { - pub id: i64, - pub title: String, - pub state: StickerState, - pub color: StickerColor, - pub sticker_type: StickerType, - pub created_at: i64, - pub updated_at: i64, -} -``` - -**StickerDetail** - Full sticker data: -```rust -pub struct StickerDetail { - pub id: i64, - pub title: String, - pub state: StickerState, - pub left: i32, // Window position - pub top: i32, - pub width: i32, // Window size - pub height: i32, - pub top_most: bool, // Always on top - pub color: StickerColor, - pub sticker_type: StickerType, - pub content: String, // JSON-encoded type-specific content - pub created_at: i64, - pub updated_at: i64, - pub display_id: Option, // Multi-monitor support -} -``` - -#### Content Types (`content.rs`) - -**CommandContent:** -```rust -pub struct CommandContent { - pub command: String, - pub environments: String, // KEY=VALUE pairs - pub working_dir: String, - pub scheduler: Option, // Cron or interval - pub run_immediately: bool, - pub result: CommandResult, - pub stream_result: bool, -} -``` - -**FileStickerContent:** -```rust -pub struct FileStickerContent { - pub sources: Vec, // File paths or URLs -} -``` - ---- - -### Storage Module (`src/storage/`) - -#### StickerStore Trait - -```rust -#[async_trait::async_trait] -pub trait StickerStore: Send + Sync { - async fn insert_sticker(&self, sticker: StickerDetail) -> anyhow::Result; - async fn delete_sticker(&self, id: i64) -> anyhow::Result<()>; - async fn get_sticker(&self, id: i64) -> anyhow::Result; - async fn update_sticker_color(&self, id: i64, color: String) -> anyhow::Result<()>; - async fn update_sticker_title(&self, id: i64, title: String) -> anyhow::Result<()>; - async fn update_sticker_bounds(&self, id: i64, left: i32, top: i32, width: i32, height: i32, display_id: Option) -> anyhow::Result<()>; - async fn update_sticker_content(&self, id: i64, content: String) -> anyhow::Result<()>; - async fn update_sticker_state(&self, id: i64, state: StickerState) -> anyhow::Result<()>; - async fn update_sticker_top_most(&self, id: i64, top_most: bool) -> anyhow::Result<()>; - async fn query_stickers(&self, search: Option, order_by: StickerOrderBy, limit: i64, offset: i64) -> anyhow::Result>; - async fn count_stickers(&self, search: Option) -> anyhow::Result; - async fn get_open_sticker_ids(&self) -> anyhow::Result>; - async fn list_stickers(&self, state: Option, search: Option) -> anyhow::Result>; -} -``` - -#### SqliteStore Implementation - -- Uses SQLx with compile-time query verification -- Connection pooling via `Arc` -- Migrations applied on first open - -#### AppPaths - -Cross-platform data directory resolution: - -| Platform | Path | -|----------|------| -| Windows | `%LOCALAPPDATA%\rustickers\data\` | -| macOS | `~/Library/Application Support/rustickers/data/` | -| Linux | `~/.local/share/rustickers/data/` | - ---- - -### Native Module (`src/native/`) - -#### run_native() - -GPUI application initialization: - -```rust -pub fn run_native( - app_paths: AppPaths, - ipc_events_rx: mpsc::Receiver, - sticker_events_tx: mpsc::Sender, - sticker_events_rx: mpsc::Receiver, -) -``` - -**Initialization Steps:** -1. Create GPUI Application with platform backend -2. Set dark theme -3. Spawn IPC event pump (20ms polling) -4. Open SQLite store -5. Restore open stickers from database -6. Open main window - -#### Windows Module (`src/native/windows/`) - -**MainWindow** - Sticker list/manager: -- Search and filter stickers -- Sort by created/updated date -- Create new stickers via dropdown menu -- Double-click to open, delete button to remove - -**StickerWindow** - Individual sticker: -- Bounds persistence (debounced 200ms) -- Color picker footer -- Title editing with Enter to save -- Type-specific view component - -Every live window is tracked in `OPEN_STICKERS` (`sticker/open.rs`) under an *open id*: the database -id for a saved sticker, a hash of the previewed sources for an unsaved file preview, and a large -positive id for the throwaway window a selection run opens. Opening an id that already has a window -raises that window instead of creating a second one, so any number of previews of *different* files -can be open at once while previewing the *same* files twice is idempotent. New previews are centred -on the primary display and then cascaded 32px at a time until they no longer sit exactly on top of -an open sticker. - -#### Components Module (`src/native/components/`) - -**Embedded Assets:** -```rust -#[derive(RustEmbed)] -#[folder = "./assets"] -#[include = "icons/**/*.svg"] -pub struct Assets; -``` - -**IconName Enum:** 31 predefined icons (play, pause, close, search, etc.) - -#### Sticker Implementations (`src/native/components/stickers/`) - -| Sticker | File | Features | -|---------|------|----------| -| Markdown | `markdown.rs` | Edit/preview modes, syntax highlighting, Ctrl+S save | -| Timer | `timer.rs` | Countdown, audible alert, pause/reset | -| Command | `command/mod.rs` | Process execution, cron scheduling, env vars, streaming output | -| Paint | `paint.rs` | Freehand drawing, eraser, color selection | -| File | `file/mod.rs` | Multi-file preview, file watching, type-specific renderers | - -**Command Sticker Sub-components:** -- `runner.rs` - Resolving, spawning and reaping the child process. No GPUI, so the headless half reuses it -- `schedule.rs` - Cron parsing, next fire time, cancellation token. No GPUI -- `activity.rs` - Process-wide registry of which command stickers are running or scheduled -- `background.rs` - Supervisor that runs armed schedules while their windows are closed -- `execution.rs` - The window's own run/schedule/stop behaviour and output streaming -- `form.rs` - Settings form -- `output.rs` - Result rendering, footer controls and the running overlay - -**File Sticker Sub-components:** -- `preview.rs` - File type detection and routing -- `editor.rs` - Text/code editor with syntax highlighting -- `audio.rs` - Audio player with metadata display -- `watcher.rs` - File system change notifications -- `summary.rs` - File/folder summary display - -#### WebView Wrapper (`src/native/components/webview.rs`) - -WRY-based WebView for rendering: -- HTML content -- PDF files -- URLs (via local:// protocol) - -#### Hotkey Module (`src/native/hotkey.rs`) - -Platform-specific global hotkey listeners: a keycode-only low-level hook on Windows, a Quartz event tap on macOS, and rdev on Linux. Recognized key chords are debounced, and action keys are consumed on Windows and macOS so they are not also handled by the active application. GPUI window-activation observers identify the exact focused transient window that owns `Esc`; the native event is consumed and an internal GPUI `Esc` is dispatched so existing save and cleanup handlers still run. - -| Hotkey | Action | -|--------|--------| -| `Ctrl + Alt + R` | Show main window | -| `Ctrl/Cmd + Alt` | Toggle file preview from selection | -| `Ctrl/Cmd + Space` | Open a command for the selected text | - -**Platform-specific modifiers:** -- Windows/Linux: Ctrl required -- macOS: Cmd or Ctrl accepted - -#### File Manager Integration (`src/native/file_manager.rs`) - -Extracts selected files from: -- **Windows**: Explorer windows (including tabs) -- **macOS**: Finder selection via AppleScript - -#### HTTP Client (`src/native/http.rs`) - -Reqwest wrapped for GPUI's `HttpClient` trait. - ---- - -### CLI Module (`src/cli/`) - -The module has two halves: one file per subcommand under `commands/`, and shared infrastructure -alongside it. A command module owns both its arguments and its behaviour, so adding a subcommand -touches one new file plus two lines in `mod.rs`. - -#### Commands (`src/cli/commands/`) - -| Command | File | Description | -|---------|------|-------------| -| `list` | `list.rs` | Find stickers by state, type or text | -| `show` | `show.rs` | Everything stored about one sticker | -| `result` | `result.rs` | What a command sticker last produced | -| `open` / `close` | `window.rs` | Show or hide a sticker's window | -| `delete` | `delete.rs` | Remove a sticker permanently | -| `view` | `view.rs` | Create a file/URL preview sticker | -| `markdown` | `markdown.rs` | Create a markdown note sticker | -| `cmd` | `cmd.rs` | Create a command sticker | -| `skill` | `skill.rs` | Catalogue of worked examples | - -#### Shared infrastructure - -| File | Responsibility | -|------|----------------| -| `output.rs` | The human/JSON split. `Format` renders a report or emits one JSON object with an `ok` field. Also the table and truncation helpers. | -| `runtime.rs` | Opening the store and talking to the running app. `Delivery` distinguishes "sent over IPC" from "the app is not running". | -| `draft.rs` | The single sticker-creation path: the shared `--width/--height/--left/--top/--color/--top-most/--closed` group, insertion, and the "created" report. | -| `shell.rs` | `--shell` wrapping and fail-fast validation of a command string, its program and its cron expression. | -| `skills.rs` | The static skill catalogue and the expansion of a skill into real `rst` arguments. | - -#### CLI Flow - -```rust -pub fn run(cli: Cli, app_paths: &AppPaths) -> anyhow::Result<()> { - let format = cli.format(); // --json is a global flag, accepted anywhere - - match cli.command { - Commands::List(args) => commands::list::run(app_paths, args, format), - Commands::Result(args) => commands::result::run(app_paths, args, format), - Commands::Cmd(args) => commands::cmd::run(app_paths, args, format), - // ...one arm per module, each taking its own Args struct - } -} -``` - -`skill run` expands a catalogue entry into an argument vector, re-parses it through -`Cli::try_parse_from` and recurses into `run`. That is what keeps `skill show`'s "equivalent -command" honest: it is the command that actually executes, and a unit test asserts every skill in -the catalogue parses. - -#### Two behaviours worth remembering - -*A command sticker does not use a shell.* The stored string is split with Windows argv rules and -the program is resolved on `PATH`. `--shell` wraps it in `pwsh -NoProfile -Command …` — not -`cmd /c`, because the string is later re-escaped by `std::process::Command` and `cmd.exe` cannot -recover an embedded `"`, silently mis-splitting something like `git log --format="%h %s"`. - -*`started_at` is what arms a command sticker.* Without it the window opens and sits idle. The CLI -sets it on creation unless `--idle` or `--accept-selection` is given — a selection sticker is -triggered by the hotkey, which sets its own start time. - -*An armed cron schedule keeps running with the sticker closed.* `command/background.rs` polls the -store for stickers that are armed and have a cron expression, fires the ones that are due, and -writes the output back into the sticker's content. It does not care about `StickerState`, which is -what makes a closed sticker keep ticking; `rst result ` then reads the latest run. - -The open window still drives its own schedule, because only it can stream output into the view. The -two are kept apart by `command/activity.rs`: a sticker window claims its sticker for as long as it -lives, and the supervisor skips anything claimed. The claim is released when the sticker entity is -dropped, so the schedule loop holds a **weak** handle — a strong one would outlive the window and -lock the supervisor out permanently. The same registry is what the main window's list reads to draw -a spinner on a running sticker and the next fire time on a scheduled one; it polls a generation -counter on the event pump it already runs rather than taking another channel. - ---- - -### Utils Module (`src/utils/`) - -#### Logging (`logging.rs`) - -```rust -pub struct LoggingGuards { - _file_guard: tracing_appender::non_blocking::WorkerGuard, - _error_guard: Option, -} -``` - -**Configuration:** -- Daily rotation -- Environment variables: `RUSTICKERS_LOG` > `RUST_LOG` > default -- Debug builds: `trace` -- Release builds: `info` - -#### File Utilities (`file.rs`) - -File type detection by extension: -- Images: png, jpg, jpeg, gif, webp, svg, ico, bmp -- Video: mp4, webm, mkv, avi, mov, flv, wmv -- Audio: mp3, wav, ogg, flac, aac, m4a -- Code: rs, py, js, ts, go, java, cpp, c, h, cs, php, rb, sh -- Documents: md, txt, json, yaml, toml, xml, html, css - -#### URL Utilities (`url.rs`) - -- URL detection regex -- `local://` protocol helper for WebView - -#### Time Utilities (`time.rs`) - -- Unix timestamp in milliseconds -- Human-readable formatting - ---- - -## Data Flow - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ rustickers.rs │ -│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ -│ │ SingleInstance│ │ HotkeyListener│ │ IPC Server Thread │ │ -│ └──────┬──────┘ └──────┬───────┘ └───────────┬────────────┘ │ -│ │ │ │ │ -│ └────────────────┴───────────────────────┘ │ -│ │ │ -│ IpcEvent Channel │ -│ │ │ -│ ┌────────────────▼────────────────┐ │ -│ │ run_native() │ │ -│ │ ┌──────────┐ ┌────────────┐ │ │ -│ │ │ MainWindow│ │StickerWindow│ │ │ -│ │ └────┬─────┘ └─────┬──────┘ │ │ -│ │ │ │ │ │ -│ │ └──────┬───────┘ │ │ -│ │ ▼ │ │ -│ │ ┌─────────────────┐ │ │ -│ │ │ StickerStore │ │ │ -│ │ │ (SqliteStore) │ │ │ -│ │ └────────┬────────┘ │ │ -│ └─────────────┼──────────────────┘ │ -└───────────────────────┼─────────────────────────────────────────┘ - │ - ▼ - ┌─────────────────┐ - │ stickers.db │ - │ (SQLite) │ - └─────────────────┘ -``` - -### Event Channels - -1. **IPC Events** (`mpsc::Sender`): External commands from CLI or second instance -2. **Sticker Events** (`mpsc::Sender`): Internal state sync between windows - -### StickerWindowEvent - -```rust -pub enum StickerWindowEvent { - Created { id: i64 }, - TitleChanged { id: i64, title: String }, - ColorChanged { id: i64, color: StickerColor }, - Closed { id: i64 }, -} -``` - ---- - -## Database Schema - -### Migrations - -#### 0001_init.sql - -```sql -CREATE TABLE IF NOT EXISTS stickers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - state TEXT NOT NULL, - left INTEGER NOT NULL, - top INTEGER NOT NULL, - width INTEGER NOT NULL, - height INTEGER NOT NULL, - color TEXT NOT NULL, - type TEXT NOT NULL, - content TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_stickers_created_at ON stickers(created_at); -CREATE INDEX IF NOT EXISTS idx_stickers_updated_at ON stickers(updated_at); -CREATE INDEX IF NOT EXISTS idx_stickers_title ON stickers(title); -``` - -#### 0002_add_top_most.sql - -```sql -ALTER TABLE stickers ADD COLUMN top_most INTEGER NOT NULL DEFAULT 0; -``` - -#### 0003_add_display_id.sql - -```sql -ALTER TABLE stickers ADD COLUMN display_id INTEGER; -``` - ---- - -## Sticker Types - -### Markdown Sticker - -- **Content**: Raw markdown text -- **Features**: Edit/preview toggle, syntax highlighting, Ctrl+S save -- **Default Size**: 400x300 - -### Timer Sticker - -- **Content**: JSON with duration, remaining time, running state -- **Features**: Countdown, pause/resume, audible alert -- **Default Size**: 200x100 - -### Command Sticker - -- **Content**: `CommandContent` JSON -- **Features**: Process execution, cron scheduling (also while closed), environment variables, working directory, streaming output -- **Default Size**: 400x300 - -### Paint Sticker - -- **Content**: Serialized drawing data -- **Features**: Freehand drawing, eraser, color selection -- **Default Size**: 300x300 - -### File Sticker - -- **Content**: `FileStickerContent` JSON with sources array -- **Features**: Multi-file preview, file watching, type-specific renderers (image, video, audio, text, PDF) -- **Default Size**: Varies by content - ---- - -## Platform Support - -| Platform | Status | Notes | -|----------|--------|-------| -| **Windows** | Full | Static CRT linking, icon embedding, Explorer integration, Named Pipes IPC | -| **macOS** | Full | Finder integration via AppleScript, Cmd+Alt hotkeys, .app bundle | -| **Linux** | Partial | GTK/Webkit dependencies, .desktop file, .service file provided | - -### Platform-Specific Code - -**Windows:** -```rust -#[cfg(target_os = "windows")] -use windows::Win32::UI::Shell::{IShellWindows, IWebBrowserApp, ...}; -``` - -**macOS:** -```rust -#[cfg(target_os = "macos")] -use cocoa::foundation::{NSAutoreleasePool, NSString}; -#[cfg(target_os = "macos")] -use objc::{class, msg_send, sel, sel_impl}; +```text +rustickers (desktop) ── GPUI windows / hotkeys / clipboard observer ─┐ + ├─ stickers.db +rst (CLI) ──────────── commands / text selection / child execution ─┘ ``` ---- +The desktop is single-instance. IPC carries requests from the CLI or another launch; internal window events refresh the manager. Database-change polling catches writes from other processes without opening sticker windows. -## Key Design Patterns +## Code map -### 1. Trait-based Storage +| Location | Responsibility | +| --- | --- | +| `src/rustickers.rs`, `src/rst.rs` | Desktop and CLI startup | +| `src/ipc.rs` | Single-instance ownership and external requests | +| `src/model/` | Sticker identity, type, state, and serialized content | +| `src/storage/` | `StickerStore`, SQLite implementation, app paths | +| `src/text/` | Shared text selection, capture lifecycle, encryption | +| `src/native/windows/` | Manager, sticker windows, TXTs, selection popup, dialogs | +| `src/native/components/stickers/` | Markdown, timer, command, file, and paint views | +| `src/native/clipboard/` | OS collection, write suppression, verified paste | +| `src/cli/` | Parsing, commands, terminal chooser, safe child argv | +| `migrations/`, `tests/` | Database evolution and regression coverage | -`StickerStore` trait allows swapping storage backends without changing business logic. +Features: `ui` is enabled by default; `cli` builds `rst`; both depend on `storage`. UI dependencies enter through GPUI Kit and its matching WebView adapter. -### 2. Entity-Component UI +## Storage and text lifecycle -GPUI-based with `Entity` for reactive state management. Views re-render automatically on state changes. +`stickers.db` is the only runtime store. SQLx applies migrations on open. General sticker operations use `StickerStore`; text operations use `TextRepository` on the same SQLite pool so multi-step changes can commit atomically. -### 3. Channel-based IPC +| Table | Purpose | +| --- | --- | +| `stickers` | Persistent stickers, content, window placement, text revisions/recency | +| `clipboard_captures` | Exact disposable text, digest, byte size, creation/copy times | +| `text_master_key` | Public recipient and password-wrapped private identity | +| `text_imports` | One-time legacy-import markers | -`mpsc` channels for cross-thread communication between: -- IPC server thread → Main thread -- Hotkey listener → Main thread -- Sticker windows → Main thread +Historical clipboard/vault tables remain in the migration chain for raw import only; they have no runtime writers. Import skips unusable or encrypted-only content and leaves the old database untouched. Never modify an already-applied migration: add a new one. -### 4. Debounced Persistence +TXTs contains Markdown stickers and optional captures, not command output or file previews. Typed IDs (`sticker:42`, `clipboard:42`) prevent cross-table collisions. Both desktop and CLI use shared full-text filtering and deterministic MRU ordering. Locked entries expose only public titles/metadata. -Window bounds saved after 200ms of inactivity to avoid excessive writes. +Capture promotion creates a **closed Markdown sticker** and consumes the source in one transaction. Capture budgets never evict saved stickers. Content/revision checks reject stale saves, duplicate promotion, and authorization races. Custom titles survive editing; title changes do not change text-use recency. -### 5. Single-instance Enforcement +## Protection -Named socket prevents multiple app instances. Corpse socket cleanup on Unix. +`src/text/protection.rs` uses `age` recipient encryption. A random X25519 identity is wrapped with an Argon2id-derived key; the password is a credential, not the private key itself. -### 6. Feature Flags +- Encryption uses the readable public recipient; decryption authorizes one action. +- No private-key cache or global unlock deadline exists. +- Ciphertext is bound to sticker identity and key generation. +- Password change generates fresh keys and rewrites all supported ciphertext in one transaction. Any failure rolls back everything. +- Editors hold plaintext only for their scoped session. Dirty encrypted drafts block rotation; stale revisions cannot overwrite rotated content. -Separate UI and CLI builds from same codebase via Cargo features. +Legacy locks, clipboard vaults, and external-file locking are unsupported. There is no password recovery. Encryption cannot erase old backups, SQLite remnants, OS history, or other applications' copies. -### 7. Content Serialization +## Runtime boundaries -Sticker content stored as JSON strings, allowing type-specific data without schema changes. - ---- - -## Development Guide - -### Adding a New Sticker Type - -1. **Define content structure** in `src/model/content.rs`: - ```rust - pub struct NewStickerContent { - // fields - } - ``` - -2. **Add to StickerType enum** in `src/model/sticker.rs`: - ```rust - pub enum StickerType { - // ... - NewType, - } - ``` - -3. **Create sticker component** in `src/native/components/stickers/new_type.rs`: - ```rust - pub struct NewTypeSticker { /* ... */ } - impl Sticker for NewTypeSticker { /* ... */ } - impl StickerView for NewTypeSticker { /* ... */ } - ``` - -4. **Register in StickerWindow::create_sticker_view()**: - ```rust - StickerType::NewType => Box::new(StickerViewEntity::new(cx.new(|cx| { - NewTypeSticker::new(id, color, store, content, window, cx, sticker_events_tx) - }))), - ``` - -5. **Add migration** if new database fields needed: - ```bash - sqlx migrate add add_new_type_fields - ``` - -6. **Add icon** to `assets/icons/` and `IconName` enum - -### Adding a New CLI Command - -1. **Create the module** `src/cli/commands/new_command.rs`. It owns its arguments as well as its - behaviour, and must handle both output modes: - ```rust - #[derive(clap::Args, Debug)] - pub struct NewCommandArgs { - /// Doc comments become the help text, so write them for a reader - #[arg(long, value_name = "TEXT")] - pub option: String, - - // Creating a sticker? Flatten the shared appearance group instead of re-declaring it. - #[command(flatten)] - pub geometry: Geometry, - } - - pub fn run(app_paths: &AppPaths, args: NewCommandArgs, format: Format) -> anyhow::Result<()> { - format.emit(json!({ "option": args.option }), |_| { - println!("did the thing"); - }) - } - ``` - -2. **Declare it** in `src/cli/commands/mod.rs`, and add a variant plus a dispatch arm in - `src/cli/mod.rs`. Nothing else needs updating: the `rst ` shorthand and the help - text both read the subcommand list from the parser. - -3. **Consider a skill.** If the command has a use worth demonstrating, add an entry to - `SKILLS` in `src/cli/skills.rs`. The catalogue tests will check the template declares every - variable it uses and expands to something the parser accepts. - -### Debugging - -**Enable verbose logging:** -```bash -RUSTICKERS_LOG=trace cargo run -``` - -**View logs:** -- Windows: `%LOCALAPPDATA%\rustickers\data\logs\` -- macOS: `~/Library/Application Support/rustickers/data/logs/` -- Linux: `~/.local/share/rustickers/data/logs/` - -**Database inspection:** -```bash -sqlite3 "$(pwd)/target/debug/data/stickers.db" -``` - -### Testing IPC - -**Send command to running instance:** -```bash -echo "SHOW" | nc -U /tmp/rustickers-.sock # Unix -echo "SHOW" > \\.\pipe\rustickers- # Windows -``` +The GUI thread owns entities, focus, and window state. Database work, command execution, and expensive crypto run outside rendering; completions update their owner through GPUI contexts. ---- +Command stickers keep execution/scheduling state independently of window visibility, so armed cron jobs can run while closed. File previews use a fallible WebView and never lock source files in place. -## Glossary +The clipboard observer writes ordinary captures through `TextRepository`. Internal decrypted writes are suppressed; externally recopied secrets are not guaranteed exclusion. Paste verifies the destination and modifier release. Windows supports ownership-aware protected clearing; unavailable capabilities fail closed. A newer clipboard value must never be cleared. -| Term | Definition | -|------|------------| -| **Sticker** | A persistent desktop window displaying content | -| **StickerWindow** | GPUI window entity for a single sticker | -| **MainWindow** | Main application window showing sticker list | -| **IpcEvent** | Inter-process communication command | -| **StickerStore** | Trait for sticker persistence | -| **AppPaths** | Cross-platform data directory resolver | +The CLI substitutes `{{txt}}` only in child argument tokens, never the executable. It preserves exact text, inherits stdio, and propagates child exit status. No implicit shell execution or expanded-secret argv logging is allowed. ---- +## Making changes -## References +Keep domain invariants in storage/text services and presentation in native views. New commands reuse shared parsing/output helpers; new sticker types require model, view, storage, and CLI integration. Test persistence against temporary databases, never live user data. -- [GPUI Documentation](https://github.com/zed-industries/zed/tree/main/crates/gpui) -- [SQLx Documentation](https://docs.rs/sqlx) -- [interprocess Documentation](https://docs.rs/interprocess) -- [rdev Documentation](https://docs.rs/rdev) -- [WRY Documentation](https://docs.rs/wry) +See [FRONTEND.md](FRONTEND.md) for UI rules and verification commands, and [README.md](README.md) for usage. diff --git a/Cargo.lock b/Cargo.lock index ce8df8d..d17ae87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -109,6 +119,49 @@ dependencies = [ "zeroize", ] +[[package]] +name = "age" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d86e4272c093c88caf7864a2d09af52a5159180848ca4832a3cdbd7d014d5" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hmac 0.12.1", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom 7.1.3", + "pin-project", + "rand 0.8.8", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305", + "cookie-factory", + "hkdf 0.12.4", + "io_tee", + "nom 7.1.3", + "rand 0.8.8", + "secrecy", + "sha2 0.10.9", +] + [[package]] name = "ahash" version = "0.8.12" @@ -776,6 +829,12 @@ version = "2.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -788,6 +847,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + [[package]] name = "bindgen" version = "0.72.1" @@ -1130,6 +1204,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -1141,6 +1226,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1437,6 +1535,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.7.0" @@ -1639,7 +1746,7 @@ dependencies = [ "log", "rangemap", "rustc-hash 2.1.3", - "self_cell", + "self_cell 1.3.0", "skrifa 0.40.0", "smol_str", "swash", @@ -1876,6 +1983,32 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -2374,6 +2507,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2395,6 +2534,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -2436,6 +2584,50 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "fluent-uri" version = "0.1.4" @@ -3546,7 +3738,7 @@ version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05be23908e707966824f8c51a609904b7f30739e8d915e120a53c1b995ba964c" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -4129,7 +4321,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -4148,6 +4340,72 @@ dependencies = [ "windows-registry 0.6.1", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -4421,6 +4679,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "inventory" version = "0.3.24" @@ -4442,6 +4719,12 @@ dependencies = [ "leaky-cow", ] +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipnet" version = "2.12.1" @@ -4717,7 +5000,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9cfe72bff8acf9af0d6d276569be5b9cb3f313f9882761ada5a50d3044214d4" dependencies = [ - "base64", + "base64 0.22.1", "block2 0.6.2", "cookie", "crossbeam-channel", @@ -6088,6 +6371,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.2" @@ -6525,6 +6814,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polycool" version = "0.4.0" @@ -6664,6 +6964,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -6879,7 +7201,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -7235,7 +7557,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -7512,12 +7834,13 @@ dependencies = [ name = "rustickers" version = "0.1.0" dependencies = [ + "age", "anyhow", "arboard", "argon2", "async-compat", "async-trait", - "base64", + "base64 0.22.1", "chrono", "clap", "cocoa 0.26.0", @@ -7695,6 +8018,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -7774,12 +8106,32 @@ dependencies = [ "once_cell", ] +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -7821,6 +8173,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.3.0", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -7855,7 +8216,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets", - "base64", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -8288,7 +8649,7 @@ dependencies = [ "async-fs", "async-io", "async-std", - "base64", + "base64 0.22.1", "bytes", "cfg-if", "crc", @@ -8386,7 +8747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "byteorder", "crc", @@ -9192,6 +9553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -9539,6 +9901,15 @@ dependencies = [ "core_maths", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "typeid" version = "1.0.3" @@ -9562,6 +9933,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + [[package]] name = "unicase" version = "2.9.0" @@ -9649,6 +10039,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -9679,7 +10079,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -9706,7 +10106,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -11215,6 +11615,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xcb" version = "1.7.0" @@ -11580,6 +11992,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index 84a766a..3061855 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,8 +6,7 @@ default-run = "rustickers" [features] default = ["ui"] -# The clipboard history backend (SQLite + sha2 + chrono) is reused by both the GUI and the CLI. -# We expose it as an explicit feature so build variants that don't need it can stay slim. +# Shared SQLite text storage and protection for desktop and CLI builds. storage = [ "dep:sqlx", "sqlx/migrate", @@ -17,6 +16,7 @@ storage = [ "dep:futures", "dep:sha2", "dep:argon2", + "dep:age", "dep:rand", "dep:chrono", ] @@ -25,9 +25,6 @@ ui = [ "sqlx/migrate", "storage", # GPUI Kit single consumer entry point plus its WRY adapter. - # Pinned to the upstream clone until the v0.7.1 tag is fetched into the - # cargo git cache; will be replaced with `[git] tag = "v0.7.1"` once the - # implementation migration is verified. "dep:gpui-kit", "dep:gpui-wry", "dep:wry", @@ -125,6 +122,8 @@ ring = { version = "0.17.14", optional = true } zeroize = { version = "1.8.2", optional = true } sha2 = { version = "0.10.8", optional = true } argon2 = { version = "0.5", optional = true } +# Recipient-only encryption; no passphrase/plugin/SSH features. +age = { version = "=0.11.3", default-features = false, optional = true } rand = { version = "0.8", optional = true } chrono = { version = "0.4.43", default-features = false, features = ["clock", "std"], optional = true } cron = { version = "0.17.0", optional = true } @@ -161,7 +160,7 @@ objc = { version = "0.2.7", optional = true } core-foundation = { version = "0.10.1", optional = true } core-graphics = { version = "0.24.0", optional = true } -# Keep the memory-hard vault KDF responsive in debug builds without weakening it. +# Keep the memory-hard master-password KDF responsive in debug builds. [profile.dev.package.argon2] opt-level = 3 diff --git a/FRONTEND.md b/FRONTEND.md index 794116f..447b41c 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -1,422 +1,80 @@ -# Rustickers frontend knowledge +# Frontend guide -> Knowledge base for AI coding agents and human contributors working -> on the desktop layer: theme tokens, component patterns, async -> ownership rules, palette contracts, and the privacy invariants the -> clipboard layer enforces. `README.md` describes *what* the app -> does and *how to run* it; this file describes *how it is built*. -> -> This is **not** a roadmap, plan, or checklist. It encodes the -> current state of the codebase. For change history, run -> `git log --oneline`. +Project-specific rules for the GPUI desktop layer. Start with [README.md](README.md) for usage and [ARCHITECTURE.md](ARCHITECTURE.md) for ownership/storage. ---- +## Read before changing UI -## 1. Stack and dependency closure +The local framework guides are authoritative: -- **UI framework**: `gpui-kit 0.7.1` from crates.io — a single - facade that re-exports `gpui`, `gpui-component`, `gpui-base`, - `gpui-assets` and a matching `gpui-wry` adapter. The old Git - pins (`gpui` / `gpui-platform` / `gpui-component` / `gpui-wry`) - are gone. -- **Closure invariant**: `gpui-pre 0.3.8` exists **exactly once** in - `cargo tree`. The same crates can only satisfy that by going - through the kit's re-exports. Verify any time: +- [Design guides](.agents/skills/gpui-kit-design-guides/references/design-guides.md): layout, hierarchy, controls, copy, and interaction states. +- [Coding guides](.agents/skills/gpui-kit/references/coding-guides.md): entities, contexts, focus, async work, and tests. - ```bash - cargo tree --features ui --edges normal | grep '^name = "gpui-' - ``` +Use the current crate source for exact APIs. Do not invent GPUI methods from web-framework patterns. - Two distinct versions means a transitive drift; stop and fix. +## Where to work -- **Bridges in `src/lib.rs`**: two namespace aliases still exist for - the migration hand-off: - - ```rust - #[cfg(feature = "ui")] - extern crate gpui_kit as gpui; - #[cfg(feature = "ui")] - pub mod gpui_component { pub use gpui_kit::component::*; } - #[cfg(feature = "ui")] - extern crate gpui_wry; - #[cfg(feature = "ui")] - extern crate lb_wry as wry; - ``` - - Submodule bare-name resolution only reaches extern crates, so - every `use gpui_component::…` in a source file was rewritten to - `use crate::gpui_component::…`. The bridges can come out once - every module imports `gpui_kit::*` directly. Verify stragglers: - - ```bash - git grep -nE '^\s*use gpui_component::' src/ tests/ examples/ - ``` - ---- - -## 2. Where things live - -``` -src/ - lib.rs ← namespace bridges, public re-exports - rustickers.rs ← CLI / `SingleInstance` IPC / glue - model/sticker.rs ← StickerColor serialised identity (no UI literals) - native/ - mod.rs ← `--features ui` boot: gpui::application(), theme, watcher - theme.rs ← UI-owned StickerColor palette split (§6) - hotkey.rs ← Global hotkey dispatcher - components/ - webview.rs ← WRY WebView lifecycle, fallible construction - stickers/ - markdown.rs content_lock.rs timer.rs paint.rs - command/ file/ (per-family modules; new ones go here - and `pub mod` is wired in stickers/mod.rs) - windows/ - main.rs ← manager (uniform_list, keyboard nav, revision token) - selection.rs ← selection popup - clipboard.rs ← clipboard chooser (uniform_list, confirm-then-forget) - clipboard/editor.rs - sticker/{open.rs,view.rs,mod.rs} -tests/ - clipboard_storage.rs ← 18 privacy regression tests -- DO NOT regress - sticker_palette.rs ← 3 StickerColor regression tests -.github/workflows/ci.yml -.cargo/ (configurations, ignored by git) -scripts/ - screenshot.ps1 ← Windows capture helper for visual checks -``` - -Adding a new family: drop `mod ;` into `src/native/components/stickers/mod.rs`, -mirror the boilerplate of `timer.rs`/`markdown.rs`, and reuse the -shared services from `crate::native::components::stickers::…` -(e.g. `FOCUS_LOSS_RELOCK_DELAY`). - ---- - -## 3. Bootstrap - -`src/native/mod.rs` boots the UI under `#[cfg(feature = "ui")]`. Three rules: - -1. `gpui::application()` factories the app. Don't reach for the legacy - `Application::with_platform(gpui_platform::current_platform(false))`. -2. **Don't override the theme.** No `cx.global_mut::().background = …` - calls. The kit-default theme is the surface; light/dark variants - swap through `Theme::change(…)` when wired. -3. **The kit root owns overlays.** No `.children(Root::render_dialog_layer(w, cx))` - in main or sticker render. `gpui_kit::component::init(cx)` is called - once during boot and the plugin model registers dialogs. - -Sticker windows need alpha corners -- construct with -`WindowBackgroundAppearance::Transparent`. The inner view owns the colour. - ---- - -## 4. Theme tokens - -`cx.theme()` is `gpui_kit::component::theme::ActiveTheme`. The project's -chrome reads only these tokens: - -| Token | Use | +| File / folder | Owns | | --- | --- | -| `background` | Root manager / window surface | -| `border` | Default 1-px border on cards, inputs, dividers | -| `muted_foreground` | Secondary copy, placeholders, empty states | -| `danger` | Error messages, destructive confirmations, expired-run marker | -| `list_active` | Selected row background | -| `list_active_border` | Selected row border | -| `list_hover` | Hovered row background | -| `transparent` | Inactive transparent surface | +| `src/native/mod.rs` | Bootstrap, theme, services, clipboard watcher | +| `src/native/windows/main.rs` | Sticker manager and event refresh | +| `src/native/windows/sticker/` | Window lifecycle, chrome, placement, close/save | +| `src/native/windows/selection.rs` | Compact search-first selection popup | +| `src/native/windows/clipboard.rs` | TXTs list, filters, selection, commands | +| `src/native/windows/text_{password,title}.rs` | Shared authorization/settings and title dialogs | +| `src/native/components/stickers/` | Type-specific rendering and editor behavior | +| `src/native/components/webview.rs` | Embedded browser lifecycle | +| `src/native/theme.rs` | Sticker background/swatch palette | -For anything else, use `cx.theme().*` or add the role to the project -theme. No raw `rgb(0x…)` / `rgba(…)` literals for chrome concerns -(see §7 for the four audited exceptions). +## Composition and styling -```bash -rg 'rgba\(|rgb\(0x\b' src/native/ # audited exceptions only -rg 'cursor_pointer' src/native/ # only allowed on real active manipulations -rg 'focus_ring\(false\)' src/native/ # must return nothing -``` +Use GPUI Kit's components through the project's `gpui` / `crate::gpui_component` aliases. Initialize components once and wrap each window in `Root`; Root owns dialog and popup layers. ---- +- Read semantic colors from `cx.theme()`; use rem-based spacing and component sizes. +- Keep app chrome neutral. `StickerColor` identity lives in the model; its palette lives in `native/theme.rs`. Paint stroke colors are content, not chrome. +- Use Buttons for commands, primary for the default commit, ghost for quiet toolbar actions, and danger for irreversible confirmation. +- Use persistent selected states, stable domain-derived element IDs, and a visible keyboard focus ring. +- Let the primary list/editor fill the available space. Flexible children need `min_w_0()` / `min_h_0()`; each scroll region has one owner. +- Virtualize large lists with `uniform_list` and consistent row heights. Preserve selection by ID, not position. +- Drag-area inputs/buttons must `.occlude()` so window dragging does not swallow clicks. -## 5. Component patterns +For popup UX, follow `selection.rs`: search first, content dominant, lightweight hints, and a short keyboard path. Secondary commands belong in standard menus, not crowded button strips. -### Buttons +## State, events, and async work -```rust -use crate::gpui_component::button::{Button, ButtonVariants, Selectable}; +Retain input entities, subscriptions, focus handles, and scroll handles in their owner. Rendering only reads state and builds elements: no I/O, mutation, task spawning, or notification loops. -Button::new("close").ghost().icon(IconName::Close).on_click(…) -Button::new("confirm").danger().label("Forget").on_click(…) // destructive -Button::new("play").selected(self.mode == Mode::Loop).label("Loop").on_click(…) -``` +Start work from an event or lifecycle method. Run blocking I/O/crypto off the GUI thread, update through weak entity handles, and tolerate a closed window. Polling loops must stop when the owner disappears; revision-tagged loops must stop when superseded. Reject stale results by domain identity/revision. -- `ghost` for chrome (close, sort, footer toolbar, color dots). -- `danger` for irreversible confirmations. -- `selected(...)` for segmented control state -- import `Selectable`, - not just `ButtonVariants`. -- Title is `Forget ""?`, body names the consequence. - `ButtonKind::Warning` is not used here. +Use one owner method for each logical command across buttons, menus, and keyboard paths. Respect modal/menu focus; Escape dismisses the topmost surface and returns focus. Guard duplicate submissions, show recoverable errors, and never silently discard dirty encrypted drafts. -### Lists +## TXTs contracts to preserve -`uniform_list` for any long collection. Both the manager and chooser -use it; the closure captures `cx.entity()` and routes through -`view.update`: +- All / Saved / Clipboard filters use the shared `TextRepository` projection. +- Save/Lock promotes captures into **closed** stickers; Edit opens the normal editor. +- Lock requires a public title; Rename uses the same required-title dialog. Never expose secret content as a locked preview. +- Copy/paste does not open stickers. Without an external destination, Enter copies; otherwise it pastes. F2/double-click edits. +- Saved-item deletion requires confirmation; capture deletion is immediate. +- The manager, TXTs, and encrypted editor share one master-password system. No vault or cached global unlock state. +- Keep clipboard suppression, destination verification, and ownership-safe expiry in OS adapters. Do not claim unsupported platform parity. -```rust -let view = cx.entity(); -uniform_list("manager-stickers", self.stickers.len(), - move |range, _window, cx| view.update(cx, |this, cx| - range.into_iter().filter_map(...) .collect::>() - ), -) -.size_full() -.track_scroll(&self.list_scroll); -``` - -Fixed row height is required by `uniform_list`; in this codebase -`ROW_HEIGHT = 80.0` (manager) and `44 px` (chooser rows). - -### Inputs - -Don't paint the input background, don't disable the focus ring -- -`Input::new(&self.state)` owns the focus ring and the surface. - -```rust -let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search stickers")); -Input::new(&self.query).cleanable(true).border_0().tab_index(0).prefix(Icon::new(IconName::Search)) -``` - -### Dialogs - -Construct via `window.open_dialog(cx, |dialog, _, _| builder)` -- the -dialog has no `.open()`; the helper opens it. - -```rust -window.open_dialog(cx, move |dialog, _, _| { - dialog - .title(format!("Forget \"{preview}\"?")) - .child(div().text_sm().child(body)) - .footer(h_flex() - .gap_2().justify_end() - .child(Button::new("cancel").label("Cancel").on_click(|_, w, cx| w.close_dialog(cx))) - .child(Button::new("forget").label("Forget").danger() - .on_click(|_, w, cx| { w.close_dialog(cx); /* … spawn delete … */ }))) - .w(px(420.0)); -}); -``` +## Native checks -### Keyboard +Sticker windows use transparent backgrounds for corners. On Windows, startup sets `GPUI_DISABLE_DIRECT_COMPOSITION=true` unless already configured, so embedded WebViews paint correctly. Keep WebView construction fallible and expose errors rather than blank panels. -Container focus: `cx.focus_handle().tab_index(n).tab_stop(true)` + -`.track_focus(&handle)`. `on_key_down(cx.listener(…));` to read -`KeyDownEvent` directly, with `cx.stop_propagation()` + -`window.prevent_default()` for handled keys. +Verify resizing, DPI, keyboard navigation, focus restoration, long text, empty/error states, and light/dark themes in a real window. Compilation and repository tests do not prove rendered UI quality. ---- - -## 6. `StickerColor` palette split - -The model holds identity; the UI holds the surface colours. Keep -categories from drifting. - -```rust -// src/model/sticker.rs -impl StickerColor { - pub const ALL: [Self; 5] = [...] - pub fn bg(&self) -> Rgba { crate::native::theme::sticker_background(*self) } - pub fn swatch(&self) -> Rgba { crate::native::theme::sticker_swatch(*self) } -} - -// src/native/theme.rs -pub fn sticker_background(c: StickerColor) -> Rgba { match c { … } } -pub fn sticker_swatch (c: StickerColor) -> Rgba { match c { … } } -``` - -`tests/sticker_palette.rs` pins three properties: - -1. Every swatch is bright; every background is dark muted. -2. `StickerColor::ALL` produces distinct backgrounds and swatches. -3. **`StickerColor::bg() == native::theme::sticker_background(...)`** - and the same for `swatch()`. A drift here is a regression. +## Verification ```bash -cargo test --locked --features cli --test sticker_palette -``` - ---- - -## 7. Audited exceptions (do not "fix") - -| File | Site | Why it stays | -| --- | --- | --- | -| `stickers/timer.rs` | `green_500()` progress fill | Semantic success state (active vs. expired run). | -| `stickers/paint.rs` | 4× stroke `rgba(…)` | Data-domain: user-picked stroke colours persisted with the paint sticker. | -| `native/theme.rs` | the entire file | Defines the surface palette; the test contract depends on it. | - -If a feature genuinely needs to change one, open it as a discussion -- -the regression contract will catch a wrong intent early. - ---- - -## 8. Spawn / cancel / poll rules - -Three categories of async work exist in this app: - -| Surface | Owner | Cancellation | -| --- | --- | --- | -| Tick loops (timer, command scheduler) | One `tick_revision: u64` on the controller entity | Loop body compares the captured revision against the entity's; bumps supersede. Loop breaks on `entity.update_in(...).is_err()`. | -| I/O dispatch (clipboard, file IO, vault) | Per-event owner; spawn from `cx.listener` | Same `update_in` error break; db transactions handle their own atomicity. | -| Watcher thread | Dedicated OS thread with its own `AtomicBool` stop signal | Not going through `cx.spawn`; control sits in `WatcherHandle::spawn(self, ...).detach()`. | - -The non-negotiables: - -- **Do not spawn from inside `Render::render`** -- the entity doesn't - have a stable revision at that point and a re-render can leak - another instance. Spawn from `new()` or from a state-transition - method like `begin_tick_loop(...)`. -- **Don't conflate revision and guard**. The guard answered *some* - loop is alive. The revision answers *this* loop is still wanted. -- **`cx.spawn_in(window, …)` is not auto-cancellation.** The future - runs until its next `await` regardless of whether `window` or - the entity still exists. Cancellation is by `update_in` returning - `Err` (entity gone) or by a revision tag mismatch (state changed). - -```rust -// Revision-tagged tick loop pattern (timer.rs). -let revision = self.tick_loop_revision; -cx.spawn(async move |entity, cx| { - loop { - let still_wanted = entity.update(cx, |this, _| { - this.tick_loop_revision == revision - }).unwrap_or(false); - if !still_wanted { break; } - // … tick body … - } -}).detach(); -``` - -A new state transition (start / pause / resume / restart) calls -`self.supersede_tick_loop()` which bumps the revision; the old -loop exits on its next iteration without explicit cancellation. - ---- - -## 9. Listener / paint rules - -- Buttons / inputs / textareas paint themselves. Don't override - `.bg(...)` on them. -- Default cursor is the right default. Override with - `.cursor_grab()` / `.cursor_pointer()` only when the manipulation - is visible: drag-handle title bars and clickable inner labels. -- Sticker cards keep **fixed `ElementId`s** of - `("sticker-row", id as usize)` so hover/selected/focused state - survives re-ordering by the event pump. -- Prefer `SharedString` over `String::clone` in render paths when - the same string is shown by multiple elements. - ---- - -## 10. Privacy invariants - -Verified by the 18-test `clipboard_storage` suite and the placement -integration test. Anything that touches clipboard, vault, or -editor flows must keep passing: - -``` -cargo test --locked --features cli --test clipboard_storage -→ 18 passed -``` - -What's enforced: - -- **Redacted previews on protected rows** -- no plaintext field - surfaces through the chooser, vault dialog, or editor body. -- **Vault generation checks** before any rewrap, unprotect, or - unprotect-via-edit cycle. New envelopes don't overwrite stale - generations. -- **Atomic generation rewrites** -- rewrap deletes the old - envelope in the same transaction it writes the new one. -- **Owned-clipboard clearing** -- on action cancel or auto-clear, - the system clipboard is wiped, not just the in-memory model. -- **Stale-preview avoidance** -- the chooser never re-renders a - row whose backing data has already been replaced. - -Capture filter rules live in `src/native/clipboard/watcher.rs`. The -fail-closed pause on protected history is enforced by the storage -`has_protected_items()` query (single `SELECT EXISTS`); see -`tests/clipboard_storage.rs` for the contract. - ---- - -## 11. Native-handle parity - -| Capability | Where it lives | -| --- | --- | -| Transparent sticker corners | `WindowBackgroundAppearance::Transparent` in `sticker/open.rs`. | -| Per-monitor DPI | `--flash` UX smoke + native window handles; verify at integration time, not via `cargo test`. | -| Single-instance IPC | `ipc::SingleInstance::acquire("rustickers")` in `src/rustickers.rs`. The secondary process `connect_and_signal`s the primary and exits. | -| Global hotkeys | `src/native/hotkey.rs` dispatcher; routes through `IpcEvent` rather than per-window registration. | -| WebView lifecycle | `SimpleWebView::try_new` is fallible (`Result, String>`); the file preview propagates `Err` into `this.error = Some(...)`. The HTML preview also requires `GPUI_DISABLE_DIRECT_COMPOSITION=true` on Windows at startup; `run_native` sets it before `gpui::application()` constructs the platform so the embedded wry `WebView` composes into a visible slot. Skip the override if the operator already set the variable. | -| Paste-destination restoration | The chooser captures the focused window id at hotkey time and passes it through `use_selected(...)` so `foreground(destination)` lands on the original app. | -| Confirm-then-forget | `remove_selected` in `clipboard.rs` fans out a `confirm_remove(...)` dialog when the row is protected or retained; ordinary disposable rows delete instantly. | - ---- - -## 12. Tests, gates, and CI - -Local baseline on a clean tree: - -``` +cargo fmt --all -- --check +cargo check --locked --features ui +cargo check --locked --no-default-features --features storage +cargo check --locked --no-default-features --features cli --bin rst +cargo clippy --locked --features ui +cargo test --locked --no-default-features --features cli cargo test --locked --features cli -→ 190 lib + 19 storage + 3 palette + 1 placement = 213 passed - + 1 ignored (interactive) -``` - -PR CI (`.github/workflows/ci.yml`) runs on `windows-latest`, -`ubuntu-latest` (with webkit + xorg apt deps), and `macos-14`: - -```bash -cargo fmt --all -- --check -cargo check --locked --bin rustickers -cargo check --locked --no-default-features --features storage -cargo check --locked --no-default-features --features cli --bin rst -cargo check --locked --features ui -cargo clippy --locked --features ui -cargo test --locked --no-default-features --features cli -cargo test --locked --features cli ``` -Plus a metalava-like check that `cargo tree --features ui` contains -exactly one `gpui-pre` family. - -UI integration tests via `#[gpui_kit::test]` are deferred: -`gpui-kit`'s `test-support` depends on `gpui_web` (`wasm`) whose -`unicode-properties = ^0.1.3` conflicts with the closure-locked -`^0.1.4`. Visual flows fall back to the dev-loop recipe under -`.agents/skills/gpui-visual-check/` plus the bundled -`scripts/screenshot.ps1`. - ---- - -## 13. Reference skill catalog - -```text -.agents/skills/ - ├─ gpui-kit/ SKILL.md + references/ - │ (coding-guides, conventions, recipes, gpui/**) - ├─ gpui-kit-design-guides/ SKILL.md + references/design-guides.md - └─ gpui-visual-check/ SKILL.md -scripts/screenshot.ps1 Windows capture helper for the dev-loop - recipes described by gpui-visual-check -skills-lock.json source URL + computed hash per skill -docs/plans/gpui-kit-upgrade{,-goal}.md (deleted; absorbed by this doc) -docs/plans/clipboard-{coding,design}-guides.md (deleted; absorbed by this doc) -README.md, ARCHITECTURE.md read these for what the app does and store/IPC contracts -``` +Keep one compatible `gpui-pre` family in the dependency tree. Focused tests cover text repository/protection/titles/CLI, clipboard import, palette, placement, and scheduling. Use temporary databases. -The local skill snapshots are the authoritative reference for kit -conventions. They stay current with the v0.7.1 source. Read them -for full design/coding details; this file distils the -project-specific consequences. +GPUI Kit 0.7.1 has documented Windows headless/test-support limitations; do not label compile checks as UI integration coverage. For optional OS-level captures, see [the visual-check recipe](.agents/skills/gpui-visual-check/SKILL.md) and `scripts/screenshot.ps1`. Use an isolated test setup, not the user's live data. diff --git a/README.md b/README.md index b467cd7..f88a2d6 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,95 @@ # Rustickers -A lightweight desktop sticker app for notes, timers, command output, and file previews. - -Built with [GPUI Kit 0.7.1](https://crates.io/crates/gpui-kit), powered by local SQLite, and designed to stay fast and simple. +A lightweight desktop sticker app for notes, timers, command output, file previews, and sketches. Built with [GPUI Kit](https://crates.io/crates/gpui-kit) and local SQLite. ![Rustickers demo](./screenshots/demo1.png) -## Highlights - -- **Markdown notes**: quick editing, preview mode, and **Ctrl+S** save. -- **Timers**: countdown timers with an audible alert on completion. -- **Command stickers**: pin command output, with optional cron scheduling, env vars, and working directory. A scheduled sticker keeps running in the background even when its window is closed; the manager shows which stickers are running or waiting for their next tick. -- **File / URL preview**: preview files, folders, or URLs and pin them as stickers. Any number of previews can be open side by side; asking for the same files again raises the window that already shows them. -- **Paint stickers**: freehand drawing space for quick sketches. - -All chrome uses the kit theme — there are no raw colour literals in chrome paths. See [`FRONTEND.md`](./FRONTEND.md) for the contributor-side conventions. +## Run -## Clipboard history - -A keyboard-first history chooser shares exact-text deduplication, search, ordering, and -SQLite storage with the CLI. Ordinary history is bounded by count and bytes; reserved, -edited, and protected items are retained independently. Collection starts **paused** so -copying a password does not silently opt you into recording it. +Install stable Rust, then: ```bash -rst cp ls # browse: type, ↑/↓, Enter, Esc -rst cp ls -s rust # start with a filter -rst cp ls --list -n 20 # readable recent-history table -rst cp ls --list --json # one machine-readable JSON object -rst cp ls -- rg -F -- '{{cp}}' . # pick and substitute into argv -rst cp ls --id 42 -- my-tool # append the exact text as one argument -rst cp add 'text from a script' -rst cp add --stdin # read a pipeline without trimming it -rst cp show --id 42 # exact text, no extra newline -rst cp rm 42 # release ordinary saved/edit retention -rst cp add --protect --stdin # authorize, encrypt, then store piped text -rst vault create # choose a master password (hidden prompts) -rst vault status # does a vault exist; how CLI authorization works +cargo run --bin rustickers +cargo run --bin rst --no-default-features --features cli -- --help ``` -Quote `'{{cp}}'` in PowerShell/POSIX shells, or `"{{cp}}"` in Windows cmd. The CLI -uses the parsed argument vector directly: spaces, quotes, newlines, and shell punctuation -remain data. Child stdin/stdout/stderr are inherited unchanged; chooser/password prompts -use the controlling terminal. Cancellation returns 130; missing/non-executable programs -return 127/126; otherwise the child exit code propagates. Use child-supported `--` to -prevent clipboard text beginning with `-` from becoming an option. **Explicitly passing -clipboard text into a shell/interpreter's code argument can still execute it.** - -Open/dismiss the desktop chooser with `Ctrl + Alt + V` (`Cmd + Alt + V` on macOS). -Type to search, use arrows/Page keys to select, and press Enter to paste; Ctrl/Cmd+Enter -copies only. Single-click selects, F2/double-click edits, and Ctrl/Cmd+R toggles reservation. -Ctrl/Cmd+L protects the selected item after password authorization; encrypted edits are saved -in place. The chooser uses the existing theme and a virtualized list, with visible actions, -and confirms irreversible deletes (pinned, edited, or protected rows) before applying them. -Verified automatic paste is currently Windows-only; unavailable or lost destinations fail -closed and leave **Copy only** available. - -Protected adds are encrypted before insertion and never fall back to ordinary history. -Protected text is not searchable; only an explicitly public alias is visible. The master -password is shared between the CLI and the desktop app (one vault, created in either place); -CLI password authorization is scoped to that invocation, **not shared with the desktop -process** — a protected command prompts for the password when it needs it. While protected -items exist, every ordinary add is checked against them with a key derived from that password: -text identical to a protected item refreshes the protected row and never lands in ordinary -history, and anything else collects normally. Unattended desktop collection stays paused until -the vault is unlocked, so a background tick can never write a recaptured secret as plaintext. - -> Encryption does not erase copies held by other apps, the OS clipboard history, backups, -> or pre-encryption database artifacts. Vault grace periods, audit trails, and rotation -> are still tracked as hardening work. - -## Global hotkeys - -- **Show main window**: `Ctrl + Alt + R` (`Cmd + Alt + R` on macOS) -- **Toggle clipboard chooser**: `Ctrl + Alt + V` (`Cmd + Alt + V` on macOS) — pastes back into the previously focused app -- **Toggle quick file preview**: `Ctrl + Alt` (modifier-only chord) -- **Open command for selected text**: `Ctrl/Cmd + Space` (consumed; not forwarded to the active app) -- **Dismiss transient Rustickers windows**: `Esc` is consumed for selection popups, quick previews, the clipboard chooser, and selected-text command runs -- **Save in editor/markdown views**: `Ctrl + S` - -## Quick start - -Prerequisite: stable Rust toolchain. +For release binaries: ```bash -cargo run # desktop app -cargo run --bin rst --no-default-features --features cli -- --help cargo build --release --bin rustickers -cargo build --release --bin rst --no-default-features --features cli +cargo build --release --no-default-features --features cli --bin rst ``` -Outputs are written to `target/release/`. +## Desktop shortcuts -## CLI commands +Use Cmd instead of Ctrl for global shortcuts on macOS. -`rst` is the scripting surface for the app. It writes to the same database the app reads, so -changes apply immediately when the app is running and on next launch when it is not. - -| Command | What it does | +| Shortcut | Action | | --- | --- | -| `list [--state open\|close\|all] [--type ] [-s ] [-n ]` | Find stickers | -| `show [--content-only]` | Everything stored about one sticker | -| `result ` | What a command sticker last produced | -| `open ` / `close ` | Show or hide a sticker's window | -| `delete [-y]` | Remove a sticker permanently | -| `view ` | Preview a file, folder or URL | -| `markdown [-c ] [-f ]` | Create a note sticker | -| `cmd [...]` | Create a command sticker | -| `skill [list\|show \|run ]` | Worked examples | +| Ctrl+Alt+R | Show the manager | +| Ctrl+Alt+V | Open or dismiss TXTs | +| Ctrl+Alt | Toggle quick file preview | +| Ctrl/Cmd+Space | Send selected text to a command sticker | +| Esc | Dismiss a transient window | +| Ctrl+S | Save editor content | -All of them accept the shared appearance options `--width`, `--height`, `--left`, `--top`, -`--color` (yellow, green, blue, pink, gray), `--top-most` and `--closed`. Markdown and command -stickers default to 400×300; file and URL stickers are sized from their content. +Command stickers support scheduled runs, including while their window is closed. File previews never encrypt or overwrite the source file. -Passing a single file path or URL is treated as `view ` automatically, and trailing -options are forwarded. +## TXTs: saved text and clipboard history -### Command stickers +TXTs combines Markdown stickers (open or closed) with disposable clipboard captures. Search, filter by **All / Saved / Clipboard**, and navigate with arrows or Page keys. -```bash -rst cmd "cargo test" --result text --dir . # run and show plain output -rst cmd "npm test 2>&1 | tail -20" --shell # use a shell for pipes and redirection -rst cmd "gh pr list" --cron "0 */5 * * *" # re-run every five minutes -rst cmd "copilot -p {{RUSTICKERS_SELECTION}}" \ - --accept-selection --result markdown --closed # answer whatever text you have selected -``` +- **Enter** pastes to the previous app when opened through the global shortcut; from the manager, it copies instead. Ctrl/Cmd+Enter always copies. +- **F2 / double-click** opens the normal sticker editor. Editing a capture first saves it as a sticker. +- **Save as sticker** (Ctrl/Cmd+R) turns a capture into a closed sticker. +- **Lock / Save encrypted** (Ctrl/Cmd+L) asks for a required public title, then encrypts without opening a sticker. +- **Rename** changes a saved sticker's title; custom titles survive content edits. +- Deleting a capture is immediate; deleting a saved sticker requires confirmation. -`--result` controls how the captured output is rendered: `text`, `markdown`, `html` -(an embedded browser view), `svg` (an image), or `source` (the output is one file path or -URL to preview). Other useful flags are `--stream`, `--no-window`, `--auto-close`, -`--padding`, `--env KEY=VALUE` and `--idle`. +Clipboard collection starts **paused**. Enable it in TXTs. Captures preserve exact text and share a budget of 500 entries / 20 MiB; saved stickers are not evicted. Automatic paste and ownership-safe protected copying currently have a supported path only on Windows. -Two things are worth knowing. A command sticker does **not** run through a shell by default: -the string is split with Windows argument rules and the program is looked up on `PATH`, -so pipes and `&&` are literal text until you add `--shell`. A sticker created without -`--idle` is *armed*, meaning it runs when its window opens and again each time the app starts. +## Protection and privacy -An armed sticker with `--cron` also keeps running with its window **closed**, for as long -as the app is running. Combine it with `--no-window` for a schedule that never puts -anything on screen, and read the result over `rst result ` from anywhere. -In the manager, a spinner marks a command that is running right now and a loop icon marks -one waiting for its next tick. Press Stop on the sticker to disarm it, or delete the -sticker to stop it for good. +Set or change the **Master password…** from the manager or TXTs locker. Once configured, encryption needs no password; each decryption requires it. There is no global unlock session. **Remove encryption…** is a separate authenticated action. -### Machine-readable output +Changing the password generates new keys and re-encrypts all supported locked text atomically. **There is no forgotten-password recovery or old-lock-format compatibility.** Titles stay public and searchable: do not put secrets in them. -Add `--json` to any command to get exactly one JSON object on stdout, with an `ok` field so -success and failure parse the same way: +Internally copied decrypted text is excluded from collection, but externally copied secrets can enter history. Protected clipboard text is cleared after 30 seconds only if still owned; a crash may prevent clearing. Encryption does not erase OS clipboard history, other apps' copies, old database pages, or backups. Protect backups too. + +## CLI ```bash -rst cmd "git status --short" --dir . --json # → {"ok":true,"id":42,...} -rst result 42 --json # → {"ok":true,"output":"...","has_run":true} +rst ls # open stickers; list is an alias +rst ls --text --cp -s notes # merged text/clipboard search +rst ls --text --cp --json # redacted metadata +rst ls --text --cp -- my-tool '{{txt}}' # choose text and pass it as an argument +rst ls --text --id sticker:42 -- my-tool '{{txt}}' +rst markdown -c "Remember this" +rst view ./report.html +rst cmd "cargo test" --dir . --result text +rst cmd "npm test 2>&1 | tail -20" --shell +rst cmd "gh pr list" --cron "0 */5 * * *" +rst show 42 +rst result 42 +rst open 42 +rst close 42 +rst delete 42 ``` -### Skills - -`rst skill list` is a catalogue of ready-made stickers — selection-driven AI prompts, -scheduled watchers, HTML and SVG reports. `skill show ` explains one and prints the -exact `rst` command it is equivalent to, and `skill run --var key=value` creates -it. `--dry-run` prints the command without creating anything. +`--text` selects text stickers; `--cp` selects captures. Combine them for both. IDs are source-specific: `sticker:42` and `clipboard:42`. Without a child command, `ls` prints a table or JSON. The old `cp` and `vault` commands are removed. -## Data and logs +Quote `{{txt}}` for your shell. Substitution preserves text as argument data; without a placeholder, it is appended as one argument. Rustickers does not implicitly invoke a shell. Passing text to an interpreter's code argument can execute it, and secret argv can be visible to other tools. Use `rst --help` for options; `rst skill list` lists ready-made sticker recipes. -Rustickers stores data in `stickers.db` under your app data directory and writes daily-rotated logs to a sibling `logs/` directory. +## Data -Typical locations: +The app and CLI share `stickers.db`; logs live beside it in `logs/`. -- **Windows**: `%LOCALAPPDATA%\rustickers\data\stickers.db` and `%LOCALAPPDATA%\rustickers\data\logs\` -- **macOS**: `~/Library/Application Support/rustickers/data/stickers.db` and `~/Library/Application Support/rustickers/data/logs/` -- **Linux**: `~/.local/share/rustickers/data/stickers.db` and `~/.local/share/rustickers/data/logs/` - -Log level precedence: - -1. `RUSTICKERS_LOG` -2. `RUST_LOG` -3. fallback: `trace` (debug) / `info` (release) - -## Testing gates - -```bash -cargo fmt --all -- --check -cargo check --locked --features ui -cargo clippy --locked --features ui -cargo test --locked --features cli # 213 passed, 1 ignored -``` - -CI also runs the same gates across Windows, Ubuntu, and macOS, plus a parity check that -the closure contains exactly one `gpui-pre` family. - -## Project layout - -``` -ARCHITECTURE.md system architecture, store/IPC contracts -FRONTEND.md contributor handbook (kit tokens, spawn rules, palette) -src/ crate source -tests/ integration tests (clipboard_storage, sticker_palette) -examples/ visual smoke harnesses -.github/workflows/ CI: fmt, clippy, multi-feature cargo check/test, multi-OS build -``` +- Windows: `%LOCALAPPDATA%\rustickers\data\` +- macOS: `~/Library/Application Support/rustickers/data/` +- Linux: `~/.local/share/rustickers/data/` -For the desktop framework, theme, and event-loop rules, see -[`FRONTEND.md`](./FRONTEND.md). For overall system architecture and storage -contracts, see [`ARCHITECTURE.md`](./ARCHITECTURE.md). +Startup imports usable raw text once from legacy clipboard storage, without recovering encrypted content. The old `clipboard.db` is left untouched and may contain sensitive data. Log level: `RUSTICKERS_LOG`, then `RUST_LOG`, then the build default. -## Releases +## Contributing -Tagging (for example `v0.1.0`) triggers GitHub Actions release artifacts for Windows and macOS (UI + CLI bundles). +See [ARCHITECTURE.md](ARCHITECTURE.md) for the system map and [FRONTEND.md](FRONTEND.md) for desktop conventions and checks. diff --git a/migrations/0012_add_unified_text.sql b/migrations/0012_add_unified_text.sql new file mode 100644 index 0000000..b18cae1 --- /dev/null +++ b/migrations/0012_add_unified_text.sql @@ -0,0 +1,31 @@ +-- Text recency is independent of window/chrome changes. +ALTER TABLE stickers ADD COLUMN text_modified_at INTEGER; +ALTER TABLE stickers ADD COLUMN text_last_used_at INTEGER; +ALTER TABLE stickers ADD COLUMN text_revision INTEGER NOT NULL DEFAULT 0; +UPDATE stickers SET text_modified_at = updated_at WHERE type = 'markdown'; + +-- New captures have no retention reasons, public labels, or encrypted payloads. +-- The legacy table remains until desktop and CLI adapters are cut over together. +CREATE TABLE clipboard_captures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL CHECK(length(text) > 0), + text_digest TEXT NOT NULL, + byte_size INTEGER NOT NULL CHECK(byte_size >= 0), + created_at INTEGER NOT NULL, + last_copied_at INTEGER NOT NULL, + last_used_at INTEGER +); +CREATE INDEX clipboard_captures_digest ON clipboard_captures(text_digest); +CREATE INDEX clipboard_captures_recency ON clipboard_captures( + MAX(created_at, last_copied_at, COALESCE(last_used_at, 0)) DESC, id DESC +); +CREATE TABLE text_master_key ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + configuration TEXT NOT NULL +); +CREATE TABLE text_imports ( + source TEXT PRIMARY KEY, + imported_at INTEGER NOT NULL, + imported_count INTEGER NOT NULL, + skipped_count INTEGER NOT NULL +); diff --git a/src/cli/commands/cp.rs b/src/cli/commands/cp.rs deleted file mode 100644 index e68594d..0000000 --- a/src/cli/commands/cp.rs +++ /dev/null @@ -1,784 +0,0 @@ -//! Clipboard history: safe argv execution, terminal selection, and script-friendly output. -use anyhow::{Context, Result, bail}; -use serde_json::json; -use std::io::{self, Read, Write}; -use std::sync::Arc; -use zeroize::Zeroizing; - -use super::clipboard_terminal::{choose, safe_preview}; -use super::vault::unlock; -use crate::cli::{ - output::{Format, table}, - runtime::block_on, -}; -use crate::clipboard::{ - argv, filter, - items::{ClipboardItem, ClipboardItemKind}, - storage::{ClipboardHistoryStore, RetentionFlag, SqliteClipboardStore, UpsertOutcome}, - vault_controller::VaultController, -}; -use crate::storage::paths::AppPaths; - -/// Exit codes needing propagation without skipping Rust destructors. -#[derive(Debug)] -pub struct ExitCode(pub i32); -impl std::fmt::Display for ExitCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "command exited with status {}", self.0) - } -} -impl std::error::Error for ExitCode {} - -#[derive(clap::Subcommand, Debug)] -pub enum CpCommand { - /// Browse, search, or reuse previous copies. - #[command(visible_alias = "list")] - Ls(CpArgs), - /// Add text to history (or pipe text with --stdin). - Add(AddArgs), - /// Remove reservation/edit retention; protected items remain protected. - Rm(RmArgs), - /// Print live clipboard text, or an exact history value with --id. - Show(ShowTextArgs), -} - -#[derive(clap::Args, Debug)] -#[command( - about = "Browse and reuse previous copies", - after_help = "Examples:\n rst cp ls browse (↑↓, Enter, Esc)\n rst cp ls -s rust start with a search\n rst cp ls --list -n 20 print recent history\n rst cp ls --json --list machine-readable history\n rst cp ls -- rg -F -- '{{cp}}' . select and run\n rst cp ls --id 42 -- my-tool append one exact argument\n\nQuote '{{cp}}' in POSIX shells and PowerShell; use \"{{cp}}\" in cmd.exe.\nClipboard text stays data; a shell/interpreter code argument can still execute it.\nChooser/password input uses the terminal, leaving child stdin and stdout untouched.\nExit codes: 130 cancelled, 127 program missing, 126 not executable, otherwise child status." -)] -pub struct CpArgs { - /// Print a compact table (or one JSON object with --json), without choosing. - #[arg(long, conflicts_with_all = ["id", "command"])] - pub list: bool, - /// Filter history; in interactive mode, prefill the search field. - #[arg(long, short = 's', value_name = "TEXT", conflicts_with = "id")] - pub search: Option, - /// Maximum rows to list. - #[arg(long, short = 'n', value_name = "COUNT", requires = "list")] - pub limit: Option, - /// Use a specific history row without a chooser. - #[arg(long, value_name = "ID")] - pub id: Option, - /// Child program and arguments. Use -- before the program. - #[arg(last = true, num_args = 1.., value_name = "COMMAND")] - pub command: Vec, -} - -#[derive(clap::Args, Debug)] -pub struct AddArgs { - /// Read exact text from stdin. - #[arg(long, conflicts_with = "value")] - pub stdin: bool, - /// Text to record, unchanged. Values starting with `-` are kept as text. - #[arg(value_name = "VALUE", required_unless_present = "stdin", allow_hyphen_values = true)] - pub value: Option, - /// Encrypt before writing; prompts for the master password on the terminal. - #[arg(long)] - pub protect: bool, -} -#[derive(clap::Args, Debug)] -pub struct RmArgs { - #[arg(value_name = "ID")] - pub id: i64, -} -#[derive(clap::Args, Debug)] -pub struct ShowTextArgs { - /// Print this history value instead of the live clipboard. - #[arg(long, value_name = "ID")] - pub id: Option, -} - -pub(super) fn open_store(paths: &AppPaths) -> Result> { - Ok(Arc::new(block_on(SqliteClipboardStore::open( - paths.db_path.with_file_name("clipboard.db"), - ))?)) -} - -fn row_text( - store: Arc, - row: &ClipboardItem, -) -> Result> { - if row.kind == ClipboardItemKind::Plain { - return Ok(Zeroizing::new(row.content.clone())); - } - let controller = VaultController::new(store); - unlock(&controller)?; - let bytes = controller - .open(&row.content)? - .context("vault locked before decrypting")?; - Ok(Zeroizing::new( - std::str::from_utf8(&bytes) - .context("protected item is not UTF-8 text")? - .to_owned(), - )) -} - -pub fn run(paths: &AppPaths, args: CpArgs, format: Format) -> Result<()> { - if !args.command.is_empty() { - argv::validate_template(&args.command).context("invalid command template")?; - } - if format.is_json() && !args.list && args.id.is_none() { - bail!("--json requires --list or --id ; interactive selection is for humans"); - } - let store = open_store(paths)?; - let snapshot = block_on(store.snapshot())?; - if args.list { - return run_list(&snapshot, args.search.as_deref(), args.limit, format); - } - let id = if let Some(id) = args.id { - id - } else { - if snapshot.is_empty() { - bail!( - "no clipboard history yet; enable collection in the desktop app or use `rst cp add `" - ); - } - choose(&snapshot, args.search.as_deref().unwrap_or(""))?.ok_or(ExitCode(130))? - }; - // Re-read after selection; a concurrent delete must not execute stale clipboard content. - let row = block_on(store.get(id)) - .with_context(|| format!("clipboard row {id} is no longer available"))?; - if args.command.is_empty() { - format.emit(json!({"id": id, "kind": kind(&row), "hint": "pass -- to reuse this item"}), || { - println!("Selected #{id}. To reuse it: rst cp ls --id {id} -- '{{{{cp}}}}'"); - }); - return Ok(()); - } - let text = row_text(store.clone(), &row)?; - let current = - block_on(store.get(id)).context("clipboard item disappeared during authorization")?; - if current.kind != row.kind || current.content != row.content { - bail!("clipboard item changed during authorization; select it again"); - } - let final_args = Zeroizing::new(argv::build(&args.command, &text)?); - let mut child = std::process::Command::new(&final_args[0]); - child - .args(&final_args[1..]) - .stdin(std::process::Stdio::inherit()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()); - let mut child = match child.spawn() { - Ok(child) => child, - Err(err) => { - let code = match err.kind() { - io::ErrorKind::NotFound => 127, - io::ErrorKind::PermissionDenied => 126, - _ => 1, - }; - eprintln!( - "error: could not launch `{}`: {err}", - safe_preview(&final_args[0], 100) - ); - return Err(ExitCode(code).into()); - } - }; - if let Err(err) = block_on(store.mark_used(id, crate::utils::time::now_unix_millis())) { - eprintln!("warning: could not update clipboard recency: {err}"); - } - let status = child.wait().context("wait for child command")?; - #[cfg(unix)] - let code = { - use std::os::unix::process::ExitStatusExt; - status - .code() - .unwrap_or_else(|| 128 + status.signal().unwrap_or(1)) - }; - #[cfg(not(unix))] - let code = status.code().unwrap_or(1); - if code == 0 { - Ok(()) - } else { - Err(ExitCode(code).into()) - } -} - -fn kind(row: &ClipboardItem) -> &'static str { - if row.kind == ClipboardItemKind::Protected { - "protected" - } else { - "plain" - } -} - -fn run_list( - snapshot: &[ClipboardItem], - search: Option<&str>, - limit: Option, - format: Format, -) -> Result<()> { - let indices = filter::filter_indices(snapshot, search.unwrap_or("")); - let rows: Vec<_> = indices - .iter() - .take(limit.unwrap_or(usize::MAX)) - .map(|index| &snapshot[*index]) - .collect(); - let payload: Vec<_> = rows.iter().map(|row| json!({ - "id": row.id, "kind": kind(row), "byte_size": row.byte_size, "preview": safe_preview(&row.preview_text(), 120), - "public_label": row.public_label, "retention": row.retention, "created_at": row.created_at, - "last_copied_at": row.last_copied_at, "last_used_at": row.last_used_at, "last_edited_at": row.last_edited_at, - })).collect(); - format.emit(json!({"items": payload, "count": rows.len(), "search": search}), || { - if rows.is_empty() { - println!("{}", if search.is_some() { "No matching clipboard items." } else { "No clipboard history yet. Enable collection in the desktop app, or use cp add ." }); - return; - } - let cells: Vec<_> = rows.iter().map(|row| vec![row.id.to_string(), safe_preview(&row.preview_text(), 72), row.byte_size.to_string(), { - let mut flags = Vec::new(); - if row.retention.protected_ { flags.push("🔒 Protected"); } - if row.retention.explicit { flags.push("Reserved"); } - if row.retention.edited { flags.push("Edited"); } - flags.join(", ") - }]).collect(); - table(&["ID", "PREVIEW", "BYTES", "SAVED"], &cells); - }); - Ok(()) -} - -pub fn add(paths: &AppPaths, args: AddArgs, format: Format) -> Result<()> { - let value = Zeroizing::new(if args.stdin { - let mut text = String::new(); - io::stdin() - .read_to_string(&mut text) - .context("read stdin")?; - text - } else { - args.value.context("provide VALUE or --stdin")? - }); - if !crate::clipboard::items::is_collectable(&value) { - bail!("cannot add empty or whitespace-only text"); - } - if value.contains('\0') { - bail!("clipboard text cannot contain a NUL byte"); - } - let store = open_store(paths)?; - let bytes = value.len() as i64; - let now = crate::utils::time::now_unix_millis(); - let (outcome, protected_row) = if args.protect { - let controller = VaultController::new(store.clone()); - unlock(&controller)?; - let sealed = controller - .seal_for_storage(value.as_bytes())? - .context("vault locked before encryption")?; - // Store the keyed token with the ciphertext so later authorized ordinary writes can - // recognise this content without decrypting it. - let token = controller - .match_token(value.as_bytes())? - .context("vault locked before encryption")?; - ( - block_on(store.upsert_protected_bound( - None, - None, - Some(&token), - &sealed.ciphertext, - bytes, - now, - &sealed.vault_envelope, - ))?, - true, - ) - } else { - add_ordinary(store.clone(), &value, bytes, now)? - }; - let (id, outcome) = match outcome { - UpsertOutcome::Inserted { id } => (id, "inserted"), - UpsertOutcome::Updated { id } => (id, "updated"), - }; - format.emit( - json!({"id": id, "outcome": outcome, "protected": protected_row, "byte_size": bytes}), - || { - println!( - "{} #{id} · {bytes} bytes{}", - if outcome == "inserted" { - "Added" - } else { - "Updated" - }, - if protected_row { " · 🔒 Protected" } else { "" } - ) - }, - ); - Ok(()) -} - -/// Write plain text into ordinary history. While protected rows exist the write is authorized -/// through the shared vault — the same master password the desktop app uses — so the CLI and the -/// desktop never diverge on what "collectable" means. -fn add_ordinary( - store: Arc, - value: &str, - bytes: i64, - now: i64, -) -> Result<(UpsertOutcome, bool)> { - let settings = block_on(store.settings())?; - if settings.normal_byte_limit > 0 && bytes > settings.normal_byte_limit { - bail!( - "text exceeds ordinary history byte limit ({} > {}); nothing added", - bytes, - settings.normal_byte_limit - ); - } - let protected: Vec<_> = block_on(store.snapshot())? - .into_iter() - .filter(|row| row.kind == ClipboardItemKind::Protected) - .collect(); - let token = if protected.is_empty() { - None - } else { - let controller = VaultController::new(store.clone()); - unlock(&controller)?; - match authorize_recapture(&store, &controller, &protected, value, now)? { - Authorization::Promoted(outcome) => return Ok((outcome, true)), - Authorization::Token(token) => Some(token), - } - }; - let outcome = block_on(store.upsert_ordinary( - value, - &safe_preview(value, 240), - &SqliteClipboardStore::ordinary_digest(value), - bytes, - now, - token.as_deref(), - ))?; - Ok((outcome, false)) -} - -enum Authorization { - /// The exact text already lives on a protected row; that row was promoted instead of a - /// plaintext copy being written. - Promoted(UpsertOutcome), - /// The text is proven absent from protected history; keyed token for the ordinary write. - Token(String), -} - -/// Decide what an ordinary write means while protected rows exist, given an already-unlocked -/// controller. Rows written before match tokens existed carry a NULL token, so each protected -/// row is also decrypted and compared directly — with the vault unlocked that is exactly what -/// "authorized" means, and it covers legacy rows. Split out from [`add_ordinary`] so tests can -/// pass a pre-unlocked controller instead of hitting the terminal prompt. -fn authorize_recapture( - store: &Arc, - controller: &VaultController, - protected: &[ClipboardItem], - value: &str, - now: i64, -) -> Result { - for row in protected { - let bytes = controller - .open(&row.content)? - .context("vault locked before decrypting")?; - if std::str::from_utf8(&bytes).map(|text| text == value).unwrap_or(false) { - // Promote the protected row's recency; never write a plaintext copy. - block_on(store.mark_used(row.id, now))?; - return Ok(Authorization::Promoted(UpsertOutcome::Updated { id: row.id })); - } - } - let token = controller - .match_token(value.as_bytes())? - .context("vault locked during authorization")?; - Ok(Authorization::Token(token)) -} - -pub fn rm(paths: &AppPaths, args: RmArgs, format: Format) -> Result<()> { - let store = open_store(paths)?; - let row = block_on(store.get(args.id))?; - if row.kind == ClipboardItemKind::Protected { - bail!("item is protected; unprotect it in the desktop app before removing retention"); - } - block_on(store.set_retention_flag(args.id, RetentionFlag::Edited, false))?; - block_on(store.set_retention_flag(args.id, RetentionFlag::Explicit, false))?; - format.emit(json!({"id": args.id}), || { - println!("Item #{} is eligible for ordinary eviction.", args.id) - }); - Ok(()) -} - -pub fn show(paths: &AppPaths, args: ShowTextArgs, format: Format) -> Result<()> { - let text = if let Some(id) = args.id { - let store = open_store(paths)?; - let row = block_on(store.get(id))?; - row_text(store, &row)? - } else { - Zeroizing::new( - arboard::Clipboard::new() - .context("open clipboard")? - .get_text() - .context("clipboard does not contain text")?, - ) - }; - if format.is_json() { - format.emit(json!({"text": *text}), || {}); - } else { - io::stdout().lock().write_all(text.as_bytes())?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - - #[derive(Parser, Debug)] - pub(crate) struct TestCli { - #[command(subcommand)] - pub(crate) command: CpCommand, - } - - #[test] - fn parser_preserves_child_flags_and_placeholder() { - let parsed = TestCli::try_parse_from([ - "cp", "ls", "--id", "42", "--", "rg", "--json", "-F", "--", "{{cp}}", ".", - ]) - .unwrap(); - let CpCommand::Ls(args) = parsed.command else { - panic!() - }; - assert_eq!(args.command, ["rg", "--json", "-F", "--", "{{cp}}", "."]); - assert_eq!(args.id, Some(42)); - } - - #[test] - fn parser_accepts_search_without_list_and_rejects_discarded_commands() { - assert!(TestCli::try_parse_from(["cp", "ls", "-s", "rust"]).is_ok()); - assert!(TestCli::try_parse_from(["cp", "ls", "--list", "--", "echo"]).is_err()); - assert!(TestCli::try_parse_from(["cp", "add"]).is_err()); - assert!(TestCli::try_parse_from(["cp", "add", "--stdin"]).is_ok()); - } - - #[test] - fn parser_keeps_flag_like_text_as_value() { - // Clipboard content very often starts with a dash; it must arrive as text, not as options. - for text in ["--json", "-n 5", "--protect-but-not"] { - let parsed = TestCli::try_parse_from(["cp", "add", text]).unwrap(); - let CpCommand::Add(args) = parsed.command else { - panic!() - }; - assert_eq!(args.value.as_deref(), Some(text)); - assert!(!args.protect); - } - } - - #[test] - fn parser_still_accepts_protect_and_stdin_flags() { - let parsed = TestCli::try_parse_from(["cp", "add", "--protect", "secret"]).unwrap(); - let CpCommand::Add(args) = parsed.command else { - panic!() - }; - assert!(args.protect); - assert_eq!(args.value.as_deref(), Some("secret")); - assert!(matches!( - TestCli::try_parse_from(["cp", "rm", "42"]).unwrap().command, - CpCommand::Rm(RmArgs { id: 42 }) - )); - } -} - -#[cfg(test)] -mod regression_tests { - use super::tests::TestCli; - use super::*; - use clap::Parser; - - fn paths(tmp: &tempfile::TempDir) -> AppPaths { - AppPaths { - db_path: tmp.path().join("stickers.db"), - } - } - - fn add_text(paths: &AppPaths, text: &str) { - add( - paths, - AddArgs { - stdin: false, - value: Some(text.into()), - protect: false, - }, - Format::Json, - ) - .unwrap(); - } - - #[test] - fn failed_protected_add_never_stores_plaintext() { - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - let error = add( - &paths, - AddArgs { - stdin: false, - value: Some("must remain secret".into()), - protect: true, - }, - Format::Json, - ) - .unwrap_err(); - assert!(error.to_string().contains("no master password")); - assert!( - block_on(open_store(&paths).unwrap().snapshot()) - .unwrap() - .is_empty() - ); - } - - #[test] - fn add_preserves_exact_payload_and_rejects_nul() { - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - let text = " Mixed CASE\n日本語\t\"$() & ;\" "; - add_text(&paths, text); - let rows = block_on(open_store(&paths).unwrap().snapshot()).unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].content, text); - assert!( - add( - &paths, - AddArgs { - stdin: false, - value: Some("bad\0text".into()), - protect: false - }, - Format::Json - ) - .is_err() - ); - assert_eq!( - block_on(open_store(&paths).unwrap().snapshot()) - .unwrap() - .len(), - 1 - ); - } - - #[test] - fn add_rejects_empty_and_whitespace_only_text() { - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - for blank in ["", " ", "\n\t "] { - assert!( - add( - &paths, - AddArgs { - stdin: false, - value: Some(blank.into()), - protect: false - }, - Format::Json - ) - .is_err(), - "expected rejection of {blank:?}" - ); - } - assert!( - block_on(open_store(&paths).unwrap().snapshot()) - .unwrap() - .is_empty() - ); - } - - #[test] - fn add_hyphen_prefixed_text_end_to_end() { - // The parser-level guarantee, verified through the real store: flag-like text lands verbatim. - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - let parsed = TestCli::try_parse_from(["cp", "add", "--grep --flags -x"]).unwrap(); - let CpCommand::Add(args) = parsed.command else { - panic!() - }; - add(&paths, args, Format::Json).unwrap(); - let rows = block_on(open_store(&paths).unwrap().snapshot()).unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].content, "--grep --flags -x"); - } - - #[test] - fn add_moves_existing_duplicate_to_top() { - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - add_text(&paths, "first"); - std::thread::sleep(std::time::Duration::from_millis(5)); - add_text(&paths, "second"); - std::thread::sleep(std::time::Duration::from_millis(5)); - add_text(&paths, "first"); // same text again: must refresh recency, not duplicate - let rows = block_on(open_store(&paths).unwrap().snapshot()).unwrap(); - assert_eq!(rows.len(), 2, "duplicate text must upsert, not insert"); - assert_eq!(rows[0].content, "first"); - assert!(rows[0].last_copied_at >= rows[1].last_copied_at); - } - - #[test] - fn add_reports_ids_and_json_outcome() { - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - add( - &paths, - AddArgs { - stdin: false, - value: Some("payload".into()), - protect: false, - }, - Format::Json, - ) - .unwrap(); - let row = block_on(open_store(&paths).unwrap().snapshot()) - .unwrap() - .remove(0); - assert_eq!(row.content, "payload"); - assert_eq!(row.byte_size, 7); - } - - #[test] - fn missing_program_preserves_127_and_does_not_promote() { - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - add_text(&paths, "payload"); - let store = open_store(&paths).unwrap(); - let row = block_on(store.snapshot()).unwrap().remove(0); - let error = run( - &paths, - CpArgs { - list: false, - search: None, - limit: None, - id: Some(row.id), - command: vec![ - tmp.path() - .join("missing-program") - .to_string_lossy() - .into_owned(), - ], - }, - Format::Human, - ) - .unwrap_err(); - assert_eq!(error.downcast_ref::().unwrap().0, 127); - assert_eq!(block_on(store.get(row.id)).unwrap().last_used_at, None); - } - - #[test] - fn recapture_with_protected_history_promotes_or_authorizes() { - // The bug this guards: while any protected row existed, every ordinary `cp add` failed - // with "ordinary collection paused". The shared master password now authorizes the - // write: identical text promotes the protected row, anything else gets a keyed token. - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - let store = open_store(&paths).unwrap(); - let controller = VaultController::new(store.clone()); - block_on(controller.create("test-master-password")).unwrap(); - assert!(block_on(controller.unlock("test-master-password")).unwrap()); - let secret = "vaulted text"; - let sealed = controller.seal_for_storage(secret.as_bytes()).unwrap().unwrap(); - let token = controller.match_token(secret.as_bytes()).unwrap().unwrap(); - let protected_id = match block_on(store.upsert_protected_bound( - None, - None, - Some(&token), - &sealed.ciphertext, - secret.len() as i64, - 1, - &sealed.vault_envelope, - )) - .unwrap() - { - UpsertOutcome::Inserted { id } | UpsertOutcome::Updated { id } => id, - }; - let protected: Vec<_> = block_on(store.snapshot()) - .unwrap() - .into_iter() - .filter(|row| row.kind == ClipboardItemKind::Protected) - .collect(); - - match authorize_recapture(&store, &controller, &protected, secret, 50).unwrap() { - Authorization::Promoted(UpsertOutcome::Updated { id }) => assert_eq!(id, protected_id), - other => panic!("expected the protected row to be promoted, got a token instead"), - } - assert!(block_on(store.get(protected_id)).unwrap().last_used_at.is_some()); - assert_eq!( - block_on(store.snapshot()).unwrap().len(), - 1, - "no plaintext copy of the protected text may exist" - ); - - let different = match authorize_recapture(&store, &controller, &protected, "brand new", 60) - { - Ok(Authorization::Token(token)) => token, - other => panic!("expected a keyed token for fresh text, got {other:?}"), - }; - assert_eq!(different.len(), 64); - // And the storage layer accepts the ordinary write with that token. - block_on(store.upsert_ordinary( - "brand new", - "brand new", - &SqliteClipboardStore::ordinary_digest("brand new"), - 9, - 60, - Some(&different), - )) - .unwrap(); - assert_eq!(block_on(store.snapshot()).unwrap().len(), 2); - } - - #[test] - fn rm_rejects_protected_ciphertext() { - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - let store = open_store(&paths).unwrap(); - let controller = VaultController::new(store.clone()); - block_on(controller.create("test-master-password")).unwrap(); - assert!(block_on(controller.unlock("test-master-password")).unwrap()); - let envelope = controller.seal_for_storage(b"secret").unwrap().unwrap(); - let outcome = block_on(store.upsert_protected_bound( - None, - None, - None, - &envelope.ciphertext, - 6, - 1, - &envelope.vault_envelope, - )) - .unwrap(); - let id = match outcome { - UpsertOutcome::Inserted { id } | UpsertOutcome::Updated { id } => id, - }; - assert!(rm(&paths, RmArgs { id }, Format::Json).is_err()); - let row = block_on(store.get(id)).unwrap(); - assert_eq!(row.kind, ClipboardItemKind::Protected); - assert!(row.retention.protected_); - } - - #[cfg(windows)] - #[test] - fn child_receives_exact_argument_and_exit_code() { - let tmp = tempfile::tempdir().unwrap(); - let paths = paths(&tmp); - let text = "日本語\nquotes \" ' ; $(not code) & --json"; - add_text(&paths, text); - let store = open_store(&paths).unwrap(); - let row = block_on(store.snapshot()).unwrap().remove(0); - let output = tmp.path().join("received.txt"); - let script = tmp.path().join("child.ps1"); - std::fs::write(&script, "param([string]$OutputPath,[string]$Payload)\n[IO.File]::WriteAllText($OutputPath, $Payload)\nexit 23\n").unwrap(); - let error = run( - &paths, - CpArgs { - list: false, - search: None, - limit: None, - id: Some(row.id), - command: vec![ - "pwsh".into(), - "-NoProfile".into(), - "-File".into(), - script.to_string_lossy().into_owned(), - output.to_string_lossy().into_owned(), - "{{cp}}".into(), - ], - }, - Format::Human, - ) - .unwrap_err(); - assert_eq!(error.downcast_ref::().unwrap().0, 23); - assert_eq!(std::fs::read_to_string(output).unwrap(), text); - assert!(block_on(store.get(row.id)).unwrap().last_used_at.is_some()); - } -} diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs index a912bb3..8566ad2 100644 --- a/src/cli/commands/list.rs +++ b/src/cli/commands/list.rs @@ -51,8 +51,8 @@ impl TypeFilter { #[derive(clap::Args, Debug)] pub struct ListArgs { /// Which stickers to include - #[arg(long, value_enum, default_value = "open")] - pub state: StateFilter, + #[arg(long, value_enum)] + pub state: Option, /// Only show stickers of this kind #[arg(long = "type", value_enum, value_name = "KIND")] @@ -65,6 +65,22 @@ pub struct ListArgs { /// Show at most this many stickers #[arg(long, short = 'n', value_name = "COUNT")] pub limit: Option, + + /// Include ordinary text stickers (open and closed by default) + #[arg(long)] + pub text: bool, + + /// Include disposable clipboard captures + #[arg(long)] + pub cp: bool, + + /// Use one text entry without choosing: sticker:42 or clipboard:42 + #[arg(long, conflicts_with_all = ["search", "limit"], requires = "command")] + pub id: Option, + + /// Select text and execute a program; replace {{txt}} in argument tokens + #[arg(last = true, num_args = 1.., conflicts_with = "limit")] + pub command: Vec, } pub fn type_name(sticker_type: StickerType) -> &'static str { @@ -97,9 +113,79 @@ fn to_json(sticker: &StickerListItem) -> serde_json::Value { }) } +fn run_text(paths: &AppPaths, args: ListArgs, format: Format) -> anyhow::Result<()> { + anyhow::ensure!( + args.sticker_type.is_none() || args.sticker_type == Some(TypeFilter::Markdown), + "text selection only supports Markdown stickers; omit --type" + ); + anyhow::ensure!( + args.command.is_empty() || !format.is_json(), + "--json cannot be combined with child execution" + ); + let repository = block_on(crate::text::TextRepository::open(&paths.db_path))?; + let sources = crate::text::TextSources::new(args.text, args.cp); + let state = args.state.and_then(StateFilter::to_state); + if !args.command.is_empty() { + return super::text_execution::run( + &repository, + sources, + state, + args.id, + args.search.as_deref().unwrap_or(""), + &args.command, + ); + } + let mut entries = block_on(repository.snapshot(sources, args.search.as_deref().unwrap_or("")))?; + entries.retain(|e| state.is_none() || e.state().is_none() || e.state() == state); + let total = entries.len(); + if let Some(limit) = args.limit { + entries.truncate(limit); + } + format.emit( + json!({"items": entries, "count": entries.len(), "total": total}), + || { + if entries.is_empty() { + println!("No texts match."); + return; + } + let rows = entries + .iter() + .map(|entry| { + vec![ + entry.id().to_string(), + if entry.is_encrypted() { + "locked".to_owned() + } else { + "plain".to_owned() + }, + entry + .state() + .map(state_name) + .unwrap_or("capture") + .to_owned(), + one_line(entry.preview(), 72), + ] + }) + .collect::>(); + table(&["ID", "PROTECTION", "STATE", "TEXT"], &rows); + }, + ); + Ok(()) +} + pub fn run(app_paths: &AppPaths, args: ListArgs, format: Format) -> anyhow::Result<()> { + if args.text || args.cp { + return run_text(app_paths, args, format); + } + anyhow::ensure!( + args.command.is_empty() && args.id.is_none(), + "child execution requires --text and/or --cp" + ); let store = open_store(app_paths)?; - let mut stickers = block_on(store.list_stickers(args.state.to_state(), args.search.clone()))?; + let mut stickers = block_on(store.list_stickers( + args.state.unwrap_or(StateFilter::Open).to_state(), + args.search.clone(), + ))?; if let Some(filter) = args.sticker_type { stickers.retain(|sticker| filter.matches(sticker.sticker_type)); @@ -117,7 +203,9 @@ pub fn run(app_paths: &AppPaths, args: ListArgs, format: Format) -> anyhow::Resu }), || { if stickers.is_empty() { - if args.state == StateFilter::Open && args.search.is_none() { + if args.state.unwrap_or(StateFilter::Open) == StateFilter::Open + && args.search.is_none() + { println!( "No open stickers. Try `rst list --state all`, or `rst skill \ list` for ready-made ones." diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 1cc22d7..ea5cdf2 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -3,15 +3,15 @@ //! Each module owns its own clap arguments as well as its behaviour, so adding a flag means //! touching one file rather than threading a new parameter through the dispatcher. -mod clipboard_terminal; pub mod cmd; -pub mod cp; pub mod delete; pub mod list; pub mod markdown; pub mod result; pub mod show; pub mod skill; -pub mod vault; +pub mod text_argv; +pub mod text_execution; +mod text_terminal; pub mod view; pub mod window; diff --git a/src/clipboard/argv.rs b/src/cli/commands/text_argv.rs similarity index 92% rename from src/clipboard/argv.rs rename to src/cli/commands/text_argv.rs index e77b6ae..f480872 100644 --- a/src/clipboard/argv.rs +++ b/src/cli/commands/text_argv.rs @@ -1,9 +1,9 @@ //! Argument-vector substitution for `rst cp ls -- ...`. //! //! Rules ([`build`]): -//! - The first token is the fixed executable. `{{cp}}` there is rejected so a malicious clipboard +//! - The first token is the fixed executable. `{{txt}}` there is rejected so a malicious clipboard //! cannot redirect the user to a different program. -//! - Every `{{cp}}` in subsequent arguments is replaced with the exact selected text, without +//! - Every `{{txt}}` in subsequent arguments is replaced with the exact selected text, without //! recursive substitution. //! - When no argument contains the placeholder, the selected text is appended as one terminator //! argument so it lands on argv unaltered. @@ -15,7 +15,7 @@ use anyhow::{Result, anyhow, bail}; /// The placeholder token used in command templates. -pub const PLACEHOLDER: &str = "{{cp}}"; +pub const PLACEHOLDER: &str = "{{txt}}"; /// Return the substituted argv ready for `std::process::Command`. /// @@ -144,19 +144,19 @@ mod tests { #[test] fn placeholder_in_program_is_rejected() { - let err = build(&args(&["{{cp}}", "arg"]), "value").unwrap_err(); + let err = build(&args(&["{{txt}}", "arg"]), "value").unwrap_err(); assert!(format!("{err:#}").contains("not allowed in the program")); } #[test] fn replaces_every_placeholder_in_arguments() { - let out = build(&args(&["rg", "--", "{{cp}}"]), "needle").unwrap(); + let out = build(&args(&["rg", "--", "{{txt}}"]), "needle").unwrap(); assert_eq!(out, vec!["rg", "--", "needle"]); } #[test] fn replaces_multiple_placeholders_in_one_argument() { - let out = build(&args(&["sh", "-c", "echo {{cp}} | {{cp}}"]), "abc").unwrap(); + let out = build(&args(&["sh", "-c", "echo {{txt}} | {{txt}}"]), "abc").unwrap(); assert_eq!(out, vec!["sh", "-c", "echo abc | abc"]); } @@ -168,8 +168,8 @@ mod tests { #[test] fn does_not_recurse_into_selected_text() { - let out = build(&args(&["echo", "{{cp}}"]), "{{cp}}").unwrap(); - assert_eq!(out, vec!["echo", "{{cp}}"]); + let out = build(&args(&["echo", "{{txt}}"]), "{{txt}}").unwrap(); + assert_eq!(out, vec!["echo", "{{txt}}"]); } #[test] @@ -189,7 +189,7 @@ mod tests { #[test] fn validation_steps_match_run_steps() { validate_template(&args(&["prog", "arg"])).unwrap(); - assert!(validate_template(&args(&["{{cp}}"])).is_err()); + assert!(validate_template(&args(&["{{txt}}"])).is_err()); } } diff --git a/src/cli/commands/text_execution.rs b/src/cli/commands/text_execution.rs new file mode 100644 index 0000000..7532996 --- /dev/null +++ b/src/cli/commands/text_execution.rs @@ -0,0 +1,86 @@ +//! Scoped text authorization and safe direct child execution. +use super::{text_argv, text_terminal}; +use crate::{ + cli::runtime::block_on, + text::{TextEntryId, TextRepository, TextSources}, +}; +use anyhow::{Context, Result, ensure}; +use zeroize::Zeroizing; + +#[derive(Debug)] +pub struct ExitCode(pub i32); +impl std::fmt::Display for ExitCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "command exited with status {}", self.0) + } +} +impl std::error::Error for ExitCode {} + +pub(super) fn run( + repository: &TextRepository, + sources: TextSources, + state: Option, + id: Option, + query: &str, + command: &[String], +) -> Result<()> { + text_argv::validate_template(command)?; + let rows = block_on(repository.snapshot(sources, ""))? + .into_iter() + .filter(|e| state.is_none() || e.state().is_none() || e.state() == state) + .collect::>(); + let id = if let Some(id) = id { + ensure!(sources.includes(id), "ID is outside selected text sources"); + id + } else { + text_terminal::choose(&rows, query)?.ok_or(ExitCode(130))? + }; + let selected = rows + .iter() + .find(|e| e.id() == id) + .context("text not available in selected sources/state")?; + let password = if selected.is_encrypted() { + Some(text_terminal::password("Master password: ")?.ok_or(ExitCode(130))?) + } else { + None + }; + let text = block_on(repository.resolve(selected, password.as_deref().map(|s| s.as_str())))?; + let args = Zeroizing::new(text_argv::build(command, &text)?); + let mut child = match std::process::Command::new(&args[0]) + .args(&args[1..]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + { + Ok(child) => child, + Err(error) => { + let code = match error.kind() { + std::io::ErrorKind::NotFound => 127, + std::io::ErrorKind::PermissionDenied => 126, + _ => 1, + }; + // Do not print expanded arguments, which can contain decrypted secrets. + eprintln!("error: could not launch selected command: {error}"); + return Err(ExitCode(code).into()); + } + }; + if block_on(repository.mark_used(id, crate::utils::time::now_unix_millis())).is_err() { + eprintln!("warning: could not update text recency"); + } + let status = child.wait().context("wait for child command")?; + #[cfg(unix)] + let code = { + use std::os::unix::process::ExitStatusExt; + status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(1)) + }; + #[cfg(not(unix))] + let code = status.code().unwrap_or(1); + if code == 0 { + Ok(()) + } else { + Err(ExitCode(code).into()) + } +} diff --git a/src/cli/commands/clipboard_terminal.rs b/src/cli/commands/text_terminal.rs similarity index 89% rename from src/cli/commands/clipboard_terminal.rs rename to src/cli/commands/text_terminal.rs index 5caccbd..d6e40fe 100644 --- a/src/cli/commands/clipboard_terminal.rs +++ b/src/cli/commands/text_terminal.rs @@ -15,7 +15,7 @@ use crossterm::{ }; use zeroize::Zeroizing; -use crate::clipboard::{filter::filter_indices, items::ClipboardItem}; +use crate::text::{TextEntry, TextEntryId, filter_indices}; /// The app's primary color, rgb(255, 218, 60) — the same yellow as the desktop `Button::primary()` /// and the `Icon::Locked` accent — so the terminal chooser reads as the same product. @@ -43,7 +43,7 @@ impl Terminal { .read(true) .write(true) .open(path) - .context("clipboard selection needs a terminal; use --list or --id for scripts")?; + .context("text selection needs a terminal; omit child command to list, or use --id for scripts")?; let mut terminal = Self { output, alternate: false, @@ -120,7 +120,7 @@ impl Selection { } } -pub(super) fn choose(rows: &[ClipboardItem], initial_query: &str) -> Result> { +pub(super) fn choose(rows: &[TextEntry], initial_query: &str) -> Result> { let mut terminal = Terminal::open(true)?; let mut selection = Selection { query: initial_query.to_owned(), @@ -142,7 +142,7 @@ pub(super) fn choose(rows: &[ClipboardItem], initial_query: &str) -> Result Result Result { if let Some(index) = matches.get(selection.selected) { - return Ok(Some(rows[*index].id)); + return Ok(Some(rows[*index].id())); } } KeyCode::Up => selection.move_by(-1, matches.len()), @@ -360,31 +358,24 @@ mod tests { #[cfg(test)] mod interactive_smoke { use super::*; - use crate::clipboard::items::{ClipboardItemKind, RetentionState}; + use crate::text::{TextRepository, TextSources}; // Run in a real PTY: cargo test terminal_chooser_smoke -- --ignored --nocapture #[test] #[ignore = "requires an interactive terminal"] fn terminal_chooser_smoke() { - let rows = ["First copy", "日本語 second copy", "Third copy"] - .iter() - .enumerate() - .map(|(index, text)| ClipboardItem { - id: index as i64 + 1, - kind: ClipboardItemKind::Plain, - content: (*text).into(), - plain_preview: None, - public_label: None, - byte_size: text.len() as i64, - created_at: 1, - last_copied_at: 1, - last_used_at: None, - last_edited_at: None, - retention: RetentionState::default(), - }) - .collect::>(); + let directory = tempfile::tempdir().unwrap(); + let repository = + futures::executor::block_on(TextRepository::open(directory.path().join("stickers.db"))) + .unwrap(); + for text in ["First copy", "日本語 second copy", "Third copy"] { + futures::executor::block_on(repository.capture(text, 1)).unwrap(); + } + let rows = + futures::executor::block_on(repository.snapshot(TextSources::new(false, true), "")) + .unwrap(); let chosen = choose(&rows, "").unwrap(); println!("TERMINAL_SELECTION={chosen:?}"); - assert_eq!(chosen, Some(2)); + assert_eq!(chosen, Some(TextEntryId::Clipboard(2))); } } diff --git a/src/cli/commands/vault.rs b/src/cli/commands/vault.rs deleted file mode 100644 index 6d071cc..0000000 --- a/src/cli/commands/vault.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! `rst vault` — the master password behind encrypted clipboard history. -//! -//! The vault itself lives in the same `clipboard.db` the desktop app uses, so a password created -//! here works there and vice versa. CLI authorization is deliberately per-invocation: nothing -//! unlocked here unlocks the desktop app or any later command. - -use anyhow::{Result, bail}; -use serde_json::json; -use zeroize::Zeroizing; - -use super::clipboard_terminal::password; -use super::cp::{ExitCode, open_store}; -use crate::cli::{ - output::Format, - runtime::block_on, -}; -use crate::clipboard::vault_controller::{VaultController, VaultState}; -use crate::storage::paths::AppPaths; - -#[derive(clap::Args, Debug)] -#[command( - about = "Manage the encrypted clipboard vault", - after_help = "Examples:\n rst vault create choose a master password (hidden prompts)\n rst vault status what exists, how CLI authorization works\n\nThe password is only ever typed on the terminal: never argv, stdin, or the environment.\nProtected commands prompt for it when they need it, for that command only.\nThe master password cannot be reset — forgetting it makes protected items unreadable." -)] -pub struct VaultArgs { - #[command(subcommand)] - pub action: VaultAction, -} - -#[derive(clap::Subcommand, Debug)] -pub enum VaultAction { - /// Choose a master password; never overwrites an existing vault - Create, - - /// Show whether a vault exists and how CLI authorization works - Status, -} - -/// Prompt for a password on the controlling terminal; cancellation reports exit code 130. -pub(super) fn require_password(prompt: &str) -> Result> { - password(prompt)?.ok_or_else(|| ExitCode(130).into()) -} - -/// Unlock `controller` for this invocation, prompting once. The shared gate for every command -/// that needs to read or write protected history (`cp add --protect`, `cp show --id`, ...). -pub(super) fn unlock(controller: &VaultController) -> Result<()> { - if block_on(controller.envelope())?.is_none() { - bail!("no master password configured; run `rst vault create` first"); - } - let password = require_password("Master password: ")?; - if !block_on(controller.unlock(&password))? { - bail!("incorrect master password; nothing was changed"); - } - Ok(()) -} - -pub fn run(paths: &AppPaths, args: VaultArgs, format: Format) -> Result<()> { - let controller = VaultController::new(open_store(paths)?); - match args.action { - VaultAction::Status => { - let configured = block_on(controller.envelope())?.is_some(); - let state = if configured { "locked" } else { "unconfigured" }; - format.emit( - json!({"state": state, "has_master_password": configured, "scope": "this CLI invocation"}), - || { - if configured { - println!( - "Vault: configured. Commands that touch protected history prompt \ - for the password, for that run only." - ); - } else { - println!( - "Vault: not configured. Run `rst vault create`, or protect an item \ - in the desktop app." - ); - } - }, - ); - } - VaultAction::Create => { - if !matches!(block_on(controller.snapshot()), VaultState::Unconfigured) { - bail!( - "master password already configured; change it in the desktop app (existing \ - ciphertext must keep its data key)" - ); - } - let password = require_password("New master password: ")?; - if password.is_empty() { - bail!("master password must not be empty"); - } - let confirm = require_password("Confirm password: ")?; - if password != confirm { - bail!("passwords do not match; nothing changed"); - } - block_on(controller.create(&password))?; - format.emit(json!({"state": "locked"}), || { - println!( - "Master password created. Protected commands will prompt for it when needed; \ - it cannot be recovered or reset." - ) - }); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - - #[derive(Parser, Debug)] - struct TestCli { - #[command(subcommand)] - command: TestCommand, - } - - #[derive(clap::Subcommand, Debug)] - enum TestCommand { - Vault(VaultArgs), - } - - #[test] - fn every_action_parses() { - // First element is argv[0], then `vault `. - for action in ["create", "status"] { - let parsed = TestCli::try_parse_from(["rst", "vault", action]).unwrap(); - let TestCommand::Vault(args) = parsed.command else { - panic!() - }; - let _ = args.action; - } - assert!(TestCli::try_parse_from(["rst", "vault"]).is_err()); - assert!(TestCli::try_parse_from(["rst", "vault", "delete-everything"]).is_err()); - // Removed actions must no longer parse; unlocking happens per command, not on demand. - assert!(TestCli::try_parse_from(["rst", "vault", "unlock"]).is_err()); - assert!(TestCli::try_parse_from(["rst", "vault", "lock"]).is_err()); - } - - #[test] - fn status_works_on_an_unconfigured_vault() { - let tmp = tempfile::tempdir().unwrap(); - let paths = crate::storage::paths::AppPaths { - db_path: tmp.path().join("stickers.db"), - }; - run( - &paths, - VaultArgs { - action: VaultAction::Status, - }, - Format::Json, - ) - .unwrap(); - } - - #[test] - fn create_refuses_to_overwrite_an_existing_vault() { - let tmp = tempfile::tempdir().unwrap(); - let paths = crate::storage::paths::AppPaths { - db_path: tmp.path().join("stickers.db"), - }; - let controller = VaultController::new(open_store(&paths).unwrap()); - block_on(controller.create("someone-elses-password")).unwrap(); - // The refusal must happen before any terminal prompt. - let error = run( - &paths, - VaultArgs { - action: VaultAction::Create, - }, - Format::Json, - ) - .unwrap_err(); - assert!(error.to_string().contains("already configured")); - } - - #[test] - fn unlock_without_a_vault_points_at_create() { - let tmp = tempfile::tempdir().unwrap(); - let paths = crate::storage::paths::AppPaths { - db_path: tmp.path().join("stickers.db"), - }; - let controller = VaultController::new(open_store(&paths).unwrap()); - let error = unlock(&controller).unwrap_err(); - assert!(error.to_string().contains("rst vault create")); - } -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 972a5cf..c4ba453 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -77,21 +77,13 @@ pub struct Cli { #[derive(Subcommand)] enum Commands { - /// List stickers + /// List stickers or select text from TXTs + #[command(name = "ls", visible_alias = "list")] List(commands::list::ListArgs), /// Show everything stored about one sticker Show(commands::show::ShowArgs), - /// Browse and reuse past clipboard copies - Cp { - #[command(subcommand)] - command: commands::cp::CpCommand, - }, - - /// Manage the master password behind protected clipboard history - Vault(commands::vault::VaultArgs), - /// Print what a command sticker last produced Result(commands::result::ResultArgs), @@ -128,7 +120,8 @@ impl Cli { pub fn subcommand_names() -> Vec { Cli::command() .get_subcommands() - .map(|sub| sub.get_name().to_owned()) + .flat_map(|sub| std::iter::once(sub.get_name()).chain(sub.get_visible_aliases())) + .map(str::to_owned) .collect() } @@ -138,14 +131,7 @@ pub fn run(cli: Cli, app_paths: &AppPaths) -> anyhow::Result<()> { match cli.command { Commands::List(args) => commands::list::run(app_paths, args, format), Commands::Show(args) => commands::show::run(app_paths, args, format), - Commands::Cp { command } => match command { - commands::cp::CpCommand::Ls(args) => commands::cp::run(app_paths, args, format), - commands::cp::CpCommand::Add(args) => commands::cp::add(app_paths, args, format), - commands::cp::CpCommand::Rm(args) => commands::cp::rm(app_paths, args, format), - commands::cp::CpCommand::Show(args) => commands::cp::show(app_paths, args, format), - }, Commands::Result(args) => commands::result::run(app_paths, args, format), - Commands::Vault(args) => commands::vault::run(app_paths, args, format), Commands::Open(args) => commands::window::open(app_paths, args, format), Commands::Close(args) => commands::window::close(app_paths, args, format), Commands::Delete(args) => commands::delete::run(app_paths, args, format), @@ -169,8 +155,7 @@ mod tests { fn the_subcommand_list_includes_every_command() { let names = subcommand_names(); for expected in [ - "list", "show", "result", "open", "close", "delete", "view", "markdown", "cmd", - "skill", "cp", "vault", + "ls", "show", "result", "open", "close", "delete", "view", "markdown", "cmd", "skill", ] { assert!(names.contains(&expected.to_owned()), "missing {expected}"); } @@ -194,17 +179,22 @@ mod tests { #[test] fn clipboard_child_json_flag_belongs_to_child_not_parent() { let cli = Cli::try_parse_from([ - "rst", "cp", "ls", "--id", "42", "--", "tool", "--json", "{{cp}}", + "rst", + "ls", + "--cp", + "--id", + "clipboard:42", + "--", + "tool", + "--json", + "{{txt}}", ]) .expect("parse child argv"); assert!(!cli.format().is_json()); - let Commands::Cp { - command: commands::cp::CpCommand::Ls(args), - } = cli.command - else { + let Commands::List(args) = cli.command else { panic!() }; - assert_eq!(args.command, ["tool", "--json", "{{cp}}"]); + assert_eq!(args.command, ["tool", "--json", "{{txt}}"]); } #[test] fn colors_are_validated_against_the_real_palette() { diff --git a/src/clipboard/filter.rs b/src/clipboard/filter.rs deleted file mode 100644 index 470b810..0000000 --- a/src/clipboard/filter.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Search and filtering rules shared by GUI and CLI. -//! -//! Filter rules: -//! 1. Query is trimmed and lower-cased once. Empty query matches everything. -//! 2. Tokenisation splits on Unicode whitespace and matches **all** tokens (AND, not OR). -//! 3. A protected row's preview is matched; the locked plaintext is not searched. The `locked` -//! column lets a caller ask only the public label. -//! 4. Result preserves whatever order the caller passes in; callers feed the MRU order. - -use super::items::ClipboardItem; - -/// Return the indices of `items` matching `query`. Preserves caller order. -pub fn filter_indices(items: &[ClipboardItem], query: &str) -> Vec { - let tokens: Vec = query - .split_whitespace() - .map(|token| token.to_lowercase()) - .collect(); - - items - .iter() - .enumerate() - .filter_map(|(index, item)| { - if matches(item, &tokens) { - Some(index) - } else { - None - } - }) - .collect() -} - -fn matches(item: &ClipboardItem, tokens: &[String]) -> bool { - if tokens.is_empty() { - return true; - } - if let Some(label) = &item.public_label { - let haystack = label.to_lowercase(); - if tokens.iter().all(|token| haystack.contains(token)) { - return true; - } - } - match item.kind { - super::items::ClipboardItemKind::Plain => { - if let Some(preview) = &item.plain_preview { - let haystack = preview.to_lowercase(); - if tokens.iter().all(|token| haystack.contains(token)) { - return true; - } - } - let content = item.content.to_lowercase(); - tokens.iter().all(|token| content.contains(token)) - } - super::items::ClipboardItemKind::Protected => { - let Some(preview) = &item.plain_preview else { - return false; - }; - let haystack = preview.to_lowercase(); - tokens.iter().all(|token| haystack.contains(token)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::clipboard::items::{ClipboardItemKind, RetentionState}; - use chrono::Utc; - - fn item(id: i64, content: &str, preview: Option<&str>) -> ClipboardItem { - let now = Utc::now().timestamp_millis(); - ClipboardItem { - id, - kind: ClipboardItemKind::Plain, - content: content.to_string(), - plain_preview: preview.map(str::to_owned), - public_label: None, - byte_size: content.len() as i64, - created_at: now, - last_copied_at: now, - last_used_at: None, - last_edited_at: None, - retention: RetentionState::default(), - } - } - - #[test] - fn empty_query_returns_everything() { - let items = vec![item(1, "foo", None), item(2, "bar", None)]; - assert_eq!(filter_indices(&items, ""), vec![0, 1]); - } - - #[test] - fn single_token_match() { - let items = vec![item(1, "foo", None), item(2, "bar", None)]; - assert_eq!(filter_indices(&items, "FOO"), vec![0]); - } - - #[test] - fn multiple_tokens_must_all_match() { - let items = vec![ - item(1, "rust cargo test", None), - item(2, "rust and ts", None), - item(3, "cargo ts", None), - ]; - let indices = filter_indices(&items, "rust cargo"); - assert_eq!(indices, vec![0]); - } - - #[test] - fn preview_short_circuit() { - let items = vec![item( - 1, - "full\nbody\nthat\u{1F4A9}\u{2603}long", - Some("full body …"), - )]; - let indices = filter_indices(&items, "body"); - assert_eq!(indices, vec![0]); - // The full-body content has an emoji; the lowercase preview path does the matching. - let indices = filter_indices(&items, "emoji"); - assert_eq!(indices, Vec::::new()); - } -} diff --git a/src/clipboard/items.rs b/src/clipboard/items.rs deleted file mode 100644 index 298b9a5..0000000 --- a/src/clipboard/items.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! The clipboard history data model. -//! -//! A [`ClipboardItem`] captures everything the GUI chooser and CLI need to share: stable identity, -//! recency, retention reasons, and the actual content (either plain text or a protected envelope). -//! Pure functions like [`recency_at_ms`] and [`retention_state`] derive the values that drive both -//! the shared ordering and the retention policy. - -use serde::{Deserialize, Serialize}; - -/// A sentinel returned by [`recency_at_ms`] when the item is currently being edited or used, and -/// the capture cycle should drop the new copy rather than evict the in-use one. Callers that -/// return this value skip pruning only if there are no other ordinary victims available. -pub const PROTECTED_MATCH_ABORT_RUN: i64 = i64::MIN; - -/// The kind of content stored in a single clipboard row. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ClipboardItemKind { - /// Plain text that is collected automatically and may be searched. - Plain, - /// Text encrypted with the master's data key; only metadata is searchable. - Protected, -} - -/// What an ordered row looks like to the chooser. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClipboardItem { - pub id: i64, - pub kind: ClipboardItemKind, - pub content: String, - pub plain_preview: Option, - pub public_label: Option, - pub byte_size: i64, - pub created_at: i64, - pub last_copied_at: i64, - pub last_used_at: Option, - pub last_edited_at: Option, - pub retention: RetentionState, -} - -/// Empty rows must never be saved: capture only stores rows that contain at least one -/// non-whitespace character. White-space-only content is also rejected so callers cannot trick the -/// store into recording empty boards. -pub fn is_collectable(content: &str) -> bool { - !content.is_empty() && content.chars().any(|c| !c.is_whitespace()) -} - -/// The single MRU timestamp that drives shared ordering. Comparisons should never enumerate every -/// field by hand; this function is the rule. It uses `MAX(...)` of: -/// - `created_at` — when the row first appeared. -/// - `last_copied_at` — when the same content was last seen copied while the user is not -/// actively editing. -/// - `last_used_at` — when the row was last successfully pasted or run. -/// - `last_edited_at` — when a saved edit changed the row's content. -/// -/// Reserve/protect/unlock alone do not bump this value. Promotion happens after the action -/// completes, never while the user is still choosing what to do. -pub fn recency_at_ms(item: &ClipboardItem) -> i64 { - item.created_at - .max(item.last_copied_at) - .max(item.last_used_at.unwrap_or(i64::MIN)) - .max(item.last_edited_at.unwrap_or(i64::MIN)) -} - -/// The retention reasons attached to a row are independent flags. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct RetentionState { - pub explicit: bool, - pub edited: bool, - pub protected_: bool, -} - -impl RetentionState { - pub fn is_retained(&self) -> bool { - self.explicit || self.edited || self.protected_ - } - - pub fn ordinary(&self) -> bool { - !self.is_retained() - } - - pub fn explicit(&self) -> bool { - self.explicit - } - - pub fn edited(&self) -> bool { - self.edited - } - - pub fn protected(&self) -> bool { - self.protected_ - } -} - -impl ClipboardItem { - /// The human-readable text used both for filtering and as the row label. - /// - /// For ordinary items this is the row's content. For protected items the chooser never has - /// the plaintext unless it is unlocked. Only an explicitly public alias or generic label - /// is displayed; previews and ciphertext are never used as protected display text. - pub fn preview_text(&self) -> String { - if let Some(public_label) = self.public_label.as_ref().filter(|s| !s.is_empty()) { - return public_label.clone(); - } - if self.kind == ClipboardItemKind::Protected { - return "Protected item".to_string(); - } - if let Some(preview) = self.plain_preview.as_ref().filter(|p| !p.is_empty()) { - // Collapse newlines so each entry renders as a single row, then truncate. - let collapsed: String = preview - .chars() - .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) - .collect(); - return collapsed.chars().take(120).collect(); - } - if !self.content.is_empty() { - return self.content.chars().take(120).collect(); - } - self.id.to_string() - } - - /// The string the chooser feeds into [`crate::clipboard::filter::filter_indices`]. We must - /// never include the secret plaintext of a protected row because the chooser rebuilds this - /// string before the vault is opened. - pub fn searchable_string(&self) -> String { - let mut combined = String::new(); - if let Some(label) = &self.public_label { - combined.push_str(label); - combined.push('\n'); - } - if self.kind == ClipboardItemKind::Protected { - return combined; - } - if let Some(preview) = &self.plain_preview { - combined.push_str(preview); - } - if combined.is_empty() { - combined.push_str(&self.content); - } - combined - } -} - -/// What the chooser surfaces about a protected row. The plaintext is the value just authorised by -/// the master password; the `recency_at_ms` view is unchanged so the picker still shows it in MRU -/// order. The match token, when present, lets collection replace a fresh identical copy rather -/// than create a parallel ordinary row. -#[derive(Clone)] -pub struct ProtectedPreview { - pub item: ClipboardItem, - pub plaintext: String, - pub match_token: Option, -} - -impl std::fmt::Debug for ProtectedPreview { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ProtectedPreview") - .field("id", &self.item.id) - .field("plaintext", &"[redacted]") - .field("match_token", &"[redacted]") - .finish() - } -} diff --git a/src/clipboard/mod.rs b/src/clipboard/mod.rs index 79050b1..01ac745 100644 --- a/src/clipboard/mod.rs +++ b/src/clipboard/mod.rs @@ -1,30 +1,3 @@ -//! Shared clipboard history model. -//! -//! Both the GUI chooser and the CLI `cp ls` command read, filter, and promote rows through the -//! functions here, so two surfaces never disagree about what "recent" or "matched by query" -//! means. Storage backends implement [`storage::ClipboardHistoryStore`]; the data model itself -//! stays in this module so tests can exercise it without a database. - -pub mod argv; -pub mod filter; -pub mod items; -pub mod ordering; -pub mod retention; +//! Clipboard collection settings. Text entries and persistence belong to `crate::text`. pub mod settings; - -#[cfg(feature = "storage")] -pub mod storage; - -#[cfg(feature = "storage")] -pub mod vault; -#[cfg(feature = "storage")] -pub mod vault_controller; - -#[cfg(test)] -mod tests; - -pub use items::{ - ClipboardItem, ClipboardItemKind, PROTECTED_MATCH_ABORT_RUN, ProtectedPreview, RetentionState, -}; - pub use settings::ClipboardSettings; diff --git a/src/clipboard/ordering.rs b/src/clipboard/ordering.rs deleted file mode 100644 index bca39a0..0000000 --- a/src/clipboard/ordering.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Shared ordering and tie-breaking. -//! -//! Both GUI and CLI use the same comparator so a row's position is identical across surfaces. - -use super::items::{ClipboardItem, RetentionState}; - -/// Stable comparator for the chooser / CLI list. -/// -/// Rules, in order: -/// 1. Larger `recency_at_ms` first. -/// 2. Larger `created_at` to break ties deterministically (older vs newer rows that share a -/// timestamp). -/// 3. Larger `id` to break rare millisecond collisions deterministically. -/// -/// Comparisons also group retained rows above pure-MRU by recency: once we have decided two -/// items belong together, retention does not change their relative order, but it keeps them out of -/// the ordinary-eviction pool. -pub fn compare_by_recency(a: &ClipboardItem, b: &ClipboardItem) -> std::cmp::Ordering { - let ra = super::items::recency_at_ms(a); - let rb = super::items::recency_at_ms(b); - rb.cmp(&ra) - .then_with(|| b.created_at.cmp(&a.created_at)) - .then_with(|| b.id.cmp(&a.id)) -} - -/// Stable in-place sort of `items` according to [`compare_by_recency`]. -pub fn sort_in_place(items: &mut [ClipboardItem]) { - items.sort_by(compare_by_recency); -} - -/// The recency key used to evict least-recent ordinary items. We use the same key as the chooser -/// so the row at the bottom is the row that gets evicted. -pub fn recency_key(item: &ClipboardItem) -> i64 { - super::items::recency_at_ms(item) -} - -/// Helper for backends that want to mark items as "ordinary" or "retained" without re-creating the -/// struct. -pub const fn empty_retention() -> RetentionState { - RetentionState { - explicit: false, - edited: false, - protected_: false, - } -} diff --git a/src/clipboard/retention.rs b/src/clipboard/retention.rs deleted file mode 100644 index da2e9bc..0000000 --- a/src/clipboard/retention.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Eviction rules shared by capture and CLI. -//! -//! `eviction_victims` returns the ordered list of ordinary items that should be deleted to bring -//! the ordinary-count and ordinary-byte budgets back under their limits. Retained rows are never -//! returned, even when the limits are tight: a saved/protected/edited/explicit item is part of -//! the user's history and is not fair game. - -use super::items::{ClipboardItem, RetentionState}; -use std::collections::HashSet; - -/// Decide which items to delete from the ordinary pool to fit new capture into a count and byte -/// budget. Both limits are checked; the worst case (most severe overflow) drives how many rows -/// are flagged. `existing_leases` are ids that are temporarily protected while the user edits or -/// uses a row; they are treated as retained for the duration of the call. -pub fn eviction_victims<'a>( - ordinary: &'a [ClipboardItem], - incoming_bytes: i64, - count_limit: i64, - byte_limit: i64, - existing_leases: &HashSet, -) -> Vec<&'a ClipboardItem> { - if count_limit <= 0 && byte_limit <= 0 { - return Vec::new(); - } - - let eligible: Vec<_> = ordinary - .iter() - .filter(|item| { - item.kind == super::items::ClipboardItemKind::Plain && item.retention.ordinary() - }) - .collect(); - let ordinary_count = eligible.len() as i64; - let mut overflow_count = 0; - if count_limit > 0 { - overflow_count = (ordinary_count + 1 - count_limit).max(0); - } - - let ordinary_bytes: i64 = eligible.iter().map(|item| item.byte_size).sum(); - let mut overflow_bytes = 0; - if byte_limit > 0 { - overflow_bytes = (ordinary_bytes + incoming_bytes - byte_limit).max(0); - } - - if overflow_count == 0 && overflow_bytes == 0 { - return Vec::new(); - } - - // Sort oldest-first: the chooser shows MRU, so what is on the bottom is what we evict. - let mut candidates: Vec<&ClipboardItem> = eligible; - candidates.sort_by_key(|item| super::ordering::recency_key(item)); - - let mut victims = Vec::new(); - let mut released_bytes: i64 = 0; - for candidate in candidates.iter() { - if existing_leases.contains(&candidate.id) { - continue; - } - let count_satisfied = overflow_count == 0 || (victims.len() as i64) >= overflow_count; - let bytes_satisfied = overflow_bytes == 0 || released_bytes >= overflow_bytes; - if count_satisfied && bytes_satisfied { - break; - } - released_bytes += candidate.byte_size; - victims.push(*candidate); - } - victims -} - -/// True when the row is considered retained by the user (any reason). -pub fn is_retained(state: &RetentionState) -> bool { - state.is_retained() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::clipboard::items::ClipboardItemKind; - use chrono::Utc; - - fn row(id: i64, byte_size: i64, edited: bool, explicit: bool, _plain: bool) -> ClipboardItem { - let now = Utc::now().timestamp_millis(); - ClipboardItem { - id, - kind: ClipboardItemKind::Plain, - content: "x".repeat(byte_size as usize), - plain_preview: None, - public_label: None, - byte_size, - created_at: now - id * 100, - last_copied_at: now - id * 50, - last_used_at: None, - last_edited_at: edited.then_some(now - id), - retention: RetentionState { - explicit, - edited, - protected_: false, - }, - } - } - - #[test] - fn returns_no_victims_when_under_budget() { - let rows = vec![row(1, 100, false, false, true)]; - let victims = eviction_victims(&rows, 100, 10, 1024, &HashSet::new()); - assert!(victims.is_empty()); - } - - #[test] - fn evicted_rows_are_oldest_ordinary_first() { - // 3 rows, cap at 1, incoming 1 → 3 victims; helper: smallest id = newest. So oldest - // first means id 3, then 2, then 1. - let rows = vec![ - row(1, 100, false, false, true), - row(2, 100, false, false, true), - row(3, 100, false, false, true), - ]; - let victims = eviction_victims(&rows, 100, 1, 4096, &HashSet::new()); - let ids: Vec = victims.iter().map(|r| r.id).collect(); - assert_eq!(ids, vec![3, 2, 1]); - } - - #[test] - fn retained_rows_are_never_evicted() { - // Retained rows are filtered out by the caller; only the ordinary rows are passed in. - let ordinary_rows: Vec = vec![ - row(1, 100, false, false, true), - row(2, 100, false, false, true), - ]; - // Caller additionally skips id=2 (which they held as a lease in a broader system). - let mut leases = HashSet::new(); - leases.insert(2); - let victims = eviction_victims(&ordinary_rows, 100, 1, 4096, &leases); - let ids: Vec = victims.iter().map(|r| r.id).collect(); - // id 1 is the only remaining candidate and not leased. - assert_eq!(ids, vec![1]); - } - - #[test] - fn leases_block_eviction_temporarily() { - // Only id 3 is ordinary; cap at 1 with 2 rows + 1 incoming → need 2 victims. With id 2 - // leased and only id 3 ordinary left, no victim is available; function returns empty. - let rows = vec![ - row(3, 100, false, false, true), - row(2, 100, false, false, true), - ]; - let mut leases = HashSet::new(); - leases.insert(2); - let victims = eviction_victims(&rows, 100, 1, 4096, &leases); - let ids: Vec = victims.iter().map(|r| r.id).collect(); - // id 2 is leased, so the loop runs out without finding enough free rows; the caller - // decides whether to keep the new capture on disk. We get id 3 as the only candidate, - // but it's not enough to satisfy the overflow, so we surface what we have. - assert_eq!(ids, vec![3]); - } -} - -#[cfg(test)] -mod regression_tests { - use super::*; - use crate::clipboard::items::ClipboardItemKind; - - #[test] - fn byte_budget_requires_deleting_the_final_victim_not_just_inspecting_it() { - let rows: Vec<_> = (1..=3) - .map(|id| ClipboardItem { - id, - kind: ClipboardItemKind::Plain, - content: "x".repeat(100), - byte_size: 100, - plain_preview: None, - public_label: None, - created_at: id, - last_copied_at: id, - last_used_at: None, - last_edited_at: None, - retention: RetentionState::default(), - }) - .collect(); - let victims = eviction_victims(&rows, 100, 100, 200, &HashSet::new()); - assert_eq!( - victims.iter().map(|row| row.id).collect::>(), - vec![1, 2] - ); - } - - #[test] - fn damaged_protected_metadata_never_makes_ciphertext_an_eviction_candidate() { - let row = ClipboardItem { - id: 1, - kind: ClipboardItemKind::Protected, - content: String::new(), - byte_size: 100, - plain_preview: None, - public_label: None, - created_at: 1, - last_copied_at: 1, - last_used_at: None, - last_edited_at: None, - retention: RetentionState::default(), - }; - assert!(eviction_victims(&[row], 100, 1, 1, &HashSet::new()).is_empty()); - } -} diff --git a/src/clipboard/settings.rs b/src/clipboard/settings.rs index 05a7f7d..57ea03a 100644 --- a/src/clipboard/settings.rs +++ b/src/clipboard/settings.rs @@ -4,9 +4,9 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ClipboardSettings { - /// Maximum number of *ordinary* rows. Retained rows do not count. + /// Maximum disposable clipboard captures. Saved stickers never count. pub normal_count_limit: i64, - /// Maximum total bytes of *ordinary* content. Retained rows do not count. + /// Maximum total UTF-8 bytes of disposable captures. Saved stickers never count. pub normal_byte_limit: i64, /// Whether the desktop collector is allowed to ingest. pub capture_enabled: bool, diff --git a/src/clipboard/storage.rs b/src/clipboard/storage.rs deleted file mode 100644 index 79fad90..0000000 --- a/src/clipboard/storage.rs +++ /dev/null @@ -1,989 +0,0 @@ -//! SQLite-backed implementation of the clipboard history storage. -//! -//! The trait lives here so it is not shared with the sticker store. Callers should treat -//! [`SqliteClipboardStore`] as the only implementation in v1. - -use anyhow::{Context, Result, ensure}; -use async_trait::async_trait; -use sha2::{Digest, Sha256}; -use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; - -use super::items::{ClipboardItem, ClipboardItemKind, RetentionState}; -use super::settings::ClipboardSettings; - -/// Storage interface for the clipboard history. -/// -/// Methods are split so the GUI chooser and CLI chooser never accidentally use a sticker-shaped -/// API. Snapshots are returned by value; backends must hand out a deep copy rather than a live -/// view that can be mutated by writers. -#[async_trait] -pub trait ClipboardHistoryStore: Send + Sync { - async fn settings(&self) -> Result; - async fn update_settings(&self, settings: &ClipboardSettings) -> Result<()>; - - /// Return every row the chooser could show, sorted MRU-first. The query, if present, is - /// applied to the search columns; the chooser API still re-filters so it can update without - /// a roundtrip. - async fn snapshot(&self) -> Result>; - - /// Cheap privacy-policy probe: does not load clipboard payloads or previews. - async fn has_protected_items(&self) -> Result; - - /// Read one row by id. - async fn get(&self, id: i64) -> Result; - - /// Insert or update a row by content identity. The implementation decides whether the content - /// is already present (plain-text digest for ordinary rows, the supplied match token for - /// protected rows). Returns the row id and whether the row was newly created. - /// - /// While any protected row exists, an ordinary write must carry `authorized_match_token` - /// (a keyed token from the unlocked vault, see [`super::vault::UnlockedVault::match_token`]). - /// A token that matches a protected row refreshes that row instead of storing a plaintext - /// copy; a non-matching token proves the content is absent from protected history and the - /// ordinary insert proceeds. Without a token the write fails closed, which is what pauses - /// unattended collection. - async fn upsert_ordinary( - &self, - content: &str, - plain_preview: &str, - plaintext_digest: &str, - byte_size: i64, - now_ms: i64, - authorized_match_token: Option<&str>, - ) -> Result; - - /// Store ciphertext only. Plaintext previews are rejected. An optional caller-authenticated - /// keyed token deduplicates exact protected content; no token means a distinct row. - async fn upsert_protected( - &self, - public_label: Option<&str>, - plain_preview: Option<&str>, - match_token: Option<&str>, - envelope: &str, - byte_size: i64, - now_ms: i64, - ) -> Result; - - /// Generation-bound protected insert. The expected vault envelope must be captured from - /// the same unlocked key that sealed `envelope`, not freshly read from storage afterward. - #[allow(clippy::too_many_arguments)] - async fn upsert_protected_bound( - &self, - public_label: Option<&str>, - plain_preview: Option<&str>, - match_token: Option<&str>, - envelope: &str, - byte_size: i64, - now_ms: i64, - expected_vault_envelope: &str, - ) -> Result; - - /// Protect an existing ordinary row without changing its identity, recency, or independent - /// retention reasons. Rejects stale content, deletion, and concurrent vault reset/rewrap. - /// Clears live plaintext columns only; this does not guarantee historical disk erasure. - /// `match_token` is the keyed token ([`super::vault::UnlockedVault::match_token`]) of the - /// protected content, so later authorized ordinary writes can recognise it without decrypting. - async fn compare_exchange_protection( - &self, - id: i64, - expected_plaintext: &str, - envelope: &str, - expected_vault_envelope: &str, - match_token: Option<&str>, - public_label: Option<&str>, - ) -> Result<()>; - /// Restore authenticated plaintext against the exact ciphertext and vault generation. - async fn compare_exchange_unprotection( - &self, - id: i64, - expected: &str, - plaintext: &str, - expected_vault_envelope: &str, - ) -> Result<()>; - /// Apply the eviction list returned by `clipboard::retention::eviction_victims`. The store - /// runs the deletion in a transaction so an in-flight capture cannot half-evict. - async fn delete_ordinary_by_ids(&self, ids: &[i64]) -> Result<()>; - - async fn set_retention_flag(&self, id: i64, flag: RetentionFlag, value: bool) -> Result<()>; - - async fn replace_content( - &self, - id: i64, - content: &str, - byte_size: i64, - now_ms: i64, - ) -> Result<()>; - - #[allow(clippy::too_many_arguments)] - /// Save against the exact content read by the editor; fails on delete or concurrent edit. - /// - /// `label` is the public alias the row should carry after the save. Pass `Some(s)` - /// to set `public_label = trim(s)` (or `NULL` when the trimmed string is empty); - /// pass `None` to leave the column untouched. Empty-string and whitespace-only - /// values always normalise to `NULL`, mirroring the protect path. - async fn compare_exchange_content( - &self, - id: i64, - expected: &str, - replacement: &str, - byte_size: i64, - now_ms: i64, - label: Option<&str>, - ) -> Result<()>; - - /// Protected edits must supply fresh ciphertext and the ciphertext read on opening. - async fn compare_exchange_protected_content( - &self, - id: i64, - expected: &str, - replacement: &str, - byte_size: i64, - now_ms: i64, - ) -> Result<()>; - - #[allow(clippy::too_many_arguments)] - /// Generation-bound counterpart of protected editing. Seal the replacement and capture - /// its vault envelope together; ciphertext from a stale cached vault must not be stored. - /// - /// `label` follows the same convention as `compare_exchange_content`: `Some(s)` sets - /// the trimmed value (or `NULL` when empty), `None` leaves the column untouched. - async fn compare_exchange_protected_content_bound( - &self, - id: i64, - expected: &str, - replacement: &str, - byte_size: i64, - now_ms: i64, - expected_vault_envelope: &str, - label: Option<&str>, - ) -> Result<()>; - async fn mark_used(&self, id: i64, now_ms: i64) -> Result<()>; - - async fn delete_row(&self, id: i64) -> Result<()>; - - async fn read_vault_envelope(&self) -> Result>; - async fn write_vault_envelope(&self, envelope: &str, active_until_unix_ms: i64) -> Result<()>; - async fn clear_vault_envelope(&self) -> Result<()>; - /// Compare-and-swap protects password changes against stale controllers. - async fn compare_exchange_vault_envelope( - &self, - expected: &str, - replacement: &str, - active_until_unix_ms: i64, - ) -> Result<()>; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RetentionFlag { - Explicit, - Edited, - Protected, -} - -impl RetentionFlag { - fn column(self) -> &'static str { - match self { - RetentionFlag::Explicit => "retention_explicit", - RetentionFlag::Edited => "retention_edited", - RetentionFlag::Protected => "retention_protected", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UpsertOutcome { - Inserted { id: i64 }, - Updated { id: i64 }, -} - -#[derive(Debug, Clone)] -pub struct SqliteClipboardStore { - pool: SqlitePool, -} - -impl SqliteClipboardStore { - pub async fn open(db_path: impl AsRef) -> Result { - let path = db_path.as_ref().to_path_buf(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).context("create clipboard db parent dir")?; - } - let options = sqlx::sqlite::SqliteConnectOptions::new() - .filename(&path) - .create_if_missing(true) - .busy_timeout(std::time::Duration::from_secs(5)) - .pragma("secure_delete", "ON"); - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(options) - .await - .context("connect clipboard sqlite pool")?; - sqlx::migrate!("./migrations").run(&pool).await?; - Ok(Self { pool }) - } - - /// Exact UTF-8 SHA-256 digest used as the ordinary-row dedupe key. It is not a secret. - pub fn ordinary_digest(content: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - let digest = hasher.finalize(); - let mut out = String::with_capacity(digest.len() * 2); - for byte in digest { - use std::fmt::Write; - let _ = write!(&mut out, "{byte:02x}"); - } - out - } - - fn row_to_item(row: ClipboardRow) -> ClipboardItem { - let kind = if row.protected_envelope.is_some() { - ClipboardItemKind::Protected - } else { - ClipboardItemKind::Plain - }; - ClipboardItem { - id: row.id, - kind, - content: row.protected_envelope.unwrap_or(row.content), - plain_preview: if kind == ClipboardItemKind::Protected { - None - } else { - row.plain_preview - }, - public_label: row.public_label, - byte_size: row.byte_size, - created_at: row.created_at, - last_copied_at: row.last_copied_at, - last_used_at: row.last_used_at, - last_edited_at: row.last_edited_at, - retention: RetentionState { - explicit: row.retention_explicit != 0, - edited: row.retention_edited != 0, - protected_: kind == ClipboardItemKind::Protected || row.retention_protected != 0, - }, - } - } - - async fn require_vault_generation( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - expected: &str, - ) -> Result<()> { - ensure!( - !expected.is_empty(), - "protected writes require an expected vault envelope" - ); - let current: Option = - sqlx::query_scalar("SELECT envelope FROM clipboard_vault WHERE id = 1") - .fetch_optional(&mut **tx) - .await?; - ensure!( - current.as_deref() == Some(expected), - "vault changed or was disabled; unlock again before saving" - ); - Ok(()) - } - /// Called under an immediate write transaction: capture and limits cannot race pruning. - async fn trim_ordinary(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>) -> Result<()> { - let settings: Vec<(String, String)> = - sqlx::query_as("SELECT key, value FROM clipboard_settings") - .fetch_all(&mut **tx) - .await?; - let mut limits = ClipboardSettings::default(); - for (key, value) in settings { - match key.as_str() { - "normal_count_limit" => limits.normal_count_limit = value.parse()?, - "normal_byte_limit" => limits.normal_byte_limit = value.parse()?, - _ => {} - } - } - let rows: Vec<(i64, i64)> = sqlx::query_as( - "SELECT id, byte_size FROM clipboard_items WHERE protected_envelope IS NULL - AND retention_explicit = 0 AND retention_edited = 0 AND retention_protected = 0 - ORDER BY MAX(created_at, last_copied_at, COALESCE(last_used_at, 0), - COALESCE(last_edited_at, 0)), created_at, id", - ) - .fetch_all(&mut **tx) - .await?; - let mut count = rows.len() as i64; - let mut bytes: i64 = rows.iter().map(|(_, size)| size).sum(); - for (id, size) in rows { - if (limits.normal_count_limit <= 0 || count <= limits.normal_count_limit) - && (limits.normal_byte_limit <= 0 || bytes <= limits.normal_byte_limit) - { - break; - } - sqlx::query("DELETE FROM clipboard_items WHERE id = ?1") - .bind(id) - .execute(&mut **tx) - .await?; - count -= 1; - bytes -= size; - } - Ok(()) - } - async fn read_settings(&self) -> Result { - let row = - sqlx::query_as::<_, (String, String)>("SELECT key, value FROM clipboard_settings") - .fetch_all(&self.pool) - .await - .context("read clipboard settings")?; - let mut s = ClipboardSettings::default(); - for (k, v) in row { - match k.as_str() { - "normal_count_limit" => { - if let Ok(n) = v.parse() { - s.normal_count_limit = n; - } - } - "normal_byte_limit" => { - if let Ok(n) = v.parse() { - s.normal_byte_limit = n; - } - } - "capture_enabled" => { - s.capture_enabled = matches!(v.as_str(), "1" | "true"); - } - _ => {} - } - } - Ok(s) - } -} - -#[derive(Debug, sqlx::FromRow)] -struct ClipboardRow { - id: i64, - content: String, - plain_preview: Option, - public_label: Option, - retention_explicit: i64, - retention_edited: i64, - retention_protected: i64, - protected_envelope: Option, - created_at: i64, - last_copied_at: i64, - last_used_at: Option, - last_edited_at: Option, - byte_size: i64, -} - -#[async_trait] -impl ClipboardHistoryStore for SqliteClipboardStore { - async fn settings(&self) -> Result { - self.read_settings().await - } - - async fn update_settings(&self, settings: &ClipboardSettings) -> Result<()> { - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - let pairs: [(&str, String); 3] = [ - ( - "normal_count_limit", - settings.normal_count_limit.to_string(), - ), - ("normal_byte_limit", settings.normal_byte_limit.to_string()), - ( - "capture_enabled", - if settings.capture_enabled { - "1".into() - } else { - "0".into() - }, - ), - ]; - for (k, v) in pairs { - sqlx::query( - "INSERT INTO clipboard_settings (key, value) VALUES (?1, ?2) - ON CONFLICT(key) DO UPDATE SET value = excluded.value", - ) - .bind(k) - .bind(v) - .execute(&mut *tx) - .await - .context("update clipboard setting")?; - } - Self::trim_ordinary(&mut tx).await?; - tx.commit().await?; - Ok(()) - } - - async fn has_protected_items(&self) -> Result { - Ok(sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM clipboard_items WHERE protected_envelope IS NOT NULL)", - ) - .fetch_one(&self.pool) - .await?) - } - async fn snapshot(&self) -> Result> { - let rows = sqlx::query_as::<_, ClipboardRow>( - r#"SELECT id, content, plain_preview, public_label, - retention_explicit, retention_edited, retention_protected, - protected_envelope, created_at, last_copied_at, - last_used_at, last_edited_at, byte_size - FROM clipboard_items - ORDER BY id DESC"#, - ) - .fetch_all(&self.pool) - .await - .context("snapshot clipboard items")?; - let mut items: Vec = rows.into_iter().map(Self::row_to_item).collect(); - super::ordering::sort_in_place(&mut items); - Ok(items) - } - - async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, ClipboardRow>( - r#"SELECT id, content, plain_preview, public_label, - retention_explicit, retention_edited, retention_protected, - protected_envelope, created_at, last_copied_at, - last_used_at, last_edited_at, byte_size - FROM clipboard_items - WHERE id = ?1"#, - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .context("get clipboard row")? - .ok_or_else(|| anyhow::anyhow!("clipboard row {id} not found"))?; - Ok(Self::row_to_item(row)) - } - - async fn upsert_ordinary( - &self, - content: &str, - plain_preview: &str, - plaintext_digest: &str, - byte_size: i64, - now_ms: i64, - authorized_match_token: Option<&str>, - ) -> Result { - ensure!( - plaintext_digest == Self::ordinary_digest(content), - "clipboard digest must match exact content" - ); - ensure!( - byte_size == content.len() as i64, - "clipboard byte size must match content" - ); - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - let byte_limit: Option = sqlx::query_scalar( - "SELECT value FROM clipboard_settings WHERE key = 'normal_byte_limit'", - ) - .fetch_optional(&mut *tx) - .await?; - let byte_limit = byte_limit - .map(|value| value.parse::()) - .transpose()? - .unwrap_or(ClipboardSettings::default().normal_byte_limit); - ensure!( - byte_limit <= 0 || byte_size <= byte_limit, - "clipboard content exceeds the ordinary byte limit" - ); - // Authorized recapture matching: while protected rows exist, an ordinary write must - // carry a keyed token from the unlocked vault. The token either promotes the matching - // protected row (no plaintext copy is ever written) or proves the content is absent. - let protected: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM clipboard_items WHERE protected_envelope IS NOT NULL)", - ) - .fetch_one(&mut *tx) - .await?; - if protected { - let Some(token) = authorized_match_token else { - anyhow::bail!( - "ordinary collection paused: protected history requires an authorized match \ - check; unlock the vault and retry (the desktop prompts, `rst vault create` \ - sets the shared master password)" - ); - }; - let matched: Option = sqlx::query_scalar( - "SELECT id FROM clipboard_items WHERE protected_envelope IS NOT NULL AND protected_match_token = ?1 LIMIT 1") - .bind(token).fetch_optional(&mut *tx).await?; - if let Some(id) = matched { - sqlx::query("UPDATE clipboard_items SET last_copied_at = MAX(last_copied_at, ?1) WHERE id = ?2") - .bind(now_ms).bind(id).execute(&mut *tx).await?; - tx.commit().await?; - return Ok(UpsertOutcome::Updated { id }); - } - } - let existing: Option<(i64, i64)> = sqlx::query_as( - "SELECT id, byte_size FROM clipboard_items - WHERE plaintext_digest = ?1 AND protected_envelope IS NULL AND content = ?2 - LIMIT 1", - ) - .bind(plaintext_digest) - .bind(content) - .fetch_optional(&mut *tx) - .await - .context("lookup digest")?; - - if let Some((id, _)) = existing { - sqlx::query( - "UPDATE clipboard_items - SET last_copied_at = MAX(last_copied_at, ?1), byte_size = ?2, content_bytes = ?2, content = ?3, plain_preview = ?4 - WHERE id = ?5", - ) - .bind(now_ms) - .bind(byte_size) - .bind(content) - .bind(plain_preview) - .bind(id) - .execute(&mut *tx) - .await - .context("update ordinary row")?; - Self::trim_ordinary(&mut tx).await?; - tx.commit().await?; - return Ok(UpsertOutcome::Updated { id }); - } - - let id: i64 = sqlx::query_scalar( - "INSERT INTO clipboard_items ( - content, content_bytes, plain_preview, plaintext_digest, - retention_explicit, retention_edited, retention_protected, locked, - created_at, last_copied_at, byte_size - ) VALUES (?1, ?2, ?3, ?4, 0, 0, 0, 0, ?5, ?6, ?7) - RETURNING id", - ) - .bind(content) - .bind(byte_size) - .bind(plain_preview) - .bind(plaintext_digest) - .bind(now_ms) - .bind(now_ms) - .bind(byte_size) - .fetch_one(&mut *tx) - .await - .context("insert ordinary row")?; - Self::trim_ordinary(&mut tx).await?; - tx.commit().await?; - Ok(UpsertOutcome::Inserted { id }) - } - - async fn upsert_protected( - &self, - _public_label: Option<&str>, - _plain_preview: Option<&str>, - _match_token: Option<&str>, - _envelope: &str, - _byte_size: i64, - _now_ms: i64, - ) -> Result { - anyhow::bail!("protected writes require a vault generation; use upsert_protected_bound") - } - #[allow(clippy::too_many_arguments)] - async fn upsert_protected_bound( - &self, - public_label: Option<&str>, - plain_preview: Option<&str>, - match_token: Option<&str>, - envelope: &str, - byte_size: i64, - now_ms: i64, - expected_vault_envelope: &str, - ) -> Result { - ensure!( - plain_preview.is_none(), - "protected rows cannot persist plaintext previews" - ); - ensure!(byte_size >= 0, "clipboard byte size cannot be negative"); - super::vault::validate_sealed(envelope)?; - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; - if let Some(token) = match_token { - let existing: Option = sqlx::query_scalar( - "SELECT id FROM clipboard_items WHERE protected_envelope IS NOT NULL AND protected_match_token = ?1 LIMIT 1") - .bind(token).fetch_optional(&mut *tx).await?; - if let Some(id) = existing { - sqlx::query("UPDATE clipboard_items SET last_copied_at = MAX(last_copied_at, ?1) WHERE id = ?2") - .bind(now_ms).bind(id).execute(&mut *tx).await?; - tx.commit().await?; - return Ok(UpsertOutcome::Updated { id }); - } - } - let id: i64 = sqlx::query_scalar( - "INSERT INTO clipboard_items ( - content, content_bytes, plain_preview, protected_match_token, - protected_envelope, public_label, - retention_explicit, retention_edited, retention_protected, locked, - created_at, last_copied_at, byte_size - ) VALUES ('', ?1, ?2, ?3, ?4, ?5, 0, 0, 1, 1, ?6, ?7, ?1) - RETURNING id", - ) - .bind(byte_size) - .bind(plain_preview) - .bind(match_token) - .bind(envelope) - .bind(public_label) - .bind(now_ms) - .bind(now_ms) - .fetch_one(&mut *tx) - .await - .context("insert protected row")?; - tx.commit().await?; - Ok(UpsertOutcome::Inserted { id }) - } - - async fn compare_exchange_protection( - &self, - id: i64, - expected_plaintext: &str, - envelope: &str, - expected_vault_envelope: &str, - match_token: Option<&str>, - public_label: Option<&str>, - ) -> Result<()> { - if let Some(label) = public_label { - ensure!(!label.trim().is_empty(), "A public label is required"); - } - super::vault::validate_sealed(envelope)?; - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; - // Read and compare while holding SQLite's writer lock; do not trust a chooser snapshot. - let current: Option<(String, Option)> = - sqlx::query_as("SELECT content, protected_envelope FROM clipboard_items WHERE id = ?1") - .bind(id) - .fetch_optional(&mut *tx) - .await?; - let (plaintext, protected) = current.context("clipboard row no longer exists")?; - let plaintext = zeroize::Zeroizing::new(plaintext); - ensure!( - protected.is_none() && plaintext.as_str() == expected_plaintext, - "clipboard row changed; reload before protecting" - ); - sqlx::query( - "UPDATE clipboard_items SET protected_envelope = ?1, content = '', - plain_preview = NULL, plaintext_digest = NULL, protected_match_token = ?5, - content_bytes = ?2, byte_size = ?2, retention_protected = 1, locked = 1, public_label = ?4 - WHERE id = ?3", - ) - .bind(envelope) - .bind(expected_plaintext.len() as i64) - .bind(id) - .bind(public_label.map(str::trim)) - .bind(match_token) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(()) - } - async fn compare_exchange_unprotection( - &self, - id: i64, - expected: &str, - plaintext: &str, - expected_vault_envelope: &str, - ) -> Result<()> { - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; - let affected = sqlx::query("UPDATE clipboard_items SET content = ?1, plain_preview = substr(?1, 1, 240), plaintext_digest = ?2, - protected_envelope = NULL, protected_match_token = NULL, public_label = NULL, - retention_protected = 0, locked = 0, content_bytes = ?3, byte_size = ?3 - WHERE id = ?4 AND protected_envelope = ?5") - .bind(plaintext).bind(Self::ordinary_digest(plaintext)).bind(plaintext.len() as i64) - .bind(id).bind(expected).execute(&mut *tx).await?.rows_affected(); - ensure!( - affected == 1, - "protected row changed or was deleted; reload before unlocking" - ); - tx.commit().await?; - Ok(()) - } - async fn delete_ordinary_by_ids(&self, id: &[i64]) -> Result<()> { - if id.is_empty() { - return Ok(()); - } - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - let placeholders = std::iter::repeat_n("?", id.len()) - .collect::>() - .join(","); - let sql = format!( - "DELETE FROM clipboard_items - WHERE id IN ({placeholders}) - AND retention_explicit = 0 - AND retention_edited = 0 - AND retention_protected = 0 - AND protected_envelope IS NULL" - ); - let mut query = sqlx::query(sqlx::AssertSqlSafe(sql)); - for id in id { - query = query.bind(id); - } - query - .execute(&mut *tx) - .await - .context("delete ordinary rows")?; - tx.commit().await?; - Ok(()) - } - - async fn set_retention_flag(&self, id: i64, flag: RetentionFlag, value: bool) -> Result<()> { - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - let row: Option<(Option, i64)> = sqlx::query_as( - "SELECT protected_envelope, retention_protected FROM clipboard_items WHERE id = ?1", - ) - .bind(id) - .fetch_optional(&mut *tx) - .await?; - let (envelope, reason) = row.context("clipboard row no longer exists")?; - if flag == RetentionFlag::Protected { - ensure!( - !value || envelope.is_some() || reason != 0, - "protect requires encrypted content, not a retention flag" - ); - ensure!( - value || envelope.is_none(), - "unprotect requires authenticated decryption" - ); - } - let column = flag.column(); - let sql = format!("UPDATE clipboard_items SET {column} = ?1 WHERE id = ?2"); - sqlx::query(sqlx::AssertSqlSafe(sql)) - .bind(i64::from(value)) - .bind(id) - .execute(&mut *tx) - .await - .context("set retention flag")?; - tx.commit().await?; - Ok(()) - } - - async fn replace_content( - &self, - id: i64, - content: &str, - byte_size: i64, - now_ms: i64, - ) -> Result<()> { - ensure!( - byte_size == content.len() as i64, - "clipboard byte size must match content" - ); - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - let row: Option<(String, Option)> = - sqlx::query_as("SELECT content, protected_envelope FROM clipboard_items WHERE id = ?1") - .bind(id) - .fetch_optional(&mut *tx) - .await?; - let (previous, envelope) = row.context("clipboard row no longer exists")?; - ensure!( - envelope.is_none(), - "protected editing requires re-encryption" - ); - if previous != content { - sqlx::query( - "UPDATE clipboard_items SET content = ?1, byte_size = ?2, - content_bytes = ?2, last_edited_at = ?3, retention_edited = 1, - plain_preview = substr(?1, 1, 240), plaintext_digest = ?4 WHERE id = ?5", - ) - .bind(content) - .bind(byte_size) - .bind(now_ms) - .bind(Self::ordinary_digest(content)) - .bind(id) - .execute(&mut *tx) - .await - .context("replace content")?; - } - tx.commit().await?; - Ok(()) - } - async fn compare_exchange_content( - &self, - id: i64, - expected: &str, - replacement: &str, - byte_size: i64, - now_ms: i64, - label: Option<&str>, - ) -> Result<()> { - ensure!( - byte_size == replacement.len() as i64, - "clipboard byte size must match content" - ); - // Some("") / Some(" ") reduce to None; None leaves the column untouched. - let normalised_label: Option<&str> = label.map(str::trim).filter(|s| !s.is_empty()); - let affected = sqlx::query( - "UPDATE clipboard_items SET content = ?1, - byte_size = ?2, content_bytes = ?2, - last_edited_at = CASE WHEN content != ?1 THEN ?3 ELSE last_edited_at END, - retention_edited = CASE WHEN content != ?1 THEN 1 ELSE retention_edited END, - plain_preview = substr(?1, 1, 240), plaintext_digest = ?4 - WHERE id = ?5 AND content = ?6 AND protected_envelope IS NULL", - ) - .bind(replacement) - .bind(byte_size) - .bind(now_ms) - .bind(Self::ordinary_digest(replacement)) - .bind(id) - .bind(expected) - .execute(&self.pool) - .await? - .rows_affected(); - ensure!( - affected == 1, - "clipboard row changed or was deleted; reload before saving" - ); - // Apply the label update for `Some(...)`: the editor read the row at - // open time and is now writing back, so the user is being authoritative. - // Bind `NULL` when the caller passed whitespace/empty so `Some("")` - // becomes a "clear", not a no-op. `None` is a separate signal that - // means "column untouched" -- we exercise it when the labelled save - // races with content-only saves. - if label.is_some() { - sqlx::query("UPDATE clipboard_items SET public_label = ?1 WHERE id = ?2") - .bind(normalised_label) - .bind(id) - .execute(&self.pool) - .await - .context("update public_label")?; - } - Ok(()) - } - - async fn compare_exchange_protected_content( - &self, - _id: i64, - _expected: &str, - _replacement: &str, - _byte_size: i64, - _now_ms: i64, - ) -> Result<()> { - anyhow::bail!( - "protected edits require a vault generation; use compare_exchange_protected_content_bound" - ) - } - async fn compare_exchange_protected_content_bound( - &self, - id: i64, - expected: &str, - replacement: &str, - byte_size: i64, - now_ms: i64, - expected_vault_envelope: &str, - label: Option<&str>, - ) -> Result<()> { - super::vault::validate_sealed(replacement)?; - ensure!(byte_size >= 0, "clipboard byte size cannot be negative"); - // Some("") / Some(" ") reduce to None; None leaves the column untouched. - let normalised_label: Option<&str> = label.map(str::trim).filter(|s| !s.is_empty()); - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - Self::require_vault_generation(&mut tx, expected_vault_envelope).await?; - let affected = sqlx::query("UPDATE clipboard_items SET protected_envelope = ?1, - content = '', plain_preview = NULL, plaintext_digest = NULL, protected_match_token = NULL, - byte_size = ?2, content_bytes = ?2, last_edited_at = ?3, - retention_edited = 1, retention_protected = 1 - WHERE id = ?4 AND protected_envelope = ?5") - .bind(replacement).bind(byte_size).bind(now_ms).bind(id).bind(expected) - .execute(&mut *tx).await?.rows_affected(); - ensure!( - affected == 1, - "protected row changed or was deleted; reload before saving" - ); - // The encrypt path already binds `public_label` into the same UPDATE; for - // encrypted edits, the editor surfaces this label in the unprotect-protected - // handoff. Some(...) writes the authoritative value (NULL passes through to - // the column, so whitespace-only inputs become a clear). None skips so - // the column is left alone. - if label.is_some() { - sqlx::query("UPDATE clipboard_items SET public_label = ?1 WHERE id = ?2") - .bind(normalised_label) - .bind(id) - .execute(&mut *tx) - .await - .context("update public_label")?; - } - tx.commit().await?; - Ok(()) - } - async fn mark_used(&self, id: i64, now_ms: i64) -> Result<()> { - sqlx::query("UPDATE clipboard_items SET last_used_at = ?1 WHERE id = ?2") - .bind(now_ms) - .bind(id) - .execute(&self.pool) - .await - .context("mark used")?; - Ok(()) - } - - async fn delete_row(&self, id: i64) -> Result<()> { - sqlx::query("DELETE FROM clipboard_items WHERE id = ?1") - .bind(id) - .execute(&self.pool) - .await - .context("delete row")?; - Ok(()) - } - - async fn read_vault_envelope(&self) -> Result> { - let row: Option<(String,)> = - sqlx::query_as("SELECT envelope FROM clipboard_vault WHERE id = 1") - .fetch_optional(&self.pool) - .await - .context("read vault envelope")?; - let Some((text,)) = row else { - return Ok(None); - }; - if text.is_empty() { - Ok(None) - } else { - Ok(Some(text)) - } - } - - async fn write_vault_envelope(&self, envelope: &str, active_until_unix_ms: i64) -> Result<()> { - super::vault::VaultEnvelope::parse(envelope)?; - let affected = sqlx::query( - "UPDATE clipboard_vault SET envelope = ?1, active_until_unix_ms = ?2 - WHERE id = 1 AND envelope = ''", - ) - .bind(envelope) - .bind(active_until_unix_ms) - .execute(&self.pool) - .await? - .rows_affected(); - ensure!( - affected == 1, - "vault already configured; authenticate and rewrap instead" - ); - Ok(()) - } - - async fn clear_vault_envelope(&self) -> Result<()> { - let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; - let protected: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM clipboard_items WHERE protected_envelope IS NOT NULL)", - ) - .fetch_one(&mut *tx) - .await?; - ensure!( - !protected, - "cannot disable a vault while protected history exists" - ); - sqlx::query( - "UPDATE clipboard_vault SET envelope = '', active_until_unix_ms = 0 WHERE id = 1", - ) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(()) - } - - async fn compare_exchange_vault_envelope( - &self, - expected: &str, - replacement: &str, - active_until_unix_ms: i64, - ) -> Result<()> { - ensure!(!expected.is_empty(), "rewrap requires an existing vault"); - super::vault::VaultEnvelope::parse(replacement)?; - let affected = sqlx::query( - "UPDATE clipboard_vault SET envelope = ?1, active_until_unix_ms = ?2 - WHERE id = 1 AND envelope = ?3", - ) - .bind(replacement) - .bind(active_until_unix_ms) - .bind(expected) - .execute(&self.pool) - .await? - .rows_affected(); - ensure!(affected == 1, "vault changed concurrently; unlock again"); - Ok(()) - } -} diff --git a/src/clipboard/tests.rs b/src/clipboard/tests.rs deleted file mode 100644 index f24f231..0000000 --- a/src/clipboard/tests.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Cross-module integration checks that exercise the shared contract end-to-end. - -use super::items::{ClipboardItem, ClipboardItemKind, RetentionState}; -use super::{ordering, retention}; - -fn make_row(id: i64, bytes: i64, retained: bool) -> ClipboardItem { - let now = chrono::Utc::now().timestamp_millis(); - ClipboardItem { - id, - kind: ClipboardItemKind::Plain, - content: "x".repeat(bytes as usize), - plain_preview: None, - public_label: None, - byte_size: bytes, - created_at: now - id * 1_000, - last_copied_at: now - id * 1_000, - last_used_at: None, - last_edited_at: None, - retention: RetentionState { - explicit: false, - edited: false, - protected_: retained, - }, - } -} - -#[test] -fn gui_and_cli_ordering_rules_agree() { - let mut rows = vec![ - make_row(1, 5, false), - make_row(3, 5, false), - make_row(2, 5, false), - ]; - ordering::sort_in_place(&mut rows); - let ids: Vec = rows.iter().map(|r| r.id).collect(); - // Smaller id → newer (created_at = now - id*1000). MRU-first == smaller id first. - assert_eq!(ids, vec![1, 2, 3]); -} - -#[test] -fn eviction_does_not_retain_protected_rows() { - // id 1 is older but protected → not evictable. id 2 is the next-oldest ordinary victim. - let rows = vec![make_row(1, 5, true), make_row(2, 5, false)]; - let victims = retention::eviction_victims(&rows, 1, 0, 5, &Default::default()); - let ids: Vec = victims.iter().map(|r| r.id).collect(); - assert_eq!(ids, vec![2]); -} diff --git a/src/clipboard/vault.rs b/src/clipboard/vault.rs deleted file mode 100644 index 30c4cac..0000000 --- a/src/clipboard/vault.rs +++ /dev/null @@ -1,442 +0,0 @@ -//! Master-password vault for protecting clipboard rows. -//! -//! The vault holds a single 32-byte data key that encrypts the plaintext of protected rows. The -//! data key is wrapped by an Argon2id-derived key from the master password; the wrapped blob is -//! the only thing persisted on disk. Plaintext keys never leave memory in any form other than a -//! zeroising wrapper. -//! -//! Why Argon2id and not PBKDF2: PBKDF2 in the existing sticker lock is fine for -//! per-sticker passwords that the user changes often, but a single long-lived master password -//! deserves a memory-hard KDF so an attacker who steals the database cannot brute-force common -//! passphrases. The sticker lock is intentionally kept as-is for backwards compatibility. - -use anyhow::{Context, Result, anyhow, bail}; -use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; -use rand::RngCore; -use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; -use serde::{Deserialize, Serialize}; -use zeroize::{Zeroize, Zeroizing}; - -/// Current vault envelope format. Increment when key derivation or seal format changes. -pub const VAULT_VERSION: u8 = 1; -/// Memory cost (KiB) for Argon2id. 64 MiB is a 2026-class default — enough to deter GPU brute -/// force, light enough for a background task on a low-end laptop. -pub const ARGON2_M_COST_KIB: u32 = 65_536; -/// Iterations. Tune with `t_cost` until interactive unlock is < 250 ms on older hardware. -pub const ARGON2_T_COST: u32 = 3; -/// Parallelism. Threads count for Argon2id. Two hides GPU-friendly patterns. -pub const ARGON2_P_COST: u32 = 2; -/// Salt length, raw bytes. -pub const SALT_LEN: usize = 16; -/// Nonce length, raw bytes (AES-GCM uses 96-bit IVs). -pub const NONCE_LEN: usize = 12; - -/// Prefix prepended to the wrapped data key so a future migration can detect version 1. -pub const VAULT_PREFIX: &str = "RUSTICKERS_VAULT_V1"; - -/// On-disk representation of the master password's data-key-encryption key. -#[derive(Clone, Serialize, Deserialize)] -pub struct VaultEnvelope { - pub salt: String, - pub nonce: String, - pub ciphertext: String, - pub m_cost: u32, - pub t_cost: u32, - pub p_cost: u32, -} - -impl std::fmt::Debug for VaultEnvelope { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("VaultEnvelope") - .field("salt", &"[redacted]") - .field("nonce", &self.nonce) - .field("ciphertext", &"[redacted]") - .field("m_cost", &self.m_cost) - .field("t_cost", &self.t_cost) - .field("p_cost", &self.p_cost) - .finish() - } -} - -impl VaultEnvelope { - /// Helper used by [`VaultController`]; same body as [`Self::serialize`] but exposed as an - /// associated function so callers can write `VaultEnvelope::serialize_string(&envelope)`. - pub fn serialize_string(envelope: &VaultEnvelope) -> Result { - envelope.serialize() - } - - pub fn serialize(&self) -> Result { - let json = serde_json::to_string(self).context("encode vault envelope")?; - Ok(format!("{VAULT_PREFIX}\n{json}")) - } - - pub fn parse(value: &str) -> Result { - let trimmed = value.trim_start(); - let Some(payload) = trimmed.strip_prefix(VAULT_PREFIX) else { - bail!("vault envelope uses an unknown version prefix"); - }; - let envelope: VaultEnvelope = - serde_json::from_str(payload.trim()).context("decode vault envelope JSON")?; - Ok(envelope) - } -} - -/// In-memory state when the vault is unlocked: the data key wrapped in a [`Zeroizing`] buffer. -#[derive(Clone)] -pub struct UnlockedVault { - envelope: VaultEnvelope, - data_key: Zeroizing<[u8; 32]>, -} - -impl std::fmt::Debug for UnlockedVault { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("UnlockedVault") - .field("envelope", &self.envelope) - .field("data_key", &"[redacted]") - .finish() - } -} - -impl UnlockedVault { - /// Persist the envelope on disk, since settings live in `clipboard_settings.kv` for v1. - pub fn envelope(&self) -> &VaultEnvelope { - &self.envelope - } - - /// Encrypt `plaintext` for storage on a protected row. Returns base64-encoded ciphertext - /// with the AES-GCM nonce prepended so callers don't have to track the nonce separately. - pub fn seal(&self, plaintext: &[u8]) -> Result { - let mut nonce_bytes = [0u8; NONCE_LEN]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); - let unbound = UnboundKey::new(&AES_256_GCM, self.data_key.as_ref()) - .map_err(|_| anyhow!("invalid AES-256-GCM key"))?; - let key = LessSafeKey::new(unbound); - let nonce = Nonce::assume_unique_for_key(nonce_bytes); - let mut buffer = Zeroizing::new(plaintext.to_vec()); - key.seal_in_place_append_tag(nonce, Aad::empty(), &mut *buffer) - .map_err(|_| anyhow!("AES-GCM seal failed"))?; - let encoded = STANDARD_NO_PAD.encode(buffer.as_slice()); - Ok(format!("{}:{}", base64_nonce(nonce_bytes), encoded)) - } - - /// Keyed duplicate-detection token: hex(HMAC-SHA256(data_key, domain || plaintext)). Only an - /// unlocked vault can produce or confirm one, so a protected row can be matched against a - /// candidate copy without decrypting anything and without a reusable content hash. Domain - /// separation keeps this token from ever being interchangeable with ciphertext. - pub fn match_token(&self, plaintext: &[u8]) -> String { - let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, self.data_key.as_ref()); - let mut context = ring::hmac::Context::with_key(&key); - context.update(b"rustickers-match-v1\n"); - context.update(plaintext); - let tag = context.sign(); - let mut out = String::with_capacity(64); - for byte in tag.as_ref() { - use std::fmt::Write; - let _ = write!(&mut out, "{byte:02x}"); - } - out - } - - /// Inverse of [`seal`]. Returns the plaintext wrapped in a [`Zeroizing`] buffer; the caller - /// is responsible for dropping the buffer (or moving it to a feature-gated scratch area). - pub fn open(&self, sealed: &str) -> Result>> { - let (nonce_b64, payload_b64) = sealed - .split_once(':') - .ok_or_else(|| anyhow!("sealed payload is missing the nonce separator"))?; - let nonce_bytes = STANDARD_NO_PAD - .decode(nonce_b64.as_bytes()) - .map_err(|_| anyhow!("nonce base64 invalid"))?; - if nonce_bytes.len() != NONCE_LEN { - bail!("nonce length is wrong: {} bytes", nonce_bytes.len()); - } - let mut payload = Zeroizing::new( - STANDARD_NO_PAD - .decode(payload_b64.as_bytes()) - .map_err(|_| anyhow!("ciphertext base64 invalid"))?, - ); - let unbound = UnboundKey::new(&AES_256_GCM, self.data_key.as_ref()) - .map_err(|_| anyhow!("invalid AES-256-GCM key"))?; - let key = LessSafeKey::new(unbound); - let mut nonce_array = [0u8; NONCE_LEN]; - nonce_array.copy_from_slice(&nonce_bytes); - let nonce = Nonce::assume_unique_for_key(nonce_array); - let plaintext = key - .open_in_place(nonce, Aad::empty(), &mut payload) - .map_err(|_| anyhow!("AES-GCM open failed (wrong master password?)"))?; - Ok(Zeroizing::new(plaintext.to_vec())) - } -} - -/// Construct a fresh envelope wrapping a randomly-generated 32-byte data key. Used during -/// `vault setup` to bootstrap the user. The data key stays in memory; only the envelope survives. -pub fn create_envelope(master_password: &str) -> Result { - let salt = random_bytes::(); - let data_key = Zeroizing::new(random_bytes::<32>()); - let envelope = derive_and_seal(master_password, &salt, data_key.as_ref())?; - Ok(UnlockedVault { envelope, data_key }) -} - -/// Re-derive the wrapping key from the master password and use it to unseal the data key. -pub fn unlock(envelope: &VaultEnvelope, master_password: &str) -> Result { - anyhow::ensure!( - envelope.m_cost == ARGON2_M_COST_KIB - && envelope.t_cost == ARGON2_T_COST - && envelope.p_cost == ARGON2_P_COST, - "unsupported vault KDF parameters" - ); - let salt = STANDARD_NO_PAD - .decode(envelope.salt.as_bytes()) - .map_err(|_| anyhow!("vault salt base64 invalid"))?; - if salt.len() != SALT_LEN { - bail!("vault salt has wrong length: {} bytes", salt.len()); - } - let mut salt_array = [0u8; SALT_LEN]; - salt_array.copy_from_slice(&salt); - - let mut wrapping_key = Zeroizing::new([0u8; 32]); - argon2id( - master_password.as_bytes(), - &salt_array, - envelope.m_cost, - envelope.t_cost, - envelope.p_cost, - wrapping_key.as_mut(), - ) - .context("Argon2id derivation failed")?; - - let nonce_bytes = STANDARD_NO_PAD - .decode(envelope.nonce.as_bytes()) - .map_err(|_| anyhow!("vault nonce base64 invalid"))?; - if nonce_bytes.len() != NONCE_LEN { - bail!("vault nonce length is wrong"); - } - let mut nonce_array = [0u8; NONCE_LEN]; - nonce_array.copy_from_slice(&nonce_bytes); - let nonce = Nonce::assume_unique_for_key(nonce_array); - - let key = UnboundKey::new(&AES_256_GCM, wrapping_key.as_ref()) - .map_err(|_| anyhow!("invalid wrapping key"))?; - let mut payload = Zeroizing::new( - STANDARD_NO_PAD - .decode(envelope.ciphertext.as_bytes()) - .map_err(|_| anyhow!("vault ciphertext base64 invalid"))?, - ); - let opened = LessSafeKey::new(key) - .open_in_place(nonce, Aad::empty(), &mut payload) - .map_err(|_| anyhow!("vault decryption failed (wrong master password?)"))?; - let data_key_bytes = Zeroizing::new(opened.to_vec()); - if data_key_bytes.len() != 32 { - bail!("vault decrypted data key has wrong length"); - } - let mut array = Zeroizing::new([0u8; 32]); - array.copy_from_slice(&data_key_bytes); - Ok(UnlockedVault { - envelope: envelope.clone(), - data_key: array, - }) -} - -/// Recompute the wrapping key; returns a new envelope that uses fresh nonce + salt. Used after -/// the user changes their master password. -pub fn rewrap(previous_envelope: UnlockedVault, new_password: &str) -> Result { - let salt = random_bytes::(); - let envelope = derive_and_seal(new_password, &salt, previous_envelope.data_key.as_ref())?; - Ok(UnlockedVault { - envelope, - data_key: previous_envelope.data_key.clone(), - }) -} - -fn derive_and_seal( - password: &str, - salt: &[u8; SALT_LEN], - data_key: &[u8], -) -> Result { - let mut wrapping_key = Zeroizing::new([0u8; 32]); - argon2id( - password.as_bytes(), - salt, - ARGON2_M_COST_KIB, - ARGON2_T_COST, - ARGON2_P_COST, - wrapping_key.as_mut(), - ) - .context("Argon2id KDF failed")?; - let mut nonce_bytes = [0u8; NONCE_LEN]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); - let nonce = Nonce::assume_unique_for_key(nonce_bytes); - let mut ciphertext = Zeroizing::new(data_key.to_vec()); - let key = UnboundKey::new(&AES_256_GCM, wrapping_key.as_ref()) - .map_err(|_| anyhow!("invalid wrapping key"))?; - LessSafeKey::new(key) - .seal_in_place_append_tag(nonce, Aad::empty(), &mut *ciphertext) - .map_err(|_| anyhow!("AES-GCM seal of the data key failed"))?; - Ok(VaultEnvelope { - salt: base64_nonce(*salt), - nonce: base64_nonce(nonce_bytes), - ciphertext: STANDARD_NO_PAD.encode(ciphertext.as_slice()), - m_cost: ARGON2_M_COST_KIB, - t_cost: ARGON2_T_COST, - p_cost: ARGON2_P_COST, - }) -} - -/// Randomly generate N bytes using the OS RNG. Wrap the call so callers don't have to import -/// `rand` directly. -fn random_bytes() -> [u8; N] { - let mut out = [0u8; N]; - rand::thread_rng().fill_bytes(&mut out); - out -} - -fn base64_nonce(bytes: [u8; N]) -> String { - STANDARD_NO_PAD.encode(bytes.as_slice()) -} - -/// Argon2id wrapper around `argon2` crate. The master password stays in memory only as long as -/// the KDF is running; afterwards the only remaining copy is the temporary wrapping key. -fn argon2id( - password: &[u8], - salt: &[u8; SALT_LEN], - m_cost: u32, - t_cost: u32, - p_cost: u32, - out: &mut [u8], -) -> Result<()> { - use argon2::{Algorithm, Argon2, Params, Version}; - let params = Params::new(m_cost, t_cost, p_cost, Some(out.len())) - .map_err(|err| anyhow!("invalid Argon2 parameters: {err}"))?; - let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - argon - .hash_password_into(password, salt.as_slice(), out) - .map_err(|err| anyhow!("Argon2id hash failed: {err}"))?; - Ok(()) -} - -/// Wipe the in-memory data key on drop. -impl Drop for UnlockedVault { - fn drop(&mut self) { - self.data_key.zeroize(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn match_token_is_keyed_stable_and_distinct() { - let vault = create_envelope("master").expect("create"); - let token = vault.match_token(b"secret text"); - assert_eq!(token.len(), 64); - assert!(token.chars().all(|c| c.is_ascii_hexdigit())); - // Stable for the same key and content; different content never collides. - assert_eq!(token, vault.match_token(b"secret text")); - assert_ne!(token, vault.match_token(b"secret texu")); - // And only this vault's key can reproduce it: a second vault proves the token is keyed, - // not a plain content hash. - let other = create_envelope("master").expect("create"); - assert_ne!(token, other.match_token(b"secret text")); - } - - #[test] - fn create_then_unlock_round_trips() { - let vault = create_envelope("correct horse battery staple").expect("create"); - let envelope = vault.envelope().clone(); - - // Dropping the original drops the data key. - drop(vault); - - let unlocked = unlock(&envelope, "correct horse battery staple").expect("unlock"); - assert_eq!(unlocked.data_key.len(), 32); - } - - #[test] - fn wrong_password_fails_to_unlock() { - let vault = create_envelope("hunter2").expect("create"); - let envelope = vault.envelope().clone(); - drop(vault); - let err = unlock(&envelope, "hunter3").unwrap_err(); - assert!(format!("{err:#}").contains("wrong master password")); - } - - #[test] - fn sealing_and_opening_round_trips() { - let vault = create_envelope("open sesame").expect("create"); - let sealed = vault.seal(b"hello clipboard").expect("seal"); - let plaintext = vault.open(&sealed).expect("open"); - assert_eq!(plaintext.as_slice(), b"hello clipboard"); - } - - #[test] - fn unknown_prefix_is_rejected() { - let result = VaultEnvelope::parse("OTHER_VERSION\n{\"salt\": \"aGVsbG8=\"}"); - assert!(result.is_err()); - } - - #[test] - fn rewrap_changes_envelope_but_preserves_data_key() { - let vault = create_envelope("old").expect("create"); - let data_key_copy = *vault.data_key; - let rewrapped = rewrap(vault, "new").expect("rewrap"); - assert_eq!(rewrapped.data_key.as_ref(), &data_key_copy); - - let opened_after_rewrap = rewrapped.seal(b"payload").expect("seal after rewrap"); - let read = rewrapped - .open(&opened_after_rewrap) - .expect("open after rewrap"); - assert_eq!(read.as_slice(), b"payload"); - - // Old password no longer accepts the new envelope. - assert!(unlock(rewrapped.envelope(), "old").is_err()); - } -} - -/// Validate structure before storing ciphertext. Authentication still requires the vault key. -pub fn validate_sealed(sealed: &str) -> Result<()> { - let (nonce, payload) = sealed - .split_once(':') - .context("sealed payload missing nonce")?; - let nonce = STANDARD_NO_PAD - .decode(nonce) - .context("invalid sealed nonce")?; - let payload = Zeroizing::new( - STANDARD_NO_PAD - .decode(payload) - .context("invalid sealed ciphertext")?, - ); - anyhow::ensure!( - nonce.len() == NONCE_LEN && payload.len() >= AES_256_GCM.tag_len(), - "invalid sealed payload lengths" - ); - Ok(()) -} - -#[cfg(test)] -mod safety_regressions { - use super::*; - #[test] - fn malformed_envelope_diagnostics_do_not_echo_clipboard_content() { - let error = VaultEnvelope::parse("private clipboard content").unwrap_err(); - assert!(!format!("{error:#}").contains("private clipboard content")); - } - #[test] - fn untrusted_kdf_cost_is_rejected_before_allocating() { - let envelope = VaultEnvelope { - salt: base64_nonce([0u8; SALT_LEN]), - nonce: String::new(), - ciphertext: String::new(), - m_cost: u32::MAX, - t_cost: u32::MAX, - p_cost: 2, - }; - assert!( - unlock(&envelope, "password") - .unwrap_err() - .to_string() - .contains("unsupported") - ); - } -} diff --git a/src/clipboard/vault_controller.rs b/src/clipboard/vault_controller.rs deleted file mode 100644 index 9a3df04..0000000 --- a/src/clipboard/vault_controller.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Persistent master-password vault plus in-memory unlock state. -//! -//! This is the controller GUI and CLI callers reach for when deciding whether a clipboard -//! row can be exposed. It is **not** the vault itself; the wrapping/unwrapping math lives in -//! [`super::vault`]. The controller owns: -//! -//! 1. The on-disk envelope in the `clipboard_vault` table. -//! 2. The current `UnlockedVault` kept in memory while the user is at a recent paste target. -//! 3. The scoped unlock deadline used by every cryptographic operation. -//! -//! The controller never logs the password or the data key. Failed unlocks are counted so the -//! caller can back off when needed (clipboard manager out of scope; the auto-clear happens after -//! `active_until_unix_ms`). - -use std::sync::Arc; -use std::sync::atomic::{AtomicI64, Ordering}; - -use anyhow::{Result, anyhow}; -use zeroize::Zeroizing; - -use super::storage::ClipboardHistoryStore; -use super::vault::{UnlockedVault, VaultEnvelope, create_envelope, rewrap, unlock}; - -/// Default idle lifetime: 5 minutes from the last unlock, in milliseconds. Pastes of protected -/// rows are restricted to this window. -pub const DEFAULT_ACTIVE_LIFETIME_MS: i64 = 5 * 60 * 1000; - -/// Manages a persistent master-password vault. -/// -/// All really sensitive material flows through [`Zeroizing`] buffers; the controller does not own -/// the data key beyond the scoped unlock lifetime. -pub struct VaultController { - store: Arc, - operations: futures::lock::Mutex<()>, - generation: AtomicI64, - /// Unix-ms deadline past which the next paste target sees the vault as locked. - active_until_unix_ms: AtomicI64, - /// Cached unlocked vault; cleared on lock. - unlocked: std::sync::Mutex>, - /// Counter for failed unlock attempts to power back-off logic. Public for the GUI modal. - failed_attempts: AtomicI64, -} - -/// Ciphertext and the exact cached vault configuration used to seal it. No plaintext or key -/// is exported. Pass both fields to generation-bound storage in the same operation. -pub struct SealedClipboardPayload { - pub ciphertext: String, - pub vault_envelope: String, -} - -impl std::fmt::Debug for SealedClipboardPayload { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SealedClipboardPayload") - .field("ciphertext", &"[redacted]") - .field("vault_envelope", &"[redacted]") - .finish() - } -} -/// Permanent vault configuration; either we have one or we don't. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VaultStatus { - pub has_master_password: bool, - pub failed_attempts: i64, -} - -/// Lightweight snapshot of vault state, used by the GUI modal to decide between Create and Unlock. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VaultState { - /// No envelope yet; the user must create one. - Unconfigured, - /// Envelope exists but is locked. - Locked, - /// Envelope is unlocked and a data key is available. - Unlocked { failed_attempts: i64 }, -} - -impl VaultState { - pub fn needs_password(&self) -> bool { - matches!(self, VaultState::Locked) - } -} - -impl VaultController { - pub fn new(store: Arc) -> Self { - Self { - store, - operations: futures::lock::Mutex::new(()), - generation: AtomicI64::new(0), - active_until_unix_ms: AtomicI64::new(0), - unlocked: std::sync::Mutex::new(None), - failed_attempts: AtomicI64::new(0), - } - } - - /// Read the on-disk envelope. Returns `Ok(None)` if no master password has been configured - /// yet. - pub async fn envelope(&self) -> Result> { - match self.store.read_vault_envelope().await? { - Some(text) => Ok(Some(VaultEnvelope::parse(&text)?)), - None => Ok(None), - } - } - - /// First-time bootstrap. Storage refuses replacement of an existing vault. Never orphan - /// existing protected items by generating a replacement data key. - pub async fn create(&self, password: &str) -> Result<()> { - let _operation = self.operations.lock().await; - let unlocked = create_envelope(password)?; - let envelope = unlocked.envelope().clone(); - let serialized = VaultEnvelope::serialize_string(&envelope)?; - self.store.write_vault_envelope(&serialized, 0).await?; - self.lock(); - Ok(()) - } - - /// Attempt to unlock with the user-supplied password. Returns `Ok(true)` on success and - /// `Ok(false)` on a wrong password (so the GUI modal can decide between back-off and a - /// "wrong password" message). - pub async fn unlock(&self, password: &str) -> Result { - let _operation = self.operations.lock().await; - let generation = self.generation.load(Ordering::SeqCst); - let Some(text) = self.store.read_vault_envelope().await? else { - return Err(anyhow!( - "No master password is configured yet; call create() first" - )); - }; - let envelope = VaultEnvelope::parse(&text)?; - match unlock(&envelope, password) { - Ok(unlocked) => { - let deadline = crate::utils::time::now_unix_millis() + DEFAULT_ACTIVE_LIFETIME_MS; - let mut guard = self - .unlocked - .lock() - .map_err(|_| anyhow!("vault mutex poisoned"))?; - anyhow::ensure!( - generation == self.generation.load(Ordering::SeqCst), - "vault locked during unlock; try again" - ); - self.active_until_unix_ms.store(deadline, Ordering::SeqCst); - *guard = Some(unlocked); - self.failed_attempts.store(0, Ordering::SeqCst); - Ok(true) - } - Err(_) => { - let _ = self.failed_attempts.fetch_add(1, Ordering::SeqCst); - Ok(false) - } - } - } - - /// Re-wrap the data key using a new password. Existing protected-row ciphertexts stay - /// decryptable because they are sealed with the data key, not the password directly. - pub async fn rewrap(&self, password: &str) -> Result<()> { - let _operation = self.operations.lock().await; - let current = { - let guard = self - .unlocked - .lock() - .map_err(|_| anyhow!("vault mutex poisoned"))?; - guard - .as_ref() - .ok_or_else(|| anyhow!("rewrap requires the vault to be unlocked"))? - .clone() - }; - anyhow::ensure!(self.is_unlocked(), "rewrap requires an unexpired unlock"); - let expected = current.envelope().serialize()?; - let rewritten = rewrap(current, password)?; - let serialized = VaultEnvelope::serialize_string(rewritten.envelope())?; - self.store - .compare_exchange_vault_envelope( - &expected, - &serialized, - self.active_until_unix_ms.load(Ordering::SeqCst), - ) - .await?; - self.lock(); - Ok(()) - } - - /// Lock the vault immediately; subsequent calls that ask whether it is `is_unlocked` will - /// return false. - pub fn lock(&self) { - self.generation.fetch_add(1, Ordering::SeqCst); - if let Ok(mut guard) = self.unlocked.lock() { - *guard = None; - } - self.active_until_unix_ms.store(0, Ordering::SeqCst); - } - - /// True only when the dedicated deadline has not elapsed and the in-memory key is still - /// cached. The caller should normally rely on [`paste_window`] instead. - pub fn is_unlocked(&self) -> bool { - let Ok(mut guard) = self.unlocked.lock() else { - return false; - }; - if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) - { - *guard = None; - return false; - } - guard.is_some() - } - /// Comment-friendly alias for the GUI/CLI who want to spell out the relationship. - pub fn paste_window(&self) -> bool { - self.is_unlocked() - } - - /// Number of consecutive failed unlock attempts since the last successful unlock. The GUI - /// uses this to suggest a back-off after a certain number of failed attempts. - pub fn failed_attempts(&self) -> i64 { - self.failed_attempts.load(Ordering::SeqCst) - } - - /// Single-flight snapshot that drives the GUI modal's "create vs. unlock" decision and lets - /// the editor refuse lock toggles when the vault is locked. - pub async fn snapshot(&self) -> VaultState { - match self.store.read_vault_envelope().await { - Ok(Some(text)) if !text.is_empty() => { - if self.is_unlocked() { - VaultState::Unlocked { - failed_attempts: self.failed_attempts(), - } - } else { - VaultState::Locked - } - } - Ok(None) => VaultState::Unconfigured, - _ => VaultState::Locked, - } - } - - /// Remove the master password only after all encrypted history has been removed. - /// Storage checks this in a write transaction, including concurrent protected inserts. - /// The vault key is preserved if disabling would strand protected data. - pub async fn disable(&self) -> Result<()> { - let _operation = self.operations.lock().await; - self.store.clear_vault_envelope().await?; - self.lock(); - Ok(()) - } - - pub fn seal(&self, plaintext: &[u8]) -> Result> { - let mut guard = self - .unlocked - .lock() - .map_err(|_| anyhow!("vault mutex poisoned"))?; - if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) - { - *guard = None; - return Ok(None); - } - let Some(unlocked) = guard.as_ref() else { - return Ok(None); - }; - Ok(Some(unlocked.seal(plaintext)?)) - } - - /// Capture ciphertext and its vault generation under the same key lock. Never pair `seal` - /// with a later `envelope()` read: another controller may reset the vault between those calls. - pub fn seal_for_storage(&self, plaintext: &[u8]) -> Result> { - let mut guard = self - .unlocked - .lock() - .map_err(|_| anyhow!("vault mutex poisoned"))?; - if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) - { - *guard = None; - return Ok(None); - } - let Some(unlocked) = guard.as_ref() else { - return Ok(None); - }; - Ok(Some(SealedClipboardPayload { - ciphertext: unlocked.seal(plaintext)?, - vault_envelope: unlocked.envelope().serialize()?, - })) - } - - /// Keyed match token for authorized recapture matching ([`UnlockedVault::match_token`] / - /// [`SqliteClipboardStore::upsert_ordinary`]). Returns `Ok(None)` when locked or expired, - /// exactly like [`seal`]: an unattended caller treats the absence of a token as - /// "not authorized" and fails closed. - /// - /// [`SqliteClipboardStore::upsert_ordinary`]: crate::clipboard::storage::SqliteClipboardStore::upsert_ordinary - pub fn match_token(&self, plaintext: &[u8]) -> Result> { - let mut guard = self - .unlocked - .lock() - .map_err(|_| anyhow!("vault mutex poisoned"))?; - if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) - { - *guard = None; - return Ok(None); - } - let Some(unlocked) = guard.as_ref() else { - return Ok(None); - }; - Ok(Some(unlocked.match_token(plaintext))) - } - - /// Inverse of [`seal`]. Returns `Ok(None)` when the vault is currently locked so callers can - /// distinguish *sealed-but-unavailable* from *sealed-and-decryptable*. - pub fn open(&self, envelope: &str) -> Result>>> { - let mut guard = self - .unlocked - .lock() - .map_err(|_| anyhow!("vault mutex poisoned"))?; - if crate::utils::time::now_unix_millis() >= self.active_until_unix_ms.load(Ordering::SeqCst) - { - *guard = None; - return Ok(None); - } - let Some(unlocked) = guard.as_ref() else { - return Ok(None); - }; - Ok(Some(unlocked.open(envelope)?)) - } -} - -/// Bundle the vault controller with the chooser's needs. The chooser only ever checks -/// `is_unlocked`; we expose it through this lightweight struct so we don't have to plumb the -/// storage trait around. -pub struct VaultHandle { - pub controller: Arc, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::clipboard::storage::SqliteClipboardStore; - - static PROCESS_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - - async fn setup() -> ( - Arc, - std::path::PathBuf, - Arc, - ) { - let path = std::env::temp_dir().join(format!( - "rustickers-vault-controller-{}-{}.db", - crate::utils::time::now_unix_millis(), - PROCESS_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - let store: Arc = - Arc::new(SqliteClipboardStore::open(&path).await.unwrap()); - let controller = Arc::new(VaultController::new(store.clone())); - (store, path, controller) - } - #[test] - fn create_unlock_lock_round_trip() { - use futures::executor::block_on; - let (_store, path, controller) = block_on(setup()); - - block_on(controller.create("hunter2-correct-horse")).expect("create vault"); - assert!(block_on(controller.envelope()).unwrap().is_some()); - assert!( - !controller.is_unlocked(), - "vault should not be unlocked right after create" - ); - - let good = block_on(controller.unlock("hunter2-correct-horse")).expect("unlock ok"); - assert!(good, "correct password should unlock"); - assert!(controller.is_unlocked()); - - controller.lock(); - assert!(!controller.is_unlocked()); - - let bad = block_on(controller.unlock("nope")).expect("unlock ok"); - assert!(!bad, "wrong password must not unlock"); - assert_eq!(controller.failed_attempts(), 1); - - block_on(controller.disable()).expect("disable vault"); - assert!(block_on(controller.envelope()).unwrap().is_none()); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn seal_and_open_consume_vault_state() { - use futures::executor::block_on; - let (_store, path, controller) = block_on(setup()); - block_on(controller.create("hunter2")).expect("create"); - assert!(matches!(controller.seal(b"hello"), Ok(None))); - assert!(block_on(controller.unlock("hunter2")).unwrap()); - let envelope = controller.seal(b"hello").unwrap().expect("seal ok"); - let plain = controller.open(&envelope).unwrap().expect("open ok"); - assert_eq!(&*plain, b"hello"); - controller.lock(); - assert!(controller.open(&envelope).unwrap().is_none()); - block_on(controller.disable()).unwrap(); - let _ = std::fs::remove_file(path); - } - - #[test] - fn snapshot_drives_create_vs_unlock() { - use futures::executor::block_on; - let (_store, path, controller) = block_on(setup()); - // Fresh store: Unconfigured. - match block_on(controller.snapshot()) { - crate::clipboard::vault_controller::VaultState::Unconfigured => {} - other => panic!("expected unconfigured, got {other:?}"), - } - block_on(controller.create("hunter2")).unwrap(); - match block_on(controller.snapshot()) { - crate::clipboard::vault_controller::VaultState::Locked => {} - other => panic!("expected locked after create, got {other:?}"), - } - assert!(block_on(controller.unlock("hunter2")).unwrap()); - match block_on(controller.snapshot()) { - crate::clipboard::vault_controller::VaultState::Unlocked { .. } => {} - other => panic!("expected unlocked, got {other:?}"), - } - block_on(controller.disable()).unwrap(); - match block_on(controller.snapshot()) { - crate::clipboard::vault_controller::VaultState::Unconfigured => {} - other => panic!("expected unconfigured after disable, got {other:?}"), - } - let _ = std::fs::remove_file(path); - } -} - -#[cfg(test)] -mod safety_regressions { - use super::*; - use crate::clipboard::storage::SqliteClipboardStore; - use futures::executor::block_on; - - #[test] - fn expired_unlock_cannot_open_seal_or_rewrap() { - let tmp = tempfile::tempdir().unwrap(); - let store = - Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); - let controller = VaultController::new(store); - block_on(controller.create("password")).unwrap(); - assert!(block_on(controller.unlock("password")).unwrap()); - let sealed = controller.seal(b"secret").unwrap().unwrap(); - controller.active_until_unix_ms.store(1, Ordering::SeqCst); - assert!(!controller.is_unlocked()); - assert!(controller.open(&sealed).unwrap().is_none()); - assert!(controller.seal(b"secret").unwrap().is_none()); - assert!(block_on(controller.rewrap("new")).is_err()); - } -} diff --git a/src/lib.rs b/src/lib.rs index f194ef8..c714558 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ pub mod model; #[cfg(feature = "ui")] pub mod native; pub mod storage; +#[cfg(feature = "storage")] +pub mod text; pub mod utils; // `--feature ui` brings in `gpui-kit` (the Kit facade). Existing modules diff --git a/src/native/clipboard/watcher.rs b/src/native/clipboard/watcher.rs index 0cbcedd..3fdd1d3 100644 --- a/src/native/clipboard/watcher.rs +++ b/src/native/clipboard/watcher.rs @@ -14,23 +14,14 @@ //! how the existing selection-capture helper ([`crate::native::selection`]) avoids polluting the //! history with the temporary marker it sets on the clipboard. //! -//! # Idle cost -//! -//! The watcher's gate reads [`ClipboardHistoryStore::has_protected_items`] -- a single -//! `SELECT EXISTS` -- rather than fetching the full [`ClipboardItem`] vec every tick. The -//! full payload is only read from a single call site: when the watcher itself persists a -//! newly captured row, and from the GUI chooser when it has been signalled that the visible -//! state is stale. - use std::collections::VecDeque; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::clipboard::items::is_collectable; use crate::clipboard::settings::ClipboardSettings; -use crate::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore, UpsertOutcome}; +use crate::text::TextRepository; /// Per-capture flag — text the system clipboard just received. #[derive(Debug, Clone)] @@ -132,57 +123,21 @@ impl LastSeenText { /// Persist [`CapturedText`] into the provided store. Returns the row id and outcome so the caller /// can update the GUI's chooser / settings in real time. pub async fn persist_capture( - store: &dyn ClipboardHistoryStore, + store: &TextRepository, captured: &CapturedText, settings: &ClipboardSettings, - authorized_match_token: Option<&str>, ) -> anyhow::Result> { - if !settings.capture_enabled { - return Ok(None); - } - if captured.source != CaptureSource::UserCopy { - return Ok(None); - } - if !is_collectable(&captured.content) { - return Ok(None); - } - let byte_size = captured.byte_size; - let ordinary_byte_limit = settings.normal_byte_limit; - if ordinary_byte_limit > 0 && byte_size > ordinary_byte_limit { - // Single oversize capture: drop entirely rather than half-collecting something the user - // didn't intend and that eviction cannot hope to fit. + if !settings.capture_enabled + || captured.source != CaptureSource::UserCopy + || captured.content.trim().is_empty() + { return Ok(None); } - - let preview = plain_preview(&captured.content); - let digest = SqliteClipboardStore::ordinary_digest(&captured.content); - let now = crate::utils::time::now_unix_millis(); - let outcome = store - .upsert_ordinary(&captured.content, &preview, &digest, byte_size, now, authorized_match_token) - .await?; - let id = match outcome { - UpsertOutcome::Inserted { id } | UpsertOutcome::Updated { id } => id, - }; - // The shared store trims atomically in upsert; never count incoming bytes twice. - Ok(Some(id)) -} - -fn plain_preview(content: &str) -> String { - let mut preview = String::with_capacity(240); - for character in content.chars() { - if character == '\n' || character == '\r' { - preview.push(' '); - } else { - preview.push(character); - } - if preview.len() >= 240 { - break; - } - } - if preview.len() < content.len() { - preview.push('…'); - } - preview + Ok(Some( + store + .capture(&captured.content, crate::utils::time::now_unix_millis()) + .await?, + )) } /// Drives the watcher loop. Designed for parking on a background thread; the GUI owns the @@ -214,46 +169,27 @@ impl WatcherHandle { /// Note: the watcher no longer takes an mpsc Sender of `CapturedText`. The /// previous channel carried content the chooser already has access to, kept /// aliases inside `Arc>>` if the receiver was dropped, - /// and could outlive a vault reset on the way to a never-read endpoint. + /// and could outlive the picker on the way to a never-read endpoint. /// Replacing it with a content-free signal avoids the leak surface and keeps /// the chooser's snapshot read on its own schedule. pub fn spawn( self, filter: Arc, - store: Arc, - vault: Option>, + store: Arc, ) -> std::thread::JoinHandle<()> { std::thread::Builder::new().name("clipboard-watcher".into()).spawn(move || { let mut last_seen = LastSeenText::new(); let mut last_generation = None; let mut active = false; - let mut last_has_protected: Option = None; while !self.stop.load(std::sync::atomic::Ordering::Relaxed) { let tick = (|| -> anyhow::Result<()> { let settings = futures::executor::block_on(store.settings())?; - // Cheap existence query, not the full snapshot. The full - // content-bearing vec is only read once, in this tick's - // capture-save path and from the chooser side, never from - // a poll gate. - let has_protected = futures::executor::block_on(store.has_protected_items())?; - // Protected history only pauses collection while the vault is locked: an - // unlocked vault can produce the authorized match token that keeps recapture - // safe (see SqliteClipboardStore::upsert_ordinary). - let authorized = vault.as_ref().is_some_and(|vault| vault.is_unlocked()); - if !settings.capture_enabled || (has_protected && !authorized) { + if !settings.capture_enabled { + if active { push_watcher_signal(WatcherSignal::CapturePaused); } active = false; - // Notify only on transitions: emitting the pause signal - // every tick would force the chooser to refresh - // unnecessarily. - if last_has_protected != Some(has_protected) { - last_has_protected = Some(has_protected); - push_watcher_signal(WatcherSignal::CapturePaused); - } - set_capture_status(if has_protected { "Collection paused: unlock the vault to keep collecting alongside protected history." } - else { "Collection paused. Enable to capture copied text (including secrets)." }); + set_capture_status("Collection paused. Enable to capture copied text (including secrets)."); return Ok(()); } - last_has_protected = Some(has_protected); if !active { last_seen = LastSeenText::new(); last_generation = None; active = true; } let transaction = filter.transaction(); let before = clipboard_generation(); @@ -276,21 +212,7 @@ impl WatcherHandle { set_capture_status("Capture skipped: text exceeds 1 MiB; nothing was truncated."); return Ok(()); } let captured = CapturedText { byte_size: text.len() as i64, content: text, source: CaptureSource::UserCopy }; - // Capture read happens only on the persist path, - // which already pays for one snapshot's worth of - // writes. Update the returned id when persist_capture - // exposes it; see the WatcherSignal::Captured - // arm below for the queueing pattern. - // With protected rows present the write must carry the keyed token; - // the vault was verified unlocked at the gate above. - let token = if has_protected { - vault.as_ref().and_then(|vault| vault.match_token(captured.content.as_bytes()).ok().flatten()) - } else { None }; - if has_protected && token.is_none() { - set_capture_status("Collection paused: the vault lock expired mid-copy; unlock it to keep collecting."); - return Ok(()); - } - futures::executor::block_on(persist_capture(&*store, &captured, &settings, token.as_deref()))?; + futures::executor::block_on(persist_capture(&store, &captured, &settings))?; push_watcher_signal(WatcherSignal::Captured); } } else { last_seen = LastSeenText::new(); } @@ -567,13 +489,6 @@ mod tests { assert!(last.accept("second")); } - #[test] - fn preview_collapses_newlines_and_truncates() { - let preview = plain_preview(&"a".repeat(300)); - assert!(preview.ends_with('…')); - assert_eq!(preview.chars().filter(|c| *c == '\n').count(), 0); - } - #[test] fn internal_write_marker_drains_on_consume() { let filter = CaptureFilter::new(); diff --git a/src/native/components/stickers/content_lock.rs b/src/native/components/stickers/content_lock.rs deleted file mode 100644 index ca205a6..0000000 --- a/src/native/components/stickers/content_lock.rs +++ /dev/null @@ -1,532 +0,0 @@ -use std::{num::NonZeroU32, time::Duration}; - -use crate::gpui_component::{ - ActiveTheme, Disableable, StyledExt, - button::Button, - h_flex, - input::{Input, InputState}, - v_flex, -}; -use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; -use gpui::{ - AnyElement, Context, Entity, Focusable, KeyDownEvent, Window, WindowControlArea, div, - prelude::*, px, -}; -use ring::{ - aead::{self, Aad, LessSafeKey, Nonce, UnboundKey}, - pbkdf2, - rand::{SecureRandom, SystemRandom}, -}; -use serde::{Deserialize, Serialize}; -use zeroize::Zeroizing; - -pub(super) const LOCKED_CONTENT_PREFIX: &str = "RUSTICKERS_LOCKED_V1"; -pub(crate) const FOCUS_LOSS_RELOCK_DELAY: Duration = Duration::from_secs(30); -const PBKDF2_ITERATIONS: u32 = 210_000; -const SALT_LEN: usize = 16; -const NONCE_LEN: usize = 12; -const KEY_LEN: usize = 32; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(super) struct LockedContent { - pub title: String, - salt: String, - nonce: String, - ciphertext: String, - iterations: u32, -} - -pub(super) struct PreparedLock { - pub locked: LockedContent, - pub serialized: String, -} - -pub(super) struct PreparedUnlock { - pub content: String, - pub title: String, - pub updated_lock: Option, -} - -pub(super) struct LockForm { - title: Entity, - password: Entity, - confirm: Entity, -} - -impl LockForm { - pub(super) fn new( - initial_title: String, - password_placeholder: &'static str, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - title: cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Sticker title") - .default_value(initial_title) - }), - password: cx.new(|cx| { - InputState::new(window, cx) - .masked(true) - .placeholder(password_placeholder) - }), - confirm: cx.new(|cx| { - InputState::new(window, cx) - .masked(true) - .placeholder("Confirm password") - }), - } - } - - pub(super) fn reset_for_lock( - &self, - title: String, - window: &mut Window, - cx: &mut Context, - ) { - self.title - .update(cx, |input, cx| input.set_value(title, window, cx)); - self.clear_passwords(window, cx); - self.focus_title(window, cx); - } - - pub(super) fn set_title(&self, title: String, window: &mut Window, cx: &mut Context) { - self.title - .update(cx, |input, cx| input.set_value(title, window, cx)); - } - - pub(super) fn clear_passwords(&self, window: &mut Window, cx: &mut Context) { - self.password - .update(cx, |input, cx| input.set_value("", window, cx)); - self.confirm - .update(cx, |input, cx| input.set_value("", window, cx)); - } - - pub(super) fn focus_title(&self, window: &mut Window, cx: &mut Context) { - self.title.update(cx, |input, cx| input.focus(window, cx)); - } - - pub(super) fn focus_password(&self, window: &mut Window, cx: &mut Context) { - self.password - .update(cx, |input, cx| input.focus(window, cx)); - } - - pub(super) fn prepare_lock( - &self, - content: &str, - cx: &Context, - ) -> Result { - let title = self.title.read(cx).value().trim().to_string(); - let password = Zeroizing::new(self.password.read(cx).value().to_string()); - let confirm = Zeroizing::new(self.confirm.read(cx).value().to_string()); - if title.is_empty() { - return Err("Sticker title cannot be empty".to_string()); - } - if password != confirm { - return Err("Passwords do not match".to_string()); - } - prepare_lock(&title, password.as_str(), content) - } - - pub(super) fn prepare_unlock( - &self, - locked: &LockedContent, - cx: &Context, - ) -> Result { - let password = Zeroizing::new(self.password.read(cx).value().to_string()); - let content = locked.decrypt(password.as_str())?; - let requested_title = self.title.read(cx).value().trim().to_string(); - let title = if requested_title.is_empty() { - locked.title.clone() - } else { - requested_title - }; - let updated_lock = if title == locked.title { - None - } else { - Some(prepare_lock(&title, password.as_str(), &content)?) - }; - Ok(PreparedUnlock { - content, - title, - updated_lock, - }) - } - - pub(super) fn prepare_unlock_forever( - &self, - locked: &LockedContent, - fallback_title: impl FnOnce(&str) -> String, - cx: &Context, - ) -> Result { - let password = Zeroizing::new(self.password.read(cx).value().to_string()); - let content = locked.decrypt(password.as_str())?; - let requested_title = self.title.read(cx).value().trim().to_string(); - let title = if requested_title.is_empty() { - fallback_title(&content) - } else { - requested_title - }; - Ok(PreparedUnlock { - content, - title, - updated_lock: None, - }) - } - - pub(super) fn locking_view( - &self, - id_prefix: &'static str, - heading: &'static str, - error: Option<&str>, - busy: bool, - actions: LockActions, - cx: &mut Context, - ) -> AnyElement { - let LockActions { - cancel: on_cancel, - confirm: on_confirm, - } = actions; - let confirm = self.confirm.clone(); - let danger_color = cx.theme().danger; - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .on_key_down(cx.listener(move |this, event: &KeyDownEvent, window, cx| { - let handled = match event.keystroke.key.as_str() { - "escape" => { - on_cancel(this, window, cx); - true - } - "enter" if confirm.read(cx).focus_handle(cx).is_focused(window) => { - on_confirm(this, window, cx); - true - } - _ => false, - }; - if handled { - cx.stop_propagation(); - window.prevent_default(); - } - })) - .child( - v_flex() - .w_full() - .max_w(px(360.0)) - .gap_2() - .items_center() - .child(div().text_lg().font_bold().child(heading)) - .child(Input::new(&self.title).w_full()) - .child(Input::new(&self.password).w_full()) - .child(Input::new(&self.confirm).w_full()) - .when_some(error, |view, error| { - view.child( - div() - .text_sm() - .text_center() - .text_color(danger_color) - .child(error.to_string()), - ) - }) - .child( - h_flex() - .gap_2() - .child( - Button::new(format!("{id_prefix}-cancel-lock")) - .label("Cancel") - .disabled(busy) - .on_click(cx.listener(move |this, _, window, cx| { - on_cancel(this, window, cx); - })), - ) - .when( - error.is_none() && !self.password.read(cx).value().is_empty(), - |view| { - view.child( - Button::new(format!("{id_prefix}-confirm-lock")) - .label("Lock") - .disabled(busy) - .on_click(cx.listener(move |this, _, window, cx| { - on_confirm(this, window, cx); - })), - ) - }, - ), - ), - ) - .into_any_element() - } - - pub(super) fn locked_view( - &self, - id_prefix: &'static str, - error: Option<&str>, - busy: bool, - actions: UnlockActions, - cx: &mut Context, - ) -> AnyElement { - let UnlockActions { - cancel: on_cancel, - unlock: on_unlock, - unlock_forever: on_unlock_forever, - } = actions; - let password = self.password.clone(); - let danger_color = cx.theme().danger; - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .window_control_area(WindowControlArea::Drag) - .on_key_down(cx.listener(move |this, event: &KeyDownEvent, window, cx| { - let handled = match event.keystroke.key.as_str() { - "enter" if password.read(cx).focus_handle(cx).is_focused(window) => { - on_unlock(this, window, cx); - true - } - "escape" => { - on_cancel(this, window, cx); - true - } - _ => false, - }; - if handled { - cx.stop_propagation(); - window.prevent_default(); - } - })) - .child( - v_flex() - .w_full() - .max_w(px(340.0)) - .gap_2() - .items_center() - .child(Input::new(&self.title).w_full()) - .child(Input::new(&self.password).w_full()) - .when(!self.password.read(cx).value().is_empty(), |view| { - view.child( - h_flex() - .gap_2() - .child( - Button::new(format!("{id_prefix}-unlock")) - .label("Unlock") - .disabled(busy) - .on_click(cx.listener(move |this, _, window, cx| { - on_unlock(this, window, cx); - })), - ) - .child( - Button::new(format!("{id_prefix}-unlock-forever")) - .label("Unlock forever") - .tooltip( - "Remove password protection and save as plain text", - ) - .disabled(busy) - .on_click(cx.listener(move |this, _, window, cx| { - on_unlock_forever(this, window, cx); - })), - ), - ) - }) - .when_some(error, |view, error| { - view.child( - div() - .text_sm() - .text_center() - .text_color(danger_color) - .child(error.to_string()), - ) - }), - ) - .into_any_element() - } -} - -/// What the "lock this sticker" form can do to the sticker hosting it. -pub(super) struct LockActions { - pub(super) cancel: fn(&mut T, &mut Window, &mut Context), - pub(super) confirm: fn(&mut T, &mut Window, &mut Context), -} - -/// What the password prompt of a locked sticker can do to the sticker hosting it. -pub(super) struct UnlockActions { - pub(super) cancel: fn(&mut T, &mut Window, &mut Context), - pub(super) unlock: fn(&mut T, &mut Window, &mut Context), - pub(super) unlock_forever: fn(&mut T, &mut Window, &mut Context), -} - -fn prepare_lock(title: &str, password: &str, content: &str) -> Result { - let locked = LockedContent::encrypt(title, password, content)?; - let serialized = locked.serialize()?; - Ok(PreparedLock { locked, serialized }) -} - -impl LockedContent { - pub(super) fn parse(content: &str) -> Result, String> { - let Some(payload) = content.strip_prefix(LOCKED_CONTENT_PREFIX) else { - return Ok(None); - }; - let payload = payload - .strip_prefix('\n') - .ok_or_else(|| "Locked content header is malformed".to_string())?; - let locked: Self = serde_json::from_str(payload) - .map_err(|err| format!("Locked content metadata is invalid: {err}"))?; - locked.validate()?; - Ok(Some(locked)) - } - - pub(super) fn encrypt(title: &str, password: &str, content: &str) -> Result { - if password.is_empty() { - return Err("Password cannot be empty".to_string()); - } - - let title = title.trim(); - let random = SystemRandom::new(); - let mut salt = [0_u8; SALT_LEN]; - let mut nonce = [0_u8; NONCE_LEN]; - random - .fill(&mut salt) - .map_err(|_| "Failed to generate encryption salt".to_string())?; - random - .fill(&mut nonce) - .map_err(|_| "Failed to generate encryption nonce".to_string())?; - - let salt_text = STANDARD_NO_PAD.encode(salt); - let mut ciphertext = Zeroizing::new(content.as_bytes().to_vec()); - let key = encryption_key(password, &salt, PBKDF2_ITERATIONS)?; - key.seal_in_place_append_tag( - Nonce::assume_unique_for_key(nonce), - Aad::from(aad(title, &salt_text, PBKDF2_ITERATIONS).as_bytes()), - &mut *ciphertext, - ) - .map_err(|_| "Failed to encrypt content".to_string())?; - - Ok(Self { - title: title.to_string(), - salt: salt_text, - nonce: STANDARD_NO_PAD.encode(nonce), - ciphertext: STANDARD_NO_PAD.encode(&*ciphertext), - iterations: PBKDF2_ITERATIONS, - }) - } - - pub(super) fn decrypt(&self, password: &str) -> Result { - self.validate()?; - let salt = decode_exact::(&self.salt, "salt")?; - let nonce = decode_exact::(&self.nonce, "nonce")?; - let mut ciphertext = Zeroizing::new( - STANDARD_NO_PAD - .decode(&self.ciphertext) - .map_err(|_| "Locked content ciphertext is invalid".to_string())?, - ); - let key = encryption_key(password, &salt, self.iterations)?; - let plaintext = key - .open_in_place( - Nonce::assume_unique_for_key(nonce), - Aad::from(aad(&self.title, &self.salt, self.iterations).as_bytes()), - &mut ciphertext, - ) - .map_err(|_| "Incorrect password or damaged locked content".to_string())?; - String::from_utf8(plaintext.to_vec()) - .map_err(|_| "Unlocked content is not valid UTF-8 text".to_string()) - } - - pub(super) fn serialize(&self) -> Result { - serde_json::to_string(self) - .map(|payload| format!("{LOCKED_CONTENT_PREFIX}\n{payload}")) - .map_err(|err| format!("Failed to serialize locked content: {err}")) - } - - fn validate(&self) -> Result<(), String> { - if self.iterations != PBKDF2_ITERATIONS { - return Err("Unsupported locked content key settings".to_string()); - } - decode_exact::(&self.salt, "salt")?; - decode_exact::(&self.nonce, "nonce")?; - if self.ciphertext.is_empty() { - return Err("Locked content ciphertext is empty".to_string()); - } - Ok(()) - } -} - -fn aad(title: &str, salt: &str, iterations: u32) -> String { - format!("{LOCKED_CONTENT_PREFIX}\0{title}\0{salt}\0{iterations}") -} - -fn decode_exact(value: &str, field: &str) -> Result<[u8; N], String> { - let decoded = STANDARD_NO_PAD - .decode(value) - .map_err(|_| format!("Locked content {field} is invalid"))?; - decoded - .try_into() - .map_err(|_| format!("Locked content {field} has an invalid length")) -} - -fn encryption_key(password: &str, salt: &[u8], iterations: u32) -> Result { - let mut key_bytes = Zeroizing::new([0_u8; KEY_LEN]); - let iterations = NonZeroU32::new(iterations) - .ok_or_else(|| "Locked content key settings are invalid".to_string())?; - pbkdf2::derive( - pbkdf2::PBKDF2_HMAC_SHA256, - iterations, - salt, - password.as_bytes(), - &mut *key_bytes, - ); - UnboundKey::new(&aead::CHACHA20_POLY1305, &*key_bytes) - .map(LessSafeKey::new) - .map_err(|_| "Failed to initialize content encryption".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encrypted_content_round_trips() { - let locked = LockedContent::encrypt("Private note", "correct horse", "# Secret\nBody") - .expect("encrypt content"); - let serialized = locked.serialize().expect("serialize content"); - let parsed = LockedContent::parse(&serialized) - .expect("parse content") - .expect("locked content"); - - assert_eq!(parsed.title, "Private note"); - assert_eq!( - parsed.decrypt("correct horse").expect("decrypt content"), - "# Secret\nBody" - ); - } - - #[test] - fn wrong_password_does_not_expose_content() { - let locked = - LockedContent::encrypt("Private note", "right", "secret").expect("encrypt content"); - assert!(locked.decrypt("wrong").is_err()); - } - - #[test] - fn plain_content_is_not_detected_as_locked() { - assert!(LockedContent::parse("ordinary text").unwrap().is_none()); - } - - #[test] - fn metadata_tampering_is_detected() { - let mut locked = - LockedContent::encrypt("Original", "password", "secret").expect("encrypt content"); - locked.title = "Changed".to_string(); - assert!(locked.decrypt("password").is_err()); - } - - #[test] - fn title_whitespace_is_normalized_before_authentication() { - let locked = LockedContent::encrypt(" Private note ", "password", "secret") - .expect("encrypt content"); - assert_eq!(locked.title, "Private note"); - assert_eq!(locked.decrypt("password").unwrap(), "secret"); - } -} diff --git a/src/native/components/stickers/file/editor.rs b/src/native/components/stickers/file/editor.rs index 1f39265..c3ad70c 100644 --- a/src/native/components/stickers/file/editor.rs +++ b/src/native/components/stickers/file/editor.rs @@ -6,9 +6,6 @@ use super::preview::FilePreview; impl super::FileSticker { pub(super) fn start_edit(&mut self, window: &mut Window, cx: &mut Context) { - if self.locked_content.is_some() { - return; - } let initial_content = match self .preview .as_ref() diff --git a/src/native/components/stickers/file/mod.rs b/src/native/components/stickers/file/mod.rs index 99a994d..9805fcc 100644 --- a/src/native/components/stickers/file/mod.rs +++ b/src/native/components/stickers/file/mod.rs @@ -30,8 +30,6 @@ use crate::native::windows::StickerWindowEvent; use crate::native::windows::sticker::StickerWindow; use crate::storage::ArcStickerStore; -use super::content_lock::{LockActions, LockForm, LockedContent, UnlockActions}; - pub struct FileSticker { id: i64, color: StickerColor, @@ -49,12 +47,6 @@ pub struct FileSticker { watch_loop_started: bool, watch_pending: Arc, watch_stop: Arc, - locked_content: Option, - locked_path: Option, - content_visible: bool, - locking: bool, - lock_busy: bool, - lock_form: LockForm, } impl FileSticker { @@ -124,7 +116,6 @@ impl FileSticker { cx.observe_keystrokes(move |this, event, window, cx| { if window.window_handle().window_id() == own_window_id && !this.is_persisted() - && !this.lock_interaction_active() && event.keystroke.key == "escape" { if !StickerWindow::try_close(id, cx) { @@ -143,12 +134,6 @@ impl FileSticker { .map(|raw_path| FileSummary::new(raw_path)) .collect(); summaries.sort_by(|a, b| a.name.cmp(&b.name)); - let initial_title = if summaries.len() == 1 { - summaries[0].name.clone() - } else { - "Private file".to_string() - }; - let lock_form = LockForm::new(initial_title, "Password to unlock this file", window, cx); let mut this = Self { id, @@ -167,12 +152,6 @@ impl FileSticker { watch_loop_started: false, watch_pending: Arc::new(AtomicBool::new(false)), watch_stop: Arc::new(AtomicBool::new(false)), - locked_content: None, - locked_path: None, - content_visible: true, - locking: false, - lock_busy: false, - lock_form, }; if this.is_persisted() { @@ -294,163 +273,6 @@ impl FileSticker { })) .into_any_element() } - - fn can_lock(&self) -> bool { - self.locked_content.is_some() - || self - .preview - .as_ref() - .and_then(FilePreview::lockable_content) - .is_some() - } - - fn lock_interaction_active(&self) -> bool { - self.locking || !self.content_visible - } - - fn begin_lock(&mut self, window: &mut Window, cx: &mut Context) { - self.lock_form.reset_for_lock(self.title(), window, cx); - self.locking = true; - self.error = None; - cx.notify(); - } - - fn cancel_lock(&mut self, window: &mut Window, cx: &mut Context) { - self.locking = false; - self.error = None; - self.lock_form.clear_passwords(window, cx); - cx.notify(); - } - - fn cancel_unlock(&mut self, window: &mut Window, cx: &mut Context) { - self.error = None; - self.lock_form.clear_passwords(window, cx); - cx.notify(); - } - - fn visible_lockable_content(&self, cx: &Context) -> Option<(std::path::PathBuf, String)> { - if let Some(editor) = self.preview_editor.as_ref() { - let path = self.preview.as_ref()?.editable_source()?.to_path_buf(); - return Some((path, editor.read(cx).value().to_string())); - } - self.preview - .as_ref()? - .lockable_content() - .map(|(path, content)| (path.to_path_buf(), content.to_string())) - } - - fn lock_new_content(&mut self, window: &mut Window, cx: &mut Context) { - if self.lock_busy { - return; - } - let Some((path, content)) = self.visible_lockable_content(cx) else { - self.error = Some("Only editable text and markdown files can be locked".to_string()); - cx.notify(); - return; - }; - let prepared = match self.lock_form.prepare_lock(&content, cx) { - Ok(prepared) => prepared, - Err(err) => { - self.error = Some(err); - cx.notify(); - return; - } - }; - match std::fs::write(&path, prepared.serialized.as_bytes()) { - Ok(()) => { - self.preview = Some(FilePreview::locked_text( - path.clone(), - prepared.locked.clone(), - )); - self.locked_content = Some(prepared.locked); - self.locked_path = Some(path); - self.content_visible = false; - self.locking = false; - self.preview_editor = None; - self.lock_form.clear_passwords(window, cx); - self.error = None; - } - Err(err) => self.error = Some(format!("Failed to lock file: {err}")), - } - cx.notify(); - } - - fn unlock(&mut self, window: &mut Window, cx: &mut Context) { - let (Some(locked), Some(path)) = (self.locked_content.clone(), self.locked_path.clone()) - else { - return; - }; - let prepared = match self.lock_form.prepare_unlock(&locked, cx) { - Ok(prepared) => prepared, - Err(err) => { - self.error = Some(err); - cx.notify(); - return; - } - }; - if let Some(updated) = prepared.updated_lock { - if let Err(err) = std::fs::write(&path, updated.serialized) { - self.error = Some(format!("Failed to save file title: {err}")); - cx.notify(); - return; - } - self.locked_content = Some(updated.locked); - } - self.preview = Some(FilePreview::unlocked_text(path, prepared.content)); - self.content_visible = true; - self.error = None; - self.lock_form.clear_passwords(window, cx); - cx.notify(); - } - - fn unlock_forever(&mut self, window: &mut Window, cx: &mut Context) { - let (Some(locked), Some(path)) = (self.locked_content.clone(), self.locked_path.clone()) - else { - return; - }; - let prepared = - match self - .lock_form - .prepare_unlock_forever(&locked, |_| locked.title.clone(), cx) - { - Ok(prepared) => prepared, - Err(err) => { - self.error = Some(err); - cx.notify(); - return; - } - }; - match std::fs::write(&path, prepared.content.as_bytes()) { - Ok(()) => { - self.preview = Some(FilePreview::unlocked_text(path, prepared.content)); - self.locked_content = None; - self.locked_path = None; - self.content_visible = true; - self.preview_editor = None; - self.lock_form.clear_passwords(window, cx); - self.error = None; - } - Err(err) => self.error = Some(format!("Failed to remove file lock: {err}")), - } - cx.notify(); - } - - fn relock(&mut self, window: &mut Window, cx: &mut Context) { - self.hide_unlocked_content(window, cx); - } - - fn hide_unlocked_content(&mut self, window: &mut Window, cx: &mut Context) { - if let (Some(path), Some(locked)) = (self.locked_path.clone(), self.locked_content.clone()) - { - self.preview = Some(FilePreview::locked_text(path, locked)); - } - self.content_visible = false; - self.preview_editor = None; - self.lock_form.clear_passwords(window, cx); - self.lock_form.focus_password(window, cx); - self.error = None; - cx.notify(); - } } impl super::Sticker for FileSticker { @@ -489,31 +311,7 @@ impl super::Sticker for FileSticker { } fn suppress_window_escape(&self) -> bool { - self.preview_editor.is_some() || self.lock_interaction_active() - } - - fn protected_content_visible(&self) -> bool { - self.locked_content.is_some() && self.content_visible - } - - fn relock_protected_content(&mut self, window: &mut Window, cx: &mut Context) { - self.hide_unlocked_content(window, cx); - } - - fn handle_lock_shortcut(&mut self, window: &mut Window, cx: &mut Context) -> bool { - if self.lock_busy || self.locking || !self.can_lock() { - return false; - } - if self.locked_content.is_some() { - if self.content_visible { - self.relock(window, cx); - } else { - self.lock_form.focus_password(window, cx); - } - } else { - self.begin_lock(window, cx); - } - true + self.preview_editor.is_some() } fn header_extension(&mut self, cx: &mut Context) -> Option { @@ -526,46 +324,10 @@ impl super::Sticker for FileSticker { } else { controls.child(self.pin_btn(cx)) }; - let controls = if self.can_lock() { - controls.child( - Button::new("file-content-lock") - .icon(if self.locked_content.is_some() && self.content_visible { - IconName::LockOpen - } else { - IconName::LockClosed - }) - .tooltip(if self.locked_content.is_some() && self.content_visible { - "Lock file now (ctrl+l)" - } else if self.locked_content.is_some() { - "File is locked" - } else { - "Protect file with a password (ctrl+l)" - }) - .disabled(self.lock_busy || self.locking) - .ghost() - .occlude() - .on_click(cx.listener(|this, _, window, cx| { - if this.locked_content.is_some() { - if this.content_visible { - this.relock(window, cx); - } else { - this.lock_form.focus_password(window, cx); - } - } else { - this.begin_lock(window, cx); - } - })), - ) - } else { - controls - }; Some(controls.child(self.refresh_btn(cx)).into_any_element()) } fn footer_extension(&mut self, cx: &mut Context) -> Option { - if !self.content_visible || self.locking || self.locked_content.is_some() { - return None; - } if self.preview_editor.is_some() { return Some( h_flex() @@ -585,10 +347,7 @@ impl super::Sticker for FileSticker { } fn is_footer_absoute(&self) -> bool { - match self.preview.as_ref() { - Some(FilePreview::WebView { .. }) => false, - _ => true, - } + !matches!(self.preview.as_ref(), Some(FilePreview::WebView { .. })) } } @@ -598,34 +357,6 @@ impl Render for FileSticker { self.ensure_watch_loop(window, cx); - if self.locking { - return self.lock_form.locking_view( - "file", - "Lock file", - self.error.as_deref(), - self.lock_busy, - LockActions { - cancel: Self::cancel_lock, - confirm: Self::lock_new_content, - }, - cx, - ); - } - - if !self.content_visible { - return self.lock_form.locked_view( - "file", - self.error.as_deref(), - self.lock_busy, - UnlockActions { - cancel: Self::cancel_unlock, - unlock: Self::unlock, - unlock_forever: Self::unlock_forever, - }, - cx, - ); - } - let bg_color = Rgba { a: 0.85, ..self.color.bg() diff --git a/src/native/components/stickers/file/preview.rs b/src/native/components/stickers/file/preview.rs index 9f2e182..efe6aed 100644 --- a/src/native/components/stickers/file/preview.rs +++ b/src/native/components/stickers/file/preview.rs @@ -8,8 +8,6 @@ use std::sync::Arc; use crate::native::components::webview::SimpleWebView; -use super::super::content_lock::LockedContent; - const MAX_TEXT_PREVIEW_BYTES: usize = 1024 * 1024 * 25; // 25 MB pub(super) enum FilePreview { @@ -29,10 +27,6 @@ pub(super) enum FilePreview { language: String, editable: bool, }, - LockedText { - source_path: PathBuf, - content: LockedContent, - }, Image(Arc), WebView(Entity), Audio { @@ -97,7 +91,11 @@ impl FilePreview { let content = content_result.map_err(|err| format!("Failed to read markdown preview: {err}"))?; return match content { - Some(content) => preview_text(path, content, editable, true).map(Some), + Some(content) => Ok(Some(FilePreview::Markdown { + source_path: path.to_path_buf(), + content, + editable, + })), None => Ok(None), }; } @@ -106,20 +104,13 @@ impl FilePreview { let language = crate::utils::file::markdown_language_for_ext(ext); return crate::utils::file::read_text_full(path) .map_err(|err| format!("Failed to read code preview: {err}")) - .and_then(|content| { - if let Some(locked) = LockedContent::parse(&content)? { - return Ok(Some(FilePreview::LockedText { - source_path: path.to_path_buf(), - content: locked, - })); - } - - Ok(Some(FilePreview::Code { + .map(|content| { + Some(FilePreview::Code { source_path: path.to_path_buf(), content, language: language.to_string(), editable: true, - })) + }) }); } @@ -128,12 +119,6 @@ impl FilePreview { content_result.map_err(|err| format!("Failed to read text preview: {err}"))?; match content { Some(content) => { - if let Some(locked) = LockedContent::parse(&content)? { - return Ok(Some(FilePreview::LockedText { - source_path: path.to_path_buf(), - content: locked, - })); - } let preview_content = crate::utils::file::format_terminal_output_for_preview(&content); Ok(Some(FilePreview::Text { @@ -203,101 +188,6 @@ impl FilePreview { _ => {} } } - - pub(super) fn lockable_content(&self) -> Option<(&Path, &str)> { - match self { - Self::Markdown { - source_path, - content, - editable: true, - } - | Self::Text { - source_path, - content, - editable: true, - } - | Self::Code { - source_path, - content, - editable: true, - .. - } => Some((source_path.as_path(), content.as_str())), - _ => None, - } - } - - pub(super) fn locked_content(&self) -> Option<(&Path, &LockedContent)> { - match self { - Self::LockedText { - source_path, - content, - } => Some((source_path.as_path(), content)), - _ => None, - } - } - - pub(super) fn unlocked_text(source_path: PathBuf, content: String) -> Self { - let ext = source_path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - if crate::utils::file::is_markdown_ext(&ext) { - Self::Markdown { - source_path, - content, - editable: true, - } - } else if crate::utils::file::is_code_ext(&ext) { - let language = crate::utils::file::markdown_language_for_ext(&ext).to_string(); - Self::Code { - source_path, - content, - language, - editable: true, - } - } else { - Self::Text { - source_path, - content, - editable: true, - } - } - } - - pub(super) fn locked_text(source_path: PathBuf, content: LockedContent) -> Self { - Self::LockedText { - source_path, - content, - } - } -} - -fn preview_text( - path: &Path, - content: String, - editable: bool, - markdown: bool, -) -> Result { - if let Some(locked) = LockedContent::parse(&content)? { - return Ok(FilePreview::LockedText { - source_path: path.to_path_buf(), - content: locked, - }); - } - if markdown { - Ok(FilePreview::Markdown { - source_path: path.to_path_buf(), - content, - editable, - }) - } else { - Ok(FilePreview::Text { - source_path: path.to_path_buf(), - content, - editable, - }) - } } impl super::FileSticker { @@ -321,28 +211,7 @@ impl super::FileSticker { match FilePreview::new(source.as_str(), bg, window, cx) { Ok(preview) => { this.preview_editor = None; - if let Some((source_path, locked)) = preview - .as_ref() - .and_then(FilePreview::locked_content) - .map(|(path, locked)| (path.to_path_buf(), locked.clone())) - { - let keep_unlocked = this.content_visible - && this.locked_content.as_ref() == Some(&locked); - this.locked_content = Some(locked.clone()); - this.locked_path = Some(source_path.clone()); - if !keep_unlocked { - this.preview = preview; - this.content_visible = false; - this.lock_form.set_title(locked.title, window, cx); - this.lock_form.clear_passwords(window, cx); - this.lock_form.focus_password(window, cx); - } - } else { - this.preview = preview; - this.locked_content = None; - this.locked_path = None; - this.content_visible = true; - } + this.preview = preview; if let Some(FilePreview::Audio { source_path, .. }) = this.preview.as_ref() { @@ -367,7 +236,7 @@ impl super::FileSticker { window: &mut Window, cx: &mut Context, ) { - if event.click_count >= 2 && self.locked_content.is_none() { + if event.click_count >= 2 { self.start_edit(window, cx); } } @@ -448,7 +317,6 @@ impl super::FileSticker { .scrollable(true), ) .into_any_element(), - Some(FilePreview::LockedText { .. }) => div().size_full().into_any_element(), Some(FilePreview::Image(image)) => div() .size_full() .child( @@ -502,7 +370,7 @@ mod tests { use std::path::{Path, PathBuf}; #[test] - fn editable_code_is_lockable() { + fn editable_code_exposes_source_content_and_language() { let preview = FilePreview::Code { source_path: PathBuf::from("example.rs"), content: "fn main() {}".to_string(), @@ -510,16 +378,55 @@ mod tests { editable: true, }; - let (path, content) = preview.lockable_content().expect("code should be lockable"); + assert_eq!(preview.editable_source(), Some(Path::new("example.rs"))); + assert_eq!(preview.editable_content(), Some("fn main() {}")); + assert_eq!(preview.code_language(), Some("rust")); + } + + #[test] + fn truncated_text_is_not_editable() { + let preview = FilePreview::Text { + source_path: PathBuf::from("large.txt"), + content: "preview only".to_string(), + editable: false, + }; + + assert!(preview.editable_source().is_none()); + assert!(preview.editable_content().is_none()); + assert!(preview.code_language().is_none()); + } + + #[test] + fn markdown_and_text_remain_editable_after_replacement() { + for mut preview in [ + FilePreview::Markdown { + source_path: PathBuf::from("note.md"), + content: "old".to_string(), + editable: true, + }, + FilePreview::Text { + source_path: PathBuf::from("note.txt"), + content: "old".to_string(), + editable: true, + }, + ] { + let source = preview.editable_source().unwrap().to_path_buf(); + preview.replace_content("new\r\ntext".to_string()); - assert_eq!(path, Path::new("example.rs")); - assert_eq!(content, "fn main() {}"); + assert_eq!(preview.editable_source(), Some(source.as_path())); + assert_eq!(preview.editable_content(), Some("new\r\ntext")); + } } #[test] - fn unlocked_code_restores_code_preview() { - let preview = - FilePreview::unlocked_text(PathBuf::from("example.rs"), "fn main() {}".to_string()); + fn replacing_code_content_preserves_code_preview() { + let mut preview = FilePreview::Code { + source_path: PathBuf::from("example.rs"), + content: "fn old() {}".to_string(), + language: "rust".to_string(), + editable: true, + }; + preview.replace_content("fn main() {}".to_string()); match preview { FilePreview::Code { @@ -532,7 +439,7 @@ mod tests { assert_eq!(language, "rust"); assert!(editable); } - _ => panic!("unlocked Rust source should remain a code preview"), + _ => panic!("edited Rust source should remain a code preview"), } } } diff --git a/src/native/components/stickers/markdown.rs b/src/native/components/stickers/markdown.rs index eb0b1d0..cab66e7 100644 --- a/src/native/components/stickers/markdown.rs +++ b/src/native/components/stickers/markdown.rs @@ -1,23 +1,26 @@ -use crate::gpui_component::text::TextView; -use crate::gpui_component::{ActiveTheme, Disableable, h_flex}; use crate::gpui_component::{ + ActiveTheme, Disableable, button::{Button, ButtonVariants}, - input::{Textarea, TextareaState}, + h_flex, + input::{InputEvent, Textarea, TextareaState}, + text::TextView, v_flex, }; +use crate::{ + model::content::FileStickerContent, + model::sticker::{StickerColor, StickerDetail, StickerState, StickerType}, + native::{ + components::IconName, + windows::{StickerWindowEvent, sticker::StickerWindow, text_password}, + }, + storage::ArcStickerStore, + text::{TextEntry, TextEntryId, protection}, +}; use gpui::{ - Context, Entity, Focusable, KeyDownEvent, MouseButton, MouseDownEvent, Window, div, prelude::*, - px, + Context, Entity, Focusable, KeyDownEvent, MouseButton, MouseDownEvent, Subscription, Window, + div, prelude::*, }; - -use crate::model::content::FileStickerContent; -use crate::model::sticker::{StickerColor, StickerDetail, StickerState, StickerType}; -use crate::native::components::IconName; -use crate::native::windows::StickerWindowEvent; -use crate::native::windows::sticker::StickerWindow; -use crate::storage::ArcStickerStore; - -use super::content_lock::{LockActions, LockForm, LockedContent, UnlockActions}; +use zeroize::Zeroizing; pub struct MarkdownSticker { id: i64, @@ -26,343 +29,415 @@ pub struct MarkdownSticker { sticker_events_tx: std::sync::mpsc::Sender, editor: Entity, editing: bool, - edit_snapshot: Option, + edit_snapshot: Option>, error: Option, converting: bool, convert_error: Option, - locked_content: Option, + entry: Option, + encrypted: bool, content_visible: bool, - locking: bool, - lock_busy: bool, - lock_form: LockForm, + busy: bool, + _changes: Subscription, +} +impl Drop for MarkdownSticker { + fn drop(&mut self) { + text_password::set_dirty(self.id, false); + } } - impl MarkdownSticker { + #[allow(clippy::too_many_arguments)] pub fn new( id: i64, color: StickerColor, store: ArcStickerStore, content: &str, window: &mut Window, - cx: &mut Context, + cx: &mut Context, sticker_events_tx: std::sync::mpsc::Sender, + edit: bool, ) -> Self { - let (locked_content, initial_content, lock_error) = match LockedContent::parse(content) { - Ok(Some(locked)) => (Some(locked), String::new(), None), - Ok(None) => (None, content.to_string(), None), - Err(err) => (None, String::new(), Some(err)), - }; - let initial_title = locked_content - .as_ref() - .map(|locked| locked.title.clone()) - .unwrap_or_else(|| derive_title(&initial_content)); + let encrypted = protection::is_encrypted(content); + let unsupported = protection::is_unsupported(content); let editor = cx.new(|cx| { TextareaState::new(window, cx) .searchable(true) - .placeholder("Input text/markdown, ctrl+s to save and preview it") - .default_value(initial_content) + .placeholder("Text / Markdown") + .default_value(if encrypted || unsupported { + "" + } else { + content + }) }); - let lock_form = LockForm::new(initial_title, "Password to unlock this sticker", window, cx); - let content_visible = locked_content.is_none() && lock_error.is_none(); - if !content_visible { - lock_form.focus_password(window, cx); - } - - let editing = content_visible && content.is_empty(); - Self { + let changes = cx.subscribe(&editor, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) && this.encrypted && this.content_visible { + text_password::set_dirty(this.id, true); + } + cx.notify(); + }); + let mut this = Self { id, color, store, sticker_events_tx, editor, - editing, - edit_snapshot: editing.then(String::new), - error: lock_error, + editing: !encrypted && !unsupported && (edit || content.is_empty()), + edit_snapshot: None, + error: unsupported.then(|| { + "This legacy lock format is unsupported. No recovery or migration is provided." + .into() + }), converting: false, convert_error: None, - locked_content, - content_visible, - locking: false, - lock_busy: false, - lock_form, - } - } - - fn save_state(&mut self, cx: &mut Context) -> bool { - if self.locked_content.is_some() { - return true; + entry: None, + encrypted, + content_visible: !encrypted && !unsupported, + busy: false, + _changes: changes, + }; + if this.editing { + this.edit_snapshot = Some(Zeroizing::new(content.to_owned())); + this.editor.focus_handle(cx).focus(window, cx); } - let content = self.editor.read(cx).value().to_string(); - let title = derive_title(&content); - - let id = self.id; - let store = self.store.clone(); - let sticker_events_tx = self.sticker_events_tx.clone(); - - cx.spawn(async move |entity, cx| { - if let Err(err) = store.update_sticker_title(id, title.clone()).await { - let _ = entity.update(cx, |this, cx| { - this.error = Some(format!("{err:#}")); - cx.notify(); - }); - return; - } - - if let Err(err) = sticker_events_tx.send(StickerWindowEvent::TitleChanged { id, title }) - { - tracing::warn!( - id, - error = %err, - "Failed to send title changed event for markdown sticker" - ); - } - - if let Err(err) = store.update_sticker_content(id, content).await { + let repo = crate::native::windows::clipboard::text_repository(); + if let Some(repo) = repo { + cx.spawn(async move |entity, cx| { + let entry = repo.get(TextEntryId::Sticker(id)).await; let _ = entity.update(cx, |this, cx| { - this.error = Some(format!("{err:#}")); + match entry { + Ok(entry) => this.entry = Some(entry), + Err(_) => { + this.error = Some("Text is unavailable. Reopen the sticker.".into()) + } + }; cx.notify(); }); - return; + }) + .detach(); + } + this + } + fn reset_editor(&mut self, window: &mut Window, cx: &mut Context) { + self.editor = cx.new(|cx| TextareaState::new(window, cx).searchable(true)); + self._changes = cx.subscribe(&self.editor, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) && this.encrypted && this.content_visible { + text_password::set_dirty(this.id, true); } - - let _ = entity.update(cx, |this, cx| { - this.editing = false; - this.edit_snapshot = None; - this.error = None; - cx.notify(); - }); - }) - .detach(); - - true + cx.notify(); + }); } - fn begin_edit(&mut self, window: &mut Window, cx: &mut Context) { - if self.editing || self.locked_content.is_some() { + pub(crate) fn begin_edit(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy || text_password::is_rotating() { + return; + } + if !self.content_visible { + self.request_unlock(window, cx); return; } - self.edit_snapshot = Some(self.editor.read(cx).value().to_string()); + self.edit_snapshot = Some(Zeroizing::new(self.editor.read(cx).value().to_string())); self.editing = true; self.editor.focus_handle(cx).focus(window, cx); cx.notify(); } - - fn cancel_edit(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(content) = self.edit_snapshot.take() { - self.editor - .update(cx, |input, cx| input.set_value(content, window, cx)); + fn request_unlock(&mut self, window: &mut Window, cx: &mut Context) { + if !self.encrypted { + return; } - self.editing = false; - self.error = None; - cx.notify(); - } - - fn begin_lock(&mut self, window: &mut Window, cx: &mut Context) { - let title = derive_title(self.editor.read(cx).value().as_ref()); - self.lock_form.reset_for_lock(title, window, cx); - self.locking = true; - self.error = None; - cx.notify(); - } - - fn cancel_lock(&mut self, window: &mut Window, cx: &mut Context) { - self.locking = false; - self.error = None; - self.lock_form.clear_passwords(window, cx); - cx.notify(); - } - - fn cancel_unlock(&mut self, window: &mut Window, cx: &mut Context) { - self.error = None; - self.lock_form.clear_passwords(window, cx); - cx.notify(); + let entity = cx.entity().downgrade(); + text_password::unlock( + window, + cx, + Box::new(move |password, window, cx| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, cx| this.authorize(password, window, cx)); + } + }), + ); } - - fn lock_new_content(&mut self, window: &mut Window, cx: &mut Context) { - if self.lock_busy { + fn authorize( + &mut self, + password: Zeroizing, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repo) = crate::native::windows::clipboard::text_repository() else { return; - } - let content = self.editor.read(cx).value().to_string(); - let prepared = match self.lock_form.prepare_lock(&content, cx) { - Ok(prepared) => prepared, - Err(err) => { - self.error = Some(err); - cx.notify(); - return; - } }; - let title = prepared.locked.title.clone(); - let locked = prepared.locked; - let serialized = prepared.serialized; - - self.lock_busy = true; - self.error = None; let id = self.id; - let store = self.store.clone(); - let sticker_events_tx = self.sticker_events_tx.clone(); - let entity = cx.entity(); + self.busy = true; + cx.notify(); + let entity = cx.entity().downgrade(); window .spawn(cx, async move |cx| { - let result = async { - store.update_sticker_content(id, serialized).await?; - store.update_sticker_title(id, title.clone()).await?; - anyhow::Ok(()) + let result = cx + .background_executor() + .spawn(async move { + futures::executor::block_on(async { + let entry = repo.get(TextEntryId::Sticker(id)).await?; + let text = repo.resolve(&entry, Some(&password)).await?; + anyhow::Ok((entry, text)) + }) + }) + .await; + if let Some(entity) = entity.upgrade() { + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + match result { + Ok((entry, text)) => { + this.entry = Some(entry); + this.editor.update(cx, |input, cx| { + input.set_value(text.to_string(), window, cx) + }); + this.edit_snapshot = Some(text); + this.content_visible = true; + this.editing = true; + this.error = None; + text_password::set_dirty(this.id, false); + } + Err(_) => { + this.error = Some( + "Could not unlock text. Check the password and try again." + .into(), + ) + } + }; + cx.notify(); + }); } - .await; - let _ = entity.update_in(cx, |this, window, cx| { - this.lock_busy = false; - match result { - Ok(()) => { - this.locked_content = Some(locked); - this.content_visible = false; - this.locking = false; - this.editor - .update(cx, |input, cx| input.set_value("", window, cx)); - this.lock_form.clear_passwords(window, cx); - this.error = None; - let _ = sticker_events_tx - .send(StickerWindowEvent::TitleChanged { id, title }); - } - Err(err) => this.error = Some(format!("Failed to lock sticker: {err:#}")), - } - cx.notify(); - }); }) .detach(); } - - fn unlock(&mut self, window: &mut Window, cx: &mut Context) { - let Some(locked) = self.locked_content.clone() else { - return; + fn save_state(&mut self, cx: &mut Context) -> bool { + if !self.content_visible || !self.editing { + return true; + } + if self.busy || text_password::is_rotating() { + return false; + } + let Some(repo) = crate::native::windows::clipboard::text_repository() else { + self.error = Some("Text storage unavailable.".into()); + cx.notify(); + return false; }; - let prepared = match self.lock_form.prepare_unlock(&locked, cx) { - Ok(prepared) => prepared, - Err(err) => { - self.error = Some(err); - cx.notify(); - return; - } + let Some(expected) = self.entry.clone() else { + self.error = Some("Text is still loading. Try again.".into()); + cx.notify(); + return false; }; - if let Some(updated) = prepared.updated_lock { - self.locked_content = Some(updated.locked); - let id = self.id; - let store = self.store.clone(); - let events = self.sticker_events_tx.clone(); - let title = prepared.title.clone(); - cx.spawn(async move |entity, cx| { - let result = async { - store.update_sticker_content(id, updated.serialized).await?; - store.update_sticker_title(id, title.clone()).await?; - anyhow::Ok(()) - } - .await; - if let Err(err) = result { - let _ = entity.update(cx, |this, cx| { - this.error = Some(format!( - "Unlocked, but failed to save the new title: {err:#}" - )); - cx.notify(); - }); - } else { - let _ = events.send(StickerWindowEvent::TitleChanged { id, title }); - } - }) - .detach(); + let text = Zeroizing::new(self.editor.read(cx).value().to_string()); + self.busy = true; + cx.notify(); + cx.spawn(async move|entity,cx|{let result=repo.save_text(&expected,&text).await; + let fresh=if result.is_ok(){repo.get(expected.id()).await.ok()}else{None}; + let _=entity.update(cx,|this,cx|{this.busy=false;match result{Ok(())=>{this.entry=fresh;this.editing=false;this.edit_snapshot=None;this.error=None;text_password::set_dirty(this.id,false);let _=this.sticker_events_tx.send(StickerWindowEvent::Created{id:this.id});},Err(_)=>this.error=Some("Text changed or could not be saved. Keep your draft and reopen the current text.".into())};cx.notify();}); + }).detach(); + false + } + fn cancel_edit(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + if let Some(snapshot) = self.edit_snapshot.take() { + self.editor.update(cx, |input, cx| { + input.set_value(snapshot.to_string(), window, cx) + }); } - self.editor.update(cx, |input, cx| { - input.set_value(prepared.content, window, cx) - }); - self.content_visible = true; self.editing = false; + text_password::set_dirty(self.id, false); + cx.notify(); + } + fn hide_unlocked_content(&mut self, window: &mut Window, cx: &mut Context) { + if !self.encrypted || self.busy { + return; + } + let dirty = self.editing + && self + .edit_snapshot + .as_ref() + .is_some_and(|s| s.as_str() != self.editor.read(cx).value().as_ref()); + if dirty { + self.error = Some("Save or discard your encrypted draft before locking.".into()); + cx.notify(); + return; + } + self.reset_editor(window, cx); self.edit_snapshot = None; - self.error = None; - self.lock_form.clear_passwords(window, cx); + self.content_visible = false; + self.editing = false; + text_password::set_dirty(self.id, false); cx.notify(); } - - fn unlock_forever(&mut self, window: &mut Window, cx: &mut Context) { - if self.lock_busy { + fn lock(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + if !self.encrypted && !self.editing { + let weak = cx.weak_entity(); + let title = self.entry.as_ref().map(|entry| entry.title()).unwrap_or(""); + crate::native::windows::text_title::prompt( + window, + cx, + true, + title, + Box::new(move |title, window, cx| { + if let Some(view) = weak.upgrade() { + view.update(cx, |this, cx| this.lock_titled(Some(title), window, cx)); + } + }), + ); + return; + } + self.lock_titled(None, window, cx); + } + fn lock_titled(&mut self, title: Option, window: &mut Window, cx: &mut Context) { + if self.encrypted { + if self.content_visible { + self.hide_unlocked_content(window, cx) + } else { + self.request_unlock(window, cx) + }; + return; + } + if self.editing { + self.error = Some("Save your text before locking it.".into()); + cx.notify(); return; } - let Some(locked) = self.locked_content.clone() else { + let Some(repo) = crate::native::windows::clipboard::text_repository() else { return; }; - let prepared = match self - .lock_form - .prepare_unlock_forever(&locked, derive_title, cx) - { - Ok(prepared) => prepared, - Err(err) => { - self.error = Some(err); - cx.notify(); - return; - } + let Some(entry) = self.entry.clone() else { + return; }; - let content = prepared.content; - let title = prepared.title; - - self.lock_busy = true; - self.error = None; - let id = self.id; - let store = self.store.clone(); - let events = self.sticker_events_tx.clone(); - let entity = cx.entity(); + let entity = cx.entity().downgrade(); + self.busy = true; + cx.notify(); window .spawn(cx, async move |cx| { - let content_result = store.update_sticker_content(id, content.clone()).await; - let title_result = if content_result.is_ok() { - store.update_sticker_title(id, title.clone()).await - } else { - Ok(()) - }; - let _ = entity.update_in(cx, |this, window, cx| { - this.lock_busy = false; - if let Err(err) = content_result { - this.error = Some(format!("Failed to remove sticker lock: {err:#}")); - cx.notify(); - return; - } - - this.locked_content = None; - this.content_visible = true; - this.editing = false; - this.edit_snapshot = None; - this.editor - .update(cx, |input, cx| input.set_value(content, window, cx)); - this.lock_form.clear_passwords(window, cx); - match title_result { - Ok(()) => { - this.error = None; - let _ = events.send(StickerWindowEvent::TitleChanged { id, title }); - } - Err(err) => { - this.error = - Some(format!("Lock removed, but failed to save title: {err:#}")); - } + let probe = repo.clone(); + let configured = cx + .background_executor() + .spawn(async move { futures::executor::block_on(probe.master_key()) }) + .await; + if !matches!(configured, Ok(Some(_))) { + if let Some(entity) = entity.upgrade() { + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + text_password::settings(window, cx, repo); + cx.notify(); + }); } - cx.notify(); - }); + return; + } + let result = cx + .background_executor() + .spawn(async move { + futures::executor::block_on(repo.set_encryption_titled( + &entry, + true, + None, + title.as_deref(), + )) + }) + .await; + if let Some(entity) = entity.upgrade() { + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + match result { + Ok(()) => { + this.encrypted = true; + this.content_visible = false; + this.edit_snapshot = None; + this.reset_editor(window, cx); + this.error = None; + let _ = this + .sticker_events_tx + .send(StickerWindowEvent::Created { id: this.id }); + } + Err(_) => { + this.error = + Some("Could not lock text. Reload and try again.".into()) + } + }; + cx.notify(); + }); + } }) .detach(); } - - fn relock(&mut self, window: &mut Window, cx: &mut Context) { - self.hide_unlocked_content(window, cx); - } - - fn hide_unlocked_content(&mut self, window: &mut Window, cx: &mut Context) { - self.content_visible = false; - self.editing = false; - self.edit_snapshot = None; - self.editor - .update(cx, |input, cx| input.set_value("", window, cx)); - self.lock_form.clear_passwords(window, cx); - self.lock_form.focus_password(window, cx); - self.error = None; - cx.notify(); + fn remove_encryption(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy || self.editing || text_password::is_rotating() { + return; + } + let entity = cx.entity().downgrade(); + text_password::unlock( + window, + cx, + Box::new(move |password, window, cx| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, cx| { + let Some(repo) = crate::native::windows::clipboard::text_repository() + else { + return; + }; + let id = this.id; + this.busy = true; + let weak = cx.entity().downgrade(); + window + .spawn(cx, async move |cx| { + let result = cx + .background_executor() + .spawn(async move { + futures::executor::block_on(async { + let entry = repo.get(TextEntryId::Sticker(id)).await?; + repo.set_encryption(&entry, false, Some(&password)) + .await?; + let current = repo.get(entry.id()).await?; + let text = repo.resolve(¤t, None).await?; + anyhow::Ok((current, text)) + }) + }) + .await; + if let Some(entity) = weak.upgrade() { + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + match result { + Ok((entry, text)) => { + this.entry = Some(entry); + this.encrypted = false; + this.content_visible = true; + this.editor.update(cx, |input, cx| { + input.set_value(text.to_string(), window, cx) + }); + this.editing = false; + this.edit_snapshot = None; + text_password::set_dirty(id, false); + let _ = this + .sticker_events_tx + .send(StickerWindowEvent::Created { id }); + } + Err(_) => { + this.error = Some( + "Could not remove encryption; nothing changed." + .into(), + ) + } + }; + cx.notify(); + }); + } + }) + .detach(); + cx.notify(); + }); + } + }), + ); } - fn start_convert(&mut self, cx: &mut Context) { + if self.encrypted { + return; + } if self.converting { return; } @@ -512,231 +587,160 @@ fn derive_md_filename(content: &str) -> String { } } -fn derive_title(content: &str) -> String { - content - .lines() - .find(|line| !line.trim().is_empty()) - .unwrap_or("Private sticker") - .trim_start_matches('#') - .trim() - .to_string() -} - impl super::Sticker for MarkdownSticker { fn id(&self) -> i64 { self.id } - fn save_on_close(&mut self, cx: &mut Context) -> bool { self.save_state(cx) } - fn min_window_size() -> gpui::Size { gpui::size(200, 100) } - fn default_window_size() -> gpui::Size { gpui::size(400, 300) } - fn set_color(&mut self, color: StickerColor) { self.color = color; } - fn suppress_window_escape(&self) -> bool { - self.editing || self.locking || !self.content_visible + self.editing || self.busy } - fn protected_content_visible(&self) -> bool { - self.locked_content.is_some() && self.content_visible + self.encrypted && self.content_visible } - fn relock_protected_content(&mut self, window: &mut Window, cx: &mut Context) { self.hide_unlocked_content(window, cx); } - + fn begin_edit(&mut self, window: &mut Window, cx: &mut Context) { + MarkdownSticker::begin_edit(self, window, cx); + } fn handle_lock_shortcut(&mut self, window: &mut Window, cx: &mut Context) -> bool { - if self.lock_busy || self.locking { - return false; - } - if self.locked_content.is_some() || !self.content_visible { - if self.content_visible { - self.relock(window, cx); - } else { - self.lock_form.focus_password(window, cx); - } - } else { - self.begin_lock(window, cx); - } + self.lock(window, cx); true } - fn header_extension(&mut self, cx: &mut Context) -> Option { - let is_protected = self.locked_content.is_some() || !self.content_visible; Some( h_flex() .flex_1() .child(div().flex_1()) .child( - Button::new("content-lock") - .icon(if is_protected && self.content_visible { + Button::new("text-lock") + .icon(if self.encrypted && self.content_visible { IconName::LockOpen } else { IconName::LockClosed }) - .tooltip(if is_protected && self.content_visible { - "Lock sticker now (ctrl+l)" - } else if is_protected { - "Sticker is locked" - } else { - "Protect sticker with a password (ctrl+l)" - }) - .disabled(self.lock_busy || self.locking) + .tooltip("Lock / unlock text (Ctrl+L)") .ghost() - .occlude() - .on_click(cx.listener(|this, _, window, cx| { - if this.locked_content.is_some() || !this.content_visible { - if this.content_visible { - this.relock(window, cx); - } else { - this.lock_form.focus_password(window, cx); - } - } else { - this.begin_lock(window, cx); - } - })), + .disabled(self.busy) + .on_click(cx.listener(|this, _, window, cx| this.lock(window, cx))), ) .into_any_element(), ) } - fn footer_extension(&mut self, cx: &mut Context) -> Option { - if !self.content_visible || self.locking || self.locked_content.is_some() { + if !self.content_visible { return None; } - let mut controls = h_flex().flex_1(); + let mut controls = h_flex().gap_2(); if self.editing { + controls = controls + .child( + Button::new("text-save") + .label("Save") + .disabled(self.busy) + .on_click(cx.listener(|this, _, _, cx| { + this.save_state(cx); + })), + ) + .child( + Button::new("text-discard") + .label("Discard edits") + .disabled(self.busy) + .on_click(cx.listener(|this, _, window, cx| this.cancel_edit(window, cx))), + ); + } else if self.encrypted { controls = controls.child( - Button::new("save") - .label("save (ctrl+s)") - .occlude() - .on_click(cx.listener(|this, _, _, cx| { - this.save_state(cx); - })), + Button::new("text-unprotect") + .label("Remove encryption…") + .on_click( + cx.listener(|this, _, window, cx| this.remove_encryption(window, cx)), + ), ); } else { controls = controls.child( Button::new("convert-to-file") .icon(IconName::DocumentText) - .tooltip("Convert to local file") + .tooltip("Convert to local file…") .disabled(self.converting) - .occlude() - .on_click(cx.listener(|this, _, _, cx| { - this.start_convert(cx); - })), + .on_click(cx.listener(|this, _, _, cx| this.start_convert(cx))), ); } - Some(controls.into_any_element()) } } - impl Render for MarkdownSticker { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let mut body = v_flex().size_full().gap_1(); - - if self.locking { - window.set_rem_size(px(14.0)); - return self.lock_form.locking_view( - "markdown", - "Lock sticker", - self.error.as_deref(), - self.lock_busy, - LockActions { - cancel: Self::cancel_lock, - confirm: Self::lock_new_content, - }, - cx, - ); + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut body = v_flex().size_full().gap_2(); + if let Some(error) = &self.error { + body = body.child(div().text_color(cx.theme().danger).child(error.clone())); } - if !self.content_visible { - window.set_rem_size(px(14.0)); - return self.lock_form.locked_view( - "markdown", - self.error.as_deref(), - self.lock_busy, - UnlockActions { - cancel: Self::cancel_unlock, - unlock: Self::unlock, - unlock_forever: Self::unlock_forever, - }, - cx, - ); + return body + .p_4() + .child( + Button::new("unlock-text") + .label("Unlock text…") + .disabled(self.busy || !self.encrypted) + .on_click( + cx.listener(|this, _, window, cx| this.request_unlock(window, cx)), + ), + ) + .into_any_element(); } - if self.editing { - window.set_rem_size(cx.theme().font_size); - - body = body.occlude().child( + body = body.child( div() - .size_full() - .p_1() + .flex_1() + .min_h_0() .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - if event.keystroke.modifiers.control + if (event.keystroke.modifiers.control || event.keystroke.modifiers.platform) && event.keystroke.key.eq_ignore_ascii_case("s") { this.save_state(cx); + cx.stop_propagation(); } else if event.keystroke.key == "escape" { this.cancel_edit(window, cx); cx.stop_propagation(); - window.prevent_default(); } })) - .child(Textarea::new(&self.editor).size_full().bordered(false)), + .child( + Textarea::new(&self.editor) + .size_full() + .bordered(false) + .readonly(self.busy || text_password::is_rotating()), + ), ); } else { - window.set_rem_size(px(14.0)); - - let mut preview_overlay = div() - .relative() - .size_full() - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, e: &MouseDownEvent, window, cx| { - if e.click_count >= 2 { - this.begin_edit(window, cx); - } - }), - ) - .child( - TextView::markdown("markdown-preview", self.editor.read(cx).value()) - .py_1() - .px_2() - .size_full() - .selectable(true) - .scrollable(true), - ); - - if let Some(err) = &self.convert_error { - preview_overlay = preview_overlay.child( - div() - .occlude() - .absolute() - .left_0() - .right_0() - .bottom_0() - .px_2() - .py_1() - .text_xs() - .text_color(gpui::red()) - .child(err.clone()), - ); - } - - body = body.child(preview_overlay); + body = body.child( + div() + .flex_1() + .min_h_0() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + if event.click_count >= 2 { + this.begin_edit(window, cx); + } + }), + ) + .child(TextView::markdown( + ("markdown-text", self.id as u64), + self.editor.read(cx).value().to_string(), + )), + ); } - body.into_any_element() } } diff --git a/src/native/components/stickers/mod.rs b/src/native/components/stickers/mod.rs index aaa50ff..3caa690 100644 --- a/src/native/components/stickers/mod.rs +++ b/src/native/components/stickers/mod.rs @@ -3,13 +3,12 @@ use gpui::{AnyElement, App, Context, Entity, IntoElement, Render, Size, Window}; use crate::model::sticker::StickerColor; pub mod command; -mod content_lock; pub mod file; pub mod markdown; pub mod paint; pub mod timer; -pub(crate) use content_lock::FOCUS_LOSS_RELOCK_DELAY; +pub(crate) const FOCUS_LOSS_RELOCK_DELAY: std::time::Duration = std::time::Duration::from_secs(30); pub trait Sticker: Sized { fn id(&self) -> i64; @@ -40,6 +39,8 @@ pub trait Sticker: Sized { fn relock_protected_content(&mut self, _window: &mut Window, _cx: &mut Context) {} + fn begin_edit(&mut self, _window: &mut Window, _cx: &mut Context) {} + fn handle_lock_shortcut(&mut self, _window: &mut Window, _cx: &mut Context) -> bool { false } @@ -67,6 +68,7 @@ pub trait StickerView { fn suppress_window_escape(&self, cx: &App) -> bool; fn protected_content_visible(&self, cx: &App) -> bool; fn relock_protected_content(&self, window: &mut Window, cx: &mut App); + fn begin_edit(&self, window: &mut Window, cx: &mut App); fn handle_lock_shortcut(&self, window: &mut Window, cx: &mut App) -> bool; fn header_extension(&self, cx: &mut App) -> Option; fn footer_extension(&self, cx: &mut App) -> Option; @@ -128,6 +130,11 @@ impl StickerView for StickerViewEntity { }); } + fn begin_edit(&self, window: &mut Window, cx: &mut App) { + self.entity + .update(cx, |this, cx| this.begin_edit(window, cx)); + } + fn handle_lock_shortcut(&self, window: &mut Window, cx: &mut App) -> bool { self.entity .update(cx, |this, cx| this.handle_lock_shortcut(window, cx)) diff --git a/src/native/mod.rs b/src/native/mod.rs index 6bf4e78..55e8918 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -6,12 +6,11 @@ use std::{ use crate::gpui_component::{Theme, ThemeMode}; use gpui::{AnyWindowHandle, App}; -use crate::clipboard::vault_controller::VaultController; use crate::{ - clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore}, ipc::IpcEvent, native::windows::{StickerWindowEvent, main::MainWindow, sticker::StickerWindow}, storage::{ArcStickerStore, open_sqlite, paths::AppPaths}, + text::TextRepository, }; pub mod clipboard; @@ -60,16 +59,15 @@ pub fn run_native( let main_window_handle = Arc::new(OnceLock::::new()); let store_handle = Arc::new(OnceLock::::new()); - let clipboard_handle_for_open: Arc>> = - Arc::new(OnceLock::new()); + let clipboard_handle_for_open: Arc>> = Arc::new(OnceLock::new()); let clipboard_handle_for_ipc = clipboard_handle_for_open.clone(); let clipboard_filter_for_open: Arc< OnceLock>, > = Arc::new(OnceLock::new()); let clipboard_filter_for_ipc = clipboard_filter_for_open.clone(); - let clipboard_vault_for_open: Arc>> = Arc::new(OnceLock::new()); app.run(move |cx: &mut App| { + cx.on_app_quit(|_| async { crate::native::windows::clipboard::clear_owned_secret(); }).detach(); crate::gpui_component::init(cx); Theme::change(ThemeMode::Dark, None, cx); @@ -242,21 +240,17 @@ pub fn run_native( tracing::info!("Sticker store opened"); // Clipboard failure must not prevent the main window or stickers from opening. - let clipboard_db_path = sticker_db.with_file_name("clipboard.db"); - match SqliteClipboardStore::open(&clipboard_db_path).await { + match TextRepository::open(&sticker_db).await { Ok(backend) => { - let clipboard_store: Arc = Arc::new(backend); + let clipboard_store = Arc::new(backend); let filter = crate::native::clipboard::CaptureFilter::new(); - let vault = Arc::new(VaultController::new(clipboard_store.clone())); let _ = clipboard_handle_for_open.set(clipboard_store.clone()); let _ = clipboard_filter_for_open.set(filter.clone()); - let _ = clipboard_vault_for_open.set(vault.clone()); let (watcher, _stop) = crate::native::clipboard::watcher::WatcherHandle::new(Duration::from_millis(250)); - watcher.spawn(filter.clone(), clipboard_store.clone(), Some(vault.clone())); - crate::native::windows::clipboard::set_global_store(clipboard_store); + watcher.spawn(filter.clone(), clipboard_store.clone()); + crate::native::windows::clipboard::set_global_store(clipboard_store, store.clone()); cx.update(move |_cx| { crate::native::windows::clipboard::set_global_capture_filter(filter); - crate::native::windows::clipboard::set_global_vault_controller(vault); }); } Err(_) => tracing::error!("Clipboard backend unavailable; continuing sticker startup"), diff --git a/src/native/windows/clipboard.rs b/src/native/windows/clipboard.rs index cf3ab5b..459dc00 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/clipboard.rs @@ -1,72 +1,55 @@ -//! Keyboard-first retrieval with password-authorized use/edit of existing encrypted rows. -//! Protect and encrypted edits use atomic, vault-generation-bound storage transitions. -mod editor; - -use crate::clipboard::{ - items::{ClipboardItem, ClipboardItemKind}, - storage::{ClipboardHistoryStore, RetentionFlag}, - vault_controller::VaultController, -}; +//! TXTs: one keyboard-first picker over saved text stickers and disposable captures. use crate::gpui_component::{ ActiveTheme, Disableable, Icon, Root, Selectable, Sizable, WindowExt, - alert::Alert, button::{Button, ButtonVariants}, h_flex, input::{Input, InputEvent, InputState}, + menu::{DropdownMenu, PopupMenuItem}, v_flex, }; -use crate::native::clipboard::{CaptureFilter, foreground, paste_foreground, write_clipboard_text}; -use crate::native::components::IconName; -use crate::utils::time::now_unix_millis; +use crate::{ + native::{ + clipboard::{CaptureFilter, foreground, paste_foreground}, + components::IconName, + windows::{StickerWindowEvent, sticker::StickerWindow}, + }, + storage::ArcStickerStore, + text::{TextEntry, TextEntryId, TextRepository, TextSources, filter_indices}, +}; use gpui::{ - App, Bounds, Context, Entity, EntityInputHandler, Focusable, IntoElement, MouseButton, - MouseDownEvent, Render, ScrollStrategy, Subscription, UniformListScrollHandle, Window, - WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowOptions, div, - prelude::*, px, size, uniform_list, + App, Bounds, Context, Entity, Focusable, KeyDownEvent, MouseButton, MouseDownEvent, Render, + ScrollStrategy, Subscription, UniformListScrollHandle, Window, WindowBackgroundAppearance, + WindowBounds, WindowControlArea, WindowKind, WindowOptions, div, prelude::*, px, size, + uniform_list, }; use std::sync::{Arc, OnceLock, RwLock}; +use zeroize::Zeroizing; -const WIDTH: f32 = 380.0; -const HEIGHT: f32 = 520.0; -const PAGE: isize = 6; -#[derive(Clone, Copy)] -enum AuthorizedAction { - Protect, - Unprotect, - Use(bool), - Edit, -} -struct PasswordPrompt { - id: i64, - action: AuthorizedAction, - expected_plaintext: Option>, - /// The label pre-filled into the dialog at open time. `submit_password` - /// forwards only typed differences into the editor so an untouched - /// input doesn't accidentally overwrite the row's stored label. - prefilled_label: Option, -} -enum AuthorizedResult { - Protected, - Unprotected, - Use(ClipboardItem, zeroize::Zeroizing, bool), - Edit( - ClipboardItem, - zeroize::Zeroizing, - Arc, - ), +static GLOBAL_REPOSITORY: OnceLock> = OnceLock::new(); +static GLOBAL_STORE: OnceLock = OnceLock::new(); +static OWNED_SECRET: std::sync::Mutex, Option)>> = + std::sync::Mutex::new(None); +pub(crate) fn clear_owned_secret() { + if let Some((filter, generation)) = OWNED_SECRET + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + let _ = crate::native::clipboard::watcher::clear_protected_if_owned(&filter, generation); + } } - static GLOBAL_CAPTURE_FILTER: OnceLock> = OnceLock::new(); +pub(crate) static CLIPBOARD_POPUP: RwLock> = RwLock::new(None); +pub fn set_global_store(repository: Arc, store: ArcStickerStore) { + let _ = GLOBAL_REPOSITORY.set(repository); + let _ = GLOBAL_STORE.set(store); +} pub fn set_global_capture_filter(filter: Arc) { let _ = GLOBAL_CAPTURE_FILTER.set(filter); } -// Keep bootstrap integration; never use session-unlocked state to bypass row authorization. -static GLOBAL_VAULT: OnceLock> = OnceLock::new(); -pub fn set_global_vault_controller(vault: Arc) { - let _ = GLOBAL_VAULT.set(vault); +pub(crate) fn text_repository() -> Option> { + GLOBAL_REPOSITORY.get().cloned() } -pub(crate) static CLIPBOARD_POPUP: RwLock> = RwLock::new(None); - fn clear_popup() { if let Ok(mut slot) = CLIPBOARD_POPUP.write() { *slot = None; @@ -74,371 +57,221 @@ fn clear_popup() { super::set_clipboard_escape_target(false); } pub fn dispatch_escape(cx: &mut App) -> bool { - let handle = CLIPBOARD_POPUP.read().ok().and_then(|slot| *slot); - handle + CLIPBOARD_POPUP + .read() + .ok() + .and_then(|s| *s) .and_then(|h| { h.update(cx, |_, w, cx| { - w.dispatch_keystroke(gpui::Keystroke::parse("escape").expect("valid key"), cx) + w.dispatch_keystroke(gpui::Keystroke::parse("escape").unwrap(), cx) }) .ok() }) .unwrap_or(false) } - pub fn open_from_main( cx: &mut App, - events: std::sync::mpsc::Sender, + events: std::sync::mpsc::Sender, ) -> anyhow::Result<()> { - let vault = GLOBAL_VAULT - .get() - .ok_or_else(|| anyhow::anyhow!("Clipboard backend unavailable"))?; - let _ = vault; - let store = GLOBAL_STORE - .get() - .cloned() - .ok_or_else(|| anyhow::anyhow!("Clipboard backend unavailable"))?; - ClipboardChooserWindow::toggle(cx, events, store, None) -} -static GLOBAL_STORE: OnceLock> = OnceLock::new(); -pub fn set_global_store(store: Arc) { - let _ = GLOBAL_STORE.set(store); + let repository = + text_repository().ok_or_else(|| anyhow::anyhow!("Text backend unavailable"))?; + ClipboardChooserWindow::toggle(cx, events, repository, None) } - pub struct ClipboardChooserWindow; impl ClipboardChooserWindow { pub fn toggle( cx: &mut App, - _events: std::sync::mpsc::Sender, - store: Arc, + events: std::sync::mpsc::Sender, + repository: Arc, destination: Option, ) -> anyhow::Result<()> { - let existing = CLIPBOARD_POPUP.read().ok().and_then(|slot| *slot); - if let Some(handle) = existing { - if dispatch_escape(cx) { - return Ok(()); - } - if handle.update(cx, |_, _, _| ()).is_ok() { - return Ok(()); - } - clear_popup(); + if CLIPBOARD_POPUP.read().ok().and_then(|s| *s).is_some() && dispatch_escape(cx) { + return Ok(()); } let filter = GLOBAL_CAPTURE_FILTER .get() .cloned() - .ok_or_else(|| anyhow::anyhow!("Clipboard backend unavailable"))?; - let bounds = Bounds::centered(None, size(px(WIDTH), px(HEIGHT)), cx); + .ok_or_else(|| anyhow::anyhow!("Capture backend unavailable"))?; + let store = GLOBAL_STORE + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("Sticker backend unavailable"))?; + // Physical window geometry is an OS boundary; internal layout uses rem helpers. + let bounds = Bounds::centered(None, size(px(440.), px(520.)), cx); let handle = cx.open_window( WindowOptions { focus: true, window_bounds: Some(WindowBounds::Windowed(bounds)), window_background: WindowBackgroundAppearance::Transparent, is_resizable: true, - window_min_size: Some(size(px(380.0), px(360.0))), + window_min_size: Some(size(px(380.), px(360.))), kind: WindowKind::Floating, titlebar: None, ..Default::default() }, move |window, cx| { - let view = cx - .new(|cx| ClipboardChooserWidget::new(window, cx, store, filter, destination)); - let surface = cx.new(|_| ClipboardChooserSurface { view }); - cx.new(|cx| Root::new(surface, window, cx)) + let view = cx.new(|cx| { + TextPicker::new(repository, store, filter, events, destination, window, cx) + }); + cx.new(|cx| Root::new(view, window, cx)) }, )?; if let Ok(mut slot) = CLIPBOARD_POPUP.write() { *slot = Some(handle.into()); } - cx.defer(move |cx| { - let _ = handle.update(cx, |_, w, _| w.activate_window()); - }); + super::set_clipboard_escape_target(true); Ok(()) } } - -// Dialog builders read the chooser: render them outside its mutable Render borrow. -struct ClipboardChooserSurface { - view: Entity, +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum SourceFilter { + #[default] + All, + Saved, + Clipboard, } -impl Render for ClipboardChooserSurface { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().relative().size_full().child(self.view.clone()) +impl SourceFilter { + fn includes(self, id: TextEntryId) -> bool { + matches!( + (self, id), + (Self::All, _) + | (Self::Saved, TextEntryId::Sticker(_)) + | (Self::Clipboard, TextEntryId::Clipboard(_)) + ) } } -fn validate_new_password(password: &str, confirmation: &str) -> Result<(), &'static str> { - if password.is_empty() { - return Err("Enter your master password."); - } - if password != confirmation { - return Err("Passwords do not match. Enter the same password in both fields."); +// Display-only recency; never changes the stored timestamp or clipboard payload. +fn recency_label(timestamp: i64, now: i64) -> String { + let seconds = now.saturating_sub(timestamp).max(0) / 1000; + match seconds { + 0..60 => "Just now".into(), + 60..3600 => format!("{}m ago", seconds / 60), + 3600..86400 => format!("{}h ago", seconds / 3600), + 86400..172800 => "Yesterday".into(), + _ => format!("{}d ago", seconds / 86400), } - Ok(()) } -fn protected(item: &ClipboardItem) -> bool { - item.kind == ClipboardItemKind::Protected || item.retention.protected() -} -/// Never use a possibly stale plaintext preview of a protected row. -fn display_preview(item: &ClipboardItem) -> String { - let text = if protected(item) { - item.public_label.as_deref().unwrap_or("Protected item") - } else { - &item.content - }; - text.chars() - .take(120) - .flat_map(|c| { - if c.is_control() { - c.escape_default().collect::>() - } else { - vec![c] - } - }) - .collect() -} -fn redact_protected_previews(rows: &mut [ClipboardItem]) { - for row in rows.iter_mut().filter(|r| protected(r)) { - row.plain_preview = None; - row.content.clear(); - row.kind = ClipboardItemKind::Protected; - } -} - -pub struct ClipboardChooserWidget { - store: Arc, +struct TextPicker { + repository: Arc, + store: ArcStickerStore, filter: Arc, - rows: Vec, + events: std::sync::mpsc::Sender, + rows: Vec, filtered: Vec, selected: usize, query: Entity, scroll: UniformListScrollHandle, destination: Option, busy: bool, - loading: bool, status: String, - status_error: bool, - status_warning: bool, - password_error: Option, - password: Entity, - confirmation: Entity, - label: Entity, - creating_vault: bool, - editor_open: bool, - hovered_row: Option, - prompt: Option, capture_enabled: bool, - capture_status: String, - query_version: u64, - _keys: Subscription, - _activation: Subscription, + source: SourceFilter, + loading: bool, + preferred_selection: Option, + _query: Subscription, } -impl ClipboardChooserWidget { +impl Drop for TextPicker { + fn drop(&mut self) { + clear_popup(); + } +} +impl TextPicker { fn new( - window: &mut Window, - cx: &mut Context, - store: Arc, + repository: Arc, + store: ArcStickerStore, filter: Arc, + events: std::sync::mpsc::Sender, destination: Option, + window: &mut Window, + cx: &mut Context, ) -> Self { - let query = - cx.new(|cx| InputState::new(window, cx).placeholder("Search clipboard history…")); - cx.subscribe_in(&query, window, |this, query, event, _, cx| { + let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search TXTs…")); + let subscription = cx.subscribe(&query, |this, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { - this.filtered = - crate::clipboard::filter::filter_indices(&this.rows, &query.read(cx).value()); + this.refilter(cx); this.selected = 0; - this.scroll_to_selection(); + this.scroll.scroll_to_item(0, ScrollStrategy::Nearest); + this.status.clear(); cx.notify(); } - }) - .detach(); - let password = cx.new(|cx| { - InputState::new(window, cx) - .masked(true) - .placeholder("Master password") }); - cx.subscribe_in(&password, window, |this, _, event, _, cx| { - if matches!(event, InputEvent::Change) && this.password_error.take().is_some() { - cx.notify(); - } - }) - .detach(); - let confirmation = cx.new(|cx| { - InputState::new(window, cx) - .masked(true) - .placeholder("Confirm master password") - }); - cx.subscribe_in(&confirmation, window, |this, _, event, _, cx| { - if matches!(event, InputEvent::Change) && this.password_error.take().is_some() { - cx.notify(); - } - }) - .detach(); - let label = cx.new(|cx| InputState::new(window, cx).placeholder("Public label (required)")); - cx.subscribe_in(&label, window, |this, _, event, _, cx| { - if matches!(event, InputEvent::Change) && this.password_error.take().is_some() { - cx.notify(); - } - }) - .detach(); - let own_id = window.window_handle().window_id(); - let entity = cx.weak_entity(); - let keys = cx.intercept_keystrokes(move |event, window, cx| { - if window.window_handle().window_id() != own_id || !window.is_window_active() { - return; - } - let Some(entity) = entity.upgrade() else { - return; - }; - let key = event.keystroke.key.as_str(); - let mods = &event.keystroke.modifiers; - let primary = if cfg!(target_os = "macos") { - mods.platform || mods.control - } else { - mods.control - }; - let handled = entity.update(cx, |this, cx| { - if this.editor_open { - return false; - } - if this.prompt.is_some() { - if this - .password - .update(cx, |q, cx| q.marked_text_range(window, cx).is_some()) - { - return false; - } - return match key { - "escape" if !this.busy => { - this.cancel_prompt(window, cx); - true - } - "enter" if !this.busy => { - this.submit_password(window, cx); - true - } - _ => false, - }; - } - // IME candidate navigation/confirmation belongs to the text input. - let composing = this - .query - .update(cx, |q, cx| q.marked_text_range(window, cx).is_some()); - if composing { - return false; - } - match key { - "escape" if !this.busy => { - window.remove_window(); - clear_popup(); - true - } - "enter" if !mods.alt && !mods.shift => { - this.use_selected(primary, window, cx); - true - } - "r" if primary && !mods.alt => { - this.reserve(window, cx); - true - } - "l" if primary && !mods.alt => { - this.protect_selected(window, cx); - true - } - "f2" => { - this.edit(window, cx); - true - } - "up" | "down" | "pageup" | "pagedown" - if !primary && !mods.alt && !mods.shift => - { - let delta = match key { - "up" => -1, - "down" => 1, - "pageup" => -PAGE, - _ => PAGE, - }; - this.selected = (this.selected as isize + delta) - .clamp(0, this.filtered.len().saturating_sub(1) as isize) - as usize; - this.scroll_to_selection(); - cx.notify(); - true - } - // Delete/Home/End retain their normal search-field editing behavior. - _ => false, - } - }); - if handled { - cx.stop_propagation(); - window.prevent_default(); - } - }); - let activation = cx.observe_window_activation(window, |_, window, _| { - super::set_clipboard_escape_target(window.is_window_active()); - }); - window.on_window_should_close(cx, |_, _| { - clear_popup(); - true - }); - query.update(cx, |q, cx| q.focus(window, cx)); - let mut this = Self { + let this = Self { + repository, store, filter, + events, rows: Vec::new(), filtered: Vec::new(), selected: 0, - query, + query: query.clone(), scroll: UniformListScrollHandle::new(), destination, busy: false, - loading: true, status: String::new(), - status_error: false, - status_warning: false, - password_error: None, - password, - confirmation, - label, - creating_vault: false, - editor_open: false, - hovered_row: None, - prompt: None, capture_enabled: false, - capture_status: "Collector starting…".into(), - query_version: 0, - _keys: keys, - _activation: activation, + source: SourceFilter::All, + loading: true, + preferred_selection: None, + _query: subscription, }; - this.refresh(window, cx); - cx.spawn_in(window, async move |entity, cx| { - // Reconciliation cadence for cross-process changes (CLI rewrites, - // external vault edits). Local watcher events drain their own - // signal queue and trigger a near-instant refresh on the next - // tick; this loop only guarantees we catch up after the watcher - // misses a write -- once every five seconds is enough. - const RECONCILE_EVERY: u32 = 5; - let mut ticks_since_refresh: u32 = 0; + query.update(cx, |input, cx| input.focus(window, cx)); + cx.spawn(async move |entity, cx| { + let mut last_version = -1; loop { - cx.background_executor() - .timer(std::time::Duration::from_secs(1)) - .await; + let repository = match entity + .update(cx, |this, _| (!this.busy).then(|| this.repository.clone())) + { + Ok(repository) => repository, + Err(_) => break, + }; + let Some(repository) = repository else { + cx.background_executor() + .timer(std::time::Duration::from_millis(400)) + .await; + continue; + }; + let version = repository.data_version().await.unwrap_or(-1); + if version == last_version && version >= 0 { + cx.background_executor() + .timer(std::time::Duration::from_millis(400)) + .await; + continue; + } + let result = repository.snapshot(TextSources::new(true, true), "").await; + if result.is_ok() { + last_version = version; + } + let settings = repository.settings().await; if entity - .update_in(cx, |this, window, cx| { - this.capture_status = crate::native::clipboard::watcher::capture_status(); - let drained = crate::native::clipboard::watcher::drain_watcher_signals(); - let signal_pushed_refresh = !drained.is_empty(); - ticks_since_refresh = ticks_since_refresh.saturating_add(1); - let due_for_reconcile = ticks_since_refresh >= RECONCILE_EVERY; - if !this.busy - && this.prompt.is_none() - && !this.editor_open - && (signal_pushed_refresh || due_for_reconcile) - { - if signal_pushed_refresh { - ticks_since_refresh = 0; + .update(cx, |this, cx| { + if this.busy { + last_version = -1; + return; + } + let id = this + .preferred_selection + .take() + .or_else(|| this.selected_entry().map(|e| e.id())); + this.loading = false; + match result { + Ok(rows) => { + this.rows = rows; + this.refilter(cx); + if let Some(id) = id + && let Some(ix) = this + .filtered + .iter() + .position(|ix| this.rows[*ix].id() == id) + { + this.selected = ix; + } } - this.refresh(window, cx); + Err(_) => this.status = "Could not load texts. Retrying.".into(), + }; + if let Ok(settings) = settings { + this.capture_enabled = settings.capture_enabled; } cx.notify(); }) @@ -446,1091 +279,795 @@ impl ClipboardChooserWidget { { break; } + cx.background_executor() + .timer(std::time::Duration::from_millis(400)) + .await; } }) .detach(); this } - fn scroll_to_selection(&self) { - self.scroll - .scroll_to_item(self.selected, ScrollStrategy::Top); + fn refilter(&mut self, cx: &App) { + self.filtered = filter_indices(&self.rows, self.query.read(cx).value().as_ref()) + .into_iter() + .filter(|ix| self.source.includes(self.rows[*ix].id())) + .collect(); + self.selected = self.selected.min(self.filtered.len().saturating_sub(1)); } - fn selected_item(&self) -> Option { - self.rows.get(*self.filtered.get(self.selected)?).cloned() + fn selected_entry(&self) -> Option { + self.filtered + .get(self.selected) + .and_then(|ix| self.rows.get(*ix)) + .cloned() } - fn refresh(&mut self, window: &mut Window, cx: &mut Context) { - let store = self.store.clone(); - let selected_id = self.selected_item().map(|r| r.id); - self.query_version += 1; - let version = self.query_version; - cx.spawn_in(window, async move |entity, cx| { - let result = store.snapshot().await; - let settings = store.settings().await; - let _ = entity.update_in(cx, |this, _, cx| { - if version != this.query_version { - return; - } - if let Ok(settings) = settings { - this.capture_enabled = settings.capture_enabled; - } - this.loading = false; - match result { - Ok(mut rows) => { - redact_protected_previews(&mut rows); - this.rows = rows; - this.filtered = crate::clipboard::filter::filter_indices( - &this.rows, - &this.query.read(cx).value(), - ); - this.selected = selected_id - .and_then(|id| { - this.filtered.iter().position(|i| this.rows[*i].id == id) - }) - .unwrap_or_else(|| { - this.selected.min(this.filtered.len().saturating_sub(1)) - }); - this.scroll_to_selection(); - } - Err(_) => { - this.status_error = true; - this.status = "Could not load history. Try refreshing.".into(); - } - } - cx.notify(); - }); - }) - .detach(); + fn move_selection(&mut self, delta: isize, cx: &mut Context) { + self.selected = self + .selected + .saturating_add_signed(delta) + .min(self.filtered.len().saturating_sub(1)); + self.scroll + .scroll_to_item(self.selected, ScrollStrategy::Nearest); + cx.notify(); } - fn use_selected(&mut self, copy_only: bool, window: &mut Window, cx: &mut Context) { + fn save(&mut self, encrypted: bool, edit: bool, window: &mut Window, cx: &mut Context) { if self.busy { return; } - let Some(item) = self.selected_item() else { + if encrypted + && let Some(entry) = self.selected_entry() + && !entry.is_encrypted() + { + let weak = cx.weak_entity(); + let initial = if matches!(entry.id(), TextEntryId::Sticker(_)) { + entry.title() + } else { + "" + }; + super::text_title::prompt( + window, + cx, + true, + initial, + Box::new(move |title, window, cx| { + if let Some(view) = weak.upgrade() { + view.update(cx, |this, cx| { + this.save_titled(encrypted, edit, Some(title), window, cx) + }); + } + }), + ); return; - }; - if protected(&item) { - self.request_password(item.id, AuthorizedAction::Use(copy_only), window, cx); + } + self.save_titled(encrypted, edit, None, window, cx); + } + fn save_titled( + &mut self, + encrypted: bool, + edit: bool, + title: Option, + window: &mut Window, + cx: &mut Context, + ) { + if self.busy { return; } + let Some(entry) = self.selected_entry() else { + return; + }; + let repo = self.repository.clone(); + let store = self.store.clone(); + let events = self.events.clone(); + let weak = cx.entity().downgrade(); self.busy = true; - self.status_error = false; - self.status = if copy_only { - "Copying…" - } else { - "Restoring destination…" - } - .into(); cx.notify(); - let store = self.store.clone(); - let filter = self.filter.clone(); - let destination = self.destination; - cx.spawn_in(window, async move |entity, cx| { - // Re-read the chosen stable ID before any side effect; never use stale cached content. - let result = async { - let current = store.get(item.id).await?; - anyhow::ensure!( - !protected(¤t), - "This item is now protected. Nothing was copied." - ); - if !copy_only { - entity.update_in(cx, |_, window, _| hide_chooser(window))??; + window + .spawn(cx, async move |cx| { + let probe = repo.clone(); + if encrypted && !entry.is_encrypted() { + let configured = cx + .background_executor() + .spawn(async move { futures::executor::block_on(probe.master_key()) }) + .await; + if !matches!(configured, Ok(Some(_))) { + if let Some(entity) = weak.upgrade() { + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + super::text_password::settings(window, cx, repo); + cx.notify(); + }); + } + return; + } } - let dispatch = cx + let operation = repo.clone(); + let result = cx .background_executor() .spawn(async move { - if !copy_only { - crate::native::clipboard::paste::wait_for_modifiers()?; - foreground(destination)?; - } - write_clipboard_text(&filter, ¤t.content)?; - if !copy_only { - paste_foreground(destination)?; - } - Ok::<_, anyhow::Error>(()) + futures::executor::block_on(async { + match entry.id() { + TextEntryId::Clipboard(_) => { + operation + .promote_titled(&entry, encrypted, title.as_deref()) + .await + } + TextEntryId::Sticker(id) => { + if encrypted && !entry.is_encrypted() { + operation + .set_encryption_titled( + &entry, + true, + None, + title.as_deref(), + ) + .await?; + } + Ok(id) + } + } + }) }) .await; - dispatch?; - store.mark_used(item.id, now_unix_millis()).await?; - Ok::<_, anyhow::Error>(()) - } - .await; - let _ = entity.update_in(cx, |this, window, cx| { - this.busy = false; match result { - Ok(()) => { - clear_popup(); - window.remove_window(); + Ok(id) => { + if encrypted { + let _ = cx.update(|_, cx| { + StickerWindow::try_close(id, cx); + }); + } + let _ = events.send(StickerWindowEvent::Created { id }); + if edit { + let _ = cx.update(|_, cx| { + let events = events.clone(); + let store = store.clone(); + cx.spawn(async move |cx| { + let _ = StickerWindow::open_async_with_options( + cx, events, store, id, true, + ) + .await; + }) + .detach(); + }); + } + if let Some(entity) = weak.upgrade() { + let _ = entity.update_in(cx, |this, window, cx| { + this.busy = false; + this.preferred_selection = Some(TextEntryId::Sticker(id)); + this.status = if encrypted { + "Saved encrypted text" + } else { + "Saved text" + } + .into(); + if edit { + clear_popup(); + window.remove_window(); + } + cx.notify(); + }); + } } - Err(err) => { - show_chooser(window); - this.status_error = true; - this.status = format!("{err}"); - window.activate_window(); - this.query.update(cx, |q, cx| q.focus(window, cx)); - cx.notify(); + Err(_) => { + if let Some(entity) = weak.upgrade() { + let _ = entity.update_in(cx, |this, _, cx| { + this.busy = false; + this.status = + "Text changed or could not be saved. Select it again.".into(); + cx.notify(); + }); + } } } - }); - }) - .detach(); + }) + .detach(); } - fn reserve(&mut self, window: &mut Window, cx: &mut Context) { + fn use_selected(&mut self, copy_only: bool, window: &mut Window, cx: &mut Context) { if self.busy { return; } - let Some(item) = self.selected_item() else { + let Some(entry) = self.selected_entry() else { return; }; - self.status.clear(); - self.status_warning = false; - self.busy = true; - let store = self.store.clone(); - cx.spawn_in(window, async move |entity, cx| { - let result = async { - let current = store.get(item.id).await?; - store - .set_retention_flag( - item.id, - RetentionFlag::Explicit, - !current.retention.explicit(), - ) - .await?; - Ok::<_, anyhow::Error>(( - !current.retention.explicit(), - current.retention.edited() || protected(¤t), - )) - } - .await; - let _ = entity.update_in(cx, |this, window, cx| { - this.busy = false; - this.status_warning = false; - this.status_error = result.is_err(); - this.status = match result { - Ok((true, _)) => "Reserved. Kept outside ordinary history limits.".into(), - Ok((false, true)) => { - "Reservation removed; edited/protected retention still keeps this item." - .into() + if entry.is_encrypted() { + let weak = cx.entity().downgrade(); + super::text_password::unlock( + window, + cx, + Box::new(move |password, window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.deliver(entry, copy_only, Some(password), window, cx) + }); } - Ok((false, false)) => { - "Reservation removed; ordinary history limits now apply.".into() - } - Err(_) => "Reservation failed; no successful change is claimed.".into(), - }; - this.refresh(window, cx); - cx.notify(); - }); - }) - .detach(); - } - fn edit(&mut self, window: &mut Window, cx: &mut Context) { - if self.busy { - return; - } - let Some(item) = self.selected_item() else { - return; - }; - if protected(&item) { - self.request_password(item.id, AuthorizedAction::Edit, window, cx); - return; - } - self.editor_open = true; - let parent = cx.weak_entity(); - if editor::open(window, cx, self.store.clone(), item, parent, None, None).is_err() { - self.status_error = true; - self.editor_open = false; - self.status = "Could not open the editor. Try again.".into(); - cx.notify(); - } - } - fn protect_selected(&mut self, window: &mut Window, cx: &mut Context) { - if self.busy || self.prompt.is_some() { - return; - } - let Some(item) = self.selected_item() else { - return; - }; - if protected(&item) { - self.request_password(item.id, AuthorizedAction::Unprotect, window, cx); - return; + }), + ); + } else { + self.deliver(entry, copy_only, None, window, cx); } - self.request_password(item.id, AuthorizedAction::Protect, window, cx); } - fn request_password( + fn deliver( &mut self, - id: i64, - action: AuthorizedAction, + entry: TextEntry, + copy_only: bool, + password: Option>, window: &mut Window, cx: &mut Context, ) { - if self.busy { - return; - } - if GLOBAL_VAULT.get().is_none() { - self.status_error = true; - self.status = "Vault unavailable. Try reopening history.".into(); + let repo = self.repository.clone(); + let filter = self.filter.clone(); + let destination = self.destination; + let weak = cx.entity().downgrade(); + self.busy = true; + cx.notify(); + if !copy_only && hide_chooser(window).is_err() { + self.busy = false; + self.status = "Verified paste unavailable. Use Copy only.".into(); cx.notify(); return; } - let expected_plaintext = if matches!(action, AuthorizedAction::Protect) { - self.selected_item() - .filter(|item| item.id == id) - .map(|item| zeroize::Zeroizing::new(item.content)) - } else { - None - }; - self.status.clear(); - self.status_warning = false; - self.password_error = None; - // Pre-fill the label input. Protect starts blank (user types fresh); - // Edit pre-fills with the row's current alias so the user only has to - // adjust when they want a change. - let preset_label = if matches!(action, AuthorizedAction::Edit) { - self.selected_item() - .filter(|item| item.id == id) - .and_then(|item| item.public_label.clone()) - .unwrap_or_default() - } else { - String::new() - }; - let placeholder = if matches!(action, AuthorizedAction::Edit) { - "Public label (optional)" - } else { - "Public label (required)" - }; - self.label.update(cx, |p, cx| { - p.set_placeholder(gpui::SharedString::from(placeholder), window, cx); - p.set_value(&preset_label, window, cx); - }); - self.prompt = Some(PasswordPrompt { - id, - action, - expected_plaintext, - prefilled_label: if matches!(action, AuthorizedAction::Edit) { - Some(preset_label) - } else { - Some(String::new()) - }, - }); - self.password - .update(cx, |p, cx| p.set_value("", window, cx)); - self.busy = true; - let vault = GLOBAL_VAULT.get().cloned().expect("vault checked above"); - cx.spawn_in(window, async move |entity, cx| { - let result = cx.background_executor().spawn(async move { - futures::executor::block_on(vault.envelope()) - }).await; - let _ = entity.update_in(cx, |this, window, cx| { - this.busy = false; - match result { - Ok(envelope) => { - this.creating_vault = envelope.is_none(); - if this.creating_vault && !matches!(action, AuthorizedAction::Protect) { - this.clear_password_prompt(window, cx); - this.status_error = true; - this.status = "The original vault is missing. Restore it to unlock protected items; a new vault cannot decrypt them.".into(); - } else { - this.open_password_dialog(window, cx); - if matches!(action, AuthorizedAction::Protect) { - this.label.update(cx, |p, cx| p.focus(window, cx)); - } else { - this.password.update(cx, |p, cx| p.focus(window, cx)); - } - } - } - Err(_) => { - this.clear_password_prompt(window, cx); - this.status_error = true; - this.status = "Could not read the clipboard vault. No changes were saved. Try reopening history.".into(); - } - } - cx.notify(); - }); + window.spawn(cx,async move|cx|{let result=cx.background_executor().spawn(async move{futures::executor::block_on(async{ + let text=repo.resolve(&entry,password.as_ref().map(|p|p.as_str())).await?; + dispatch_content(&filter,&text,destination,copy_only,entry.is_encrypted())?; + let _=repo.mark_used(entry.id(),crate::utils::time::now_unix_millis()).await;anyhow::Ok(()) + })}).await; + if let Some(entity)=weak.upgrade(){let _=entity.update_in(cx,|this,window,cx|{this.busy=false;match result{Ok(())=>{clear_popup();window.remove_window();},Err(_)=>{show_chooser(window);window.activate_window();this.status="Could not use text. Check the password or paste destination; try Copy only.".into();}}cx.notify();});} }).detach(); - cx.notify(); } - fn open_password_dialog(&self, window: &mut Window, cx: &mut Context) { - let entity = cx.entity(); - window.open_dialog(cx, move |dialog, window, cx| { - let this = entity.read(cx); - let protecting = this.prompt.as_ref().is_some_and(|p| matches!(p.action, AuthorizedAction::Protect)); - let editing = this.prompt.as_ref().is_some_and(|p| matches!(p.action, AuthorizedAction::Edit)); - let unprotecting = this.prompt.as_ref().is_some_and(|p| matches!(p.action, AuthorizedAction::Unprotect)); - let creating = this.creating_vault; - let label = this.label.clone(); - let confirmation = this.confirmation.clone(); - let busy = this.busy; - let password = this.password.clone(); - let error = this.password_error.clone(); - let submit = entity.clone(); - let cancel = entity.clone(); - let closed = entity.clone(); - dialog - .title( - if creating { "Set up clipboard vault" } - else if protecting { "Protect clipboard item" } - else if editing { "Edit clipboard item" } - else if unprotecting { "Remove item protection" } - else { "Unlock clipboard item" }, - ) - .w(window.viewport_size().width.min(px(400.0)) - px(32.0)) - .text_sm() - .overlay_closable(false) - .keyboard(false) - .close_button(!busy) - .on_close(move |_, window, cx| { - closed.update(cx, |this, cx| { - if !this.busy && this.prompt.is_some() { - this.clear_password_prompt(window, cx); - } - }); - }) - .child(v_flex().gap_3() - .child(div().text_color(cx.theme().muted_foreground).child( - if creating { "Choose a master password to securely encrypt your clipboard items." } - else if protecting { "Enter your master password to encrypt this item." } - else if editing { "Enter your master password to edit and (optionally) relabel this item." } - else if unprotecting { "Enter your master password to remove encryption from this item." } - else { "Enter your master password to use this protected item." })) - .when(creating, |body| body.child(Alert::warning("clipboard-vault-setup", "No clipboard vault exists yet. Keep your master password safe: it cannot be recovered or reset to unlock existing items.").small())) - // The label field is shared between Protect (required) and Edit - // (optional). Same input, same hint, same style; only the - // empty-check in `submit_password` differs. - .when(protecting || editing, |body| body.child(Input::new(&label).disabled(busy).w_full())) - .when(protecting || editing, |body| body.child( - div().text_xs().text_color(cx.theme().muted_foreground) - .child("Visible in history without unlocking. Do not include secrets."), - )) - .child(Input::new(&password).disabled(busy).w_full()) - .when(creating, |body| body.child(Input::new(&confirmation).disabled(busy).w_full())) - .when(error.is_some(), |body| body.child(Alert::error("clipboard-password-error", error.unwrap_or_default()).small())) - .child(div().text_xs().text_color(cx.theme().muted_foreground).child( - if protecting { "This item is encrypted only after you confirm. Existing backups may still contain the original text." } - else if editing { "Authorization is temporary. The label persists with the row; leave empty to clear it." } - else if unprotecting { "The item will be stored as plain text and ordinary history limits will apply unless pinned or edited." } - else { "Authorization is temporary. Protected copies clear after 30 seconds if unchanged." }))) - .footer(h_flex().items_center().gap_2() - .child(div().flex_1().text_xs().text_color(cx.theme().muted_foreground).child("Enter confirm · Esc cancel")) - .child(Button::new("cancel-authorization").label("Cancel").small().disabled(busy) - .on_click(move |_, window, cx| cancel.update(cx, |this, cx| this.cancel_prompt(window, cx)))) - .child(Button::new("authorize-item").label( - if creating { "Create vault & protect" } - else if protecting { "Protect" } - else if editing { "Edit" } - else if unprotecting { "Remove protection" } - else { "Unlock" }, - ).primary().small().loading(busy).disabled(busy) - .on_click(move |_, window, cx| submit.update(cx, |this, cx| this.submit_password(window, cx))))) - }); - } - - fn clear_password_prompt(&mut self, window: &mut Window, cx: &mut Context) { - self.prompt = None; - self.creating_vault = false; - self.confirmation - .update(cx, |p, cx| p.set_value("", window, cx)); - self.password_error = None; - self.password - .update(cx, |p, cx| p.set_value("", window, cx)); - if let Some(vault) = GLOBAL_VAULT.get() { - vault.lock(); - } - self.query.update(cx, |q, cx| q.focus(window, cx)); - cx.notify(); - } - fn cancel_prompt(&mut self, window: &mut Window, cx: &mut Context) { - window.close_dialog(cx); - self.clear_password_prompt(window, cx); - } - fn submit_password(&mut self, window: &mut Window, cx: &mut Context) { - if self.busy { + fn delete(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy || window.has_active_dialog(cx) { return; } - let Some(prompt) = &self.prompt else { + let Some(entry) = self.selected_entry() else { return; }; - let id = prompt.id; - let action = prompt.action; - let expected_plaintext = prompt.expected_plaintext.clone(); - let label = self.label.read(cx).value().trim().to_owned(); - let prefilled_label = prompt - .prefilled_label - .as_deref() - .unwrap_or("") - .trim() - .to_owned(); - if matches!(action, AuthorizedAction::Protect) && label.is_empty() { - self.password_error = Some("Enter a public label for this item.".into()); - self.label.update(cx, |p, cx| p.focus(window, cx)); - cx.notify(); - return; - } - let creating = self.creating_vault; - let password = zeroize::Zeroizing::new(self.password.read(cx).value().to_string()); - if creating { - let confirmation = - zeroize::Zeroizing::new(self.confirmation.read(cx).value().to_string()); - if let Err(message) = validate_new_password(&password, &confirmation) { - self.password_error = Some(message.into()); - cx.notify(); - return; - } + let weak = cx.entity().downgrade(); + if matches!(entry.id(), TextEntryId::Sticker(_)) { + window.open_alert_dialog(cx, move |dialog, _, _| { + let weak = weak.clone(); + let entry = entry.clone(); + dialog + .title("Delete saved text?") + .child("This permanently deletes the text sticker.") + .on_ok(move |_, window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.delete_entry(entry.clone(), window, cx) + }); + } + true + }) + }); + } else { + self.delete_entry(entry, window, cx); } - if password.is_empty() { - self.password_error = Some("Enter your master password.".into()); - cx.notify(); + } + fn delete_entry(&mut self, entry: TextEntry, window: &mut Window, cx: &mut Context) { + if self.busy { return; } - self.password - .update(cx, |p, cx| p.set_value("", window, cx)); - self.confirmation - .update(cx, |p, cx| p.set_value("", window, cx)); + let repo = self.repository.clone(); + let events = self.events.clone(); + let id = entry.id(); + let entity = cx.entity().downgrade(); self.busy = true; - self.password_error = None; - cx.notify(); - let Some(vault) = GLOBAL_VAULT.get().cloned() else { - self.busy = false; - return; - }; - let store = self.store.clone(); - let filter = self.filter.clone(); - let destination = self.destination; - cx.spawn_in(window, async move |entity, cx| { - let vault_for_work = vault.clone(); - let store_for_work = store.clone(); - let result = cx.background_executor().spawn(async move { - futures::executor::block_on(async { - let failures = vault_for_work.failed_attempts().clamp(0, 5) as u64; - if failures > 0 { std::thread::sleep(std::time::Duration::from_millis(250 * failures)); } - if creating { vault_for_work.create(&password).await?; } - anyhow::ensure!(vault_for_work.unlock(&password).await?, "Wrong master password"); - let current = store_for_work.get(id).await?; - if matches!(action, AuthorizedAction::Protect) { - let expected = expected_plaintext.as_ref().ok_or_else(|| anyhow::anyhow!("Original item unavailable; reload"))?; - anyhow::ensure!(current.kind == ClipboardItemKind::Plain && current.content == **expected, "Item changed; reload before protecting"); - let sealed = vault_for_work.seal_for_storage(expected.as_bytes())? - .ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; - // Persist the keyed token so later authorized collection can recognise - // this content and keep it out of ordinary plaintext rows. - let token = vault_for_work.match_token(expected.as_bytes())? - .ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; - store_for_work.compare_exchange_protection(id, expected, &sealed.ciphertext, &sealed.vault_envelope, Some(&token), Some(&label)).await?; - return Ok::<_, anyhow::Error>(AuthorizedResult::Protected); - } - anyhow::ensure!(current.kind == ClipboardItemKind::Protected, "Item changed; reload before use"); - let bytes = vault_for_work.open(¤t.content)?.ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; - let text = zeroize::Zeroizing::new(std::str::from_utf8(&bytes)?.to_owned()); - if matches!(action, AuthorizedAction::Unprotect) { - let binding = vault_for_work.seal_for_storage(text.as_bytes())?.ok_or_else(|| anyhow::anyhow!("Authorization expired"))?; - store_for_work.compare_exchange_unprotection(id, ¤t.content, &text, &binding.vault_envelope).await?; - return Ok(AuthorizedResult::Unprotected); - } - match action { - AuthorizedAction::Protect | AuthorizedAction::Unprotect => unreachable!("protection returns before decryption"), - AuthorizedAction::Use(copy_only) => { - Ok::<_, anyhow::Error>(AuthorizedResult::Use(current, text, copy_only)) - } - AuthorizedAction::Edit => Ok(AuthorizedResult::Edit(current, text, vault_for_work.clone())), - } - }) - }).await; - let result = match result { - Ok(AuthorizedResult::Use(item, text, copy_only)) => { - let hidden = if copy_only { Ok(()) } else { - entity.update_in(cx, |_, window, _| hide_chooser(window)).and_then(|r| r) - }; - match hidden { - Err(err) => Err(err), - Ok(()) => { - let store = store.clone(); - cx.background_executor().spawn(async move { - dispatch_content(&filter, &text, destination, copy_only, true)?; - futures::executor::block_on(store.mark_used(item.id, now_unix_millis()))?; - Ok::<_, anyhow::Error>(AuthorizedResult::Use(item, text, copy_only)) - }).await - } - } + window + .spawn(cx, async move |cx| { + let result = repo.delete(id).await; + if let TextEntryId::Sticker(id) = id { + let _ = events.send(StickerWindowEvent::Created { id }); + let _ = cx.update(|_, cx| StickerWindow::try_close(id, cx)); } - other => other, - }; - // Creation can succeed before protection fails. Check persistence before - // deciding whether retry should create or unlock; never replace a vault. - let still_unconfigured = if creating { - let vault = vault.clone(); - cx.background_executor().spawn(async move { - matches!(futures::executor::block_on(vault.envelope()), Ok(None)) - }).await - } else { false }; - // One-shot use; only the protected editor retains a scoped key. - let editor_result = matches!(result, Ok(AuthorizedResult::Edit(..))); - if !editor_result { vault.lock(); } - let update = entity.update_in(cx, |this, window, cx| { - this.busy = false; - // Creation may succeed even when subsequent protection fails. Never replace it on retry. - if creating { this.creating_vault = still_unconfigured; } - match result { - Ok(AuthorizedResult::Unprotected) => { - this.cancel_prompt(window, cx); - this.status_error = false; - this.status_warning = true; - this.status = "Protection removed. This item is now stored as plain text.".into(); - this.refresh(window, cx); - } - Ok(AuthorizedResult::Protected) => { - this.cancel_prompt(window, cx); - this.status_error = false; this.status = "Item protected. Future copies need the vault unlocked to join ordinary history.".into(); - // Erase the stale chooser snapshot before the asynchronous refresh. - for row in this.rows.iter_mut().filter(|row| row.id == id) { - row.content.clear(); row.plain_preview = None; - row.kind = ClipboardItemKind::Protected; row.retention.protected_ = true; + if let Some(entity) = entity.upgrade() { + let _ = entity.update_in(cx, |this, _, cx| { + this.busy = false; + if result.is_err() { + this.status = "Could not delete text.".into(); } - this.refresh(window, cx); - } - Ok(AuthorizedResult::Use(..)) => { this.cancel_prompt(window, cx); clear_popup(); window.remove_window(); } - Ok(AuthorizedResult::Edit(item, text, vault)) => { - window.close_dialog(cx); - this.prompt = None; this.password_error = None; - this.password.update(cx, |p, cx| p.set_value("", window, cx)); - this.editor_open = true; - let parent = cx.weak_entity(); - // Forward the label only when it differs from the dialog's - // pre-fill. An untouched input -> `None` so the editor - // leaves the column alone (no overwrite). A clearance -> - // `Some("")` so the store normalises it to NULL. - let typed_label = this.label.read(cx).value().trim().to_owned(); - let prefilled_label = if typed_label == prefilled_label { - None - } else { - Some(typed_label) - }; - if editor::open(window, cx, this.store.clone(), item, parent, Some((text, vault.clone())), prefilled_label).is_err() { this.editor_open = false; vault.lock(); this.status_error = true; this.status = "Could not open the encrypted editor.".into(); } - // Drop the dialog's label input back to empty so the next - // protect/unprotect pass doesn't leak a stale draft into the - // wrong prompt. - this.label.update(cx, |p, cx| p.set_value("", window, cx)); - } - Err(err) => { - show_chooser(window); - this.password_error = Some(if err.to_string().contains("No master password") { - "No clipboard vault yet. Protect an item here, or run: rst vault create." - } else if err.to_string() == "Wrong master password" { - "Incorrect master password. Try again." - } else if matches!(action, AuthorizedAction::Protect) { - "Could not protect this item. If setup succeeded, use your new master password. Refresh history if the item changed." - } else { - "Could not unlock or use this item. Check your password, or cancel and try Copy only." - }.into()); - window.activate_window(); this.password.update(cx, |p, cx| p.focus(window, cx)); - } + cx.notify(); + }); } - cx.notify(); - }); - if update.is_err() && editor_result { vault.lock(); } - }).detach(); + }) + .detach(); } fn toggle_capture(&mut self, window: &mut Window, cx: &mut Context) { if self.busy { return; } - self.status.clear(); - self.status_warning = false; self.busy = true; - let store = self.store.clone(); - cx.spawn_in(window, async move |entity, cx| { - let result = async { - let mut settings = store.settings().await?; - settings.capture_enabled = !settings.capture_enabled; - store.update_settings(&settings).await?; - Ok::<_, anyhow::Error>(settings.capture_enabled) - } - .await; - let _ = entity.update_in(cx, |this, _, cx| { - this.busy = false; - this.status_warning = false; - this.status_error = result.is_err(); - match result { - Ok(enabled) => { - this.capture_enabled = enabled; - this.status_warning = enabled; - this.status = if enabled { - "Collection enabled. Copied text, including secrets, will be saved." - } else { - "Collection paused. Your existing history is still available." - } - .into(); - } - Err(_) => { - this.status = - "Could not update collection. Your previous setting is unchanged." - .into() - } + cx.notify(); + let repo = self.repository.clone(); + let entity = cx.entity().downgrade(); + window + .spawn(cx, async move |cx| { + let result = async { + let mut settings = repo.settings().await?; + settings.capture_enabled = !settings.capture_enabled; + repo.update_settings(&settings).await?; + anyhow::Ok(settings.capture_enabled) } - cx.notify(); - }); - }) - .detach(); + .await; + if let Some(entity) = entity.upgrade() { + let _ = entity.update_in(cx, |this, _, cx| { + this.busy = false; + match result { + Ok(enabled) => { + this.capture_enabled = enabled; + this.status.clear(); + } + Err(_) => this.status = "Could not update collection settings.".into(), + }; + cx.notify(); + }); + } + }) + .detach(); } - fn remove_selected(&mut self, window: &mut Window, cx: &mut Context) { - if self.busy || self.prompt.is_some() || self.editor_open { + fn key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let key = event.keystroke.key.as_str(); + let command = event.keystroke.modifiers.control || event.keystroke.modifiers.platform; + if window.has_active_dialog(cx) { return; } - let Some(item) = self.selected_item() else { - return; - }; - // Instant delete is fine for ordinary disposable items -- the - // chooser is keyboard-first and the user can always re-copy. - // Protected and explicitly retained items need a confirmation - // because the action is irreversible (a pinned note, an - // edited entry, or a protected envelope's plaintext cannot be - // recovered once the store drops them). The design-guide rule - // applies here: name the object, name the consequence, no - // blanket confirmation ritual. - if item.retention.is_retained() || protected(&item) { - self.confirm_remove(item, window, cx); + // Menus consume Escape first; toolbar focus must not strand the utility. + if key != "escape" && !self.query.focus_handle(cx).contains_focused(window, cx) { return; } - self.busy = true; - let store = self.store.clone(); - cx.spawn_in(window, async move |entity, cx| { - let result = store.delete_row(item.id).await; - let _ = entity.update_in(cx, |this, window, cx| { - this.busy = false; - this.status_error = result.is_err(); - this.status_warning = false; - this.status = if result.is_ok() { - "Item removed." - } else { - "Could not remove this item. Try again." + match key { + "escape" => { + if !self.busy { + clear_popup(); + window.remove_window(); } - .into(); - this.refresh(window, cx); - }); - }) - .detach(); - } - - /// Confirm-then-delete for items whose removal is irreversible from - /// the chooser's point of view. The body spells out the consequence - /// of the action -- "removes the pin", "removes your edit", - /// "removes the encryption envelope" -- rather than "Are you sure?" - /// so the user can decide without consulting a separate doc. - fn confirm_remove(&mut self, item: ClipboardItem, window: &mut Window, cx: &mut Context) { - let id = item.id; - let preview = item.preview_text(); - let already_protected = protected(&item); - let retain_reasons = ( - item.retention.explicit(), - item.retention.edited(), - already_protected, - ); - let body: &'static str = match retain_reasons { - (true, _, true) => { - "This item is pinned and encrypted. Forgetting it removes both the pin and the encryption envelope -- it falls off your history and the plaintext is gone for good." - } - (true, _, false) => { - "This item is pinned. Forgetting it unpins the entry and removes it from your history." } - (false, true, true) => { - "This item carries your edit and is encrypted. Forgetting it removes the edit and the encryption envelope -- the original captured text is gone for good." - } - (false, true, false) => { - "This item carries your edit. Forgetting it removes the edit and the entry from your history." - } - (false, false, true) => { - "This item is encrypted. Forgetting it removes the encryption envelope -- the plaintext is gone for good." - } - // Caller only routes here when at least one of those flags - // is true. The exhaustion arm here is defensive; if the - // route ever changes, the explanatory copy needs updating. - (false, false, false) => "This item's history entry will be removed.", + "up" => self.move_selection(-1, cx), + "down" => self.move_selection(1, cx), + "pageup" => self.move_selection(-6, cx), + "pagedown" => self.move_selection(6, cx), + "enter" => self.use_selected(command || self.destination.is_none(), window, cx), + "f2" => self.save(false, true, window, cx), + "delete" if self.query.read(cx).value().is_empty() => self.delete(window, cx), + "r" if command => self.save(false, false, window, cx), + "l" if command => self.save(true, false, window, cx), + _ => return, }; - - let entity = cx.entity(); - let store = self.store.clone(); - window.open_dialog(cx, move |dialog, _, _| { - let entity = entity.clone(); - let store = store.clone(); - dialog - .title(format!("Forget \"{preview}\"?")) - .child(div().text_sm().child(body)) - .footer( - h_flex() - .gap_2() - .justify_end() - .child( - Button::new("cancel-forget") - .label("Cancel") - .small() - .on_click(|_, window, cx| { - window.close_dialog(cx); - }), - ) - .child( - Button::new("confirm-forget") - .label("Forget") - .danger() - .small() - .on_click(move |_, window, cx| { - window.close_dialog(cx); - let store = store.clone(); - let entity = entity.clone(); - window - .spawn(cx, async move |cx| { - let result = store.delete_row(id).await; - let _ = entity.update_in(cx, |this, window, cx| { - this.busy = false; - this.status_error = result.is_err(); - this.status_warning = false; - this.status = if result.is_ok() { - "Item forgotten." - } else { - "Could not forget this item. Try again." - } - .into(); - this.refresh(window, cx); - cx.notify(); - }); - }) - .detach(); - }), - ), - ) - .w(px(420.0)) - }); + cx.stop_propagation(); + } +} +impl Focusable for TextPicker { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.query.focus_handle(cx) } - fn row(&self, index: usize, cx: &mut Context) -> gpui::AnyElement { - let item = &self.rows[self.filtered[index]]; - let pinned = item.retention.explicit(); - let locked = protected(item); - // The persistent pin badge: any reason retention holds (pinned OR - // edited) shows the pin icon -- unless the row is locked, in which - // case the lock icon wins. The badge is always visible regardless of - // hover; the existing actions row already disables the pin button on - // locked items, so there's no visual conflict. - let show_pin_badge = item.retention.is_retained() && !locked; - let disabled = self.busy || self.prompt.is_some() || self.editor_open; - let hovered = self.hovered_row == Some(item.id); - let id = item.id; - let actions = h_flex() - .gap_0() +} +impl TextPicker { + fn render_header(&self, cx: &mut Context) -> impl IntoElement { + h_flex() .flex_shrink_0() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .when(!locked && hovered, |actions| { - actions.child( - div().when(!hovered, |el| el.hidden()).child( - Button::new(("row-pin", item.id as usize)) - .icon(IconName::Pin) - .ghost() - .small() - .selected(pinned) - .disabled(disabled) - .tooltip(if pinned { - "Unpin · Ctrl/Cmd+R" - } else { - "Pin · Ctrl/Cmd+R" - }) - .on_click(cx.listener(move |this, _, w, cx| { - cx.stop_propagation(); - this.selected = index; - this.reserve(w, cx); - })), - ), - ) - }) + .px_2() + .py_2() + .gap_1() + .window_control_area(WindowControlArea::Drag) .child( - div().when(!hovered, |el| el.hidden()).child( - Button::new(("row-copy", item.id as usize)) - .icon(IconName::Copy) - .ghost() - .small() - .disabled(disabled) - .tooltip("Copy only · Ctrl/Cmd+Enter") - .on_click(cx.listener(move |this, _, w, cx| { - cx.stop_propagation(); - this.selected = index; - this.use_selected(true, w, cx); - })), + div().flex_1().min_w_0().occlude().child( + Input::new(&self.query) + .prefix(Icon::new(IconName::Search)) + .cleanable(true) + .border_0() + .w_full() + .disabled(self.busy), ), ) .child( - div() - .when(!locked, |el| el.when(!hovered, |el| el.hidden())) - .child( - Button::new(("row-lock", item.id as usize)) - .icon(if locked { - IconName::LockClosed - } else { - IconName::LockOpen - }) - .ghost() - .small() - .selected(locked) - .disabled(disabled) - .tooltip(if locked { - "Remove protection · master password required · Ctrl/Cmd+L" - } else { - "Protect with a public label · Ctrl/Cmd+L" - }) - .on_click(cx.listener(move |this, _, w, cx| { - cx.stop_propagation(); - this.selected = index; - this.protect_selected(w, cx); - })), - ), + Button::new("master-password") + .icon(IconName::LockClosed) + .ghost() + .small() + .occlude() + .tooltip("Master password…") + .disabled(self.busy) + .on_click(cx.listener(|this, _, window, cx| { + super::text_password::settings(window, cx, this.repository.clone()) + })), ) .child( - div().when(!hovered, |el| el.hidden()).child( - Button::new(("row-remove", item.id as usize)) - .icon(IconName::Delete) + Button::new("close-texts") + .icon(IconName::Close) + .ghost() + .small() + .occlude() + .tooltip("Close · Esc") + .disabled(self.busy) + .on_click(|_, window, _| { + clear_popup(); + window.remove_window(); + }), + ) + } + + fn render_filters(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .px_3() + .pb_2() + .gap_1() + .flex_shrink_0() + .children( + [ + (SourceFilter::All, "all-texts", "All"), + (SourceFilter::Saved, "saved-texts", "Saved"), + (SourceFilter::Clipboard, "clipboard-texts", "Clipboard"), + ] + .into_iter() + .map(|(source, id, label)| { + Button::new(id) + .label(label) .ghost() .small() - .disabled(disabled) - .tooltip("Delete this item from clipboard history") - .on_click(cx.listener(move |this, _, w, cx| { - cx.stop_propagation(); - this.selected = index; - this.remove_selected(w, cx); - })), - ), - ); + .selected(self.source == source) + .disabled(self.busy) + .on_click(cx.listener(move |this, _, window, cx| { + this.source = source; + this.selected = 0; + this.refilter(cx); + this.scroll.scroll_to_item(0, ScrollStrategy::Nearest); + this.status.clear(); + this.query.update(cx, |query, cx| query.focus(window, cx)); + cx.notify(); + })) + }), + ) + .child(div().flex_1()) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!( + "{} {}", + self.filtered.len(), + if self.filtered.len() == 1 { + "text" + } else { + "texts" + } + )), + ) + } + + fn render_row(&self, ix: usize, cx: &mut Context) -> gpui::AnyElement { + let entry = &self.rows[self.filtered[ix]]; + let id = entry.id(); + let encrypted = entry.is_encrypted(); + let saved = matches!(id, TextEntryId::Sticker(_)); + let selected = ix == self.selected; + let source = if encrypted { + "Saved · Locked" + } else if saved { + "Saved" + } else { + "Clipboard" + }; h_flex() - .id(("clipboard-row", item.id as usize)) - .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { - if *hovered { - this.hovered_row = Some(id); - } else if this.hovered_row == Some(id) { - this.hovered_row = None; - } - cx.notify(); - })) + .id(gpui::SharedString::from(id.to_string())) .w_full() - .items_center() - .h(px(44.0)) - .px_2() - .gap_1() + .h_12() + .px_3() + .gap_3() .rounded_md() .border_1() - .border_color(if index == self.selected { + .border_color(if selected { cx.theme().list_active_border } else { cx.theme().transparent }) - .bg(if index == self.selected { + .bg(if selected { cx.theme().list_active } else { cx.theme().transparent }) - .hover(|row| row.bg(cx.theme().list_hover)) + .when(!selected && !self.busy, |row| { + row.hover(|row| row.bg(cx.theme().list_hover)) + }) .on_mouse_down( MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, window, cx| { - if this.busy || this.prompt.is_some() || this.editor_open { + if this.busy || window.has_active_dialog(cx) { return; } - this.selected = index; - this.query.update(cx, |q, cx| q.focus(window, cx)); + this.selected = ix; + this.status.clear(); + this.query.update(cx, |query, cx| query.focus(window, cx)); if event.click_count == 2 { - this.edit(window, cx); + this.save(false, true, window, cx); } cx.notify(); }), ) .child( - div() + Icon::new(if encrypted { + IconName::LockClosed + } else if saved { + IconName::DocumentText + } else { + IconName::Copy + }) + .size_4() + .flex_shrink_0() + .text_color(cx.theme().muted_foreground), + ) + .child( + v_flex() .flex_1() .min_w_0() - .text_sm() - .truncate() - .child(display_preview(item)), + .gap_1() + .child(div().text_sm().truncate().child(entry.preview().to_owned())) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(source) + .child(recency_label( + entry.activity_at(), + crate::utils::time::now_unix_millis(), + )), + ), + ) + .child( + Button::new(gpui::SharedString::from(format!("copy-{id}"))) + .icon(IconName::Copy) + .ghost() + .small() + .tooltip("Copy only · Ctrl+Enter") + .disabled(self.busy) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + // Use domain identity: refreshes can change a row's positional index. + if let Some(ix) = this + .filtered + .iter() + .position(|ix| this.rows[*ix].id() == id) + { + this.selected = ix; + this.use_selected(true, window, cx); + } + })), ) - .when(show_pin_badge, |row| { - row.child( - Icon::new(IconName::Pin) - .flex_shrink_0() - .with_size(px(14.0)) - .text_color(cx.theme().muted_foreground), - ) - }) - .when(hovered || locked, |row| row.child(actions)) .into_any_element() } -} -impl Focusable for ClipboardChooserWidget { - fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - self.query.read(cx).focus_handle(cx) + + fn rename(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + let Some(entry) = self.selected_entry() else { + return; + }; + if !matches!(entry.id(), TextEntryId::Sticker(_)) { + return; + } + let repo = self.repository.clone(); + let events = self.events.clone(); + let weak = cx.weak_entity(); + let initial = entry.title().to_owned(); + super::text_title::prompt( + window, + cx, + false, + &initial, + Box::new(move |title, window, cx| { + window + .spawn(cx, async move |cx| { + let result = repo.update_title(entry.id(), &title).await; + if result.is_ok() + && let TextEntryId::Sticker(id) = entry.id() + { + let _ = events.send(StickerWindowEvent::TitleChanged { id, title }); + } + let _ = weak.update_in(cx, |this, _, cx| { + this.status = if result.is_ok() { + "Title updated" + } else { + "Could not update title. Try again." + } + .into(); + cx.notify(); + }); + }) + .detach(); + }), + ); } -} -impl Render for ClipboardChooserWidget { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render_actions(&self, cx: &mut Context) -> impl IntoElement { + let selected = self.selected_entry(); + let capture = selected + .as_ref() + .is_some_and(|entry| matches!(entry.id(), TextEntryId::Clipboard(_))); + let encrypted = selected.as_ref().is_some_and(TextEntry::is_encrypted); + let disabled = self.busy || selected.is_none(); let view = cx.entity(); - let count = self.filtered.len(); - let disabled = self.busy || self.prompt.is_some() || self.editor_open; - let has_protected = self.rows.iter().any(protected); - let collector_label = if !self.capture_enabled { - "Collection paused" - } else if has_protected { - "Collection paused · protected history" - } else { - "Collecting" - }; - v_flex() - .relative() - .size_full() - .rounded_lg() - .overflow_hidden() - .bg(cx.theme().background) - .text_color(cx.theme().foreground) + h_flex() + .flex_shrink_0() + .px_3() + .py_2() + .gap_1() + .border_t_1() + .border_color(cx.theme().border) .child( - h_flex() - .flex_shrink_0() - .items_center() - .gap_2() - .px_3() - .py_2() - .window_control_area(WindowControlArea::Drag) - .child(div().flex_1().text_sm().child("Clipboard history")) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!("{count} items")), - ) - .child( - Button::new("refresh") - .icon(IconName::Refresh) - .ghost() - .small() - .tooltip("Refresh history") - .occlude() - .disabled(disabled) - .on_click(cx.listener(|this, _, w, cx| this.refresh(w, cx))), - ) - .child( - Button::new("close-clipboard") - .icon(IconName::Close) - .ghost() - .small() - .tooltip("Close · Esc") - .occlude() - .disabled(disabled) - .on_click(cx.listener(|_, _, window, _| { - clear_popup(); - window.remove_window(); - })), + Button::new("text-actions") + .label("Actions") + .icon(IconName::Adjustments) + .ghost() + .small() + .disabled(disabled) + .dropdown_menu(move |menu, window, _| { + menu.item( + PopupMenuItem::new("Rename…").disabled(capture).on_click( + window.listener_for(&view, |this, _, window, cx| { + this.rename(window, cx) + }), + ), + ) + .item( + PopupMenuItem::new("Edit…") + .icon(IconName::DocumentText) + .on_click(window.listener_for(&view, |this, _, window, cx| { + this.save(false, true, window, cx) + })), + ) + .item( + PopupMenuItem::new("Save as sticker") + .icon(IconName::Pin) + .disabled(!capture) + .on_click(window.listener_for(&view, |this, _, window, cx| { + this.save(false, false, window, cx) + })), + ) + .item( + PopupMenuItem::new(if capture { "Save encrypted" } else { "Lock" }) + .icon(IconName::LockClosed) + .disabled(encrypted) + .on_click(window.listener_for(&view, |this, _, window, cx| { + this.save(true, false, window, cx) + })), + ) + .separator() + .item( + PopupMenuItem::new(if capture { + "Delete capture" + } else { + "Delete saved text…" + }) + .icon(IconName::Delete) + .on_click( + window.listener_for(&view, |this, _, window, cx| { + this.delete(window, cx) + }), + ), + ) + }), + ) + .child(div().flex_1()) + .child( + Button::new("copy-text") + .label("Copy") + .icon(IconName::Copy) + .small() + .ghost() + .disabled(disabled) + .tooltip("Copy only · Ctrl+Enter") + .on_click( + cx.listener(|this, _, window, cx| this.use_selected(true, window, cx)), ), ) .child( - div().px_3().pb_2().flex_shrink_0().child( - Input::new(&self.query) - .cleanable(true) - .prefix(Icon::new(IconName::Search)) - .disabled(disabled) - .w_full(), - ), + Button::new("paste-text") + .label("Paste") + .icon(IconName::ArrowRight) + .small() + .primary() + .disabled(disabled || self.destination.is_none()) + .tooltip(if self.destination.is_some() { + "Paste to previous app · Enter" + } else { + "Open TXTs with the global shortcut to paste to another app" + }) + .on_click( + cx.listener(|this, _, window, cx| this.use_selected(false, window, cx)), + ), ) + } + + fn render_footer(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .flex_shrink_0() + .px_3() + .pb_2() + .gap_1() .child( div() .flex_1() - .min_h_0() - .w_full() - .px_2() - .overflow_hidden() - .when(count > 0, |el| { - el.child( - uniform_list("clipboard-history", count, move |range, _, cx| { - view.update(cx, |this, cx| { - range.map(|index| this.row(index, cx)).collect::>() - }) - }) - .size_full() - .track_scroll(&self.scroll), - ) - }) - .when(count == 0, |el| { - el.child( - v_flex() - .size_full() - .items_center() - .justify_center() - .gap_2() - .px_4() - .child(Icon::new(IconName::DocumentText).with_size(px(24.0))) - .child(div().text_sm().child(if self.loading { - "Loading history…" - } else if self.rows.is_empty() { - "Your copied text will appear here" - } else { - "No matching items" - })) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(if self.rows.is_empty() { - "Enable collection below to get started." - } else { - "Try a different search or clear the filter." - }), - ), - ) + .min_w_0() + .text_xs() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(if self.busy { + "Working…".to_owned() + } else if !self.status.is_empty() { + self.status.clone() + } else { + if self.destination.is_some() { + "↑↓ select · Enter paste · F2 edit" + } else { + "↑↓ select · Enter copy · F2 edit" + } + .into() }), ) .child( - v_flex() - .flex_shrink_0() - .gap_2() - .px_3() - .py_2() - .border_t_1() - .border_color(cx.theme().border) - .child( - h_flex() - .items_center() - .justify_between() - .gap_2() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("↑↓ · Enter paste · F2 edit · Esc"), - ) - .child( - Button::new("toggle-collection") - .label(if self.capture_enabled { - "Pause" - } else { - "Collect" - }) - .icon(if self.capture_enabled { - IconName::Pause - } else { - IconName::Play - }) - .ghost() - .small() - .tooltip(if self.capture_enabled { - collector_label - } else { - "Enable collection. Copied secrets can enter history." - }) - .disabled(disabled) - .on_click( - cx.listener(|this, _, w, cx| this.toggle_capture(w, cx)), - ), - ), - ) - .when(!self.status.is_empty() && self.prompt.is_none(), |footer| { - footer.child(if self.status_error { - Alert::error("clipboard-status", self.status.clone()).small() - } else if self.status_warning { - Alert::warning("clipboard-status", self.status.clone()).small() - } else { - Alert::info("clipboard-status", self.status.clone()).small() - }) - }), + Button::new("capture-toggle") + .label(if self.capture_enabled { + "Collecting" + } else { + "Paused" + }) + .icon(if self.capture_enabled { + IconName::Pause + } else { + IconName::Play + }) + .ghost() + .small() + .disabled(self.busy) + .tooltip(if self.capture_enabled { + "Pause clipboard collection" + } else { + "Enable clipboard collection. Copied secrets can enter history." + }) + .on_click(cx.listener(|this, _, window, cx| this.toggle_capture(window, cx))), ) } } +impl Render for TextPicker { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let count = self.filtered.len(); + v_flex().size_full().relative().rounded_lg().overflow_hidden() + .bg(cx.theme().background).text_color(cx.theme().foreground) + .track_focus(&self.query.focus_handle(cx)).on_key_down(cx.listener(Self::key)) + .child(self.render_header(cx)) + .child(self.render_filters(cx)) + .child(div().flex_1().min_h_0().w_full().overflow_hidden() + .when(count > 0, |body| body.child(uniform_list("texts-list", count, move |range, _, cx| { + view.update(cx, |this, cx| range.map(|ix| this.render_row(ix, cx)).collect::>()) + }).size_full().px_2().track_scroll(&self.scroll))) + .when(count == 0, |body| body.child(v_flex().size_full().items_center().justify_center().gap_2().px_4() + .child(Icon::new(IconName::DocumentText).size_6().text_color(cx.theme().muted_foreground)) + .child(div().text_sm().child(if self.loading { "Loading texts…" } else if !self.query.read(cx).value().is_empty() { "No matching texts" } else if self.source == SourceFilter::Saved { "No saved texts yet" } else if self.source == SourceFilter::Clipboard { "No clipboard captures yet" } else { "Your text, within reach" })) + .child(div().text_xs().text_center().text_color(cx.theme().muted_foreground) + .child(if !self.query.read(cx).value().is_empty() { "Try another search or clear the filter." } else if self.source == SourceFilter::Saved { "Save a capture from the Actions menu to keep it." } else if self.capture_enabled { "Copied text will appear here. Saved stickers are always kept." } else { "Enable collection below, or create a text sticker." }))))) + .child(self.render_actions(cx)) + .child(self.render_footer(cx)) + } +} +#[cfg(test)] +mod picker_tests { + use super::*; + #[test] + fn source_filters_keep_typed_identity_separate() { + assert!(SourceFilter::All.includes(TextEntryId::Sticker(1))); + assert!(SourceFilter::All.includes(TextEntryId::Clipboard(1))); + assert!(SourceFilter::Saved.includes(TextEntryId::Sticker(1))); + assert!(!SourceFilter::Saved.includes(TextEntryId::Clipboard(1))); + assert!(SourceFilter::Clipboard.includes(TextEntryId::Clipboard(1))); + assert!(!SourceFilter::Clipboard.includes(TextEntryId::Sticker(1))); + } + #[test] + fn relative_time_handles_future_and_boundaries() { + assert_eq!(recency_label(10_000, 0), "Just now"); + assert_eq!(recency_label(0, 60_000), "1m ago"); + assert_eq!(recency_label(0, 3_600_000), "1h ago"); + assert_eq!(recency_label(0, 86_400_000), "Yesterday"); + assert_eq!(recency_label(0, 172_800_000), "2d ago"); + } +} fn dispatch_content( filter: &Arc, content: &str, @@ -1551,6 +1088,8 @@ fn dispatch_content( let generation = crate::native::clipboard::watcher::write_clipboard_text_owned(filter, content)?; if secret { + *OWNED_SECRET.lock().unwrap_or_else(|e| e.into_inner()) = + Some((filter.clone(), generation)); let filter = filter.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(30)); @@ -1563,12 +1102,11 @@ fn dispatch_content( } Ok(()) } - #[cfg(target_os = "windows")] fn hide_chooser(window: &Window) -> anyhow::Result<()> { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; let RawWindowHandle::Win32(handle) = HasWindowHandle::window_handle(window)?.as_raw() else { - anyhow::bail!("No native chooser handle"); + anyhow::bail!("No native chooser handle") }; unsafe { windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( @@ -1596,63 +1134,7 @@ fn show_chooser(window: &Window) { ); } } + super::set_clipboard_escape_target(true); } #[cfg(not(target_os = "windows"))] fn show_chooser(_: &Window) {} - -#[cfg(test)] -mod tests { - use super::*; - fn item() -> ClipboardItem { - ClipboardItem { - id: 1, - kind: ClipboardItemKind::Plain, - content: "secret\n\t\u{1b}".into(), - plain_preview: Some("secret".into()), - public_label: None, - byte_size: 9, - created_at: 0, - last_copied_at: 0, - last_used_at: None, - last_edited_at: None, - retention: Default::default(), - } - } - #[test] - fn clipboard_action_icons_are_embedded() { - use crate::gpui_component::IconNamed; - for icon in [IconName::Copy, IconName::Delete] { - assert!(crate::native::components::Assets::get(icon.path().as_ref()).is_some()); - } - } - #[test] - fn vault_setup_requires_matching_passwords() { - assert!(validate_new_password("", "").is_err()); - assert!(validate_new_password("a", "a").is_ok()); - assert!(validate_new_password(" ", " ").is_ok()); - assert!(validate_new_password("long-password", "different-password").is_err()); - assert!(validate_new_password("long-password", "long-password").is_ok()); - } - #[test] - fn protected_preview_never_falls_back_to_content() { - let mut row = item(); - row.kind = ClipboardItemKind::Protected; - assert_eq!(display_preview(&row), "Protected item"); - redact_protected_previews(std::slice::from_mut(&mut row)); - assert!(row.content.is_empty()); - assert!(row.plain_preview.is_none()); - } - #[test] - fn legacy_protection_flag_is_fail_closed() { - let mut row = item(); - row.retention.protected_ = true; - assert!(protected(&row)); - assert_eq!(display_preview(&row), "Protected item"); - } - #[test] - fn previews_escape_controls_without_changing_payload() { - let row = item(); - assert!(!display_preview(&row).chars().any(char::is_control)); - assert!(row.content.contains('\n')); - } -} diff --git a/src/native/windows/clipboard/editor.rs b/src/native/windows/clipboard/editor.rs deleted file mode 100644 index d7b13f1..0000000 --- a/src/native/windows/clipboard/editor.rs +++ /dev/null @@ -1,383 +0,0 @@ -//! In-history modal editor with atomic saves and scoped protected authorization. -use super::{ClipboardChooserWidget, protected}; -use crate::clipboard::{items::ClipboardItem, storage::ClipboardHistoryStore}; - -use crate::gpui_component::{ - ActiveTheme, Disableable, Sizable, WindowExt, - alert::Alert, - button::{Button, ButtonVariants}, - h_flex, - input::{Input, InputState, Textarea, TextareaState}, - v_flex, -}; -use gpui::{ - App, Context, Entity, Focusable, IntoElement, Render, Subscription, WeakEntity, Window, div, - prelude::*, px, -}; -use std::sync::Arc; - -pub(super) fn open( - window: &mut Window, - cx: &mut App, - store: Arc, - initial: ClipboardItem, - parent: WeakEntity, - authorized: Option<( - zeroize::Zeroizing, - Arc, - )>, - prefilled_label: Option, -) -> anyhow::Result<()> { - anyhow::ensure!( - !protected(&initial) || authorized.is_some(), - "Encrypted editing requires authorization" - ); - let view = cx.new(|cx| { - ClipboardEditor::new( - window, - cx, - store, - initial, - parent, - authorized, - prefilled_label, - ) - }); - let focus = view.read(cx).focus_handle(cx); - window.open_dialog(cx, move |dialog, window, cx| { - dialog - .title( - h_flex() - .w_full() - .items_center() - .child(div().flex_1().child("Edit clipboard text")) - .child( - Button::new("close-clipboard-editor") - .icon(crate::native::components::IconName::Close) - .ghost() - .small() - .tooltip("Close · Esc") - .disabled(view.read(cx).saving) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |this, cx| this.cancel(window, cx)); - } - }), - ), - ) - .w(window.viewport_size().width.min(px(500.0)) - px(32.0)) - .text_sm() - .overlay_closable(false) - .keyboard(false) - .close_button(false) - .child(view.clone()) - }); - focus.focus(window, cx); - Ok(()) -} -struct ClipboardEditor { - store: Arc, - initial: ClipboardItem, - content: Entity, - /// Optional public alias. Pre-filled from `initial.public_label` (or from the - /// caller when the protected-edit password dialog pre-fills with a fresh value). - /// Empty/whitespace maps to `public_label = NULL` at save time. - label: Entity, - original_label: Option, - parent: WeakEntity, - original_plain: zeroize::Zeroizing, - vault: Option>, - saving: bool, - confirm_discard: bool, - error: Option, - _keys: Subscription, -} -impl ClipboardEditor { - fn new( - window: &mut Window, - cx: &mut Context, - store: Arc, - initial: ClipboardItem, - parent: WeakEntity, - authorized: Option<( - zeroize::Zeroizing, - Arc, - )>, - prefilled_label: Option, - ) -> Self { - let (original_plain, vault) = match authorized { - Some((text, vault)) => (text, Some(vault)), - None => (zeroize::Zeroizing::new(initial.content.clone()), None), - }; - // Pre-fill priority: - // `Some(s)` -- caller is being explicit; empty/whitespace-only values - // shorten to `""` and through the save pipeline null out the column. - // `None` -- the password dialog saw no edit; fall back to the row's - // stored alias so an idle unlock doesn't change the alias. - let initial_label = initial - .public_label - .as_deref() - .unwrap_or("") - .trim() - .to_owned(); - let start_with_label = match prefilled_label.as_deref() { - Some(p) => p.trim().to_owned(), - None => initial_label.clone(), - }; - let label_state = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Public label (optional)") - .default_value(start_with_label.clone()) - }); - let content = cx.new(|cx| { - let mut text = TextareaState::new(window, cx); - text.set_value(original_plain.to_string(), window, cx); - text - }); - let own_id = window.window_handle().window_id(); - let entity = cx.weak_entity(); - let keys = cx.intercept_keystrokes(move |event, window, cx| { - if window.window_handle().window_id() != own_id || !window.is_window_active() { - return; - } - let primary = if cfg!(target_os = "macos") { - event.keystroke.modifiers.platform || event.keystroke.modifiers.control - } else { - event.keystroke.modifiers.control - }; - let Some(entity) = entity.upgrade() else { - return; - }; - let handled = match event.keystroke.key.as_str() { - "s" if primary => { - entity.update(cx, |this, cx| this.save(window, cx)); - true - } - "escape" => { - entity.update(cx, |this, cx| this.cancel(window, cx)); - true - } - _ => false, - }; - if handled { - cx.stop_propagation(); - window.prevent_default(); - } - }); - content.update(cx, |text, cx| text.focus(window, cx)); - let protected_editor = vault.is_some(); - if protected_editor { - cx.spawn_in(window, async move |entity, cx| { - cx.background_executor() - .timer(std::time::Duration::from_secs(300)) - .await; - let _ = entity.update_in(cx, |this, window, cx| this.close(window, cx)); - }) - .detach(); - } - Self { - store, - initial, - content, - label: label_state, - original_label: Some(start_with_label), - parent, - original_plain, - vault, - saving: false, - confirm_discard: false, - error: None, - _keys: keys, - } - } - fn close(&mut self, window: &mut Window, cx: &mut Context) { - self.content - .update(cx, |text, cx| text.set_value("", window, cx)); - if let Some(vault) = &self.vault { - vault.lock(); - } - self.original_plain.clear(); - window.close_dialog(cx); - let _ = self.parent.update(cx, |parent, cx| { - parent.editor_open = false; - parent.query.update(cx, |q, cx| q.focus(window, cx)); - cx.notify(); - }); - } - fn cancel(&mut self, window: &mut Window, cx: &mut Context) { - if self.saving { - return; - } - let content_changed = - self.content.read(cx).value().as_str() != self.original_plain.as_str(); - let label_changed = - self.label.read(cx).value().as_str() != self.original_label.as_deref().unwrap_or(""); - if (content_changed || label_changed) && !self.confirm_discard { - self.confirm_discard = true; - self.error = Some( - "Unsaved changes. Press Esc again or Cancel to discard; Save keeps them.".into(), - ); - cx.notify(); - return; - } - self.close(window, cx); - } - fn save(&mut self, window: &mut Window, cx: &mut Context) { - if self.saving { - return; - } - let text = zeroize::Zeroizing::new(self.content.read(cx).value().to_string()); - // Read the label before we commit to saving so we can diff it against the - // pre-fill on this same open -- "label-only changes" still go through the - // store so the user's edit isn't lost the moment they rescue a stuck alias. - let raw_label = self.label.read(cx).value(); - let label_trimmed = raw_label.trim().to_string(); - let label_changed = Some(label_trimmed.as_str()) != self.original_label.as_deref(); - let content_changed = text.as_str() != self.original_plain.as_str(); - if !content_changed && !label_changed { - self.close(window, cx); - return; - } - // Move the trimmed label into the spawned task so its lifetime is not - // tied to the editor borrow; `Some("")` and `Some(" ") reduce to NULL - // inside the storage layer. - let label_for_storage: Option = if label_changed { - Some(label_trimmed) - } else { - None - }; - self.saving = true; - self.error = None; - cx.notify(); - let store = self.store.clone(); - let initial = self.initial.clone(); - let parent = self.parent.clone(); - let vault = self.vault.clone(); - cx.spawn_in(window, async move |entity, cx| { - // Re-borrow the storage path with the label value held by the task. - let label_ref: Option<&str> = label_for_storage.as_deref(); - let result = async { - if let Some(vault) = vault { - let sealed = vault.seal_for_storage(text.as_bytes())?.ok_or_else(|| anyhow::anyhow!("Authorization expired; reopen to unlock"))?; - store - .compare_exchange_protected_content_bound( - initial.id, - &initial.content, - &sealed.ciphertext, - text.len() as i64, - crate::utils::time::now_unix_millis(), - &sealed.vault_envelope, - label_ref, - ) - .await - } else { - // Compare-and-swap is atomic with edited retention and rejects protection/deletion races. - store - .compare_exchange_content( - initial.id, - &initial.content, - &text, - text.len() as i64, - crate::utils::time::now_unix_millis(), - label_ref, - ) - .await - } - }.await; - let _ = entity.update_in(cx, |this, window, cx| { - this.saving = false; - match result { - Ok(()) => { - this.close(window, cx); - let _ = parent.update(cx, |this, cx| this.refresh(window, cx)); } - Err(_) => { this.error = Some("Save failed or the item changed/deleted. Your draft is still here; retry or cancel.".into()); cx.notify(); } - } - }); - }).detach(); - } -} -impl Focusable for ClipboardEditor { - fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - self.content.read(cx).focus_handle(cx) - } -} -impl Render for ClipboardEditor { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .w_full() - .gap_2() - .bg(cx.theme().background) - .text_color(cx.theme().foreground) - .child( - div() - .h(px(200.0)) - .min_h_0() - .child(Textarea::new(&self.content).size_full()), - ) - .child( - v_flex() - .gap_1() - .child(Input::new(&self.label).w_full().small()) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child( - "Public alias shown in history without unlocking. Leave empty to clear; do not include secrets.", - ), - ), - ) - .when(self.error.is_some(), |body| { - body.child(if self.confirm_discard { - Alert::warning( - "clipboard-editor-error", - self.error.clone().unwrap_or_default(), - ) - .small() - } else { - Alert::error( - "clipboard-editor-error", - self.error.clone().unwrap_or_default(), - ) - .small() - }) - }) - .child( - h_flex() - .gap_2() - .items_center() - .child( - div() - .flex_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("Ctrl/Cmd+S save · Esc cancel"), - ) - .child( - Button::new("save-clipboard-edit") - .label("Save") - .primary() - .small() - .loading(self.saving) - .disabled(self.saving) - .on_click(cx.listener(|this, _, w, cx| this.save(w, cx))), - ) - .child( - Button::new("cancel-clipboard-edit") - .label("Cancel") - .small() - .disabled(self.saving) - .on_click(cx.listener(|this, _, w, cx| this.cancel(w, cx))), - ), - ) - } -} - -impl Drop for ClipboardEditor { - fn drop(&mut self) { - if let Some(vault) = &self.vault { - vault.lock(); - } - } -} diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs index bc3b566..e716057 100644 --- a/src/native/windows/main.rs +++ b/src/native/windows/main.rs @@ -118,9 +118,13 @@ impl MainWindow { ) -> Self { let query = cx.new(|cx| InputState::new(window, cx).placeholder("Rustickers")); - window.on_window_should_close(cx, |_, cx| { - cx.quit(); - true + window.on_window_should_close(cx, |window, cx| { + if super::text_password::is_rotating() || super::text_password::has_dirty_editors() { + window.open_alert_dialog(cx, |dialog,_,_| dialog.title("Finish encrypted text edits").child("Save or discard encrypted drafts and wait for password changes before quitting.")); + return false; + } + super::clipboard::clear_owned_secret(); + cx.quit(); true }); cx.spawn(async move |this, cx| { @@ -163,6 +167,7 @@ impl MainWindow { cx: &mut AsyncApp, ) { let mut last_activity = command_activity::generation(); + let mut last_text_version = -1i64; loop { cx.background_executor() @@ -181,12 +186,25 @@ impl MainWindow { let activity_changed = activity != last_activity; last_activity = activity; - if events.is_empty() && !activity_changed { + // SQLite data_version changes for other connections/processes, including CLI + // writes. Poll the existing event pump without carrying plaintext notifications. + let text_version = if let Some(repository) = super::clipboard::text_repository() { + repository.data_version().await.unwrap_or(last_text_version) + } else { + last_text_version + }; + let text_changed = text_version != last_text_version; + last_text_version = text_version; + if events.is_empty() && !activity_changed && !text_changed { continue; } let updated = this.update(cx, |this, cx| { let mut changed = activity_changed; + if text_changed { + this.spawn_load_stickers(cx); + changed = true; + } for ev in events { changed |= this.apply_event(ev, cx); } @@ -814,6 +832,17 @@ impl MainWindow { ) .child( h_flex() + .child( + Button::new("master-password") + .icon(IconName::LockClosed) + .tooltip("Master password…") + .ghost() + .on_click(|_, window, cx| { + if let Some(repository) = super::clipboard::text_repository() { + super::text_password::settings(window, cx, repository); + } + }), + ) .child( Button::new("minimize") .icon(IconName::Minus) @@ -828,8 +857,10 @@ impl MainWindow { .icon(IconName::Close) .ghost() .occlude() - .on_click(cx.listener(|_, _, _, cx| { - cx.quit(); + .on_click(cx.listener(|_, _, window, cx| { + if super::text_password::is_rotating() || super::text_password::has_dirty_editors() { + window.open_alert_dialog(cx, |dialog,_,_| dialog.title("Finish encrypted text edits").child("Save or discard encrypted drafts and wait for password changes before quitting.")); + } else { super::clipboard::clear_owned_secret(); cx.quit(); } })), ) .child(self.create_button(cx)), diff --git a/src/native/windows/mod.rs b/src/native/windows/mod.rs index 6556c6f..b83c4f2 100644 --- a/src/native/windows/mod.rs +++ b/src/native/windows/mod.rs @@ -6,6 +6,8 @@ pub mod main; pub mod placement; pub mod selection; pub mod sticker; +pub(crate) mod text_password; +pub(crate) mod text_title; mod transient_topmost; #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/src/native/windows/sticker/open.rs b/src/native/windows/sticker/open.rs index ef0295c..a05dfe7 100644 --- a/src/native/windows/sticker/open.rs +++ b/src/native/windows/sticker/open.rs @@ -89,7 +89,21 @@ impl StickerWindow { id: i64, open_in_settings: bool, ) -> anyhow::Result<()> { - if cx.update(|cx| activate_open_window(id, cx))? { + if cx.update(|cx| -> anyhow::Result { + if let Some(handle) = find_open_window(id) { + handle.update(cx, |_, window, cx| { + window.activate_window(); + if open_in_settings + && let Some(Some(root)) = window.root::() + && let Ok(entity) = root.read(cx).view().clone().downcast::() + { + entity.update(cx, |this, cx| this.view.begin_edit(window, cx)); + } + })?; + return Ok(true); + } + Ok(false) + })? { return Ok(()); } @@ -213,6 +227,25 @@ impl StickerWindow { Self::open_with_options(cx, sticker_events_tx, store, detail, open_options) } + /// Discard clean unlocked views after key rotation without saving stale ciphertext. + pub(crate) fn invalidate_protected_views(cx: &mut App) { + let handles = OPEN_STICKERS + .read() + .map(|rows| rows.clone()) + .unwrap_or_default(); + for (_, handle) in handles { + let _ = handle.update(cx, |_, window, cx| { + if let Some(Some(root)) = window.root::() + && let Ok(entity) = root.read(cx).view().clone().downcast::() + { + entity.update(cx, |this, cx| { + this.view.relock_protected_content(window, cx) + }); + } + }); + } + } + pub fn try_close(id: i64, cx: &mut App) -> bool { tracing::info!("Trying to close sticker with id: {}", id); let Some((open_id, handle)) = OPEN_STICKERS.write().ok().and_then(|mut open_stickers| { diff --git a/src/native/windows/sticker/view.rs b/src/native/windows/sticker/view.rs index e3cdc81..a15d87a 100644 --- a/src/native/windows/sticker/view.rs +++ b/src/native/windows/sticker/view.rs @@ -107,7 +107,16 @@ impl StickerWindow { TimerSticker::new(id, color, store, content, window, cx, sticker_events_tx) }))), StickerType::Markdown => Box::new(StickerViewEntity::new(cx.new(|cx| { - MarkdownSticker::new(id, color, store, content, window, cx, sticker_events_tx) + MarkdownSticker::new( + id, + color, + store, + content, + window, + cx, + sticker_events_tx, + options.open_in_settings, + ) }))), StickerType::Command => { let init = CommandStickerInit { @@ -172,6 +181,59 @@ impl StickerWindow { .min_w_0() .when_some(extension, |view, extension| view.child(extension)), ) + .when( + self.detail.sticker_type == StickerType::Markdown, + |header| { + header.child( + Button::new("rename-text") + .icon(IconName::DocumentText) + .ghost() + .occlude() + .tooltip("Rename text…") + .on_click(cx.listener(|this, _, window, cx| { + let Some(repo) = + crate::native::windows::clipboard::text_repository() + else { + return; + }; + let id = this.detail.id; + let events = this.sticker_events_tx.clone(); + let weak = cx.weak_entity(); + crate::native::windows::text_title::prompt( + window, + cx, + false, + &this.detail.title, + Box::new(move |title, window, cx| { + window + .spawn(cx, async move |cx| { + if repo + .update_title( + crate::text::TextEntryId::Sticker(id), + &title, + ) + .await + .is_ok() + { + let _ = events.send( + StickerWindowEvent::TitleChanged { + id, + title: title.clone(), + }, + ); + let _ = weak.update_in(cx, |this, _, cx| { + this.detail.title = title; + cx.notify(); + }); + } + }) + .detach(); + }), + ); + })), + ) + }, + ) .child(self.create_button(cx)) .child( Button::new("close") diff --git a/src/native/windows/text_password.rs b/src/native/windows/text_password.rs new file mode 100644 index 0000000..22c66ba --- /dev/null +++ b/src/native/windows/text_password.rs @@ -0,0 +1,212 @@ +//! One password/settings overlay shared by TXTs and ordinary text stickers. +use crate::gpui_component::{ + ActiveTheme, Disableable, WindowExt, + button::{Button, ButtonVariants}, + h_flex, + input::{Input, InputState}, + v_flex, +}; +use crate::text::TextRepository; +use gpui::{Context, Entity, Window, div, prelude::*}; +use std::sync::{Arc, Mutex, OnceLock}; +use zeroize::Zeroizing; + +type PasswordCallback = Box, &mut Window, &mut gpui::App)>; +static DIRTY_EDITORS: OnceLock>> = OnceLock::new(); +static ROTATING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +pub(crate) fn is_rotating() -> bool { + ROTATING.load(std::sync::atomic::Ordering::Acquire) +} +pub(crate) fn set_dirty(id: i64, dirty: bool) { + let mut editors = DIRTY_EDITORS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|e| e.into_inner()); + if dirty { + editors.insert(id); + } else { + editors.remove(&id); + } +} +pub(crate) fn has_dirty_editors() -> bool { + !DIRTY_EDITORS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_empty() +} +struct PasswordForm { + password: Entity, + new_password: Entity, + confirmation: Entity, + callback: Option, + repository: Option>, + configured: bool, + busy: bool, + error: Option, +} +impl PasswordForm { + fn clear(&self, window: &mut Window, cx: &mut Context) { + for input in [&self.password, &self.new_password, &self.confirmation] { + input.update(cx, |input, cx| input.set_value("", window, cx)); + } + } + fn submit(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + let password = Zeroizing::new(self.password.read(cx).value().to_string()); + if let Some(repository) = self.repository.clone() { + let new = Zeroizing::new(self.new_password.read(cx).value().to_string()); + let confirmation = Zeroizing::new(self.confirmation.read(cx).value().to_string()); + if new.is_empty() || new != confirmation { + self.clear(window, cx); + self.error = + Some("Enter a nonempty new password and matching confirmation.".into()); + cx.notify(); + return; + } + if has_dirty_editors() { + self.error = Some( + "Save or discard unsaved encrypted text edits before changing the password." + .into(), + ); + cx.notify(); + return; + } + if ROTATING.swap(true, std::sync::atomic::Ordering::AcqRel) { + self.error = Some("Another password change is in progress.".into()); + cx.notify(); + return; + } + cx.refresh_windows(); + let configured = self.configured; + self.busy = true; + self.error = None; + cx.notify(); + let entity = cx.entity(); + window.spawn(cx, async move |cx| { + let result = cx.background_executor().spawn(async move { + futures::executor::block_on(async { + if configured { repository.change_master_password(&password, &new).await } + else { repository.set_master_password(&new).await } + }) + }).await; + ROTATING.store(false,std::sync::atomic::Ordering::Release); + let _ = entity.update_in(cx, |this, window, cx| { + cx.refresh_windows(); + this.busy = false; + match result { + Ok(()) => { this.clear(window, cx); window.close_dialog(cx); cx.defer(super::sticker::StickerWindow::invalidate_protected_views); } + Err(_) => { this.clear(window,cx); this.error = Some("Could not change the master password. Check the current password and locked texts; nothing was changed.".into()); }, + } + cx.notify(); + }); + }).detach(); + } else if let Some(callback) = self.callback.take() { + self.clear(window, cx); + window.close_dialog(cx); + callback(password, window, cx); + } + } +} +fn open_form( + window: &mut Window, + cx: &mut gpui::App, + title: &'static str, + repository: Option>, + configured: bool, + callback: Option, +) { + if window.has_active_dialog(cx) { + return; + } + let form = cx.new(|cx| PasswordForm { + password: cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("Current master password") + }), + new_password: cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("New master password") + }), + confirmation: cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("Confirm new password") + }), + callback, + repository, + configured, + busy: false, + error: None, + }); + let focus = if form.read(cx).repository.is_some() && !configured { + form.read(cx).new_password.clone() + } else { + form.read(cx).password.clone() + }; + window.open_dialog(cx, move |dialog, _, cx| { + let state = form.read(cx); let busy = state.busy; let settings = state.repository.is_some(); + let submit = form.clone(); let cancel = form.clone(); let closed = form.clone(); + dialog.title(title).overlay_closable(false).keyboard(false).close_button(!busy) + .on_close(move |_, window, cx| closed.update(cx, |this, cx| { this.clear(window, cx); this.callback = None; })) + .child(v_flex().gap_3().on_key_down({ let form = form.clone(); move |event: &gpui::KeyDownEvent,window,cx| { + if event.keystroke.key == "enter" { form.update(cx,|this,cx|this.submit(window,cx)); cx.stop_propagation(); } + else if event.keystroke.key == "escape" { if !form.read(cx).busy { form.update(cx,|this,cx|{this.clear(window,cx);this.callback=None;});window.close_dialog(cx); } cx.stop_propagation(); } + }}) + .when(!settings || configured, |body| body.child(div().child("Master password")).child(Input::new(&state.password).disabled(busy))) + .when(settings, |body| body.child(div().child("New password")).child(Input::new(&state.new_password).disabled(busy)) + .child(div().child("Confirm password")).child(Input::new(&state.confirmation).disabled(busy)) + .child(div().text_sm().text_color(cx.theme().muted_foreground).child(if configured { + "Every locked text will be re-encrypted. Keep your password safe; there is no recovery. Old backups are not erased." + } else { "Keep this password safe. Forgotten passwords cannot recover locked texts." }))) + .when(busy, |body| body.child("Re-encrypting locked texts. Do not close the app.")) + .when_some(state.error.clone(), |body, error| body.child(div().text_color(cx.theme().danger).child(error)))) + .footer(h_flex().gap_2().child(Button::new("password-cancel").label("Cancel").disabled(busy) + .on_click(move |_, window, cx| { cancel.update(cx, |this, cx| { this.clear(window,cx); this.callback=None; }); window.close_dialog(cx); })) + .child(Button::new("password-submit").label(if settings { if configured { "Change password" } else { "Set password" } } else { "Unlock" }) + .primary().loading(busy).disabled(busy).on_click(move |_, window, cx| submit.update(cx, |this,cx| this.submit(window,cx))))) + }); + focus.update(cx, |input, cx| input.focus(window, cx)); +} +pub(crate) fn unlock(window: &mut Window, cx: &mut gpui::App, callback: PasswordCallback) { + open_form(window, cx, "Unlock text", None, false, Some(callback)); +} +pub(crate) fn settings(window: &mut Window, cx: &mut gpui::App, repository: Arc) { + if window.has_active_dialog(cx) || is_rotating() { + return; + } + let executor = cx.background_executor().clone(); + window + .spawn(cx, async move |cx| { + let probe = repository.clone(); + let result = executor + .spawn(async move { futures::executor::block_on(probe.master_key()) }) + .await; + let _ = cx.update(|window, cx| match result { + Ok(key) => open_form( + window, + cx, + if key.is_some() { + "Change master password" + } else { + "Set master password" + }, + Some(repository), + key.is_some(), + None, + ), + Err(_) => { + window.open_alert_dialog(cx, |dialog, _, _| { + dialog + .title("Master password unavailable") + .child("The stored key configuration could not be read.") + }); + } + }); + }) + .detach(); +} diff --git a/src/native/windows/text_title.rs b/src/native/windows/text_title.rs new file mode 100644 index 0000000..4908115 --- /dev/null +++ b/src/native/windows/text_title.rs @@ -0,0 +1,71 @@ +//! Shared required public-title prompt for locking and renaming text. +use crate::gpui_component::{ + ActiveTheme, Disableable, WindowExt, + button::{Button, ButtonVariants}, + h_flex, + input::{Input, InputState}, + v_flex, +}; +use gpui::{App, Context, Entity, Window, div, prelude::*}; +type TitleCallback = Box; +struct TitleForm { + input: Entity, + error: bool, + callback: Option, +} +impl TitleForm { + fn submit(&mut self, window: &mut Window, cx: &mut Context) { + let title = self.input.read(cx).value().to_string(); + if title.trim().is_empty() { + self.error = true; + cx.notify(); + return; + } + let callback = self.callback.take(); + window.close_dialog(cx); + if let Some(callback) = callback { + callback(title, window, cx); + } + } +} +pub(crate) fn prompt( + window: &mut Window, + cx: &mut App, + locking: bool, + initial: &str, + callback: TitleCallback, +) { + if window.has_active_dialog(cx) { + return; + } + let form = cx.new(|cx| TitleForm { + input: cx.new(|cx| { + InputState::new(window, cx) + .default_value(initial.to_owned()) + .placeholder("Give this text a name") + }), + error: false, + callback: Some(callback), + }); + let input = form.read(cx).input.clone(); + window.open_dialog(cx, move |dialog, _, cx| { + let submit = form.clone(); let cancel = form.clone(); let keys = form.clone(); + let state = form.read(cx); + dialog.title(if locking { "Name and lock text" } else { "Rename text" }).keyboard(false) + .child(v_flex().gap_2().on_key_down(move |event: &gpui::KeyDownEvent, window, cx| { + match event.keystroke.key.as_str() { + "enter" => keys.update(cx, |this,cx| this.submit(window,cx)), + "escape" => { window.close_dialog(cx); }, + _ => return, + }; cx.stop_propagation(); + }).child(div().child("Title (required)")) + .child(Input::new(&state.input)) + .child(div().text_sm().text_color(cx.theme().muted_foreground).child("Titles are public and searchable, even when the text is locked. Do not put secrets in the title.")) + .when(state.error, |body| body.child(div().text_sm().text_color(cx.theme().danger).child("Enter a title to continue.")))) + .footer(h_flex().gap_2() + .child(Button::new("title-cancel").label("Cancel").on_click(move |_,window,cx| { cancel.update(cx, |this,_| this.callback=None); window.close_dialog(cx); })) + .child(Button::new("title-submit").label(if locking { "Lock text" } else { "Save title" }).primary().disabled(state.callback.is_none()) + .on_click(move |_,window,cx| submit.update(cx, |this,cx| this.submit(window,cx))))) + }); + input.update(cx, |input, cx| input.focus(window, cx)); +} diff --git a/src/rst.rs b/src/rst.rs index 63ac1f1..2b7a11e 100644 --- a/src/rst.rs +++ b/src/rst.rs @@ -20,7 +20,7 @@ fn main() { let format = cli.format(); if let Err(err) = cli::run(cli, &app_paths) { - if let Some(code) = err.downcast_ref::() { + if let Some(code) = err.downcast_ref::() { std::process::exit(code.0); } format.emit_error(&err); diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 946e5b8..5116232 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -25,6 +25,11 @@ pub struct SqliteStore { } impl SqliteStore { + /// Shared canonical pool for atomic text/capture operations. + pub(crate) fn pool(&self) -> SqlitePool { + self.pool.clone() + } + pub async fn open(db_path: impl AsRef) -> anyhow::Result { let db_path = db_path.as_ref().to_path_buf(); @@ -33,6 +38,8 @@ impl SqliteStore { } let options = SqliteConnectOptions::new() .filename(&db_path) + .busy_timeout(std::time::Duration::from_secs(10)) + .pragma("secure_delete", "ON") .create_if_missing(true); let pool = SqlitePoolOptions::new() @@ -338,12 +345,18 @@ impl super::StickerStore for SqliteStore { let now = crate::utils::time::now_unix_millis(); - sqlx::query( + anyhow::ensure!( + !crate::text::protection::is_encrypted(&content), + "encrypted text must be saved through the text repository" + ); + let result = sqlx::query( r#" UPDATE stickers SET content = ?1, - updated_at = ?2 - WHERE id = ?3 + updated_at = ?2, + text_modified_at = CASE WHEN type = 'markdown' THEN ?2 ELSE text_modified_at END, + text_revision = text_revision + 1 + WHERE id = ?3 AND (type != 'markdown' OR content NOT GLOB 'RUSTICKERS_TEXT_*') "#, ) .bind(content) @@ -352,6 +365,10 @@ impl super::StickerStore for SqliteStore { .execute(&self.pool) .await .context("update sticker content")?; + anyhow::ensure!( + result.rows_affected() == 1, + "sticker disappeared or requires an authorized text save" + ); Ok(()) } diff --git a/src/text/mod.rs b/src/text/mod.rs new file mode 100644 index 0000000..eb3fdd0 --- /dev/null +++ b/src/text/mod.rs @@ -0,0 +1,146 @@ +//! Shared text collection: ordinary Markdown stickers and disposable captures. +//! Mutation and protection policy live here, not in picker adapters. +pub mod protection; +mod repository; +pub use repository::TextRepository; + +use anyhow::{Result, bail}; +use serde::Serialize; +use std::{fmt, str::FromStr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(into = "String")] +pub enum TextEntryId { + Sticker(i64), + Clipboard(i64), +} +impl fmt::Display for TextEntryId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sticker(id) => write!(f, "sticker:{id}"), + Self::Clipboard(id) => write!(f, "clipboard:{id}"), + } + } +} +impl From for String { + fn from(id: TextEntryId) -> Self { + id.to_string() + } +} +impl FromStr for TextEntryId { + type Err = anyhow::Error; + fn from_str(value: &str) -> Result { + let (source, number) = value + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("use sticker: or clipboard:"))?; + let id: i64 = number + .parse() + .map_err(|_| anyhow::anyhow!("invalid text ID"))?; + anyhow::ensure!(id > 0, "text ID must be positive"); + match source { + "sticker" => Ok(Self::Sticker(id)), + "clipboard" => Ok(Self::Clipboard(id)), + _ => bail!("unknown text source"), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct TextSources { + stickers: bool, + clipboard: bool, +} +impl TextSources { + pub fn new(stickers: bool, clipboard: bool) -> Self { + Self { + stickers, + clipboard, + } + } + pub fn includes(self, id: TextEntryId) -> bool { + match id { + TextEntryId::Sticker(_) => self.stickers, + TextEntryId::Clipboard(_) => self.clipboard, + } + } +} + +/// Redacted public snapshot. Ciphertext and full searchable text are not serialized. +#[derive(Clone, Serialize)] +pub struct TextEntry { + id: TextEntryId, + title: String, + preview: String, + encrypted: bool, + state: Option, + created_at: i64, + activity_at: i64, + revision: i64, + #[serde(skip)] + content: String, +} +impl fmt::Debug for TextEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TextEntry") + .field("id", &self.id) + .field("revision", &self.revision) + .finish_non_exhaustive() + } +} +impl TextEntry { + pub fn id(&self) -> TextEntryId { + self.id + } + pub fn title(&self) -> &str { + &self.title + } + pub fn preview(&self) -> &str { + &self.preview + } + pub fn is_encrypted(&self) -> bool { + self.encrypted + } + pub fn state(&self) -> Option { + self.state + } + pub fn revision(&self) -> i64 { + self.revision + } + pub fn activity_at(&self) -> i64 { + self.activity_at + } + pub fn matches(&self, query: &str) -> bool { + let searchable = if self.encrypted { + format!("{} {}", self.title, self.id) + } else { + format!("{} {}", self.title, self.content) + } + .to_lowercase(); + query + .split_whitespace() + .all(|token| searchable.contains(&token.to_lowercase())) + } +} + +pub fn filter_indices(entries: &[TextEntry], query: &str) -> Vec { + entries + .iter() + .enumerate() + .filter_map(|(ix, entry)| entry.matches(query).then_some(ix)) + .collect() +} +pub fn compare_by_recency(a: &TextEntry, b: &TextEntry) -> std::cmp::Ordering { + fn identity(id: TextEntryId) -> (u8, i64) { + match id { + TextEntryId::Sticker(n) => (0, n), + TextEntryId::Clipboard(n) => (1, n), + } + } + let (a_source, a_id) = identity(a.id); + let (b_source, b_id) = identity(b.id); + b.activity_at + .cmp(&a.activity_at) + .then_with(|| b.created_at.cmp(&a.created_at)) + .then_with(|| a_source.cmp(&b_source)) + .then_with(|| b_id.cmp(&a_id)) +} diff --git a/src/text/protection.rs b/src/text/protection.rs new file mode 100644 index 0000000..08469a1 --- /dev/null +++ b/src/text/protection.rs @@ -0,0 +1,265 @@ +//! Recipient-only text encryption and password-wrapped private identities. +//! No unlocked identity is cached by this module. +use age::secrecy::ExposeSecret; +use anyhow::{Context, Result, anyhow, ensure}; +use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD as BASE64}; +use rand::RngCore; +use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; +use serde::{Deserialize, Serialize}; +use std::io::Read; +use zeroize::Zeroizing; + +pub const CONTENT_PREFIX: &str = "RUSTICKERS_TEXT_V1\n"; +const MEMORY_KIB: u32 = 65_536; +const ITERATIONS: u32 = 3; +const LANES: u32 = 2; +const MAX_ENVELOPE: usize = 64 * 1024 * 1024; + +/// Persisted configuration contains only the public recipient and wrapped identity. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct MasterKey { + version: u8, + key_id: String, + recipient: String, + salt: String, + nonce: String, + wrapped_identity: String, + memory_kib: u32, + iterations: u32, + lanes: u32, +} + +impl std::fmt::Debug for MasterKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MasterKey") + .field("key_id", &self.key_id) + .finish_non_exhaustive() + } +} + +impl MasterKey { + pub fn create(password: &str) -> Result { + ensure!(!password.is_empty(), "master password cannot be empty"); + let identity = age::x25519::Identity::generate(); + let salt = random::<16>(); + let nonce = random::<12>(); + let mut configuration = Self { + version: 1, + key_id: BASE64.encode(random::<16>()), + recipient: identity.to_public().to_string(), + salt: BASE64.encode(salt), + nonce: BASE64.encode(nonce), + wrapped_identity: String::new(), + memory_kib: MEMORY_KIB, + iterations: ITERATIONS, + lanes: LANES, + }; + let wrapping = derive(password, &salt)?; + let encoded = identity.to_string(); + let mut buffer = Zeroizing::new(encoded.expose_secret().as_bytes().to_vec()); + cipher(&wrapping)? + .seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(configuration.binding()), + &mut *buffer, + ) + .map_err(|_| anyhow!("could not wrap private identity"))?; + configuration.wrapped_identity = BASE64.encode(&*buffer); + Ok(configuration) + } + + pub fn parse(value: &str) -> Result { + ensure!(value.len() <= 8192, "master key configuration is too large"); + let configuration: Self = + serde_json::from_str(value).map_err(|_| anyhow!("invalid master key configuration"))?; + configuration.validate()?; + Ok(configuration) + } + + pub fn serialize(&self) -> Result { + Ok(serde_json::to_string(self)?) + } + pub fn key_id(&self) -> &str { + &self.key_id + } + pub fn public_recipient(&self) -> &str { + &self.recipient + } + + fn binding(&self) -> Vec { + format!( + "rustickers-private-identity-v1\n{}\n{}", + self.key_id, self.recipient + ) + .into_bytes() + } + + fn validate(&self) -> Result<()> { + ensure!( + self.version == 1 + && self.memory_kib == MEMORY_KIB + && self.iterations == ITERATIONS + && self.lanes == LANES, + "unsupported master key format or KDF parameters" + ); + ensure!( + BASE64.decode(&self.key_id).map(|v| v.len()).unwrap_or(0) == 16, + "invalid master key identity" + ); + self.recipient + .parse::() + .map_err(|_| anyhow!("invalid public recipient"))?; + Ok(()) + } + + pub(super) fn authorize(&self, password: &str) -> Result { + self.validate()?; + let salt: [u8; 16] = BASE64 + .decode(&self.salt)? + .try_into() + .map_err(|_| anyhow!("invalid master key salt"))?; + let nonce: [u8; 12] = BASE64 + .decode(&self.nonce)? + .try_into() + .map_err(|_| anyhow!("invalid master key nonce"))?; + let wrapping = derive(password, &salt)?; + let mut buffer = Zeroizing::new(BASE64.decode(&self.wrapped_identity)?); + let key = cipher(&wrapping)?; + let opened = key + .open_in_place( + Nonce::assume_unique_for_key(nonce), + Aad::from(self.binding()), + &mut buffer, + ) + .map_err(|_| anyhow!("incorrect master password or damaged key configuration"))?; + let identity: age::x25519::Identity = std::str::from_utf8(opened)? + .parse() + .map_err(|_| anyhow!("invalid private identity"))?; + ensure!( + identity.to_public().to_string() == self.recipient, + "private identity does not match recipient" + ); + Ok(identity) + } + + /// Verify a password without retaining its private identity. + pub fn verify_password(&self, password: &str) -> Result<()> { + self.authorize(password).map(drop) + } + + /// Encrypt using only public configuration, binding plaintext to its sticker ID. + pub fn seal(&self, sticker_id: i64, text: &str) -> Result { + self.validate()?; + ensure!( + sticker_id > 0 && text.len() < MAX_ENVELOPE / 2, + "invalid or oversized text" + ); + let recipient = self + .recipient + .parse::() + .map_err(|_| anyhow!("invalid public recipient"))?; + let mut payload = Zeroizing::new(Vec::new()); + payload.extend_from_slice( + format!("rustickers-text-v1\n{sticker_id}\n{}\n", self.key_id).as_bytes(), + ); + payload.extend_from_slice(text.as_bytes()); + let ciphertext = age::encrypt(&recipient, &payload).context("encrypt text")?; + Ok(format!( + "{CONTENT_PREFIX}{}\n{}", + self.key_id, + BASE64.encode(ciphertext) + )) + } + + pub fn open( + &self, + sticker_id: i64, + envelope: &str, + password: &str, + ) -> Result> { + ensure!( + envelope.len() <= MAX_ENVELOPE, + "encrypted text is too large" + ); + let identity = self.authorize(password)?; + self.open_authorized(sticker_id, envelope, &identity) + } + + pub(super) fn open_authorized( + &self, + sticker_id: i64, + envelope: &str, + identity: &age::x25519::Identity, + ) -> Result> { + ensure!( + envelope.len() <= MAX_ENVELOPE, + "encrypted text is too large" + ); + let (generation, ciphertext) = envelope + .strip_prefix(CONTENT_PREFIX) + .and_then(|s| s.split_once('\n')) + .context("unsupported encrypted text format")?; + ensure!( + generation == self.key_id, + "encrypted text belongs to a different master key" + ); + let encrypted = BASE64 + .decode(ciphertext) + .map_err(|_| anyhow!("damaged encrypted text"))?; + let decryptor = + age::Decryptor::new(encrypted.as_slice()).context("damaged encrypted text")?; + // Never permit ciphertext to choose an expensive password-based age recipient. + ensure!( + !decryptor.is_scrypt(), + "unsupported text encryption recipient" + ); + let mut reader = decryptor + .decrypt(std::iter::once(identity as &dyn age::Identity)) + .context("could not decrypt text")?; + let mut payload = Zeroizing::new(Vec::new()); + reader + .by_ref() + .take(MAX_ENVELOPE as u64 + 1) + .read_to_end(&mut payload)?; + ensure!(payload.len() <= MAX_ENVELOPE, "decrypted text is too large"); + let binding = format!("rustickers-text-v1\n{sticker_id}\n{}\n", self.key_id); + let text = std::str::from_utf8(&payload)? + .strip_prefix(&binding) + .context("encrypted text identity mismatch")?; + Ok(Zeroizing::new(text.to_owned())) + } +} + +pub fn is_encrypted(content: &str) -> bool { + content.starts_with(CONTENT_PREFIX) +} +/// Unsupported legacy formats must not become plaintext picker entries. +pub fn is_unsupported(content: &str) -> bool { + content.trim_start().starts_with("RUSTICKERS_LOCKED_") + || content.trim_start().starts_with("RUSTICKERS_VAULT_") + || (content.starts_with("RUSTICKERS_TEXT_") && !is_encrypted(content)) +} +fn random() -> [u8; N] { + let mut bytes = [0; N]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes +} +fn derive(password: &str, salt: &[u8; 16]) -> Result> { + let parameters = argon2::Params::new(MEMORY_KIB, ITERATIONS, LANES, Some(32)) + .map_err(|_| anyhow!("invalid KDF parameters"))?; + let mut key = Zeroizing::new([0; 32]); + argon2::Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + parameters, + ) + .hash_password_into(password.as_bytes(), salt, key.as_mut()) + .map_err(|_| anyhow!("master password derivation failed"))?; + Ok(key) +} +fn cipher(key: &[u8; 32]) -> Result { + Ok(LessSafeKey::new( + UnboundKey::new(&AES_256_GCM, key).map_err(|_| anyhow!("invalid wrapping key"))?, + )) +} diff --git a/src/text/repository.rs b/src/text/repository.rs new file mode 100644 index 0000000..c092441 --- /dev/null +++ b/src/text/repository.rs @@ -0,0 +1,617 @@ +use super::{ + TextEntry, TextEntryId, TextSources, compare_by_recency, + protection::{self, MasterKey}, +}; +use crate::{clipboard::ClipboardSettings, storage::sqlite::SqliteStore}; +use anyhow::{Context, Result, ensure}; +use sha2::{Digest, Sha256}; +use sqlx::{Row, SqlitePool}; +use zeroize::Zeroizing; + +/// Composes text and capture operations on the sticker store's canonical pool. +#[derive(Clone)] +pub struct TextRepository { + pool: SqlitePool, +} +impl TextRepository { + pub fn new(store: &SqliteStore) -> Self { + Self { pool: store.pool() } + } + pub async fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + let repository = Self::new(&SqliteStore::open(path).await?); + repository + .import_legacy(path.with_file_name("clipboard.db")) + .await?; + Ok(repository) + } + + /// One-shot read-only import. Never decrypts or mutates the legacy database. + pub async fn import_legacy(&self, path: impl AsRef) -> Result<()> { + if !path.as_ref().is_file() { + return Ok(()); + } + let imported: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM text_imports WHERE source='legacy-clipboard'") + .fetch_one(&self.pool) + .await?; + if imported != 0 { + return Ok(()); + } + let legacy = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect_with( + sqlx::sqlite::SqliteConnectOptions::new() + .filename(path.as_ref()) + .read_only(true), + ) + .await + .context("open legacy clipboard read-only")?; + let rows = sqlx::query( + "SELECT content,created_at,last_copied_at,last_used_at FROM clipboard_items", + ) + .fetch_all(&legacy) + .await + .context("read legacy raw clipboard text")?; + let legacy_settings = sqlx::query("SELECT key,value FROM clipboard_settings") + .fetch_all(&legacy) + .await?; + legacy.close().await; + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let imported: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM text_imports WHERE source='legacy-clipboard'") + .fetch_one(&mut *tx) + .await?; + if imported != 0 { + return Ok(()); + } + // Import collection preferences once, without importing any protection metadata. + for row in legacy_settings { + let key: String = row.try_get("key")?; + let value: String = row.try_get("value")?; + let valid = match key.as_str() { + "normal_count_limit" | "normal_byte_limit" => { + value.parse::().is_ok_and(|n| n >= 0) + } + "capture_enabled" => value == "0" || value == "1", + _ => false, + }; + if valid { + sqlx::query("UPDATE clipboard_settings SET value=? WHERE key=?") + .bind(value) + .bind(key) + .execute(&mut *tx) + .await?; + } + } + let mut copied = 0i64; + let mut skipped = 0i64; + // Include obsolete rows already in the canonical DB; only raw content is eligible. + let mut all = sqlx::query( + "SELECT content,created_at,last_copied_at,last_used_at FROM clipboard_items", + ) + .fetch_all(&mut *tx) + .await?; + all.extend(rows); + for row in all { + let text: String = row.try_get("content")?; + if text.trim().is_empty() + || text.contains('\0') + || text.len() > 1024 * 1024 + || protection::is_encrypted(&text) + || protection::is_unsupported(&text) + { + skipped += 1; + continue; + } + let existing: Option = + sqlx::query_scalar("SELECT id FROM clipboard_captures WHERE text=? LIMIT 1") + .bind(&text) + .fetch_optional(&mut *tx) + .await?; + let copied_at: i64 = row.try_get("last_copied_at")?; + let used_at: Option = row.try_get("last_used_at")?; + if let Some(id) = existing { + sqlx::query("UPDATE clipboard_captures SET last_copied_at=MAX(last_copied_at,?),last_used_at=MAX(COALESCE(last_used_at,0),COALESCE(?,0)) WHERE id=?") + .bind(copied_at).bind(used_at).bind(id).execute(&mut *tx).await?; + } else { + sqlx::query("INSERT INTO clipboard_captures(text,text_digest,byte_size,created_at,last_copied_at,last_used_at) VALUES(?,?,?,?,?,?)") + .bind(&text).bind(format!("{:x}", Sha256::digest(text.as_bytes()))) + .bind(text.len() as i64).bind(row.try_get::("created_at")?) + .bind(copied_at).bind(used_at).execute(&mut *tx).await?; + copied += 1; + } + } + let settings = settings_in(&mut tx).await?; + prune(&mut tx, &settings).await?; + sqlx::query("INSERT INTO text_imports(source,imported_at,imported_count,skipped_count) VALUES('legacy-clipboard',?,?,?)") + .bind(crate::utils::time::now_unix_millis()).bind(copied).bind(skipped).execute(&mut *tx).await?; + tx.commit().await?; + tracing::info!( + imported_count = copied, + skipped_count = skipped, + "Imported legacy raw clipboard text" + ); + Ok(()) + } + /// Content-free cross-process invalidation for desktop list refresh. + pub async fn data_version(&self) -> Result { + // A single-connection pool observes both its own mutations and external commits. + let mut connection = self.pool.acquire().await?; + let external: i64 = sqlx::query_scalar("PRAGMA data_version") + .fetch_one(&mut *connection) + .await?; + let local: i64 = sqlx::query_scalar("SELECT total_changes()") + .fetch_one(&mut *connection) + .await?; + Ok(external.wrapping_mul(1_000_000_007).wrapping_add(local)) + } + + pub async fn master_key(&self) -> Result> { + let value: Option = + sqlx::query_scalar("SELECT configuration FROM text_master_key WHERE singleton = 1") + .fetch_optional(&self.pool) + .await?; + value.as_deref().map(MasterKey::parse).transpose() + } + pub async fn set_master_password(&self, password: &str) -> Result<()> { + let key = MasterKey::create(password)?.serialize()?; + sqlx::query("INSERT INTO text_master_key(singleton, configuration) VALUES(1, ?)") + .bind(key) + .execute(&self.pool) + .await + .context("master password already configured or could not be saved")?; + Ok(()) + } + pub async fn settings(&self) -> Result { + let mut settings = ClipboardSettings::default(); + for row in sqlx::query("SELECT key, value FROM clipboard_settings") + .fetch_all(&self.pool) + .await? + { + let key: String = row.try_get("key")?; + let value: String = row.try_get("value")?; + match key.as_str() { + "normal_count_limit" => settings.normal_count_limit = value.parse()?, + "normal_byte_limit" => settings.normal_byte_limit = value.parse()?, + "capture_enabled" => settings.capture_enabled = value == "1", + _ => {} + } + } + Ok(settings) + } + pub async fn update_settings(&self, settings: &ClipboardSettings) -> Result<()> { + ensure!( + settings.normal_count_limit >= 0 && settings.normal_byte_limit >= 0, + "capture limits cannot be negative" + ); + let mut tx = self.pool.begin().await?; + for (key, value) in [ + ( + "normal_count_limit", + settings.normal_count_limit.to_string(), + ), + ("normal_byte_limit", settings.normal_byte_limit.to_string()), + ( + "capture_enabled", + if settings.capture_enabled { "1" } else { "0" }.to_owned(), + ), + ] { + sqlx::query("INSERT INTO clipboard_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value") + .bind(key).bind(value).execute(&mut *tx).await?; + } + prune(&mut tx, settings).await?; + tx.commit().await?; + Ok(()) + } + pub async fn capture(&self, text: &str, now: i64) -> Result { + ensure!( + !text.trim().is_empty() && !text.contains('\0'), + "capture must be nonempty text without NUL" + ); + ensure!(text.len() <= 1024 * 1024, "capture exceeds one MiB"); + let digest = format!("{:x}", Sha256::digest(text.as_bytes())); + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let settings = settings_in(&mut tx).await?; + let existing: Option = sqlx::query_scalar( + "SELECT id FROM clipboard_captures WHERE text_digest=? AND text=? LIMIT 1", + ) + .bind(&digest) + .bind(text) + .fetch_optional(&mut *tx) + .await?; + let id = if let Some(id) = existing { + sqlx::query( + "UPDATE clipboard_captures SET last_copied_at=MAX(last_copied_at,?) WHERE id=?", + ) + .bind(now) + .bind(id) + .execute(&mut *tx) + .await?; + id + } else { + sqlx::query_scalar("INSERT INTO clipboard_captures(text,text_digest,byte_size,created_at,last_copied_at) VALUES(?,?,?,?,?) RETURNING id") + .bind(text).bind(digest).bind(text.len() as i64).bind(now).bind(now).fetch_one(&mut *tx).await? + }; + prune(&mut tx, &settings).await?; + tx.commit().await?; + Ok(id) + } + pub async fn snapshot(&self, sources: TextSources, query: &str) -> Result> { + let mut entries = Vec::new(); + if sources.includes(TextEntryId::Sticker(1)) { + for row in sqlx::query("SELECT id,title,state,content,created_at,COALESCE(text_modified_at,created_at) AS modified,COALESCE(text_last_used_at,0) AS used,text_revision FROM stickers WHERE type='markdown' ORDER BY id ASC") + .fetch_all(&self.pool).await? { + let content: String = row.try_get("content")?; + if protection::is_unsupported(&content) { continue; } + let encrypted = protection::is_encrypted(&content); + let title: String = row.try_get("title")?; let created_at: i64 = row.try_get("created_at")?; + entries.push(TextEntry { id: TextEntryId::Sticker(row.try_get("id")?), + preview: preview(if encrypted { &title } else { &content }), title, encrypted, + state: Some(row.try_get("state")?), created_at, + activity_at: created_at.max(row.try_get("modified")?).max(row.try_get("used")?), + revision: row.try_get("text_revision")?, content }); + } + } + if sources.includes(TextEntryId::Clipboard(1)) { + for row in + sqlx::query("SELECT *,COALESCE(last_used_at,0) AS used FROM clipboard_captures") + .fetch_all(&self.pool) + .await? + { + let content: String = row.try_get("text")?; + let created_at: i64 = row.try_get("created_at")?; + let activity_at = created_at + .max(row.try_get("last_copied_at")?) + .max(row.try_get("used")?); + // Projection dedupe only: a capture can refresh the representative sticker's display activity. + if let Some(sticker) = entries + .iter_mut() + .find(|e| !e.encrypted && e.content == content) + { + sticker.activity_at = sticker.activity_at.max(activity_at); + continue; + } + entries.push(TextEntry { + id: TextEntryId::Clipboard(row.try_get("id")?), + title: String::new(), + preview: preview(&content), + encrypted: false, + state: None, + created_at, + activity_at, + revision: 0, + content, + }); + } + } + entries.retain(|e| e.matches(query)); + entries.sort_by(compare_by_recency); + Ok(entries) + } + pub async fn get(&self, id: TextEntryId) -> Result { + // Query separately so projection deduplication never makes direct identity disappear. + let sources = TextSources::new( + matches!(id, TextEntryId::Sticker(_)), + matches!(id, TextEntryId::Clipboard(_)), + ); + self.snapshot(sources, "") + .await? + .into_iter() + .find(|e| e.id == id) + .context("text no longer available") + } + pub async fn resolve( + &self, + expected: &TextEntry, + password: Option<&str>, + ) -> Result> { + let current = self.get(expected.id).await?; + ensure!( + current.revision == expected.revision && current.content == expected.content, + "text changed; select it again" + ); + let text = if current.encrypted { + let TextEntryId::Sticker(id) = current.id else { + unreachable!() + }; + self.master_key() + .await? + .context("master password not configured")? + .open( + id, + ¤t.content, + password.context("master password required")?, + )? + } else { + Zeroizing::new(current.content.clone()) + }; + let after = self.get(expected.id).await?; + ensure!( + after.revision == current.revision && after.content == current.content, + "text changed during authorization" + ); + Ok(text) + } + pub async fn promote(&self, expected: &TextEntry, encrypted: bool) -> Result { + self.promote_titled(expected, encrypted, None).await + } + pub async fn promote_titled( + &self, + expected: &TextEntry, + encrypted: bool, + title: Option<&str>, + ) -> Result { + if let Some(title) = title { + ensure!(!title.trim().is_empty(), "title is required"); + } + let TextEntryId::Clipboard(capture_id) = expected.id else { + anyhow::bail!("only captures can be promoted") + }; + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let content: String = sqlx::query_scalar("SELECT text FROM clipboard_captures WHERE id=?") + .bind(capture_id) + .fetch_optional(&mut *tx) + .await? + .context("capture no longer available")?; + ensure!(content == expected.content, "capture changed"); + let now = crate::utils::time::now_unix_millis(); + let id: i64 = sqlx::query_scalar("INSERT INTO stickers(title,state,left,top,width,height,color,type,content,created_at,updated_at,text_modified_at) VALUES('','close',100,100,400,300,'yellow','markdown','',?,?,?) RETURNING id") + .bind(now).bind(now).bind(now).fetch_one(&mut *tx).await?; + let saved = if encrypted { + key_in(&mut tx) + .await? + .context("set master password first")? + .seal(id, &content)? + } else { + content.clone() + }; + sqlx::query("UPDATE stickers SET title=?,content=? WHERE id=?") + .bind(title.map(str::to_owned).unwrap_or_else(|| { + if encrypted { + "Locked text".to_owned() + } else { + preview(&content) + } + })) + .bind(saved) + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM clipboard_captures WHERE id=? OR (? AND text=?)") + .bind(capture_id) + .bind(encrypted) + .bind(&content) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(id) + } + pub async fn save_text(&self, expected: &TextEntry, text: &str) -> Result<()> { + let TextEntryId::Sticker(id) = expected.id else { + anyhow::bail!("promote capture before editing") + }; + ensure!( + !protection::is_unsupported(text), + "unsupported text envelope" + ); + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let content = if expected.encrypted { + key_in(&mut tx) + .await? + .context("master password not configured")? + .seal(id, text)? + } else { + ensure!( + !protection::is_encrypted(text), + "cannot save an encryption envelope as plaintext" + ); + text.to_owned() + }; + let result = sqlx::query("UPDATE stickers SET content=?,title=CASE WHEN ? OR title != ? THEN title ELSE ? END,text_revision=text_revision+1,text_modified_at=?,updated_at=? WHERE id=? AND type='markdown' AND text_revision=? AND content=?") + .bind(content).bind(expected.encrypted).bind(preview(&expected.content)).bind(if expected.encrypted { String::new() } else { preview(text) }).bind(crate::utils::time::now_unix_millis()).bind(crate::utils::time::now_unix_millis()) + .bind(id).bind(expected.revision).bind(&expected.content).execute(&mut *tx).await?; + ensure!( + result.rows_affected() == 1, + "text changed; reload before saving" + ); + tx.commit().await?; + Ok(()) + } + pub async fn set_encryption( + &self, + expected: &TextEntry, + enabled: bool, + password: Option<&str>, + ) -> Result<()> { + self.set_encryption_titled(expected, enabled, password, None) + .await + } + pub async fn set_encryption_titled( + &self, + expected: &TextEntry, + enabled: bool, + password: Option<&str>, + title: Option<&str>, + ) -> Result<()> { + if let Some(title) = title { + ensure!(!title.trim().is_empty(), "title is required"); + } + let TextEntryId::Sticker(id) = expected.id else { + anyhow::bail!("promote capture to encrypt it") + }; + ensure!( + enabled != expected.encrypted, + "text already has requested protection" + ); + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let key = key_in(&mut tx) + .await? + .context("set master password first")?; + let plaintext = if enabled { + Zeroizing::new(expected.content.clone()) + } else { + key.open( + id, + &expected.content, + password.context("master password required")?, + )? + }; + let replacement = if enabled { + key.seal(id, &plaintext)? + } else { + plaintext.to_string() + }; + let result = sqlx::query("UPDATE stickers SET content=?,title=?,text_revision=text_revision+1 WHERE id=? AND type='markdown' AND text_revision=? AND content=?") + .bind(replacement).bind(title.map(str::to_owned).unwrap_or_else(|| if enabled { "Locked text".to_owned() } else { expected.title.clone() })) + .bind(id).bind(expected.revision).bind(&expected.content).execute(&mut *tx).await?; + ensure!( + result.rows_affected() == 1, + "text changed; reload before changing protection" + ); + if enabled { + sqlx::query("DELETE FROM clipboard_captures WHERE text=?") + .bind(&*plaintext) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + pub async fn update_title(&self, id: TextEntryId, title: &str) -> Result<()> { + ensure!(!title.trim().is_empty(), "title is required"); + let TextEntryId::Sticker(id) = id else { + anyhow::bail!("save capture before renaming"); + }; + let result = + sqlx::query("UPDATE stickers SET title=?,updated_at=? WHERE id=? AND type='markdown'") + .bind(title) + .bind(crate::utils::time::now_unix_millis()) + .bind(id) + .execute(&self.pool) + .await?; + ensure!(result.rows_affected() == 1, "text no longer available"); + Ok(()) + } + pub async fn change_master_password(&self, old: &str, new: &str) -> Result<()> { + // Single writer transaction prevents concurrent membership changes and stale generations. + // Bounded one-payload-at-a-time plaintext; no plaintext staging on disk. + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let previous = key_in(&mut tx) + .await? + .context("master password not configured")?; + let identity = previous.authorize(old)?; + let next = MasterKey::create(new)?; + let rows = sqlx::query("SELECT id,content FROM stickers WHERE type='markdown' AND content GLOB 'RUSTICKERS_TEXT_*'") + .fetch_all(&mut *tx).await?; + for row in rows { + let id: i64 = row.try_get("id")?; + let envelope: String = row.try_get("content")?; + let plaintext = previous.open_authorized(id, &envelope, &identity)?; + let replacement = next.seal(id, &plaintext)?; + sqlx::query("UPDATE stickers SET content=?,text_revision=text_revision+1 WHERE id=?") + .bind(replacement) + .bind(id) + .execute(&mut *tx) + .await?; + } + sqlx::query("UPDATE text_master_key SET configuration=? WHERE singleton=1") + .bind(next.serialize()?) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + pub async fn mark_used(&self, id: TextEntryId, now: i64) -> Result<()> { + let (query, number) = match id { + TextEntryId::Sticker(n) => ( + "UPDATE stickers SET text_last_used_at=MAX(COALESCE(text_last_used_at,0),?) WHERE id=? AND type='markdown'", + n, + ), + TextEntryId::Clipboard(n) => ( + "UPDATE clipboard_captures SET last_used_at=MAX(COALESCE(last_used_at,0),?) WHERE id=?", + n, + ), + }; + sqlx::query(query) + .bind(now) + .bind(number) + .execute(&self.pool) + .await?; + Ok(()) + } + pub async fn delete(&self, id: TextEntryId) -> Result<()> { + let mut tx = self.pool.begin().await?; + match id { + TextEntryId::Clipboard(n) => { + sqlx::query("DELETE FROM clipboard_captures WHERE id=?") + .bind(n) + .execute(&mut *tx) + .await?; + } + TextEntryId::Sticker(n) => { + sqlx::query("DELETE FROM sticker_placements WHERE sticker_id=? AND EXISTS(SELECT 1 FROM stickers WHERE id=? AND type='markdown')") + .bind(n).bind(n).execute(&mut *tx).await?; + sqlx::query("DELETE FROM stickers WHERE id=? AND type='markdown'") + .bind(n) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + Ok(()) + } +} +fn preview(text: &str) -> String { + text.chars() + .take(120) + .map(|c| if c.is_control() { ' ' } else { c }) + .collect() +} +async fn key_in(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>) -> Result> { + let value: Option = + sqlx::query_scalar("SELECT configuration FROM text_master_key WHERE singleton=1") + .fetch_optional(&mut **tx) + .await?; + value.as_deref().map(MasterKey::parse).transpose() +} +async fn settings_in(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>) -> Result { + let mut settings = ClipboardSettings::default(); + for row in sqlx::query("SELECT key,value FROM clipboard_settings") + .fetch_all(&mut **tx) + .await? + { + let key: String = row.try_get("key")?; + let value: String = row.try_get("value")?; + match key.as_str() { + "normal_count_limit" => settings.normal_count_limit = value.parse()?, + "normal_byte_limit" => settings.normal_byte_limit = value.parse()?, + "capture_enabled" => settings.capture_enabled = value == "1", + _ => {} + } + } + Ok(settings) +} +async fn prune( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + settings: &ClipboardSettings, +) -> Result<()> { + let rows = sqlx::query("SELECT id,byte_size FROM clipboard_captures ORDER BY MAX(created_at,last_copied_at,COALESCE(last_used_at,0)) DESC,id DESC") + .fetch_all(&mut **tx).await?; + let mut count = 0i64; + let mut bytes = 0i64; + let mut exhausted = false; + for row in rows { + let size: i64 = row.try_get("byte_size")?; + count += 1; + bytes = bytes.saturating_add(size); + exhausted |= count > settings.normal_count_limit || bytes > settings.normal_byte_limit; + if exhausted { + sqlx::query("DELETE FROM clipboard_captures WHERE id=?") + .bind(row.try_get::("id")?) + .execute(&mut **tx) + .await?; + } + } + Ok(()) +} diff --git a/tests/clipboard_storage.rs b/tests/clipboard_storage.rs index b078f99..6023522 100644 --- a/tests/clipboard_storage.rs +++ b/tests/clipboard_storage.rs @@ -1,953 +1,274 @@ -//! Integration tests against the real SQLite-backed clipboard store. -//! -//! The CLI and GUI both go through this storage layer, so verifying it as an isolated contract -//! gives us the confidence to plug the future GUI chooser on top without revalidating the -//! ordering/dedupe invariants. - +//! Capture and read-only legacy import contracts on isolated SQLite files. #![cfg(feature = "storage")] use futures::executor::block_on; -use rustickers::clipboard::items::{ClipboardItemKind, RetentionState}; -use rustickers::clipboard::ordering::sort_in_place; -use rustickers::clipboard::storage::{ClipboardHistoryStore, SqliteClipboardStore}; - -#[test] -fn insert_then_snapshot_round_trips() { - let tmp = tempfile::tempdir().expect("tmpdir"); - let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open"); - - let first = block_on(store.upsert_ordinary( - "alpha content", - "alpha content", - &SqliteClipboardStore::ordinary_digest("alpha content"), - 13, - 1_700_000_000_000, - None, - )) - .expect("insert first"); - let id_first = match first { - rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, - _ => panic!("first insert should create"), - }; - - let second = block_on(store.upsert_ordinary( - "beta content", - "beta content", - &SqliteClipboardStore::ordinary_digest("beta content"), - 12, - 1_700_000_001_000, - None, - )) - .expect("insert second"); - let id_second = match second { - rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, - _ => panic!("second insert should create"), - }; - - let snapshot = block_on(store.snapshot()).expect("snapshot"); - assert_eq!(snapshot.len(), 2); - // MRU sort: bigger timestamp first; ties fall back to created_at + id, both deterministic. - assert_eq!(snapshot[0].id, id_second); - assert_eq!(snapshot[1].id, id_first); - assert_eq!(snapshot[0].kind, ClipboardItemKind::Plain); -} - -#[test] -fn digest_dedupe_returns_existing_row() { - let tmp = tempfile::tempdir().expect("tmpdir"); - let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open"); - - let digest = SqliteClipboardStore::ordinary_digest("dup"); - let id1 = match block_on(store.upsert_ordinary("dup", "dup", &digest, 3, 1_700_000_000_000, None)) - .expect("first") - { - rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, - other => panic!("expected Inserted, got {other:?}"), - }; - - let id2 = match block_on(store.upsert_ordinary("dup", "dup", &digest, 3, 1_700_000_010_000, None)) - .expect("second") - { - rustickers::clipboard::storage::UpsertOutcome::Updated { id } => id, - other => panic!("expected Updated, got {other:?}"), - }; - assert_eq!(id1, id2); - - let snapshot = block_on(store.snapshot()).expect("snapshot"); - assert_eq!(snapshot.len(), 1, "no parallel rows for identical content"); - assert_eq!( - snapshot[0].last_copied_at, 1_700_000_010_000, - "duplicate copy bumps recency timestamp" - ); -} - -#[test] -fn setting_retention_flags_protects_against_evict() { - let tmp = tempfile::tempdir().expect("tmpdir"); - let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open"); - - let id = match block_on(store.upsert_ordinary( - "pinned", - "pinned", - &SqliteClipboardStore::ordinary_digest("pinned"), - "pinned".len() as i64, - 1_700_000_000_000, - None, - )) - .expect("insert") - { - rustickers::clipboard::storage::UpsertOutcome::Inserted { id } => id, - _ => panic!("insert"), - }; - - block_on(store.set_retention_flag( - id, - rustickers::clipboard::storage::RetentionFlag::Explicit, - true, - )) - .expect("flag"); - - let snapshot = block_on(store.snapshot()).expect("snapshot"); - assert!(snapshot[0].retention.explicit); - assert!(snapshot[0].retention.is_retained()); - assert_eq!( - snapshot[0].retention, - RetentionState { - explicit: true, - edited: false, - protected_: false - } - ); -} - -#[test] -fn filter_indices_match_against_plain_preview() { - use rustickers::clipboard::filter::filter_indices; - let snapshot = vec![ - make_row(1, "the quick brown fox", Some("the quick brown fox")), - make_row(2, "another line", Some("another line")), - ]; - let hits = filter_indices(&snapshot, "fox"); - assert_eq!(hits, vec![0]); -} - -#[test] -fn sort_in_place_is_stable_across_modules() { - let mut snapshot = Vec::new(); - snapshot.push(make_row(3, "z", Some("z"))); - snapshot.push(make_row(1, "a", Some("a"))); - snapshot.push(make_row(2, "m", Some("m"))); - sort_in_place(&mut snapshot); - let ids: Vec = snapshot.iter().map(|row| row.id).collect(); - // Larger recency wins; helper makes id=1 newest, id=3 oldest: [1,2,3]. - assert_eq!(ids, vec![1, 2, 3]); -} - -fn make_row( - id: i64, - content: &str, - preview: Option<&str>, -) -> rustickers::clipboard::items::ClipboardItem { - let now = chrono::Utc::now().timestamp_millis(); - rustickers::clipboard::items::ClipboardItem { - id, - kind: ClipboardItemKind::Plain, - content: content.to_string(), - plain_preview: preview.map(|p| p.to_owned()), - public_label: None, - byte_size: content.len() as i64, - created_at: now - id, - last_copied_at: now - id, - last_used_at: None, - last_edited_at: None, - retention: RetentionState::default(), - } -} - -fn inserted_id(outcome: rustickers::clipboard::storage::UpsertOutcome) -> i64 { - match outcome { - rustickers::clipboard::storage::UpsertOutcome::Inserted { id } - | rustickers::clipboard::storage::UpsertOutcome::Updated { id } => id, - } -} - -fn insert(store: &SqliteClipboardStore, text: &str, time: i64) -> i64 { - inserted_id( - block_on(store.upsert_ordinary( - text, - text, - &SqliteClipboardStore::ordinary_digest(text), - text.len() as i64, - time, - None, - )) - .unwrap(), +use rustickers::text::{TextEntryId, TextRepository, TextSources}; +use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; + +async fn pool(path: &std::path::Path) -> anyhow::Result { + Ok(SqlitePool::connect_with( + SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), ) + .await?) } -#[test] -fn dedupe_preserves_exact_case_whitespace_and_edited_identity() { - let tmp = tempfile::tempdir().unwrap(); - let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); - let id = insert(&store, "Alpha", 10); - assert_ne!(id, insert(&store, "alpha", 11)); - assert_ne!(id, insert(&store, " Alpha ", 12)); - block_on(store.replace_content(id, "edited", 6, 13)).unwrap(); - assert_eq!(id, insert(&store, "edited", 14)); - assert_ne!(id, insert(&store, "Alpha", 15)); - assert_eq!(block_on(store.get(id)).unwrap().content, "edited"); +async fn legacy(path: &std::path::Path) -> anyhow::Result { + let pool = pool(path).await?; + sqlx::raw_sql(include_str!("../migrations/0010_add_clipboard_history.sql")) + .execute(&pool) + .await?; + Ok(pool) } -#[test] -fn no_op_edit_does_not_retain_and_deleted_edit_fails() { - let tmp = tempfile::tempdir().unwrap(); - let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); - let id = insert(&store, "same", 10); - block_on(store.replace_content(id, "same", 4, 20)).unwrap(); - let row = block_on(store.get(id)).unwrap(); - assert!(!row.retention.edited); - assert_eq!(row.last_edited_at, None); - block_on(store.delete_row(id)).unwrap(); - assert!(block_on(store.replace_content(id, "lost", 4, 30)).is_err()); +async fn raw( + pool: &SqlitePool, + text: &str, + created: i64, + copied: i64, + used: Option, +) -> anyhow::Result<()> { + // Deliberately inaccurate metadata and obsolete flags must not affect raw import. + sqlx::query("INSERT INTO clipboard_items(content,content_bytes,plain_preview,plaintext_digest,protected_envelope,public_label,retention_explicit,retention_edited,retention_protected,locked,created_at,last_copied_at,last_used_at,last_edited_at,byte_size) VALUES(?,999,'wrong preview','wrong digest','RUSTICKERS_VAULT_V1\nold envelope','old label',1,1,1,1,?,?,?,9999,999)") + .bind(text).bind(created).bind(copied).bind(used).execute(pool).await?; + Ok(()) } #[test] -fn concurrent_connections_dedupe_and_prune_revalidates_saved_rows() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("clip.db"); - let a = block_on(SqliteClipboardStore::open(&path)).unwrap(); - let b = block_on(SqliteClipboardStore::open(&path)).unwrap(); - let worker = std::thread::spawn(move || insert(&b, "duplicate", 20)); - let id = insert(&a, "duplicate", 10); - assert_eq!(id, worker.join().unwrap()); - assert_eq!(block_on(a.get(id)).unwrap().last_copied_at, 20); - block_on(a.replace_content(id, "saved", 5, 30)).unwrap(); - block_on(a.delete_ordinary_by_ids(&[id])).unwrap(); - assert!(block_on(a.get(id)).unwrap().retention.edited); +fn capture_round_trip_persists_without_legacy_vault_dependency() -> anyhow::Result<()> { + block_on(async { + let dir = tempfile::tempdir()?; + let path = dir.path().join("stickers.db"); + let repo = TextRepository::open(&path).await?; + let settings = repo.settings().await?; + assert!( + !settings.capture_enabled, + "observer capture defaults to paused" + ); + assert_eq!(settings.normal_count_limit, 500); + assert_eq!(settings.normal_byte_limit, 20 * 1024 * 1024); + assert!(repo.master_key().await?.is_none()); + let exact = " \t大文字\r\n'quoted' ; & "; + let id = repo.capture(exact, 10).await?; + assert_eq!(repo.capture(exact, 20).await?, id); + let reopened = TextRepository::open(&path).await?; + let rows = reopened.snapshot(TextSources::new(false, true), "").await?; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id(), TextEntryId::Clipboard(id)); + assert_eq!(rows[0].activity_at(), 20); + assert_eq!(&*reopened.resolve(&rows[0], None).await?, exact); + assert!( + !dir.path().join("clipboard.db").exists(), + "opening the canonical store must not create an old backend" + ); + Ok(()) + }) } #[test] -fn protected_storage_never_exposes_plaintext_and_cannot_orphan_vault() { - use rustickers::clipboard::storage::RetentionFlag; - use rustickers::clipboard::vault_controller::VaultController; - use std::sync::Arc; - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("clip.db"); - let store = Arc::new(block_on(SqliteClipboardStore::open(&path)).unwrap()); - let controller = VaultController::new(store.clone()); - block_on(controller.create("password")).unwrap(); - assert!(block_on(controller.unlock("password")).unwrap()); - let vault_envelope = controller - .seal_for_storage(b"generation probe") - .unwrap() - .unwrap() - .vault_envelope; - let secret = "regression-secret-7791"; - let sealed = controller.seal(secret.as_bytes()).unwrap().unwrap(); - assert!( - block_on(store.upsert_protected_bound( - None, - Some(secret), - None, - &sealed, - 22, - 10, - &vault_envelope - )) - .is_err() - ); - let id = inserted_id( - block_on(store.upsert_protected_bound( - Some("alias"), - None, - Some("keyed-token"), - &sealed, - 22, - 10, - &vault_envelope, - )) - .unwrap(), - ); - let sealed_again = controller.seal(secret.as_bytes()).unwrap().unwrap(); - assert_eq!( - id, - inserted_id( - block_on(store.upsert_protected_bound( - None, - None, - Some("keyed-token"), - &sealed_again, - 22, - 20, - &vault_envelope - )) - .unwrap() +fn legacy_raw_import_ignores_flags_recomputes_bytes_and_is_restart_safe() -> anyhow::Result<()> { + block_on(async { + let dir = tempfile::tempdir()?; + let path = dir.path().join("stickers.db"); + let old_path = dir.path().join("clipboard.db"); + let old = legacy(&old_path).await?; + let exact = " \tRaw 雪\r\n'keep' "; + raw(&old, exact, 10, 20, Some(30)).await?; + raw(&old, exact, 15, 40, Some(50)).await?; + raw(&old, "raw 雪", 60, 60, None).await?; + let invalid = [ + "", + " \r\n\t", + "bad\0text", + "RUSTICKERS_VAULT_V1\nsecret", + " RUSTICKERS_LOCKED_V1\nsecret", + "RUSTICKERS_TEXT_V2\nsecret", + "RUSTICKERS_TEXT_V1\ngeneration\nciphertext", + ]; + for text in invalid { + raw(&old, text, 1, 1, None).await?; + } + raw(&old, &"x".repeat(1024 * 1024 + 1), 1, 1, None).await?; + old.close().await; + let original_file = std::fs::read(&old_path)?; + let repo = TextRepository::open(&path).await?; // Startup owns the one-shot import. + let db = pool(&path).await?; + let rows = repo.snapshot(TextSources::new(false, true), "").await?; + assert_eq!(rows.len(), 2); + let imported = rows + .iter() + .find(|row| row.matches("Raw") && row.activity_at() == 50) + .unwrap(); + assert_eq!(&*repo.resolve(imported, None).await?, exact); + assert!(!imported.matches("old label")); + assert!(!imported.matches("wrong preview")); + let metadata: (i64, i64, Option) = sqlx::query_as( + "SELECT byte_size,last_copied_at,last_used_at FROM clipboard_captures WHERE id=?", ) - ); - let row = block_on(store.get(id)).unwrap(); - assert_eq!(row.kind, ClipboardItemKind::Protected); - assert_eq!(row.content, sealed); - assert_eq!(row.plain_preview, None); - assert_eq!(row.searchable_string(), "alias\n"); - assert_eq!( - controller.open(&row.content).unwrap().unwrap().as_slice(), - secret.as_bytes() - ); - assert!(block_on(store.replace_content(id, secret, secret.len() as i64, 30)).is_err()); - assert!(block_on(store.set_retention_flag(id, RetentionFlag::Protected, false)).is_err()); - block_on(store.set_retention_flag(id, RetentionFlag::Explicit, false)).unwrap(); - block_on(store.delete_ordinary_by_ids(&[id])).unwrap(); - assert!(block_on(store.get(id)).unwrap().retention.protected_); - assert!(block_on(controller.create("replacement")).is_err()); - assert!(block_on(controller.disable()).is_err()); - assert!( - block_on(store.upsert_ordinary( - secret, - secret, - &SqliteClipboardStore::ordinary_digest(secret), - secret.len() as i64, - 30, - None, - )) - .is_err() - ); - block_on(controller.rewrap("new-password")).unwrap(); - assert!(block_on(controller.unlock("new-password")).unwrap()); - assert_eq!( - controller.open(&sealed).unwrap().unwrap().as_slice(), - secret.as_bytes() - ); - controller.lock(); - assert!(controller.open(&sealed).unwrap().is_none()); - // Check persisted columns, not just the redacted snapshot. - let pool = block_on(sqlx::SqlitePool::connect_with( - sqlx::sqlite::SqliteConnectOptions::new().filename(&path), - )) - .unwrap(); - let raw: (String, Option, Option) = block_on( - sqlx::query_as( - "SELECT content, plain_preview, plaintext_digest FROM clipboard_items WHERE id = ?1", + .bind(match imported.id() { + TextEntryId::Clipboard(id) => id, + _ => unreachable!(), + }) + .fetch_one(&db) + .await?; + assert_eq!(metadata, (exact.len() as i64, 40, Some(50))); + let counts: (i64, i64) = sqlx::query_as( + "SELECT imported_count,skipped_count FROM text_imports WHERE source='legacy-clipboard'", ) - .bind(id) - .fetch_one(&pool), - ) - .unwrap(); - assert_eq!(raw, (String::new(), None, None)); - assert!( - !std::fs::read(path) - .unwrap() - .windows(secret.len()) - .any(|window| window == secret.as_bytes()) - ); + .fetch_one(&db) + .await?; + assert_eq!(counts, (2, 8)); + repo.delete(imported.id()).await?; + let reopened = TextRepository::open(&path).await?; + reopened.import_legacy(&old_path).await?; + assert!( + reopened.get(imported.id()).await.is_err(), + "deleted imported text must not resurrect on restart" + ); + assert_eq!( + reopened + .snapshot(TextSources::new(false, true), "") + .await? + .len(), + 1 + ); + assert_eq!( + std::fs::read(&old_path)?, + original_file, + "legacy database must be byte-for-byte untouched" + ); + Ok(()) + }) } #[test] -fn stale_password_change_cannot_overwrite_new_envelope() { - use rustickers::clipboard::vault_controller::VaultController; - use std::sync::Arc; - let tmp = tempfile::tempdir().unwrap(); - let store = Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); - let a = VaultController::new(store.clone()); - let b = VaultController::new(store.clone()); - block_on(a.create("old")).unwrap(); - assert!(block_on(a.unlock("old")).unwrap()); - assert!(block_on(b.unlock("old")).unwrap()); - block_on(a.rewrap("first")).unwrap(); - assert!(block_on(b.rewrap("stale")).is_err()); - assert!(block_on(a.unlock("first")).unwrap()); -} - -#[test] -fn stale_editor_save_fails_without_overwriting_newer_content() { - let tmp = tempfile::tempdir().unwrap(); - let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); - let id = insert(&store, "original", 10); - block_on(store.compare_exchange_content(id, "original", "first", 5, 20, None)).unwrap(); - assert!( - block_on(store.compare_exchange_content(id, "original", "stale", 5, 30, None)).is_err() - ); - assert_eq!(block_on(store.get(id)).unwrap().content, "first"); - block_on(store.delete_row(id)).unwrap(); - assert!(block_on(store.compare_exchange_content(id, "first", "lost", 4, 40, None)).is_err()); -} - -#[test] -fn editor_save_writes_label_alongside_content() { - let tmp = tempfile::tempdir().unwrap(); - let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); - let id = insert(&store, "first", 5); - - // Edit content + label together; both must persist. - block_on(store.compare_exchange_content(id, "first", "second", 6, 20, Some("alias"))).unwrap(); - let row = block_on(store.get(id)).unwrap(); - assert_eq!(row.content, "second"); - assert_eq!(row.public_label.as_deref(), Some("alias")); - - // Edit content while leaving the label untouched; the label survives. - block_on(store.compare_exchange_content(id, "second", "third", 5, 30, None)).unwrap(); - let row = block_on(store.get(id)).unwrap(); - assert_eq!(row.content, "third"); - assert_eq!(row.public_label.as_deref(), Some("alias")); - - // Empty/whitespace label collapses to NULL. - block_on(store.compare_exchange_content(id, "third", "fourth", 6, 40, Some(" "))).unwrap(); - let row = block_on(store.get(id)).unwrap(); - assert_eq!(row.content, "fourth"); - assert!(row.public_label.is_none()); - - // A label-only change also commits: content moves with the new alias and - // `last_edited_at` is bumped because the content differs. - block_on(store.compare_exchange_content(id, "fourth", "fifth", 5, 50, Some("next-alias"))) - .unwrap(); - let row = block_on(store.get(id)).unwrap(); - assert_eq!(row.content, "fifth"); - assert_eq!(row.public_label.as_deref(), Some("next-alias")); - assert_eq!(row.last_edited_at, Some(50)); - assert!(row.retention.edited); -} - -#[test] -fn protected_display_helpers_ignore_even_damaged_plaintext_metadata() { - let mut row = make_row(1, "secret-plaintext", Some("secret-preview")); - row.kind = ClipboardItemKind::Protected; - assert_eq!(row.preview_text(), "Protected item"); - assert_eq!(row.searchable_string(), ""); - row.public_label = Some("public alias".into()); - assert_eq!(row.preview_text(), "public alias"); - assert_eq!(row.searchable_string(), "public alias\n"); - let preview = rustickers::clipboard::ProtectedPreview { - item: row, - plaintext: "secret-plaintext".into(), - match_token: Some("private-token".into()), - }; - let debug = format!("{preview:?}"); - assert!(!debug.contains("secret")); - assert!(!debug.contains("private-token")); -} - -#[test] -fn ordinary_upsert_and_lowered_limits_trim_atomically_without_touching_retained() { - use rustickers::clipboard::{ClipboardSettings, storage::RetentionFlag}; - let tmp = tempfile::tempdir().unwrap(); - let store = block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap(); - let saved = insert(&store, "saved", 1); - block_on(store.set_retention_flag(saved, RetentionFlag::Explicit, true)).unwrap(); - let limits = ClipboardSettings { - normal_count_limit: 2, - normal_byte_limit: 8, - capture_enabled: true, - }; - block_on(store.update_settings(&limits)).unwrap(); - let old = insert(&store, "aaaa", 10); - let current = insert(&store, "bbbb", 20); - assert_eq!(block_on(store.snapshot()).unwrap().len(), 3); - assert_eq!(current, insert(&store, "bbbb", 30)); - assert!( - block_on(store.get(old)).is_ok(), - "dedupe must not double count an incoming row" - ); - let newest = insert(&store, "cccc", 40); - assert!(block_on(store.get(old)).is_err()); - assert!(block_on(store.get(saved)).is_ok()); - let smaller = ClipboardSettings { - normal_count_limit: 1, - normal_byte_limit: 4, - capture_enabled: true, - }; - block_on(store.update_settings(&smaller)).unwrap(); - assert!(block_on(store.get(current)).is_err()); - assert!(block_on(store.get(newest)).is_ok()); - assert!(block_on(store.get(saved)).is_ok()); - assert!( - block_on(store.upsert_ordinary( - "oversized", - "oversized", - &SqliteClipboardStore::ordinary_digest("oversized"), - 9, - 50, - None, - )) - .is_err() - ); - assert!( - block_on(store.get(newest)).is_ok(), - "oversized insert must not prune old data" - ); -} - -#[test] -fn protected_editor_cas_keeps_ciphertext_only_and_rejects_stale_save() { - use rustickers::clipboard::vault_controller::VaultController; - use std::sync::Arc; - let tmp = tempfile::tempdir().unwrap(); - let store = Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); - let controller = VaultController::new(store.clone()); - block_on(controller.create("password")).unwrap(); - assert!(block_on(controller.unlock("password")).unwrap()); - let vault_envelope = controller - .seal_for_storage(b"generation probe") - .unwrap() - .unwrap() - .vault_envelope; - let first = controller.seal(b"first-secret").unwrap().unwrap(); - let id = inserted_id( - block_on(store.upsert_protected_bound(None, None, None, &first, 12, 10, &vault_envelope)) - .unwrap(), - ); - let second = controller.seal(b"edited-secret").unwrap().unwrap(); - block_on(store.compare_exchange_protected_content_bound( - id, - &first, - &second, - 13, - 20, - &vault_envelope, - None, - )) - .unwrap(); - assert!( - block_on(store.compare_exchange_protected_content_bound( - id, - &first, - &first, - 12, - 30, - &vault_envelope, - None, - )) - .is_err() - ); - let row = block_on(store.get(id)).unwrap(); - assert!(row.retention.edited && row.retention.protected_); - assert!( - !row.retention.explicit, - "protection must not add an unrelated reservation reason" - ); - assert_eq!(row.plain_preview, None); - assert_eq!( - controller.open(&row.content).unwrap().unwrap().as_slice(), - b"edited-secret" - ); -} - -#[test] -fn reset_generation_rejects_stale_insert_and_protection_without_mutation() { - use rustickers::clipboard::vault_controller::VaultController; - use std::sync::Arc; - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("clip.db"); - let store = Arc::new(block_on(SqliteClipboardStore::open(&path)).unwrap()); - let other_store = Arc::new(block_on(SqliteClipboardStore::open(&path)).unwrap()); - let stale = VaultController::new(store.clone()); - let resetter = VaultController::new(other_store); - let id = insert(&store, "ordinary", 10); - assert!(!block_on(store.has_protected_items()).unwrap()); - block_on(stale.create("old-password")).unwrap(); - assert!(block_on(stale.unlock("old-password")).unwrap()); - let sealed = stale.seal_for_storage(b"ordinary").unwrap().unwrap(); - block_on(resetter.disable()).unwrap(); - // Reconfiguration happens through a different pool/controller; stale still holds its key. - block_on(resetter.create("new-password")).unwrap(); - assert!( - block_on(store.upsert_protected_bound( - None, - None, - None, - &sealed.ciphertext, - 8, - 20, - &sealed.vault_envelope - )) - .is_err() - ); - assert!( - block_on(store.compare_exchange_protection( - id, - "ordinary", - &sealed.ciphertext, - &sealed.vault_envelope, - None, - None, - )) - .is_err() - ); - assert!(block_on(store.upsert_protected(None, None, None, &sealed.ciphertext, 8, 20)).is_err()); - assert_eq!(block_on(store.get(id)).unwrap().content, "ordinary"); - assert_eq!(block_on(store.snapshot()).unwrap().len(), 1); - assert!(!block_on(store.has_protected_items()).unwrap()); - assert!(block_on(resetter.unlock("new-password")).unwrap()); - let fresh = resetter.seal_for_storage(b"ordinary").unwrap().unwrap(); - block_on(store.compare_exchange_protection( - id, - "ordinary", - &fresh.ciphertext, - &fresh.vault_envelope, - None, - Some(" Saved command "), - )) - .unwrap(); - assert!(block_on(store.has_protected_items()).unwrap()); - assert_eq!( - block_on(store.get(id)).unwrap().public_label.as_deref(), - Some("Saved command") - ); - assert_eq!( - resetter - .open(&block_on(store.get(id)).unwrap().content) - .unwrap() - .unwrap() - .as_slice(), - b"ordinary" - ); +fn legacy_settings_are_preserved_and_import_obeys_their_budgets() -> anyhow::Result<()> { + block_on(async { + let dir = tempfile::tempdir()?; + let old = legacy(&dir.path().join("clipboard.db")).await?; + sqlx::query("UPDATE clipboard_settings SET value=CASE key WHEN 'normal_count_limit' THEN '2' WHEN 'normal_byte_limit' THEN '4' WHEN 'capture_enabled' THEN '1' ELSE value END") + .execute(&old).await?; + for (text, time) in [("aa", 10), ("bb", 20), ("cc", 30)] { + raw(&old, text, time, time, None).await?; + } + old.close().await; + let repo = TextRepository::open(dir.path().join("stickers.db")).await?; + let settings = repo.settings().await?; + assert_eq!( + settings.normal_count_limit, 2, + "cutover must preserve the user's legacy count budget" + ); + assert_eq!( + settings.normal_byte_limit, 4, + "cutover must preserve the user's legacy byte budget" + ); + assert!( + settings.capture_enabled, + "cutover must preserve the explicit collection opt-in" + ); + let rows = repo.snapshot(TextSources::new(false, true), "").await?; + assert_eq!(rows.len(), 2); + assert_eq!(&*repo.resolve(&rows[0], None).await?, "cc"); + assert_eq!(&*repo.resolve(&rows[1], None).await?, "bb"); + Ok(()) + }) } #[test] -fn protection_cas_preserves_identity_retention_and_recency_and_clears_live_plaintext() { - use rustickers::clipboard::{storage::RetentionFlag, vault_controller::VaultController}; - use std::sync::Arc; - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("clip.db"); - let store = Arc::new(block_on(SqliteClipboardStore::open(&path)).unwrap()); - let id = insert(&store, "original", 10); - block_on(store.replace_content(id, "saved secret", 12, 20)).unwrap(); - block_on(store.set_retention_flag(id, RetentionFlag::Explicit, true)).unwrap(); - block_on(store.mark_used(id, 30)).unwrap(); - let before = block_on(store.get(id)).unwrap(); - let controller = VaultController::new(store.clone()); - block_on(controller.create("password")).unwrap(); - assert!(block_on(controller.unlock("password")).unwrap()); - let sealed = controller - .seal_for_storage(before.content.as_bytes()) - .unwrap() - .unwrap(); - assert!( - block_on(store.compare_exchange_protection( - id, - "original", - &sealed.ciphertext, - &sealed.vault_envelope, - None, - None, - )) - .is_err() - ); - block_on(store.compare_exchange_protection( - id, - &before.content, - &sealed.ciphertext, - &sealed.vault_envelope, - None, - None, - )) - .unwrap(); - let after = block_on(store.get(id)).unwrap(); - assert_eq!(after.id, before.id); - assert_eq!(after.created_at, before.created_at); - assert_eq!(after.last_copied_at, before.last_copied_at); - assert_eq!(after.last_used_at, before.last_used_at); - assert_eq!(after.last_edited_at, before.last_edited_at); - assert!(after.retention.explicit && after.retention.edited && after.retention.protected_); - assert_eq!(after.byte_size, before.byte_size); - assert_eq!(after.plain_preview, None); - assert_eq!(after.searchable_string(), ""); - assert_eq!( - controller.open(&after.content).unwrap().unwrap().as_slice(), - b"saved secret" - ); - let pool = block_on(sqlx::SqlitePool::connect_with( - sqlx::sqlite::SqliteConnectOptions::new().filename(&path), - )) - .unwrap(); - let raw: (String, Option, Option, Option) = block_on( - sqlx::query_as( - "SELECT content, plain_preview, plaintext_digest, protected_match_token - FROM clipboard_items WHERE id = ?1", +fn import_merges_canonical_raw_rows_with_external_rows_and_never_replays_them() -> anyhow::Result<()> +{ + block_on(async { + let dir = tempfile::tempdir()?; + let path = dir.path().join("stickers.db"); + let repo = TextRepository::open(&path).await?; + let db = pool(&path).await?; + raw(&db, "canonical raw", 1, 10, Some(20)).await?; + let old_path = dir.path().join("clipboard.db"); + let old = legacy(&old_path).await?; + raw(&old, "canonical raw", 2, 30, Some(40)).await?; + raw(&old, "external raw", 3, 50, None).await?; + old.close().await; + repo.import_legacy(&old_path).await?; + let rows = repo.snapshot(TextSources::new(false, true), "").await?; + assert_eq!(rows.len(), 2); + let canonical = rows.iter().find(|row| row.matches("canonical")).unwrap(); + assert_eq!(canonical.activity_at(), 40); + assert_eq!(&*repo.resolve(canonical, None).await?, "canonical raw"); + let counts: (i64, i64) = sqlx::query_as( + "SELECT imported_count,skipped_count FROM text_imports WHERE source='legacy-clipboard'", ) - .bind(id) - .fetch_one(&pool), - ) - .unwrap(); - assert_eq!(raw, (String::new(), None, None, None)); - // A stale repeat and a deleted selection cannot mutate or resurrect a protected row. - assert!( - block_on(store.compare_exchange_protection( - id, - &before.content, - &sealed.ciphertext, - &sealed.vault_envelope, - None, - None, - )) - .is_err() - ); - block_on(store.delete_row(id)).unwrap(); - assert!( - block_on(store.compare_exchange_protection( - id, - &before.content, - &sealed.ciphertext, - &sealed.vault_envelope, - None, - None, - )) - .is_err() - ); -} - -#[test] -fn password_rewrap_requires_new_generation_for_protected_edit() { - use rustickers::clipboard::vault_controller::VaultController; - use std::sync::Arc; - let tmp = tempfile::tempdir().unwrap(); - let store = Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); - let controller = VaultController::new(store.clone()); - block_on(controller.create("old")).unwrap(); - assert!(block_on(controller.unlock("old")).unwrap()); - let first = controller.seal_for_storage(b"first").unwrap().unwrap(); - let id = inserted_id( - block_on(store.upsert_protected_bound( - None, - None, - None, - &first.ciphertext, - 5, - 10, - &first.vault_envelope, - )) - .unwrap(), - ); - let stale_edit = controller.seal_for_storage(b"stale").unwrap().unwrap(); - block_on(controller.rewrap("new")).unwrap(); - assert!( - block_on(store.compare_exchange_protected_content_bound( - id, - &first.ciphertext, - &stale_edit.ciphertext, - 5, - 20, - &stale_edit.vault_envelope, - None, - )) - .is_err() - ); - assert_eq!(block_on(store.get(id)).unwrap().content, first.ciphertext); - assert!(block_on(controller.unlock("new")).unwrap()); - let fresh = controller.seal_for_storage(b"fresh").unwrap().unwrap(); - block_on(store.compare_exchange_protected_content_bound( - id, - &first.ciphertext, - &fresh.ciphertext, - 5, - 20, - &fresh.vault_envelope, - None, - )) - .unwrap(); - assert_eq!( - controller - .open(&block_on(store.get(id)).unwrap().content) - .unwrap() - .unwrap() - .as_slice(), - b"fresh" - ); -} - -#[test] -fn authenticated_unprotection_is_atomic_and_preserves_pin() { - use rustickers::clipboard::storage::RetentionFlag; - use rustickers::clipboard::vault_controller::VaultController; - use std::sync::Arc; - let tmp = tempfile::tempdir().unwrap(); - let store = Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).unwrap()); - let vault = VaultController::new(store.clone()); - block_on(vault.create("p")).unwrap(); - assert!(block_on(vault.unlock("p")).unwrap()); - let sealed = vault.seal_for_storage(b"secret").unwrap().unwrap(); - let id = inserted_id( - block_on(store.upsert_protected_bound( - None, - None, - None, - &sealed.ciphertext, - 6, - 10, - &sealed.vault_envelope, - )) - .unwrap(), - ); - block_on(store.set_retention_flag(id, RetentionFlag::Explicit, true)).unwrap(); - assert!( - block_on(store.compare_exchange_unprotection( - id, - "stale", - "secret", - &sealed.vault_envelope - )) - .is_err() - ); - assert!( - block_on(store.compare_exchange_unprotection( - id, - &sealed.ciphertext, - "secret", - "stale-vault" - )) - .is_err() - ); - assert_eq!( - block_on(store.get(id)).unwrap().kind, - ClipboardItemKind::Protected - ); - block_on(store.compare_exchange_unprotection( - id, - &sealed.ciphertext, - "secret", - &sealed.vault_envelope, - )) - .unwrap(); - let row = block_on(store.get(id)).unwrap(); - assert_eq!(row.kind, ClipboardItemKind::Plain); - assert_eq!(row.content, "secret"); - assert!(row.retention.explicit()); - assert!(!row.retention.protected()); - assert!( - block_on(store.compare_exchange_unprotection( - id, - &sealed.ciphertext, - "secret", - &sealed.vault_envelope - )) - .is_err() - ); + .fetch_one(&db) + .await?; + assert_eq!(counts, (2, 0)); + repo.delete(canonical.id()).await?; + repo.import_legacy(&old_path).await?; + assert!(repo.get(canonical.id()).await.is_err()); + Ok(()) + }) } #[test] -fn authorized_recapture_promotes_protected_row_and_lets_new_text_through() { - use rustickers::clipboard::storage::UpsertOutcome; - use rustickers::clipboard::vault_controller::VaultController; - - let tmp = tempfile::tempdir().expect("tmpdir"); - let store: std::sync::Arc = - std::sync::Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open")); - let controller = VaultController::new(store.clone()); - block_on(controller.create("password")).unwrap(); - assert!(block_on(controller.unlock("password")).unwrap()); - - let secret = "recapture secret"; - let sealed = controller.seal_for_storage(secret.as_bytes()).unwrap().unwrap(); - let token = controller.match_token(secret.as_bytes()).unwrap().unwrap(); - let protected_id = inserted_id( - block_on(store.upsert_protected_bound( - None, - None, - Some(&token), - &sealed.ciphertext, - secret.len() as i64, - 10, - &sealed.vault_envelope, - )) - .unwrap(), - ); - - // Without an authorized token the ordinary write still fails closed. - assert!(block_on(store.upsert_ordinary( - secret, - secret, - &SqliteClipboardStore::ordinary_digest(secret), - secret.len() as i64, - 20, - None - )) - .is_err()); - - // Authorized and identical to the protected content: the protected row is promoted and no - // plaintext copy is ever written. - let outcome = block_on(store.upsert_ordinary( - secret, - secret, - &SqliteClipboardStore::ordinary_digest(secret), - secret.len() as i64, - 30, - Some(&token), - )) - .unwrap(); - match outcome { - UpsertOutcome::Updated { id } => assert_eq!(id, protected_id), - other => panic!("expected promotion of the protected row, got {other:?}"), - } - assert_eq!(block_on(store.snapshot()).unwrap().len(), 1); - assert!(block_on(store.get(protected_id)).unwrap().retention.protected_); - - // Authorized and different: the token proves absence, so ordinary collection proceeds. - let fresh = "an ordinary later copy"; - let fresh_token = controller.match_token(fresh.as_bytes()).unwrap().unwrap(); - let outcome = block_on(store.upsert_ordinary( - fresh, - fresh, - &SqliteClipboardStore::ordinary_digest(fresh), - fresh.len() as i64, - 40, - Some(&fresh_token), - )) - .unwrap(); - match outcome { - UpsertOutcome::Inserted { .. } => {} - other => panic!("expected Inserted for non-matching text, got {other:?}"), - } - assert_eq!(block_on(store.snapshot()).unwrap().len(), 2); +fn absent_legacy_file_does_not_mark_import_complete_or_prevent_later_import() -> anyhow::Result<()> +{ + block_on(async { + let dir = tempfile::tempdir()?; + let path = dir.path().join("stickers.db"); + let old_path = dir.path().join("clipboard.db"); + let repo = TextRepository::open(&path).await?; + repo.import_legacy(&old_path).await?; + let db = pool(&path).await?; + let markers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM text_imports") + .fetch_one(&db) + .await?; + assert_eq!(markers, 0); + assert!(!old_path.exists()); + let old = legacy(&old_path).await?; + raw(&old, "arrived later", 1, 2, None).await?; + old.close().await; + repo.import_legacy(&old_path).await?; + assert_eq!( + repo.snapshot(TextSources::new(false, true), "") + .await? + .len(), + 1 + ); + Ok(()) + }) } #[test] -fn protecting_a_row_stores_its_token_for_later_recapture() { - use rustickers::clipboard::vault_controller::VaultController; - - let tmp = tempfile::tempdir().expect("tmpdir"); - let store: std::sync::Arc = - std::sync::Arc::new(block_on(SqliteClipboardStore::open(tmp.path().join("clip.db"))).expect("open")); - let text = "about to be protected"; - let id = { - use rustickers::clipboard::storage::UpsertOutcome; - match block_on(store.upsert_ordinary( - text, - text, - &SqliteClipboardStore::ordinary_digest(text), - text.len() as i64, - 10, - None, - )) - .unwrap() - { - UpsertOutcome::Inserted { id } => id, - other => panic!("{other:?}"), - } - }; - let controller = VaultController::new(store.clone()); - block_on(controller.create("password")).unwrap(); - assert!(block_on(controller.unlock("password")).unwrap()); - let sealed = controller.seal_for_storage(text.as_bytes()).unwrap().unwrap(); - let token = controller.match_token(text.as_bytes()).unwrap().unwrap(); - block_on(store.compare_exchange_protection( - id, - text, - &sealed.ciphertext, - &sealed.vault_envelope, - Some(&token), - None, - )) - .unwrap(); - - // The token survived protecting, so an authorized recapture of the same text promotes the - // protected row instead of writing plaintext back. - let outcome = block_on(store.upsert_ordinary( - text, - text, - &SqliteClipboardStore::ordinary_digest(text), - text.len() as i64, - 20, - Some(&token), - )) - .unwrap(); - assert!(matches!(outcome, rustickers::clipboard::storage::UpsertOutcome::Updated { id: promoted } if promoted == id)); - assert_eq!(block_on(store.get(id)).unwrap().content, sealed.ciphertext); +fn failed_import_rolls_back_rows_and_marker_then_can_retry() -> anyhow::Result<()> { + block_on(async { + let dir = tempfile::tempdir()?; + let path = dir.path().join("stickers.db"); + let repo = TextRepository::open(&path).await?; + let db = pool(&path).await?; + let old_path = dir.path().join("clipboard.db"); + let old = legacy(&old_path).await?; + raw(&old, "first", 1, 1, None).await?; + raw(&old, "reject", 2, 2, None).await?; + old.close().await; + sqlx::raw_sql("CREATE TRIGGER fail_import BEFORE INSERT ON clipboard_captures WHEN NEW.text='reject' BEGIN SELECT RAISE(ABORT,'injected import failure'); END;") + .execute(&db).await?; + assert!(repo.import_legacy(&old_path).await.is_err()); + assert!( + repo.snapshot(TextSources::new(false, true), "") + .await? + .is_empty() + ); + let markers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM text_imports") + .fetch_one(&db) + .await?; + assert_eq!(markers, 0, "failed import must remain retryable"); + sqlx::query("DROP TRIGGER fail_import").execute(&db).await?; + repo.import_legacy(&old_path).await?; + assert_eq!( + repo.snapshot(TextSources::new(false, true), "") + .await? + .len(), + 2 + ); + Ok(()) + }) } diff --git a/tests/text_cli.rs b/tests/text_cli.rs new file mode 100644 index 0000000..760803f --- /dev/null +++ b/tests/text_cli.rs @@ -0,0 +1,460 @@ +//! Public CLI regression coverage; every runtime call uses an isolated database. +#![cfg(feature = "cli")] + +use clap::Parser; +use futures::executor::block_on; +use rustickers::{ + cli::{ + self, Cli, + commands::{text_argv, text_execution::ExitCode}, + }, + model::sticker::StickerState, + storage::{StickerStore, paths::AppPaths, sqlite::SqliteStore}, + text::{TextEntryId, TextRepository}, +}; + +fn strings(items: &[&str]) -> Vec { + items.iter().map(|s| (*s).to_owned()).collect() +} + +struct Fixture { + _dir: tempfile::TempDir, + paths: AppPaths, + repo: TextRepository, +} +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let paths = AppPaths { + db_path: dir.path().join("stickers.db"), + }; + let repo = block_on(TextRepository::open(&paths.db_path)).unwrap(); + Self { + _dir: dir, + paths, + repo, + } + } + fn capture(&self, text: &str) -> TextEntryId { + TextEntryId::Clipboard(block_on(self.repo.capture(text, 100)).unwrap()) + } + fn sticker(&self, text: &str, state: StickerState) -> TextEntryId { + let capture = block_on(self.repo.get(self.capture(text))).unwrap(); + let id = block_on(self.repo.promote(&capture, false)).unwrap(); + let store = block_on(SqliteStore::open(&self.paths.db_path)).unwrap(); + block_on(store.update_sticker_state(id, state)).unwrap(); + TextEntryId::Sticker(id) + } + fn run(&self, args: &[&str]) -> anyhow::Result<()> { + cli::run(Cli::try_parse_from(args).unwrap(), &self.paths) + } + fn execute( + &self, + selectors: &[&str], + id: TextEntryId, + command: &[String], + ) -> anyhow::Result<()> { + let mut args = strings(&["rst", "ls"]); + args.extend(strings(selectors)); + args.extend(["--id".into(), id.to_string(), "--".into()]); + args.extend_from_slice(command); + cli::run(Cli::try_parse_from(args).unwrap(), &self.paths) + } +} + +// Explicit interpreters are test helpers only: the production CLI must not invent a shell. +fn child(code: i32, dir: &std::path::Path) -> Vec { + #[cfg(windows)] + { + let script = dir.join(format!("exit-{code}.ps1")); + std::fs::write(&script, format!("param([string]$Text)\nexit {code}\n")).unwrap(); + strings(&[ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-File", + script.to_str().unwrap(), + "{{txt}}", + ]) + } + #[cfg(unix)] + { + let _ = dir; + strings(&["/bin/sh", "-c", &format!("exit {code}"), "{{txt}}"]) + } +} + +#[test] +fn merged_grammar_aliases_and_removed_families() { + for name in ["ls", "list"] { + for tail in [ + vec![], + vec!["--state", "all"], + vec!["--text"], + vec!["--cp"], + vec!["--text", "--cp", "-s", "rust", "-n", "20"], + vec!["--text", "--cp", "--json"], + vec!["--text", "--cp", "--", "some_cmd", "xxx", "{{txt}}", "xxx"], + vec!["--text", "--id", "sticker:42", "--", "my-tool", "{{txt}}"], + vec!["--cp", "--id", "clipboard:42", "--", "my-tool"], + ] { + let mut args = vec!["rst", name]; + args.extend(tail); + assert!(Cli::try_parse_from(&args).is_ok(), "{args:?}"); + } + } + let names = cli::subcommand_names(); + assert!(names.iter().any(|s| s == "ls")); + assert!(names.iter().any(|s| s == "list")); + for removed in ["cp", "vault"] { + assert!(!names.iter().any(|s| s == removed)); + assert!(Cli::try_parse_from(["rst", removed]).is_err()); + } +} + +#[test] +fn id_limit_and_search_conflicts_are_parser_errors() { + for tail in [ + vec!["--id", "sticker:1"], + vec!["--id", "sticker:1", "-s", "needle", "--", "tool"], + vec!["--id", "sticker:1", "-n", "1", "--", "tool"], + vec!["-n", "1", "--", "tool"], + ] { + let mut args = vec!["rst", "ls", "--text"]; + args.extend(tail); + assert!(Cli::try_parse_from(&args).is_err(), "{args:?}"); + } + for id in [ + "42", + "sticker:0", + "clipboard:-1", + "unknown:1", + "sticker:nope", + ] { + assert!( + Cli::try_parse_from(["rst", "ls", "--text", "--id", id, "--", "tool"]).is_err(), + "{id}" + ); + } + assert!(Cli::try_parse_from(["rst", "ls", "--text", "-s", "needle", "--", "tool"]).is_ok()); +} + +#[test] +fn empty_child_separator_is_ordinary_listing() { + // An optional delimiter without a child preserves standard Clap listing semantics. + assert!(Cli::try_parse_from(["rst", "ls", "--text", "--"]).is_ok()); +} + +#[test] +fn parent_json_is_rejected_but_child_json_is_not_global() { + let f = Fixture::new(); + for args in [ + vec![ + "rst", + "--json", + "ls", + "--text", + "--id", + "sticker:1", + "--", + "tool", + ], + vec![ + "rst", + "ls", + "--text", + "--json", + "--id", + "sticker:1", + "--", + "tool", + ], + ] { + let cli = Cli::try_parse_from(args).unwrap(); + assert!(cli.format().is_json()); + assert!( + cli::run(cli, &f.paths) + .unwrap_err() + .to_string() + .contains("--json") + ); + } + let cli = Cli::try_parse_from([ + "rst", + "ls", + "--cp", + "--id", + "clipboard:1", + "--", + "tool", + "--json", + "{{txt}}", + ]) + .unwrap(); + assert!(!cli.format().is_json()); + // A missing ID proves execution got past the parent JSON guard, without launching a child. + assert!( + cli::run(cli, &f.paths) + .unwrap_err() + .to_string() + .contains("not available") + ); +} + +#[test] +fn runtime_rejects_execution_without_sources_and_nontext_types() { + let f = Fixture::new(); + assert!( + f.run(&["rst", "ls", "--", "tool"]) + .unwrap_err() + .to_string() + .contains("requires --text") + ); + for kind in ["command", "file", "timer", "paint"] { + let error = f + .run(&["rst", "ls", "--text", "--type", kind, "--", "tool"]) + .unwrap_err(); + assert!( + error.to_string().contains("only supports Markdown"), + "{error}" + ); + } + // Noninteractive listings and the legacy alias always operate on this fixture, never AppData. + for args in [ + vec!["rst", "ls"], + vec!["rst", "list", "--state", "all"], + vec![ + "rst", "ls", "--text", "--cp", "-s", "rust", "-n", "1", "--json", + ], + vec!["rst", "ls", "--text", "--type", "markdown"], + ] { + f.run(&args).unwrap(); + } +} + +#[test] +fn runtime_honors_source_and_state_filters_for_direct_ids() { + let f = Fixture::new(); + let closed = f.sticker("closed text", StickerState::Close); + let open = f.sticker("open text", StickerState::Open); + let capture = f.capture("clipboard text"); + let command = child(0, f._dir.path()); + for (selectors, id) in [ + (vec!["--text"], closed), // omitted state includes closed stickers + (vec!["--text", "--state", "open"], open), + (vec!["--text", "--state", "close"], closed), + (vec!["--text", "--state", "all"], closed), + (vec!["--cp", "--state", "open"], capture), + (vec!["--cp", "--state", "close"], capture), + (vec!["--text", "--cp"], capture), + ] { + f.execute(&selectors, id, &command).unwrap(); + } + for (selectors, id, expected) in [ + (vec!["--cp"], closed, "outside selected text sources"), + (vec!["--text"], capture, "outside selected text sources"), + (vec!["--text", "--state", "open"], closed, "not available"), + (vec!["--text", "--state", "close"], open, "not available"), + ] { + let error = f.execute(&selectors, id, &command).unwrap_err(); + assert!(error.to_string().contains(expected), "{error}"); + } +} + +#[test] +fn exact_argv_replacement_and_append_do_not_retokenize_or_recurse() { + let text = " Unicode 雪\r\n\"quotes\" 'single' ; & | $(no) {{txt}} "; + let template = strings(&[ + "fixed-program", + "--json", + "prefix={{txt}}/{{txt}}", + "{{txt}}", + "", + ]); + assert_eq!( + text_argv::build(&template, text).unwrap(), + vec![ + "fixed-program".to_owned(), + "--json".to_owned(), + format!("prefix={text}/{text}"), + text.to_owned(), + String::new(), + ] + ); + assert_eq!( + text_argv::build(&strings(&["fixed-program", "--flag", "two words"]), text).unwrap(), + vec![ + "fixed-program".to_owned(), + "--flag".to_owned(), + "two words".to_owned(), + text.to_owned(), + ] + ); + assert_eq!( + text_argv::build(&strings(&["program"]), "").unwrap(), + strings(&["program", ""]) + ); +} + +#[test] +fn nul_and_invalid_programs_fail_before_selection() { + for template in [ + vec![], + strings(&[""]), + strings(&["{{txt}}"]), + strings(&["prefix{{txt}}"]), + strings(&["bad\0program"]), + strings(&["program", "bad\0argument"]), + ] { + assert!( + text_argv::validate_template(&template).is_err(), + "{template:?}" + ); + assert!(text_argv::build(&template, "safe").is_err()); + } + assert!( + text_argv::build(&strings(&["program", "{{txt}}"]), "secret\0text") + .unwrap_err() + .to_string() + .contains("NUL") + ); + assert!(text_argv::build(&strings(&["program"]), "secret\0text").is_err()); + let f = Fixture::new(); + let error = f + .run(&["rst", "ls", "--text", "--", "{{txt}}"]) + .unwrap_err(); + assert!(error.to_string().contains("program name")); +} + +#[test] +fn child_receives_exact_text_as_one_argument_without_shell_expansion() { + let f = Fixture::new(); + let exact = " Unicode 雪\r\n\"quotes\" 'single' ; & | $(no) {{txt}} "; + let id = f.capture(exact); + let output = f._dir.path().join("received.txt"); + #[cfg(windows)] + let command = { + let script = f._dir.path().join("receive.ps1"); + std::fs::write(&script, "param([string]$OutputPath,[string]$Text)\n[System.IO.File]::WriteAllText($OutputPath,$Text,(New-Object System.Text.UTF8Encoding($false)))\n").unwrap(); + strings(&[ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-File", + script.to_str().unwrap(), + output.to_str().unwrap(), + "prefix={{txt}}/{{txt}}", + ]) + }; + #[cfg(unix)] + let command = strings(&[ + "/bin/sh", + "-c", + "printf '%s' \"$2\" > \"$1\"", + "receive", + output.to_str().unwrap(), + "prefix={{txt}}/{{txt}}", + ]); + f.execute(&["--cp"], id, &command).unwrap(); + assert_eq!( + std::fs::read_to_string(&output).unwrap(), + format!("prefix={exact}/{exact}") + ); + assert!(block_on(f.repo.get(id)).unwrap().activity_at() > 100); + let mut appended = command; + *appended.last_mut().unwrap() = "fixed argument".to_owned(); + // With no placeholder the CLI appends exactly one argument after the fixed argument. + #[cfg(windows)] + { + let script = f._dir.path().join("append.ps1"); + std::fs::write(&script, "param([string]$OutputPath,[string]$Fixed,[string]$Text)\nif ($Fixed -ne 'fixed argument') { exit 71 }\n[System.IO.File]::WriteAllText($OutputPath,$Text,(New-Object System.Text.UTF8Encoding($false)))\n").unwrap(); + appended[5] = script.to_str().unwrap().to_owned(); + } + #[cfg(unix)] + { + appended[2] = + "test \"$2\" = 'fixed argument' || exit 71; printf '%s' \"$3\" > \"$1\"".to_owned(); + } + f.execute(&["--cp"], id, &appended).unwrap(); + assert_eq!(std::fs::read_to_string(output).unwrap(), exact); +} + +#[test] +fn unsupported_envelopes_cannot_be_selected_or_spawn_a_child() { + let f = Fixture::new(); + let pool = block_on(sqlx::SqlitePool::connect_with( + sqlx::sqlite::SqliteConnectOptions::new().filename(&f.paths.db_path), + )) + .unwrap(); + let marker = f._dir.path().join("received.txt"); + #[cfg(windows)] + let command = { + let script = f._dir.path().join("must-not-spawn.ps1"); + std::fs::write(&script, "param([string]$OutputPath,[string]$Text)\n[System.IO.File]::WriteAllText($OutputPath,'spawned')\n").unwrap(); + strings(&[ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-File", + script.to_str().unwrap(), + marker.to_str().unwrap(), + "{{txt}}", + ]) + }; + #[cfg(unix)] + let command = strings(&[ + "/bin/sh", + "-c", + "printf spawned > \"$1\"", + "marker", + marker.to_str().unwrap(), + "{{txt}}", + ]); + for content in [ + "RUSTICKERS_LOCKED_V1\nprivate legacy marker", + "RUSTICKERS_VAULT_V1\nprivate legacy marker", + "RUSTICKERS_TEXT_V2\nprivate legacy marker", + ] { + let id: i64 = block_on(sqlx::query_scalar("INSERT INTO stickers(title,state,left,top,width,height,color,type,content,created_at,updated_at) VALUES('old title','close',0,0,400,300,'yellow','markdown',?,1,1) RETURNING id") + .bind(content).fetch_one(&pool)).unwrap(); + let error = f + .execute(&["--text"], TextEntryId::Sticker(id), &command) + .unwrap_err(); + assert!(error.to_string().contains("not available"), "{error}"); + assert!(!format!("{error:#}").contains("private legacy marker")); + assert!(!marker.exists()); + let last_used: Option = block_on( + sqlx::query_scalar("SELECT text_last_used_at FROM stickers WHERE id=?") + .bind(id) + .fetch_one(&pool), + ) + .unwrap(); + assert_eq!(last_used, None); + } +} + +#[test] +fn child_success_nonzero_and_missing_executable_propagate_exit_codes() { + let f = Fixture::new(); + let id = f.capture("exact argument with spaces\nquotes: \" ' ; &"); + f.execute(&["--cp"], id, &child(0, f._dir.path())).unwrap(); + let nonzero_id = f.capture("nonzero child still counts as successful delivery"); + let error = f + .execute(&["--cp"], nonzero_id, &child(23, f._dir.path())) + .unwrap_err(); + assert_eq!(error.downcast_ref::().unwrap().0, 23); + assert!( + block_on(f.repo.get(nonzero_id)).unwrap().activity_at() > 100, + "successful spawn advances MRU even when the child exits nonzero" + ); + let before = block_on(f.repo.get(id)).unwrap().activity_at(); + let missing = f._dir.path().join("definitely-missing-executable"); + let error = f + .execute(&["--cp"], id, &[missing.to_str().unwrap().to_owned()]) + .unwrap_err(); + assert_eq!(error.downcast_ref::().unwrap().0, 127); + assert_eq!(block_on(f.repo.get(id)).unwrap().activity_at(), before); +} diff --git a/tests/text_protection.rs b/tests/text_protection.rs new file mode 100644 index 0000000..66b89db --- /dev/null +++ b/tests/text_protection.rs @@ -0,0 +1,524 @@ +#![cfg(feature = "storage")] + +use futures::executor::block_on; +use rustickers::text::{ + TextEntry, TextEntryId, TextRepository, TextSources, + protection::{self, CONTENT_PREFIX, MasterKey}, +}; +use sqlx::{ + SqlitePool, + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, +}; +use tempfile::TempDir; + +async fn fixture() -> anyhow::Result<(TempDir, TextRepository, SqlitePool)> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("stickers.db"); + let repo = TextRepository::open(&path).await?; + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(SqliteConnectOptions::new().filename(&path)) + .await?; + Ok((dir, repo, pool)) +} +async fn promote(repo: &TextRepository, text: &str, encrypted: bool) -> anyhow::Result { + let id = repo.capture(text, 1).await?; + let source = repo.get(TextEntryId::Clipboard(id)).await?; + let saved = repo.promote(&source, encrypted).await?; + repo.get(TextEntryId::Sticker(saved)).await +} +async fn content(pool: &SqlitePool, id: TextEntryId) -> anyhow::Result { + let TextEntryId::Sticker(id) = id else { + panic!("expected sticker") + }; + Ok( + sqlx::query_scalar("SELECT content FROM stickers WHERE id=?") + .bind(id) + .fetch_one(pool) + .await?, + ) +} +async fn persisted(pool: &SqlitePool) -> anyhow::Result { + // Capture all columns, not just ciphertext: rollback must restore revisions and metadata too. + let rows: Vec = sqlx::query_scalar("SELECT json_object('id',id,'title',title,'state',state,'left',left,'top',top,'width',width,'height',height,'color',color,'content',content,'created',created_at,'updated',updated_at,'modified',text_modified_at,'used',text_last_used_at,'revision',text_revision) FROM stickers ORDER BY id") + .fetch_all(pool).await?; + let key: String = + sqlx::query_scalar("SELECT configuration FROM text_master_key WHERE singleton=1") + .fetch_one(pool) + .await?; + Ok(serde_json::json!({"rows": rows, "key": key})) +} + +#[test] +fn public_only_seal_exact_open_and_fail_closed_formats() -> anyhow::Result<()> { + let key = MasterKey::create("correct password")?; + let text = " \t秘密\r\n'quotes' \"double\";\n "; + let sealed = key.seal(7, text)?; + assert!(protection::is_encrypted(&sealed)); + assert!(!sealed.contains(text)); + assert_eq!(&*key.open(7, &sealed, "correct password")?, text); + assert!(key.open(7, &sealed, "wrong password").is_err()); + assert!(key.open(8, &sealed, "correct password").is_err()); + assert!( + key.open( + 7, + &format!("{CONTENT_PREFIX}{}\ninvalid!", key.key_id()), + "correct password" + ) + .is_err() + ); + assert!( + key.open( + 7, + &sealed.replace(key.key_id(), "different-generation"), + "correct password" + ) + .is_err() + ); + assert!( + key.open(7, "RUSTICKERS_TEXT_V2\nanything", "correct password") + .is_err() + ); + assert!(MasterKey::create("").is_err()); + assert!(MasterKey::parse("").is_err()); + let mut configuration: serde_json::Value = serde_json::from_str(&key.serialize()?)?; + // Unusable wrapped private identity proves sealing does not authorize or decrypt it. + configuration["wrapped_identity"] = serde_json::json!(""); + let public_only = MasterKey::parse(&configuration.to_string())?; + let public_sealed = public_only.seal(7, text)?; + assert_eq!(&*key.open(7, &public_sealed, "correct password")?, text); + assert!(public_only.verify_password("correct password").is_err()); + for field in ["version", "memory_kib", "iterations", "lanes"] { + let mut invalid = configuration.clone(); + invalid[field] = serde_json::json!(0); + assert!(MasterKey::parse(&invalid.to_string()).is_err(), "{field}"); + } + for value in [ + "RUSTICKERS_TEXT_V2\nx", + " RUSTICKERS_LOCKED_V1\nx", + "RUSTICKERS_VAULT_V1\nx", + ] { + assert!(protection::is_unsupported(value)); + } + Ok(()) +} + +#[test] +fn encrypted_promotion_lock_save_redaction_and_explicit_remove_encryption() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + repo.set_master_password("password").await?; + assert!(repo.set_master_password("replacement").await.is_err()); + let secret = "secret 大文字\r\n'keep' "; + let independent = promote(&repo, secret, false).await?; + let encrypted = promote(&repo, secret, true).await?; + assert!(encrypted.is_encrypted()); + assert_eq!(encrypted.title(), "Locked text"); + assert_eq!(encrypted.preview(), "Locked text"); + assert!( + repo.snapshot(TextSources::new(false, true), "") + .await? + .is_empty() + ); + assert!(!repo.get(independent.id()).await?.is_encrypted()); + let before = content(&pool, encrypted.id()).await?; + assert!(before.starts_with(CONTENT_PREFIX)); + assert!(!before.contains(secret)); + let json = serde_json::to_string(&encrypted)?; + assert!(!json.contains(secret)); + assert!(!json.contains(CONTENT_PREFIX)); + assert!(!format!("{encrypted:?}").contains(secret)); + assert!(!encrypted.matches("secret")); + assert!(encrypted.matches("locked")); + assert!(repo.resolve(&encrypted, None).await.is_err()); + assert!(repo.resolve(&encrypted, Some("wrong")).await.is_err()); + assert_eq!(&*repo.resolve(&encrypted, Some("password")).await?, secret); + assert_eq!(content(&pool, encrypted.id()).await?, before); // Temporary unlock is read-only. + let replacement = "edited secret\r\n新 "; + repo.save_text(&encrypted, replacement).await?; // No private key/password needed. + assert!(repo.save_text(&encrypted, "stale").await.is_err()); + let edited = repo.get(encrypted.id()).await?; + assert!(edited.is_encrypted()); + assert!(!content(&pool, edited.id()).await?.contains(replacement)); + assert_eq!(edited.title(), "Locked text"); + assert_eq!( + &*repo.resolve(&edited, Some("password")).await?, + replacement + ); + assert!( + repo.set_encryption(&edited, false, Some("wrong")) + .await + .is_err() + ); + repo.set_encryption(&edited, false, Some("password")) + .await?; + assert_eq!(content(&pool, edited.id()).await?, replacement); + let stale_plain = repo.get(independent.id()).await?; + repo.capture(secret, 50).await?; + repo.set_encryption(&stale_plain, true, None).await?; + assert!( + repo.save_text(&stale_plain, "plaintext resurrection") + .await + .is_err() + ); + assert!( + repo.snapshot(TextSources::new(false, true), "") + .await? + .is_empty() + ); + assert_eq!( + repo.get(independent.id()).await?.activity_at(), + stale_plain.activity_at() + ); + Ok(()) + }) +} + +#[test] +fn row_swaps_are_rejected_and_corrupt_configuration_never_promotes_plaintext() -> anyhow::Result<()> +{ + block_on(async { + let (_dir, repo, pool) = fixture().await?; + repo.set_master_password("password").await?; + let a = promote(&repo, "first secret", true).await?; + let b = promote(&repo, "second secret", true).await?; + let ca = content(&pool, a.id()).await?; + let cb = content(&pool, b.id()).await?; + let TextEntryId::Sticker(aid) = a.id() else { + unreachable!() + }; + let TextEntryId::Sticker(bid) = b.id() else { + unreachable!() + }; + sqlx::query( + "UPDATE stickers SET content=CASE id WHEN ? THEN ? WHEN ? THEN ? END WHERE id IN (?,?)", + ) + .bind(aid) + .bind(cb) + .bind(bid) + .bind(ca) + .bind(aid) + .bind(bid) + .execute(&pool) + .await?; + for id in [a.id(), b.id()] { + let swapped = repo.get(id).await?; + assert!(repo.resolve(&swapped, Some("password")).await.is_err()); + } + sqlx::query("UPDATE text_master_key SET configuration='{}'") + .execute(&pool) + .await?; + let capture = repo.capture("must stay capture", 10).await?; + let source = repo.get(TextEntryId::Clipboard(capture)).await?; + assert!(repo.promote(&source, true).await.is_err()); + assert!(repo.get(source.id()).await.is_ok()); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stickers") + .fetch_one(&pool) + .await?; + assert_eq!(count, 2); + Ok(()) + }) +} + +#[test] +fn complete_rotation_rewrites_all_payloads_preserves_metadata_and_rejects_stale_save() +-> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + repo.set_master_password("old password").await?; + let a = promote(&repo, "first\r\n秘密 ", true).await?; + let b = promote(&repo, "second\t'quoted'", true).await?; + let plain = promote(&repo, "ordinary", false).await?; + let before = persisted(&pool).await?; + let old_key = repo.master_key().await?.unwrap(); + let old_contents = [content(&pool, a.id()).await?, content(&pool, b.id()).await?]; + repo.change_master_password("old password", "new password") + .await?; + let new_key = repo.master_key().await?.unwrap(); + assert_ne!(old_key.key_id(), new_key.key_id()); + let after = persisted(&pool).await?; + for (index, stale) in [a, b].iter().enumerate() { + let current = repo.get(stale.id()).await?; + let ciphertext = content(&pool, current.id()).await?; + assert_ne!(ciphertext, old_contents[index]); + assert!(ciphertext.contains(new_key.key_id())); + assert_eq!(current.revision(), stale.revision() + 1); + assert_eq!(current.activity_at(), stale.activity_at()); + assert!(repo.resolve(¤t, Some("old password")).await.is_err()); + let exact = if index == 0 { + "first\r\n秘密 " + } else { + "second\t'quoted'" + }; + assert_eq!(&*repo.resolve(¤t, Some("new password")).await?, exact); + assert!(repo.save_text(stale, "stale draft").await.is_err()); + let TextEntryId::Sticker(id) = stale.id() else { + unreachable!() + }; + assert!(old_key.open(id, &ciphertext, "old password").is_err()); + let mut old: serde_json::Value = + serde_json::from_str(before["rows"][index].as_str().unwrap())?; + let mut new: serde_json::Value = + serde_json::from_str(after["rows"][index].as_str().unwrap())?; + for row in [&mut old, &mut new] { + row.as_object_mut().unwrap().remove("content"); + row.as_object_mut().unwrap().remove("revision"); + } + assert_eq!(old, new); + } + assert_eq!(repo.get(plain.id()).await?.revision(), plain.revision()); + assert_eq!( + &*repo.resolve(&repo.get(plain.id()).await?, None).await?, + "ordinary" + ); + assert_eq!(before["rows"][2], after["rows"][2]); + Ok(()) + }) +} + +#[test] +fn unsupported_stickers_are_unavailable_and_damaged_current_entries_are_redacted() +-> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + repo.set_master_password("password").await?; + for envelope in [ + "RUSTICKERS_LOCKED_V1\nlegacy-secret", + " RUSTICKERS_VAULT_V1\nlegacy-secret", + "RUSTICKERS_TEXT_V2\nlegacy-secret", + ] { + let id: i64 = sqlx::query_scalar("INSERT INTO stickers(title,state,left,top,width,height,color,type,content,created_at,updated_at) VALUES('legacy title','close',0,0,400,300,'yellow','markdown',?,1,1) RETURNING id") + .bind(envelope).fetch_one(&pool).await?; + assert!(repo.get(TextEntryId::Sticker(id)).await.is_err()); + } + let damaged = format!( + "{CONTENT_PREFIX}{}\nprivate-payload-marker!", + repo.master_key().await?.unwrap().key_id() + ); + let id: i64 = sqlx::query_scalar("INSERT INTO stickers(title,state,left,top,width,height,color,type,content,created_at,updated_at) VALUES('Public alias','close',0,0,400,300,'yellow','markdown',?,1,1) RETURNING id") + .bind(&damaged).fetch_one(&pool).await?; + let rows = repo.snapshot(TextSources::new(true, true), "").await?; + assert_eq!(rows.len(), 1); + let entry = &rows[0]; + assert_eq!(entry.id(), TextEntryId::Sticker(id)); + assert!(entry.is_encrypted()); + assert_eq!(entry.preview(), "Public alias"); + assert!(entry.matches("public alias")); + assert!(entry.matches(&entry.id().to_string())); + assert!(!entry.matches("private-payload-marker")); + let json = serde_json::to_string(entry)?; + let debug = format!("{entry:?}"); + for output in [json, debug] { + assert!(!output.contains(CONTENT_PREFIX)); + assert!(!output.contains("private-payload-marker")); + assert!(!output.contains("legacy-secret")); + } + let error = repo.resolve(entry, Some("password")).await.unwrap_err(); + assert!(!format!("{error:#}").contains("private-payload-marker")); + assert_eq!(content(&pool, entry.id()).await?, damaged); + Ok(()) + }) +} + +#[test] +fn rotation_final_write_failure_restores_every_row_and_key() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + repo.set_master_password("old password").await?; + let first = promote(&repo, "first intact secret", true).await?; + let second = promote(&repo, "second intact secret", true).await?; + let before = persisted(&pool).await?; + sqlx::raw_sql("CREATE TRIGGER reject_new_key BEFORE UPDATE ON text_master_key BEGIN SELECT RAISE(ABORT,'injected key write failure'); END;") + .execute(&pool).await?; + assert!( + repo.change_master_password("old password", "new password") + .await + .is_err() + ); + assert_eq!( + persisted(&pool).await?, + before, + "failure after ciphertext rewrites must roll back the whole transaction" + ); + for (entry, exact) in [ + (&first, "first intact secret"), + (&second, "second intact secret"), + ] { + assert_eq!(&*repo.resolve(entry, Some("old password")).await?, exact); + } + Ok(()) + }) +} + +#[test] +fn concurrent_rotations_have_one_winner_and_stale_cross_pool_editor_cannot_write() +-> anyhow::Result<()> { + block_on(async { + let (dir, repo, pool) = fixture().await?; + repo.set_master_password("old password").await?; + let stale = promote(&repo, "rotation race secret", true).await?; + let other = TextRepository::open(dir.path().join("stickers.db")).await?; + let remote_stale = other.get(stale.id()).await?; + let gate = std::sync::Arc::new(std::sync::Barrier::new(2)); + let worker_gate = gate.clone(); + let worker_repo = other.clone(); + let worker = std::thread::spawn(move || { + worker_gate.wait(); + block_on(worker_repo.change_master_password("old password", "remote password")) + }); + gate.wait(); + let local = repo + .change_master_password("old password", "local password") + .await; + let remote = worker.join().expect("rotation worker panicked"); + assert_eq!( + usize::from(local.is_ok()) + usize::from(remote.is_ok()), + 1, + "concurrent old-password rotations must not overwrite each other's generation" + ); + let winner = if local.is_ok() { + "local password" + } else { + "remote password" + }; + let loser = if local.is_ok() { + "remote password" + } else { + "local password" + }; + let before_stale = persisted(&pool).await?; + assert!( + other + .save_text(&remote_stale, "stale plaintext resurrection") + .await + .is_err() + ); + assert!( + other + .set_encryption(&remote_stale, false, Some("old password")) + .await + .is_err() + ); + assert!( + other + .resolve(&remote_stale, Some("old password")) + .await + .is_err() + ); + assert_eq!(persisted(&pool).await?, before_stale); + let current = repo.get(stale.id()).await?; + assert_eq!(current.revision(), stale.revision() + 1); + assert_eq!( + &*repo.resolve(¤t, Some(winner)).await?, + "rotation race secret" + ); + assert!(repo.resolve(¤t, Some(loser)).await.is_err()); + assert!(repo.resolve(¤t, Some("old password")).await.is_err()); + // Collection remains ordinary and independent of encryption/private-key authorization. + let capture = other.capture("external later secret copy", 100).await?; + let entry = other.get(TextEntryId::Clipboard(capture)).await?; + assert_eq!( + &*other.resolve(&entry, None).await?, + "external later secret copy" + ); + Ok(()) + }) +} + +#[test] +fn encrypted_promotion_racing_rotation_never_commits_old_generation_payload() -> anyhow::Result<()> +{ + block_on(async { + let (dir, repo, pool) = fixture().await?; + repo.set_master_password("old password").await?; + let existing = promote(&repo, "existing secret", true).await?; + let source_id = repo.capture("concurrently promoted secret", 10).await?; + let source = repo.get(TextEntryId::Clipboard(source_id)).await?; + let other = TextRepository::open(dir.path().join("stickers.db")).await?; + let gate = std::sync::Arc::new(std::sync::Barrier::new(2)); + let worker_gate = gate.clone(); + let worker = std::thread::spawn(move || { + worker_gate.wait(); + block_on(other.promote(&source, true)) + }); + gate.wait(); + repo.change_master_password("old password", "new password") + .await?; + let saved_id = worker.join().expect("promotion worker panicked")?; + let key = repo.master_key().await?.unwrap(); + assert!(repo.get(TextEntryId::Clipboard(source_id)).await.is_err()); + for (id, exact) in [ + (existing.id(), "existing secret"), + ( + TextEntryId::Sticker(saved_id), + "concurrently promoted secret", + ), + ] { + let current = repo.get(id).await?; + assert!(current.is_encrypted()); + assert!(content(&pool, id).await?.contains(key.key_id())); + assert_eq!(&*repo.resolve(¤t, Some("new password")).await?, exact); + assert!(repo.resolve(¤t, Some("old password")).await.is_err()); + } + assert_eq!( + repo.snapshot(TextSources::new(true, false), "") + .await? + .len(), + 2 + ); + Ok(()) + }) +} + +#[test] +fn damaged_second_item_and_wrong_password_roll_back_entire_rotation() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + repo.set_master_password("old password").await?; + let first = promote(&repo, "first intact secret", true).await?; + let second = promote(&repo, "second secret", true).await?; + let before = persisted(&pool).await?; + assert!( + repo.change_master_password("wrong", "new password") + .await + .is_err() + ); + assert_eq!(persisted(&pool).await?, before); + let TextEntryId::Sticker(id) = second.id() else { + unreachable!() + }; + let damaged = format!( + "{CONTENT_PREFIX}{}\nnot-valid-base64!", + repo.master_key().await?.unwrap().key_id() + ); + sqlx::query("UPDATE stickers SET content=? WHERE id=?") + .bind(damaged) + .bind(id) + .execute(&pool) + .await?; + let before_failure = persisted(&pool).await?; + assert!( + repo.change_master_password("old password", "new password") + .await + .is_err() + ); + assert_eq!(persisted(&pool).await?, before_failure); + assert_eq!( + &*repo.resolve(&first, Some("old password")).await?, + "first intact secret" + ); + assert!( + repo.master_key() + .await? + .unwrap() + .verify_password("new password") + .is_err() + ); + assert!( + repo.resolve(&repo.get(second.id()).await?, Some("old password")) + .await + .is_err() + ); + Ok(()) + }) +} diff --git a/tests/text_repository.rs b/tests/text_repository.rs new file mode 100644 index 0000000..5ee8002 --- /dev/null +++ b/tests/text_repository.rs @@ -0,0 +1,295 @@ +#![cfg(feature = "storage")] + +use futures::executor::block_on; +use rustickers::{ + model::sticker::StickerState, + text::{TextEntryId, TextRepository, TextSources, filter_indices}, +}; +use sqlx::{ + SqlitePool, + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, +}; +use tempfile::TempDir; + +async fn fixture() -> anyhow::Result<(TempDir, TextRepository, SqlitePool)> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("stickers.db"); + let repo = TextRepository::open(&path).await?; + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(SqliteConnectOptions::new().filename(&path)) + .await?; + Ok((dir, repo, pool)) +} + +async fn sticker(pool: &SqlitePool, title: &str, text: &str, time: i64) -> anyhow::Result { + Ok(sqlx::query_scalar("INSERT INTO stickers(title,state,left,top,width,height,color,type,content,created_at,updated_at,text_modified_at) VALUES(?,'close',100,100,400,300,'yellow','markdown',?,?,?,?) RETURNING id") + .bind(title).bind(text).bind(time).bind(time).bind(time).fetch_one(pool).await?) +} + +#[test] +fn exact_capture_dedupe_and_validation() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + let text = " \tRust 大文字\r\n'quoted' \"x\"; !\n "; + let id = repo.capture(text, 10).await?; + assert_eq!(repo.capture(text, 20).await?, id); + assert_eq!(repo.capture(text, 15).await?, id); + let entry = repo.get(TextEntryId::Clipboard(id)).await?; + assert_eq!(&*repo.resolve(&entry, None).await?, text); + assert_eq!(entry.activity_at(), 20); + let row: (i64, i64, i64) = sqlx::query_as( + "SELECT created_at,last_copied_at,byte_size FROM clipboard_captures WHERE id=?", + ) + .bind(id) + .fetch_one(&pool) + .await?; + assert_eq!(row, (10, 20, text.len() as i64)); + assert_ne!(repo.capture(&text.to_lowercase(), 21).await?, id); + // Even a colliding digest is only an accelerator, never an identity. + sqlx::query("UPDATE clipboard_captures SET text='different bytes' WHERE id=?") + .bind(id) + .execute(&pool) + .await?; + assert_ne!(repo.capture(text, 22).await?, id); + for invalid in ["", " \r\n\t", "a\0b"] { + assert!(repo.capture(invalid, 30).await.is_err()); + } + assert!( + repo.capture(&"x".repeat(1024 * 1024 + 1), 30) + .await + .is_err() + ); + Ok(()) + }) +} + +#[test] +fn lowered_count_and_byte_limits_prune_only_captures() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + let saved = sticker(&pool, "saved", "never evict", 1).await?; + let a = repo.capture("aa", 10).await?; + let b = repo.capture("éé", 20).await?; + let c = repo.capture("cc", 30).await?; + let mut settings = repo.settings().await?; + settings.normal_count_limit = 2; + repo.update_settings(&settings).await?; + assert!(repo.get(TextEntryId::Clipboard(a)).await.is_err()); + assert_eq!( + repo.snapshot(TextSources::new(false, true), "") + .await? + .len(), + 2 + ); + settings.normal_byte_limit = 2; + repo.update_settings(&settings).await?; + assert!(repo.get(TextEntryId::Clipboard(b)).await.is_err()); + assert!(repo.get(TextEntryId::Clipboard(c)).await.is_ok()); + assert!(repo.get(TextEntryId::Sticker(saved)).await.is_ok()); + settings.normal_count_limit = -1; + assert!(repo.update_settings(&settings).await.is_err()); + assert_eq!(repo.settings().await?.normal_count_limit, 2); + settings.normal_count_limit = 0; + repo.update_settings(&settings).await?; + assert!( + repo.snapshot(TextSources::new(false, true), "") + .await? + .is_empty() + ); + assert!(repo.get(TextEntryId::Sticker(saved)).await.is_ok()); + Ok(()) + }) +} + +#[test] +fn promotion_consumes_source_once_and_stale_or_failed_promotion_rolls_back() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + let exact = " \r\n大文字 'keep' \t"; + let capture = repo.capture(exact, 1).await?; + let entry = repo.get(TextEntryId::Clipboard(capture)).await?; + assert!(repo.promote(&entry, true).await.is_err()); // No key: no placeholder commit. + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stickers") + .fetch_one(&pool) + .await?; + assert_eq!(count, 0); + assert!(repo.get(entry.id()).await.is_ok()); + let id = repo.promote(&entry, false).await?; + let saved = repo.get(TextEntryId::Sticker(id)).await?; + assert_eq!(saved.state(), Some(StickerState::Close)); + assert_eq!(&*repo.resolve(&saved, None).await?, exact); + assert!(repo.get(entry.id()).await.is_err()); + assert!(repo.promote(&entry, false).await.is_err()); + assert!(repo.promote(&saved, false).await.is_err()); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stickers WHERE type='markdown'") + .fetch_one(&pool) + .await?; + assert_eq!(count, 1); + let evicted = repo.capture("evicted", 2).await?; + let stale = repo.get(TextEntryId::Clipboard(evicted)).await?; + repo.delete(stale.id()).await?; + assert!(repo.promote(&stale, false).await.is_err()); + Ok(()) + }) +} + +#[test] +fn merged_order_filter_projection_and_typed_identity() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + let s1 = sticker(&pool, "ALPHA", "body βeta", 10).await?; + let s2 = sticker(&pool, "Other", "body βeta", 10).await?; + let c1 = repo.capture("unique", 10).await?; + let c2 = repo.capture("another", 10).await?; + assert_eq!(s1, c1); // Same numeric ID, separate domain objects. + let all = TextSources::new(true, true); + let entries = repo.snapshot(all, "").await?; + assert_eq!( + entries.iter().map(|e| e.id()).collect::>(), + vec![ + TextEntryId::Sticker(s2), + TextEntryId::Sticker(s1), + TextEntryId::Clipboard(c2), + TextEntryId::Clipboard(c1) + ] + ); + assert_eq!(filter_indices(&entries, "alpha\u{2003}ΒETA"), vec![1]); + assert_eq!( + repo.snapshot(all, "alpha βeta").await?[0].id(), + TextEntryId::Sticker(s1) + ); + assert!(repo.snapshot(all, "alpha absent").await?.is_empty()); + assert!( + repo.snapshot(TextSources::new(false, false), "") + .await? + .is_empty() + ); + let duplicate = repo.capture("body βeta", 50).await?; + let merged = repo.snapshot(all, "").await?; + assert_eq!(merged[0].id(), TextEntryId::Sticker(s1)); + assert_eq!(merged[0].activity_at(), 50); + assert!( + !merged + .iter() + .any(|e| e.id() == TextEntryId::Clipboard(duplicate)) + ); + assert!(repo.get(TextEntryId::Clipboard(duplicate)).await.is_ok()); + // Chrome updates don't edit text MRU; direct use is monotonic and table-specific. + sqlx::query("UPDATE stickers SET updated_at=9999,color='blue' WHERE id=?") + .bind(s1) + .execute(&pool) + .await?; + assert_eq!(repo.get(TextEntryId::Sticker(s1)).await?.activity_at(), 10); + repo.mark_used(TextEntryId::Clipboard(c1), 100).await?; + repo.mark_used(TextEntryId::Clipboard(c1), 90).await?; + assert_eq!( + repo.get(TextEntryId::Clipboard(c1)).await?.activity_at(), + 100 + ); + assert_eq!(repo.get(TextEntryId::Sticker(s1)).await?.activity_at(), 10); + repo.delete(TextEntryId::Clipboard(c1)).await?; + assert!(repo.get(TextEntryId::Sticker(s1)).await.is_ok()); + for id in [TextEntryId::Sticker(s1), TextEntryId::Clipboard(c2)] { + assert_eq!(id.to_string().parse::()?, id); + assert_eq!(serde_json::to_value(id)?, id.to_string()); + } + for invalid in ["1", "text:1", "sticker:0", "clipboard:-1", "sticker:x"] { + assert!(invalid.parse::().is_err()); + } + Ok(()) + }) +} + +#[test] +fn concurrent_connections_capture_and_promote_exactly_once() -> anyhow::Result<()> { + block_on(async { + let (dir, repo, pool) = fixture().await?; + let other = TextRepository::open(dir.path().join("stickers.db")).await?; + let gate = std::sync::Arc::new(std::sync::Barrier::new(2)); + let worker_gate = gate.clone(); + let worker_repo = other.clone(); + let worker = std::thread::spawn(move || { + worker_gate.wait(); + block_on(worker_repo.capture("simultaneous capture", 20)) + }); + gate.wait(); + let id = repo.capture("simultaneous capture", 10).await?; + assert_eq!(worker.join().expect("capture worker panicked")?, id); + assert_eq!( + repo.get(TextEntryId::Clipboard(id)).await?.activity_at(), + 20 + ); + let selected = repo.get(TextEntryId::Clipboard(id)).await?; + let worker_selected = selected.clone(); + let gate = std::sync::Arc::new(std::sync::Barrier::new(2)); + let worker_gate = gate.clone(); + let worker = std::thread::spawn(move || { + worker_gate.wait(); + block_on(other.promote(&worker_selected, false)) + }); + gate.wait(); + let local = repo.promote(&selected, false).await; + let remote = worker.join().expect("promotion worker panicked"); + assert_eq!( + usize::from(local.is_ok()) + usize::from(remote.is_ok()), + 1, + "only one concurrent promotion may commit" + ); + assert!(repo.get(selected.id()).await.is_err()); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stickers") + .fetch_one(&pool) + .await?; + assert_eq!(count, 1); + let saved = repo.snapshot(TextSources::new(true, false), "").await?; + assert_eq!(saved[0].state(), Some(StickerState::Close)); + assert_eq!( + &*repo.resolve(&saved[0], None).await?, + "simultaneous capture" + ); + Ok(()) + }) +} + +#[test] +fn failed_promotion_final_write_rolls_back_source_and_placeholder() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + let capture = repo.capture("must survive", 10).await?; + let source = repo.get(TextEntryId::Clipboard(capture)).await?; + sqlx::raw_sql("CREATE TRIGGER reject_promotion BEFORE DELETE ON clipboard_captures BEGIN SELECT RAISE(ABORT,'injected final promotion failure'); END;") + .execute(&pool).await?; + assert!(repo.promote(&source, false).await.is_err()); + assert_eq!(&*repo.resolve(&source, None).await?, "must survive"); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stickers") + .fetch_one(&pool) + .await?; + assert_eq!( + count, 0, + "failed source consumption must roll back the inserted sticker" + ); + sqlx::query("DROP TRIGGER reject_promotion") + .execute(&pool) + .await?; + assert!(repo.promote(&source, false).await.is_ok()); + Ok(()) + }) +} + +#[test] +fn stale_save_and_resolve_cannot_overwrite_new_content() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + let id = sticker(&pool, "note", "original", 1).await?; + let old = repo.get(TextEntryId::Sticker(id)).await?; + let replacement = " \r\n新 'text'\t "; + repo.save_text(&old, replacement).await?; + assert!(repo.save_text(&old, "stale").await.is_err()); + assert!(repo.resolve(&old, None).await.is_err()); + let current = repo.get(old.id()).await?; + assert_eq!(current.revision(), old.revision() + 1); + assert!(current.activity_at() > old.activity_at()); + assert_eq!(&*repo.resolve(¤t, None).await?, replacement); + Ok(()) + }) +} diff --git a/tests/text_titles.rs b/tests/text_titles.rs new file mode 100644 index 0000000..35c1959 --- /dev/null +++ b/tests/text_titles.rs @@ -0,0 +1,50 @@ +#![cfg(feature = "storage")] +use futures::executor::block_on; +use rustickers::text::{TextEntryId, TextRepository}; +#[test] +fn required_titles_are_searchable_and_survive_edits_and_protection() -> anyhow::Result<()> { + block_on(async { + let dir = tempfile::tempdir()?; + let repo = TextRepository::open(dir.path().join("stickers.db")).await?; + repo.set_master_password("test-password").await?; + let capture = repo.capture("private content", 1).await?; + let entry = repo.get(TextEntryId::Clipboard(capture)).await?; + assert!( + repo.promote_titled(&entry, true, Some(" \t ")) + .await + .is_err() + ); + assert!(repo.get(entry.id()).await.is_ok()); + let id = repo + .promote_titled(&entry, true, Some("Account notes")) + .await?; + let entry = repo.get(TextEntryId::Sticker(id)).await?; + assert_eq!(entry.title(), "Account notes"); + assert!(entry.matches("account")); + assert!(!entry.matches("private")); + repo.save_text(&entry, "updated private content").await?; + let entry = repo.get(entry.id()).await?; + assert_eq!(entry.title(), "Account notes"); + repo.update_title(entry.id(), "Renamed notes").await?; + assert!(repo.update_title(entry.id(), " ").await.is_err()); + // A draft acquired before rename must not replace the new title. + repo.save_text(&entry, "another edit").await?; + let entry = repo.get(entry.id()).await?; + assert_eq!(entry.title(), "Renamed notes"); + repo.set_encryption(&entry, false, Some("test-password")) + .await?; + let entry = repo.get(entry.id()).await?; + repo.save_text(&entry, "plaintext edit").await?; + let entry = repo.get(entry.id()).await?; + assert_eq!(entry.title(), "Renamed notes"); + assert!( + repo.set_encryption_titled(&entry, true, None, Some("")) + .await + .is_err() + ); + repo.set_encryption_titled(&entry, true, None, Some("Locked notes")) + .await?; + assert_eq!(repo.get(entry.id()).await?.title(), "Locked notes"); + Ok(()) + }) +} From 389c9762b2fbc13e8c671021c9a89b7a4cb6aea0 Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 8 Oct 2026 17:05:50 +0800 Subject: [PATCH 37/69] fix(ui): repair text controls and transient picker workflows Protect chrome controls from drag hit testing, unlock Markdown into preview mode, and explicitly raise TXTs. Group text window workflows, add multiline hover-action rows, close on deactivation, and virtualize selection scrolling. Preserve exact-text and locked-preview contracts with regression coverage. --- README.md | 4 +- src/native/components/stickers/markdown.rs | 134 ++++- src/native/mod.rs | 8 +- src/native/windows/main.rs | 36 +- src/native/windows/mod.rs | 14 +- src/native/windows/selection.rs | 76 ++- src/native/windows/sticker/mod.rs | 31 +- src/native/windows/sticker/view.rs | 10 +- src/native/windows/text/mod.rs | 10 + .../{text_password.rs => text/password.rs} | 6 +- .../windows/{clipboard.rs => text/picker.rs} | 497 ++++++++---------- src/native/windows/text/platform.rs | 51 ++ .../windows/{text_title.rs => text/title.rs} | 8 +- src/text/mod.rs | 9 + tests/text_repository.rs | 16 + tests/text_titles.rs | 2 + 16 files changed, 500 insertions(+), 412 deletions(-) create mode 100644 src/native/windows/text/mod.rs rename src/native/windows/{text_password.rs => text/password.rs} (97%) rename src/native/windows/{clipboard.rs => text/picker.rs} (74%) create mode 100644 src/native/windows/text/platform.rs rename src/native/windows/{text_title.rs => text/title.rs} (94%) diff --git a/README.md b/README.md index f88a2d6..a04e268 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ Command stickers support scheduled runs, including while their window is closed. ## TXTs: saved text and clipboard history -TXTs combines Markdown stickers (open or closed) with disposable clipboard captures. Search, filter by **All / Saved / Clipboard**, and navigate with arrows or Page keys. +TXTs combines Markdown stickers (open or closed) with disposable clipboard captures. Search, filter by **All / Text / Clipboard**, and navigate with arrows or Page keys. Each item shows up to three lines; hover its trailing actions to rename, edit, save, lock, or delete. TXTs closes when it loses focus. - **Enter** pastes to the previous app when opened through the global shortcut; from the manager, it copies instead. Ctrl/Cmd+Enter always copies. - **F2 / double-click** opens the normal sticker editor. Editing a capture first saves it as a sticker. - **Save as sticker** (Ctrl/Cmd+R) turns a capture into a closed sticker. - **Lock / Save encrypted** (Ctrl/Cmd+L) asks for a required public title, then encrypts without opening a sticker. -- **Rename** changes a saved sticker's title; custom titles survive content edits. +- **Rename** (Ctrl/Cmd+F2) changes a saved sticker's title; custom titles survive content edits. Locked stickers also show an editable public-title field above Unlock. - Deleting a capture is immediate; deleting a saved sticker requires confirmation. Clipboard collection starts **paused**. Enable it in TXTs. Captures preserve exact text and share a budget of 500 entries / 20 MiB; saved stickers are not evicted. Automatic paste and ownership-safe protected copying currently have a supported path only on Windows. diff --git a/src/native/components/stickers/markdown.rs b/src/native/components/stickers/markdown.rs index cab66e7..49d9c55 100644 --- a/src/native/components/stickers/markdown.rs +++ b/src/native/components/stickers/markdown.rs @@ -2,7 +2,7 @@ use crate::gpui_component::{ ActiveTheme, Disableable, button::{Button, ButtonVariants}, h_flex, - input::{InputEvent, Textarea, TextareaState}, + input::{Input, InputEvent, InputState, Textarea, TextareaState}, text::TextView, v_flex, }; @@ -11,7 +11,11 @@ use crate::{ model::sticker::{StickerColor, StickerDetail, StickerState, StickerType}, native::{ components::IconName, - windows::{StickerWindowEvent, sticker::StickerWindow, text_password}, + windows::{ + StickerWindowEvent, + sticker::StickerWindow, + text::{password as text_password, text_repository}, + }, }, storage::ArcStickerStore, text::{TextEntry, TextEntryId, protection}, @@ -28,6 +32,8 @@ pub struct MarkdownSticker { store: ArcStickerStore, sticker_events_tx: std::sync::mpsc::Sender, editor: Entity, + title: Entity, + title_busy: bool, editing: bool, edit_snapshot: Option>, error: Option, @@ -68,6 +74,7 @@ impl MarkdownSticker { content }) }); + let title = cx.new(|cx| InputState::new(window, cx).placeholder("Public title (required)")); let changes = cx.subscribe(&editor, |this, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) && this.encrypted && this.content_visible { text_password::set_dirty(this.id, true); @@ -80,6 +87,8 @@ impl MarkdownSticker { store, sticker_events_tx, editor, + title, + title_busy: false, editing: !encrypted && !unsupported && (edit || content.is_empty()), edit_snapshot: None, error: unsupported.then(|| { @@ -98,13 +107,18 @@ impl MarkdownSticker { this.edit_snapshot = Some(Zeroizing::new(content.to_owned())); this.editor.focus_handle(cx).focus(window, cx); } - let repo = crate::native::windows::clipboard::text_repository(); + let repo = text_repository(); if let Some(repo) = repo { - cx.spawn(async move |entity, cx| { + cx.spawn_in(window, async move |entity, cx| { let entry = repo.get(TextEntryId::Sticker(id)).await; - let _ = entity.update(cx, |this, cx| { + let _ = entity.update_in(cx, |this, window, cx| { match entry { - Ok(entry) => this.entry = Some(entry), + Ok(entry) => { + this.title.update(cx, |input, cx| { + input.set_value(entry.title().to_owned(), window, cx) + }); + this.entry = Some(entry); + } Err(_) => { this.error = Some("Text is unavailable. Reopen the sticker.".into()) } @@ -116,6 +130,42 @@ impl MarkdownSticker { } this } + fn save_title(&mut self, window: &mut Window, cx: &mut Context) { + if self.title_busy || self.busy { + return; + } + let title = self.title.read(cx).value().to_string(); + if title.trim().is_empty() { + self.error = Some("Enter a public title to continue.".into()); + cx.notify(); + return; + } + let Some(repo) = text_repository() else { + self.error = Some("Text storage unavailable.".into()); + cx.notify(); + return; + }; + let id = self.id; + self.title_busy = true; + self.error = None; + cx.notify(); + cx.spawn_in(window, async move |entity, cx| { + let result = repo.update_title(TextEntryId::Sticker(id), &title).await; + let _ = entity.update_in(cx, |this, _, cx| { + this.title_busy = false; + if result.is_ok() { + let _ = this + .sticker_events_tx + .send(StickerWindowEvent::TitleChanged { id, title }); + } else { + this.error = Some("Could not save the title. Try again.".into()); + } + cx.notify(); + }); + }) + .detach(); + } + fn reset_editor(&mut self, window: &mut Window, cx: &mut Context) { self.editor = cx.new(|cx| TextareaState::new(window, cx).searchable(true)); self._changes = cx.subscribe(&self.editor, |this, _, event: &InputEvent, cx| { @@ -160,7 +210,7 @@ impl MarkdownSticker { window: &mut Window, cx: &mut Context, ) { - let Some(repo) = crate::native::windows::clipboard::text_repository() else { + let Some(repo) = text_repository() else { return; }; let id = self.id; @@ -188,9 +238,9 @@ impl MarkdownSticker { this.editor.update(cx, |input, cx| { input.set_value(text.to_string(), window, cx) }); - this.edit_snapshot = Some(text); + this.edit_snapshot = None; this.content_visible = true; - this.editing = true; + this.editing = false; this.error = None; text_password::set_dirty(this.id, false); } @@ -214,7 +264,7 @@ impl MarkdownSticker { if self.busy || text_password::is_rotating() { return false; } - let Some(repo) = crate::native::windows::clipboard::text_repository() else { + let Some(repo) = text_repository() else { self.error = Some("Text storage unavailable.".into()); cx.notify(); return false; @@ -274,7 +324,7 @@ impl MarkdownSticker { if !self.encrypted && !self.editing { let weak = cx.weak_entity(); let title = self.entry.as_ref().map(|entry| entry.title()).unwrap_or(""); - crate::native::windows::text_title::prompt( + crate::native::windows::text::title::prompt( window, cx, true, @@ -303,7 +353,7 @@ impl MarkdownSticker { cx.notify(); return; } - let Some(repo) = crate::native::windows::clipboard::text_repository() else { + let Some(repo) = text_repository() else { return; }; let Some(entry) = self.entry.clone() else { @@ -329,6 +379,7 @@ impl MarkdownSticker { } return; } + let public_title = title.clone(); let result = cx .background_executor() .spawn(async move { @@ -349,6 +400,11 @@ impl MarkdownSticker { this.content_visible = false; this.edit_snapshot = None; this.reset_editor(window, cx); + if let Some(title) = &public_title { + this.title.update(cx, |input, cx| { + input.set_value(title.clone(), window, cx) + }); + } this.error = None; let _ = this .sticker_events_tx @@ -376,8 +432,7 @@ impl MarkdownSticker { Box::new(move |password, window, cx| { if let Some(entity) = entity.upgrade() { entity.update(cx, |this, cx| { - let Some(repo) = crate::native::windows::clipboard::text_repository() - else { + let Some(repo) = text_repository() else { return; }; let id = this.id; @@ -633,6 +688,7 @@ impl super::Sticker for MarkdownSticker { }) .tooltip("Lock / unlock text (Ctrl+L)") .ghost() + .occlude() .disabled(self.busy) .on_click(cx.listener(|this, _, window, cx| this.lock(window, cx))), ) @@ -645,24 +701,20 @@ impl super::Sticker for MarkdownSticker { } let mut controls = h_flex().gap_2(); if self.editing { - controls = controls - .child( - Button::new("text-save") - .label("Save") - .disabled(self.busy) - .on_click(cx.listener(|this, _, _, cx| { - this.save_state(cx); - })), - ) - .child( - Button::new("text-discard") - .label("Discard edits") - .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| this.cancel_edit(window, cx))), - ); + controls = controls.child( + Button::new("text-save") + .occlude() + .label("Save") + .disabled(self.busy) + .on_click(cx.listener(|this, _, _, cx| { + this.save_state(cx); + })), + ); } else if self.encrypted { controls = controls.child( Button::new("text-unprotect") + .occlude() + .disabled(self.busy || text_password::is_rotating()) .label("Remove encryption…") .on_click( cx.listener(|this, _, window, cx| this.remove_encryption(window, cx)), @@ -671,6 +723,7 @@ impl super::Sticker for MarkdownSticker { } else { controls = controls.child( Button::new("convert-to-file") + .occlude() .icon(IconName::DocumentText) .tooltip("Convert to local file…") .disabled(self.converting) @@ -689,6 +742,28 @@ impl Render for MarkdownSticker { if !self.content_visible { return body .p_4() + .justify_center() + .child(div().text_sm().child("Public title")) + .child( + h_flex().gap_2().child( + div() + .flex_1() + .min_w_0() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "enter" { + this.save_title(window, cx); + cx.stop_propagation(); + } + })) + .child(Input::new(&self.title).disabled(self.title_busy)), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Public and searchable. Do not include secrets."), + ) .child( Button::new("unlock-text") .label("Unlock text…") @@ -725,6 +800,7 @@ impl Render for MarkdownSticker { } else { body = body.child( div() + .p_2() .flex_1() .min_h_0() .on_mouse_down( diff --git a/src/native/mod.rs b/src/native/mod.rs index 55e8918..c15c9ff 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -67,7 +67,7 @@ pub fn run_native( let clipboard_filter_for_ipc = clipboard_filter_for_open.clone(); app.run(move |cx: &mut App| { - cx.on_app_quit(|_| async { crate::native::windows::clipboard::clear_owned_secret(); }).detach(); + cx.on_app_quit(|_| async { crate::native::windows::text::clear_owned_secret(); }).detach(); crate::gpui_component::init(cx); Theme::change(ThemeMode::Dark, None, cx); @@ -202,7 +202,7 @@ pub fn run_native( let filter = clipboard_filter_for_ipc.get().cloned(); if let (Some(store), Some(_filter)) = (store, filter) { cx.update(|cx| { - if let Err(err) = crate::native::windows::clipboard::ClipboardChooserWindow::toggle( + if let Err(err) = crate::native::windows::text::TextPickerWindow::toggle( cx, sticker_events_tx_for_ipc.clone(), store, @@ -248,9 +248,9 @@ pub fn run_native( let _ = clipboard_filter_for_open.set(filter.clone()); let (watcher, _stop) = crate::native::clipboard::watcher::WatcherHandle::new(Duration::from_millis(250)); watcher.spawn(filter.clone(), clipboard_store.clone()); - crate::native::windows::clipboard::set_global_store(clipboard_store, store.clone()); + crate::native::windows::text::set_global_store(clipboard_store, store.clone()); cx.update(move |_cx| { - crate::native::windows::clipboard::set_global_capture_filter(filter); + crate::native::windows::text::set_global_capture_filter(filter); }); } Err(_) => tracing::error!("Clipboard backend unavailable; continuing sticker startup"), diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs index e716057..fe57ee3 100644 --- a/src/native/windows/main.rs +++ b/src/native/windows/main.rs @@ -119,11 +119,11 @@ impl MainWindow { let query = cx.new(|cx| InputState::new(window, cx).placeholder("Rustickers")); window.on_window_should_close(cx, |window, cx| { - if super::text_password::is_rotating() || super::text_password::has_dirty_editors() { + if super::text::password::is_rotating() || super::text::password::has_dirty_editors() { window.open_alert_dialog(cx, |dialog,_,_| dialog.title("Finish encrypted text edits").child("Save or discard encrypted drafts and wait for password changes before quitting.")); return false; } - super::clipboard::clear_owned_secret(); + super::text::clear_owned_secret(); cx.quit(); true }); @@ -188,7 +188,7 @@ impl MainWindow { // SQLite data_version changes for other connections/processes, including CLI // writes. Poll the existing event pump without carrying plaintext notifications. - let text_version = if let Some(repository) = super::clipboard::text_repository() { + let text_version = if let Some(repository) = super::text::text_repository() { repository.data_version().await.unwrap_or(last_text_version) } else { last_text_version @@ -656,22 +656,6 @@ impl MainWindow { this.create_sticker(cx, &StickerType::Paint); })), ) - .separator() - .item( - PopupMenuItem::new("Clipboard history") - .icon(IconName::DocumentText) - .on_click(window.listener_for(&root_entity, |this, _, _, cx| { - if super::clipboard::open_from_main( - cx, - this.sticker_events_sender.clone(), - ) - .is_err() - { - this.error = Some("Clipboard backend is unavailable".into()); - cx.notify(); - } - })), - ) }) .into_any_element() } @@ -821,12 +805,13 @@ impl MainWindow { .gap_1() .items_center() .child( - Input::new(&self.query) + div().occlude().child(Input::new(&self.query) .cleanable(true) + .focus_bordered(false) .border_0() .w(px(160.0)) .tab_index(0) - .prefix(Icon::new(IconName::Search)), + .prefix(Icon::new(IconName::Search))), ) .child(self.sort_button(cx)), ) @@ -837,9 +822,10 @@ impl MainWindow { .icon(IconName::LockClosed) .tooltip("Master password…") .ghost() + .occlude() .on_click(|_, window, cx| { - if let Some(repository) = super::clipboard::text_repository() { - super::text_password::settings(window, cx, repository); + if let Some(repository) = super::text::text_repository() { + super::text::password::settings(window, cx, repository); } }), ) @@ -858,9 +844,9 @@ impl MainWindow { .ghost() .occlude() .on_click(cx.listener(|_, _, window, cx| { - if super::text_password::is_rotating() || super::text_password::has_dirty_editors() { + if super::text::password::is_rotating() || super::text::password::has_dirty_editors() { window.open_alert_dialog(cx, |dialog,_,_| dialog.title("Finish encrypted text edits").child("Save or discard encrypted drafts and wait for password changes before quitting.")); - } else { super::clipboard::clear_owned_secret(); cx.quit(); } + } else { super::text::clear_owned_secret(); cx.quit(); } })), ) .child(self.create_button(cx)), diff --git a/src/native/windows/mod.rs b/src/native/windows/mod.rs index b83c4f2..6b3e12b 100644 --- a/src/native/windows/mod.rs +++ b/src/native/windows/mod.rs @@ -1,20 +1,18 @@ use crate::model::sticker::StickerColor; use std::sync::RwLock; -pub mod clipboard; pub mod main; pub mod placement; pub mod selection; pub mod sticker; -pub(crate) mod text_password; -pub(crate) mod text_title; +pub mod text; mod transient_topmost; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum EscapeDismissTarget { Selection, Sticker(i64), - Clipboard, + TextPicker, } static ACTIVE_ESCAPE_DISMISS_TARGET: RwLock> = RwLock::new(None); @@ -29,9 +27,9 @@ pub(crate) fn set_escape_dismiss_target_active(target: EscapeDismissTarget, acti } } -/// Toggle whether the global escape dispatch should target the clipboard chooser. -pub(crate) fn set_clipboard_escape_target(active: bool) { - set_escape_dismiss_target_active(EscapeDismissTarget::Clipboard, active); +/// Toggle whether global Escape dispatch targets the text picker. +pub(crate) fn set_text_escape_target(active: bool) { + set_escape_dismiss_target_active(EscapeDismissTarget::TextPicker, active); } pub fn has_escape_dismiss_target() -> bool { @@ -50,7 +48,7 @@ pub fn close_active_escape_target(cx: &mut gpui::App) -> bool { Some(EscapeDismissTarget::Sticker(open_id)) => { sticker::StickerWindow::dispatch_escape(open_id, cx) } - Some(EscapeDismissTarget::Clipboard) => clipboard::dispatch_escape(cx), + Some(EscapeDismissTarget::TextPicker) => text::dispatch_escape(cx), None => false, } } diff --git a/src/native/windows/selection.rs b/src/native/windows/selection.rs index 7fcd8c1..eae37b1 100644 --- a/src/native/windows/selection.rs +++ b/src/native/windows/selection.rs @@ -5,13 +5,13 @@ use crate::gpui_component::{ button::{Button, ButtonVariants}, h_flex, input::{Input, InputEvent, InputState, Textarea, TextareaState}, - scroll::ScrollableElement, v_flex, }; use gpui::{ AnyElement, AnyWindowHandle, App, AppContext, Bounds, Context, Entity, Focusable, IntoElement, - Render, ScrollHandle, Subscription, Window, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowKind, WindowOptions, div, prelude::*, px, size, + Render, ScrollStrategy, Subscription, UniformListScrollHandle, Window, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowOptions, div, + prelude::*, px, size, uniform_list, }; #[cfg(target_os = "macos")] @@ -68,7 +68,7 @@ pub struct SelectionPopup { view: SelectionView, input_error: Option, resolving: bool, - scroll_handle: ScrollHandle, + scroll_handle: UniformListScrollHandle, closing: bool, _keystroke_subscription: Subscription, _window_activation_subscription: Subscription, @@ -288,7 +288,7 @@ impl SelectionPopup { view, input_error: None, resolving: false, - scroll_handle: ScrollHandle::new(), + scroll_handle: UniformListScrollHandle::new(), closing: false, _keystroke_subscription: keystroke_subscription, _window_activation_subscription: window_activation_subscription, @@ -352,7 +352,8 @@ impl SelectionPopup { self.stickers = Arc::new(stickers); self.selected = 0; self.view = SelectionView::Choose; - self.scroll_handle.scroll_to_item(0); + self.scroll_handle + .scroll_to_item(0, ScrollStrategy::Nearest); self.query.update(cx, |query, cx| query.focus(window, cx)); cx.notify(); } @@ -360,7 +361,8 @@ impl SelectionPopup { fn apply_filter(&mut self, query: &str, cx: &mut Context) { self.filtered = filtered_sticker_indices(&self.stickers, query); self.selected = 0; - self.scroll_handle.scroll_to_item(0); + self.scroll_handle + .scroll_to_item(0, ScrollStrategy::Nearest); cx.notify(); } @@ -382,7 +384,8 @@ impl SelectionPopup { self.selected = (self.selected as isize + offset) .clamp(0, self.filtered.len().saturating_sub(1) as isize) as usize; - self.scroll_handle.scroll_to_item(self.selected); + self.scroll_handle + .scroll_to_item(self.selected, ScrollStrategy::Nearest); cx.notify(); } @@ -463,34 +466,32 @@ impl SelectionPopup { v_flex() .id(("selection-sticker", sticker.id as u64)) .w_full() + .h_16() + .flex_shrink_0() .px_3() .py_2() + .overflow_hidden() .when(selected, |row| row.bg(cx.theme().list_active)) .hover(|row| row.bg(cx.theme().list_hover)) .on_click(cx.listener(move |this, _, _, cx| { this.open_at(filtered_index, cx); })) - .child(div().child(title)) + .child(div().truncate().child(title)) .when(!command.is_empty(), |row| { - row.child( - div() - .text_sm() - .opacity(0.7) - .overflow_hidden() - .flex_wrap() - .child(command), - ) + row.child(div().text_sm().opacity(0.7).truncate().child(command)) }) .into_any_element() } fn choose_view(&self, cx: &mut Context) -> AnyElement { let visible_count = self.filtered.len(); + let view = cx.entity(); v_flex() .size_full() .child( h_flex() + .flex_shrink_0() .items_center() .pb_2() .window_control_area(WindowControlArea::Drag) @@ -501,6 +502,7 @@ impl SelectionPopup { Input::new(&self.query) .cleanable(true) .border_0() + .focus_bordered(false) .w(px(200.0)) .tab_index(0) .prefix(Icon::new(IconName::Search)), @@ -517,17 +519,27 @@ impl SelectionPopup { .on_click(cx.listener(|this, _, _, cx| this.dismiss(cx))), ), ) - .child(h_flex().px_3().pb_2().text_sm().opacity(0.7).child(format!( - "{visible_count} matches · ↑/↓ select · Enter open · Esc close" - ))) .child( - v_flex() - .id("selection-popup-list") + div() .flex_1() .min_h_0() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .vertical_scrollbar(&self.scroll_handle) + .w_full() + .overflow_hidden() + .when(visible_count > 0, |body| { + body.child( + uniform_list( + "selection-popup-list", + visible_count, + move |range, _, cx| { + view.update(cx, |this, cx| { + range.map(|ix| this.row(ix, cx)).collect::>() + }) + }, + ) + .size_full() + .track_scroll(&self.scroll_handle), + ) + }) .when(visible_count == 0, |list| { list.child( div() @@ -536,8 +548,18 @@ impl SelectionPopup { .text_color(cx.theme().muted_foreground) .child("No matching command stickers"), ) - }) - .children((0..visible_count).map(|index| self.row(index, cx))), + }), + ) + .child( + h_flex() + .flex_shrink_0() + .justify_between() + .px_3() + .pb_2() + .text_xs() + .opacity(0.6) + .child(format!("{visible_count} · ↑/↓ select")) + .child("Enter/Esc"), ) .into_any_element() } diff --git a/src/native/windows/sticker/mod.rs b/src/native/windows/sticker/mod.rs index 24af073..e49acef 100644 --- a/src/native/windows/sticker/mod.rs +++ b/src/native/windows/sticker/mod.rs @@ -16,8 +16,7 @@ mod view; use std::{sync::mpsc, time::Instant}; -use crate::gpui_component::input::{InputEvent, InputState}; -use gpui::{AppContext, Context, Subscription, Window}; +use gpui::{Context, Subscription, Window}; use crate::model::sticker::{StickerColor, StickerDetail, StickerState}; use crate::native::components::stickers::{FOCUS_LOSS_RELOCK_DELAY, StickerView}; @@ -93,7 +92,6 @@ impl StickerWindow { ); view.set_color(cx, detail.color); - Self::watch_title(&detail, window, cx); Self::watch_bounds(window, cx); Self::watch_close_request(open_id, window, cx); Self::watch_dismiss_keystrokes(window, cx); @@ -126,33 +124,6 @@ impl StickerWindow { } } - /// Save the title as soon as the user confirms it with Enter. - fn watch_title(detail: &StickerDetail, window: &mut Window, cx: &mut Context) { - let title_value = detail.title.clone(); - let title = cx.new(|cx| InputState::new(window, cx).default_value(title_value)); - - cx.subscribe_in(&title, window, |this, input_state, event, _, cx| { - let InputEvent::PressEnter { .. } = event else { - return; - }; - let id = this.view.id(cx); - let text = input_state.read(cx).value().to_string(); - let store = this.store.clone(); - let events = this.sticker_events_tx.clone(); - cx.spawn(async move |entity, cx| { - if let Err(err) = store.update_sticker_title(id, text.clone()).await { - let _ = entity.update(cx, |this, cx| { - this.set_error(format!("Failed to save title: {err}"), cx); - }); - } else { - let _ = events.send(StickerWindowEvent::TitleChanged { id, title: text }); - } - }) - .detach(); - }) - .detach(); - } - /// Poll the window's geometry so moves, resizes and monitor changes are noticed. fn watch_bounds(window: &mut Window, cx: &mut Context) { let entity = cx.entity().downgrade(); diff --git a/src/native/windows/sticker/view.rs b/src/native/windows/sticker/view.rs index a15d87a..0a0bb0d 100644 --- a/src/native/windows/sticker/view.rs +++ b/src/native/windows/sticker/view.rs @@ -185,21 +185,20 @@ impl StickerWindow { self.detail.sticker_type == StickerType::Markdown, |header| { header.child( - Button::new("rename-text") + Button::new("rename-title") .icon(IconName::DocumentText) .ghost() .occlude() - .tooltip("Rename text…") + .tooltip("Rename title") .on_click(cx.listener(|this, _, window, cx| { - let Some(repo) = - crate::native::windows::clipboard::text_repository() + let Some(repo) = crate::native::windows::text::text_repository() else { return; }; let id = this.detail.id; let events = this.sticker_events_tx.clone(); let weak = cx.weak_entity(); - crate::native::windows::text_title::prompt( + crate::native::windows::text::title::prompt( window, cx, false, @@ -283,6 +282,7 @@ impl StickerWindow { div() .flex_1() .min_w_0() + .occlude() .when_some(extension, |view, extension| view.child(extension)), ) .when(!self.view.disable_color_picker(cx), move |v| { diff --git a/src/native/windows/text/mod.rs b/src/native/windows/text/mod.rs new file mode 100644 index 0000000..ae0e452 --- /dev/null +++ b/src/native/windows/text/mod.rs @@ -0,0 +1,10 @@ +//! Desktop text workflows: picker, public titles, and per-action authorization. +pub(crate) mod password; +mod picker; +mod platform; +pub(crate) mod title; + +pub use picker::{ + TextPickerWindow, dispatch_escape, open_from_main, set_global_capture_filter, set_global_store, +}; +pub(crate) use picker::{clear_owned_secret, text_repository}; diff --git a/src/native/windows/text_password.rs b/src/native/windows/text/password.rs similarity index 97% rename from src/native/windows/text_password.rs rename to src/native/windows/text/password.rs index 22c66ba..2512fbb 100644 --- a/src/native/windows/text_password.rs +++ b/src/native/windows/text/password.rs @@ -97,7 +97,7 @@ impl PasswordForm { cx.refresh_windows(); this.busy = false; match result { - Ok(()) => { this.clear(window, cx); window.close_dialog(cx); cx.defer(super::sticker::StickerWindow::invalidate_protected_views); } + Ok(()) => { this.clear(window, cx); window.close_dialog(cx); cx.defer(super::super::sticker::StickerWindow::invalidate_protected_views); } Err(_) => { this.clear(window,cx); this.error = Some("Could not change the master password. Check the current password and locked texts; nothing was changed.".into()); }, } cx.notify(); @@ -157,7 +157,7 @@ fn open_form( if event.keystroke.key == "enter" { form.update(cx,|this,cx|this.submit(window,cx)); cx.stop_propagation(); } else if event.keystroke.key == "escape" { if !form.read(cx).busy { form.update(cx,|this,cx|{this.clear(window,cx);this.callback=None;});window.close_dialog(cx); } cx.stop_propagation(); } }}) - .when(!settings || configured, |body| body.child(div().child("Master password")).child(Input::new(&state.password).disabled(busy))) + .when(!settings || configured, |body| body.child(Input::new(&state.password).disabled(busy))) .when(settings, |body| body.child(div().child("New password")).child(Input::new(&state.new_password).disabled(busy)) .child(div().child("Confirm password")).child(Input::new(&state.confirmation).disabled(busy)) .child(div().text_sm().text_color(cx.theme().muted_foreground).child(if configured { @@ -165,7 +165,7 @@ fn open_form( } else { "Keep this password safe. Forgotten passwords cannot recover locked texts." }))) .when(busy, |body| body.child("Re-encrypting locked texts. Do not close the app.")) .when_some(state.error.clone(), |body, error| body.child(div().text_color(cx.theme().danger).child(error)))) - .footer(h_flex().gap_2().child(Button::new("password-cancel").label("Cancel").disabled(busy) + .footer(h_flex().justify_end().gap_2().child(Button::new("password-cancel").label("Cancel").disabled(busy) .on_click(move |_, window, cx| { cancel.update(cx, |this, cx| { this.clear(window,cx); this.callback=None; }); window.close_dialog(cx); })) .child(Button::new("password-submit").label(if settings { if configured { "Change password" } else { "Set password" } } else { "Unlock" }) .primary().loading(busy).disabled(busy).on_click(move |_, window, cx| submit.update(cx, |this,cx| this.submit(window,cx))))) diff --git a/src/native/windows/clipboard.rs b/src/native/windows/text/picker.rs similarity index 74% rename from src/native/windows/clipboard.rs rename to src/native/windows/text/picker.rs index 459dc00..6aeeda7 100644 --- a/src/native/windows/clipboard.rs +++ b/src/native/windows/text/picker.rs @@ -4,7 +4,6 @@ use crate::gpui_component::{ button::{Button, ButtonVariants}, h_flex, input::{Input, InputEvent, InputState}, - menu::{DropdownMenu, PopupMenuItem}, v_flex, }; use crate::{ @@ -39,7 +38,7 @@ pub(crate) fn clear_owned_secret() { } } static GLOBAL_CAPTURE_FILTER: OnceLock> = OnceLock::new(); -pub(crate) static CLIPBOARD_POPUP: RwLock> = RwLock::new(None); +static TEXT_POPUP: RwLock> = RwLock::new(None); pub fn set_global_store(repository: Arc, store: ArcStickerStore) { let _ = GLOBAL_REPOSITORY.set(repository); let _ = GLOBAL_STORE.set(store); @@ -51,13 +50,13 @@ pub(crate) fn text_repository() -> Option> { GLOBAL_REPOSITORY.get().cloned() } fn clear_popup() { - if let Ok(mut slot) = CLIPBOARD_POPUP.write() { + if let Ok(mut slot) = TEXT_POPUP.write() { *slot = None; } - super::set_clipboard_escape_target(false); + super::super::set_text_escape_target(false); } pub fn dispatch_escape(cx: &mut App) -> bool { - CLIPBOARD_POPUP + TEXT_POPUP .read() .ok() .and_then(|s| *s) @@ -75,17 +74,17 @@ pub fn open_from_main( ) -> anyhow::Result<()> { let repository = text_repository().ok_or_else(|| anyhow::anyhow!("Text backend unavailable"))?; - ClipboardChooserWindow::toggle(cx, events, repository, None) + TextPickerWindow::toggle(cx, events, repository, None) } -pub struct ClipboardChooserWindow; -impl ClipboardChooserWindow { +pub struct TextPickerWindow; +impl TextPickerWindow { pub fn toggle( cx: &mut App, events: std::sync::mpsc::Sender, repository: Arc, destination: Option, ) -> anyhow::Result<()> { - if CLIPBOARD_POPUP.read().ok().and_then(|s| *s).is_some() && dispatch_escape(cx) { + if TEXT_POPUP.read().ok().and_then(|s| *s).is_some() && dispatch_escape(cx) { return Ok(()); } let filter = GLOBAL_CAPTURE_FILTER @@ -97,7 +96,7 @@ impl ClipboardChooserWindow { .cloned() .ok_or_else(|| anyhow::anyhow!("Sticker backend unavailable"))?; // Physical window geometry is an OS boundary; internal layout uses rem helpers. - let bounds = Bounds::centered(None, size(px(440.), px(520.)), cx); + let bounds = Bounds::centered(None, size(px(380.), px(520.)), cx); let handle = cx.open_window( WindowOptions { focus: true, @@ -116,10 +115,15 @@ impl ClipboardChooserWindow { cx.new(|cx| Root::new(view, window, cx)) }, )?; - if let Ok(mut slot) = CLIPBOARD_POPUP.write() { + if let Ok(mut slot) = TEXT_POPUP.write() { *slot = Some(handle.into()); } - super::set_clipboard_escape_target(true); + // Reassert after native creation: the hotkey can arrive while another + // application is active, before the new window has been presented. + cx.defer(move |cx| { + let _ = handle.update(cx, |_, window, _| super::platform::raise(window)); + }); + super::super::set_text_escape_target(true); Ok(()) } } @@ -127,7 +131,7 @@ impl ClipboardChooserWindow { enum SourceFilter { #[default] All, - Saved, + Text, Clipboard, } impl SourceFilter { @@ -135,7 +139,7 @@ impl SourceFilter { matches!( (self, id), (Self::All, _) - | (Self::Saved, TextEntryId::Sticker(_)) + | (Self::Text, TextEntryId::Sticker(_)) | (Self::Clipboard, TextEntryId::Clipboard(_)) ) } @@ -171,6 +175,7 @@ struct TextPicker { loading: bool, preferred_selection: Option, _query: Subscription, + _activation: Subscription, } impl Drop for TextPicker { fn drop(&mut self) { @@ -187,7 +192,7 @@ impl TextPicker { window: &mut Window, cx: &mut Context, ) -> Self { - let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search TXTs…")); + let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search texts ...")); let subscription = cx.subscribe(&query, |this, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { this.refilter(cx); @@ -197,6 +202,12 @@ impl TextPicker { cx.notify(); } }); + let activation = cx.observe_window_activation(window, |_, window, _| { + if !window.is_window_active() { + clear_popup(); + window.remove_window(); + } + }); let this = Self { repository, store, @@ -215,6 +226,7 @@ impl TextPicker { loading: true, preferred_selection: None, _query: subscription, + _activation: activation, }; query.update(cx, |input, cx| input.focus(window, cx)); cx.spawn(async move |entity, cx| { @@ -323,7 +335,7 @@ impl TextPicker { } else { "" }; - super::text_title::prompt( + super::title::prompt( window, cx, true, @@ -372,7 +384,7 @@ impl TextPicker { if let Some(entity) = weak.upgrade() { let _ = entity.update_in(cx, |this, window, cx| { this.busy = false; - super::text_password::settings(window, cx, repo); + super::password::settings(window, cx, repo); cx.notify(); }); } @@ -469,7 +481,7 @@ impl TextPicker { }; if entry.is_encrypted() { let weak = cx.entity().downgrade(); - super::text_password::unlock( + super::password::unlock( window, cx, Box::new(move |password, window, cx| { @@ -498,19 +510,50 @@ impl TextPicker { let weak = cx.entity().downgrade(); self.busy = true; cx.notify(); - if !copy_only && hide_chooser(window).is_err() { - self.busy = false; - self.status = "Verified paste unavailable. Use Copy only.".into(); - cx.notify(); - return; + // Paste switches foreground windows. The authorized action is app-owned so + // closing this transient picker cannot cancel delivery or revive it on failure. + let handle = window.window_handle(); + if !copy_only { + clear_popup(); + window.remove_window(); } - window.spawn(cx,async move|cx|{let result=cx.background_executor().spawn(async move{futures::executor::block_on(async{ - let text=repo.resolve(&entry,password.as_ref().map(|p|p.as_str())).await?; - dispatch_content(&filter,&text,destination,copy_only,entry.is_encrypted())?; - let _=repo.mark_used(entry.id(),crate::utils::time::now_unix_millis()).await;anyhow::Ok(()) - })}).await; - if let Some(entity)=weak.upgrade(){let _=entity.update_in(cx,|this,window,cx|{this.busy=false;match result{Ok(())=>{clear_popup();window.remove_window();},Err(_)=>{show_chooser(window);window.activate_window();this.status="Could not use text. Check the password or paste destination; try Copy only.".into();}}cx.notify();});} - }).detach(); + App::spawn(cx, async move |cx| { + let result = cx + .background_executor() + .spawn(async move { + futures::executor::block_on(async { + let text = repo + .resolve(&entry, password.as_ref().map(|p| p.as_str())) + .await?; + dispatch_content( + &filter, + &text, + destination, + copy_only, + entry.is_encrypted(), + )?; + let _ = repo + .mark_used(entry.id(), crate::utils::time::now_unix_millis()) + .await; + anyhow::Ok(()) + }) + }) + .await; + let _ = handle.update(cx, |_, window, cx| { + let _ = weak.update(cx, |this, cx| { + this.busy = false; + if result.is_ok() { + clear_popup(); + window.remove_window(); + } else { + this.status = + "Could not use text. Check the password or paste destination.".into(); + } + cx.notify(); + }); + }); + }) + .detach(); } fn delete(&mut self, window: &mut Window, cx: &mut Context) { if self.busy || window.has_active_dialog(cx) { @@ -604,7 +647,7 @@ impl TextPicker { fn key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let key = event.keystroke.key.as_str(); let command = event.keystroke.modifiers.control || event.keystroke.modifiers.platform; - if window.has_active_dialog(cx) { + if window.has_active_dialog(cx) || self.busy { return; } // Menus consume Escape first; toolbar focus must not strand the utility. @@ -620,9 +663,10 @@ impl TextPicker { } "up" => self.move_selection(-1, cx), "down" => self.move_selection(1, cx), - "pageup" => self.move_selection(-6, cx), - "pagedown" => self.move_selection(6, cx), + "pageup" => self.move_selection(-3, cx), + "pagedown" => self.move_selection(3, cx), "enter" => self.use_selected(command || self.destination.is_none(), window, cx), + "f2" if command => self.rename(window, cx), "f2" => self.save(false, true, window, cx), "delete" if self.query.read(cx).value().is_empty() => self.delete(window, cx), "r" if command => self.save(false, false, window, cx), @@ -641,8 +685,8 @@ impl TextPicker { fn render_header(&self, cx: &mut Context) -> impl IntoElement { h_flex() .flex_shrink_0() - .px_2() - .py_2() + .justify_between() + .pb_2() .gap_1() .window_control_area(WindowControlArea::Drag) .child( @@ -651,47 +695,51 @@ impl TextPicker { .prefix(Icon::new(IconName::Search)) .cleanable(true) .border_0() + .focus_bordered(false) .w_full() + .max_w_64() .disabled(self.busy), ), ) .child( - Button::new("master-password") - .icon(IconName::LockClosed) - .ghost() - .small() - .occlude() - .tooltip("Master password…") - .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| { - super::text_password::settings(window, cx, this.repository.clone()) - })), - ) - .child( - Button::new("close-texts") - .icon(IconName::Close) - .ghost() - .small() - .occlude() - .tooltip("Close · Esc") - .disabled(self.busy) - .on_click(|_, window, _| { - clear_popup(); - window.remove_window(); - }), + h_flex() + .gap_1() + .child( + Button::new("master-password") + .icon(IconName::LockClosed) + .ghost() + .occlude() + .tooltip("Master password…") + .disabled(self.busy) + .on_click(cx.listener(|this, _, window, cx| { + super::password::settings(window, cx, this.repository.clone()) + })), + ) + .child( + Button::new("close-texts") + .icon(IconName::Close) + .ghost() + .occlude() + .tooltip("Close · Esc") + .disabled(self.busy) + .on_click(|_, window, _| { + clear_popup(); + window.remove_window(); + }), + ), ) } fn render_filters(&self, cx: &mut Context) -> impl IntoElement { h_flex() - .px_3() + .px_2() .pb_2() .gap_1() .flex_shrink_0() .children( [ (SourceFilter::All, "all-texts", "All"), - (SourceFilter::Saved, "saved-texts", "Saved"), + (SourceFilter::Text, "saved-texts", "Text"), (SourceFilter::Clipboard, "clipboard-texts", "Clipboard"), ] .into_iter() @@ -718,15 +766,7 @@ impl TextPicker { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!( - "{} {}", - self.filtered.len(), - if self.filtered.len() == 1 { - "text" - } else { - "texts" - } - )), + .child(self.filtered.len().to_string()), ) } @@ -736,26 +776,14 @@ impl TextPicker { let encrypted = entry.is_encrypted(); let saved = matches!(id, TextEntryId::Sticker(_)); let selected = ix == self.selected; - let source = if encrypted { - "Saved · Locked" - } else if saved { - "Saved" - } else { - "Clipboard" - }; - h_flex() + v_flex() .id(gpui::SharedString::from(id.to_string())) + .group("text-row") .w_full() - .h_12() - .px_3() - .gap_3() - .rounded_md() - .border_1() - .border_color(if selected { - cx.theme().list_active_border - } else { - cx.theme().transparent - }) + .flex_shrink_0() + .px_2() + .py_2() + .gap_1() .bg(if selected { cx.theme().list_active } else { @@ -770,68 +798,124 @@ impl TextPicker { if this.busy || window.has_active_dialog(cx) { return; } - this.selected = ix; - this.status.clear(); - this.query.update(cx, |query, cx| query.focus(window, cx)); - if event.click_count == 2 { - this.save(false, true, window, cx); + if this.select_id(id) { + this.status.clear(); + this.query.update(cx, |query, cx| query.focus(window, cx)); + if event.click_count == 2 { + this.save(false, true, window, cx); + } + cx.notify(); } - cx.notify(); }), ) .child( - Icon::new(if encrypted { - IconName::LockClosed - } else if saved { - IconName::DocumentText - } else { - IconName::Copy - }) - .size_4() - .flex_shrink_0() - .text_color(cx.theme().muted_foreground), + div() + .text_sm() + .truncate() + .child(entry.preview().trim().to_owned()), ) .child( - v_flex() - .flex_1() - .min_w_0() + h_flex() .gap_1() - .child(div().text_sm().truncate().child(entry.preview().to_owned())) + .flex_shrink_0() + .items_center() .child( - h_flex() - .gap_2() + Icon::new(if encrypted { + IconName::LockClosed + } else if saved { + IconName::DocumentText + } else { + IconName::Copy + }) + .size_4() + .flex_shrink_0() + .text_color(if encrypted { + cx.theme().warning + } else if saved { + cx.theme().primary + } else { + cx.theme().muted_foreground + }), + ) + .child(div().flex_1()) + .child(self.render_row_actions(id, saved, encrypted, cx)) + .child( + div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(source) .child(recency_label( entry.activity_at(), crate::utils::time::now_unix_millis(), )), ), ) - .child( - Button::new(gpui::SharedString::from(format!("copy-{id}"))) - .icon(IconName::Copy) - .ghost() - .small() - .tooltip("Copy only · Ctrl+Enter") - .disabled(self.busy) - .on_click(cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - // Use domain identity: refreshes can change a row's positional index. - if let Some(ix) = this - .filtered - .iter() - .position(|ix| this.rows[*ix].id() == id) - { - this.selected = ix; - this.use_selected(true, window, cx); - } - })), - ) .into_any_element() } + fn select_id(&mut self, id: TextEntryId) -> bool { + let Some(ix) = self + .filtered + .iter() + .position(|ix| self.rows[*ix].id() == id) + else { + return false; + }; + self.selected = ix; + true + } + + fn render_row_actions( + &self, + id: TextEntryId, + saved: bool, + encrypted: bool, + cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .gap_1() + .invisible() + .group_hover("text-row", |actions| actions.visible()) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .children( + [ + ( + "rename", + IconName::DocumentText, + "Rename… · Ctrl+F2", + !saved, + ), + ("edit", IconName::Adjustments, "Edit… · F2", false), + ("save", IconName::Pin, "Save as sticker · Ctrl+R", saved), + ("lock", IconName::LockClosed, "Lock… · Ctrl+L", encrypted), + ("delete", IconName::Delete, "Delete · Delete", false), + ] + .into_iter() + .filter(|(action, _, _, disabled)| !*disabled || *action == "lock") + .map(|(action, icon, hint, disabled)| { + Button::new(gpui::SharedString::from(format!("{action}-{id}"))) + .icon(icon) + .ghost() + .small() + .tooltip(hint) + .disabled(self.busy || disabled) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + if this.busy || !this.select_id(id) { + return; + } + match action { + "rename" => this.rename(window, cx), + "edit" => this.save(false, true, window, cx), + "save" => this.save(false, false, window, cx), + "lock" => this.save(true, false, window, cx), + "delete" => this.delete(window, cx), + _ => unreachable!(), + } + })) + }), + ) + } + fn rename(&mut self, window: &mut Window, cx: &mut Context) { if self.busy { return; @@ -846,7 +930,7 @@ impl TextPicker { let events = self.events.clone(); let weak = cx.weak_entity(); let initial = entry.title().to_owned(); - super::text_title::prompt( + super::title::prompt( window, cx, false, @@ -874,114 +958,14 @@ impl TextPicker { }), ); } - fn render_actions(&self, cx: &mut Context) -> impl IntoElement { - let selected = self.selected_entry(); - let capture = selected - .as_ref() - .is_some_and(|entry| matches!(entry.id(), TextEntryId::Clipboard(_))); - let encrypted = selected.as_ref().is_some_and(TextEntry::is_encrypted); - let disabled = self.busy || selected.is_none(); - let view = cx.entity(); - h_flex() - .flex_shrink_0() - .px_3() - .py_2() - .gap_1() - .border_t_1() - .border_color(cx.theme().border) - .child( - Button::new("text-actions") - .label("Actions") - .icon(IconName::Adjustments) - .ghost() - .small() - .disabled(disabled) - .dropdown_menu(move |menu, window, _| { - menu.item( - PopupMenuItem::new("Rename…").disabled(capture).on_click( - window.listener_for(&view, |this, _, window, cx| { - this.rename(window, cx) - }), - ), - ) - .item( - PopupMenuItem::new("Edit…") - .icon(IconName::DocumentText) - .on_click(window.listener_for(&view, |this, _, window, cx| { - this.save(false, true, window, cx) - })), - ) - .item( - PopupMenuItem::new("Save as sticker") - .icon(IconName::Pin) - .disabled(!capture) - .on_click(window.listener_for(&view, |this, _, window, cx| { - this.save(false, false, window, cx) - })), - ) - .item( - PopupMenuItem::new(if capture { "Save encrypted" } else { "Lock" }) - .icon(IconName::LockClosed) - .disabled(encrypted) - .on_click(window.listener_for(&view, |this, _, window, cx| { - this.save(true, false, window, cx) - })), - ) - .separator() - .item( - PopupMenuItem::new(if capture { - "Delete capture" - } else { - "Delete saved text…" - }) - .icon(IconName::Delete) - .on_click( - window.listener_for(&view, |this, _, window, cx| { - this.delete(window, cx) - }), - ), - ) - }), - ) - .child(div().flex_1()) - .child( - Button::new("copy-text") - .label("Copy") - .icon(IconName::Copy) - .small() - .ghost() - .disabled(disabled) - .tooltip("Copy only · Ctrl+Enter") - .on_click( - cx.listener(|this, _, window, cx| this.use_selected(true, window, cx)), - ), - ) - .child( - Button::new("paste-text") - .label("Paste") - .icon(IconName::ArrowRight) - .small() - .primary() - .disabled(disabled || self.destination.is_none()) - .tooltip(if self.destination.is_some() { - "Paste to previous app · Enter" - } else { - "Open TXTs with the global shortcut to paste to another app" - }) - .on_click( - cx.listener(|this, _, window, cx| this.use_selected(false, window, cx)), - ), - ) - } - fn render_footer(&self, cx: &mut Context) -> impl IntoElement { h_flex() .flex_shrink_0() - .px_3() - .pb_2() .gap_1() + .pt_1() .child( div() + .px_3() .flex_1() .min_w_0() .text_xs() @@ -1036,13 +1020,12 @@ impl Render for TextPicker { .child(div().flex_1().min_h_0().w_full().overflow_hidden() .when(count > 0, |body| body.child(uniform_list("texts-list", count, move |range, _, cx| { view.update(cx, |this, cx| range.map(|ix| this.render_row(ix, cx)).collect::>()) - }).size_full().px_2().track_scroll(&self.scroll))) + }).size_full().track_scroll(&self.scroll))) .when(count == 0, |body| body.child(v_flex().size_full().items_center().justify_center().gap_2().px_4() .child(Icon::new(IconName::DocumentText).size_6().text_color(cx.theme().muted_foreground)) - .child(div().text_sm().child(if self.loading { "Loading texts…" } else if !self.query.read(cx).value().is_empty() { "No matching texts" } else if self.source == SourceFilter::Saved { "No saved texts yet" } else if self.source == SourceFilter::Clipboard { "No clipboard captures yet" } else { "Your text, within reach" })) + .child(div().text_sm().child(if self.loading { "Loading texts…" } else if !self.query.read(cx).value().is_empty() { "No matching texts" } else if self.source == SourceFilter::Text { "No saved texts yet" } else if self.source == SourceFilter::Clipboard { "No clipboard captures yet" } else { "Your text, within reach" })) .child(div().text_xs().text_center().text_color(cx.theme().muted_foreground) - .child(if !self.query.read(cx).value().is_empty() { "Try another search or clear the filter." } else if self.source == SourceFilter::Saved { "Save a capture from the Actions menu to keep it." } else if self.capture_enabled { "Copied text will appear here. Saved stickers are always kept." } else { "Enable collection below, or create a text sticker." }))))) - .child(self.render_actions(cx)) + .child(if !self.query.read(cx).value().is_empty() { "Try another search or clear the filter." } else if self.source == SourceFilter::Text { "Save a capture with its pin button or Ctrl+R to keep it." } else if self.capture_enabled { "Copied text will appear here. Saved stickers are always kept." } else { "Enable collection below, or create a text sticker." }))))) .child(self.render_footer(cx)) } } @@ -1054,8 +1037,8 @@ mod picker_tests { fn source_filters_keep_typed_identity_separate() { assert!(SourceFilter::All.includes(TextEntryId::Sticker(1))); assert!(SourceFilter::All.includes(TextEntryId::Clipboard(1))); - assert!(SourceFilter::Saved.includes(TextEntryId::Sticker(1))); - assert!(!SourceFilter::Saved.includes(TextEntryId::Clipboard(1))); + assert!(SourceFilter::Text.includes(TextEntryId::Sticker(1))); + assert!(!SourceFilter::Text.includes(TextEntryId::Clipboard(1))); assert!(SourceFilter::Clipboard.includes(TextEntryId::Clipboard(1))); assert!(!SourceFilter::Clipboard.includes(TextEntryId::Sticker(1))); } @@ -1102,39 +1085,3 @@ fn dispatch_content( } Ok(()) } -#[cfg(target_os = "windows")] -fn hide_chooser(window: &Window) -> anyhow::Result<()> { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - let RawWindowHandle::Win32(handle) = HasWindowHandle::window_handle(window)?.as_raw() else { - anyhow::bail!("No native chooser handle") - }; - unsafe { - windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( - handle.hwnd.get() as _, - windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, - ); - } - super::set_clipboard_escape_target(false); - Ok(()) -} -#[cfg(not(target_os = "windows"))] -fn hide_chooser(_: &Window) -> anyhow::Result<()> { - anyhow::bail!("Verified paste unavailable; use Copy only") -} -#[cfg(target_os = "windows")] -fn show_chooser(window: &Window) { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - if let Ok(handle) = HasWindowHandle::window_handle(window) - && let RawWindowHandle::Win32(handle) = handle.as_raw() - { - unsafe { - windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( - handle.hwnd.get() as _, - windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW, - ); - } - } - super::set_clipboard_escape_target(true); -} -#[cfg(not(target_os = "windows"))] -fn show_chooser(_: &Window) {} diff --git a/src/native/windows/text/platform.rs b/src/native/windows/text/platform.rs new file mode 100644 index 0000000..8f0a2a5 --- /dev/null +++ b/src/native/windows/text/platform.rs @@ -0,0 +1,51 @@ +//! Native presentation for the short-lived TXTs utility window. +use gpui::Window; + +pub(super) fn raise(window: &mut Window) { + #[cfg(target_os = "windows")] + { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, SWP_SHOWWINDOW, SetWindowPos, + }; + if let Ok(handle) = HasWindowHandle::window_handle(window) + && let RawWindowHandle::Win32(handle) = handle.as_raw() + { + // Floating is not sufficient when another app already owns topmost windows. + unsafe { + SetWindowPos( + handle.hwnd.get() as _, + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW, + ); + } + } + } + #[cfg(target_os = "macos")] + { + use cocoa::{ + appkit::{NSApplication, NSMainMenuWindowLevel, NSWindow}, + base::{YES, nil}, + }; + use objc::{msg_send, sel, sel_impl}; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + if let Ok(handle) = HasWindowHandle::window_handle(window) + && let RawWindowHandle::AppKit(handle) = handle.as_raw() + { + unsafe { + let view = handle.ns_view.as_ptr() as cocoa::base::id; + let native: cocoa::base::id = msg_send![view, window]; + if !native.is_null() { + native.setLevel_((NSMainMenuWindowLevel + 1) as _); + NSApplication::sharedApplication(nil).activateIgnoringOtherApps_(YES); + native.makeKeyAndOrderFront_(nil); + } + } + } + } + window.activate_window(); +} diff --git a/src/native/windows/text_title.rs b/src/native/windows/text/title.rs similarity index 94% rename from src/native/windows/text_title.rs rename to src/native/windows/text/title.rs index 4908115..5a855ab 100644 --- a/src/native/windows/text_title.rs +++ b/src/native/windows/text/title.rs @@ -42,7 +42,7 @@ pub(crate) fn prompt( input: cx.new(|cx| { InputState::new(window, cx) .default_value(initial.to_owned()) - .placeholder("Give this text a name") + .placeholder("Give this text a title") }), error: false, callback: Some(callback), @@ -51,18 +51,18 @@ pub(crate) fn prompt( window.open_dialog(cx, move |dialog, _, cx| { let submit = form.clone(); let cancel = form.clone(); let keys = form.clone(); let state = form.read(cx); - dialog.title(if locking { "Name and lock text" } else { "Rename text" }).keyboard(false) + dialog.title(if locking { "Name and lock text" } else { "Rename title" }).keyboard(false) .child(v_flex().gap_2().on_key_down(move |event: &gpui::KeyDownEvent, window, cx| { match event.keystroke.key.as_str() { "enter" => keys.update(cx, |this,cx| this.submit(window,cx)), "escape" => { window.close_dialog(cx); }, _ => return, }; cx.stop_propagation(); - }).child(div().child("Title (required)")) + }) .child(Input::new(&state.input)) .child(div().text_sm().text_color(cx.theme().muted_foreground).child("Titles are public and searchable, even when the text is locked. Do not put secrets in the title.")) .when(state.error, |body| body.child(div().text_sm().text_color(cx.theme().danger).child("Enter a title to continue.")))) - .footer(h_flex().gap_2() + .footer(h_flex().gap_2().justify_end() .child(Button::new("title-cancel").label("Cancel").on_click(move |_,window,cx| { cancel.update(cx, |this,_| this.callback=None); window.close_dialog(cx); })) .child(Button::new("title-submit").label(if locking { "Lock text" } else { "Save title" }).primary().disabled(state.callback.is_none()) .on_click(move |_,window,cx| submit.update(cx, |this,cx| this.submit(window,cx))))) diff --git a/src/text/mod.rs b/src/text/mod.rs index eb3fdd0..845b09b 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -97,6 +97,15 @@ impl TextEntry { pub fn preview(&self) -> &str { &self.preview } + /// Bounded multiline display text. Locked entries expose only their public title. + pub fn multiline_preview(&self) -> String { + let text = if self.encrypted { + &self.title + } else { + &self.content + }; + text.chars().take(1024).collect() + } pub fn is_encrypted(&self) -> bool { self.encrypted } diff --git a/tests/text_repository.rs b/tests/text_repository.rs index 5ee8002..1d1903c 100644 --- a/tests/text_repository.rs +++ b/tests/text_repository.rs @@ -27,6 +27,21 @@ async fn sticker(pool: &SqlitePool, title: &str, text: &str, time: i64) -> anyho .bind(title).bind(text).bind(time).bind(time).bind(time).fetch_one(pool).await?) } +#[test] +fn multiline_preview_is_bounded_without_splitting_unicode() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, _) = fixture().await?; + let text = "界\n".repeat(600); + let id = repo.capture(&text, 1).await?; + let entry = repo.get(TextEntryId::Clipboard(id)).await?; + let preview = entry.multiline_preview(); + assert_eq!(preview.chars().count(), 1024); + assert!(text.starts_with(&preview)); + assert_eq!(repo.resolve(&entry, None).await?.as_str(), text); + Ok(()) + }) +} + #[test] fn exact_capture_dedupe_and_validation() -> anyhow::Result<()> { block_on(async { @@ -37,6 +52,7 @@ fn exact_capture_dedupe_and_validation() -> anyhow::Result<()> { assert_eq!(repo.capture(text, 15).await?, id); let entry = repo.get(TextEntryId::Clipboard(id)).await?; assert_eq!(&*repo.resolve(&entry, None).await?, text); + assert_eq!(entry.multiline_preview(), text); assert_eq!(entry.activity_at(), 20); let row: (i64, i64, i64) = sqlx::query_as( "SELECT created_at,last_copied_at,byte_size FROM clipboard_captures WHERE id=?", diff --git a/tests/text_titles.rs b/tests/text_titles.rs index 35c1959..5f3aac1 100644 --- a/tests/text_titles.rs +++ b/tests/text_titles.rs @@ -20,6 +20,8 @@ fn required_titles_are_searchable_and_survive_edits_and_protection() -> anyhow:: .await?; let entry = repo.get(TextEntryId::Sticker(id)).await?; assert_eq!(entry.title(), "Account notes"); + assert_eq!(entry.multiline_preview(), "Account notes"); + assert!(!entry.multiline_preview().contains("private")); assert!(entry.matches("account")); assert!(!entry.matches("private")); repo.save_text(&entry, "updated private content").await?; From d96800e22544028f4c413845287238b66778f749 Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 8 Oct 2026 17:06:14 +0800 Subject: [PATCH 38/69] docs(frontend): capture desktop UX debugging and iteration lessons --- FRONTEND.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/FRONTEND.md b/FRONTEND.md index 447b41c..59ef7b4 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -19,8 +19,9 @@ Use the current crate source for exact APIs. Do not invent GPUI methods from web | `src/native/windows/main.rs` | Sticker manager and event refresh | | `src/native/windows/sticker/` | Window lifecycle, chrome, placement, close/save | | `src/native/windows/selection.rs` | Compact search-first selection popup | -| `src/native/windows/clipboard.rs` | TXTs list, filters, selection, commands | -| `src/native/windows/text_{password,title}.rs` | Shared authorization/settings and title dialogs | +| `src/native/windows/text/picker.rs` | TXTs list, filters, hover actions, and focus dismissal | +| `src/native/windows/text/{password,title}.rs` | Shared authorization/settings and title dialogs | +| `src/native/windows/text/platform.rs` | Native picker raising; separate from focus and domain actions | | `src/native/components/stickers/` | Type-specific rendering and editor behavior | | `src/native/components/webview.rs` | Embedded browser lifecycle | | `src/native/theme.rs` | Sticker background/swatch palette | @@ -35,9 +36,9 @@ Use GPUI Kit's components through the project's `gpui` / `crate::gpui_component` - Use persistent selected states, stable domain-derived element IDs, and a visible keyboard focus ring. - Let the primary list/editor fill the available space. Flexible children need `min_w_0()` / `min_h_0()`; each scroll region has one owner. - Virtualize large lists with `uniform_list` and consistent row heights. Preserve selection by ID, not position. -- Drag-area inputs/buttons must `.occlude()` so window dragging does not swallow clicks. +- Drag regions swallow pointer events before ordinary controls. Add `.occlude()` to Buttons and control containers in **both headers and footers**. Input does not expose that method: wrap it in `div().occlude().child(Input::new(...))`. Parent chrome occlusion alone is not enough. -For popup UX, follow `selection.rs`: search first, content dominant, lightweight hints, and a short keyboard path. Secondary commands belong in standard menus, not crowded button strips. +For popup UX, follow `selection.rs`: search first, content dominant, lightweight hints, and a short keyboard path. TXTs uses explicitly requested trailing hover actions, with equivalent keyboard commands; Copy/Paste are keyboard-only. ## State, events, and async work @@ -49,10 +50,12 @@ Use one owner method for each logical command across buttons, menus, and keyboar ## TXTs contracts to preserve -- All / Saved / Clipboard filters use the shared `TextRepository` projection. +- All / Text / Clipboard filters use the shared `TextRepository` projection. Rows show at most three lines; locked previews never contain plaintext. - Save/Lock promotes captures into **closed** stickers; Edit opens the normal editor. -- Lock requires a public title; Rename uses the same required-title dialog. Never expose secret content as a locked preview. -- Copy/paste does not open stickers. Without an external destination, Enter copies; otherwise it pastes. F2/double-click edits. +- Lock requires a public title; Rename uses the same required-title dialog. Locked stickers show a centered public-title field above Unlock; Enter commits its title. Never expose secret content as a locked preview. +- Unlock authorizes viewing, not editing: show Markdown preview, clear the edit snapshot, and leave dirty state false. Enter editing only through an explicit edit command; create its snapshot at that transition. +- Copy/paste does not open stickers. Without an external destination, Enter copies; otherwise it pastes. F2/double-click edits; Ctrl/Cmd+F2 renames. Hover actions must remain reachable through these equivalent keyboard commands. +- Close TXTs on window deactivation, even while busy. An authorized paste must be app-owned, not window-owned: closing the picker must neither cancel delivery nor resurrect the window after failure. - Saved-item deletion requires confirmation; capture deletion is immediate. - The manager, TXTs, and encrypted editor share one master-password system. No vault or cached global unlock state. - Keep clipboard suppression, destination verification, and ownership-safe expiry in OS adapters. Do not claim unsupported platform parity. @@ -63,6 +66,17 @@ Sticker windows use transparent backgrounds for corners. On Windows, startup set Verify resizing, DPI, keyboard navigation, focus restoration, long text, empty/error states, and light/dark themes in a real window. Compilation and repository tests do not prove rendered UI quality. +## Fast iteration and regression checks + +1. Inspect the current working diff before editing; preserve user tweaks. Trace the real owner method from pointer, keyboard, and menu entry points. +2. For a dead click, check drag hit testing/occlusion, disabled/busy guards, modal guards, then callback/async completion. A working keyboard path does not prove a working button. +3. For clipped tail rows, bound the **viewport**, not the content: fixed header/footer use `flex_shrink_0()`, list body uses `flex_1().min_h_0().overflow_hidden()`, and `uniform_list` fills that body. Give every virtual row the same height; release long text's width with `min_w_0()`. Check the first and last item with wheel and keyboard navigation. +4. Multiline previews must preserve line breaks and Unicode in display data, then use `line_clamp(3)` in the view. A single-line sanitized preview cannot become multiline through styling. Budget row height for three text lines, metadata, actions, padding, and rem zoom. +5. Hotkey presentation needs both native z-order and activation. `WindowKind::Floating` alone does not guarantee topmost ordering; raise after native creation through `text/platform.rs`. Test from another app, including one with topmost windows, not only from the manager. +6. Use an isolated temporary database/window to exercise actual pointer paths. Assert the outcome (dialog displayed, content saved, encryption removed, window closed), not merely event delivery. Capture preview/edit/locked states, hover actions, and scroll tails in one bounded pass; fix in a batch, then confirm once. + +Keep evidence precise: repository tests prove storage contracts; native-event probes prove their specific event paths; screenshots prove only what was inspected. A synthetic Windows `WM_ACTIVATE` check is not proof of normal foreground switching. If the host blocks foreground transfer or images cannot be inspected, report that limitation. Remove temporary probes, debug instrumentation, and stale documentation references before committing. + ## Verification ```bash From 414699125704424639b04fa73b7047fbbd0f6e7d Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 8 Oct 2026 20:46:52 +0800 Subject: [PATCH 39/69] refactor(ui): restructure manager workspace and repair inspection flow Split src/native/windows/main.rs into lifecycle/navigation/collection/settings modules. Manager opening no longer implicitly reruns commands, search includes command metadata, navigation uses the shared sidebar with Settings anchored at the bottom, and the title and description live in the header. Add storage projection for the manager and authoritative regression coverage. Doc records updated; visual fixture guarded under tests/visual/. --- src/native/components/mod.rs | 1 + .../components/stickers/command/form.rs | 2 +- src/native/components/stickers/command/mod.rs | 13 +- src/native/windows/main.rs | 1063 ----------------- src/native/windows/main/collection.rs | 350 ++++++ src/native/windows/main/mod.rs | 758 ++++++++++++ src/native/windows/main/navigation.rs | 235 ++++ src/native/windows/main/settings.rs | 159 +++ src/storage/mod.rs | 7 + src/storage/sqlite.rs | 20 + tests/manager_commands.rs | 215 ++++ 11 files changed, 1758 insertions(+), 1065 deletions(-) delete mode 100644 src/native/windows/main.rs create mode 100644 src/native/windows/main/collection.rs create mode 100644 src/native/windows/main/mod.rs create mode 100644 src/native/windows/main/navigation.rs create mode 100644 src/native/windows/main/settings.rs create mode 100644 tests/manager_commands.rs diff --git a/src/native/components/mod.rs b/src/native/components/mod.rs index 272d289..908fcea 100644 --- a/src/native/components/mod.rs +++ b/src/native/components/mod.rs @@ -10,6 +10,7 @@ pub mod webview; #[derive(RustEmbed)] #[folder = "./assets"] #[include = "icons/**/*.svg"] +#[include = "icon.png"] pub struct Assets; impl AssetSource for Assets { diff --git a/src/native/components/stickers/command/form.rs b/src/native/components/stickers/command/form.rs index 37fa0bd..eba9a16 100644 --- a/src/native/components/stickers/command/form.rs +++ b/src/native/components/stickers/command/form.rs @@ -55,7 +55,7 @@ impl CommandSticker { .child( field().label("Selected text").child( Switch::new("accept_selection") - .label("Open and run from Ctrl/Cmd + Alt + C") + .label("Open and run with the selection shortcut") .small() .checked(self.accept_selection) .on_click(cx.listener(|this, _, _, _| { diff --git a/src/native/components/stickers/command/mod.rs b/src/native/components/stickers/command/mod.rs index d15d529..8e38881 100644 --- a/src/native/components/stickers/command/mod.rs +++ b/src/native/components/stickers/command/mod.rs @@ -118,7 +118,8 @@ impl CommandSticker { } = init; let cmd = serde_json::from_str::(&content).unwrap_or_default(); - let open_in_settings = open_in_settings && (cmd.auto_close || cmd.run_without_window); + // Opening from the manager is inspection, never implicit execution. + let open_in_settings = open_in_settings && selection.is_none(); let title = cx.new(|cx| { InputState::new(window, cx) @@ -317,6 +318,7 @@ impl CommandSticker { return; } + let _ = sticker_events_tx.send(StickerWindowEvent::Created { id }); let _ = entity.update(cx, |this, cx| { this.error = None; cx.notify(); @@ -371,6 +373,15 @@ impl super::Sticker for CommandSticker { self.show_editing_view() } + fn begin_edit(&mut self, _window: &mut Window, cx: &mut Context) { + if self.process.is_some() { + return; + } + self.stop_schedule(); + self.open_in_settings = true; + cx.notify(); + } + fn disable_color_picker(&self) -> bool { !self.show_editing_view() } diff --git a/src/native/windows/main.rs b/src/native/windows/main.rs deleted file mode 100644 index fe57ee3..0000000 --- a/src/native/windows/main.rs +++ /dev/null @@ -1,1063 +0,0 @@ -use crate::gpui_component::Root; -use crate::gpui_component::alert::Alert; -use crate::gpui_component::button::{Button, ButtonVariants}; -use crate::gpui_component::input::{Input, InputEvent, InputState}; -use crate::gpui_component::menu::{DropdownMenu, PopupMenuItem}; -use crate::gpui_component::spinner::Spinner; -use crate::gpui_component::*; -use gpui::{ - AnyWindowHandle, App, AsyncApp, Bounds, Context, Entity, FocusHandle, IntoElement, - KeyDownEvent, MouseButton, MouseUpEvent, Render, ScrollStrategy, UniformListScrollHandle, - WeakEntity, Window, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowOptions, - div, prelude::*, px, size, uniform_list, -}; - -use std::sync::mpsc::{self}; -use std::time::Duration; - -use crate::model::sticker::*; -use crate::native::components::IconName; -use crate::native::components::stickers::Sticker; -use crate::native::components::stickers::command::CommandSticker; -use crate::native::components::stickers::command::activity as command_activity; -use crate::native::components::stickers::file::FileSticker; -use crate::native::components::stickers::markdown::MarkdownSticker; -use crate::native::components::stickers::paint::PaintSticker; -use crate::native::components::stickers::timer::TimerSticker; -use crate::native::windows::StickerWindowEvent; -use crate::native::windows::sticker::StickerWindow; -use crate::storage::ArcStickerStore; - -const STICKER_LOAD_LIMIT: i64 = 10000; -const STICKER_EVENT_PUMP_INTERVAL: Duration = Duration::from_millis(120); - -/// Fixed row height for the manager sticker list. uniform_list only -/// renders rows whose bounding boxes are identical; titles are clipped -/// at three lines inside the row so the box stays bounded. -const ROW_HEIGHT: f32 = 80.0; -/// How many rows one PageUp / PageDown step moves. Chosen to roughly -/// match a six-row viewport so the focused row stays on screen. -const PAGE_STEP: usize = 6; - -pub struct MainWindow { - store: ArcStickerStore, - sticker_events_sender: mpsc::Sender, - - query: Entity, - order: StickerOrderBy, - stickers: Vec, - - /// Index of the currently focused row in the sticker list. - /// `None` means nothing is focused (e.g. before any load completes - /// or right after a delete removed the focused row). Reset to - /// `None` on sort changes; clamped into bounds on every select. - /// Keyboard-driven navigation keeps this value stable across - /// re-renders caused by the event pump, but a sort/search change - /// discards it because the underlying row positions change. - /// Domain IDs are the only safe identity for selection over a - /// refresh; we still keep an index here because the keyboard - /// model is index-based. - selected_index: Option, - /// Focus handle attached to the sticker list region. Created with - /// \`tab_stop(true)\` so a Tab from the search input lands on the - /// list, exposing arrow-navigation, Enter, and Delete actions. - list_focus: FocusHandle, - /// Scroll handle driving the virtualised list so keyboard navigation - /// can scroll the focused row into view. - list_scroll: UniformListScrollHandle, - - loading: bool, - error: Option, - /// Monotonic counter incremented every time a sticker-load request is - /// launched. Each request captures the counter at the moment it - /// starts; the spawned task ignores its result unless it matches the - /// counter held here when the response lands. This is the same - /// revision-tag pattern as the timer sticker: a newer request - /// supersedes older ones, so out-of-order responses (slow SQL - /// followed by a sort change, a deletion that races a refresh, etc.) - /// cannot overwrite a fresher intent. A successful delete also bumps - /// this counter for the same reason: any in-flight reload that still - /// has the deleted sticker in its payload must be invalidated before - /// it lands. - load_request_token: u64, -} - -impl MainWindow { - pub fn open( - cx: &mut App, - sticker_events_rx: mpsc::Receiver, - sticker_events_tx: mpsc::Sender, - store: ArcStickerStore, - ) -> anyhow::Result { - let bounds = Bounds::centered(None, size(px(340.), px(550.0)), cx); - - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_min_size: Some(size(px(300.0), px(400.0))), - window_background: WindowBackgroundAppearance::Transparent, - titlebar: None, - ..Default::default() - }, - |window, cx| { - let view = cx.new(|cx| { - MainWindow::new(window, cx, sticker_events_rx, sticker_events_tx, store) - }); - cx.new(|cx| Root::new(view, window, cx)) - }, - ) - .map(|x| x.into()) - } - - fn new( - window: &mut Window, - cx: &mut Context, - sticker_events_rx: mpsc::Receiver, - sticker_events_tx: mpsc::Sender, - store: ArcStickerStore, - ) -> Self { - let query = cx.new(|cx| InputState::new(window, cx).placeholder("Rustickers")); - - window.on_window_should_close(cx, |window, cx| { - if super::text::password::is_rotating() || super::text::password::has_dirty_editors() { - window.open_alert_dialog(cx, |dialog,_,_| dialog.title("Finish encrypted text edits").child("Save or discard encrypted drafts and wait for password changes before quitting.")); - return false; - } - super::text::clear_owned_secret(); - cx.quit(); true - }); - - cx.spawn(async move |this, cx| { - let _ = this.update(cx, |this, cx| { - this.spawn_load_stickers(cx); - }); - - Self::loop_events(this, sticker_events_rx, cx).await; - }) - .detach(); - - cx.subscribe(&query, |this, _, event: &InputEvent, cx| { - if let InputEvent::PressEnter { .. } = event { - this.spawn_load_stickers(cx); - } - }) - .detach(); - - Self { - store, - sticker_events_sender: sticker_events_tx, - - query, - order: StickerOrderBy::CreatedDesc, - stickers: Vec::new(), - - selected_index: None, - list_focus: cx.focus_handle().tab_index(1).tab_stop(true), - list_scroll: UniformListScrollHandle::default(), - - loading: false, - error: None, - load_request_token: 0, - } - } - - async fn loop_events( - this: WeakEntity, - sticker_events_rx: mpsc::Receiver, - cx: &mut AsyncApp, - ) { - let mut last_activity = command_activity::generation(); - let mut last_text_version = -1i64; - - loop { - cx.background_executor() - .timer(STICKER_EVENT_PUMP_INTERVAL) - .await; - - let mut events: Vec = Vec::new(); - while let Ok(ev) = sticker_events_rx.try_recv() { - events.push(ev); - } - - // Command runs are tracked in a process-wide registry rather than pushed as events, - // because the background scheduler has no channel into this window. Polling its - // generation counter on the pump that already exists keeps the indicators live. - let activity = command_activity::generation(); - let activity_changed = activity != last_activity; - last_activity = activity; - - // SQLite data_version changes for other connections/processes, including CLI - // writes. Poll the existing event pump without carrying plaintext notifications. - let text_version = if let Some(repository) = super::text::text_repository() { - repository.data_version().await.unwrap_or(last_text_version) - } else { - last_text_version - }; - let text_changed = text_version != last_text_version; - last_text_version = text_version; - if events.is_empty() && !activity_changed && !text_changed { - continue; - } - - let updated = this.update(cx, |this, cx| { - let mut changed = activity_changed; - if text_changed { - this.spawn_load_stickers(cx); - changed = true; - } - for ev in events { - changed |= this.apply_event(ev, cx); - } - if changed { - cx.notify(); - } - }); - - if let Err(err) = updated { - tracing::warn!(error = %err, "Failed to process sticker window events"); - } - } - } - - fn apply_event(&mut self, event: StickerWindowEvent, cx: &mut Context) -> bool { - match event { - StickerWindowEvent::Created { .. } => { - self.spawn_load_stickers(cx); - false - } - StickerWindowEvent::TitleChanged { id, title } => { - if let Some(sticker) = self.stickers.iter_mut().find(|s| s.id == id) - && sticker.title != title - { - sticker.title = title; - sticker.updated_at = crate::utils::time::now_unix_millis(); - return true; - } - false - } - StickerWindowEvent::ColorChanged { id, color } => { - if let Some(sticker) = self.stickers.iter_mut().find(|s| s.id == id) - && sticker.color != color - { - sticker.color = color; - sticker.updated_at = crate::utils::time::now_unix_millis(); - return true; - } - false - } - StickerWindowEvent::Closed { id } => { - if let Some(sticker) = self.stickers.iter_mut().find(|s| s.id == id) { - sticker.state = StickerState::Close; - sticker.updated_at = crate::utils::time::now_unix_millis(); - return true; - } - false - } - } - } - - fn create_sticker(&mut self, cx: &mut Context, sticker_type: &StickerType) { - if self.loading { - return; - } - - self.error = None; - - let size = match sticker_type { - StickerType::Markdown => MarkdownSticker::default_window_size(), - StickerType::Command => CommandSticker::default_window_size(), - StickerType::Timer => TimerSticker::default_window_size(), - StickerType::Paint => PaintSticker::default_window_size(), - StickerType::File => FileSticker::default_window_size(), - }; - - let title = match sticker_type { - StickerType::Markdown => "New Text Sticker", - StickerType::Command => "New Command Sticker", - StickerType::Timer => "New Timer Sticker", - StickerType::Paint => "New Paint Sticker", - StickerType::File => "New File Sticker", - }; - - let detail = StickerDetail { - id: 0, - title: title.to_string(), - content: "".to_string(), - color: StickerColor::Gray, - sticker_type: *sticker_type, - state: StickerState::Open, - left: 100, - top: 100, - width: size.width, - height: size.height, - top_most: false, - created_at: 0, - updated_at: 0, - display_id: None, - display_uuid: None, - virtual_desktop_id: None, - native_left: None, - native_top: None, - native_width: None, - native_height: None, - preferred_display_uuid: None, - placements: Vec::new(), - }; - - let store = self.store.clone(); - let sticker_events_tx = self.sticker_events_sender.clone(); - cx.spawn( - async move |entity, cx| match store.insert_sticker(detail).await { - Ok(id) => { - let _ = sticker_events_tx.send(StickerWindowEvent::Created { id }); - - if let Err(err) = - StickerWindow::open_async(cx, sticker_events_tx.clone(), store.clone(), id) - .await - { - let _ = entity.update(cx, |this, cx| { - this.error = Some(format!("Failed to open sticker window: {err:#}")); - cx.notify(); - }); - } - } - Err(err) => { - let _ = entity.update(cx, |this, cx| { - this.error = Some(format!("Failed to create sticker: {err:#}")); - cx.notify(); - }); - } - }, - ) - .detach(); - } - - fn spawn_load_stickers(&mut self, cx: &mut Context) { - self.error = None; - self.load_request_token = self.load_request_token.wrapping_add(1); - let token = self.load_request_token; - self.loading = true; - cx.notify(); - - let query = self.query.read(cx).value().to_string(); - let order_by = self.order; - let store = self.store.clone(); - - cx.spawn(async move |entity, cx| { - let query_filter = (!query.is_empty()).then_some(query); - let result = store - .query_stickers(query_filter, order_by, STICKER_LOAD_LIMIT, 0) - .await; - - let _ = entity.update(cx, move |this, cx| { - // A newer request has been issued since this one was - // dispatched -- either another sort/search change or a - // successful delete that bumped the token to defend - // against this very response. Drop the result so the - // older ordering, filter, or about-to-be-deleted row - // does not stomp on fresher state. `loading` stays true - // here on purpose: whichever request is current will - // clear it when its own response lands. - if this.load_request_token != token { - return; - } - - this.loading = false; - match result { - Ok(stickers) => this.stickers = stickers, - Err(err) => { - this.error = Some(format!("Failed to query stickers: {err:#}")); - } - } - cx.notify(); - }); - }) - .detach(); - } - - /// Open a sticker window starting from a list activation (mouse - /// double-click, or the keyboard Enter handler in - /// \`handle_list_key\`). The - /// in-memory sticker is flipped to `Open` synchronously so the - /// "greyed-out" closed state clears immediately; if the underlying - /// `StickerWindow::open_async_with_options` call fails, the prior - /// state is restored and the user sees a single actionable error. - /// That keeps the optimistic flip honest while still surfacing - /// failures instead of swallowing them. - /// - /// All callsites must funnel through this method so future keyboard - /// and menu entry points share one error-handling and notification - /// path. - fn open_sticker(&mut self, id: i64, cx: &mut Context) { - let prior_state = self.stickers.iter().find(|s| s.id == id).map(|s| s.state); - if let Some(sticker) = self.stickers.iter_mut().find(|s| s.id == id) { - sticker.state = StickerState::Open; - cx.notify(); - } - - let store = self.store.clone(); - let sticker_events_tx = self.sticker_events_sender.clone(); - cx.spawn(async move |entity, cx| { - // Opened from the list: command stickers with auto close start - // in their settings view instead of running again. - let result = - StickerWindow::open_async_with_options(cx, sticker_events_tx, store, id, true) - .await; - if let Err(err) = result { - tracing::warn!(id, error = ?err, "Failed to open sticker window from list"); - let _ = entity.update(cx, |this, cx| { - if let Some(sticker) = this.stickers.iter_mut().find(|s| s.id == id) - && let Some(prior) = prior_state - { - sticker.state = prior; - } - this.error = Some(format!("Could not open sticker {id}: {err:#}")); - cx.notify(); - }); - } - }) - .detach(); - } - - /// Domain id of the currently focused row, if any. The index may - /// become stale across sort/search refreshes; the id is the only - /// stable handle across those boundaries. - fn selected_sticker_id(&self) -> Option { - self.selected_index - .and_then(|i| self.stickers.get(i).map(|s| s.id)) - } - - /// Move the focused row to a new index, clamped into bounds. Caller - /// is expected to provide either a valid index or one past the end - /// when the list is empty. After clamping, the scroll handle is - /// asked to bring the row into view so keyboard navigation never - /// leaves the focus target off-screen. - fn move_selection_to(&mut self, new_index: usize, cx: &mut Context) { - if self.stickers.is_empty() { - self.selected_index = None; - cx.notify(); - return; - } - let clamped = new_index.min(self.stickers.len() - 1); - self.selected_index = Some(clamped); - self.list_scroll - .scroll_to_item(clamped, ScrollStrategy::Nearest); - cx.notify(); - } - - /// Dispatch keyboard events scoped to the manager list. The list - /// region uses \`track_focus\`; this handler only fires when the - /// list has focus (after a Tab from the search input, or after a - /// user clicked a row). Other key events fall through to whatever - /// built-in handler the focused input has. - fn handle_list_key( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - if self.stickers.is_empty() { - return; - } - - let count = self.stickers.len(); - let current = self.selected_index.unwrap_or(0); - let mods = event.keystroke.modifiers; - - let key = event.keystroke.key.as_str(); - let next: Option = match key { - "down" => Some(current.saturating_add(1).min(count - 1)), - "up" => Some(current.saturating_sub(1)), - "home" => Some(0), - "end" => Some(count - 1), - "pageDown" => Some(current.saturating_add(PAGE_STEP).min(count - 1)), - "pageUp" => Some(current.saturating_sub(PAGE_STEP)), - _ => None, - }; - - if let Some(target) = next { - self.move_selection_to(target, cx); - cx.stop_propagation(); - window.prevent_default(); - return; - } - - if key == "enter" && !mods.control && !mods.alt && !mods.platform && !mods.shift { - if let Some(id) = self.selected_sticker_id() { - self.open_sticker(id, cx); - } - cx.stop_propagation(); - window.prevent_default(); - } - } - - fn delete_sticker( - &mut self, - id: i64, - title: String, - window: &mut Window, - cx: &mut Context, - ) { - let entity = cx.entity(); - let store = self.store.clone(); - window.open_dialog(cx, move |dialog, _, _cx| { - let store = store.clone(); - let entity = entity.clone(); - dialog - .title(format!("Delete \"{title}\"?")) - .child("This permanently removes the sticker and its saved content.") - .footer( - h_flex() - .justify_end() - .gap_2() - .child(Button::new("cancel").label("Cancel").small().on_click( - |_, window, cx| { - window.close_dialog(cx); - }, - )) - .child( - Button::new("delete-sticker") - .danger() - .label("Delete") - .small() - .on_click(move |_, window, cx| { - window.close_dialog(cx); - let store = store.clone(); - let entity = entity.clone(); - cx.spawn(async move |cx| { - match store.delete_sticker(id).await { - Ok(()) => { - entity.update(cx, |this, cx| { - // Bump the load token so - // any already-running - // reload that still - // includes this row - // cannot resurrect it. - this.load_request_token = - this.load_request_token.wrapping_add(1); - this.loading = false; - StickerWindow::try_close(id, cx); - this.stickers.retain(|s| s.id != id); - cx.notify(); - }); - } - Err(err) => { - entity.update(cx, |this, cx| { - this.error = Some(format!( - "Failed to delete sticker: {err:#}" - )); - cx.notify(); - }); - } - } - }) - .detach(); - }), - ), - ) - .w(px(360.0)) - }); - } - - fn status_banner(&self, cx: &mut Context) -> gpui::AnyElement { - if let Some(err) = &self.error { - return div() - .p(px(8.0)) - .child(Alert::error("main-load-error", err.as_str())) - .into_any_element(); - } - if self.loading { - return div() - .p(px(8.0)) - .child(Spinner::new().color(cx.theme().accent)) - .into_any_element(); - } - gpui::Empty.into_any_element() - } - - fn sort_button(&self, cx: &mut Context) -> gpui::AnyElement { - let root_entity = cx.entity(); - Button::new("sort") - .icon(match self.order { - StickerOrderBy::CreatedAsc | StickerOrderBy::UpdatedAsc => IconName::SortAscending, - _ => IconName::SortDescending, - }) - .ghost() - .dropdown_menu(move |menu, window, cx| { - let order_by = root_entity.read(cx).order; - menu.item( - PopupMenuItem::new(order_label(StickerOrderBy::CreatedDesc)) - .checked(order_by == StickerOrderBy::CreatedDesc) - .on_click(window.listener_for(&root_entity, move |this, _, _, cx| { - this.order = StickerOrderBy::CreatedDesc; - this.spawn_load_stickers(cx); - })), - ) - .item( - PopupMenuItem::new(order_label(StickerOrderBy::CreatedAsc)) - .checked(order_by == StickerOrderBy::CreatedAsc) - .on_click(window.listener_for(&root_entity, move |this, _, _, cx| { - this.order = StickerOrderBy::CreatedAsc; - this.spawn_load_stickers(cx); - })), - ) - .item( - PopupMenuItem::new(order_label(StickerOrderBy::UpdatedDesc)) - .checked(order_by == StickerOrderBy::UpdatedDesc) - .on_click(window.listener_for(&root_entity, move |this, _, _, cx| { - this.order = StickerOrderBy::UpdatedDesc; - this.spawn_load_stickers(cx); - })), - ) - .item( - PopupMenuItem::new(order_label(StickerOrderBy::UpdatedAsc)) - .checked(order_by == StickerOrderBy::UpdatedAsc) - .on_click(window.listener_for(&root_entity, move |this, _, _, cx| { - this.order = StickerOrderBy::UpdatedAsc; - this.spawn_load_stickers(cx); - })), - ) - }) - .into_any_element() - } - - fn create_button(&self, cx: &mut Context) -> gpui::AnyElement { - let root_entity = cx.entity(); - Button::new("create") - .ghost() - .icon(IconName::Plus) - .dropdown_menu(move |menu, window, _| { - let root_entity = root_entity.clone(); - menu.item( - PopupMenuItem::new("text") - .icon(sticker_type_icon(&StickerType::Markdown)) - .on_click(window.listener_for(&root_entity, |this, _, _, cx| { - this.create_sticker(cx, &StickerType::Markdown); - })), - ) - .item( - PopupMenuItem::new("timer") - .icon(sticker_type_icon(&StickerType::Timer)) - .on_click(window.listener_for(&root_entity, |this, _, _, cx| { - this.create_sticker(cx, &StickerType::Timer); - })), - ) - .item( - PopupMenuItem::new("command") - .icon(sticker_type_icon(&StickerType::Command)) - .on_click(window.listener_for(&root_entity, |this, _, _, cx| { - this.create_sticker(cx, &StickerType::Command); - })), - ) - .item( - PopupMenuItem::new("paint") - .icon(sticker_type_icon(&StickerType::Paint)) - .on_click(window.listener_for(&root_entity, |this, _, _, cx| { - this.create_sticker(cx, &StickerType::Paint); - })), - ) - }) - .into_any_element() - } - - fn sticker_card( - &self, - index: usize, - sticker: &StickerBrief, - cx: &mut Context, - ) -> gpui::AnyElement { - let id = sticker.id; - let title = sticker.title.clone(); - let updated = crate::utils::time::format_unix_millis(sticker.updated_at); - let is_selected = self.selected_index == Some(index); - - // A command sticker can be busy, or waiting on a schedule, even while it is closed. That is - // otherwise invisible from the list. - let activity = if sticker.sticker_type == StickerType::Command { - CardActivity::of_command( - command_activity::is_running(id), - command_activity::next_run(id), - ) - } else { - CardActivity::Idle - }; - - let running = matches!(activity, CardActivity::Running); - let next_run = activity.next_run(); - let status = activity.status_line(&updated); - - let main = div() - .flex_shrink_0() - .flex() - .flex_col() - .gap_1() - .p_2() - .child( - div() - .flex() - .items_center() - .gap_1() - .child(div().text_color(sticker.color.swatch()).child( - Icon::new(sticker_type_icon(&sticker.sticker_type)).with_size(px(14.)), - )) - .when(running, |row| { - row.child( - div() - .text_color(yellow_500()) - .child(Spinner::new().with_size(px(12.))), - ) - }) - .when(next_run.is_some(), |row| { - row.child( - div() - .opacity(0.7) - .child(Icon::new(IconName::Loop).with_size(px(12.))), - ) - }) - .child( - div() - .text_sm() - .overflow_hidden() - .line_clamp(3) - .text_ellipsis() - .pr_2() - .child(if title.is_empty() { - "...".to_string() - } else { - title.clone() - }), - ), - ) - .child( - div() - .text_xs() - .opacity(0.75) - .text_right() - .when(running, |view| view.text_color(yellow_500())) - .child(status), - ); - - // Fixed row height. uniform_list requires one row height for the - // whole surface; titles are already line_clamp(3) inside the - // padded main column so the content stays bounded. The number is - // roughly title area (48 px × 14 px line height) + status (16 px) - // + padding (16 px vertical inside .p_2 outer). At rem size 14, - // 80 px works without clipping. - let row_bg: gpui::Hsla = if is_selected { - cx.theme().list_active - } else { - sticker.color.bg().into() - }; - div() - .id(("sticker-row", id as usize)) - .h(px(ROW_HEIGHT)) - .bg(row_bg) - .border_1() - .rounded_md() - .when(is_selected, |el| { - el.border_color(cx.theme().list_active_border) - }) - .when(!is_selected, |el| el.border_color(cx.theme().border)) - .opacity(if sticker.state == StickerState::Close { - 0.6 - } else { - 1.0 - }) - .hover(|s| s.bg(cx.theme().list_hover)) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, _, cx| { - this.selected_index = Some(index); - cx.notify(); - }), - ) - .on_mouse_up( - MouseButton::Left, - cx.listener(move |this, event: &MouseUpEvent, _, cx| { - if event.click_count >= 2 { - this.open_sticker(id, cx); - } - }), - ) - .child(main) - .child( - Button::new(("delete", id as u64)) - .absolute() - .top_0() - .right_0() - .icon(IconName::Close) - .ghost() - .occlude() - .on_click(cx.listener(move |this, _, window, cx| { - this.delete_sticker(id, title.clone(), window, cx); - })), - ) - .into_any_element() - } - - fn title_bar(&self, cx: &mut Context) -> gpui::AnyElement { - h_flex() - .gap_2() - .justify_between() - .window_control_area(WindowControlArea::Drag) - .child( - h_flex() - .gap_1() - .items_center() - .child( - div().occlude().child(Input::new(&self.query) - .cleanable(true) - .focus_bordered(false) - .border_0() - .w(px(160.0)) - .tab_index(0) - .prefix(Icon::new(IconName::Search))), - ) - .child(self.sort_button(cx)), - ) - .child( - h_flex() - .child( - Button::new("master-password") - .icon(IconName::LockClosed) - .tooltip("Master password…") - .ghost() - .occlude() - .on_click(|_, window, cx| { - if let Some(repository) = super::text::text_repository() { - super::text::password::settings(window, cx, repository); - } - }), - ) - .child( - Button::new("minimize") - .icon(IconName::Minus) - .ghost() - .occlude() - .on_click(cx.listener(|_, _, window, _| { - window.minimize_window(); - })), - ) - .child( - Button::new("close") - .icon(IconName::Close) - .ghost() - .occlude() - .on_click(cx.listener(|_, _, window, cx| { - if super::text::password::is_rotating() || super::text::password::has_dirty_editors() { - window.open_alert_dialog(cx, |dialog,_,_| dialog.title("Finish encrypted text edits").child("Save or discard encrypted drafts and wait for password changes before quitting.")); - } else { super::text::clear_owned_secret(); cx.quit(); } - })), - ) - .child(self.create_button(cx)), - ) - .into_any_element() - } -} - -impl Render for MainWindow { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let list_count = self.stickers.len(); - let view = cx.entity(); - - v_flex() - .gap_2() - .size_full() - .bg(cx.theme().background) - .on_mouse_down(MouseButton::Left, |_, window, _| { - if !window.is_window_active() { - window.activate_window(); - } - }) - .child(self.title_bar(cx)) - .child( - div() - .h_full() - .flex_shrink(1.0) - .overflow_hidden() - .track_focus(&self.list_focus) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - this.handle_list_key(event, window, cx); - })) - .when(list_count > 0, |el| { - el.child( - uniform_list( - "manager-stickers", - list_count, - move |range, _window, cx| { - view.update(cx, |this, cx| { - range - .into_iter() - .filter_map(|index| { - this.stickers - .get(index) - .map(|s| this.sticker_card(index, s, cx)) - }) - .collect::>() - }) - }, - ) - .size_full() - .track_scroll(&self.list_scroll), - ) - }) - .when(list_count == 0, |el| { - el.child( - v_flex() - .flex_1() - .items_center() - .justify_center() - .gap_1() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(IconName::DocumentText).with_size(px(20.))) - .child(if self.loading { - "Loading stickers\u{2026}" - } else if self.error.is_some() { - "Could not load stickers." - } else { - "No stickers yet. Create one with the + menu." - }), - ) - }), - ) - .child( - v_flex() - .gap_2() - .px_2() - .window_control_area(WindowControlArea::Drag) - .child(self.status_banner(cx)), - ) - } -} - -/// What a card tells the user about a command sticker's schedule. -/// -/// Only command stickers can be anything but [`CardActivity::Idle`]; every other type simply shows -/// when it was last updated. -#[derive(Debug, Clone, PartialEq, Eq)] -enum CardActivity { - Idle, - Running, - Scheduled(String), -} - -impl CardActivity { - fn of_command(running: bool, next_run: Option) -> Self { - // Running wins: while a command is going, its next fire time is the less interesting fact, - // and showing both would make the card jitter between two lines of text. - match (running, next_run) { - (true, _) => Self::Running, - (false, Some(next_run)) => Self::Scheduled(next_run), - (false, None) => Self::Idle, - } - } - - fn next_run(&self) -> Option<&str> { - match self { - Self::Scheduled(next_run) => Some(next_run.as_str()), - _ => None, - } - } - - fn status_line(&self, updated: &str) -> String { - match self { - Self::Running => "Running...".to_string(), - Self::Scheduled(next_run) => format!("Next run: {next_run}"), - Self::Idle => format!("Updated: {updated}"), - } - } -} - -fn sticker_type_icon(sticker_type: &StickerType) -> IconName { - match sticker_type { - StickerType::Markdown => IconName::DocumentText, - StickerType::Command => IconName::Command, - StickerType::Timer => IconName::Bell, - StickerType::Paint => IconName::Paint, - StickerType::File => IconName::DocumentText, - } -} - -fn order_label(order_by: StickerOrderBy) -> &'static str { - match order_by { - StickerOrderBy::CreatedDesc => "Created ↓", - StickerOrderBy::CreatedAsc => "Created ↑", - StickerOrderBy::UpdatedDesc => "Updated ↓", - StickerOrderBy::UpdatedAsc => "Updated ↑", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_plain_sticker_card_shows_when_it_was_updated() { - let activity = CardActivity::Idle; - - assert_eq!(activity.next_run(), None); - assert_eq!(activity.status_line("yesterday"), "Updated: yesterday"); - } - - #[test] - fn a_scheduled_command_card_shows_its_next_run() { - let activity = CardActivity::of_command(false, Some("2030-01-01 00:00:00".to_string())); - - assert_eq!(activity.next_run(), Some("2030-01-01 00:00:00")); - assert_eq!( - activity.status_line("yesterday"), - "Next run: 2030-01-01 00:00:00" - ); - } - - #[test] - fn a_running_command_card_hides_the_next_run() { - let activity = CardActivity::of_command(true, Some("2030-01-01 00:00:00".to_string())); - - assert_eq!(activity, CardActivity::Running); - assert_eq!( - activity.next_run(), - None, - "no clock icon while the spinner is up" - ); - assert_eq!(activity.status_line("yesterday"), "Running..."); - } - - #[test] - fn an_unscheduled_command_card_is_indistinguishable_from_any_other() { - assert_eq!(CardActivity::of_command(false, None), CardActivity::Idle); - } - - /// Pure arithmetic covering the keyboard-navigation logic. The real - /// \`handle_list_key\` needs GPUI context for keyboard wiring; this - /// exercises the same clamp / page-step behaviour so regressions in - /// \`move_selection_to\` show up without UI integration tests. - #[test] - fn keyboard_navigation_arithmetic_clamps_and_pages() { - fn clamp(current: usize, count: usize) -> usize { - if count == 0 { - 0 - } else { - current.min(count - 1) - } - } - fn step_up(current: usize, step: usize, count: usize) -> usize { - (current + step).min(count.saturating_sub(1)) - } - fn step_down(current: usize, step: usize) -> usize { - current.saturating_sub(step) - } - - // Empty list keeps the user on a valid index. - assert_eq!(clamp(0, 0), 0); - // Last element is a hard cap. - assert_eq!(step_up(998, 10, 1000), 999); - // Saturating subtract at the top. - assert_eq!(step_down(0, 5), 0); - assert_eq!(step_down(2, 5), 0); - assert_eq!(step_down(20, 5), 15); - // Page step equals a chunk of rows. - assert_eq!(step_up(0, PAGE_STEP, 1000), PAGE_STEP); - assert_eq!(step_down(PAGE_STEP, PAGE_STEP), 0); - } -} diff --git a/src/native/windows/main/collection.rs b/src/native/windows/main/collection.rs new file mode 100644 index 0000000..a301d61 --- /dev/null +++ b/src/native/windows/main/collection.rs @@ -0,0 +1,350 @@ +//! The collection projection and its virtualized row presentation. +use super::*; +use crate::model::content::{CommandContent, Scheduler}; + +#[derive(Clone, Debug)] +pub(super) struct CommandSummary { + pub(super) command: String, + pub(super) working_dir: String, + pub(super) cron: Option, + pub(super) armed: bool, + pub(super) accept_selection: bool, +} +impl CommandSummary { + pub(super) fn parse(content: &str) -> Option { + let content: CommandContent = serde_json::from_str(content).ok()?; + let cron = content.scheduler.map(|Scheduler::Cron(cron)| cron); + Some(Self { + command: content.command, + working_dir: content.working_dir, + armed: content.started_at.is_some() + && cron.as_ref().is_some_and(|c| !c.trim().is_empty()), + cron, + accept_selection: content.accept_selection, + }) + } + fn subtitle(&self) -> String { + let command = self.command.replace(['\r', '\n'], " "); + if self.working_dir.is_empty() { + command + } else { + format!("{command} · {}", self.working_dir) + } + } +} + +impl MainWindow { + pub(super) fn rebuild_visible(&mut self, cx: &mut Context) { + let selected = self.selected_sticker_id(); + let query = self.query.read(cx).value().to_lowercase(); + self.visible = self + .stickers + .iter() + .enumerate() + .filter_map(|(ix, sticker)| { + let command = self.commands.get(&sticker.id); + let matches_query = query.is_empty() + || sticker.title.to_lowercase().contains(&query) + || command.is_some_and(|c| { + c.command.to_lowercase().contains(&query) + || c.working_dir.to_lowercase().contains(&query) + || c.cron + .as_ref() + .is_some_and(|cron| cron.to_lowercase().contains(&query)) + }); + (matches_query && self.section.matches(sticker, command)).then_some(ix) + }) + .collect(); + self.selected_index = selected.and_then(|id| { + self.visible + .iter() + .position(|&ix| self.stickers[ix].id == id) + }); + cx.notify(); + } + pub(super) fn collection(&self, cx: &mut Context) -> gpui::AnyElement { + let count = self.visible.len(); + let view = cx.entity(); + div() + .flex_1() + .min_h_0() + .overflow_hidden() + .track_focus(&self.list_focus) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + this.handle_list_key(event, window, cx) + })) + .when(count > 0, |v| { + v.child( + uniform_list("manager-stickers", count, move |range, _, cx| { + view.update(cx, |this, cx| { + range + .filter_map(|ix| { + this.visible + .get(ix) + .and_then(|&source| this.stickers.get(source)) + .map(|s| this.sticker_card(ix, s, cx)) + }) + .collect::>() + }) + }) + .size_full() + .track_scroll(&self.list_scroll), + ) + }) + .when(count == 0, |v| { + v.child( + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_3() + .p_6() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(self.section.icon()).large()) + .child( + div() + .text_base() + .font_medium() + .text_color(cx.theme().foreground) + .child(if self.loading { + "Loading stickers…" + } else if self.error.is_some() { + "Could not load stickers" + } else if !self.query.read(cx).value().is_empty() { + "No matching stickers" + } else { + "A place for your next idea" + }), + ) + .child( + div() + .text_sm() + .child(if !self.query.read(cx).value().is_empty() { + "Try another title, command, or folder." + } else if self.section == Section::Scheduled { + "Add a cron schedule and start a command to arm it." + } else if self.section == Section::Selection { + "Enable selection input in a command’s settings." + } else { + "Create a sticker to keep something useful close." + }), + ) + .child(self.create_button(cx)), + ) + }) + .into_any_element() + } + fn sticker_card( + &self, + ix: usize, + sticker: &StickerBrief, + cx: &mut Context, + ) -> gpui::AnyElement { + let id = sticker.id; + let title = if sticker.title.is_empty() { + "Untitled sticker".to_string() + } else { + sticker.title.clone() + }; + let selected = self.selected_index == Some(ix); + let command = self.commands.get(&id); + let running = command_activity::is_running(id); + let activity = CardActivity::of_command(running, command_activity::next_run(id)); + let status = activity.status_line(if sticker.state == StickerState::Open { + "On desktop" + } else { + "Closed" + }); + let subtitle = command.map(CommandSummary::subtitle).unwrap_or_else(|| { + format!( + "{} · {}", + type_label(sticker.sticker_type), + crate::utils::time::format_unix_millis(sticker.updated_at) + ) + }); + let delete_title = title.clone(); + h_flex() + .w_full() + .id(("sticker-row", id as usize)) + .group("manager-row") + .h(gpui::rems(ROW_HEIGHT)) + .px_5() + .gap_3() + .border_b_1() + .border_color(cx.theme().border) + .bg(if selected { + cx.theme().list_active + } else { + cx.theme().background + }) + .when(!selected, |v| v.hover(|v| v.bg(cx.theme().list_hover))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.selected_index = Some(ix); + this.list_focus.focus(window, cx); + cx.notify(); + }), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |this, event: &MouseUpEvent, _, cx| { + if event.click_count >= 2 { + this.open_sticker(id, cx); + } + }), + ) + .child( + div() + .size_8() + .flex_shrink_0() + .rounded_md() + .bg(cx.theme().muted) + .flex() + .items_center() + .justify_center() + .text_color(if sticker.color == StickerColor::Gray { + cx.theme().muted_foreground + } else { + sticker.color.swatch().into() + }) + .child(Icon::new(sticker_type_icon(&sticker.sticker_type)).small()), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(div().text_sm().font_medium().truncate().child(title)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(subtitle), + ) + .when_some(command, |v, c| { + v.child( + h_flex() + .min_w_0() + .overflow_hidden() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .when_some(c.cron.as_ref(), |v, cron| { + v.child(div().flex_1().min_w_0().truncate().child(format!( + "{} · {cron}", + if c.armed { + "Scheduled" + } else { + "Schedule not armed" + } + ))) + }) + .when(c.accept_selection, |v| { + v.child(div().flex_shrink_0().child("Selection input")) + }), + ) + }), + ) + .child( + v_flex() + .items_end() + .gap_1() + .flex_shrink_0() + .w(gpui::rems(12.)) + .child( + h_flex() + .w_full() + .min_w_0() + .justify_end() + .gap_1() + .text_xs() + .text_color(if running { + cx.theme().warning + } else { + cx.theme().muted_foreground + }) + .when(running, |v| v.child(Spinner::new().small())) + .child(div().min_w_0().truncate().child(status)), + ) + .child( + h_flex() + .gap_1() + .invisible() + .group_hover("manager-row", |v| v.visible()) + .when(selected, |v| v.visible()) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_up(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Button::new(("open-sticker", id as u64)) + .small() + .ghost() + .icon(IconName::ArrowRight) + .accessibility_label("Open sticker") + .tooltip("Open sticker (Enter)") + .on_click( + cx.listener(move |this, _, _, cx| { + this.open_sticker(id, cx) + }), + ), + ) + .child( + Button::new(("delete-sticker", id as u64)) + .small() + .ghost() + .icon(IconName::Delete) + .accessibility_label("Delete sticker") + .tooltip("Delete sticker (Delete)") + .on_click(cx.listener(move |this, _, window, cx| { + this.delete_sticker(id, delete_title.clone(), window, cx); + })), + ), + ), + ) + .into_any_element() + } +} +fn type_label(kind: StickerType) -> &'static str { + match kind { + StickerType::Markdown => "Text", + StickerType::Command => "Command", + StickerType::Timer => "Timer", + StickerType::Paint => "Sketch", + StickerType::File => "File", + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn malformed_content_never_claims_automation_state() { + assert!(CommandSummary::parse("not json").is_none()); + } + #[test] + fn a_schedule_needs_an_arming_timestamp_and_expression() { + let mut c = CommandContent { + command: "echo hi".into(), + scheduler: Some(Scheduler::Cron("0 * * * * *".into())), + ..Default::default() + }; + assert!( + !CommandSummary::parse(&serde_json::to_string(&c).unwrap()) + .unwrap() + .armed + ); + c.started_at = Some(123); + assert!( + CommandSummary::parse(&serde_json::to_string(&c).unwrap()) + .unwrap() + .armed + ); + c.scheduler = Some(Scheduler::Cron(" ".into())); + assert!( + !CommandSummary::parse(&serde_json::to_string(&c).unwrap()) + .unwrap() + .armed + ); + } +} diff --git a/src/native/windows/main/mod.rs b/src/native/windows/main/mod.rs new file mode 100644 index 0000000..77f2eef --- /dev/null +++ b/src/native/windows/main/mod.rs @@ -0,0 +1,758 @@ +//! The manager owns collection navigation, refresh, and sticker lifecycle commands. +mod collection; +mod navigation; +mod settings; + +use crate::{ + gpui_component::{ + Root, + alert::Alert, + button::{Button, ButtonVariants}, + input::{Input, InputEvent, InputState}, + menu::{DropdownMenu, PopupMenuItem}, + spinner::Spinner, + *, + }, + model::sticker::*, + native::{ + components::{ + IconName, + stickers::{ + Sticker, + command::{CommandSticker, activity as command_activity}, + file::FileSticker, + markdown::MarkdownSticker, + paint::PaintSticker, + timer::TimerSticker, + }, + }, + windows::{StickerWindowEvent, sticker::StickerWindow, text}, + }, + storage::ArcStickerStore, +}; +use collection::CommandSummary; +use gpui::{ + AnyWindowHandle, App, AsyncApp, Bounds, Context, Entity, FocusHandle, Focusable, IntoElement, + KeyDownEvent, MouseButton, MouseUpEvent, Render, ScrollStrategy, UniformListScrollHandle, + WeakEntity, Window, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowOptions, + div, prelude::*, px, size, uniform_list, +}; +use navigation::Section; +use std::{collections::HashMap, sync::mpsc, time::Duration}; + +const STICKER_LOAD_LIMIT: i64 = 10000; +const STICKER_EVENT_PUMP_INTERVAL: Duration = Duration::from_millis(120); +/// Virtual rows scale with Root's rem size; all types reserve the same three-line budget. +const ROW_HEIGHT: f32 = 5.5; +const PAGE_STEP: usize = 6; + +pub struct MainWindow { + store: ArcStickerStore, + sticker_events_sender: mpsc::Sender, + query: Entity, + order: StickerOrderBy, + stickers: Vec, + commands: HashMap, + /// Source coordinates; selection and scrolling use coordinates within this projection. + visible: Vec, + section: Section, + selected_index: Option, + list_focus: FocusHandle, + list_scroll: UniformListScrollHandle, + loading: bool, + error: Option, + load_request_token: u64, + creating: bool, + clipboard_settings: Option, + password_configured: Option, + settings_busy: bool, + settings_revision: u64, + shortcuts: [Entity; 4], + shortcuts_dirty: bool, + shortcuts_busy: bool, + shortcuts_error: Option, +} + +impl MainWindow { + pub fn open( + cx: &mut App, + rx: mpsc::Receiver, + tx: mpsc::Sender, + store: ArcStickerStore, + ) -> anyhow::Result { + // WindowOptions takes logical pixels; the platform handles display scaling. + let bounds = Bounds::centered(None, size(px(820.), px(620.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(560.), px(480.))), + window_background: WindowBackgroundAppearance::Transparent, + titlebar: None, + ..Default::default() + }, + |window, cx| { + let view = cx.new(|cx| Self::new(window, cx, rx, tx, store)); + // Reapply logical size: borderless Windows creation can initially + // size in physical pixels while GPUI lays out logical pixels. + #[cfg(target_os = "windows")] + window.resize(bounds.size); + cx.new(|cx| Root::new(view, window, cx)) + }, + ) + .map(Into::into) + } + fn new( + window: &mut Window, + cx: &mut Context, + rx: mpsc::Receiver, + tx: mpsc::Sender, + store: ArcStickerStore, + ) -> Self { + let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search stickers…")); + let bindings = crate::native::hotkey::bindings(); + let shortcuts = std::array::from_fn(|ix| { + cx.new(|cx| InputState::new(window, cx).default_value(bindings[ix].clone())) + }); + for input in &shortcuts { + cx.subscribe(input, |this, _, event: &InputEvent, cx| match event { + InputEvent::Change => { + this.shortcuts_dirty = true; + this.shortcuts_error = None; + cx.notify(); + } + InputEvent::PressEnter { .. } => this.save_shortcuts(cx), + _ => {} + }) + .detach(); + } + window.on_window_should_close(cx, Self::request_quit); + cx.subscribe(&query, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.rebuild_visible(cx); + } + }) + .detach(); + cx.spawn(async move |this, cx| { + let _ = this.update(cx, |this, cx| this.spawn_load_stickers(cx)); + Self::loop_events(this, rx, cx).await; + }) + .detach(); + Self { + store, + sticker_events_sender: tx, + query, + order: StickerOrderBy::UpdatedDesc, + stickers: Vec::new(), + commands: HashMap::new(), + visible: Vec::new(), + section: Section::All, + selected_index: None, + list_focus: cx.focus_handle().tab_index(1).tab_stop(true), + list_scroll: UniformListScrollHandle::default(), + loading: false, + error: None, + load_request_token: 0, + creating: false, + clipboard_settings: None, + password_configured: None, + settings_busy: false, + settings_revision: 0, + shortcuts, + shortcuts_dirty: false, + shortcuts_busy: false, + shortcuts_error: None, + } + } + fn request_quit(window: &mut Window, cx: &mut App) -> bool { + if text::password::is_rotating() || text::password::has_dirty_editors() { + window.open_alert_dialog(cx, |dialog, _, _| dialog.title("Finish encrypted text edits").child("Save or discard encrypted drafts and wait for password changes before quitting.")); + false + } else { + text::clear_owned_secret(); + cx.quit(); + true + } + } + async fn loop_events( + this: WeakEntity, + rx: mpsc::Receiver, + cx: &mut AsyncApp, + ) { + let mut activity = command_activity::generation(); + let mut text_version = -1; + loop { + cx.background_executor() + .timer(STICKER_EVENT_PUMP_INTERVAL) + .await; + let events: Vec<_> = rx.try_iter().collect(); + let generation = command_activity::generation(); + let activity_changed = generation != activity; + activity = generation; + let version = if let Some(repo) = text::text_repository() { + repo.data_version().await.unwrap_or(text_version) + } else { + text_version + }; + let text_changed = version != text_version; + text_version = version; + if events.is_empty() && !activity_changed && !text_changed { + if this.update(cx, |_, _| {}).is_err() { + break; + } + continue; + } + if this + .update(cx, |this, cx| { + let reload = text_changed + || events + .iter() + .any(|e| matches!(e, StickerWindowEvent::Created { .. })); + for event in events { + match event { + StickerWindowEvent::TitleChanged { id, title } => { + if let Some(s) = this.stickers.iter_mut().find(|s| s.id == id) { + s.title = title; + } + } + StickerWindowEvent::ColorChanged { id, color } => { + if let Some(s) = this.stickers.iter_mut().find(|s| s.id == id) { + s.color = color; + } + } + StickerWindowEvent::Closed { id } => { + if let Some(s) = this.stickers.iter_mut().find(|s| s.id == id) { + s.state = StickerState::Close; + } + } + StickerWindowEvent::Created { .. } => {} + } + } + if reload { + this.spawn_load_stickers(cx); + if this.section == Section::Settings && !this.settings_busy { + this.load_settings(cx); + } + } else { + this.rebuild_visible(cx); + } + }) + .is_err() + { + break; + } + } + } + fn spawn_load_stickers(&mut self, cx: &mut Context) { + self.load_request_token = self.load_request_token.wrapping_add(1); + let token = self.load_request_token; + let store = self.store.clone(); + let order = self.order; + self.loading = true; + cx.notify(); + cx.spawn(async move |entity, cx| { + let result = async { + let stickers = store + .query_stickers(None, order, STICKER_LOAD_LIMIT, 0) + .await?; + let commands = store.get_command_stickers().await?; + anyhow::Ok((stickers, commands)) + } + .await; + let _ = entity.update(cx, |this, cx| { + if token != this.load_request_token { + return; + } + this.loading = false; + match result { + Ok((stickers, commands)) => { + let selected = this.selected_sticker_id(); + this.stickers = stickers; + this.commands = commands + .into_iter() + .filter_map(|s| CommandSummary::parse(&s.content).map(|c| (s.id, c))) + .collect(); + this.rebuild_visible(cx); + this.selected_index = selected.and_then(|id| { + this.visible + .iter() + .position(|&ix| this.stickers[ix].id == id) + }); + } + Err(error) => this.error = Some(format!("Could not load stickers: {error}")), + } + cx.notify(); + }); + }) + .detach(); + } + fn create_sticker(&mut self, cx: &mut Context, kind: &StickerType) { + if self.creating { + return; + } + let size = match kind { + StickerType::Markdown => MarkdownSticker::default_window_size(), + StickerType::Command => CommandSticker::default_window_size(), + StickerType::Timer => TimerSticker::default_window_size(), + StickerType::Paint => PaintSticker::default_window_size(), + StickerType::File => FileSticker::default_window_size(), + }; + let title = match kind { + StickerType::Markdown => "New text sticker", + StickerType::Command => "New command sticker", + StickerType::Timer => "New timer sticker", + StickerType::Paint => "New sketch", + StickerType::File => "New file sticker", + }; + let detail = StickerDetail { + id: 0, + title: title.into(), + content: String::new(), + color: StickerColor::Gray, + sticker_type: *kind, + state: StickerState::Open, + left: 100, + top: 100, + width: size.width, + height: size.height, + top_most: false, + created_at: 0, + updated_at: 0, + display_id: None, + display_uuid: None, + virtual_desktop_id: None, + native_left: None, + native_top: None, + native_width: None, + native_height: None, + preferred_display_uuid: None, + placements: Vec::new(), + }; + self.creating = true; + self.error = None; + cx.notify(); + let store = self.store.clone(); + let tx = self.sticker_events_sender.clone(); + cx.spawn(async move |entity, cx| { + let result = async { + let id = store.insert_sticker(detail).await?; + let _ = tx.send(StickerWindowEvent::Created { id }); + StickerWindow::open_async(cx, tx, store, id).await + } + .await; + let _ = entity.update(cx, |this, cx| { + this.creating = false; + if let Err(error) = result { + this.error = Some(format!("Could not create or open sticker: {error}")); + } + cx.notify(); + }); + }) + .detach(); + } + /// One pointer/keyboard opening path, with honest rollback of optimistic state. + fn open_sticker(&mut self, id: i64, cx: &mut Context) { + let prior = self.stickers.iter().find(|s| s.id == id).map(|s| s.state); + if let Some(s) = self.stickers.iter_mut().find(|s| s.id == id) { + s.state = StickerState::Open; + cx.notify(); + } + let store = self.store.clone(); + let tx = self.sticker_events_sender.clone(); + cx.spawn(async move |entity, cx| { + if let Err(error) = + StickerWindow::open_async_with_options(cx, tx, store, id, true).await + { + let _ = entity.update(cx, |this, cx| { + if let Some(s) = this.stickers.iter_mut().find(|s| s.id == id) + && let Some(prior) = prior + { + s.state = prior; + } + this.error = Some(format!("Could not open sticker: {error}")); + cx.notify(); + }); + } + }) + .detach(); + } + fn selected_sticker_id(&self) -> Option { + self.selected_index + .and_then(|ix| self.visible.get(ix)) + .and_then(|&ix| self.stickers.get(ix)) + .map(|s| s.id) + } + fn handle_list_key( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if window.has_active_dialog(cx) + || !self.list_focus.is_focused(window) + || self.visible.is_empty() + { + return; + } + let count = self.visible.len(); + let current = self.selected_index.unwrap_or(0); + let key = event.keystroke.key.to_lowercase(); + let next = match key.as_str() { + "down" => Some(if self.selected_index.is_none() { + 0 + } else { + current.saturating_add(1) + }), + "up" => Some(current.saturating_sub(1)), + "home" => Some(0), + "end" => Some(count - 1), + "pagedown" => Some(current.saturating_add(PAGE_STEP)), + "pageup" => Some(current.saturating_sub(PAGE_STEP)), + _ => None, + }; + if let Some(next) = next { + let next = next.min(count - 1); + self.selected_index = Some(next); + self.list_scroll + .scroll_to_item(next, ScrollStrategy::Nearest); + cx.notify(); + } else if key == "enter" { + if let Some(id) = self.selected_sticker_id() { + self.open_sticker(id, cx); + } + } else if key == "delete" { + if let Some(id) = self.selected_sticker_id() + && let Some(sticker) = self.stickers.iter().find(|s| s.id == id) + { + self.delete_sticker(id, sticker.title.clone(), window, cx); + } + } else { + return; + } + cx.stop_propagation(); + window.prevent_default(); + } + fn delete_sticker( + &mut self, + id: i64, + title: String, + window: &mut Window, + cx: &mut Context, + ) { + let weak = cx.entity().downgrade(); + let store = self.store.clone(); + window.open_alert_dialog(cx, move |dialog, _, _| { + let store = store.clone(); + let weak = weak.clone(); + dialog + .title(format!("Delete “{title}”?")) + .child("This permanently removes the sticker and its saved content.") + .confirm() + .ok_text("Delete") + .ok_variant(crate::gpui_component::button::ButtonVariant::Danger) + .on_ok(move |_, _, cx| { + let store = store.clone(); + let weak = weak.clone(); + cx.spawn(async move |cx| { + let result = store.delete_sticker(id).await; + let _ = weak.update(cx, |this, cx| { + match result { + Ok(()) => { + this.load_request_token = + this.load_request_token.wrapping_add(1); + this.loading = false; + StickerWindow::try_close(id, cx); + this.selected_index = None; + this.stickers.retain(|s| s.id != id); + this.commands.remove(&id); + this.rebuild_visible(cx); + } + Err(error) => { + this.error = Some(format!("Could not delete sticker: {error}")) + } + } + cx.notify(); + }); + }) + .detach(); + true + }) + }); + } + fn sort_button(&self, cx: &mut Context) -> gpui::AnyElement { + let owner = cx.entity(); + Button::new("sort") + .small() + .ghost() + .icon(match self.order { + StickerOrderBy::CreatedAsc | StickerOrderBy::UpdatedAsc => IconName::SortAscending, + _ => IconName::SortDescending, + }) + .accessibility_label("Sort stickers") + .tooltip("Sort stickers") + .dropdown_menu(move |mut menu, window, cx| { + let selected = owner.read(cx).order; + for order in [ + StickerOrderBy::UpdatedDesc, + StickerOrderBy::CreatedDesc, + StickerOrderBy::CreatedAsc, + StickerOrderBy::UpdatedAsc, + ] { + menu = menu.item( + PopupMenuItem::new(order_label(order)) + .checked(selected == order) + .on_click(window.listener_for(&owner, move |this, _, _, cx| { + this.order = order; + this.spawn_load_stickers(cx); + })), + ); + } + menu + }) + .into_any_element() + } + fn create_button(&self, cx: &mut Context) -> gpui::AnyElement { + let owner = cx.entity(); + Button::new("create") + .small() + .label("New sticker…") + .icon(IconName::Plus) + .disabled(self.creating) + .dropdown_menu(move |mut menu, window, _| { + for (label, kind) in [ + ("Text", StickerType::Markdown), + ("Command", StickerType::Command), + ("Timer", StickerType::Timer), + ("Sketch", StickerType::Paint), + ] { + menu = menu.item( + PopupMenuItem::new(label) + .icon(sticker_type_icon(&kind)) + .on_click(window.listener_for(&owner, move |this, _, _, cx| { + this.create_sticker(cx, &kind) + })), + ); + } + menu + }) + .into_any_element() + } + fn title_bar(&self, cx: &mut Context) -> gpui::AnyElement { + h_flex() + .min_h(gpui::rems(4.5)) + .py_3() + .flex_shrink_0() + .px_5() + .gap_3() + .border_b_1() + .border_color(cx.theme().border) + .window_control_area(WindowControlArea::Drag) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(div().text_lg().font_semibold().child(self.section.label())) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.section.description()), + ), + ) + .child( + h_flex() + .occlude() + .gap_1() + .child( + Button::new("refresh-manager") + .small() + .ghost() + .occlude() + .icon(IconName::Refresh) + .accessibility_label("Refresh stickers and settings") + .tooltip("Refresh stickers and settings") + .disabled(self.loading || self.settings_busy) + .on_click(cx.listener(|this, _, _, cx| { + this.error = None; + this.spawn_load_stickers(cx); + if this.section == Section::Settings { + this.load_settings(cx); + } + })), + ) + .child( + Button::new("minimize") + .small() + .ghost() + .occlude() + .icon(IconName::Minus) + .accessibility_label("Minimize") + .tooltip("Minimize") + .on_click(|_, window, _| window.minimize_window()), + ) + .child( + Button::new("close") + .small() + .ghost() + .occlude() + .icon(IconName::Close) + .accessibility_label("Quit Rustickers") + .tooltip("Quit Rustickers") + .on_click(|_, window, cx| { + Self::request_quit(window, cx); + }), + ), + ) + .into_any_element() + } + fn toolbar(&self, cx: &mut Context) -> gpui::AnyElement { + v_flex() + .flex_shrink_0() + .px_5() + .py_3() + .when(self.section != Section::Settings, |v| { + v.child( + h_flex() + .gap_2() + .child( + div().flex_1().min_w_0().child( + Input::new(&self.query) + .small() + .cleanable(true) + .prefix(Icon::new(IconName::Search)) + .tab_index(0), + ), + ) + .child(self.sort_button(cx)) + .child(self.create_button(cx)), + ) + }) + .into_any_element() + } +} +impl Render for MainWindow { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let compact = window.bounds().size.width < window.rem_size() * 48.; + h_flex() + .items_stretch() + .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if window.has_active_dialog(cx) { + return; + } + if this.section != Section::Settings + && (event.keystroke.modifiers.control || event.keystroke.modifiers.platform) + && event.keystroke.key.eq_ignore_ascii_case("f") + { + this.query.focus_handle(cx).focus(window, cx); + cx.stop_propagation(); + } + })) + .child(self.navigation(compact, window, cx)) + .child( + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child(self.title_bar(cx)) + .when(self.section != Section::Settings, |v| v.child(self.toolbar(cx))) + .when_some(self.error.as_ref(), |v, error| { + v.child( + div() + .px_5() + .pb_3() + .child(Alert::error("manager-error", error.as_str())), + ) + }) + .child(if self.section == Section::Settings { + self.settings_view(cx) + } else { + self.collection(cx) + }) + .child( + h_flex() + .h_9() + .flex_shrink_0() + .px_5() + .gap_2() + .border_t_1() + .border_color(cx.theme().border) + .text_xs() + .text_color(cx.theme().muted_foreground) + .when(self.loading, |v| v.child(Spinner::new().small())) + .child(if self.section == Section::Settings { + "Changes apply immediately".into() + } else { + format!( + "{} stickers · {} on desktop", + self.visible.len(), + self.visible + .iter() + .filter(|&&ix| self.stickers[ix].state == StickerState::Open) + .count() + ) + }) + .child(div().flex_1()) + .when(self.section != Section::Settings, |v| { + v.child("↑ ↓ navigate · Enter opens") + }), + ), + ) + } +} +fn sticker_type_icon(kind: &StickerType) -> IconName { + match kind { + StickerType::Markdown => IconName::DocumentText, + StickerType::Command => IconName::Command, + StickerType::Timer => IconName::Bell, + StickerType::Paint => IconName::Paint, + StickerType::File => IconName::Folder, + } +} +fn order_label(order: StickerOrderBy) -> &'static str { + match order { + StickerOrderBy::CreatedDesc => "Newest created", + StickerOrderBy::CreatedAsc => "Oldest created", + StickerOrderBy::UpdatedDesc => "Recently updated", + StickerOrderBy::UpdatedAsc => "Least recently updated", + } +} +#[derive(Debug, PartialEq, Eq)] +enum CardActivity { + Idle, + Running, + Scheduled(String), +} +impl CardActivity { + fn of_command(running: bool, next: Option) -> Self { + match (running, next) { + (true, _) => Self::Running, + (_, Some(next)) => Self::Scheduled(next), + _ => Self::Idle, + } + } + fn status_line(&self, fallback: &str) -> String { + match self { + Self::Running => "Running".into(), + Self::Scheduled(next) => format!("Next: {next}"), + Self::Idle => fallback.into(), + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn running_status_takes_priority_over_schedule() { + assert_eq!( + CardActivity::of_command(true, Some("tomorrow".into())), + CardActivity::Running + ); + } + #[test] + fn idle_status_preserves_the_object_state() { + assert_eq!(CardActivity::Idle.status_line("Closed"), "Closed"); + } +} diff --git a/src/native/windows/main/navigation.rs b/src/native/windows/main/navigation.rs new file mode 100644 index 0000000..63c3ef4 --- /dev/null +++ b/src/native/windows/main/navigation.rs @@ -0,0 +1,235 @@ +//! Manager destinations and the small, shared filter policy. +use super::*; +use crate::gpui_component::sidebar::{ + Sidebar, SidebarGroup, SidebarItem, SidebarMenu, SidebarMenuItem, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum Section { + #[default] + All, + Text, + Commands, + Timers, + Sketches, + Files, + Scheduled, + Selection, + Settings, +} +impl Section { + pub(super) const COLLECTIONS: [Self; 6] = [ + Self::All, + Self::Text, + Self::Commands, + Self::Timers, + Self::Sketches, + Self::Files, + ]; + pub(super) fn label(self) -> &'static str { + match self { + Self::All => "All stickers", + Self::Text => "Text", + Self::Commands => "Commands", + Self::Timers => "Timers", + Self::Sketches => "Sketches", + Self::Files => "Files", + Self::Scheduled => "Scheduled", + Self::Selection => "Selection commands", + Self::Settings => "Settings", + } + } + pub(super) fn description(self) -> &'static str { + match self { + Self::All => "Your desktop tools, together in one place.", + Self::Text => "Notes and protected text. Clipboard history lives in TXTs.", + Self::Commands => "Commands, output, and the routines you rely on.", + Self::Timers => "A little structure for your time.", + Self::Sketches => "A space to think in strokes.", + Self::Files => "Local files, without changing the source.", + Self::Scheduled => "Armed routines keep running with their windows closed.", + Self::Selection => "Send selected text to these commands with your selection shortcut.", + Self::Settings => "Privacy, collection, and the shortcuts that keep you fast.", + } + } + pub(super) fn kind(self) -> Option { + match self { + Self::Text => Some(StickerType::Markdown), + Self::Commands | Self::Scheduled | Self::Selection => Some(StickerType::Command), + Self::Timers => Some(StickerType::Timer), + Self::Sketches => Some(StickerType::Paint), + Self::Files => Some(StickerType::File), + _ => None, + } + } + pub(super) fn icon(self) -> IconName { + match self { + Self::All => IconName::Folder, + Self::Scheduled => IconName::Loop, + Self::Selection => IconName::Forward, + Self::Settings => IconName::Adjustments, + _ => sticker_type_icon(&self.kind().unwrap_or(StickerType::Markdown)), + } + } + pub(super) fn matches(self, sticker: &StickerBrief, command: Option<&CommandSummary>) -> bool { + match self { + Self::Settings => false, + Self::Scheduled => command.is_some_and(|c| c.armed), + Self::Selection => command.is_some_and(|c| c.accept_selection), + _ => self.kind().is_none_or(|kind| kind == sticker.sticker_type), + } + } +} + +impl MainWindow { + pub(super) fn select_section( + &mut self, + section: Section, + window: &mut Window, + cx: &mut Context, + ) { + self.section = section; + self.query + .update(cx, |input, cx| input.set_value("", window, cx)); + self.selected_index = None; + self.rebuild_visible(cx); + self.list_scroll.scroll_to_item(0, ScrollStrategy::Top); + if section == Section::Settings { + self.load_settings(cx); + } + cx.notify(); + } + pub(super) fn navigation( + &self, + compact: bool, + window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + let item = + |section: Section, cx: &mut Context| { + let count = self + .stickers + .iter() + .filter(|s| section.matches(s, self.commands.get(&s.id))) + .count(); + SidebarMenuItem::new(section.label()) + .icon(section.icon()) + .active(self.section == section) + .suffix(move |_, cx| { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(count.to_string()) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_section(section, window, cx) + })) + }; + Sidebar::new("manager-navigation") + .w(gpui::rems(13.)) + .collapsed(compact) + .header( + h_flex() + .h_10() + .gap_2() + .child(gpui::img("icon.png").size_6().flex_shrink_0()) + .when(!compact, |v| { + v.child(div().font_semibold().child("Rustickers")) + }), + ) + .child(SidebarGroup::new("Library").child( + SidebarMenu::new().children(Section::COLLECTIONS.into_iter().map(|s| item(s, cx))), + )) + .child( + SidebarGroup::new("Automation").child( + SidebarMenu::new() + .child(item(Section::Scheduled, cx)) + .child(item(Section::Selection, cx)), + ), + ) + .footer( + v_flex() + .w_full() + .gap_2() + .child( + Button::new("manager-txts") + .ghost() + .icon(IconName::Copy) + .when(!compact, |v| v.label("Open TXTs…")) + .accessibility_label("Open TXTs") + .tooltip("Saved text and clipboard history") + .on_click(cx.listener(|this, _, _, cx| { + if let Err(error) = super::super::text::open_from_main( + cx, + this.sticker_events_sender.clone(), + ) { + this.error = Some(format!("Could not open TXTs: {error}")); + cx.notify(); + } + })), + ) + .child( + SidebarMenu::new() + .collapsed(compact) + .child( + SidebarMenuItem::new("Settings") + .icon(IconName::Adjustments) + .collapsed(compact) + .active(self.section == Section::Settings) + .on_click(cx.listener(|this, _, window, cx| { + this.select_section(Section::Settings, window, cx) + })), + ) + .render("manager-settings-menu", window, cx), + ), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn sticker(kind: StickerType) -> StickerBrief { + StickerBrief { + id: 1, + title: "Sample".into(), + state: StickerState::Close, + color: StickerColor::Gray, + sticker_type: kind, + created_at: 0, + updated_at: 0, + } + } + #[test] + fn library_filters_are_type_specific_and_include_closed_stickers() { + for section in Section::COLLECTIONS { + for kind in [ + StickerType::Markdown, + StickerType::Command, + StickerType::Timer, + StickerType::Paint, + StickerType::File, + ] { + assert_eq!( + section.matches(&sticker(kind), None), + section.kind().is_none_or(|k| k == kind) + ); + } + } + } + #[test] + fn automation_filters_require_the_actual_command_configuration() { + let c = CommandSummary { + command: "echo hello".into(), + working_dir: String::new(), + cron: Some("0 * * * * *".into()), + armed: false, + accept_selection: true, + }; + assert!(!Section::Scheduled.matches(&sticker(StickerType::Command), Some(&c))); + assert!(Section::Selection.matches(&sticker(StickerType::Command), Some(&c))); + assert!(!Section::Scheduled.matches(&sticker(StickerType::Command), None)); + assert!(!Section::Settings.matches(&sticker(StickerType::Markdown), None)); + } +} diff --git a/src/native/windows/main/settings.rs b/src/native/windows/main/settings.rs new file mode 100644 index 0000000..584146f --- /dev/null +++ b/src/native/windows/main/settings.rs @@ -0,0 +1,159 @@ +//! Manager settings reuse the text service and existing authorization dialogs. +use super::*; +use crate::gpui_component::{scroll::ScrollableElement, switch::Switch}; +use crate::native::windows::text; + +impl MainWindow { + pub(super) fn save_shortcuts(&mut self, cx: &mut Context) { + if self.shortcuts_busy || !self.shortcuts_dirty { + return; + } + let values = std::array::from_fn(|ix| self.shortcuts[ix].read(cx).value().to_string()); + self.shortcuts_busy = true; + self.shortcuts_error = None; + cx.notify(); + cx.spawn(async move |entity, cx| { + let result = cx + .background_executor() + .spawn(async move { crate::native::hotkey::save_bindings(values) }) + .await; + let _ = entity.update(cx, |this, cx| { + this.shortcuts_busy = false; + match result { + Ok(()) => this.shortcuts_dirty = false, + Err(error) => this.shortcuts_error = Some(format!("{error:#}")), + } + cx.notify(); + }); + }) + .detach(); + } + fn reset_shortcuts(&mut self, window: &mut Window, cx: &mut Context) { + if self.shortcuts_busy { + return; + } + let values = crate::native::hotkey::default_bindings(); + for (input, value) in self.shortcuts.iter().zip(values) { + input.update(cx, |input, cx| input.set_value(value, window, cx)); + } + self.shortcuts_dirty = true; + self.shortcuts_error = None; + cx.notify(); + } + fn shortcuts_view(&self, cx: &mut Context) -> gpui::AnyElement { + v_flex().gap_3().border_t_1().border_color(cx.theme().border).pt_5() + .child(heading("Global shortcuts")) + .child(help("Use Ctrl, Alt, Shift, a letter, digit, F1–F12, or Space. On macOS, Ctrl also matches Cmd.")) + .children(["Show manager", "Open TXTs", "Quick file preview", "Selection command"].into_iter().enumerate().map(|(ix, label)| { + v_flex().gap_1() + .child(div().text_sm().child(label)) + .child(Input::new(&self.shortcuts[ix]).small().disabled(self.shortcuts_busy)) + })) + .child(help("Only quick file preview may use modifiers alone; it fires on release. Avoid shortcuts already used by other apps.")) + .when_some(self.shortcuts_error.as_ref(), |v, error| v.child(div().text_xs().text_color(cx.theme().danger).child(error.clone()))) + .child(h_flex().gap_2() + .child(Button::new("save-global-shortcuts").small().primary() + .label(if self.shortcuts_busy { "Saving…" } else if self.shortcuts_dirty { "Save shortcuts" } else { "Shortcuts saved" }) + .disabled(self.shortcuts_busy || !self.shortcuts_dirty) + .on_click(cx.listener(|this, _, _, cx| this.save_shortcuts(cx)))) + .child(Button::new("reset-global-shortcuts").small().ghost().label("Use defaults") + .disabled(self.shortcuts_busy) + .on_click(cx.listener(|this, _, window, cx| this.reset_shortcuts(window, cx))))) + .into_any_element() + } + pub(super) fn load_settings(&mut self, cx: &mut Context) { + let Some(repo) = text::text_repository() else { + return; + }; + self.settings_revision = self.settings_revision.wrapping_add(1); + let revision = self.settings_revision; + cx.spawn(async move |entity, cx| { + let result = + async { anyhow::Ok((repo.settings().await?, repo.master_key().await?.is_some())) } + .await; + let _ = entity.update(cx, |this, cx| { + if revision != this.settings_revision { + return; + } + match result { + Ok((settings, configured)) => { + this.clipboard_settings = Some(settings); + this.password_configured = Some(configured); + } + Err(_) => this.error = Some("Could not load settings. Try again.".into()), + } + cx.notify(); + }); + }) + .detach(); + } + fn set_capture(&mut self, enabled: bool, cx: &mut Context) { + if self.settings_busy { + return; + } + let Some(repo) = text::text_repository() else { + return; + }; + self.settings_busy = true; + self.settings_revision = self.settings_revision.wrapping_add(1); + cx.notify(); + cx.spawn(async move |entity, cx| { + let result = async { + let mut settings = repo.settings().await?; + settings.capture_enabled = enabled; + repo.update_settings(&settings).await?; + anyhow::Ok(settings) + } + .await; + let _ = entity.update(cx, |this, cx| { + this.settings_busy = false; + this.settings_revision = this.settings_revision.wrapping_add(1); + match result { + Ok(settings) => { + this.clipboard_settings = Some(settings); + this.error = None; + } + Err(_) => { + this.error = + Some("Could not update clipboard collection. Try again.".into()) + } + } + cx.notify(); + }); + }) + .detach(); + } + pub(super) fn settings_view(&self, cx: &mut Context) -> gpui::AnyElement { + let settings = self.clipboard_settings.as_ref(); + div().flex_1().min_h_0().overflow_y_scrollbar().child(v_flex().p_5().gap_6() + .child(v_flex().gap_3() + .child(heading("Text protection")) + .child(h_flex().gap_4().items_start() + .child(v_flex().flex_1().min_w_0().gap_1() + .child(div().text_sm().font_medium().child(match self.password_configured { Some(true) => "Master password configured", Some(false) => "No master password", None => "Checking protection…" })) + .child(help("Encryption needs no password. Each unlock is authorized separately."))) + .child(Button::new("manager-password").small().label(if self.password_configured == Some(true) { "Change password…" } else { "Set password…" }) + .disabled(self.password_configured.is_none() || text::password::is_rotating()) + .on_click(|_, window, cx| { if let Some(repo) = text::text_repository() { text::password::settings(window, cx, repo); } }))) + .child(help("Titles remain public and searchable. There is no forgotten-password recovery."))) + .child(v_flex().gap_3().border_t_1().border_color(cx.theme().border).pt_5() + .child(heading("Clipboard history")) + .child(h_flex().gap_4() + .child(v_flex().flex_1().min_w_0().gap_1().child(div().text_sm().font_medium().child("Collect copied text")).child(help("Disposable captures appear in TXTs. Saved stickers are kept separately."))) + .child(Switch::new("manager-capture").accessibility_label("Collect copied text").checked(settings.is_some_and(|s| s.capture_enabled)).disabled(self.settings_busy || settings.is_none()) + .on_change(cx.listener(|this, enabled, _, cx| this.set_capture(*enabled, cx))))) + .when_some(settings, |v, s| v.child(help(format!("History budget: {} captures / {} MiB. Collection does not erase OS clipboard history.", s.normal_count_limit, s.normal_byte_limit / (1024 * 1024)))))) + .child(self.shortcuts_view(cx)) + .child(v_flex().gap_2().border_t_1().border_color(cx.theme().border).pt_5() + .child(heading("Local data")) + .child(help("Stickers and captures share a local SQLite database. Protect database backups too.")) + .child(help(format!("Rustickers {}", env!("CARGO_PKG_VERSION")))))) + .into_any_element() + } +} +fn heading(label: &'static str) -> gpui::Div { + div().text_sm().font_semibold().child(label) +} +fn help(label: impl Into) -> gpui::Div { + div().text_xs().child(label.into()) +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index cbe0ac9..5ef5a35 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -57,6 +57,13 @@ pub trait StickerStore: Send + Sync { /// Get command stickers with accept_selection=true, ordered by LRU (most recently used first). async fn get_accept_selection_stickers(&self) -> anyhow::Result>; + /// Every Command sticker, open or closed, for the redesigned manager. + /// + /// A plain projection: content is returned exactly as stored, even when it is not valid + /// JSON, and placements are loaded alongside each sticker. Ordering is deterministic: + /// most recently updated first, ties broken by descending id. + async fn get_command_stickers(&self) -> anyhow::Result>; + /// Command stickers armed with a cron expression, whether their window is open or not. /// /// This is what lets a schedule keep ticking after the sticker has been closed. diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 5116232..93e63e0 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -548,6 +548,26 @@ ORDER BY l.last_used_at DESC NULLS LAST, s.updated_at DESC, s.id DESC"# Ok(rows) } + async fn get_command_stickers(&self) -> anyhow::Result> { + // No `json_valid` guard here, unlike the accept-selection query: the manager must show + // every command sticker, malformed or raw content included, exactly as stored. + let mut rows = sqlx::query_as::<_, StickerDetail>( + r#"SELECT s.id, s.title, s.state, s.left, s.top, s.width, s.height, s.top_most, s.color, s.type, s.content, s.created_at, s.updated_at, s.display_id, s.display_uuid, s.virtual_desktop_id, s.native_left, s.native_top, s.native_width, s.native_height, s.preferred_display_uuid +FROM stickers s +WHERE s.type = 'command' +ORDER BY s.updated_at DESC, s.id DESC"#, + ) + .fetch_all(&self.pool) + .await + .context("query command stickers")?; + + for row in rows.iter_mut() { + row.placements = self.get_sticker_placements(row.id).await?; + } + + Ok(rows) + } + async fn get_scheduled_command_stickers(&self) -> anyhow::Result> { // `started_at` is the arming flag and `scheduler.Cron` the expression; a sticker needs // both to be due for a background run. `state` is deliberately not filtered on, that is diff --git a/tests/manager_commands.rs b/tests/manager_commands.rs new file mode 100644 index 0000000..afb6b21 --- /dev/null +++ b/tests/manager_commands.rs @@ -0,0 +1,215 @@ +use rustickers::model::sticker::*; +use rustickers::storage::StickerStore; +use rustickers::storage::sqlite::SqliteStore; + +fn detail(sticker_type: StickerType, state: StickerState, content: &str) -> StickerDetail { + StickerDetail { + id: 0, + title: "t".into(), + state, + left: 10, + top: 20, + width: 210, + height: 114, + top_most: false, + color: StickerColor::Yellow, + sticker_type, + content: content.into(), + created_at: 0, + updated_at: 0, + display_id: None, + display_uuid: None, + virtual_desktop_id: None, + native_left: None, + native_top: None, + native_width: None, + native_height: None, + preferred_display_uuid: None, + placements: Vec::new(), + } +} + +/// Each test gets a unique, ownership-scoped database directory. +struct TempDb(tempfile::TempDir); + +impl TempDb { + fn new(name: &str) -> Self { + Self( + tempfile::Builder::new() + .prefix(&format!("rustickers-manager-{name}-")) + .tempdir() + .unwrap(), + ) + } + + fn path(&self) -> std::path::PathBuf { + self.0.path().join("stickers.db") + } +} + +#[test] +fn command_stickers_exclude_non_command_types() { + let db = TempDb::new("exclude"); + futures::executor::block_on(async { + let store = SqliteStore::open(db.path()).await.expect("open store"); + store + .insert_sticker(detail(StickerType::Command, StickerState::Open, "cmd")) + .await + .expect("insert command"); + store + .insert_sticker(detail(StickerType::Markdown, StickerState::Open, "# note")) + .await + .expect("insert markdown"); + store + .insert_sticker(detail(StickerType::Timer, StickerState::Open, "{}")) + .await + .expect("insert timer"); + store + .insert_sticker(detail(StickerType::Paint, StickerState::Open, "{}")) + .await + .expect("insert paint"); + store + .insert_sticker(detail(StickerType::File, StickerState::Open, "{}")) + .await + .expect("insert file"); + + let found = store.get_command_stickers().await.expect("query"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].sticker_type, StickerType::Command); + assert_eq!(found[0].content, "cmd"); + }); +} + +#[test] +fn command_stickers_include_closed_and_populate_placements() { + let db = TempDb::new("closed"); + futures::executor::block_on(async { + let store = SqliteStore::open(db.path()).await.expect("open store"); + let open_id = store + .insert_sticker(detail( + StickerType::Command, + StickerState::Open, + r#"{"command":"ls"}"#, + )) + .await + .expect("insert open"); + let closed_id = store + .insert_sticker(detail( + StickerType::Command, + StickerState::Close, + r#"{"command":"pwd"}"#, + )) + .await + .expect("insert closed"); + + store + .upsert_sticker_placement( + closed_id, + StickerPlacement { + display_uuid: "a".into(), + display_id: Some(7), + native_left: 100, + native_top: 30, + native_width: 315, + native_height: 171, + scale_factor: 1.5, + updated_at: 1, + }, + Some("a".into()), + ) + .await + .expect("upsert placement"); + + let found = store.get_command_stickers().await.expect("query"); + let ids: Vec = found.iter().map(|s| s.id).collect(); + assert!(ids.contains(&open_id), "open command missing: {ids:?}"); + assert!(ids.contains(&closed_id), "closed command missing: {ids:?}"); + + let closed = found.iter().find(|s| s.id == closed_id).expect("closed"); + assert_eq!(closed.state, StickerState::Close); + assert_eq!(closed.placements.len(), 1); + assert_eq!(closed.placements[0].display_uuid, "a"); + assert_eq!(closed.placements[0].native_left, 100); + }); +} + +#[test] +fn command_stickers_preserve_malformed_and_raw_content() { + let db = TempDb::new("malformed"); + futures::executor::block_on(async { + let store = SqliteStore::open(db.path()).await.expect("open store"); + let cases = [ + "{not valid json", + "echo hello && ls -la", + r#"{"command":"ls",,}"#, + "", + r#"{"command":"ls","accept_selection":true}"#, + ]; + for content in cases { + store + .insert_sticker(detail(StickerType::Command, StickerState::Open, content)) + .await + .expect("insert"); + } + + let found = store.get_command_stickers().await.expect("query"); + let mut contents: Vec<&str> = found.iter().map(|s| s.content.as_str()).collect(); + contents.sort_unstable(); + let mut expected = cases.to_vec(); + expected.sort_unstable(); + assert_eq!(contents, expected); + }); +} + +#[test] +fn command_stickers_order_updated_desc_then_id_desc() { + let db = TempDb::new("order"); + futures::executor::block_on(async { + let store = SqliteStore::open(db.path()).await.expect("open store"); + let oldest = store + .insert_sticker(detail(StickerType::Command, StickerState::Open, "oldest")) + .await + .expect("insert oldest"); + let middle = store + .insert_sticker(detail(StickerType::Command, StickerState::Open, "middle")) + .await + .expect("insert middle"); + let newest = store + .insert_sticker(detail(StickerType::Command, StickerState::Open, "newest")) + .await + .expect("insert newest"); + + // Pin timestamps explicitly: prove both ordering keys without timing sleeps. + let pool = sqlx::SqlitePool::connect(&format!("sqlite:{}", db.path().display())) + .await + .unwrap(); + for (id, timestamp) in [(oldest, 10), (middle, 20), (newest, 20)] { + sqlx::query("UPDATE stickers SET updated_at = ? WHERE id = ?") + .bind(timestamp) + .bind(id) + .execute(&pool) + .await + .unwrap(); + } + pool.close().await; + + let ids: Vec = store + .get_command_stickers() + .await + .expect("query") + .iter() + .map(|s| s.id) + .collect(); + assert_eq!(ids, vec![newest, middle, oldest]); + + // The projection is deterministic: a second read returns the same order. + let again: Vec = store + .get_command_stickers() + .await + .expect("query again") + .iter() + .map(|s| s.id) + .collect(); + assert_eq!(again, ids); + }); +} From 54455565763f5dbfd83f3c67190fa7ba8d3e50ed Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 8 Oct 2026 20:46:57 +0800 Subject: [PATCH 40/69] feat(hotkey): editable global shortcuts with validated persistence Replace the hard-coded shortcut state machine with shared parser, matcher, and bindings modules. Persistent editor beside the database; runtime reload preserves hold-state and prevents phantom previews. Validation rejects duplicate or invalid chords and keeps the listener bound on persistence failure. Add focused unit coverage for parsing, validation, atomic replacement, and rollback. macOS/Linux adapters are wired but unexercised. --- src/native/hotkey.rs | 946 +++++++++------------------------- src/native/hotkey/bindings.rs | 285 ++++++++++ src/native/hotkey/matcher.rs | 336 ++++++++++++ src/rustickers.rs | 5 + 4 files changed, 883 insertions(+), 689 deletions(-) create mode 100644 src/native/hotkey/bindings.rs create mode 100644 src/native/hotkey/matcher.rs diff --git a/src/native/hotkey.rs b/src/native/hotkey.rs index d4c6ff2..33fba9e 100644 --- a/src/native/hotkey.rs +++ b/src/native/hotkey.rs @@ -1,403 +1,143 @@ -use std::sync::mpsc::Sender; - +//! Global shortcut configuration and narrow OS keyboard adapters. +mod bindings; +mod matcher; use crate::ipc::IpcEvent; +use bindings::{ALT, CTRL, SHIFT}; +pub use bindings::{bindings, configure, default_bindings, save_bindings}; +use matcher::{Key, Matcher, Outcome}; +use std::sync::mpsc::Sender; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HotkeyAction { Show, + ToggleClipboardChooser, ToggleFilePreview, TriggerSelectionToCommand, DismissEscapeTarget, - /// Toggle the clipboard history chooser window. The focused window id is captured by the - /// keyboard hook so the chooser can restore focus before simulating the paste. - ToggleClipboardChooser, } - impl HotkeyAction { + fn from_slot(slot: usize) -> Self { + [ + Self::Show, + Self::ToggleClipboardChooser, + Self::ToggleFilePreview, + Self::TriggerSelectionToCommand, + ][slot] + } fn into_ipc_event(self, focused_window: Option) -> IpcEvent { match self { Self::Show => IpcEvent::Show, + Self::ToggleClipboardChooser => IpcEvent::ToggleClipboardChooser { focused_window }, Self::ToggleFilePreview => IpcEvent::ToggleFilePreview, Self::TriggerSelectionToCommand => IpcEvent::TriggerSelectionToCommand, Self::DismissEscapeTarget => IpcEvent::DismissEscapeTarget, - Self::ToggleClipboardChooser => IpcEvent::ToggleClipboardChooser { focused_window }, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HotkeyKey { - CtrlLeft, - CtrlRight, - AltLeft, - AltRight, - R, - V, - Space, - Other, -} - -#[derive(Default, Debug, Clone, Copy)] -struct HotkeyOutcome { - action: Option, - suppress: bool, -} - -impl PartialEq for HotkeyOutcome { - fn eq(&self, other: &Self) -> bool { - self.action == other.action && self.suppress == other.suppress - } -} - -impl Eq for HotkeyOutcome {} - -#[derive(Default, Debug, Clone, Copy)] -struct KeyState { - ctrl_left: bool, - ctrl_right: bool, - alt_left: bool, - alt_right: bool, - r_down: bool, - v_down: bool, - space_down: bool, - suppress_r_until_release: bool, - suppress_v_until_release: bool, - suppress_space_until_release: bool, - suppress_escape_until_release: bool, - modifier_combo_active: bool, - modifier_combo_consumed: bool, -} - -impl KeyState { - fn ctrl(self) -> bool { - self.ctrl_left || self.ctrl_right - } - - fn alt(self) -> bool { - self.alt_left || self.alt_right - } - - fn key_down(&mut self, key: HotkeyKey) -> HotkeyOutcome { - match key { - HotkeyKey::CtrlLeft => self.ctrl_left = true, - HotkeyKey::CtrlRight => self.ctrl_right = true, - HotkeyKey::AltLeft => self.alt_left = true, - HotkeyKey::AltRight => self.alt_right = true, - HotkeyKey::R => { - if !self.r_down { - self.r_down = true; - if self.ctrl() && self.alt() { - self.modifier_combo_consumed = true; - self.suppress_r_until_release = true; - return HotkeyOutcome { - action: Some(HotkeyAction::Show), - suppress: true, - }; - } - } - if self.modifier_combo_active { - self.modifier_combo_consumed = true; - } - return HotkeyOutcome { - suppress: self.suppress_r_until_release, - ..HotkeyOutcome::default() - }; - } - HotkeyKey::V => { - if !self.v_down { - self.v_down = true; - if self.ctrl() && self.alt() { - self.modifier_combo_consumed = true; - self.suppress_v_until_release = true; - return HotkeyOutcome { - action: Some(HotkeyAction::ToggleClipboardChooser), - suppress: true, - }; - } - } - if self.modifier_combo_active { - self.modifier_combo_consumed = true; - } - return HotkeyOutcome { - suppress: self.suppress_v_until_release, - ..HotkeyOutcome::default() - }; - } - HotkeyKey::Space => { - if !self.space_down { - self.space_down = true; - if self.ctrl() && !self.alt() { - self.suppress_space_until_release = true; - return HotkeyOutcome { - action: Some(HotkeyAction::TriggerSelectionToCommand), - suppress: true, - }; - } - } - if self.modifier_combo_active { - self.modifier_combo_consumed = true; - } - return HotkeyOutcome { - suppress: self.suppress_space_until_release, - ..HotkeyOutcome::default() - }; - } - HotkeyKey::Other => { - if self.modifier_combo_active { - self.modifier_combo_consumed = true; - } - } - } - - if self.ctrl() && self.alt() && !self.modifier_combo_active { - self.modifier_combo_active = true; - self.modifier_combo_consumed = false; - } - HotkeyOutcome::default() - } - - fn key_up(&mut self, key: HotkeyKey) -> HotkeyOutcome { - let mut outcome = HotkeyOutcome::default(); - match key { - HotkeyKey::CtrlLeft => self.ctrl_left = false, - HotkeyKey::CtrlRight => self.ctrl_right = false, - HotkeyKey::AltLeft => self.alt_left = false, - HotkeyKey::AltRight => self.alt_right = false, - HotkeyKey::R => { - self.r_down = false; - outcome.suppress = self.suppress_r_until_release; - self.suppress_r_until_release = false; - } - HotkeyKey::V => { - self.v_down = false; - outcome.suppress = self.suppress_v_until_release; - self.suppress_v_until_release = false; - } - HotkeyKey::Space => { - self.space_down = false; - outcome.suppress = self.suppress_space_until_release; - self.suppress_space_until_release = false; - } - HotkeyKey::Other => {} - } - if self.modifier_combo_active && !self.ctrl() && !self.alt() { - if !self.modifier_combo_consumed { - outcome.action = Some(HotkeyAction::ToggleFilePreview); - } - self.modifier_combo_active = false; - self.modifier_combo_consumed = false; - } - outcome - } - - fn escape_down(&mut self, has_escape_target: bool) -> HotkeyOutcome { - if self.suppress_escape_until_release { - return HotkeyOutcome { - suppress: true, - ..HotkeyOutcome::default() - }; - } - if has_escape_target { - self.suppress_escape_until_release = true; - return HotkeyOutcome { - action: Some(HotkeyAction::DismissEscapeTarget), - suppress: true, - }; - } - HotkeyOutcome::default() - } - - fn escape_up(&mut self) -> HotkeyOutcome { - let suppress = self.suppress_escape_until_release; - self.suppress_escape_until_release = false; - HotkeyOutcome { - suppress, - ..HotkeyOutcome::default() } } } - -pub fn start_global_hotkey_listener(ipc_events_tx: Sender) -> anyhow::Result<()> { +pub fn start_global_hotkey_listener(tx: Sender) -> anyhow::Result<()> { std::thread::Builder::new() - .name("global-hotkey-listener".to_string()) + .name("global-hotkey-listener".into()) .spawn(move || { - tracing::info!("Global hotkey listener started"); - if let Err(err) = start_listen(ipc_events_tx) { - tracing::error!(error = %err, "Global hotkey listener stopped"); + if let Err(error) = start_listen(tx) { + tracing::error!(%error,"Global hotkey listener stopped"); } })?; Ok(()) } - -fn dispatch_action( - ipc_events_tx: &Sender, - action: Option, - focused_window: Option, -) { - let Some(action) = action else { return }; - tracing::debug!(?action, ?focused_window, "Global hotkey triggered"); - let _ = ipc_events_tx.send(action.into_ipc_event(focused_window)); -} - -#[cfg(target_os = "windows")] -fn capture_focused_window() -> Option { - use windows_sys::Win32::UI::WindowsAndMessaging::GetForegroundWindow; - let handle = unsafe { GetForegroundWindow() }; - if handle.is_null() { - None - } else { - Some(handle as u64) +fn dispatch(tx: &Sender, outcome: Outcome, focused: Option) { + if let Some(action) = outcome.action { + let _ = tx.send(action.into_ipc_event(focused)); } } - -#[cfg(target_os = "macos")] -fn capture_focused_window() -> Option { - None -} - -#[cfg(not(any(target_os = "windows", target_os = "macos")))] -fn capture_focused_window() -> Option { - None -} - #[cfg(target_os = "windows")] -struct WindowsHotkeyContext { - ipc_events_tx: Sender, - state: KeyState, +struct WindowsContext { + tx: Sender, + matcher: Matcher, } - #[cfg(target_os = "windows")] -static WINDOWS_HOTKEY_CONTEXT: std::sync::OnceLock> = +static WINDOWS_CONTEXT: std::sync::OnceLock> = std::sync::OnceLock::new(); - -#[cfg(target_os = "windows")] -fn windows_hotkey_key(vk_code: u32) -> HotkeyKey { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - VK_CONTROL, VK_LCONTROL, VK_LMENU, VK_MENU, VK_RCONTROL, VK_RMENU, VK_SPACE, - }; - - const VK_R: u32 = b'R' as u32; - const VK_V: u32 = b'V' as u32; - - match vk_code { - code if code == VK_LCONTROL as u32 || code == VK_CONTROL as u32 => HotkeyKey::CtrlLeft, - code if code == VK_RCONTROL as u32 => HotkeyKey::CtrlRight, - code if code == VK_LMENU as u32 || code == VK_MENU as u32 => HotkeyKey::AltLeft, - code if code == VK_RMENU as u32 => HotkeyKey::AltRight, - VK_R => HotkeyKey::R, - VK_V => HotkeyKey::V, - code if code == VK_SPACE as u32 => HotkeyKey::Space, - _ => HotkeyKey::Other, - } -} - #[cfg(target_os = "windows")] -fn is_windows_escape(vk_code: u32) -> bool { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::VK_ESCAPE; - - vk_code == VK_ESCAPE as u32 -} - -#[cfg(target_os = "windows")] -fn is_rustickers_foreground() -> bool { - use windows_sys::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, GetWindowThreadProcessId, - }; - - let foreground = unsafe { GetForegroundWindow() }; - if foreground.is_null() { - return false; - } - let mut process_id = 0; - unsafe { - GetWindowThreadProcessId(foreground, &mut process_id); +fn windows_key(code: u32) -> Key { + match code { + 0x11 | 0xa2 => Key::Modifier(CTRL, 1), + 0xa3 => Key::Modifier(CTRL, 2), + 0x12 | 0xa4 => Key::Modifier(ALT, 1), + 0xa5 => Key::Modifier(ALT, 2), + 0x10 | 0xa0 => Key::Modifier(SHIFT, 1), + 0xa1 => Key::Modifier(SHIFT, 2), + // Meta is not supported syntax; it must consume modifier-only gestures. + 0x5b => Key::Modifier(8, 1), + 0x5c => Key::Modifier(8, 2), + code if code < 256 => Key::Code(code as u16), + _ => Key::Other, } - process_id == std::process::id() } - #[cfg(target_os = "windows")] -unsafe extern "system" fn windows_keyboard_hook( +unsafe extern "system" fn keyboard_hook( code: i32, wparam: windows_sys::Win32::Foundation::WPARAM, lparam: windows_sys::Win32::Foundation::LPARAM, ) -> windows_sys::Win32::Foundation::LRESULT { use windows_sys::Win32::UI::WindowsAndMessaging::{ - CallNextHookEx, KBDLLHOOKSTRUCT, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP, + CallNextHookEx, GetForegroundWindow, GetWindowThreadProcessId, KBDLLHOOKSTRUCT, WM_KEYDOWN, + WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP, }; - - if code >= 0 { + if code >= 0 + && let Some(context) = WINDOWS_CONTEXT.get() + { let message = wparam as u32; - let is_key_down = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; - let is_key_up = message == WM_KEYUP || message == WM_SYSKEYUP; - - if is_key_down || is_key_up { - let keyboard_event = unsafe { &*(lparam as *const KBDLLHOOKSTRUCT) }; - if let Some(context) = WINDOWS_HOTKEY_CONTEXT.get() { - let (ipc_events_tx, outcome) = { - let mut context = match context.lock() { - Ok(context) => context, - Err(poisoned) => poisoned.into_inner(), - }; - let outcome = if is_windows_escape(keyboard_event.vkCode) { - if is_key_down { - context.state.escape_down( - is_rustickers_foreground() - && crate::native::windows::has_escape_dismiss_target(), - ) - } else { - context.state.escape_up() - } - } else if is_key_down { - context - .state - .key_down(windows_hotkey_key(keyboard_event.vkCode)) - } else { - context - .state - .key_up(windows_hotkey_key(keyboard_event.vkCode)) - }; - (context.ipc_events_tx.clone(), outcome) - }; - - dispatch_action(&ipc_events_tx, outcome.action, capture_focused_window()); - if outcome.suppress { - return 1; + let pressed = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; + if pressed || message == WM_KEYUP || message == WM_SYSKEYUP { + let event = unsafe { &*(lparam as *const KBDLLHOOKSTRUCT) }; + let foreground = unsafe { GetForegroundWindow() }; + let mut pid = 0; + if !foreground.is_null() { + unsafe { + GetWindowThreadProcessId(foreground, &mut pid); } } + let mut context = context.lock().unwrap_or_else(|e| e.into_inner()); + let outcome = if event.vkCode == 0x1b { + context.matcher.escape( + pressed, + pid == std::process::id() + && crate::native::windows::has_escape_dismiss_target(), + ) + } else { + context + .matcher + .event(windows_key(event.vkCode), pressed, bindings::snapshot()) + }; + let suppress = outcome.suppress; + dispatch( + &context.tx, + outcome, + (!foreground.is_null()).then_some(foreground as u64), + ); + if suppress { + return 1; + } } } - unsafe { CallNextHookEx(std::ptr::null_mut(), code, wparam, lparam) } } - #[cfg(target_os = "windows")] -fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { +fn start_listen(tx: Sender) -> anyhow::Result<()> { use windows_sys::Win32::UI::WindowsAndMessaging::{ GetMessageW, MSG, SetWindowsHookExW, UnhookWindowsHookEx, WH_KEYBOARD_LL, }; - - WINDOWS_HOTKEY_CONTEXT - .set(std::sync::Mutex::new(WindowsHotkeyContext { - ipc_events_tx, - state: KeyState::default(), + WINDOWS_CONTEXT + .set(std::sync::Mutex::new(WindowsContext { + tx, + matcher: Matcher::default(), })) - .map_err(|_| anyhow::anyhow!("Windows hotkey listener was already started"))?; - - let hook = unsafe { - SetWindowsHookExW( - WH_KEYBOARD_LL, - Some(windows_keyboard_hook), - std::ptr::null_mut(), - 0, - ) - }; + .map_err(|_| anyhow::anyhow!("Keyboard listener already started"))?; + let hook = + unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook), std::ptr::null_mut(), 0) }; if hook.is_null() { - return Err(anyhow::anyhow!( - "failed to install Windows keyboard hook: {}", - std::io::Error::last_os_error() - )); + return Err(std::io::Error::last_os_error().into()); } - let mut message = MSG::default(); let result = loop { let result = unsafe { GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) }; @@ -408,46 +148,75 @@ fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { unsafe { UnhookWindowsHookEx(hook); } - if result == -1 { - Err(anyhow::anyhow!( - "Windows hotkey message loop failed: {}", - std::io::Error::last_os_error() - )) - } else { - Ok(()) + return Err(std::io::Error::last_os_error().into()); } + Ok(()) +} +#[cfg(target_os = "macos")] +fn mac_key(code: i64) -> Key { + let key = match code { + 0 => 65, + 1 => 83, + 2 => 68, + 3 => 70, + 4 => 72, + 5 => 71, + 6 => 90, + 7 => 88, + 8 => 67, + 9 => 86, + 11 => 66, + 12 => 81, + 13 => 87, + 14 => 69, + 15 => 82, + 16 => 89, + 17 => 84, + 18 => 49, + 19 => 50, + 20 => 51, + 21 => 52, + 22 => 54, + 23 => 53, + 25 => 57, + 26 => 55, + 28 => 56, + 29 => 48, + 31 => 79, + 32 => 85, + 34 => 73, + 35 => 80, + 37 => 76, + 38 => 74, + 40 => 75, + 45 => 78, + 46 => 77, + 49 => 32, + 122 => 112, + 120 => 113, + 99 => 114, + 118 => 115, + 96 => 116, + 97 => 117, + 98 => 118, + 100 => 119, + 101 => 120, + 109 => 121, + 103 => 122, + 111 => 123, + _ => return Key::Other, + }; + Key::Code(key) } - #[cfg(target_os = "macos")] -fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { +fn start_listen(tx: Sender) -> anyhow::Result<()> { use core_foundation::runloop::{CFRunLoop, kCFRunLoopCommonModes}; use core_graphics::event::{ CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, EventField, }; - use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }; - - const KEY_R: i64 = 0x0f; - const KEY_V: i64 = 0x09; - const KEY_SPACE: i64 = 0x31; - const KEY_ESCAPE: i64 = 0x35; - - let modifier_combo_down = Arc::new(AtomicBool::new(false)); - let modifier_combo_down_for_tap = modifier_combo_down.clone(); - let modifier_combo_consumed = Arc::new(AtomicBool::new(false)); - let modifier_combo_consumed_for_tap = modifier_combo_consumed.clone(); - let suppress_r_until_release = Arc::new(AtomicBool::new(false)); - let suppress_r_until_release_for_tap = suppress_r_until_release.clone(); - let suppress_space_until_release = Arc::new(AtomicBool::new(false)); - let suppress_space_until_release_for_tap = suppress_space_until_release.clone(); - let suppress_escape_until_release = Arc::new(AtomicBool::new(false)); - let suppress_escape_until_release_for_tap = suppress_escape_until_release.clone(); - let suppress_v_until_release = Arc::new(AtomicBool::new(false)); - let suppress_v_until_release_for_tap = suppress_v_until_release.clone(); + let matcher = std::sync::Mutex::new(Matcher::default()); let tap = CGEventTap::new( CGEventTapLocation::Session, CGEventTapPlacement::HeadInsertEventTap, @@ -457,342 +226,141 @@ fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { CGEventType::KeyDown, CGEventType::KeyUp, ], - move |_, event_type, event| { + move |_, kind, event| { let flags = event.get_flags(); - let primary = flags.intersects(CGEventFlags::CGEventFlagCommand) - || flags.intersects(CGEventFlags::CGEventFlagControl); - let alt = flags.intersects(CGEventFlags::CGEventFlagAlternate); - let key_code = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE); - let is_repeat = - event.get_integer_value_field(EventField::KEYBOARD_EVENT_AUTOREPEAT) != 0; - let modifier_combo = primary && alt; - let any_combo_modifier = primary || alt; - let modifier_combo_was_down = modifier_combo_down_for_tap.load(Ordering::Relaxed); - - match event_type { - CGEventType::FlagsChanged if modifier_combo && !modifier_combo_was_down => { - modifier_combo_down_for_tap.store(true, Ordering::Relaxed); - modifier_combo_consumed_for_tap.store(false, Ordering::Relaxed); - } - CGEventType::FlagsChanged if !any_combo_modifier && modifier_combo_was_down => { - modifier_combo_down_for_tap.store(false, Ordering::Relaxed); - if !modifier_combo_consumed_for_tap.swap(false, Ordering::Relaxed) { - dispatch_action(&ipc_events_tx, Some(HotkeyAction::ToggleFilePreview)); - } - } - CGEventType::KeyDown if primary && alt && key_code == KEY_R => { - modifier_combo_consumed_for_tap.store(true, Ordering::Relaxed); - suppress_r_until_release_for_tap.store(true, Ordering::Relaxed); - if !is_repeat { - dispatch_action(&ipc_events_tx, Some(HotkeyAction::Show)); - } - event.set_type(CGEventType::Null); - } - CGEventType::KeyUp - if key_code == KEY_R - && suppress_r_until_release_for_tap.swap(false, Ordering::Relaxed) => - { - event.set_type(CGEventType::Null); - } - CGEventType::KeyDown if primary && alt && key_code == KEY_V => { - modifier_combo_consumed_for_tap.store(true, Ordering::Relaxed); - suppress_v_until_release_for_tap.store(true, Ordering::Relaxed); - if !is_repeat { - dispatch_action( - &ipc_events_tx, - Some(HotkeyAction::ToggleClipboardChooser), - None, - ); - } - event.set_type(CGEventType::Null); - } - CGEventType::KeyUp - if key_code == KEY_V - && suppress_v_until_release_for_tap.swap(false, Ordering::Relaxed) => - { - event.set_type(CGEventType::Null); - } - CGEventType::KeyDown if primary && !alt && key_code == KEY_SPACE => { - suppress_space_until_release_for_tap.store(true, Ordering::Relaxed); - if !is_repeat { - dispatch_action( - &ipc_events_tx, - Some(HotkeyAction::TriggerSelectionToCommand), - ); - } - event.set_type(CGEventType::Null); - } - CGEventType::KeyUp - if key_code == KEY_SPACE - && suppress_space_until_release_for_tap.swap(false, Ordering::Relaxed) => - { - event.set_type(CGEventType::Null); - } - CGEventType::KeyDown - if key_code == KEY_ESCAPE - && (crate::native::windows::has_escape_dismiss_target() - || suppress_escape_until_release_for_tap.load(Ordering::Relaxed)) => - { - if !suppress_escape_until_release_for_tap.swap(true, Ordering::Relaxed) { - dispatch_action( - &ipc_events_tx, - Some(HotkeyAction::DismissEscapeTarget), - ); - } - event.set_type(CGEventType::Null); - } - CGEventType::KeyUp - if key_code == KEY_ESCAPE - && suppress_escape_until_release_for_tap.swap(false, Ordering::Relaxed) => - { + let modifiers = if flags.intersects(CGEventFlags::CGEventFlagCommand) + || flags.intersects(CGEventFlags::CGEventFlagControl) + { + CTRL + } else { + 0 + } | if flags.intersects(CGEventFlags::CGEventFlagAlternate) { + ALT + } else { + 0 + } | if flags.intersects(CGEventFlags::CGEventFlagShift) { + SHIFT + } else { + 0 + }; + let snapshot = bindings::snapshot(); + let mut matcher = matcher.lock().unwrap_or_else(|e| e.into_inner()); + let flags_outcome = matcher.flags(modifiers, snapshot); + dispatch(&tx, flags_outcome, None); + if kind == CGEventType::KeyDown || kind == CGEventType::KeyUp { + let code = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE); + let outcome = if code == 0x35 { + matcher.escape( + kind == CGEventType::KeyDown, + crate::native::windows::has_escape_dismiss_target(), + ) + } else { + matcher.event(mac_key(code), kind == CGEventType::KeyDown, snapshot) + }; + let suppress = outcome.suppress; + dispatch(&tx, outcome, None); + if suppress { event.set_type(CGEventType::Null); } - CGEventType::KeyDown if modifier_combo_was_down => { - modifier_combo_consumed_for_tap.store(true, Ordering::Relaxed); - } - _ => {} } - None }, ) .map_err(|_| { anyhow::anyhow!( - "failed to create macOS event tap; enable Accessibility for Rustickers in System Settings > Privacy & Security" + "Enable Accessibility for Rustickers in System Settings > Privacy & Security" ) })?; - - let run_loop = CFRunLoop::get_current(); let source = tap .mach_port .create_runloop_source(0) - .map_err(|_| anyhow::anyhow!("failed to create macOS hotkey run-loop source"))?; + .map_err(|_| anyhow::anyhow!("Could not create keyboard run-loop source"))?; unsafe { - run_loop.add_source(&source, kCFRunLoopCommonModes); + CFRunLoop::get_current().add_source(&source, kCFRunLoopCommonModes); tap.enable(); CFRunLoop::run_current(); } Ok(()) } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] -fn start_listen(ipc_events_tx: Sender) -> anyhow::Result<()> { - use rdev::{Event, EventType, Key, listen}; - use std::sync::{Arc, Mutex}; - - fn map_key(key: Key) -> HotkeyKey { +fn start_listen(tx: Sender) -> anyhow::Result<()> { + use rdev::{EventType, Key as RKey}; + fn map_key(key: RKey) -> Key { match key { - Key::ControlLeft => HotkeyKey::CtrlLeft, - Key::ControlRight => HotkeyKey::CtrlRight, - Key::Alt => HotkeyKey::AltLeft, - Key::AltGr => HotkeyKey::AltRight, - Key::KeyR => HotkeyKey::R, - Key::KeyV => HotkeyKey::V, - Key::Space => HotkeyKey::Space, - _ => HotkeyKey::Other, + RKey::ControlLeft => Key::Modifier(CTRL, 1), + RKey::ControlRight => Key::Modifier(CTRL, 2), + RKey::Alt => Key::Modifier(ALT, 1), + RKey::AltGr => Key::Modifier(ALT, 2), + RKey::ShiftLeft => Key::Modifier(SHIFT, 1), + RKey::ShiftRight => Key::Modifier(SHIFT, 2), + RKey::MetaLeft => Key::Modifier(8, 1), + RKey::MetaRight => Key::Modifier(8, 2), + RKey::Space => Key::Code(32), + RKey::KeyA => Key::Code(65), + RKey::KeyB => Key::Code(66), + RKey::KeyC => Key::Code(67), + RKey::KeyD => Key::Code(68), + RKey::KeyE => Key::Code(69), + RKey::KeyF => Key::Code(70), + RKey::KeyG => Key::Code(71), + RKey::KeyH => Key::Code(72), + RKey::KeyI => Key::Code(73), + RKey::KeyJ => Key::Code(74), + RKey::KeyK => Key::Code(75), + RKey::KeyL => Key::Code(76), + RKey::KeyM => Key::Code(77), + RKey::KeyN => Key::Code(78), + RKey::KeyO => Key::Code(79), + RKey::KeyP => Key::Code(80), + RKey::KeyQ => Key::Code(81), + RKey::KeyR => Key::Code(82), + RKey::KeyS => Key::Code(83), + RKey::KeyT => Key::Code(84), + RKey::KeyU => Key::Code(85), + RKey::KeyV => Key::Code(86), + RKey::KeyW => Key::Code(87), + RKey::KeyX => Key::Code(88), + RKey::KeyY => Key::Code(89), + RKey::KeyZ => Key::Code(90), + RKey::Num0 => Key::Code(48), + RKey::Num1 => Key::Code(49), + RKey::Num2 => Key::Code(50), + RKey::Num3 => Key::Code(51), + RKey::Num4 => Key::Code(52), + RKey::Num5 => Key::Code(53), + RKey::Num6 => Key::Code(54), + RKey::Num7 => Key::Code(55), + RKey::Num8 => Key::Code(56), + RKey::Num9 => Key::Code(57), + RKey::F1 => Key::Code(112), + RKey::F2 => Key::Code(113), + RKey::F3 => Key::Code(114), + RKey::F4 => Key::Code(115), + RKey::F5 => Key::Code(116), + RKey::F6 => Key::Code(117), + RKey::F7 => Key::Code(118), + RKey::F8 => Key::Code(119), + RKey::F9 => Key::Code(120), + RKey::F10 => Key::Code(121), + RKey::F11 => Key::Code(122), + RKey::F12 => Key::Code(123), + _ => Key::Other, } } - - let state = Arc::new(Mutex::new(KeyState::default())); - let callback = move |event: Event| { - let outcome = { - let mut state = match state.lock() { - Ok(state) => state, - Err(poisoned) => poisoned.into_inner(), - }; - match event.event_type { - EventType::KeyPress(key) => state.key_down(map_key(key)), - EventType::KeyRelease(key) => state.key_up(map_key(key)), - _ => HotkeyOutcome::default(), - } + let state = std::sync::Mutex::new(Matcher::default()); + rdev::listen(move |event| { + let (key, pressed) = match event.event_type { + EventType::KeyPress(key) => (key, true), + EventType::KeyRelease(key) => (key, false), + _ => return, }; - dispatch_action(&ipc_events_tx, outcome.action, None); - }; - - listen(callback).map_err(|err| anyhow::anyhow!("rdev listen failed: {err:?}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ctrl_space_triggers_once_and_suppresses_repeat_and_release() { - let mut state = KeyState::default(); - - assert_eq!( - state.key_down(HotkeyKey::CtrlLeft), - HotkeyOutcome::default() - ); - assert_eq!( - state.key_down(HotkeyKey::Space), - HotkeyOutcome { - action: Some(HotkeyAction::TriggerSelectionToCommand), - suppress: true, - } - ); - assert_eq!( - state.key_down(HotkeyKey::Space), - HotkeyOutcome { - action: None, - suppress: true, - } - ); - assert_eq!( - state.key_up(HotkeyKey::Space), - HotkeyOutcome { - action: None, - suppress: true, - } - ); - } - - #[test] - fn consumed_space_release_stays_suppressed_after_ctrl_release() { - let mut state = KeyState::default(); - - state.key_down(HotkeyKey::CtrlLeft); - state.key_down(HotkeyKey::Space); - state.key_up(HotkeyKey::CtrlLeft); - - assert!(state.key_up(HotkeyKey::Space).suppress); - } - - #[test] - fn overlapping_ctrl_keys_do_not_clear_ctrl_early() { - let mut state = KeyState::default(); - - state.key_down(HotkeyKey::CtrlLeft); - state.key_down(HotkeyKey::CtrlRight); - state.key_up(HotkeyKey::CtrlLeft); - - assert_eq!( - state.key_down(HotkeyKey::Space).action, - Some(HotkeyAction::TriggerSelectionToCommand) - ); - } - - #[test] - fn typing_during_ctrl_alt_does_not_toggle_preview_on_release() { - let mut state = KeyState::default(); - - state.key_down(HotkeyKey::CtrlLeft); - state.key_down(HotkeyKey::AltLeft); - state.key_down(HotkeyKey::Other); - state.key_up(HotkeyKey::AltLeft); - - assert_eq!(state.key_up(HotkeyKey::CtrlLeft).action, None); - } - - #[test] - fn bare_ctrl_alt_chord_toggles_preview_once() { - let mut state = KeyState::default(); - - state.key_down(HotkeyKey::CtrlLeft); - state.key_down(HotkeyKey::AltLeft); - state.key_up(HotkeyKey::AltLeft); - - assert_eq!( - state.key_up(HotkeyKey::CtrlLeft).action, - Some(HotkeyAction::ToggleFilePreview) - ); - } - - #[test] - fn ctrl_alt_r_is_suppressed_and_does_not_toggle_preview() { - let mut state = KeyState::default(); - - state.key_down(HotkeyKey::CtrlLeft); - state.key_down(HotkeyKey::AltLeft); - assert_eq!( - state.key_down(HotkeyKey::R), - HotkeyOutcome { - action: Some(HotkeyAction::Show), - suppress: true, - } - ); - assert!(state.key_up(HotkeyKey::R).suppress); - state.key_up(HotkeyKey::AltLeft); - - assert_eq!(state.key_up(HotkeyKey::CtrlLeft).action, None); - } - - #[test] - fn ctrl_alt_v_triggers_the_clipboard_chooser_once() { - let mut state = KeyState::default(); - - state.key_down(HotkeyKey::CtrlLeft); - state.key_down(HotkeyKey::AltLeft); - assert_eq!( - state.key_down(HotkeyKey::V), - HotkeyOutcome { - action: Some(HotkeyAction::ToggleClipboardChooser), - suppress: true, - } - ); - // Held V during a window scroll reports no new action and stays suppressed. - assert_eq!( - state.key_down(HotkeyKey::V), - HotkeyOutcome { - action: None, - suppress: true, - } - ); - assert!(state.key_up(HotkeyKey::V).suppress); - state.key_up(HotkeyKey::AltLeft); - - assert_eq!(state.key_up(HotkeyKey::CtrlLeft).action, None); - } - - #[test] - fn ctrl_alt_v_does_not_toggle_preview_when_released() { - let mut state = KeyState::default(); - - state.key_down(HotkeyKey::CtrlLeft); - state.key_down(HotkeyKey::AltLeft); - assert_eq!( - state.key_down(HotkeyKey::V), - HotkeyOutcome { - action: Some(HotkeyAction::ToggleClipboardChooser), - suppress: true, - } - ); - state.key_up(HotkeyKey::V); - state.key_up(HotkeyKey::AltLeft); - - assert_eq!( - state.key_up(HotkeyKey::CtrlLeft).action, - None, - "ctrl+alt+V must consume the modifier combo, so the trailing release must not also toggle the file preview", - ); - } - - #[test] - fn escape_is_only_consumed_while_a_dismiss_target_is_active() { - let mut state = KeyState::default(); - - assert_eq!(state.escape_down(false), HotkeyOutcome::default()); - assert_eq!( - state.escape_down(true), - HotkeyOutcome { - action: Some(HotkeyAction::DismissEscapeTarget), - suppress: true, - } - ); - assert_eq!( - state.escape_down(false), - HotkeyOutcome { - action: None, - suppress: true, - } - ); - assert_eq!( - state.escape_up(), - HotkeyOutcome { - action: None, - suppress: true, - } - ); - assert_eq!(state.escape_up(), HotkeyOutcome::default()); - } + let mut state = state.lock().unwrap_or_else(|e| e.into_inner()); + let outcome = if key == RKey::Escape { + state.escape(pressed, crate::native::windows::has_escape_dismiss_target()) + } else { + state.event(map_key(key), pressed, bindings::snapshot()) + }; + dispatch(&tx, outcome, None); + }) + .map_err(|error| anyhow::anyhow!("Keyboard listener failed: {error:?}")) } diff --git a/src/native/hotkey/bindings.rs b/src/native/hotkey/bindings.rs new file mode 100644 index 0000000..66ecaad --- /dev/null +++ b/src/native/hotkey/bindings.rs @@ -0,0 +1,285 @@ +//! Validated shortcut syntax and atomic configuration publication. +use anyhow::{Context as _, Result, bail}; +use std::{ + path::{Path, PathBuf}, + sync::{LazyLock, RwLock}, +}; + +pub(super) const CTRL: u8 = 1; +pub(super) const ALT: u8 = 2; +pub(super) const SHIFT: u8 = 4; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Chord { + pub(super) modifiers: u8, + pub(super) key: Option, +} +#[derive(Clone, Copy, Debug)] +pub(super) struct Snapshot { + pub(super) chords: [Chord; 4], + pub(super) revision: u64, +} +struct Configuration { + values: [String; 4], + snapshot: Snapshot, + path: Option, +} +static WRITE_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); +static CONFIGURATION: LazyLock> = LazyLock::new(|| { + let values = default_bindings(); + let chords = validate(&values).expect("default shortcuts must be valid"); + RwLock::new(Configuration { + values, + snapshot: Snapshot { + chords, + revision: 0, + }, + path: None, + }) +}); + +pub fn default_bindings() -> [String; 4] { + ["Ctrl+Alt+R", "Ctrl+Alt+V", "Ctrl+Alt", "Ctrl+Space"].map(str::to_string) +} +pub fn bindings() -> [String; 4] { + CONFIGURATION + .read() + .unwrap_or_else(|e| e.into_inner()) + .values + .clone() +} +pub(super) fn snapshot() -> Snapshot { + CONFIGURATION + .read() + .unwrap_or_else(|e| e.into_inner()) + .snapshot +} +pub fn configure(path: &Path) -> Result<()> { + let _write = WRITE_GATE.lock().unwrap_or_else(|e| e.into_inner()); + // Even a corrupt saved file must remain repairable through Settings. + CONFIGURATION + .write() + .unwrap_or_else(|e| e.into_inner()) + .path = Some(path.to_path_buf()); + let values = match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice::<[String; 4]>(&bytes) + .context("Could not read saved shortcuts")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => default_bindings(), + Err(error) => return Err(error).context("Could not read saved shortcuts"), + }; + let chords = validate(&values)?; + let mut state = CONFIGURATION.write().unwrap_or_else(|e| e.into_inner()); + state.values = values; + state.snapshot = Snapshot { + chords, + revision: state.snapshot.revision.wrapping_add(1), + }; + state.path = Some(path.to_path_buf()); + Ok(()) +} +pub fn save_bindings(values: [String; 4]) -> Result<()> { + let chords = validate(&values)?; + let _write = WRITE_GATE.lock().unwrap_or_else(|e| e.into_inner()); + let path = CONFIGURATION + .read() + .unwrap_or_else(|e| e.into_inner()) + .path + .clone() + .context("Shortcut storage is unavailable. Restart Rustickers and try again.")?; + // Never hold the runtime snapshot lock during file I/O: hooks must stay fast. + persist(&path, &values)?; + let mut state = CONFIGURATION.write().unwrap_or_else(|e| e.into_inner()); + // Publish only after replacement succeeds; hooks observe one coherent snapshot. + state.values = values; + state.snapshot = Snapshot { + chords, + revision: state.snapshot.revision.wrapping_add(1), + }; + Ok(()) +} +pub(super) fn validate(values: &[String; 4]) -> Result<[Chord; 4]> { + let mut chords = [Chord { + modifiers: 0, + key: None, + }; 4]; + for (ix, value) in values.iter().enumerate() { + chords[ix] = parse(value).with_context(|| format!("Shortcut {}", ix + 1))?; + if ix != 2 && chords[ix].key.is_none() { + bail!("Only quick file preview may use modifiers without a key."); + } + if chords[..ix].contains(&chords[ix]) { + bail!("Two actions cannot use the same shortcut."); + } + } + Ok(chords) +} +fn parse(value: &str) -> Result { + let mut chord = Chord { + modifiers: 0, + key: None, + }; + for part in value.split('+') { + let part = part.trim().to_ascii_uppercase(); + let modifier = match part.as_str() { + "CTRL" | "CONTROL" => CTRL, + "ALT" | "OPTION" => ALT, + "SHIFT" => SHIFT, + _ => 0, + }; + if modifier != 0 { + if chord.modifiers & modifier != 0 { + bail!("A modifier is repeated."); + } + chord.modifiers |= modifier; + } else { + let key = if part == "SPACE" { + 32 + } else if part.len() == 1 && part.as_bytes()[0].is_ascii_alphanumeric() { + u16::from(part.as_bytes()[0]) + } else if let Some(number) = part + .strip_prefix('F') + .and_then(|n| n.parse::().ok()) + .filter(|n| (1..=12).contains(n)) + { + 111 + number + } else { + bail!("Use Ctrl, Alt, Shift, A–Z, 0–9, F1–F12, or Space, separated by +."); + }; + if chord.key.replace(key).is_some() { + bail!("A shortcut may contain only one non-modifier key."); + } + } + } + if chord.modifiers == 0 { + bail!("Every global shortcut needs at least one modifier."); + } + Ok(chord) +} +fn persist(path: &Path, values: &[String; 4]) -> Result<()> { + use std::io::Write; + static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let temp = path.with_extension(format!("{}.{}.tmp", std::process::id(), serial)); + let result = (|| { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp)?; + file.write_all(&serde_json::to_vec_pretty(values)?)?; + file.sync_all()?; + drop(file); + replace(&temp, path)?; + anyhow::Ok(()) + })(); + let _ = std::fs::remove_file(&temp); + result.context("Could not save shortcuts. The previous bindings are still active.") +} +#[cfg(not(target_os = "windows"))] +fn replace(from: &Path, to: &Path) -> Result<()> { + std::fs::rename(from, to)?; + Ok(()) +} +#[cfg(target_os = "windows")] +fn replace(from: &Path, to: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + #[link(name = "kernel32")] + unsafe extern "system" { + fn MoveFileExW(from: *const u16, to: *const u16, flags: u32) -> i32; + } + let from: Vec = from.as_os_str().encode_wide().chain(Some(0)).collect(); + let to: Vec = to.as_os_str().encode_wide().chain(Some(0)).collect(); + // REPLACE_EXISTING | WRITE_THROUGH, same-directory replacement. + if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), 1 | 8) } == 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn defaults_and_custom_chords_validate() { + assert!(validate(&default_bindings()).is_ok()); + assert!( + validate(&["Ctrl+Shift+F2", "Alt+9", "Alt+Shift", "Ctrl+Q"].map(str::to_string)) + .is_ok() + ); + } + #[test] + fn invalid_and_duplicate_chords_are_rejected() { + for invalid in [ + "", + "R", + "Ctrl+Escape", + "Ctrl+Ctrl+Q", + "Ctrl+Q+R", + "Ctrl+F13", + "Super+R", + ] { + let mut values = default_bindings(); + values[0] = invalid.into(); + assert!(validate(&values).is_err(), "{invalid}"); + } + let mut values = default_bindings(); + values[0] = "alt+ctrl+v".into(); + assert!(validate(&values).is_err()); + values = default_bindings(); + values[0] = "Alt+Ctrl".into(); + assert!(validate(&values).is_err()); + } + #[test] + fn save_reloads_and_failed_validation_or_persistence_keeps_runtime() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("hotkeys.json"); + configure(&path).unwrap(); + let first = bindings(); + let mut custom = first.clone(); + custom[0] = "Alt+Shift+F3".into(); + save_bindings(custom.clone()).unwrap(); + assert_eq!(bindings(), custom); + configure(&path).unwrap(); + assert_eq!(bindings(), custom); + let revision = snapshot().revision; + let mut invalid = custom.clone(); + invalid[0] = invalid[1].clone(); + assert!(save_bindings(invalid).is_err()); + assert_eq!(snapshot().revision, revision); + assert!(configure(&temp.path().join("missing/hotkeys.json")).is_ok()); + let previous = bindings(); + let revision = snapshot().revision; + assert!(save_bindings(custom).is_err()); + assert_eq!(bindings(), previous); + assert_eq!(snapshot().revision, revision); + // Reset this process-local service after the isolated test. + let mut state = CONFIGURATION.write().unwrap(); + state.path = None; + state.values = default_bindings(); + state.snapshot.chords = validate(&state.values).unwrap(); + } + #[test] + fn persistence_replaces_existing_file_and_leaves_it_on_failure() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("hotkeys.json"); + let first = default_bindings(); + persist(&path, &first).unwrap(); + let mut second = first.clone(); + second[0] = "Alt+Shift+F3".into(); + persist(&path, &second).unwrap(); + assert_eq!( + serde_json::from_slice::<[String; 4]>(&std::fs::read(&path).unwrap()).unwrap(), + second + ); + assert!(persist(&temp.path().join("missing/hotkeys.json"), &first).is_err()); + // A non-replaceable target must survive a failed atomic replacement. + let directory = temp.path().join("directory"); + std::fs::create_dir(&directory).unwrap(); + assert!(persist(&directory, &first).is_err()); + assert!(directory.is_dir()); + assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 2); + assert_eq!( + serde_json::from_slice::<[String; 4]>(&std::fs::read(&path).unwrap()).unwrap(), + second + ); + } +} diff --git a/src/native/hotkey/matcher.rs b/src/native/hotkey/matcher.rs new file mode 100644 index 0000000..3ad0d4a --- /dev/null +++ b/src/native/hotkey/matcher.rs @@ -0,0 +1,336 @@ +//! OS-independent matching, repeat suppression, and modifier-only gestures. +#[cfg(any(target_os = "macos", test))] +use super::bindings::{ALT, CTRL, SHIFT}; +use super::{HotkeyAction, bindings::Snapshot}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Key { + Modifier(u8, u8), + Code(u16), + Other, +} +#[derive(Default, Debug, PartialEq, Eq)] +pub(super) struct Outcome { + pub(super) action: Option, + pub(super) suppress: bool, +} +pub(super) struct Matcher { + sides: [u8; 4], + down: [bool; 256], + suppressed: [bool; 256], + gesture: bool, + consumed: bool, + revision: u64, + waiting_for_release: bool, + escape_suppressed: bool, +} +impl Default for Matcher { + fn default() -> Self { + Self { + sides: [0; 4], + down: [false; 256], + suppressed: [false; 256], + gesture: false, + consumed: false, + revision: 0, + waiting_for_release: false, + escape_suppressed: false, + } + } +} +impl Matcher { + fn modifiers(&self) -> u8 { + self.sides.iter().enumerate().fold(0, |mask, (ix, sides)| { + mask | if *sides != 0 { 1 << ix } else { 0 } + }) + } + fn active(&self) -> bool { + self.modifiers() != 0 || self.down.iter().any(|d| *d) + } + pub(super) fn event(&mut self, key: Key, pressed: bool, snapshot: Snapshot) -> Outcome { + if self.revision != snapshot.revision { + self.revision = snapshot.revision; + self.waiting_for_release = self.active(); + self.gesture = false; + self.consumed = true; + } + let previous_modifiers = self.modifiers(); + let mut outcome = Outcome::default(); + match key { + Key::Modifier(modifier, side) => { + let ix = modifier.trailing_zeros() as usize; + if pressed { + self.sides[ix] |= side; + } else { + self.sides[ix] &= !side; + } + } + Key::Code(code) if code < 256 => { + let ix = code as usize; + if pressed { + let repeat = self.down[ix]; + self.down[ix] = true; + self.consumed = true; + outcome.suppress = self.suppressed[ix]; + if !repeat + && !self.waiting_for_release + && let Some(slot) = snapshot.chords.iter().position(|chord| { + chord.key == Some(code) && chord.modifiers == self.modifiers() + }) + { + self.suppressed[ix] = true; + outcome.suppress = true; + outcome.action = Some(HotkeyAction::from_slot(slot)); + } + } else { + self.down[ix] = false; + outcome.suppress = self.suppressed[ix]; + self.suppressed[ix] = false; + } + } + _ => { + if pressed { + self.consumed = true; + } + } + } + let preview = snapshot.chords[2]; + let modifiers = self.modifiers(); + if !self.waiting_for_release && preview.key.is_none() { + if pressed + && !self.gesture + && matches!(key, Key::Modifier(_, _)) + && modifiers == preview.modifiers + && previous_modifiers != modifiers + { + self.gesture = true; + self.consumed = self.down.iter().any(|d| *d); + } else if self.gesture && modifiers & !preview.modifiers != 0 { + self.consumed = true; + } + if self.gesture && modifiers == 0 { + if !self.consumed { + outcome.action = Some(HotkeyAction::ToggleFilePreview); + } + self.gesture = false; + } + } + if self.waiting_for_release && !self.active() { + self.waiting_for_release = false; + self.gesture = false; + } + outcome + } + #[cfg(any(target_os = "macos", test))] + pub(super) fn flags(&mut self, modifiers: u8, snapshot: Snapshot) -> Outcome { + // macOS FlagsChanged supplies aggregate state; preserve all transitions in one event. + let old = self.modifiers(); + if self.revision != snapshot.revision { + self.revision = snapshot.revision; + self.waiting_for_release = self.active() || modifiers != 0; + self.gesture = false; + self.consumed = true; + } + let mut result = Outcome::default(); + // Add modifiers before removing others: a chord replacement must not + // transiently look like all modifiers were released. + for modifier in [CTRL, ALT, SHIFT] + .into_iter() + .filter(|m| modifiers & m != 0) + .chain( + [CTRL, ALT, SHIFT] + .into_iter() + .filter(|m| modifiers & m == 0), + ) + { + if (old & modifier != 0) != (modifiers & modifier != 0) { + let next = self.event( + Key::Modifier(modifier, 1), + modifiers & modifier != 0, + snapshot, + ); + if next.action.is_some() { + result = next; + } + } + } + result + } + pub(super) fn escape(&mut self, pressed: bool, target: bool) -> Outcome { + if pressed { + self.consumed = true; + if self.escape_suppressed { + return Outcome { + action: None, + suppress: true, + }; + } + if target { + self.escape_suppressed = true; + return Outcome { + action: Some(HotkeyAction::DismissEscapeTarget), + suppress: true, + }; + } + self.consumed = true; + Outcome::default() + } else { + let suppress = std::mem::take(&mut self.escape_suppressed); + Outcome { + action: None, + suppress, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn config(values: [&str; 4]) -> Snapshot { + Snapshot { + chords: super::super::bindings::validate(&values.map(str::to_string)).unwrap(), + revision: 0, + } + } + fn defaults() -> Snapshot { + config(["Ctrl+Alt+R", "Ctrl+Alt+V", "Ctrl+Alt", "Ctrl+Space"]) + } + #[test] + fn keyed_shortcuts_fire_once_and_suppress_repeat_and_release() { + for (code, slot, alt) in [(82, 0, true), (86, 1, true), (32, 3, false)] { + let mut m = Matcher::default(); + let s = defaults(); + m.event(Key::Modifier(CTRL, 1), true, s); + if alt { + m.event(Key::Modifier(ALT, 1), true, s); + } + assert_eq!( + m.event(Key::Code(code), true, s), + Outcome { + action: Some(HotkeyAction::from_slot(slot)), + suppress: true + } + ); + assert_eq!( + m.event(Key::Code(code), true, s), + Outcome { + action: None, + suppress: true + } + ); + m.event(Key::Modifier(CTRL, 1), false, s); + assert!(m.event(Key::Code(code), false, s).suppress); + assert_eq!(m.event(Key::Modifier(ALT, 1), false, s).action, None); + } + } + #[test] + fn bare_preview_waits_for_both_modifiers_and_never_fires_after_typing() { + for typed in [false, true] { + let mut m = Matcher::default(); + let s = defaults(); + m.event(Key::Modifier(CTRL, 1), true, s); + m.event(Key::Modifier(ALT, 1), true, s); + if typed { + m.event(Key::Other, true, s); + } + assert!(m.event(Key::Modifier(ALT, 1), false, s).action.is_none()); + assert_eq!( + m.event(Key::Modifier(CTRL, 1), false, s).action, + if typed { + None + } else { + Some(HotkeyAction::ToggleFilePreview) + } + ); + } + } + #[test] + fn side_modifiers_and_exact_modifier_matching() { + let mut m = Matcher::default(); + let s = defaults(); + m.event(Key::Modifier(CTRL, 1), true, s); + m.event(Key::Modifier(CTRL, 2), true, s); + m.event(Key::Modifier(CTRL, 1), false, s); + assert_eq!( + m.event(Key::Code(32), true, s).action, + Some(HotkeyAction::TriggerSelectionToCommand) + ); + let mut m = Matcher::default(); + m.event(Key::Modifier(CTRL, 1), true, s); + m.event(Key::Modifier(SHIFT, 1), true, s); + assert!(m.event(Key::Code(32), true, s).action.is_none()); + } + #[test] + fn a_consumed_preview_cannot_rearm_until_all_modifiers_are_released() { + let mut m = Matcher::default(); + let s = defaults(); + m.event(Key::Modifier(CTRL, 1), true, s); + m.event(Key::Modifier(ALT, 1), true, s); + m.event(Key::Code(65), true, s); + m.event(Key::Code(65), false, s); + m.event(Key::Modifier(ALT, 1), false, s); + m.event(Key::Modifier(ALT, 1), true, s); + m.event(Key::Modifier(ALT, 1), false, s); + assert!(m.event(Key::Modifier(CTRL, 1), false, s).action.is_none()); + } + #[test] + fn reload_while_held_preserves_suppression_and_prevents_phantom_preview() { + let mut m = Matcher::default(); + let s = defaults(); + m.event(Key::Modifier(CTRL, 1), true, s); + m.event(Key::Modifier(ALT, 1), true, s); + m.event(Key::Code(86), true, s); + let mut next = config(["Alt+F2", "Ctrl+Q", "Alt+Shift", "Ctrl+Space"]); + next.revision = 1; + assert!(m.event(Key::Code(86), false, next).suppress); + m.event(Key::Modifier(CTRL, 1), false, next); + assert!(m.event(Key::Modifier(ALT, 1), false, next).action.is_none()); + m.event(Key::Modifier(ALT, 1), true, next); + assert_eq!( + m.event(Key::Code(113), true, next).action, + Some(HotkeyAction::Show) + ); + } + #[test] + fn custom_modifier_preview_and_keyed_preview_work() { + let mut m = Matcher::default(); + let s = config(["Ctrl+R", "Ctrl+V", "Alt+Shift", "Ctrl+Space"]); + m.event(Key::Modifier(ALT, 1), true, s); + m.event(Key::Modifier(SHIFT, 1), true, s); + m.event(Key::Modifier(ALT, 1), false, s); + assert_eq!( + m.event(Key::Modifier(SHIFT, 1), false, s).action, + Some(HotkeyAction::ToggleFilePreview) + ); + let mut m = Matcher::default(); + let s = config(["Ctrl+R", "Ctrl+V", "Alt+F3", "Ctrl+Space"]); + m.event(Key::Modifier(ALT, 1), true, s); + assert_eq!( + m.event(Key::Code(114), true, s).action, + Some(HotkeyAction::ToggleFilePreview) + ); + } + #[test] + fn aggregate_flags_preserve_modifier_gesture_semantics() { + let mut m = Matcher::default(); + let s = defaults(); + m.flags(CTRL | ALT, s); + assert!(m.flags(SHIFT, s).action.is_none()); + assert!(m.flags(0, s).action.is_none()); + m.flags(CTRL | ALT, s); + assert_eq!(m.flags(0, s).action, Some(HotkeyAction::ToggleFilePreview)); + } + #[test] + fn escape_remains_scoped_and_repeat_suppressed() { + let mut m = Matcher::default(); + assert_eq!(m.escape(true, false), Outcome::default()); + assert_eq!( + m.escape(true, true).action, + Some(HotkeyAction::DismissEscapeTarget) + ); + assert!(m.escape(true, false).suppress); + assert!(m.escape(false, false).suppress); + assert!(!m.escape(false, false).suppress); + } +} diff --git a/src/rustickers.rs b/src/rustickers.rs index 713039e..87709f0 100644 --- a/src/rustickers.rs +++ b/src/rustickers.rs @@ -45,6 +45,11 @@ fn main() { instance.start_ipc_server(ipc_events_tx.clone()); } + let hotkey_path = app_paths.db_path.with_file_name("hotkeys.json"); + if let Err(err) = native::hotkey::configure(&hotkey_path) { + tracing::error!(error = %err, "Could not load saved shortcuts; using defaults"); + } + if let Err(err) = native::hotkey::start_global_hotkey_listener(ipc_events_tx.clone()) { tracing::error!(error = %err, "Failed to start global hotkey listener"); } From 2cc8039dd096fa9012bd593c44e68718711a2cfe Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 8 Oct 2026 20:47:01 +0800 Subject: [PATCH 41/69] docs: add product and desktop design records PRODUCT.md captures durable product truth, brand commitments, and explicit limits (no hotkey conflict detection across other applications). DESIGN.md describes the manager workspace chrome, tokens, and accessibility contracts the build obeys, including the verification status from local captures. --- DESIGN.md | 26 ++++++++++++++++++++++++++ PRODUCT.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 DESIGN.md create mode 100644 PRODUCT.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..dfc0b16 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,26 @@ +# Desktop design + +The GPUI Kit Design Guides are normative. This record describes the manager implementation, not a separate theme framework. + +## Manager workspace + +**Purpose:** find, inspect, and open saved desktop tools without implicitly executing them. The collection remains the largest region; no dashboard metrics or decorative cards. + +- Sidebar: Library (All stickers, Text, Commands, Timers, Sketches, Files), Automation (Scheduled, Selection commands), Settings. TXTs is a separate transient workflow linked at the bottom; Settings is anchored beneath it. Brand uses the embedded `assets/icon.png`, not a pin glyph. +- Main pane: draggable header containing the single section title and description, search/sort/New sticker toolbar, virtualized collection, compact status footer. No repeated page heading. +- Default window: 820 × 620 logical pixels; minimum 560 × 480. Below 48 rem, the sidebar uses the framework's icon-collapse mode and tooltips. Both columns stretch to the viewport. +- Rows: 5.5 rem, stable object IDs, neutral surfaces, type/color identity in one icon slot, title and metadata in the flexible column, status and Open/Delete icon actions in a fixed trailing lane. Actions reveal on hover or selection and have equivalent Enter/Delete commands; there is no per-row settings dropdown. No faded closed rows; Closed and On desktop are explicit states. +- Command summaries: command, working directory, cron/arming state, selection input, and live execution/next-run status. Detailed editing uses the existing command sticker, never an independent inline editor. +- Settings: shared master-password dialogs, immediate clipboard collection switch, editable global shortcuts with explicit Save/default controls and validation feedback, local-data/privacy explanation. + +## Tokens and geometry + +Use `cx.theme()` background, foreground, sidebar, muted, border, list active/hover, and warning roles. Sticker swatches are data identity, not panel backgrounds. Use rem helpers, component Small/Medium sizes, system UI typography, and theme radii. Main content shares a 5-unit horizontal inset; related controls use 1–3-unit gaps and sections 5–6. Hairlines belong to region boundaries. No custom palette, fonts, shadows, or continuous motion. + +## Interaction + +Search filters immediately without SQL per keystroke. Selection survives refresh by sticker ID; arrows/Home/End/Page keys navigate and Enter opens. Ctrl/Cmd+F focuses search. Menus and settings controls use framework focus and dismissal. Header controls occlude the drag region. Command inspection does not restart a saved command; explicit selection execution remains separate. + +## Verification status + +Isolated Windows captures and reports live in Git-ignored `tests/visual/`; earlier captures are under `history/`. Fresh checks cover dark collection/settings, narrow scheduled commands, light/18-pixel base-font zoom, empty search, list-tail navigation, and password-dialog opening/Escape dismissal. OCR verifies outcome text and the last row above the footer; sampled pixels reject blank captures. Width constraints, persistent selection on hover, neutral-icon contrast, and accessible button names were tightened in the confirmation pass. The image-read tool still omits attachments for the available model: these are structural/interaction checks, **not a visual aesthetic approval**. Human review of the captures remains necessary. macOS/Linux visual behavior was not exercised. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..8c5490c --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,32 @@ +# Product + + + +## Platform + +Native desktop: Windows, macOS, and Linux, using GPUI Kit. The desktop GPUI guides in `.agents/skills/` are authoritative; mobile/web conventions do not apply. + +## Product purpose + +Rustickers is a lightweight local desktop sticker app for text notes, timers, command output, file previews, and sketches. Stickers can be open on the desktop or closed without being deleted. Command schedules can run while their windows are closed. + +## Operating context + +The manager organizes saved stickers; transient TXTs and selection popups support short keyboard-driven workflows in another application. The app and CLI share SQLite storage. See README.md for usage and protection limitations. + +## Capabilities and constraints + +- Existing Markdown, Command, Timer, Paint, and File sticker types; no new text/vault product. +- One master-password system, per-action decryption, public searchable titles, and no recovery/global unlock session. +- Exact clipboard text and argument data must survive display and dispatch. +- The manager should expose useful object state without executing commands implicitly or creating competing editors. +- Four global hotkeys are editable in Settings with validation and durable local storage; quick file preview also supports a modifier-only release gesture. Escape dismissal is reserved. Hooks cannot guarantee conflict-free operation with other applications. +- Use temporary databases for development verification, never live user data. + +## Brand commitments + +The user requested a reimagined manager that is pretty, fast, simple, restrained, logical, and ergonomic (简洁高级). The user delegated design and implementation decisions to the assistant; suggested settings and dedicated command views are references rather than requirements. + +## Evidence on hand + +README.md, FRONTEND.md, existing native screens, and the supplied current-manager screenshot. No commercial claims or usage metrics. Audience is not confirmed beyond desktop users of the documented workflows. From c753d202bc9653e338cf41fdf2ed6753008dcbca Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 8 Oct 2026 20:47:01 +0800 Subject: [PATCH 42/69] chore: ignore local visual verification fixtures --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f13b053..d5ea198 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target TEMP-IMPLEMENTATION-PLAN.md .tmp/ +/tests/visual/ From c1188cc327ff999080bb09139251ffebc5b0e81a Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 8 Oct 2026 20:47:05 +0800 Subject: [PATCH 43/69] docs: document manager workspace and editable shortcuts README and FRONTEND describe the sidebar workspace, hover-trailing row actions, single-header title semantics, editable shortcut model, and the visual fixture workflow under tests/visual/. Drop the fixed-shortcut footnote and the Local by design tag in favor of the Settings-anchored architecture. --- FRONTEND.md | 20 ++++++++++++++++---- README.md | 8 +++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/FRONTEND.md b/FRONTEND.md index 59ef7b4..76e6a2c 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -16,7 +16,7 @@ Use the current crate source for exact APIs. Do not invent GPUI methods from web | File / folder | Owns | | --- | --- | | `src/native/mod.rs` | Bootstrap, theme, services, clipboard watcher | -| `src/native/windows/main.rs` | Sticker manager and event refresh | +| `src/native/windows/main/` | Manager workspace: lifecycle/refresh, navigation, collection projection, settings | | `src/native/windows/sticker/` | Window lifecycle, chrome, placement, close/save | | `src/native/windows/selection.rs` | Compact search-first selection popup | | `src/native/windows/text/picker.rs` | TXTs list, filters, hover actions, and focus dismissal | @@ -48,6 +48,17 @@ Start work from an event or lifecycle method. Run blocking I/O/crypto off the GU Use one owner method for each logical command across buttons, menus, and keyboard paths. Respect modal/menu focus; Escape dismisses the topmost surface and returns focus. Guard duplicate submissions, show recoverable errors, and never silently discard dirty encrypted drafts. +## Manager workspace contracts + +- `main/mod.rs` owns lifecycle commands and revision-guarded refresh. `navigation.rs` owns destinations; `collection.rs` owns local filtering and virtual rows; `settings.rs` reuses shared text services. +- Collection coordinates are not source coordinates. Preserve selection by domain ID before replacing either projection; keyboard scrolling uses the visible projection. +- Parse command summaries at refresh, not in render. Search includes title, command, working directory, and cron without SQL on every keystroke. Command configuration commits must invalidate the manager after content is persisted. +- The manager opens commands for inspection/settings, never implicit execution. Do not add a competing inline editor without a concurrency contract with the open sticker and scheduler. +- The single page title/description belongs to the draggable header; do not repeat it in the toolbar. Brand uses embedded `assets/icon.png`. Settings is anchored at the sidebar bottom. +- Manager row commands reveal as icon buttons on hover or selection; Enter/Open and Delete/confirmation share owner methods. Do not add a second per-row settings dropdown. +- Sidebar collapses below 48 rem; test its icon tooltips and keyboard path. Uniform rows explicitly fill the width and scale their fixed height in rem. Trailing action/status lanes must stay aligned across types. +- Settings toggles persist requested values, keep previous state on failure, reject stale loads, and share the master-password dialog rather than implementing a second protection flow. Global shortcuts are staged in retained Input entities and applied only by Save. Validation rejects duplicate/invalid chords; persistence must complete before publishing the runtime configuration. Escape dismissal is not rebindable. Hooks cannot establish ownership of shortcuts used by other applications, so do not claim OS-wide conflict detection. + ## TXTs contracts to preserve - All / Text / Clipboard filters use the shared `TextRepository` projection. Rows show at most three lines; locked previews never contain plaintext. @@ -72,8 +83,9 @@ Verify resizing, DPI, keyboard navigation, focus restoration, long text, empty/e 2. For a dead click, check drag hit testing/occlusion, disabled/busy guards, modal guards, then callback/async completion. A working keyboard path does not prove a working button. 3. For clipped tail rows, bound the **viewport**, not the content: fixed header/footer use `flex_shrink_0()`, list body uses `flex_1().min_h_0().overflow_hidden()`, and `uniform_list` fills that body. Give every virtual row the same height; release long text's width with `min_w_0()`. Check the first and last item with wheel and keyboard navigation. 4. Multiline previews must preserve line breaks and Unicode in display data, then use `line_clamp(3)` in the view. A single-line sanitized preview cannot become multiline through styling. Budget row height for three text lines, metadata, actions, padding, and rem zoom. -5. Hotkey presentation needs both native z-order and activation. `WindowKind::Floating` alone does not guarantee topmost ordering; raise after native creation through `text/platform.rs`. Test from another app, including one with topmost windows, not only from the manager. -6. Use an isolated temporary database/window to exercise actual pointer paths. Assert the outcome (dialog displayed, content saved, encryption removed, window closed), not merely event delivery. Capture preview/edit/locked states, hover actions, and scroll tails in one bounded pass; fix in a batch, then confirm once. +5. DPI is a measurement boundary: native coordinates and screenshots are physical, GPUI layout is logical. Check `viewport_size`, `scale_factor`, and actual client bounds before diagnosing clipping. Windows borderless creation may need `window.resize` to reapply logical size. Screenshot/input scripts must set their own thread DPI awareness; do not compensate by scaling product spacing. +6. Hotkey presentation needs both native z-order and activation. `WindowKind::Floating` alone does not guarantee topmost ordering; raise after native creation through `text/platform.rs`. Test from another app, including one with topmost windows, not only from the manager. +7. Use an isolated temporary database/window to exercise actual pointer paths. Assert the outcome (dialog displayed, content saved, encryption removed, window closed), not merely event delivery. Capture preview/edit/locked states, hover actions, and scroll tails in one bounded pass; fix in a batch, then confirm once. Keep evidence precise: repository tests prove storage contracts; native-event probes prove their specific event paths; screenshots prove only what was inspected. A synthetic Windows `WM_ACTIVATE` check is not proof of normal foreground switching. If the host blocks foreground transfer or images cannot be inspected, report that limitation. Remove temporary probes, debug instrumentation, and stale documentation references before committing. @@ -91,4 +103,4 @@ cargo test --locked --features cli Keep one compatible `gpui-pre` family in the dependency tree. Focused tests cover text repository/protection/titles/CLI, clipboard import, palette, placement, and scheduling. Use temporary databases. -GPUI Kit 0.7.1 has documented Windows headless/test-support limitations; do not label compile checks as UI integration coverage. For optional OS-level captures, see [the visual-check recipe](.agents/skills/gpui-visual-check/SKILL.md) and `scripts/screenshot.ps1`. Use an isolated test setup, not the user's live data. +GPUI Kit 0.7.1 has documented Windows headless/test-support limitations; do not label compile checks as UI integration coverage. For optional OS-level captures, see [the visual-check recipe](.agents/skills/gpui-visual-check/SKILL.md) and `scripts/screenshot.ps1`. Keep temporary screenshots, snapshot reports, fixture databases, and local probes under `tests/visual/` (Git-ignored). Use an isolated test setup, not the user's live data. A successful `read` call does not establish image inspection if the harness omits the attachment; report that limitation and distinguish OCR/pixel checks from visual review. diff --git a/README.md b/README.md index a04e268..fc1fd12 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ cargo build --release --no-default-features --features cli --bin rst ## Desktop shortcuts -Use Cmd instead of Ctrl for global shortcuts on macOS. +These are the defaults. Edit and save the four global shortcuts in **Settings**; changes take effect immediately and survive restart. Use Cmd instead of Ctrl on macOS. | Shortcut | Action | | --- | --- | @@ -35,6 +35,12 @@ Use Cmd instead of Ctrl for global shortcuts on macOS. Command stickers support scheduled runs, including while their window is closed. File previews never encrypt or overwrite the source file. +## Manager + +The manager organizes stickers by **Text / Commands / Timers / Sketches / Files**, with dedicated **Scheduled** and **Selection commands** views. Search titles, command lines, folders, and cron expressions instantly. Hover a row for Open/Delete icon actions; Enter opens and Delete asks for confirmation. Command stickers open for inspection/settings without automatically rerunning. + +**Settings**, at the bottom of the sidebar, brings together master-password management, clipboard collection, and editable global shortcuts. Shortcut changes are staged until **Save shortcuts**; **Use defaults** fills the defaults without applying them. The sidebar collapses in narrow windows; Ctrl/Cmd+F focuses search, and arrows/Enter navigate and open the collection. + ## TXTs: saved text and clipboard history TXTs combines Markdown stickers (open or closed) with disposable clipboard captures. Search, filter by **All / Text / Clipboard**, and navigate with arrows or Page keys. Each item shows up to three lines; hover its trailing actions to rename, edit, save, lock, or delete. TXTs closes when it loses focus. From caf1886a57ae7726c66f87597c46a7bcbf4bab93 Mon Sep 17 00:00:00 2001 From: binwenw Date: Fri, 9 Oct 2026 10:35:19 +0800 Subject: [PATCH 44/69] feat(text): dedupe clipboard captures against saved text by digest A copy of text that is already a saved sticker (plain or locked) no longer materializes a duplicate clipboard_captures row. Stickers now carry a text_digest of their plaintext, computed at seal/save/promote time, so the capture path can find the matching saved identity and advance its recency instead, consuming any existing duplicate capture and folding its activity into the sticker. Ciphertext is never hashed: legacy locked rows gain a digest only once an authorized edit, unlock, or rotation yields plaintext. Startup backfills legacy plaintext digests and reconciles leftover captures. --- ARCHITECTURE.md | 4 +- README.md | 4 +- migrations/0013_add_text_digest.sql | 27 +++ src/cli/commands/text_terminal.rs | 4 +- src/native/clipboard/watcher.rs | 16 +- src/storage/sqlite.rs | 45 ++++- src/text/mod.rs | 10 +- src/text/repository.rs | 214 ++++++++++++++++++---- tests/clipboard_storage.rs | 11 +- tests/text_cli.rs | 6 +- tests/text_protection.rs | 264 +++++++++++++++++++++++++--- tests/text_repository.rs | 202 ++++++++++++++++++--- tests/text_titles.rs | 7 +- 13 files changed, 708 insertions(+), 106 deletions(-) create mode 100644 migrations/0013_add_text_digest.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d5271f7..dd11466 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,7 +33,7 @@ Features: `ui` is enabled by default; `cli` builds `rst`; both depend on `storag | Table | Purpose | | --- | --- | -| `stickers` | Persistent stickers, content, window placement, text revisions/recency | +| `stickers` | Persistent stickers, content, window placement, text revisions/recency, plaintext digest | | `clipboard_captures` | Exact disposable text, digest, byte size, creation/copy times | | `text_master_key` | Public recipient and password-wrapped private identity | | `text_imports` | One-time legacy-import markers | @@ -42,7 +42,7 @@ Historical clipboard/vault tables remain in the migration chain for raw import o TXTs contains Markdown stickers and optional captures, not command output or file previews. Typed IDs (`sticker:42`, `clipboard:42`) prevent cross-table collisions. Both desktop and CLI use shared full-text filtering and deterministic MRU ordering. Locked entries expose only public titles/metadata. -Capture promotion creates a **closed Markdown sticker** and consumes the source in one transaction. Capture budgets never evict saved stickers. Content/revision checks reject stale saves, duplicate promotion, and authorization races. Custom titles survive editing; title changes do not change text-use recency. +Capture promotion creates a **closed Markdown sticker** and consumes the source in one transaction. Capture budgets never evict saved stickers. Content/revision checks reject stale saves, duplicate promotion, and authorization races. Custom titles survive editing; title changes do not change text-use recency. `text_digest` is computed at seal/save/promote time on the **plaintext** bytes, so dedup happens across encryption transitions: a clipboard copy whose content matches a saved (plain or locked) sticker refreshes its recency instead of materializing a new capture row, keeping duplicate plaintext from lingering beside a sealed copy. Startup persistently backfills legacy plaintext sticker digests and consumes matching disposable captures, preserving their activity on the saved sticker. General sticker writes, text edits, protection changes, and legacy import use the same reconciliation policy. Legacy locked rows gain a digest only when an authorized edit, unlock, or password rotation provides plaintext; ciphertext is never hashed as a substitute. Intentionally separate saved sticker identities are not merged. ## Protection diff --git a/README.md b/README.md index fc1fd12..102bcaa 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ TXTs combines Markdown stickers (open or closed) with disposable clipboard captu - **Rename** (Ctrl/Cmd+F2) changes a saved sticker's title; custom titles survive content edits. Locked stickers also show an editable public-title field above Unlock. - Deleting a capture is immediate; deleting a saved sticker requires confirmation. -Clipboard collection starts **paused**. Enable it in TXTs. Captures preserve exact text and share a budget of 500 entries / 20 MiB; saved stickers are not evicted. Automatic paste and ownership-safe protected copying currently have a supported path only on Windows. +Clipboard collection starts **paused**. Enable it in TXTs. Captures preserve exact text and share a budget of 500 entries / 20 MiB; saved stickers are not evicted. When a new clipboard copy's content matches an already-saved text sticker (plain or locked) by digest, the saved sticker absorbs the recency and no capture row is created, keeping duplicate plaintext from lingering next to a sealed copy. Automatic paste and ownership-safe protected copying currently have a supported path only on Windows. ## Protection and privacy @@ -60,7 +60,7 @@ Set or change the **Master password…** from the manager or TXTs locker. Once c Changing the password generates new keys and re-encrypts all supported locked text atomically. **There is no forgotten-password recovery or old-lock-format compatibility.** Titles stay public and searchable: do not put secrets in them. -Internally copied decrypted text is excluded from collection, but externally copied secrets can enter history. Protected clipboard text is cleared after 30 seconds only if still owned; a crash may prevent clearing. Encryption does not erase OS clipboard history, other apps' copies, old database pages, or backups. Protect backups too. +Internally copied decrypted text is excluded from collection, but externally copied secrets can enter history. The stored digest of an encrypted sticker lets a later copy of identical plaintext refresh the locked sticker's recency instead of adding a new plaintext capture, so the sensitive row does not silently get duplicated beside its sealed copy. Protected clipboard text is cleared after 30 seconds only if still owned; a crash may prevent clearing. Encryption does not erase OS clipboard history, other apps' copies, old database pages, backups, or the persisted digest (a digest reveals content equality and permits offline guessing of low-entropy secrets; it is omitted from redacted JSON output). Legacy locked stickers gain a digest only after an authorized edit, unlock, or password rotation; until then identical external copies cannot be recognized without decrypting. Protect backups too. ## CLI diff --git a/migrations/0013_add_text_digest.sql b/migrations/0013_add_text_digest.sql new file mode 100644 index 0000000..c399c22 --- /dev/null +++ b/migrations/0013_add_text_digest.sql @@ -0,0 +1,27 @@ +-- Content-addressable dedup across the captures and stickers tables. +-- +-- `text_digest` stores the SHA-256 of the underlying plaintext bytes for +-- markdown stickers, computed at the moment the row is created, edited, or +-- re-encrypted. Application startup backfills legacy plaintext rows; legacy +-- encrypted rows remain empty until an authorized edit, unlock, or password +-- rotation provides plaintext. This SQL migration never needs the master password. +-- +-- Captures already deduplicate against `clipboard_captures.text_digest`. The +-- new column lets the capture step deduplicate against saved text too: when +-- clipboard text matches an already-saved (plaintext or locked) sticker by +-- digest, the capture is dropped and only the sticker's recency metadata is +-- bumped. That keeps duplicate plaintext from lingering next to a sealed copy +-- and surfaces the saved entry at the top of the recency-sorted list. +-- +-- `text_digest` is a content fingerprint, not a confidentiality mechanism. +-- Database readers can correlate equal encrypted content and test guessed +-- plaintext (especially low-entropy secrets) against its SHA-256. Do not expose +-- these fingerprints in redacted snapshots or logs. Existing +-- `text_*` recency columns stay exclusive to stickers so window/chrome writes +-- do not advance text activity. + +ALTER TABLE stickers ADD COLUMN text_digest TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_stickers_text_digest + ON stickers (text_digest) + WHERE text_digest != ''; diff --git a/src/cli/commands/text_terminal.rs b/src/cli/commands/text_terminal.rs index d6e40fe..167a6d6 100644 --- a/src/cli/commands/text_terminal.rs +++ b/src/cli/commands/text_terminal.rs @@ -364,12 +364,14 @@ mod interactive_smoke { #[test] #[ignore = "requires an interactive terminal"] fn terminal_chooser_smoke() { + use crate::text::CaptureOutcome; let directory = tempfile::tempdir().unwrap(); let repository = futures::executor::block_on(TextRepository::open(directory.path().join("stickers.db"))) .unwrap(); for text in ["First copy", "日本語 second copy", "Third copy"] { - futures::executor::block_on(repository.capture(text, 1)).unwrap(); + let outcome = futures::executor::block_on(repository.capture(text, 1)).unwrap(); + assert!(matches!(outcome, CaptureOutcome::Stored(_))); } let rows = futures::executor::block_on(repository.snapshot(TextSources::new(false, true), "")) diff --git a/src/native/clipboard/watcher.rs b/src/native/clipboard/watcher.rs index 3fdd1d3..d108b0f 100644 --- a/src/native/clipboard/watcher.rs +++ b/src/native/clipboard/watcher.rs @@ -21,7 +21,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::clipboard::settings::ClipboardSettings; -use crate::text::TextRepository; +use crate::text::{CaptureOutcome, TextRepository}; /// Per-capture flag — text the system clipboard just received. #[derive(Debug, Clone)] @@ -133,11 +133,15 @@ pub async fn persist_capture( { return Ok(None); } - Ok(Some( - store - .capture(&captured.content, crate::utils::time::now_unix_millis()) - .await?, - )) + let outcome = store + .capture(&captured.content, crate::utils::time::now_unix_millis()) + .await?; + Ok(Some(match outcome { + // Both branches signal the watcher to refresh — the picker reads its + // own snapshot, so whether a row was stored or a saved sticker's + // recency was bumped is invisible at this layer. + CaptureOutcome::Stored(id) | CaptureOutcome::RefreshedSticker(id) => id, + })) } /// Drives the watcher loop. Designed for parking on a background thread; the GUI owns the diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 93e63e0..58a6da5 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -56,6 +56,9 @@ impl SqliteStore { .await .context("run sqlx migrations")?; + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + crate::text::reconcile_captures(&mut tx).await?; + tx.commit().await?; Ok(Self { pool }) } } @@ -70,13 +73,23 @@ impl super::StickerStore for SqliteStore { ); let now = crate::utils::time::now_unix_millis(); + let plaintext = matches!(sticker.sticker_type, StickerType::Markdown) + && !crate::text::protection::is_encrypted(&sticker.content) + && !crate::text::protection::is_unsupported(&sticker.content); + let digest = if plaintext { + crate::text::digest(&sticker.content) + } else { + String::new() + }; + let content = sticker.content.clone(); + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; let row = sqlx::query_scalar::<_, i64>( r#" INSERT INTO stickers ( - title, state, left, top, width, height, color, type, content, created_at, updated_at + title, state, left, top, width, height, color, type, content, created_at, updated_at, text_digest ) VALUES ( - ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12 ) RETURNING id "#, @@ -92,10 +105,14 @@ impl super::StickerStore for SqliteStore { .bind(sticker.content) .bind(now) .bind(now) - .fetch_one(&self.pool) + .bind(digest) + .fetch_one(&mut *tx) .await .context("insert sticker")?; - + if plaintext { + crate::text::absorb_captures(&mut tx, row, &content).await?; + } + tx.commit().await?; Ok(row) } @@ -346,30 +363,42 @@ impl super::StickerStore for SqliteStore { let now = crate::utils::time::now_unix_millis(); anyhow::ensure!( - !crate::text::protection::is_encrypted(&content), + !crate::text::protection::is_encrypted(&content) + && !crate::text::protection::is_unsupported(&content), "encrypted text must be saved through the text repository" ); + let digest = crate::text::digest(&content); + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; let result = sqlx::query( r#" UPDATE stickers SET content = ?1, + text_digest = CASE WHEN type = 'markdown' THEN ?4 ELSE text_digest END, updated_at = ?2, text_modified_at = CASE WHEN type = 'markdown' THEN ?2 ELSE text_modified_at END, text_revision = text_revision + 1 WHERE id = ?3 AND (type != 'markdown' OR content NOT GLOB 'RUSTICKERS_TEXT_*') "#, ) - .bind(content) + .bind(&content) .bind(now) .bind(id) - .execute(&self.pool) + .bind(digest) + .execute(&mut *tx) .await .context("update sticker content")?; anyhow::ensure!( result.rows_affected() == 1, "sticker disappeared or requires an authorized text save" ); - + let markdown: bool = sqlx::query_scalar("SELECT type='markdown' FROM stickers WHERE id=?") + .bind(id) + .fetch_one(&mut *tx) + .await?; + if markdown { + crate::text::absorb_captures(&mut tx, id, &content).await?; + } + tx.commit().await?; Ok(()) } diff --git a/src/text/mod.rs b/src/text/mod.rs index 845b09b..5c220e6 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -2,7 +2,8 @@ //! Mutation and protection policy live here, not in picker adapters. pub mod protection; mod repository; -pub use repository::TextRepository; +pub use repository::{CaptureOutcome, TextRepository}; +pub(crate) use repository::{absorb_captures, digest, reconcile_captures}; use anyhow::{Result, bail}; use serde::Serialize; @@ -76,6 +77,13 @@ pub struct TextEntry { created_at: i64, activity_at: i64, revision: i64, + /// SHA-256 of the underlying plaintext bytes, lowercase hex. Empty for + /// rows whose content is not yet digested (legacy entries that were never + /// edited through the new repository paths). Always set for new captures, + /// edits, lock transitions, and promotions. Internal only: public snapshots + /// must not expose fingerprints of locked content. + #[serde(skip)] + digest: String, #[serde(skip)] content: String, } diff --git a/src/text/repository.rs b/src/text/repository.rs index c092441..370e066 100644 --- a/src/text/repository.rs +++ b/src/text/repository.rs @@ -8,6 +8,38 @@ use sha2::{Digest, Sha256}; use sqlx::{Row, SqlitePool}; use zeroize::Zeroizing; +/// Outcome of [`TextRepository::capture`]: either a fresh `clipboard_captures` +/// row was inserted or refreshed, or an existing saved sticker absorbed the +/// new copy's recency and nothing was stored in the captures table at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureOutcome { + /// A `clipboard_captures` row ID (created or refreshed) carries this + /// capture. The row already exists exactly once in the captures table. + Stored(i64), + /// A saved markdown sticker with a matching plaintext digest was found. + /// Its recency metadata was advanced and no capture row was inserted; + /// the carried value is the saved sticker ID. + RefreshedSticker(i64), +} +impl CaptureOutcome { + /// The capture row id when one was stored/refreshed, or `None` when the + /// new copy was deduplicated against an existing saved sticker. + pub fn capture_id(self) -> Option { + match self { + Self::Stored(id) => Some(id), + Self::RefreshedSticker(_) => None, + } + } + /// The saved-sticker id when the new copy was absorbed into an existing + /// sticker, or `None` when a fresh capture row was created. + pub fn sticker_id(self) -> Option { + match self { + Self::Stored(_) => None, + Self::RefreshedSticker(id) => Some(id), + } + } +} + /// Composes text and capture operations on the sticker store's canonical pool. #[derive(Clone)] pub struct TextRepository { @@ -84,6 +116,7 @@ impl TextRepository { .await?; } } + backfill_plaintext_digests(&mut tx).await?; let mut copied = 0i64; let mut skipped = 0i64; // Include obsolete rows already in the canonical DB; only raw content is eligible. @@ -104,6 +137,18 @@ impl TextRepository { skipped += 1; continue; } + let fingerprint = digest(&text); + let saved = matching_sticker(&mut tx, &fingerprint).await?; + if let Some(id) = saved { + let activity = row + .try_get::("created_at")? + .max(row.try_get("last_copied_at")?) + .max(row.try_get::, _>("last_used_at")?.unwrap_or(0)); + sqlx::query("UPDATE stickers SET text_last_used_at=MAX(COALESCE(text_last_used_at,0),?) WHERE id=?") + .bind(activity).bind(id).execute(&mut *tx).await?; + absorb_captures(&mut tx, id, &text).await?; + continue; + } let existing: Option = sqlx::query_scalar("SELECT id FROM clipboard_captures WHERE text=? LIMIT 1") .bind(&text) @@ -116,7 +161,7 @@ impl TextRepository { .bind(copied_at).bind(used_at).bind(id).execute(&mut *tx).await?; } else { sqlx::query("INSERT INTO clipboard_captures(text,text_digest,byte_size,created_at,last_copied_at,last_used_at) VALUES(?,?,?,?,?,?)") - .bind(&text).bind(format!("{:x}", Sha256::digest(text.as_bytes()))) + .bind(&text).bind(&fingerprint) .bind(text.len() as i64).bind(row.try_get::("created_at")?) .bind(copied_at).bind(used_at).execute(&mut *tx).await?; copied += 1; @@ -204,23 +249,45 @@ impl TextRepository { tx.commit().await?; Ok(()) } - pub async fn capture(&self, text: &str, now: i64) -> Result { + pub async fn capture(&self, text: &str, now: i64) -> Result { ensure!( !text.trim().is_empty() && !text.contains('\0'), "capture must be nonempty text without NUL" ); ensure!(text.len() <= 1024 * 1024, "capture exceeds one MiB"); - let digest = format!("{:x}", Sha256::digest(text.as_bytes())); + let digest = digest(text); let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; let settings = settings_in(&mut tx).await?; - let existing: Option = sqlx::query_scalar( + // Cross-format dedup: if a saved sticker (plaintext or encrypted) already + // matches this content by digest, refresh its recency instead of creating + // a duplicate capture. The sticker carries the same plaintext digest even + // when its `content` is a sealed envelope, so the same lookup handles both. + backfill_plaintext_digests(&mut tx).await?; + let existing_sticker = matching_sticker(&mut tx, &digest).await?; + if let Some(sticker) = existing_sticker { + let result = sqlx::query( + "UPDATE stickers SET text_last_used_at=MAX(COALESCE(text_last_used_at,0),?) WHERE id=? AND type='markdown'", + ) + .bind(now) + .bind(sticker) + .execute(&mut *tx) + .await?; + ensure!( + result.rows_affected() == 1, + "matched sticker disappeared before recency refresh" + ); + absorb_captures(&mut tx, sticker, text).await?; + tx.commit().await?; + return Ok(CaptureOutcome::RefreshedSticker(sticker)); + } + let existing_capture: Option = sqlx::query_scalar( "SELECT id FROM clipboard_captures WHERE text_digest=? AND text=? LIMIT 1", ) .bind(&digest) .bind(text) .fetch_optional(&mut *tx) .await?; - let id = if let Some(id) = existing { + let id = if let Some(id) = existing_capture { sqlx::query( "UPDATE clipboard_captures SET last_copied_at=MAX(last_copied_at,?) WHERE id=?", ) @@ -231,31 +298,37 @@ impl TextRepository { id } else { sqlx::query_scalar("INSERT INTO clipboard_captures(text,text_digest,byte_size,created_at,last_copied_at) VALUES(?,?,?,?,?) RETURNING id") - .bind(text).bind(digest).bind(text.len() as i64).bind(now).bind(now).fetch_one(&mut *tx).await? + .bind(text).bind(&digest).bind(text.len() as i64).bind(now).bind(now).fetch_one(&mut *tx).await? }; prune(&mut tx, &settings).await?; tx.commit().await?; - Ok(id) + Ok(CaptureOutcome::Stored(id)) } pub async fn snapshot(&self, sources: TextSources, query: &str) -> Result> { let mut entries = Vec::new(); if sources.includes(TextEntryId::Sticker(1)) { - for row in sqlx::query("SELECT id,title,state,content,created_at,COALESCE(text_modified_at,created_at) AS modified,COALESCE(text_last_used_at,0) AS used,text_revision FROM stickers WHERE type='markdown' ORDER BY id ASC") + for row in sqlx::query("SELECT id,title,state,content,created_at,COALESCE(text_modified_at,created_at) AS modified,COALESCE(text_last_used_at,0) AS used,text_revision,COALESCE(text_digest,'') AS digest FROM stickers WHERE type='markdown' ORDER BY id ASC") .fetch_all(&self.pool).await? { let content: String = row.try_get("content")?; if protection::is_unsupported(&content) { continue; } let encrypted = protection::is_encrypted(&content); let title: String = row.try_get("title")?; let created_at: i64 = row.try_get("created_at")?; + let mut stored_digest: String = row.try_get("digest")?; + // Read-only fallback for legacy rows; persistent backfill runs at open/capture. + if stored_digest.is_empty() && !encrypted { + stored_digest = digest(&content); + } entries.push(TextEntry { id: TextEntryId::Sticker(row.try_get("id")?), preview: preview(if encrypted { &title } else { &content }), title, encrypted, state: Some(row.try_get("state")?), created_at, activity_at: created_at.max(row.try_get("modified")?).max(row.try_get("used")?), - revision: row.try_get("text_revision")?, content }); + revision: row.try_get("text_revision")?, + digest: stored_digest, content }); } } if sources.includes(TextEntryId::Clipboard(1)) { for row in - sqlx::query("SELECT *,COALESCE(last_used_at,0) AS used FROM clipboard_captures") + sqlx::query("SELECT id,text,created_at,last_copied_at,text_digest,COALESCE(last_used_at,0) AS used FROM clipboard_captures") .fetch_all(&self.pool) .await? { @@ -264,10 +337,14 @@ impl TextRepository { let activity_at = created_at .max(row.try_get("last_copied_at")?) .max(row.try_get("used")?); - // Projection dedupe only: a capture can refresh the representative sticker's display activity. - if let Some(sticker) = entries - .iter_mut() - .find(|e| !e.encrypted && e.content == content) + let capture_digest: String = row.try_get("text_digest")?; + // Projection dedupe only: a capture can refresh the representative + // sticker's display activity. Digest matching also folds captures + // whose plaintext matches an already-locked sticker. + if !capture_digest.is_empty() + && let Some(sticker) = entries + .iter_mut() + .find(|e| matches!(e.id, TextEntryId::Sticker(_)) && !e.digest.is_empty() && e.digest == capture_digest) { sticker.activity_at = sticker.activity_at.max(activity_at); continue; @@ -281,6 +358,7 @@ impl TextRepository { created_at, activity_at, revision: 0, + digest: capture_digest, content, }); } @@ -366,7 +444,7 @@ impl TextRepository { } else { content.clone() }; - sqlx::query("UPDATE stickers SET title=?,content=? WHERE id=?") + sqlx::query("UPDATE stickers SET title=?,content=?,text_digest=? WHERE id=?") .bind(title.map(str::to_owned).unwrap_or_else(|| { if encrypted { "Locked text".to_owned() @@ -375,15 +453,11 @@ impl TextRepository { } })) .bind(saved) + .bind(digest(&content)) .bind(id) .execute(&mut *tx) .await?; - sqlx::query("DELETE FROM clipboard_captures WHERE id=? OR (? AND text=?)") - .bind(capture_id) - .bind(encrypted) - .bind(&content) - .execute(&mut *tx) - .await?; + absorb_captures(&mut tx, id, &content).await?; tx.commit().await?; Ok(id) } @@ -408,13 +482,24 @@ impl TextRepository { ); text.to_owned() }; - let result = sqlx::query("UPDATE stickers SET content=?,title=CASE WHEN ? OR title != ? THEN title ELSE ? END,text_revision=text_revision+1,text_modified_at=?,updated_at=? WHERE id=? AND type='markdown' AND text_revision=? AND content=?") - .bind(content).bind(expected.encrypted).bind(preview(&expected.content)).bind(if expected.encrypted { String::new() } else { preview(text) }).bind(crate::utils::time::now_unix_millis()).bind(crate::utils::time::now_unix_millis()) - .bind(id).bind(expected.revision).bind(&expected.content).execute(&mut *tx).await?; + let result = sqlx::query("UPDATE stickers SET content=?,title=CASE WHEN ? OR title != ? THEN title ELSE ? END,text_digest=?,text_revision=text_revision+1,text_modified_at=?,updated_at=? WHERE id=? AND type='markdown' AND text_revision=? AND content=?") + .bind(content) + .bind(expected.encrypted) + .bind(preview(&expected.content)) + .bind(if expected.encrypted { String::new() } else { preview(text) }) + .bind(digest(text)) + .bind(crate::utils::time::now_unix_millis()) + .bind(crate::utils::time::now_unix_millis()) + .bind(id) + .bind(expected.revision) + .bind(&expected.content) + .execute(&mut *tx) + .await?; ensure!( result.rows_affected() == 1, "text changed; reload before saving" ); + absorb_captures(&mut tx, id, text).await?; tx.commit().await?; Ok(()) } @@ -462,19 +547,16 @@ impl TextRepository { } else { plaintext.to_string() }; - let result = sqlx::query("UPDATE stickers SET content=?,title=?,text_revision=text_revision+1 WHERE id=? AND type='markdown' AND text_revision=? AND content=?") + let plaintext_digest = digest(&plaintext); + let result = sqlx::query("UPDATE stickers SET content=?,title=?,text_digest=?,text_revision=text_revision+1 WHERE id=? AND type='markdown' AND text_revision=? AND content=?") .bind(replacement).bind(title.map(str::to_owned).unwrap_or_else(|| if enabled { "Locked text".to_owned() } else { expected.title.clone() })) + .bind(plaintext_digest) .bind(id).bind(expected.revision).bind(&expected.content).execute(&mut *tx).await?; ensure!( result.rows_affected() == 1, "text changed; reload before changing protection" ); - if enabled { - sqlx::query("DELETE FROM clipboard_captures WHERE text=?") - .bind(&*plaintext) - .execute(&mut *tx) - .await?; - } + absorb_captures(&mut tx, id, &plaintext).await?; tx.commit().await?; Ok(()) } @@ -509,11 +591,13 @@ impl TextRepository { let envelope: String = row.try_get("content")?; let plaintext = previous.open_authorized(id, &envelope, &identity)?; let replacement = next.seal(id, &plaintext)?; - sqlx::query("UPDATE stickers SET content=?,text_revision=text_revision+1 WHERE id=?") + sqlx::query("UPDATE stickers SET content=?,text_digest=?,text_revision=text_revision+1 WHERE id=?") .bind(replacement) + .bind(digest(&plaintext)) .bind(id) .execute(&mut *tx) .await?; + absorb_captures(&mut tx, id, &plaintext).await?; } sqlx::query("UPDATE text_master_key SET configuration=? WHERE singleton=1") .bind(next.serialize()?) @@ -568,6 +652,72 @@ fn preview(text: &str) -> String { .map(|c| if c.is_control() { ' ' } else { c }) .collect() } +/// Lowercase-hex SHA-256 of the exact bytes used to identify plaintext +/// duplicates. This is a content fingerprint, not a secret; see the +/// migration comment for the privacy contract. +pub(crate) fn digest(text: &str) -> String { + format!("{:x}", Sha256::digest(text.as_bytes())) +} +/// Persist fingerprints only where plaintext is already available. Never hash ciphertext. +pub(crate) async fn backfill_plaintext_digests( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, +) -> Result<()> { + let rows = sqlx::query("SELECT id,content FROM stickers WHERE type='markdown' AND text_digest='' AND content NOT GLOB 'RUSTICKERS_TEXT_*'") + .fetch_all(&mut **tx).await?; + for row in rows { + let content: String = row.try_get("content")?; + sqlx::query("UPDATE stickers SET text_digest=? WHERE id=?") + .bind(digest(&content)) + .bind(row.try_get::("id")?) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +async fn matching_sticker( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + digest: &str, +) -> Result> { + sqlx::query_scalar( + "SELECT id FROM stickers WHERE type='markdown' AND text_digest=? ORDER BY id ASC LIMIT 1", + ) + .bind(digest) + .fetch_optional(&mut **tx) + .await + .map_err(Into::into) +} + +/// Upgrade legacy plaintext identities and consume cross-table duplicates at startup. +pub(crate) async fn reconcile_captures(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>) -> Result<()> { + backfill_plaintext_digests(tx).await?; + let captures: Vec<(String, String)> = + sqlx::query_as("SELECT text,text_digest FROM clipboard_captures") + .fetch_all(&mut **tx) + .await?; + for (text, digest) in captures { + if let Some(id) = matching_sticker(tx, &digest).await? { + absorb_captures(tx, id, &text).await?; + } + } + Ok(()) +} + +/// Consume disposable duplicates and preserve their activity in the saved identity. +/// Match exact plaintext here so even a legacy capture with a stale digest is removed. +pub(crate) async fn absorb_captures( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: i64, + text: &str, +) -> Result<()> { + sqlx::query("UPDATE stickers SET text_last_used_at=MAX(COALESCE(text_last_used_at,0),COALESCE((SELECT MAX(MAX(created_at,last_copied_at,COALESCE(last_used_at,0))) FROM clipboard_captures WHERE text=?),0)) WHERE id=?") + .bind(text).bind(id).execute(&mut **tx).await?; + sqlx::query("DELETE FROM clipboard_captures WHERE text=?") + .bind(text) + .execute(&mut **tx) + .await?; + Ok(()) +} async fn key_in(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>) -> Result> { let value: Option = sqlx::query_scalar("SELECT configuration FROM text_master_key WHERE singleton=1") diff --git a/tests/clipboard_storage.rs b/tests/clipboard_storage.rs index 6023522..6fc34bd 100644 --- a/tests/clipboard_storage.rs +++ b/tests/clipboard_storage.rs @@ -1,8 +1,9 @@ //! Capture and read-only legacy import contracts on isolated SQLite files. #![cfg(feature = "storage")] +use anyhow::Context as _; use futures::executor::block_on; -use rustickers::text::{TextEntryId, TextRepository, TextSources}; +use rustickers::text::{CaptureOutcome, TextEntryId, TextRepository, TextSources}; use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; async fn pool(path: &std::path::Path) -> anyhow::Result { @@ -50,8 +51,12 @@ fn capture_round_trip_persists_without_legacy_vault_dependency() -> anyhow::Resu assert_eq!(settings.normal_byte_limit, 20 * 1024 * 1024); assert!(repo.master_key().await?.is_none()); let exact = " \t大文字\r\n'quoted' ; & "; - let id = repo.capture(exact, 10).await?; - assert_eq!(repo.capture(exact, 20).await?, id); + let id = repo + .capture(exact, 10) + .await? + .capture_id() + .context("expected a stored clipboard capture")?; + assert_eq!(repo.capture(exact, 20).await?, CaptureOutcome::Stored(id)); let reopened = TextRepository::open(&path).await?; let rows = reopened.snapshot(TextSources::new(false, true), "").await?; assert_eq!(rows.len(), 1); diff --git a/tests/text_cli.rs b/tests/text_cli.rs index 760803f..85fa58a 100644 --- a/tests/text_cli.rs +++ b/tests/text_cli.rs @@ -36,7 +36,11 @@ impl Fixture { } } fn capture(&self, text: &str) -> TextEntryId { - TextEntryId::Clipboard(block_on(self.repo.capture(text, 100)).unwrap()) + let id = block_on(self.repo.capture(text, 100)) + .unwrap() + .capture_id() + .expect("expected the CLI helper to seed a fresh capture"); + TextEntryId::Clipboard(id) } fn sticker(&self, text: &str, state: StickerState) -> TextEntryId { let capture = block_on(self.repo.get(self.capture(text))).unwrap(); diff --git a/tests/text_protection.rs b/tests/text_protection.rs index 66b89db..2188944 100644 --- a/tests/text_protection.rs +++ b/tests/text_protection.rs @@ -1,10 +1,12 @@ #![cfg(feature = "storage")] +use anyhow::Context as _; use futures::executor::block_on; use rustickers::text::{ - TextEntry, TextEntryId, TextRepository, TextSources, + CaptureOutcome, TextEntry, TextEntryId, TextRepository, TextSources, protection::{self, CONTENT_PREFIX, MasterKey}, }; +use sha2::{Digest, Sha256}; use sqlx::{ SqlitePool, sqlite::{SqliteConnectOptions, SqlitePoolOptions}, @@ -22,7 +24,11 @@ async fn fixture() -> anyhow::Result<(TempDir, TextRepository, SqlitePool)> { Ok((dir, repo, pool)) } async fn promote(repo: &TextRepository, text: &str, encrypted: bool) -> anyhow::Result { - let id = repo.capture(text, 1).await?; + let id = repo + .capture(text, 1) + .await? + .capture_id() + .context("expected a fresh clipboard capture")?; let source = repo.get(TextEntryId::Clipboard(id)).await?; let saved = repo.promote(&source, encrypted).await?; repo.get(TextEntryId::Sticker(saved)).await @@ -40,7 +46,7 @@ async fn content(pool: &SqlitePool, id: TextEntryId) -> anyhow::Result { } async fn persisted(pool: &SqlitePool) -> anyhow::Result { // Capture all columns, not just ciphertext: rollback must restore revisions and metadata too. - let rows: Vec = sqlx::query_scalar("SELECT json_object('id',id,'title',title,'state',state,'left',left,'top',top,'width',width,'height',height,'color',color,'content',content,'created',created_at,'updated',updated_at,'modified',text_modified_at,'used',text_last_used_at,'revision',text_revision) FROM stickers ORDER BY id") + let rows: Vec = sqlx::query_scalar("SELECT json_object('id',id,'title',title,'state',state,'left',left,'top',top,'width',width,'height',height,'color',color,'content',content,'created',created_at,'updated',updated_at,'modified',text_modified_at,'used',text_last_used_at,'revision',text_revision,'digest',text_digest) FROM stickers ORDER BY id") .fetch_all(pool).await?; let key: String = sqlx::query_scalar("SELECT configuration FROM text_master_key WHERE singleton=1") @@ -49,6 +55,93 @@ async fn persisted(pool: &SqlitePool) -> anyhow::Result { Ok(serde_json::json!({"rows": rows, "key": key})) } +#[test] +fn rotation_backfills_legacy_locked_digest_and_consumes_plain_capture() -> anyhow::Result<()> { + block_on(async { + let (dir, repo, pool) = fixture().await?; + repo.set_master_password("old password").await?; + let locked = promote(&repo, "legacy locked secret", true).await?; + let TextEntryId::Sticker(id) = locked.id() else { + unreachable!() + }; + sqlx::query("UPDATE stickers SET text_digest='' WHERE id=?") + .bind(id) + .execute(&pool) + .await?; + let reopened = TextRepository::open(dir.path().join("stickers.db")).await?; + let digest: String = sqlx::query_scalar("SELECT text_digest FROM stickers WHERE id=?") + .bind(id) + .fetch_one(&pool) + .await?; + assert!( + digest.is_empty(), + "startup must not hash ciphertext or decrypt" + ); + let capture = reopened + .capture("legacy locked secret", 100) + .await? + .capture_id() + .unwrap(); + reopened + .change_master_password("old password", "new password") + .await?; + assert!(reopened.get(TextEntryId::Clipboard(capture)).await.is_err()); + assert_eq!( + reopened + .capture("legacy locked secret", 200) + .await? + .sticker_id(), + Some(id) + ); + assert_eq!( + reopened + .resolve(&reopened.get(locked.id()).await?, Some("new password")) + .await? + .as_str(), + "legacy locked secret" + ); + Ok(()) + }) +} + +#[test] +fn locked_edit_consumes_matching_capture_and_redacts_digest() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + repo.set_master_password("password").await?; + let locked = promote(&repo, "original secret", true).await?; + let capture = repo + .capture("replacement secret", 100) + .await? + .capture_id() + .unwrap(); + repo.save_text(&locked, "replacement secret").await?; + assert!(repo.get(TextEntryId::Clipboard(capture)).await.is_err()); + let current = repo.get(locked.id()).await?; + let json = serde_json::to_value(¤t)?; + assert!( + json.get("digest").is_none(), + "redacted snapshots must not publish fingerprints" + ); + let TextEntryId::Sticker(id) = current.id() else { + unreachable!() + }; + let digest: String = sqlx::query_scalar("SELECT text_digest FROM stickers WHERE id=?") + .bind(id) + .fetch_one(&pool) + .await?; + assert_eq!( + digest, + format!("{:x}", Sha256::digest(b"replacement secret")) + ); + assert_eq!( + repo.capture("replacement secret", 200).await?.sticker_id(), + Some(id) + ); + Ok(()) + }) +} + #[test] fn public_only_seal_exact_open_and_fail_closed_formats() -> anyhow::Result<()> { let key = MasterKey::create("correct password")?; @@ -110,8 +203,21 @@ fn encrypted_promotion_lock_save_redaction_and_explicit_remove_encryption() -> a repo.set_master_password("password").await?; assert!(repo.set_master_password("replacement").await.is_err()); let secret = "secret 大文字\r\n'keep' "; - let independent = promote(&repo, secret, false).await?; - let encrypted = promote(&repo, secret, true).await?; + let locked_secret = "locked 大文字 'leak' "; + let plain_sticker = promote(&repo, secret, false).await?; + let locked_entry = promote(&repo, locked_secret, true).await?; + // The two stickers hold different plaintext: an identical capture would + // be deduplicated into the first one instead of promoting twice. + let plain_sticker_id = match plain_sticker.id() { + TextEntryId::Sticker(id) => id, + TextEntryId::Clipboard(_) => unreachable!("plain promote returns a sticker"), + }; + let locked_sticker_id = match locked_entry.id() { + TextEntryId::Sticker(id) => id, + TextEntryId::Clipboard(_) => unreachable!("encrypted promote returns a sticker"), + }; + assert_ne!(plain_sticker_id, locked_sticker_id); + let encrypted = repo.get(locked_entry.id()).await?; assert!(encrypted.is_encrypted()); assert_eq!(encrypted.title(), "Locked text"); assert_eq!(encrypted.preview(), "Locked text"); @@ -120,21 +226,28 @@ fn encrypted_promotion_lock_save_redaction_and_explicit_remove_encryption() -> a .await? .is_empty() ); - assert!(!repo.get(independent.id()).await?.is_encrypted()); + // The plainly stored sticker lives side-by-side with a sealed sticker + // holding a different plaintext, exercising the projection paths the new + // digest index enables. + assert!(!plain_sticker.is_encrypted()); let before = content(&pool, encrypted.id()).await?; assert!(before.starts_with(CONTENT_PREFIX)); - assert!(!before.contains(secret)); + assert!(!before.contains(locked_secret)); let json = serde_json::to_string(&encrypted)?; - assert!(!json.contains(secret)); + assert!(!json.contains(locked_secret)); assert!(!json.contains(CONTENT_PREFIX)); - assert!(!format!("{encrypted:?}").contains(secret)); - assert!(!encrypted.matches("secret")); + assert!(!format!("{encrypted:?}").contains(locked_secret)); + assert!(!encrypted.matches(locked_secret)); assert!(encrypted.matches("locked")); assert!(repo.resolve(&encrypted, None).await.is_err()); assert!(repo.resolve(&encrypted, Some("wrong")).await.is_err()); - assert_eq!(&*repo.resolve(&encrypted, Some("password")).await?, secret); + assert_eq!( + &*repo.resolve(&encrypted, Some("password")).await?, + locked_secret + ); assert_eq!(content(&pool, encrypted.id()).await?, before); // Temporary unlock is read-only. - let replacement = "edited secret\r\n新 "; + assert_eq!(&*repo.resolve(&plain_sticker, None).await?, secret); + let replacement = "edited locked text\r\n新 "; repo.save_text(&encrypted, replacement).await?; // No private key/password needed. assert!(repo.save_text(&encrypted, "stale").await.is_err()); let edited = repo.get(encrypted.id()).await?; @@ -153,8 +266,22 @@ fn encrypted_promotion_lock_save_redaction_and_explicit_remove_encryption() -> a repo.set_encryption(&edited, false, Some("password")) .await?; assert_eq!(content(&pool, edited.id()).await?, replacement); - let stale_plain = repo.get(independent.id()).await?; - repo.capture(secret, 50).await?; + let stale_plain = repo.get(plain_sticker.id()).await?; + let copied_at = stale_plain.activity_at() + 1_000; + assert_eq!( + repo.capture(secret, copied_at).await?, + CaptureOutcome::RefreshedSticker(plain_sticker_id) + ); + // Recopying identical text must not introduce a fresh clipboard row; + // the saved plaintext sticker already absorbs the activity. + let row: Option = sqlx::query_scalar("SELECT id FROM clipboard_captures WHERE text=?") + .bind(secret) + .fetch_optional(&pool) + .await?; + assert!( + row.is_none(), + "no plaintext capture row should leak next to the saved sticker" + ); repo.set_encryption(&stale_plain, true, None).await?; assert!( repo.save_text(&stale_plain, "plaintext resurrection") @@ -166,10 +293,12 @@ fn encrypted_promotion_lock_save_redaction_and_explicit_remove_encryption() -> a .await? .is_empty() ); - assert_eq!( - repo.get(independent.id()).await?.activity_at(), - stale_plain.activity_at() - ); + // The pre-encryption recency is preserved (`set_encryption` does not + // touch text_modified_at or text_last_used_at). The new dedup path + // does advance the saved sticker's recency, so the post-encryption + // activity should equal what the duplicate capture just installed. + assert_eq!(repo.get(plain_sticker.id()).await?.activity_at(), copied_at); + assert_eq!(repo.get(plain_sticker.id()).await?.revision(), 1); Ok(()) }) } @@ -208,7 +337,11 @@ fn row_swaps_are_rejected_and_corrupt_configuration_never_promotes_plaintext() - sqlx::query("UPDATE text_master_key SET configuration='{}'") .execute(&pool) .await?; - let capture = repo.capture("must stay capture", 10).await?; + let capture = repo + .capture("must stay capture", 10) + .await? + .capture_id() + .context("expected a fresh clipboard capture")?; let source = repo.get(TextEntryId::Clipboard(capture)).await?; assert!(repo.promote(&source, true).await.is_err()); assert!(repo.get(source.id()).await.is_ok()); @@ -415,7 +548,11 @@ fn concurrent_rotations_have_one_winner_and_stale_cross_pool_editor_cannot_write assert!(repo.resolve(¤t, Some(loser)).await.is_err()); assert!(repo.resolve(¤t, Some("old password")).await.is_err()); // Collection remains ordinary and independent of encryption/private-key authorization. - let capture = other.capture("external later secret copy", 100).await?; + let capture = other + .capture("external later secret copy", 100) + .await? + .capture_id() + .context("expected a fresh clipboard capture")?; let entry = other.get(TextEntryId::Clipboard(capture)).await?; assert_eq!( &*other.resolve(&entry, None).await?, @@ -432,7 +569,11 @@ fn encrypted_promotion_racing_rotation_never_commits_old_generation_payload() -> let (dir, repo, pool) = fixture().await?; repo.set_master_password("old password").await?; let existing = promote(&repo, "existing secret", true).await?; - let source_id = repo.capture("concurrently promoted secret", 10).await?; + let source_id = repo + .capture("concurrently promoted secret", 10) + .await? + .capture_id() + .context("expected a fresh clipboard capture")?; let source = repo.get(TextEntryId::Clipboard(source_id)).await?; let other = TextRepository::open(dir.path().join("stickers.db")).await?; let gate = std::sync::Arc::new(std::sync::Barrier::new(2)); @@ -522,3 +663,84 @@ fn damaged_second_item_and_wrong_password_roll_back_entire_rotation() -> anyhow: Ok(()) }) } + +/// Same-text clipboard capture must refresh the existing saved sticker's +/// recency without ever materialising a duplicate capture row, and the +/// sticker involved may be either plaintext or already locked — the digest +/// is computed at seal time so the two cases share the same identity. +#[test] +fn clipboard_capture_with_matching_save_refreshes_not_duplicates() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + repo.set_master_password("password").await?; + let secret = "secret 大文字\r\n'keep' "; + let independent = promote(&repo, secret, false).await?; + let tracked_id = independent.id(); + let before_recency: (i64, i64) = sqlx::query_as( + "SELECT COALESCE(text_last_used_at,0),text_modified_at FROM stickers WHERE id=?", + ) + .bind(match tracked_id { + TextEntryId::Sticker(id) => id, + TextEntryId::Clipboard(_) => unreachable!(), + }) + .fetch_one(&pool) + .await?; + // Lock the sticker; the digest should now match the plaintext we + // capture below, exercising the cross-format dedup path. + repo.set_encryption(&independent, true, None).await?; + // Recopy identical text while it's already a sealed sticker. + let copied_at = 1_700_000_000_000; + let outcome = repo.capture(secret, copied_at).await?; + let refreshed = outcome + .sticker_id() + .context("expected the duplicate to refresh the saved sticker")?; + let TextEntryId::Sticker(tracked_sticker) = tracked_id else { + unreachable!() + }; + assert_eq!(refreshed, tracked_sticker); + let after: (i64, i64, String) = sqlx::query_as( + "SELECT COALESCE(text_last_used_at,0),text_modified_at,content FROM stickers WHERE id=?", + ) + .bind(match tracked_id { + TextEntryId::Sticker(id) => id, + TextEntryId::Clipboard(_) => unreachable!(), + }) + .fetch_one(&pool) + .await?; + assert_eq!( + after.0, copied_at, + "text_last_used_at jumps to the new capture time" + ); + assert_eq!( + after.1, before_recency.1, + "text_modified_at remains stable: encryption is a protection flip, not a content edit" + ); + assert!( + after.2.starts_with(CONTENT_PREFIX), + "sticker stays locked; the duplicate capture must not silently unlock it" + ); + let captures: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM clipboard_captures") + .fetch_one(&pool) + .await?; + assert_eq!( + captures, 0, + "the duplicate must not produce a plaintext capture row next to a locked sticker" + ); + // After deleting the saved sticker, a fresh capture of the same text + // re-creates an ordinary clipboard row (the path through which new + // copies land once the prior matching record is gone). + repo.delete(tracked_id).await?; + let stored = repo + .capture(secret, copied_at + 1) + .await? + .capture_id() + .context("expected a stored capture row after the saved sticker was deleted")?; + let row: (i64, String) = + sqlx::query_as("SELECT id,text FROM clipboard_captures WHERE id=?") + .bind(stored) + .fetch_one(&pool) + .await?; + assert_eq!(row.1, secret); + Ok(()) + }) +} diff --git a/tests/text_repository.rs b/tests/text_repository.rs index 1d1903c..26ae8c1 100644 --- a/tests/text_repository.rs +++ b/tests/text_repository.rs @@ -1,10 +1,13 @@ #![cfg(feature = "storage")] +use anyhow::Context as _; use futures::executor::block_on; use rustickers::{ - model::sticker::StickerState, - text::{TextEntryId, TextRepository, TextSources, filter_indices}, + model::sticker::{StickerDetail, StickerState}, + storage::{StickerStore, sqlite::SqliteStore}, + text::{CaptureOutcome, TextEntryId, TextRepository, TextSources, filter_indices}, }; +use sha2::{Digest, Sha256}; use sqlx::{ SqlitePool, sqlite::{SqliteConnectOptions, SqlitePoolOptions}, @@ -23,8 +26,121 @@ async fn fixture() -> anyhow::Result<(TempDir, TextRepository, SqlitePool)> { } async fn sticker(pool: &SqlitePool, title: &str, text: &str, time: i64) -> anyhow::Result { - Ok(sqlx::query_scalar("INSERT INTO stickers(title,state,left,top,width,height,color,type,content,created_at,updated_at,text_modified_at) VALUES(?,'close',100,100,400,300,'yellow','markdown',?,?,?,?) RETURNING id") - .bind(title).bind(text).bind(time).bind(time).bind(time).fetch_one(pool).await?) + let digest = format!("{:x}", Sha256::digest(text.as_bytes())); + Ok(sqlx::query_scalar("INSERT INTO stickers(title,state,left,top,width,height,color,type,content,created_at,updated_at,text_modified_at,text_digest) VALUES(?,'close',100,100,400,300,'yellow','markdown',?,?,?,?,?) RETURNING id") + .bind(title).bind(text).bind(time).bind(time).bind(time).bind(digest).fetch_one(pool).await?) +} +/// Unwrap a `Stored(id)` capture outcome in tests; helper so the new dedup +/// intersection with stickers reads as one line. +fn stored(outcome: CaptureOutcome) -> anyhow::Result { + outcome + .capture_id() + .context("expected a stored clipboard capture") +} + +#[test] +fn legacy_and_general_sticker_writes_deduplicate_in_storage() -> anyhow::Result<()> { + block_on(async { + let (dir, repo, pool) = fixture().await?; + let id = sticker(&pool, "legacy", "legacy body", 1).await?; + sqlx::query("UPDATE stickers SET text_digest='' WHERE id=?") + .bind(id) + .execute(&pool) + .await?; + assert_eq!( + repo.capture("legacy body", 20).await?, + CaptureOutcome::RefreshedSticker(id) + ); + let digest: String = sqlx::query_scalar("SELECT text_digest FROM stickers WHERE id=?") + .bind(id) + .fetch_one(&pool) + .await?; + assert_eq!(digest, format!("{:x}", Sha256::digest(b"legacy body"))); + let store = SqliteStore::open(dir.path().join("stickers.db")).await?; + let detail = store.get_sticker(id).await?; + let inserted = store + .insert_sticker(StickerDetail { + id: 0, + content: "created body".into(), + ..detail + }) + .await?; + assert_eq!( + repo.capture("created body", 30).await?, + CaptureOutcome::RefreshedSticker(inserted) + ); + let pending = stored(repo.capture("edited body", 40).await?)?; + store + .update_sticker_content(inserted, "edited body".into()) + .await?; + assert!(repo.get(TextEntryId::Clipboard(pending)).await.is_err()); + assert_eq!( + repo.capture("edited body", 50).await?, + CaptureOutcome::RefreshedSticker(inserted) + ); + assert!( + repo.capture("created body", 60) + .await? + .capture_id() + .is_some(), + "old digest must not match edited content" + ); + Ok(()) + }) +} + +#[test] +fn reopen_reconciles_legacy_stickers_and_duplicate_captures() -> anyhow::Result<()> { + block_on(async { + let (dir, repo, pool) = fixture().await?; + let capture = stored(repo.capture("old duplicate", 100).await?)?; + let id = sticker(&pool, "old saved", "old duplicate", 1).await?; + sqlx::query("UPDATE stickers SET text_digest='' WHERE id=?") + .bind(id) + .execute(&pool) + .await?; + let reopened = TextRepository::open(dir.path().join("stickers.db")).await?; + assert!(reopened.get(TextEntryId::Clipboard(capture)).await.is_err()); + assert_eq!( + reopened.get(TextEntryId::Sticker(id)).await?.activity_at(), + 100 + ); + assert_eq!( + reopened.capture("old duplicate", 110).await?, + CaptureOutcome::RefreshedSticker(id) + ); + Ok(()) + }) +} + +#[test] +fn failed_duplicate_cleanup_rolls_back_recency() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + let capture = stored(repo.capture("rollback duplicate", 100).await?)?; + let id = sticker(&pool, "saved", "rollback duplicate", 1).await?; + sqlx::raw_sql("CREATE TRIGGER reject_cleanup BEFORE DELETE ON clipboard_captures BEGIN SELECT RAISE(ABORT,'reject cleanup'); END;").execute(&pool).await?; + assert!(repo.capture("rollback duplicate", 200).await.is_err()); + assert_eq!(repo.get(TextEntryId::Sticker(id)).await?.activity_at(), 1); + assert!(repo.get(TextEntryId::Clipboard(capture)).await.is_ok()); + Ok(()) + }) +} + +#[test] +fn capture_removes_existing_plaintext_duplicate_atomically() -> anyhow::Result<()> { + block_on(async { + let (_dir, repo, pool) = fixture().await?; + let capture = stored(repo.capture("same body", 100).await?)?; + let id = sticker(&pool, "saved", "same body", 1).await?; + assert_eq!( + repo.capture("same body", 50).await?, + CaptureOutcome::RefreshedSticker(id) + ); + assert!(repo.get(TextEntryId::Clipboard(capture)).await.is_err()); + assert_eq!(repo.get(TextEntryId::Sticker(id)).await?.activity_at(), 100); + Ok(()) + }) } #[test] @@ -32,7 +148,7 @@ fn multiline_preview_is_bounded_without_splitting_unicode() -> anyhow::Result<() block_on(async { let (_dir, repo, _) = fixture().await?; let text = "界\n".repeat(600); - let id = repo.capture(&text, 1).await?; + let id = stored(repo.capture(&text, 1).await?)?; let entry = repo.get(TextEntryId::Clipboard(id)).await?; let preview = entry.multiline_preview(); assert_eq!(preview.chars().count(), 1024); @@ -47,13 +163,22 @@ fn exact_capture_dedupe_and_validation() -> anyhow::Result<()> { block_on(async { let (_dir, repo, pool) = fixture().await?; let text = " \tRust 大文字\r\n'quoted' \"x\"; !\n "; - let id = repo.capture(text, 10).await?; - assert_eq!(repo.capture(text, 20).await?, id); - assert_eq!(repo.capture(text, 15).await?, id); + let id = stored(repo.capture(text, 10).await?)?; + assert_eq!(repo.capture(text, 20).await?, CaptureOutcome::Stored(id)); + assert_eq!(repo.capture(text, 15).await?, CaptureOutcome::Stored(id)); let entry = repo.get(TextEntryId::Clipboard(id)).await?; assert_eq!(&*repo.resolve(&entry, None).await?, text); assert_eq!(entry.multiline_preview(), text); assert_eq!(entry.activity_at(), 20); + let persisted_digest: String = + sqlx::query_scalar("SELECT text_digest FROM clipboard_captures WHERE id=?") + .bind(id) + .fetch_one(&pool) + .await?; + assert_eq!( + persisted_digest, + format!("{:x}", Sha256::digest(text.as_bytes())) + ); let row: (i64, i64, i64) = sqlx::query_as( "SELECT created_at,last_copied_at,byte_size FROM clipboard_captures WHERE id=?", ) @@ -61,13 +186,13 @@ fn exact_capture_dedupe_and_validation() -> anyhow::Result<()> { .fetch_one(&pool) .await?; assert_eq!(row, (10, 20, text.len() as i64)); - assert_ne!(repo.capture(&text.to_lowercase(), 21).await?, id); + assert_ne!(stored(repo.capture(&text.to_lowercase(), 21).await?)?, id); // Even a colliding digest is only an accelerator, never an identity. sqlx::query("UPDATE clipboard_captures SET text='different bytes' WHERE id=?") .bind(id) .execute(&pool) .await?; - assert_ne!(repo.capture(text, 22).await?, id); + assert_ne!(stored(repo.capture(text, 22).await?)?, id); for invalid in ["", " \r\n\t", "a\0b"] { assert!(repo.capture(invalid, 30).await.is_err()); } @@ -85,9 +210,9 @@ fn lowered_count_and_byte_limits_prune_only_captures() -> anyhow::Result<()> { block_on(async { let (_dir, repo, pool) = fixture().await?; let saved = sticker(&pool, "saved", "never evict", 1).await?; - let a = repo.capture("aa", 10).await?; - let b = repo.capture("éé", 20).await?; - let c = repo.capture("cc", 30).await?; + let a = stored(repo.capture("aa", 10).await?)?; + let b = stored(repo.capture("éé", 20).await?)?; + let c = stored(repo.capture("cc", 30).await?)?; let mut settings = repo.settings().await?; settings.normal_count_limit = 2; repo.update_settings(&settings).await?; @@ -123,7 +248,7 @@ fn promotion_consumes_source_once_and_stale_or_failed_promotion_rolls_back() -> block_on(async { let (_dir, repo, pool) = fixture().await?; let exact = " \r\n大文字 'keep' \t"; - let capture = repo.capture(exact, 1).await?; + let capture = stored(repo.capture(exact, 1).await?)?; let entry = repo.get(TextEntryId::Clipboard(capture)).await?; assert!(repo.promote(&entry, true).await.is_err()); // No key: no placeholder commit. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stickers") @@ -142,7 +267,7 @@ fn promotion_consumes_source_once_and_stale_or_failed_promotion_rolls_back() -> .fetch_one(&pool) .await?; assert_eq!(count, 1); - let evicted = repo.capture("evicted", 2).await?; + let evicted = stored(repo.capture("evicted", 2).await?)?; let stale = repo.get(TextEntryId::Clipboard(evicted)).await?; repo.delete(stale.id()).await?; assert!(repo.promote(&stale, false).await.is_err()); @@ -156,8 +281,8 @@ fn merged_order_filter_projection_and_typed_identity() -> anyhow::Result<()> { let (_dir, repo, pool) = fixture().await?; let s1 = sticker(&pool, "ALPHA", "body βeta", 10).await?; let s2 = sticker(&pool, "Other", "body βeta", 10).await?; - let c1 = repo.capture("unique", 10).await?; - let c2 = repo.capture("another", 10).await?; + let c1 = stored(repo.capture("unique", 10).await?)?; + let c2 = stored(repo.capture("another", 10).await?)?; assert_eq!(s1, c1); // Same numeric ID, separate domain objects. let all = TextSources::new(true, true); let entries = repo.snapshot(all, "").await?; @@ -181,29 +306,47 @@ fn merged_order_filter_projection_and_typed_identity() -> anyhow::Result<()> { .await? .is_empty() ); - let duplicate = repo.capture("body βeta", 50).await?; + // A new copy that matches an existing saved sticker is never persisted as + // a clipboard capture; the saved sticker's recency absorbs the activity. + let refreshed = repo.capture("body βeta", 50).await?; + let promoted_sticker = refreshed + .sticker_id() + .context("expected the duplicate capture to refresh a saved sticker")?; + assert!( + promoted_sticker == s1, + "refresh target is deterministic when saved identities share content" + ); let merged = repo.snapshot(all, "").await?; - assert_eq!(merged[0].id(), TextEntryId::Sticker(s1)); + assert_eq!(merged[0].id(), TextEntryId::Sticker(promoted_sticker)); assert_eq!(merged[0].activity_at(), 50); + assert_eq!( + merged.len(), + 4, + "duplicate capture must not create a new row" + ); + let row: Option = sqlx::query_scalar("SELECT id FROM clipboard_captures WHERE text=?") + .bind("body βeta") + .fetch_optional(&pool) + .await?; assert!( - !merged - .iter() - .any(|e| e.id() == TextEntryId::Clipboard(duplicate)) + row.is_none(), + "no plain capture row should exist for 'body βeta'" ); - assert!(repo.get(TextEntryId::Clipboard(duplicate)).await.is_ok()); // Chrome updates don't edit text MRU; direct use is monotonic and table-specific. + // `s1`'s recency has already absorbed the duplicate capture above (50). sqlx::query("UPDATE stickers SET updated_at=9999,color='blue' WHERE id=?") .bind(s1) .execute(&pool) .await?; - assert_eq!(repo.get(TextEntryId::Sticker(s1)).await?.activity_at(), 10); + assert_eq!(repo.get(TextEntryId::Sticker(s1)).await?.activity_at(), 50); repo.mark_used(TextEntryId::Clipboard(c1), 100).await?; repo.mark_used(TextEntryId::Clipboard(c1), 90).await?; assert_eq!( repo.get(TextEntryId::Clipboard(c1)).await?.activity_at(), 100 ); - assert_eq!(repo.get(TextEntryId::Sticker(s1)).await?.activity_at(), 10); + // `mark_used` only advances recency on its own table. + assert_eq!(repo.get(TextEntryId::Sticker(s1)).await?.activity_at(), 50); repo.delete(TextEntryId::Clipboard(c1)).await?; assert!(repo.get(TextEntryId::Sticker(s1)).await.is_ok()); for id in [TextEntryId::Sticker(s1), TextEntryId::Clipboard(c2)] { @@ -230,8 +373,11 @@ fn concurrent_connections_capture_and_promote_exactly_once() -> anyhow::Result<( block_on(worker_repo.capture("simultaneous capture", 20)) }); gate.wait(); - let id = repo.capture("simultaneous capture", 10).await?; - assert_eq!(worker.join().expect("capture worker panicked")?, id); + let id = stored(repo.capture("simultaneous capture", 10).await?)?; + assert_eq!( + worker.join().expect("capture worker panicked")?, + CaptureOutcome::Stored(id) + ); assert_eq!( repo.get(TextEntryId::Clipboard(id)).await?.activity_at(), 20 @@ -271,7 +417,7 @@ fn concurrent_connections_capture_and_promote_exactly_once() -> anyhow::Result<( fn failed_promotion_final_write_rolls_back_source_and_placeholder() -> anyhow::Result<()> { block_on(async { let (_dir, repo, pool) = fixture().await?; - let capture = repo.capture("must survive", 10).await?; + let capture = stored(repo.capture("must survive", 10).await?)?; let source = repo.get(TextEntryId::Clipboard(capture)).await?; sqlx::raw_sql("CREATE TRIGGER reject_promotion BEFORE DELETE ON clipboard_captures BEGIN SELECT RAISE(ABORT,'injected final promotion failure'); END;") .execute(&pool).await?; diff --git a/tests/text_titles.rs b/tests/text_titles.rs index 5f3aac1..057d9be 100644 --- a/tests/text_titles.rs +++ b/tests/text_titles.rs @@ -1,4 +1,5 @@ #![cfg(feature = "storage")] +use anyhow::Context as _; use futures::executor::block_on; use rustickers::text::{TextEntryId, TextRepository}; #[test] @@ -7,7 +8,11 @@ fn required_titles_are_searchable_and_survive_edits_and_protection() -> anyhow:: let dir = tempfile::tempdir()?; let repo = TextRepository::open(dir.path().join("stickers.db")).await?; repo.set_master_password("test-password").await?; - let capture = repo.capture("private content", 1).await?; + let capture = repo + .capture("private content", 1) + .await? + .capture_id() + .context("expected a fresh clipboard capture")?; let entry = repo.get(TextEntryId::Clipboard(capture)).await?; assert!( repo.promote_titled(&entry, true, Some(" \t ")) From f525ecd6412ffb951b16f4ebc2308eb2752cac1e Mon Sep 17 00:00:00 2001 From: binwenw Date: Fri, 9 Oct 2026 10:35:29 +0800 Subject: [PATCH 45/69] style(picker): move test module to end of file Fixes the clippy items_after_test_module warning so the tree is lint-clean. --- src/native/windows/text/picker.rs | 43 ++++++++++++++++--------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/native/windows/text/picker.rs b/src/native/windows/text/picker.rs index 6aeeda7..d84c47a 100644 --- a/src/native/windows/text/picker.rs +++ b/src/native/windows/text/picker.rs @@ -1030,27 +1030,6 @@ impl Render for TextPicker { } } -#[cfg(test)] -mod picker_tests { - use super::*; - #[test] - fn source_filters_keep_typed_identity_separate() { - assert!(SourceFilter::All.includes(TextEntryId::Sticker(1))); - assert!(SourceFilter::All.includes(TextEntryId::Clipboard(1))); - assert!(SourceFilter::Text.includes(TextEntryId::Sticker(1))); - assert!(!SourceFilter::Text.includes(TextEntryId::Clipboard(1))); - assert!(SourceFilter::Clipboard.includes(TextEntryId::Clipboard(1))); - assert!(!SourceFilter::Clipboard.includes(TextEntryId::Sticker(1))); - } - #[test] - fn relative_time_handles_future_and_boundaries() { - assert_eq!(recency_label(10_000, 0), "Just now"); - assert_eq!(recency_label(0, 60_000), "1m ago"); - assert_eq!(recency_label(0, 3_600_000), "1h ago"); - assert_eq!(recency_label(0, 86_400_000), "Yesterday"); - assert_eq!(recency_label(0, 172_800_000), "2d ago"); - } -} fn dispatch_content( filter: &Arc, content: &str, @@ -1085,3 +1064,25 @@ fn dispatch_content( } Ok(()) } + +#[cfg(test)] +mod picker_tests { + use super::*; + #[test] + fn source_filters_keep_typed_identity_separate() { + assert!(SourceFilter::All.includes(TextEntryId::Sticker(1))); + assert!(SourceFilter::All.includes(TextEntryId::Clipboard(1))); + assert!(SourceFilter::Text.includes(TextEntryId::Sticker(1))); + assert!(!SourceFilter::Text.includes(TextEntryId::Clipboard(1))); + assert!(SourceFilter::Clipboard.includes(TextEntryId::Clipboard(1))); + assert!(!SourceFilter::Clipboard.includes(TextEntryId::Sticker(1))); + } + #[test] + fn relative_time_handles_future_and_boundaries() { + assert_eq!(recency_label(10_000, 0), "Just now"); + assert_eq!(recency_label(0, 60_000), "1m ago"); + assert_eq!(recency_label(0, 3_600_000), "1h ago"); + assert_eq!(recency_label(0, 86_400_000), "Yesterday"); + assert_eq!(recency_label(0, 172_800_000), "2d ago"); + } +} From fb3d8bccda3edffc00de3e89e6b8669780f49de1 Mon Sep 17 00:00:00 2001 From: rustickers Date: Fri, 9 Oct 2026 13:36:33 +0800 Subject: [PATCH 46/69] =?UTF-8?q?feat(cli):=20break=20redesign=20=E2=80=94?= =?UTF-8?q?=20ergonomic=20human=20interface,=20deterministic=20agent=20con?= =?UTF-8?q?tract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ambiguous subcommands and hidden ambient behavior with an explicit grammar that serves both humans and supervising agents. Top-level: rst note [TEXT | --file PATH | --file -] # Markdown notes from text or stream rst command [--shell SCRIPT] -- PROGRAM ARGS # direct exec, no shell parsing rst list | show ID | result ID # discover and inspect rst open ID | close ID # window state (desktop only) rst delete ID --yes # permanent removal rst view PATH # transient file/folder/URL preview rst text list [--source/--state/--search] # text entries rst text exec [--id SOURCE:ID --non-interactive] [--terminal] -- ARGS rst skill list | show | run [--set K=V] [--dry-run] | guide [--json] Global: --json (one object), --data-dir PATH (isolated, offline), --offline. Exit codes: 0 ok, 2 grammar/clap, 1 runtime; text exec propagates child status (130 cancel, 126/127 launch failure). Architecture: - new Runtime{paths,offline} as the single seam for storage location and IPC - Cli::try_parse_from cross-validates global + subcommand conflicts so placement before or after the subcommand is identical - utils::command_line::split replaces winsplit: Windows-style quoting, preserves empty arguments, rejects NUL and unterminated quotes; shared between CLI and the desktop command runner so preview and execution parse identically - New agent-guide.md documents the canonical recipe for supervising agents; parsed by tests/cli_docs.rs so the documentation never drifts - AppPaths::default_location separates discovery from filesystem creation, so dry runs and JSON-only subcommands never touch %LOCALAPPDATA% Verification: - tests/cli_process.rs (8) — binary startup, exit codes, --data-dir isolation - tests/cli_docs.rs (parser-tests every literal `rst …` in README + agent-guide) - tests/skill_cli.rs (11) — black-box catalog, dry-run, --set validation, recipes - tests/skill_execution.rs (12) — desktop command runner against recipe outputs (escaped selection is data; static code never embeds engines; hostile --set values stored verbatim) - cargo test --features cli: 205 passed, 0 failed - cargo clippy --features cli -- -D warnings: clean - sub-agent audits validated chooser search, exit-code propagation, recipe isolation, JSON error shape, byte-exact Unicode output No live user database was modified; all experiments ran under --data-dir. --- ARCHITECTURE.md | 6 +- Cargo.lock | 9 +- Cargo.toml | 6 +- README.md | 104 +- src/cli/agent-guide.md | 143 +++ src/cli/commands/cmd.rs | 460 -------- src/cli/commands/command.rs | 1048 +++++++++++++++++ src/cli/commands/delete.rs | 26 +- src/cli/commands/list.rs | 164 +-- src/cli/commands/mod.rs | 5 +- src/cli/commands/{markdown.rs => note.rs} | 26 +- src/cli/commands/result.rs | 14 +- src/cli/commands/show.rs | 35 +- src/cli/commands/skill.rs | 238 +++- src/cli/commands/text.rs | 287 +++++ src/cli/commands/text_argv.rs | 10 +- src/cli/commands/text_execution.rs | 26 + src/cli/commands/text_terminal.rs | 11 +- src/cli/commands/view.rs | 23 +- src/cli/commands/window.rs | 76 +- src/cli/draft.rs | 41 +- src/cli/mod.rs | 351 +++--- src/cli/output.rs | 52 +- src/cli/runtime.rs | 108 +- src/cli/shell.rs | 360 ++++-- src/cli/skills.rs | 677 ++++++++--- .../components/stickers/command/runner.rs | 51 +- src/rst.rs | 52 +- src/storage/paths.rs | 12 +- src/storage/sqlite.rs | 5 +- src/utils/command_line.rs | 116 ++ src/utils/mod.rs | 1 + tests/cli_docs.rs | 33 + tests/cli_process.rs | 221 ++++ tests/skill_cli.rs | 197 ++++ tests/skill_execution.rs | 229 ++++ tests/sticker_palette.rs | 1 + tests/text_cli.rs | 385 ++++-- 38 files changed, 4234 insertions(+), 1375 deletions(-) create mode 100644 src/cli/agent-guide.md delete mode 100644 src/cli/commands/cmd.rs create mode 100644 src/cli/commands/command.rs rename src/cli/commands/{markdown.rs => note.rs} (82%) create mode 100644 src/cli/commands/text.rs create mode 100644 src/utils/command_line.rs create mode 100644 tests/cli_docs.rs create mode 100644 tests/cli_process.rs create mode 100644 tests/skill_cli.rs create mode 100644 tests/skill_execution.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dd11466..a55f757 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -22,7 +22,7 @@ The desktop is single-instance. IPC carries requests from the CLI or another lau | `src/native/windows/` | Manager, sticker windows, TXTs, selection popup, dialogs | | `src/native/components/stickers/` | Markdown, timer, command, file, and paint views | | `src/native/clipboard/` | OS collection, write suppression, verified paste | -| `src/cli/` | Parsing, commands, terminal chooser, safe child argv | +| `src/cli/` | Explicit sticker commands, separate text workflows, terminal chooser, exact argv, bundled recipes/agent guide | | `migrations/`, `tests/` | Database evolution and regression coverage | Features: `ui` is enabled by default; `cli` builds `rst`; both depend on `storage`. UI dependencies enter through GPUI Kit and its matching WebView adapter. @@ -64,7 +64,9 @@ Command stickers keep execution/scheduling state independently of window visibil The clipboard observer writes ordinary captures through `TextRepository`. Internal decrypted writes are suppressed; externally recopied secrets are not guaranteed exclusion. Paste verifies the destination and modifier release. Windows supports ownership-aware protected clearing; unavailable capabilities fail closed. A newer clipboard value must never be cleared. -The CLI substitutes `{{txt}}` only in child argument tokens, never the executable. It preserves exact text, inherits stdio, and propagates child exit status. No implicit shell execution or expanded-secret argv logging is allowed. +The CLI separates sticker `list` from `text list`/`text exec`. Direct command creation accepts an argument vector after `--` and round-trip-validates its encoding into the desktop's command storage; shell scripts are explicit. `{{txt}}` is substituted only in immediate child argument tokens, never the executable. It preserves exact text, inherits stdio, and propagates child exit status. Selection stickers use `{{RUSTICKERS_SELECTION}}` or the environment variable; shell selection uses environment data rather than interpolation into code. No implicit shell execution or expanded-secret argv logging is allowed. + +One invocation-owned runtime carries storage location and IPC policy through ordinary commands and recipe expansion. `--data-dir` and library test execution are always offline, preventing experimental IDs from controlling real windows. Notification status describes transport delivery, not application acknowledgement. Parsing/validation precede storage initialization; help, recipes, and the bundled agent guide are database-free. JSON usage and runtime errors use the same `ok` envelope. Recipe previews/execution share one nonrecursive argv expansion; tests enforce parser and documentation synchronization. ## Making changes diff --git a/Cargo.lock b/Cargo.lock index d17ae87..241a690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7873,12 +7873,14 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "shlex 2.0.1", "sqlx", "tempfile", "tracing", "tracing-appender", "tracing-error", "tracing-subscriber", + "unicode-width", "url", "urlencoding", "webbrowser", @@ -7886,7 +7888,6 @@ dependencies = [ "windows 0.62.2", "windows-sys 0.61.2", "winres", - "winsplit", "zeroize", ] @@ -11547,12 +11548,6 @@ dependencies = [ "toml 0.5.11", ] -[[package]] -name = "winsplit" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab703352da6a72f35c39a533526393725640575bb211f61987a2748323ad956" - [[package]] name = "wio" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 3061855..613023a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ ui = [ "dep:lyon", "dep:mimalloc", "dep:rdev", - "dep:winsplit", "dep:rust-embed", "dep:notify-rust", "dep:async-compat", @@ -65,6 +64,7 @@ ui = [ cli = [ "dep:crossterm", "dep:clap", + "dep:unicode-width", "dep:futures", "dep:sqlx", "storage", @@ -72,7 +72,6 @@ cli = [ # at creation time instead of silently failing later inside the app. "dep:cron", "dep:which", - "dep:winsplit", "dep:arboard", "dep:sha2", "dep:zeroize", @@ -92,6 +91,7 @@ required-features = ["cli"] [dependencies] crossterm = { version = "0.29", features = ["use-dev-tty"], optional = true } +unicode-width = { version = "0.2", optional = true } # GPUI Kit single consumer entry point. Kit re-exports the gpui-pre family # as `gpui_kit::gpui` and the component library as `gpui_kit::component`, so # application code can adopt the Kit namespaces without depending on the @@ -132,7 +132,6 @@ lyon = { version = "1.0.16", optional = true } mimalloc = { version = "0.1.52", features = ["v2"], optional = true } rdev = { version = "0.5.3", optional = true } which = { version = "8.0.0", optional = true } -winsplit = { version = "0.1.0", optional = true } rust-embed = { version = "8", features = ["interpolate-folder-path"], optional = true } notify-rust = { version = "4.11.7", optional = true } async-compat = { version = "0.2.5", optional = true } @@ -180,3 +179,4 @@ winres = "0.1" [dev-dependencies] chrono = { version = "0.4.43", default-features = false, features = ["clock", "std"] } tempfile = "3" +shlex = "2" diff --git a/README.md b/README.md index 102bcaa..ea43fb4 100644 --- a/README.md +++ b/README.md @@ -64,38 +64,108 @@ Internally copied decrypted text is excluded from collection, but externally cop ## CLI +`rst` separates saved stickers, text workflows, and live previews. One command has one job; there are no legacy command aliases or implicit file-path commands. Start with `rst --help`, then `rst --help`. + +### Save and manage stickers + ```bash -rst ls # open stickers; list is an alias -rst ls --text --cp -s notes # merged text/clipboard search -rst ls --text --cp --json # redacted metadata -rst ls --text --cp -- my-tool '{{txt}}' # choose text and pass it as an argument -rst ls --text --id sticker:42 -- my-tool '{{txt}}' -rst markdown -c "Remember this" -rst view ./report.html -rst cmd "cargo test" --dir . --result text -rst cmd "npm test 2>&1 | tail -20" --shell -rst cmd "gh pr list" --cron "0 */5 * * *" -rst show 42 -rst result 42 +rst note # blank editable note +rst note "Remember this" # Markdown note +rst note --file notes.md # exact UTF-8 file contents +rst note --file - # read stdin; empty note is valid +rst list # all stickers, open and closed +rst list --type command --state open -s tests +rst show 42 # inspect stored content and settings +rst show 42 --content-only # exact content, no added newline rst open 42 rst close 42 -rst delete 42 +rst delete 42 --yes # permanent; explicit for scripts +``` + +Sticker IDs are positive integers. States are `all`, `open`, and `closed`. Creating a note opens it by default; `--closed` saves it without a window. Width and height must be positive; color names are validated. Deletion asks for confirmation only in an interactive terminal; JSON and redirected input require `--yes`. + +### Command stickers + +```bash +rst command --dir . --result text -- cargo test +rst command --title "Git status" --closed -- git status --short +rst command --shell "npm test 2>&1" # explicit platform shell +rst command --cron "0 */5 * * * *" -- gh pr list +rst result 42 # latest stored output, not a new run +``` + +Put sticker options **before `--`**; everything after it is the program's exact argument list. No second layer of command-string quoting is needed. Direct mode never invokes a shell. `--shell SCRIPT` is exclusive with direct arguments; it uses PowerShell on Windows (`pwsh` when available) and `sh` elsewhere. Shell scripts are platform-specific code. + +Creation **saves a command; it does not run it in the CLI or wait for output**. The desktop runs armed commands according to their lifecycle. `--idle` saves a stopped command; selection commands are triggered only by the hotkey. Schedules remain armed when their windows are closed and wait for their first tick after creation/app launch. `--run-now` requires a schedule and applies to a manual start from the sticker window, not creation or restoration. Cron uses six fields beginning with seconds, with an optional seventh year field. + +Working directories are checked and pinned to absolute paths at creation, including the default current directory. Repeat `--env KEY=VALUE` to set environment variables; values retain whitespace, names and duplicates are checked. Missing executables are errors unless explicitly allowed with `--allow-missing-program`; malformed commands still fail. Inert or conflicting options are rejected rather than silently ignored. `--result` supports `text`, `markdown`, `html`, `svg`, and `source`; progressive `--stream` requires text or Markdown. + +### Text to a program + +```bash +rst text list # saved notes + disposable captures +rst text list --source saved --search notes +rst text list --source clipboard --json +rst text exec -- my-tool '{{txt}}' # choose interactively +rst text exec --id sticker:42 --non-interactive -- my-tool '{{txt}}' +rst text exec --source clipboard --id clipboard:42 --non-interactive -- my-tool ``` -`--text` selects text stickers; `--cp` selects captures. Combine them for both. IDs are source-specific: `sticker:42` and `clipboard:42`. Without a child command, `ls` prints a table or JSON. The old `cp` and `vault` commands are removed. +Both text commands default to `--source all --state all`. Source values are `saved`, `clipboard`, and `all`. Typed IDs (`sticker:42`, `clipboard:42`) prevent cross-table collisions. State filters apply to saved notes, not captures. Text listings are metadata/previews, not a plaintext export; locked entries expose only public titles/metadata. + +Quote `{{txt}}` for your shell. It is replaced only in argument tokens, never the executable. Exact text stays one argument, including quotes, newlines, and shell characters; inserted text is not expanded recursively. Without a placeholder, text is appended as one argument. Child stdin/stdout/stderr are inherited, and its exit status is returned. `--json` is incompatible with `text exec`; a child's own `--json` after `--` remains its argument. + +The chooser and password prompt use the controlling terminal, leaving child stdin untouched. For automation, use `--id` and `--non-interactive`; encrypted entries fail instead of prompting. Interactive decryption authorizes one use. Putting text into an interpreter's code argument can execute it, and sensitive argv can be visible to other processes. -Quote `{{txt}}` for your shell. Substitution preserves text as argument data; without a placeholder, it is appended as one argument. Rustickers does not implicitly invoke a shell. Passing text to an interpreter's code argument can execute it, and secret argv can be visible to other tools. Use `rst --help` for options; `rst skill list` lists ready-made sticker recipes. +Selection **stickers** use a different placeholder: `{{RUSTICKERS_SELECTION}}`, or the `RUSTICKERS_SELECTION` environment variable. Use `--accept-selection`; these stickers default closed. Shell code must read the environment variable rather than splice the placeholder into code. + +### Recipes and agents + +```bash +rst skill list +rst skill show translate +rst skill run translate --set language=Japanese --dry-run +rst skill run note --set "text=Remember this" +rst skill guide # operational Markdown guide for agents +rst skill guide --json # same guide in a JSON response +``` + +Recipes are bundled with the binary, expand to the same canonical commands, and are checked against the parser. `--set KEY=VALUE` is repeatable; unknown, duplicate, and missing required variables fail. `--dry-run` validates without creating storage or stickers. External tools are prerequisites, not installed automatically. `skill show` identifies incomplete recipes and platform requirements. Preview and execution share one expansion path; JSON argument arrays are authoritative, human command lines are quoted for the host shell. + +### Automation contract + +```bash +rst --json list +rst --data-dir ./scratch --json note "Experiment" --closed +rst --offline command --closed -- cargo test +``` + +- `--json` works before or after the subcommand and returns **one object on stdout** with `ok: true` or `ok: false`. Help/version are normal text. Grammar errors exit **2**, validation/runtime failures **1**, success **0**. `text exec` is the child-stdio exception, including cancellation **130** and launch errors **126/127**. +- `list` and `text list` return `items`, `count`, and `total`. IDs retain their command-specific integer/typed-string representation. +- Desktop notification is `sent`, `unavailable`, `offline`, or `not_requested`. `sent` means transport delivery, **not** that a window opened or execution finished. Stored command results distinguish `armed` from `has_output`. +- `--data-dir PATH` uses `PATH/stickers.db` and **always disables desktop IPC**, so experimental IDs cannot affect real windows. `--offline` disables IPC while using normal storage. Both propagate through recipe execution. Discovery, help, and dry-runs do not initialize storage. +- `view PATH_OR_URL` is a transient desktop-only request; it requires an existing source or URL and enabled IPC. It does not save a sticker. +- On Windows, Git Bash/MSYS can rewrite path-looking arguments before `rst` receives them. Set `MSYS_NO_PATHCONV=1` when literal child arguments such as `/c` must remain unchanged. ## Data -The app and CLI share `stickers.db`; logs live beside it in `logs/`. +The app and CLI share `stickers.db`; logs live beside it in `logs/`. The CLI's `--data-dir` override is isolated and does not change the desktop's location. - Windows: `%LOCALAPPDATA%\rustickers\data\` - macOS: `~/Library/Application Support/rustickers/data/` -- Linux: `~/.local/share/rustickers/data/` +- Linux: `~/.local/share/rustickers/` Startup imports usable raw text once from legacy clipboard storage, without recovering encrypted content. The old `clipboard.db` is left untouched and may contain sensitive data. Log level: `RUSTICKERS_LOG`, then `RUST_LOG`, then the build default. ## Contributing See [ARCHITECTURE.md](ARCHITECTURE.md) for the system map and [FRONTEND.md](FRONTEND.md) for desktop conventions and checks. + +CLI-only verification (no desktop dependencies): + +```bash +cargo test --no-default-features --features cli +cargo clippy --no-default-features --features cli --all-targets -- -D warnings +``` + +Process tests use temporary stores and disabled IPC. The bundled agent guide and recipes are tested against the same parser as the binary. diff --git a/src/cli/agent-guide.md b/src/cli/agent-guide.md new file mode 100644 index 0000000..dd0ba7f --- /dev/null +++ b/src/cli/agent-guide.md @@ -0,0 +1,143 @@ +# rst agent guide + +Operational handbook for running `rst` (the Rustickers CLI) from an agent. Every fenced +command line below is parser-tested against the real argument definitions; lines that begin +with `#` are commentary. + +## Mental model + +- `rst` writes stickers to a local SQLite store. It **creates and inspects**; the desktop app + **executes**. `rst command ...` saves a sticker — it does not run the program. The sticker + runs when its window opens, on its schedule, or when the app starts while it is armed. +- `rst result ` prints the latest stored output of a command sticker; it never executes or + waits. +- Every call replies with a human report, or — with `--json` — exactly one JSON object that + always carries an `ok` field. Parse the payload; do not scrape prose. + +## Global flags (outer level only) + +- `--json` one JSON object per call, errors included (except text help/version). It is incompatible + with `text exec`, whose child owns stdio. Grammar errors exit 2; validation/runtime failures exit 1; + successful commands exit 0. Child statuses pass through, cancellation is 130, launch failures 126/127. +- `--data-dir PATH` use `PATH/stickers.db` instead of the user's store **and disable desktop + IPC**. This is the isolation switch: every experiment, test or dry run an agent performs + must carry it. +- `--offline` save without notifying the running desktop app. Delivery reports read + `sent`, `unavailable`, `offline`, `not_requested`. + +## Grammar + +- `rst list` — all stickers, open and closed. +- `rst show ID [--content-only]` — everything stored about one sticker. +- `rst result ID` — latest stored command output; never executes. +- `rst open ID` / `rst close ID` — request a window; schedules keep running. +- `rst delete ID --yes` — permanent delete. +- `rst view PATH` — transient preview (desktop required). +- `rst note [TEXT | --file PATH | --file -]` — markdown note; TEXT is positional. +- `rst command [OPTIONS] -- PROGRAM ARGS` — save a direct-argv command sticker. +- `rst command [OPTIONS] --shell SCRIPT` — same, but SCRIPT runs in a shell (exclusive). +- `rst text list [--source saved|clipboard|all] [--state all|open|closed] [--json]` — + text/clipboard metadata only. +- `rst text exec [--source ...] [--state ...] [--id sticker:N|clipboard:N] + [--non-interactive] -- PROGRAM ARGS` — hand one stored text to a child program. +- `rst skill list` / `rst skill show NAME` / `rst skill run NAME [--set KEY=VALUE ...] + [--dry-run]` / `rst skill guide` — the recipe catalogue. + +## Commands do not run through a shell + +With `-- PROGRAM ARGS`, argv is preserved exactly: empty arguments stay empty, quotes are +data, and the first token is resolved on `PATH` and executed directly. Pipes, `>`, `&&` and +`$(...)` are literal text here. When you genuinely want a shell, pass the whole script as the +one `--shell SCRIPT` value: on Windows that is PowerShell (`pwsh`, falling back to +`powershell`); elsewhere it is `sh -c`. The script is code you chose — never interpolate +untrusted text into it. + +```text +# a direct command, run in this checkout +rst command --dir . -- cargo test +# pipes and redirects need the explicit shell form +rst command --shell "git status | head -20" +# a selection sticker whose prompt is one argv segment +rst command --accept-selection --result markdown -- copilot -p "Summarize this: {{RUSTICKERS_SELECTION}}" +``` + +## Selection contract + +`--accept-selection` offers the sticker on the selected-text hotkey. + +- `{{RUSTICKERS_SELECTION}}` is substituted into **argument positions only**, as one argv + value, as data — it is never re-parsed, never expanded again, and rejected in the program + position. Keep the placeholder inside the one argument that should receive the text. +- The selection is always also exported as the `RUSTICKERS_SELECTION` environment variable. + Prefer the variable inside static shell code, quoted as data (for example, `"$RUSTICKERS_SELECTION"` + in POSIX shell). Never evaluate the value as code or splice it into the script string. +- Passing text to an interpreter's code argument (`-c`, `-Command`) can execute it. The + built-in skills keep code static and pass data via argv or `--env`. + +## Text workflows + +`rst text list` prints metadata and bounded plaintext previews; locked entries expose only public titles. `rst text exec` substitutes the chosen +text into argv: `{{txt}}` is replaced as data in each argument that carries it, and when no +argument has the placeholder the text is appended as one final argument. Child stdio and exit +status pass through. Agents use `--id` with `--non-interactive` to skip the chooser. + +```text +# metadata only, machine-readable +rst text list --json +# hand one capture to ripgrep without ever opening a chooser +rst text exec --id clipboard:42 --non-interactive -- rg "{{txt}}" +``` + +## Schedules + +`--cron` takes a 6-field Quartz expression (second first): `0 */5 * * * *` is every five +minutes; the five-field crontab form is rejected at the door. A scheduled sticker keeps +ticking while the app runs, whether or not its window is open. + +## Skills + +- `rst skill list` / `rst skill show ` are pure discovery: no database, no PATH + lookups. `show` parser-validates its expansion; when a variable has no default it prints + the template marked **not runnable** plus the `--set` form — never a fabricated command. +- `rst skill run [--set KEY=VALUE ...] [--dry-run]` creates the sticker. `--set` + rejects unknown keys, duplicate keys and empty names. `${name}` recipe substitution is one-pass: + inserted values are not rescanned for more recipe variables. The desktop selection placeholder + `{{RUSTICKERS_SELECTION}}` still has its separate runtime meaning in selection arguments. +- `--dry-run` runs the full semantic validation (program on `PATH`, selection contract, + cron) and writes nothing. Use it before creating, and always with `--data-dir`. +- In JSON replies the `argv` array is canonical; `command_line` is a convenience line quoted + for the host shell (PowerShell rules on Windows, POSIX single quotes elsewhere). Drive + programs from `argv`, not from quoted text. +- `rst skill guide` prints this guide from inside the binary — no database needed. + +## The catalogue + +| Skill | What it creates | Platform | +| --- | --- | --- | +| ask | selection → one prompt argument → markdown reply | any tool on PATH | +| translate | one prompt argument holding the instruction, `--set language=...` | any tool on PATH | +| explain-code | one prompt argument explaining the selected code | any tool on PATH | +| web-search | static PowerShell code, `--env SEARCH_ENGINE=...` data only | Windows | +| format-json | static PowerShell code, `--env JSON_DEPTH=...` data only | Windows | +| watch | scheduled `--shell` script, `--stream` output | platform shell | +| html-report | `--shell` script printing an HTML document | platform shell | +| svg-chart | `--shell` script printing an SVG document | platform shell | +| note | markdown note pinned to the desktop | any | + +```text +# discover the catalogue and one recipe in detail +rst skill list +rst skill show translate +# create a Japanese menu entry after checking it validates +rst skill run translate --set language=Japanese --dry-run +# re-run the tests every five minutes +rst skill run watch --set script="cargo test" --set cron="0 */5 * * * *" --dry-run +# the plain ask sticker with its default tool +rst skill run ask --dry-run +# this handbook, from inside the binary +rst skill guide +# a pinned note +rst note "Remember this" +# everything above, safely, inside an isolated store +rst --data-dir ./demo-db skill list +``` diff --git a/src/cli/commands/cmd.rs b/src/cli/commands/cmd.rs deleted file mode 100644 index cba1f03..0000000 --- a/src/cli/commands/cmd.rs +++ /dev/null @@ -1,460 +0,0 @@ -//! `rst cmd` — create a command sticker. -//! -//! This is the command with the most surface, because a command sticker is the most configurable -//! thing Rustickers has: it can render its output five different ways, run on a schedule, run -//! invisibly, or wait to be fed the text you have selected in another application. - -use anyhow::{Context as _, bail}; -use clap::ValueEnum; -use serde_json::json; -use std::path::Path; - -use crate::model::content::{ - CommandContent, CommandResult, SELECTION_ENV_VAR, SELECTION_PLACEHOLDER, Scheduler, -}; -use crate::model::sticker::{StickerColor, StickerType}; -use crate::storage::paths::AppPaths; -use crate::utils::time::now_unix_millis; - -use crate::cli::draft::{Geometry, StickerDraft}; -use crate::cli::output::{Format, ellipsize}; -use crate::cli::runtime::open_store; -use crate::cli::shell; - -/// How a command sticker renders whatever its command writes to stdout and stderr. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum ResultFormat { - /// Plain text. The safe default. - Text, - /// Rendered markdown: headings, lists, tables, code blocks. - Markdown, - /// The output is a full HTML document, shown in an embedded browser view. - Html, - /// The output is an SVG document, drawn as an image. - Svg, - /// The output is a single file path or URL, opened in the file preview. - Source, -} - -impl ResultFormat { - fn to_result(self) -> CommandResult { - match self { - Self::Text => CommandResult::Text(None), - Self::Markdown => CommandResult::Markdown(None), - Self::Html => CommandResult::Html(None), - Self::Svg => CommandResult::Svg(None), - Self::Source => CommandResult::Source(None), - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Text => "text", - Self::Markdown => "markdown", - Self::Html => "html", - Self::Svg => "svg", - Self::Source => "source", - } - } - - /// Whether output can be appended line by line as it arrives. The other formats need a - /// complete document before they can render anything. - fn supports_streaming(self) -> bool { - matches!(self, Self::Text | Self::Markdown) - } -} - -#[derive(clap::Args, Debug)] -pub struct CmdArgs { - /// The command to run, as a single string - /// - /// It is split with Windows argument rules and the program is looked up on PATH. There is no - /// shell, so pipes, redirection and `&&` are literal text unless you pass --shell. - #[arg(value_name = "COMMAND")] - pub command: String, - - /// Title shown in the sticker list and the selection picker - #[arg(long, short = 't', value_name = "TEXT")] - pub title: Option, - - /// How to render the output - #[arg(long, value_enum, default_value = "text", value_name = "FORMAT")] - pub result: ResultFormat, - - /// Run the command through the system shell, so pipes and redirection work - #[arg(long)] - pub shell: bool, - - /// Offer this sticker when you trigger the selected-text hotkey - /// - /// The selected text replaces {{RUSTICKERS_SELECTION}} in the arguments, and is always - /// exported as the RUSTICKERS_SELECTION environment variable. - #[arg(long, help_heading = "Behaviour")] - pub accept_selection: bool, - - /// Run on a schedule, as a 6-field Quartz cron expression - /// - /// Fields are: second minute hour day-of-month month day-of-week. "0 */5 * * * *" is every - /// five minutes. - /// - /// The schedule keeps running for as long as the app is, whether or not the sticker's window - /// is open, so `rst result` always has the latest run waiting. - #[arg(long, value_name = "EXPR", help_heading = "Behaviour")] - pub cron: Option, - - /// With --cron, also run once immediately when started from the sticker's own window - /// - /// It does not affect the run that follows creation, or a run resumed at app launch: those - /// always wait for the first tick so that starting the app does not fire every schedule at - /// once. - #[arg(long = "run-now", help_heading = "Behaviour")] - pub run_now: bool, - - /// Create the sticker stopped, so nothing runs until you press Run - /// - /// Without this the sticker is armed: it runs when its window opens, and again each time the - /// app starts, which is what makes `rst result` useful. - #[arg(long, help_heading = "Behaviour")] - pub idle: bool, - - /// Show output line by line while the command runs, clearing the previous run first - #[arg(long, help_heading = "Behaviour")] - pub stream: bool, - - /// Never show a window; it only appears if the command fails - #[arg(long = "no-window", help_heading = "Behaviour")] - pub no_window: bool, - - /// Close the window once the command finishes successfully - #[arg(long = "auto-close", help_heading = "Behaviour")] - pub auto_close: bool, - - /// Environment variable for the command, repeatable - #[arg(long, value_name = "KEY=VALUE", help_heading = "Behaviour")] - pub env: Vec, - - /// Working directory for the command - /// - /// A relative path is resolved against the current directory now, because the sticker will - /// later run from the desktop app's working directory, not from here. - #[arg(long, value_name = "PATH", help_heading = "Behaviour")] - pub dir: Option, - - /// Padding around the output, 0-64 pixels - #[arg(long, value_name = "PX", value_parser = parse_padding, help_heading = "Appearance")] - pub padding: Option, - - /// Store the command even if its program cannot be found on PATH - #[arg(long)] - pub no_validate: bool, - - #[command(flatten)] - pub geometry: Geometry, -} - -fn parse_padding(s: &str) -> Result { - match s.parse::() { - Ok(value) if value <= 64 => Ok(value), - _ => Err(format!("'{s}' is not a whole number between 0 and 64")), - } -} - -/// Turn `KEY=VALUE` arguments into the newline-separated form a command sticker stores. -fn parse_env(pairs: &[String]) -> anyhow::Result { - let mut lines = Vec::with_capacity(pairs.len()); - for pair in pairs { - let Some((key, value)) = pair.split_once('=') else { - bail!("--env expects KEY=VALUE, got '{pair}'"); - }; - if key.trim().is_empty() { - bail!("--env has an empty variable name in '{pair}'"); - } - if value.contains('\n') { - bail!("--env values cannot contain newlines ('{key}')"); - } - lines.push(format!("{}={}", key.trim(), value.trim())); - } - Ok(lines.join("\n")) -} - -/// Point out the combinations that are accepted but will not do what they look like they do. -/// -/// These are all cases the sticker runtime resolves silently, so the only chance to say anything -/// is here, while the person (or agent) that chose them is still paying attention. -fn collect_warnings( - args: &CmdArgs, - inspection: Option<&shell::Inspection>, - cron: Option<&String>, -) -> Vec { - let mut warnings = Vec::new(); - - if let Some(inspection) = inspection - && !inspection.literal_shell_syntax.is_empty() - { - warnings.push(format!( - "{} will reach the program as literal text, not interpreted — pass --shell if you \ - meant to use a shell.", - inspection - .literal_shell_syntax - .iter() - .map(|s| format!("'{s}'")) - .collect::>() - .join(", ") - )); - } - - let uses_placeholder = inspection.is_some_and(|i| i.uses_selection_placeholder); - if args.accept_selection && !uses_placeholder { - warnings.push(format!( - "the command has no {SELECTION_PLACEHOLDER} argument, so the selected text will only \ - reach it through the {SELECTION_ENV_VAR} environment variable." - )); - } - if !args.accept_selection && uses_placeholder { - warnings.push(format!( - "{SELECTION_PLACEHOLDER} is only substituted during a selection run — add \ - --accept-selection, or it is passed through literally." - )); - } - if args.accept_selection && !args.geometry.closed { - warnings.push( - "a selection sticker does not have to be open to be offered; --closed creates it \ - without a window getting in the way." - .to_owned(), - ); - } - - if args.run_now && cron.is_none() { - warnings.push( - "--run-now only matters together with --cron; without a schedule the command already \ - runs whenever the sticker opens." - .to_owned(), - ); - } - if cron.is_some() && !args.idle { - warnings.push( - "a scheduled sticker waits for its first tick, so `rst result` stays empty until \ - then." - .to_owned(), - ); - } - if args.stream && !args.result.supports_streaming() { - warnings.push(format!( - "--stream only renders progressively for text and markdown; for {} it just clears the \ - previous output when a run starts.", - args.result.as_str() - )); - } - if args.auto_close && args.no_window { - warnings.push( - "--auto-close is redundant with --no-window: a hidden window already disposes of \ - itself when the command succeeds." - .to_owned(), - ); - } - if cron.is_some() && (args.auto_close || args.no_window) { - warnings.push( - "a scheduled sticker's window is never closed automatically while it is armed; close \ - it yourself and the schedule carries on in the background." - .to_owned(), - ); - } - if args.padding.is_some() && args.result == ResultFormat::Source { - warnings.push("--padding has no effect on a source result.".to_owned()); - } - if args.idle && args.accept_selection { - warnings.push( - "--idle is redundant with --accept-selection: a selection sticker only ever runs when \ - the selected-text hotkey feeds it." - .to_owned(), - ); - } - if args.idle { - warnings.push( - "--idle means nothing runs yet, so `rst result` will stay empty until you press \ - Run on the sticker." - .to_owned(), - ); - } - - warnings -} - -/// Turn a relative working directory into an absolute one. -/// -/// The sticker runs inside the desktop app, whose current directory is unrelated to the shell the -/// CLI was invoked from, so `--dir .` has to be pinned down here to mean what the user meant. -fn resolve_dir(dir: Option<&str>) -> anyhow::Result { - let Some(dir) = dir.map(str::trim).filter(|dir| !dir.is_empty()) else { - return Ok(String::new()); - }; - - let path = Path::new(dir); - if path.is_absolute() { - return Ok(dir.to_owned()); - } - - let absolute = std::env::current_dir() - .context("resolve the current directory for --dir")? - .join(path); - // `canonicalize` would give a \\?\ path that some programs mishandle, so only tidy up the - // components we introduced ourselves. - Ok(absolute - .to_str() - .map(|s| s.trim_end_matches("\\.").to_owned()) - .unwrap_or_else(|| absolute.to_string_lossy().into_owned())) -} - -pub fn run(app_paths: &AppPaths, args: CmdArgs, format: Format) -> anyhow::Result<()> { - let command = if args.shell { - shell::wrap_in_shell(args.command.trim()) - } else { - args.command.trim().to_owned() - }; - - if command.is_empty() { - bail!("the command is empty"); - } - - let inspection = if args.no_validate { - None - } else { - Some(shell::validate(&command)?) - }; - let cron = args.cron.as_deref().map(shell::validate_cron).transpose()?; - let working_dir = resolve_dir(args.dir.as_deref())?; - - // `started_at` is what arms a command sticker: the window only runs or schedules its command - // when a start time is present. Leaving it unset would create a sticker that sits there doing - // nothing until someone presses Run. A selection sticker is the exception — it is triggered by - // the selected-text hotkey, which sets its own start time, and arming it would make it run - // with no selection every time the app launches. - let started_at = (!args.idle && !args.accept_selection).then(now_unix_millis); - let warnings = collect_warnings(&args, inspection.as_ref(), cron.as_ref()); - - let content = CommandContent { - command: command.clone(), - environments: parse_env(&args.env)?, - working_dir: working_dir.clone(), - scheduler: cron.clone().map(Scheduler::Cron), - run_immediately: args.run_now, - result: args.result.to_result(), - stream_result: args.stream, - padding: args.padding, - started_at, - accept_selection: args.accept_selection, - auto_close: args.auto_close, - run_without_window: args.no_window, - }; - - let title = args - .title - .clone() - .unwrap_or_else(|| ellipsize(args.command.trim(), 40)); - - let store = open_store(app_paths)?; - let created = StickerDraft { - title: title.clone(), - sticker_type: StickerType::Command, - content: serde_json::to_string(&content)?, - default_color: StickerColor::Gray, - default_width: 400, - default_height: 300, - } - .create(&store, &args.geometry)?; - - let mut payload = created.json("command"); - payload["title"] = json!(title); - payload["command"] = json!(command); - payload["result_format"] = json!(args.result.as_str()); - payload["accept_selection"] = json!(args.accept_selection); - payload["cron"] = json!(cron); - payload["run_without_window"] = json!(args.no_window); - payload["program"] = json!(inspection.as_ref().map(|i| i.program_path.clone())); - payload["warnings"] = json!(warnings); - - format.emit(payload, || { - created.report(format, "command"); - println!(" title: {title}"); - println!(" command: {command}"); - println!(" output: {}", args.result.as_str()); - if let Some(cron) = &cron { - println!(" schedule: {cron}"); - } - if args.accept_selection { - println!(" selection: offered on the selected-text hotkey"); - } - if args.no_window { - println!(" window: hidden unless the command fails"); - } - for warning in &warnings { - println!("note: {warning}"); - } - }); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_relative_working_directory_is_pinned_to_an_absolute_one() { - assert_eq!(resolve_dir(None).unwrap(), ""); - assert_eq!(resolve_dir(Some(" ")).unwrap(), ""); - - let here = std::env::current_dir().unwrap(); - assert_eq!( - resolve_dir(Some(".")).unwrap(), - here.to_string_lossy().trim_end_matches('\\') - ); - assert!(Path::new(&resolve_dir(Some("src")).unwrap()).is_absolute()); - - let absolute = here.to_string_lossy().into_owned(); - assert_eq!(resolve_dir(Some(&absolute)).unwrap(), absolute); - } - - #[test] - fn env_pairs_become_newline_separated_lines() { - let env = parse_env(&["A=1".into(), "B=two words".into()]).unwrap(); - assert_eq!(env, "A=1\nB=two words"); - } - - #[test] - fn an_env_value_may_contain_equals_signs() { - assert_eq!(parse_env(&["URL=a=b".into()]).unwrap(), "URL=a=b"); - } - - #[test] - fn env_without_an_equals_sign_is_rejected() { - assert!(parse_env(&["JUST_A_NAME".into()]).is_err()); - } - - #[test] - fn env_with_an_empty_name_is_rejected() { - assert!(parse_env(&["=value".into()]).is_err()); - } - - #[test] - fn no_env_pairs_produce_an_empty_string() { - assert_eq!(parse_env(&[]).unwrap(), ""); - } - - #[test] - fn padding_is_limited_to_the_sliders_range() { - assert_eq!(parse_padding("64"), Ok(64)); - assert_eq!(parse_padding("0"), Ok(0)); - assert!(parse_padding("65").is_err()); - assert!(parse_padding("-1").is_err()); - } - - #[test] - fn only_text_and_markdown_can_stream() { - assert!(ResultFormat::Text.supports_streaming()); - assert!(ResultFormat::Markdown.supports_streaming()); - assert!(!ResultFormat::Html.supports_streaming()); - assert!(!ResultFormat::Svg.supports_streaming()); - assert!(!ResultFormat::Source.supports_streaming()); - } -} diff --git a/src/cli/commands/command.rs b/src/cli/commands/command.rs new file mode 100644 index 0000000..f4e744c --- /dev/null +++ b/src/cli/commands/command.rs @@ -0,0 +1,1048 @@ +//! `rst command` — create a command sticker. +//! +//! This is the command with the most surface, because a command sticker is the most configurable +//! thing Rustickers has: it can render its output five different ways, run on a schedule, run +//! invisibly, or wait to be fed the text you have selected in another application. +//! +//! What gets stored is an argument vector, not a shell line. Everything after `--` is kept token +//! for token ([`shell::encode_argv`]), so an argument with spaces, quotes, backslashes, newlines or +//! nothing in it at all arrives at the program exactly as typed. A shell is available with +//! `--shell "…"`, and it is exclusive: either a script for the shell, or a program and arguments. +//! +//! [`CommandArgs::validate`] does the entire job except writing, which is what lets `rst skill` prove a +//! recipe is well formed without creating anything. + +use anyhow::{Context as _, bail}; +use clap::ValueEnum; +use serde_json::json; +use std::path::{Component, Path, PathBuf}; + +use crate::model::content::{ + CommandContent, CommandResult, SELECTION_ENV_VAR, SELECTION_PLACEHOLDER, Scheduler, +}; +use crate::model::sticker::{StickerColor, StickerType}; +use crate::utils::time::now_unix_millis; + +use crate::cli::draft::{Geometry, StickerDraft}; +use crate::cli::output::{Format, ellipsize, sanitize}; +use crate::cli::runtime::Runtime; +use crate::cli::shell; + +/// How a command sticker renders whatever its command writes to stdout and stderr. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum ResultFormat { + /// Plain text. The safe default. + Text, + /// Rendered markdown: headings, lists, tables, code blocks. + Markdown, + /// The output is a full HTML document, shown in an embedded browser view. + Html, + /// The output is an SVG document, drawn as an image. + Svg, + /// The output is a single file path or URL, opened in the file preview. + Source, +} + +impl ResultFormat { + fn to_result(self) -> CommandResult { + match self { + Self::Text => CommandResult::Text(None), + Self::Markdown => CommandResult::Markdown(None), + Self::Html => CommandResult::Html(None), + Self::Svg => CommandResult::Svg(None), + Self::Source => CommandResult::Source(None), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Markdown => "markdown", + Self::Html => "html", + Self::Svg => "svg", + Self::Source => "source", + } + } + + /// Whether output can be appended line by line as it arrives. The other formats need a + /// complete document before they can render anything. + fn supports_streaming(self) -> bool { + matches!(self, Self::Text | Self::Markdown) + } +} + +#[derive(clap::Args, Debug)] +pub struct CommandArgs { + /// Run this script through the system shell instead of a program and arguments + /// + /// Pipes, redirection and `&&` work inside the script. Cannot combine with `--`: a script is + /// interpreted by a shell, an argument vector is not. + #[arg( + long, + value_name = "SCRIPT", + conflicts_with = "argv", + help_heading = "Command" + )] + pub shell: Option, + + /// Program to run and its arguments, each kept exactly as typed + #[arg(last = true, num_args = 1.., required_unless_present = "shell", value_name = "PROGRAM_ARGS", help_heading = "Command")] + pub argv: Vec, + + /// Store the sticker without checking that the program is on PATH + /// + /// Everything else is still checked: the argument encoding, the placeholder rules, the + /// schedule, the working directory, the environment. A program that is not installed fails when + /// the sticker runs. + #[arg(long, help_heading = "Command")] + pub allow_missing_program: bool, + + /// Title shown in the sticker list and the selection picker + #[arg(long, short = 't', value_name = "TEXT")] + pub title: Option, + + /// How to render the output + #[arg(long, value_enum, default_value = "text", value_name = "FORMAT")] + pub result: ResultFormat, + + /// Offer this sticker when you trigger the selected-text hotkey + /// + /// The selected text replaces {{RUSTICKERS_SELECTION}} in the arguments, and is always exported + /// as the RUSTICKERS_SELECTION environment variable. Created closed: a selection sticker is + /// offered from the hotkey, not left sitting on the desktop. + #[arg(long, help_heading = "Behaviour")] + pub accept_selection: bool, + + /// Run on a schedule, as a 6-field Quartz cron expression + /// + /// Fields are: second minute hour day-of-month month day-of-week. "0 */5 * * * *" is every + /// five minutes. The schedule runs for as long as the app does, whether or not the window is + /// open, so `rst result` has the latest run waiting. + #[arg(long, value_name = "EXPR", help_heading = "Behaviour")] + pub cron: Option, + + /// With --cron, also run once immediately when started from the sticker's own window + #[arg(long = "run-now", requires = "cron", help_heading = "Behaviour")] + pub run_now: bool, + + /// Create the sticker stopped, so nothing runs until you press Run + /// + /// Without this the sticker is armed: it runs when its window opens, and again each time the + /// app starts, which is what makes `rst result` useful. + #[arg(long, help_heading = "Behaviour")] + pub idle: bool, + + /// Show output line by line while the command runs, clearing the previous run first + #[arg(long, help_heading = "Behaviour")] + pub stream: bool, + + /// Never show a window; it only appears if the command fails + #[arg(long = "no-window", help_heading = "Behaviour")] + pub no_window: bool, + + /// Close the window once the command finishes successfully + #[arg(long = "auto-close", help_heading = "Behaviour")] + pub auto_close: bool, + + /// Environment variable for the command, repeatable + /// + /// The name is a portable identifier and the value is stored exactly as typed. Duplicate names + /// are refused. + #[arg( + long, + short = 'e', + value_name = "KEY=VALUE", + help_heading = "Behaviour" + )] + pub env: Vec, + + /// Working directory for the command, which has to exist + /// + /// The sticker keeps this path and runs the command in it later, from the desktop app, whatever + /// the current directory is by then, so a relative path — and the default, the directory this + /// was run from — is pinned to an absolute one here. + #[arg(long, value_name = "PATH", help_heading = "Behaviour")] + pub dir: Option, + + /// Padding around the output, 0-64 pixels + #[arg(long, value_name = "PX", value_parser = parse_padding, help_heading = "Appearance")] + pub padding: Option, + + #[command(flatten)] + pub geometry: Geometry, +} + +/// Everything about the sticker this call would create, resolved and checked with no database +/// involved. Building one is the whole of [`CommandArgs::validate`]. +struct Plan { + /// The string the sticker stores, and later splits back into argv. + command: String, + /// The typed argument vector; empty for `--shell`, which has a script instead. + argv: Vec, + script: Option, + inspection: shell::Inspection, + cron: Option, + environments: String, + env_names: Vec, + working_dir: String, + title: String, + accept_selection: bool, + run_now: bool, + /// `Some` when the sticker is armed to run on its own; `None` means it waits for a person to + /// press Run, or for the selected-text hotkey. + started_at: Option, + closed: bool, + warnings: Vec, +} + +impl Plan { + /// The shared geometry with this command's one override: a selection sticker is created closed, + /// so offering it on the hotkey never puts a window in the way. + fn geometry(&self, base: &Geometry) -> Geometry { + Geometry { + closed: self.closed, + ..base.clone() + } + } + + /// What was asked for, in the shape it was typed. + fn display(&self) -> &str { + self.script.as_deref().unwrap_or(&self.command) + } + + /// When the sticker will actually run. About the sticker, not about this call: nothing has run + /// yet, and for an idle or scheduled sticker nothing runs from here either. + fn runs_line(&self, id: i64) -> String { + if self.accept_selection { + "only when the selected-text hotkey feeds it".to_owned() + } else if self.started_at.is_none() { + format!("not until you press Run — `rst open {id}` shows the window") + } else if let Some(cron) = &self.cron { + format!( + "on schedule ({cron}); the first run waits for the next tick{}", + if self.run_now { + ", plus once when the sticker's own window starts it" + } else { + "" + } + ) + } else { + "when its window opens, and again each time Rustickers starts".to_owned() + } + } +} + +impl CommandArgs { + /// Check everything about these arguments that needs no database and writes nothing. + /// + /// This is all of [`run`] except the insert, so `rst skill` can say whether a recipe is well + /// formed without creating a sticker. + pub(crate) fn validate(&self) -> anyhow::Result<()> { + self.plan()?; + Ok(()) + } + + /// Resolve every argument into the exact data a sticker would store, or explain the problem. + fn plan(&self) -> anyhow::Result { + self.check_combinations()?; + + let (command, argv, script) = self.prepare_command()?; + // Structure is always checked; `--allow-missing-program` skips only the PATH lookup. + let inspection = shell::inspect(&command)?; + anyhow::ensure!( + self.accept_selection || !inspection.uses_selection_placeholder, + "{SELECTION_PLACEHOLDER} requires --accept-selection" + ); + let working_dir = resolve_dir(self.dir.as_deref())?; + let inspection = if self.allow_missing_program { + inspection + } else { + inspection.resolve_program_in(&working_dir)? + }; + + let cron = self.cron.as_deref().map(shell::validate_cron).transpose()?; + let env = parse_env(&self.env)?; + let warnings = self.collect_warnings(&inspection, cron.as_deref(), &env); + + // `started_at` is what arms a command sticker: the window only runs or schedules its command + // when a start time is present. A selection sticker is the exception — the hotkey sets its + // own start time, and arming it would run it with no selection on every app launch. + let started_at = (!self.idle && !self.accept_selection).then(now_unix_millis); + + Ok(Plan { + command, + argv, + script: script.clone(), + inspection, + cron, + environments: encode_env(&env), + env_names: env.iter().map(|var| var.name.clone()).collect(), + working_dir: into_stored_path(working_dir)?, + title: self.title.clone().unwrap_or_else(|| { + ellipsize(&script.unwrap_or_else(|| display_argv(&self.argv)), 40) + }), + accept_selection: self.accept_selection, + run_now: self.run_now, + started_at, + closed: self.geometry.closed || self.accept_selection, + warnings, + }) + } + + /// The program and arguments, or the shell script, as the one string the sticker stores. + fn prepare_command(&self) -> anyhow::Result<(String, Vec, Option)> { + if let Some(script) = &self.shell { + if script.trim().is_empty() { + bail!( + "--shell was given an empty script; pass the command the shell should run, \ + or drop --shell and use -- ..." + ); + } + if script.contains(SELECTION_PLACEHOLDER) { + bail!( + "{SELECTION_PLACEHOLDER} cannot be used with --shell. The selection is \ + substituted into an argument the shell then re-reads, so whatever text is \ + selected would be parsed as shell code. Run the program directly with -- \ + ... and the placeholder in an argument, or read the selection \ + from {SELECTION_ENV_VAR}, which the shell inherits." + ); + } + return Ok(( + shell::wrap_in_shell(script), + Vec::new(), + Some(script.to_owned()), + )); + } + + if self.argv.is_empty() { + bail!( + "no command to run. Pass it after `--`: rst command [OPTIONS] -- \ + ..., or give --shell \"