diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2807521..1d2e7e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev \ - libayatana-appindicator3-dev patchelf + libayatana-appindicator3-dev patchelf libasound2-dev - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cf828e..7c260d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev \ - libayatana-appindicator3-dev patchelf + libayatana-appindicator3-dev patchelf libasound2-dev - uses: actions/setup-node@v4 with: @@ -84,6 +84,9 @@ jobs: # to another repo. If the secret is absent the step no-ops (release still ships). update-cask: needs: release + # Disabled: the Homebrew cask is bumped manually for now. Re-enable by + # removing this guard to restore automatic tap updates on release. + if: false runs-on: ubuntu-latest steps: - name: Bump the Homebrew cask diff --git a/README.md b/README.md index 4b1acf5..a2cfcf2 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ Press `?` in the app (outside a text input) for the full, always-current list. | Cycle sidebar view (project / sections / stacks) | `⇄` | | Move a session to a section | drag its row onto a section header (drop on *In Progress* to unpin) | | Save a review comment | `Cmd/Ctrl+Enter` | +| Review: previous / next file | `↑` / `↓` or `Ctrl-P` / `Ctrl-N` | +| Review: toggle a file reviewed (bands the row) | click `○` / `✓` on the file row | ## Platform notes @@ -135,3 +137,20 @@ The packaging scripts (`app:install`) and the login-shell PATH fix are macOS-spe ## Theming CC-GUI owns its own theming (independent of `claude-commander` config). Ten themes ship built in, and you can drop your own JSON themes into the app's config folder without rebuilding. Full authoring guide: [`docs/theming.md`](docs/theming.md). + +## Usage telemetry + +CC-GUI embeds `claude-commander`, which reports anonymous **feature-usage** telemetry so the maintainers can see which features are used. It is **on by default** and **opt-out**, identifying itself as `cc-gui` so GUI usage is distinguishable from the terminal app. + +**What is sent:** the name of each feature used (e.g. `review.open`), a coarse environment fingerprint (OS, architecture, shell name, terminal colour mode), a non-sensitive config snapshot (theme preset, view mode, which optional features are enabled), the app name + version, and a random, resettable install id. + +**What is never sent:** typed text, prompts, Claude session content, comment bodies, branch/session names, repository paths, or command arguments. The event schema is a fixed set of typed fields with no path that forwards free-form text. + +**To opt out**, either uncheck **telemetry (send anonymous usage)** in Settings (`Cmd/Ctrl+K` → "Settings"), set the config flag, or export the standard [`DO_NOT_TRACK`](https://consoledonottrack.com/) variable: + +```toml +[telemetry] +enabled = false +``` + +See [`claude-commander`'s configuration docs](https://github.com/sizeak/claude-commander/blob/main/docs/configuration.md#usage-telemetry) for the full detail (and self-hosting the ingest endpoint). diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0863077..1efc70e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,6 +77,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -370,6 +392,24 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.117", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -645,7 +685,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -673,6 +713,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfb" version = "0.7.3" @@ -726,6 +775,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.6.1" @@ -768,8 +828,8 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "claude-commander" -version = "0.17.0" -source = "git+https://github.com/sizeak/claude-commander?tag=v0.17.0#34ebb647247295be127923d076198f6fec3d130e" +version = "0.21.0" +source = "git+https://github.com/sizeak/claude-commander?tag=v0.21.0#c42e3a2ac758055938bf5edb1a37431906cb1cce" dependencies = [ "ansi-to-tui", "anyhow", @@ -777,12 +837,14 @@ dependencies = [ "chrono", "clap", "color-eyre", + "cpal", "crossterm", "directories", "figment", "futures", "fuzzy-matcher", "gix", + "hound", "image", "nix 0.29.0", "pty-process", @@ -790,6 +852,8 @@ dependencies = [ "ratatui-image", "rayon", "regex", + "reqwest 0.12.28", + "rodio", "serde", "serde_json", "syntect", @@ -945,6 +1009,49 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1144,6 +1251,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "dbus" version = "0.9.11" @@ -1913,8 +2026,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1924,9 +2039,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2894,6 +3011,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.38.0" @@ -2963,6 +3086,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -3293,6 +3432,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3534,7 +3682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -3573,6 +3721,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" @@ -3660,6 +3818,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -3670,6 +3834,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -3736,6 +3909,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3795,6 +3978,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3804,12 +4001,27 @@ dependencies = [ "bitflags 2.13.0", "jni-sys 0.3.1", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3966,7 +4178,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -4186,6 +4398,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4229,7 +4464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4663,15 +4898,6 @@ dependencies = [ "toml_edit 0.20.7", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", -] - [[package]] name = "proc-macro-error" version = "1.0.4" @@ -4807,6 +5033,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -4928,7 +5209,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -5010,7 +5291,7 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -5036,7 +5317,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -5176,6 +5457,46 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -5216,6 +5537,32 @@ version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rodio" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +dependencies = [ + "cpal", + "hound", + "symphonia", + "thiserror 1.0.69", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -5263,6 +5610,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -5480,6 +5862,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -5596,6 +5990,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -5690,7 +6090,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk", + "ndk 0.9.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -5793,6 +6193,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -5804,6 +6210,55 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + [[package]] name = "syn" version = "1.0.109" @@ -5901,8 +6356,8 @@ dependencies = [ "jni", "libc", "log", - "ndk", - "ndk-sys", + "ndk 0.9.0", + "ndk-sys 0.6.0+11769913", "objc2", "objc2-app-kit", "objc2-foundation", @@ -5973,7 +6428,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -6444,6 +6899,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6562,18 +7027,6 @@ dependencies = [ "winnow 0.7.15", ] -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.3", -] - [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -6840,6 +7293,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bom" version = "2.0.3" @@ -6873,7 +7332,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -6890,6 +7349,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -7235,6 +7700,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -7291,6 +7766,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -7461,6 +7945,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -7493,6 +7987,16 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -7596,6 +8100,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -7906,9 +8419,6 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] [[package]] name = "winreg" @@ -8067,7 +8577,7 @@ dependencies = [ "javascriptcore-rs", "jni", "libc", - "ndk", + "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -8229,6 +8739,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0c9ae7c..7d86b98 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,7 +20,7 @@ uuid = "1" # Pinned to a published claude-commander release for reproducible builds. To # develop against a local checkout instead, add a gitignored `.cargo/config.toml` # with `paths = ["../claude-commander"]` — see CONTRIBUTING.md. -claude-commander = { git = "https://github.com/sizeak/claude-commander", tag = "v0.17.0" } +claude-commander = { git = "https://github.com/sizeak/claude-commander", tag = "v0.21.0" } futures = "0.3.32" tracing = "0.1.44" chrono = "0.4.45" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1bd3d0b..82c4862 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -114,7 +114,9 @@ fn main() { review::read_review_image, review::create_comment, review::delete_comment, + review::toggle_file_reviewed, review::apply_comments, + polling::refresh_pr_status, pty::attach, pty::write_pty, pty::resize_pty, diff --git a/src-tauri/src/polling.rs b/src-tauri/src/polling.rs index f3053f7..e389424 100644 --- a/src-tauri/src/polling.rs +++ b/src-tauri/src/polling.rs @@ -3,6 +3,7 @@ //! branches (blocked reasons surfaced on project headers). use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{LazyLock, Mutex}; use std::time::Duration; @@ -28,6 +29,33 @@ pub fn spawn_polling_loops() { spawn_project_pull_loop(); } +/// True while a manual PR-status refresh is running, so a second trigger is a +/// no-op rather than stacking another `gh` fan-out (e.g. a double-Enter on the +/// palette command). Mirrors claude-commander's debounce of the same action. +static MANUAL_PR_REFRESH_IN_FLIGHT: AtomicBool = AtomicBool::new(false); + +/// Palette "Refresh PR status": run one PR-status sweep immediately instead of +/// waiting for the periodic loop. Debounced — returns early if a manual refresh +/// is already in flight. The sidebar reflects the result on its next loop tick. +#[tauri::command] +pub async fn refresh_pr_status() -> Result<(), String> { + if MANUAL_PR_REFRESH_IN_FLIGHT.swap(true, Ordering::SeqCst) { + return Ok(()); + } + let outcome = run_manual_pr_refresh().await; + MANUAL_PR_REFRESH_IN_FLIGHT.store(false, Ordering::SeqCst); + outcome +} + +async fn run_manual_pr_refresh() -> Result<(), String> { + if !is_gh_available().await { + return Err("gh CLI not available".to_string()); + } + let svc = service().await?; + poll_prs_once(svc).await; + Ok(()) +} + /// Every `pr_check_interval_secs`: check the PR for each session's branch via /// `gh`, and persist the result exactly the way the TUI does (including /// clearing on authoritative not-found, preserving on transient failure, and diff --git a/src-tauri/src/review.rs b/src-tauri/src/review.rs index cbddea8..1870543 100644 --- a/src-tauri/src/review.rs +++ b/src-tauri/src/review.rs @@ -85,6 +85,29 @@ pub async fn read_review_image(id: String, path: String, side: String) -> Result .await } +/// Toggle the "reviewed" (read) mark on a file in a session's review, returning +/// the file's new reviewed state. The mark is persisted and shared with the TUI, +/// and is keyed on the file's content so it self-invalidates if the file later +/// changes. Re-opens the snapshot to resolve the live `FileDiff` for `path` +/// (a display path) since the mark store hashes the diff. +#[tauri::command] +pub async fn toggle_file_reviewed(id: String, path: String) -> Result { + let id = parse_session_id(&id)?; + with_service(move |svc| async move { + let snapshot = svc.open_review(&id).await.map_err(|e| e.to_string())?; + let file = snapshot + .diff + .files + .iter() + .find(|f| f.display_path() == path.as_str()) + .ok_or_else(|| format!("file not in review diff: {path}"))?; + svc.toggle_file_reviewed(&id, file) + .await + .map_err(|e| e.to_string()) + }) + .await +} + /// Apply staged comments: composes the markdown brief and injects a pointer /// prompt into the session's pane (delivery gated on agent state). May block /// up to the library's hold timeout while a permission prompt clears. diff --git a/src-tauri/src/service.rs b/src-tauri/src/service.rs index 66b4481..63ba6a8 100644 --- a/src-tauri/src/service.rs +++ b/src-tauri/src/service.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use claude_commander::api::CommanderService; use claude_commander::session::{ProjectId, SessionId}; +use claude_commander::telemetry::FrontendInfo; use tokio::sync::OnceCell; static SERVICE: OnceCell> = OnceCell::const_new(); @@ -12,7 +13,8 @@ pub async fn service() -> Result<&'static Arc, String> { SERVICE .get_or_try_init(|| async { let config = claude_commander::Config::load().map_err(|e| e.to_string())?; - CommanderService::for_cli(config) + let frontend = FrontendInfo::new("cc-gui", env!("CARGO_PKG_VERSION")); + CommanderService::for_cli(config, frontend) .map(Arc::new) .map_err(|e| e.to_string()) }) diff --git a/src/help.ts b/src/help.ts index 1599841..3d27357 100644 --- a/src/help.ts +++ b/src/help.ts @@ -30,6 +30,8 @@ const HELP_SECTIONS: [string, [string, string][]][] = [ [ ["Click line", "Select for comment (shift-click extends, Esc clears)"], ["Cmd/Ctrl+Enter", "Save comment"], + ["↑/↓ or Ctrl-P/N", "Previous / next file"], + ["○ / ✓", "Toggle file reviewed (bands the row)"], ["Apply (n)", "Send staged comments to the agent"], ["↻ / Esc", "Refresh diff / close"], ], diff --git a/src/main.ts b/src/main.ts index bce1933..00b7aec 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2761,6 +2761,14 @@ registerPaletteProvider(() => [ hint: "command", action: () => void deleteMergedSessions(), }, + { + label: "Refresh PR status", + hint: "command", + action: () => { + toast("Refreshing PR status…"); + void invoke("refresh_pr_status").catch((e) => toast(`${e}`, "error")); + }, + }, { label: "Attach commander session", hint: "command", diff --git a/src/playwright/iwft/network/TauriSimulator.testHelper.ts b/src/playwright/iwft/network/TauriSimulator.testHelper.ts index 5390596..2519e98 100644 --- a/src/playwright/iwft/network/TauriSimulator.testHelper.ts +++ b/src/playwright/iwft/network/TauriSimulator.testHelper.ts @@ -23,6 +23,7 @@ class TauriSimulator { private snapshot: Snapshot; private reviews: Record; private comments: Record; // by session id + private reviewed: Record>; // reviewed file paths, by session id private config: Record; private keybindings: Record; private customThemes: unknown[]; @@ -43,8 +44,10 @@ class TauriSimulator { this.keybindings = seed.keybindings ?? {}; this.customThemes = seed.customThemes ?? []; this.comments = {}; + this.reviewed = {}; for (const [id, review] of Object.entries(seed.reviews)) { this.comments[id] = [...review.comments]; + this.reviewed[id] = new Set(review.reviewed); } this.handle = this.handle.bind(this); } @@ -59,6 +62,12 @@ class TauriSimulator { return this.comments[sessionId] ?? []; } + /** Reviewed file paths the fake now holds for a session — the state a + * reviewed-toggle test asserts against. */ + getReviewed(sessionId: string): string[] { + return [...(this.reviewed[sessionId] ?? [])]; + } + /** Flattened live sessions across all groups — the state a sidebar test asserts. */ getSessions(): SessionRow[] { return this.snapshot.groups.flatMap((g) => g.sessions); @@ -170,8 +179,14 @@ class TauriSimulator { return this.createComment(args as unknown as CreateCommentArgs); case "delete_comment": return this.deleteComment(args.id as string, args.commentId as string); + case "toggle_file_reviewed": + return this.toggleFileReviewed(args.id as string, args.path as string); case "apply_comments": return this.applyComments(args.id as string); + case "refresh_pr_status": + // The native command kicks the PR-poll loop; the fake has no live PR + // state to refresh, so it's a no-op (matches the UI: a background nudge). + return null; default: // Unhandled commands resolve to null rather than throwing, so an // unstubbed call surfaces as a UI no-op, not a crashed boot. @@ -272,7 +287,24 @@ class TauriSimulator { private openReview(id: string): ReviewSnapshot { const review = this.reviews[id]; - return { base: review.base, diff: review.diff, comments: this.comments[id] ?? [] }; + return { + base: review.base, + diff: review.diff, + comments: this.comments[id] ?? [], + reviewed: [...(this.reviewed[id] ?? [])], + }; + } + + /** Toggle a file's reviewed mark and return its new state, mirroring the + * backend's persisted toggle. */ + private toggleFileReviewed(id: string, path: string): boolean { + const marks = this.reviewed[id] ?? (this.reviewed[id] = new Set()); + if (marks.has(path)) { + marks.delete(path); + return false; + } + marks.add(path); + return true; } private createComment(a: CreateCommentArgs): null { diff --git a/src/playwright/iwft/network/seed.testHelper.ts b/src/playwright/iwft/network/seed.testHelper.ts index 013a8a2..844320b 100644 --- a/src/playwright/iwft/network/seed.testHelper.ts +++ b/src/playwright/iwft/network/seed.testHelper.ts @@ -83,6 +83,7 @@ export function makeReview(over: Partial = {}): ReviewSnapshot { ], }, comments: [], + reviewed: [], ...over, }; } diff --git a/src/playwright/iwft/scenarios/review/review.iwft.ts b/src/playwright/iwft/scenarios/review/review.iwft.ts index 3490e28..047d87e 100644 --- a/src/playwright/iwft/scenarios/review/review.iwft.ts +++ b/src/playwright/iwft/scenarios/review/review.iwft.ts @@ -1,5 +1,6 @@ import { test, expect } from "../../support/fixture.testHelper"; -import { SESSION_ID } from "../../network/seed.testHelper"; +import { defaultSeed, makeReview, SESSION_ID } from "../../network/seed.testHelper"; +import type { FileDiff } from "../../../review/model"; test("comment renders after saving, and the fake stores the derived draft", async ({ review }) => { await review.selectLine("beta new"); @@ -44,3 +45,55 @@ test("a comment can be deleted", async ({ review }) => { await review.deleteFirstComment(); await expect(review.commentBodies()).toHaveCount(0); }); + +test("toggling a file reviewed bands its row and persists to the fake", async ({ review }) => { + await expect(review.reviewedRows()).toHaveCount(0); + + await review.toggleReviewed("notes.txt"); + await expect(review.reviewedRows()).toHaveCount(1); + expect(await review.storedReviewed(SESSION_ID)).toEqual(["notes.txt"]); + + // Toggling again clears the mark. + await review.toggleReviewed("notes.txt"); + await expect(review.reviewedRows()).toHaveCount(0); + expect(await review.storedReviewed(SESSION_ID)).toEqual([]); +}); + +test.describe("with two files", () => { + const emptyFile = (name: string): FileDiff => ({ + old_path: name, + new_path: name, + status: "modified", + added: 1, + removed: 0, + hunks: [], + binary: null, + }); + test.use({ + seed: { + ...defaultSeed(), + reviews: { + [SESSION_ID]: makeReview({ + diff: { files: [emptyFile("alpha.txt"), emptyFile("zeta.txt")] }, + }), + }, + }, + }); + + test("Ctrl-N/P and arrows move between files", async ({ review }) => { + // refresh() selects the first file by default. + await expect.poll(() => review.activeFileName()).toBe("alpha.txt"); + + await review.pressFileNav("ArrowDown"); + await expect.poll(() => review.activeFileName()).toBe("zeta.txt"); + + await review.pressFileNav("Control+p"); + await expect.poll(() => review.activeFileName()).toBe("alpha.txt"); + + await review.pressFileNav("Control+n"); + await expect.poll(() => review.activeFileName()).toBe("zeta.txt"); + + await review.pressFileNav("ArrowUp"); + await expect.poll(() => review.activeFileName()).toBe("alpha.txt"); + }); +}); diff --git a/src/playwright/iwft/scenarios/settings/settings.iwft.ts b/src/playwright/iwft/scenarios/settings/settings.iwft.ts index f497d1f..8c93014 100644 --- a/src/playwright/iwft/scenarios/settings/settings.iwft.ts +++ b/src/playwright/iwft/scenarios/settings/settings.iwft.ts @@ -61,3 +61,30 @@ test("valid edits round-trip through save_config", async ({ settings }) => { // NB: the data-kind="number" branch's NaN→toast path is unreachable from the UI — // sanitizes non-numeric input, so collect() never sees NaN. // Invalid-number abort is therefore not exercised here (it can't happen in-browser). + +test.describe("telemetry opt-out", () => { + test.use({ + seed: { + snapshot: makeSnapshot(), + reviews: {}, + config: { telemetry: { enabled: true, endpoint: null, token: null } }, + }, + }); + + test("renders a checkbox; opting out persists enabled:false and keeps endpoint/token", async ({ + settings, + }) => { + await settings.open(); + + // The telemetry object surfaces as a friendly checkbox, not a JSON blob. + expect(await settings.fieldKind("telemetry")).toBe("telemetry-enabled"); + expect(await settings.fieldInputType("telemetry")).toBe("checkbox"); + + await settings.setChecked("telemetry", false); + await settings.save(); + + expect(await settings.savedConfig()).toEqual({ + telemetry: { enabled: false, endpoint: null, token: null }, + }); + }); +}); diff --git a/src/playwright/pageObjects/ReviewPanePageObject.testHelper.ts b/src/playwright/pageObjects/ReviewPanePageObject.testHelper.ts index 9c56cbc..48c2447 100644 --- a/src/playwright/pageObjects/ReviewPanePageObject.testHelper.ts +++ b/src/playwright/pageObjects/ReviewPanePageObject.testHelper.ts @@ -6,6 +6,7 @@ import type { Comment } from "../../review/model"; // review surface is well-identified, so no data-test attributes are needed yet. export class ReviewPanePageObject extends AppPageObject { private readonly pane = this.page.locator("#review"); + private readonly files = this.page.locator("#review-files"); private readonly diff = this.page.locator("#review-diff"); private readonly applyButton = this.page.locator("#review-apply"); private readonly status = this.page.locator("#review-status"); @@ -78,4 +79,41 @@ export class ReviewPanePageObject extends AppPageObject { sessionId, ); } + + // ----- file tree: navigation + reviewed marks ----- + + /** A file-tree row by its basename (the `.file-path` label). */ + fileRow(basename: string): Locator { + return this.files.locator(".review-file", { hasText: basename }); + } + + /** Toggle a file's reviewed mark via its ○/✓ control. */ + toggleReviewed(basename: string): Promise { + return this.step(`toggleReviewed: ${basename}`, () => + this.fileRow(basename).locator(".file-reviewed-toggle").click(), + ); + } + + /** Rows currently banded as reviewed. */ + reviewedRows(): Locator { + return this.files.locator(".review-file.reviewed"); + } + + /** Basename of the active (selected) file row. */ + activeFileName(): Promise { + return this.files.locator(".review-file.active .file-path").innerText(); + } + + /** Press a file-navigation key (e.g. "ArrowDown", "Control+n"). */ + pressFileNav(key: string): Promise { + return this.step(`pressFileNav: ${key}`, () => this.page.keyboard.press(key)); + } + + /** Reviewed file paths the fake now holds for this session. */ + storedReviewed(sessionId: string): Promise { + return this.page.evaluate( + (id) => (window as unknown as { __CC_SIM__: { getReviewed(i: string): string[] } }).__CC_SIM__.getReviewed(id), + sessionId, + ); + } } diff --git a/src/review.ts b/src/review.ts index 2e220cd..b981159 100644 --- a/src/review.ts +++ b/src/review.ts @@ -204,6 +204,9 @@ let sessionId: string | null = null; let snapshot: ReviewSnapshot | null = null; let selectedFile: string | null = null; +// Display paths of files marked reviewed (read); mirrors the persisted store. +let reviewed = new Set(); + // Line selection for a new comment: inclusive index range into the rendered // (selectable) lines of the current file, in click order. let selection: { anchor: number; head: number } | null = null; @@ -238,6 +241,7 @@ async function refresh(): Promise { } if (sessionId !== id) return; // closed or switched while loading snapshot = snap; + reviewed = new Set(snap.reviewed); tokenCache.clear(); imageCache.clear(); baseEl.textContent = `vs ${snap.base}`; @@ -289,6 +293,39 @@ function currentFile(): FileDiff | undefined { return snapshot?.diff.files.find((f) => displayPath(f) === selectedFile); } +/** Toggle the reviewed mark for a file (persisted via the backend) and reflect + * it in the local mirror + file list. */ +async function toggleReviewed(path: string): Promise { + if (!sessionId) return; + let now: boolean; + try { + now = await invoke("toggle_file_reviewed", { id: sessionId, path }); + } catch (e) { + statusEl.textContent = `mark failed: ${e}`; + return; + } + if (now) reviewed.add(path); + else reviewed.delete(path); + renderFiles(); +} + +/** Move the file selection by `delta` (clamped at the ends, no wrap) and keep + * the newly selected row visible. Backs the Ctrl-N/P and arrow navigation. */ +function selectFileByOffset(delta: number): void { + const files = snapshot?.diff.files; + if (!files || !files.length) return; + const cur = files.findIndex((f) => displayPath(f) === selectedFile); + const next = Math.min(files.length - 1, Math.max(0, (cur === -1 ? 0 : cur) + delta)); + const path = displayPath(files[next]); + if (path === selectedFile) return; + selectedFile = path; + clearSelection(); + renderFiles(); + renderDiff(); + highlightCurrentFile(); + filesEl.querySelector(".review-file.active")?.scrollIntoView({ block: "nearest" }); +} + // ------------------------------------------------------------------- files function renderFiles(): void { @@ -317,6 +354,17 @@ function renderFiles(): void { const row = document.createElement("div"); row.className = "review-file"; row.classList.toggle("active", path === selectedFile); + const isReviewed = reviewed.has(path); + row.classList.toggle("reviewed", isReviewed); + + const tick = document.createElement("span"); + tick.className = "file-reviewed-toggle"; + tick.textContent = isReviewed ? "✓" : "○"; + tick.title = isReviewed ? "Mark as not reviewed" : "Mark as reviewed"; + tick.addEventListener("click", (e) => { + e.stopPropagation(); // toggling reviewed shouldn't also open the diff + void toggleReviewed(path); + }); const status = document.createElement("span"); status.className = `file-status file-${f.status}`; @@ -344,7 +392,7 @@ function renderFiles(): void { removed.textContent = `-${f.removed}`; counts.append(added, removed); - row.append(status, name, counts); + row.append(tick, status, name, counts); row.addEventListener("click", () => { selectedFile = path; clearSelection(); @@ -780,3 +828,17 @@ document.addEventListener("keydown", (e) => { closeReview(); } }); + +// File navigation: ↑/↓ and Ctrl-P/Ctrl-N move between files (matching the TUI's +// review aliases). Skipped while typing in the comment editor. +document.addEventListener("keydown", (e) => { + if (reviewEl.classList.contains("hidden")) return; + const t = e.target as HTMLElement; + if (t instanceof HTMLTextAreaElement || t instanceof HTMLInputElement) return; + let delta: number; + if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) delta = 1; + else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) delta = -1; + else return; + e.preventDefault(); + selectFileByOffset(delta); +}); diff --git a/src/review/model.ts b/src/review/model.ts index 5eb6bbf..424dc1e 100644 --- a/src/review/model.ts +++ b/src/review/model.ts @@ -65,6 +65,8 @@ export type ReviewSnapshot = { base: string; diff: { files: FileDiff[] }; comments: Comment[]; + /** Display paths of files marked reviewed (read); their row is banded. */ + reviewed: string[]; }; export type ApplyOutcome = diff --git a/src/settings.ts b/src/settings.ts index 11c3e01..43eab4f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -52,6 +52,19 @@ function fieldInput(key: string, value: ConfigValue): HTMLElement { return area; } +/** A friendly on/off control for the `telemetry` object, surfacing its + * `enabled` flag as a checkbox instead of a raw JSON blob. Endpoint/token + * (self-hoster fields) are left to the config file and preserved on save. */ +function telemetryToggle(): HTMLElement { + const tel = (current.telemetry ?? {}) as Record; + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = tel.enabled !== false; // on by default + input.dataset.key = "telemetry"; + input.dataset.kind = "telemetry-enabled"; + return input; +} + function collect(): Record | null { const out: Record = { ...current }; for (const el of box.querySelectorAll("[data-key]")) { @@ -75,6 +88,12 @@ function collect(): Record | null { case "string": out[key] = el.value; break; + case "telemetry-enabled": { + // Update only `enabled`, preserving any endpoint/token already set. + const prev = (current.telemetry ?? {}) as Record; + out["telemetry"] = { ...prev, enabled: (el as HTMLInputElement).checked }; + break; + } case "json": try { out[key] = JSON.parse(el.value); @@ -103,6 +122,12 @@ function render(): void { grid.className = "settings-grid"; for (const key of Object.keys(current).sort()) { const label = document.createElement("label"); + if (key === "telemetry") { + label.textContent = "telemetry (send anonymous usage)"; + grid.appendChild(label); + grid.appendChild(telemetryToggle()); + continue; + } label.textContent = key; grid.appendChild(label); grid.appendChild(fieldInput(key, current[key])); diff --git a/src/style.css b/src/style.css index 66f3cc4..6cdd6c7 100644 --- a/src/style.css +++ b/src/style.css @@ -2061,12 +2061,18 @@ body.resizing .panel-resizer { display: flex; align-items: center; gap: 7px; - padding: 4px 8px 4px 16px; + padding: 4px 8px 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; } +/* A subtle band marks reviewed (read) files so done rows stand out beyond the + ✓ alone. Listed before :hover/.active so those still win on the focused row. */ +.review-file.reviewed { + background: color-mix(in srgb, var(--success) 15%, transparent); +} + .review-file:hover { background: var(--border); } @@ -2075,6 +2081,22 @@ body.resizing .panel-resizer { background: var(--border-strong); } +.file-reviewed-toggle { + width: 12px; + flex-shrink: 0; + text-align: center; + color: var(--text-dim); + cursor: pointer; +} + +.file-reviewed-toggle:hover { + color: var(--text); +} + +.review-file.reviewed .file-reviewed-toggle { + color: var(--success); +} + .file-status { width: 12px; flex-shrink: 0;