diff --git a/.gitignore b/.gitignore index ed4befcf..b5ee9b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ src-tauri/gen/schemas # Excalidraw fonts (copied from node_modules at install time) static/excalidraw-assets/fonts/ + +# Local-only working files (specs, scratch) +*.local diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 03e75a96..727e2bea 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.13.1" + ".": "0.14.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a666ba..c0c24974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.14.0](https://github.com/denolehov/annot/compare/v0.13.1...v0.14.0) (2026-07-10) + + +### Features + +* **diff:** file tree sidebar + palette fuzzy-jump ([#86](https://github.com/denolehov/annot/issues/86)) ([bf07f1d](https://github.com/denolehov/annot/commit/bf07f1d1a3fb88694e372cb0d1b086228702e43b)) +* **diff:** per-file collapse with sticky headers + changeset summary ([#88](https://github.com/denolehov/annot/issues/88)) ([7a60c25](https://github.com/denolehov/annot/commit/7a60c25bdf48d3ecf42a73ecbde7bff52bc2892d)) +* **sidebar:** make filetree pane resizable via paneforge ([fddef9f](https://github.com/denolehov/annot/commit/fddef9f8fe19eb4aeeb64f50712c71257cb3498e)) + + +### Bug Fixes + +* **diff:** accept annotation ranges spanning removed and added lines ([dad0c9b](https://github.com/denolehov/annot/commit/dad0c9bd8ba79663da77993a19314e4df31391e5)) +* **diff:** fix file-tree jump-to-file scroll and highlight tracking ([ca85b92](https://github.com/denolehov/annot/commit/ca85b922554359dd532ca80659734132212d882d)) +* **sidebar:** truncate the changed-files summary label like paths ([7a72bf8](https://github.com/denolehov/annot/commit/7a72bf8e2490bee38bff23772e8d756505a8ae34)) + ## [0.13.1](https://github.com/denolehov/annot/compare/v0.13.0...v0.13.1) (2026-06-22) diff --git a/CLAUDE.md b/CLAUDE.md index b7ade257..81dbb9ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,6 +176,28 @@ Kill the stray `annot.exe` before rebuilding. - **Declarative over imperative**: `map`/`collect`/`join` over manual loops - **Composables pattern**: Svelte 5 runes in `src/lib/composables/` +Before ending a session that changes Rust code, format it and run strict Clippy: + +```bash +cargo fmt --manifest-path src-tauri/Cargo.toml +cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +``` + +## Commit Messages + +release-please lifts every `feat`/`fix` subject line verbatim into +CHANGELOG.md and the GitHub release notes (`refactor`/`chore`/etc. stay +hidden). Write subjects for that audience: + +- `feat`/`fix` only for user-visible changes, phrased as what the user gets + ("working-tree reviews include untracked files"), not internal milestones + ("git substrate on gix — FileSource seam") +- Internal capability, scaffolding, and inert types are `refactor` even when + they add code +- Engineering narrative goes in the body — it's for `git log`, not the notes +- No `BREAKING CHANGE:` footers / `!` until v1.0 is planned deliberately; + note migration steps as plain body text instead + ## Agent Output Preferences - **Use `review_content` for reports/summaries**: Present plans, analysis, or structured output via the MCP tool instead of inline text. diff --git a/README.md b/README.md index 514f0338..dde610f6 100644 --- a/README.md +++ b/README.md @@ -209,12 +209,13 @@ Press `Shift+C` to add comments that apply to the entire review — framing cont | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `git_diff_args` | array | no* | Git diff arguments (e.g., `["--staged"]`) | -| `diff_content` | string | no* | Raw unified diff content | +| `target` | object | no* | What to diff: `{"kind": "working_tree"}` (default), `{"kind": "staged"}`, or `{"kind": "range", "from": "main", "to": "HEAD", "merge_base": true}` | +| `pathspecs` | array | no | Git pathspecs limiting the diff (e.g., `["src/", "*.rs"]`) | +| `diff_content` | string | no* | Raw unified diff content (mutually exclusive with `target`/`pathspecs`) | | `label` | string | no | Display name (default: "diff") | | `exit_modes` | array | no | Ephemeral exit modes for this session | -*Either `git_diff_args` or `diff_content` must be provided. +*`target` defaults to `working_tree` — worktree vs HEAD, staged + unstaged combined, untracked files included. `range` with `merge_base: true` diffs from `merge_base(from, to)` to `to`, like `from...to`. ### `review_content` diff --git a/docs/features.md b/docs/features.md index 6d2749d6..39ff46a9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -25,6 +25,10 @@ Open any source file for annotation. Syntax highlighting adapts to language. Nav ### Diff Review Review git changes (`--staged`, `main...HEAD`) or raw unified diffs. Color-coded: additions green, deletions red. Annotations capture both old and new line numbers. +**File tree** — `Cmd+B` toggles a sidebar listing every changed file with its +/− counts. Clicking a file scrolls to it (expanding it if collapsed); the row for the file currently in view stays highlighted. The `:` palette's **Files** namespace does the same jump by fuzzy search. + +**Per-file collapse** — every file gets a header bar (chevron, path, +/− counts) that sticks to the top while its lines scroll. Clicking the bar collapses the file to just its header; the titlebar shows the changeset's +A −D totals with a fold-all/unfold-all toggle. Files with more than 500 changed lines start collapsed. Search hits and file jumps auto-expand collapsed files. Collapse is pure presentation — annotations keep resolving to the same lines. + ### Content Review Review agent-generated content — plans, drafts, analysis. Markdown rendering with Mermaid diagrams and portal links that embed live code. @@ -125,7 +129,7 @@ Press `Shift+C` to add a high-level comment that applies to the entire review (n ## Command Palette (`:`) -Press `:` (colon) to open. Seven namespaces: +Press `:` (colon) to open. Eight namespaces: ### Tags - Browse, create, edit, delete tags @@ -138,6 +142,9 @@ Press `:` (colon) to open. Seven namespaces: - Press `s` to set as active - Press `r` to reorder (drag with arrow keys) +### Files +- Fuzzy-jump to a changed file (diff review only) + ### Copy - Copy content only - Copy annotations only @@ -169,6 +176,7 @@ Press `:` (colon) to open. Seven namespaces: | `:` | Command palette | | `Alt+Tab` | Command palette → Exit modes | | Ctrl+F | Search | +| `Cmd+B` | Toggle file tree (diffs) | | `e` | Edit item (in command palette) | | `r` | Reorder items (exit modes only) | | `Cmd+D` | Delete item (in command palette) | @@ -216,7 +224,7 @@ Saved to /path/to/file.md Three tools exposed via Model Context Protocol: 1. **review_file** — Open file at path -2. **review_diff** — Review git or raw diffs +2. **review_diff** — Review git diffs (structured target: working tree / staged / rev range) or raw diff content 3. **review_content** — Review agent-generated content All block until window closes, returning structured output with annotations, exit mode, and any images. diff --git a/package.json b/package.json index 73f5f502..1f992fbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "annot", - "version": "0.13.1", + "version": "0.14.0", "description": "Human-in-the-loop annotation tool for AI workflows", "type": "module", "scripts": { @@ -45,6 +45,7 @@ "@tiptap/suggestion": "^3.27.1", "fuse.js": "^7.4.2", "mermaid": "^11.15.0", + "paneforge": "^1.0.2", "panzoom": "^9.4.4", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce6f6292..af2ff449 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: mermaid: specifier: ^11.15.0 version: 11.15.0 + paneforge: + specifier: ^1.0.2 + version: 1.0.2(svelte@5.56.3) panzoom: specifier: ^9.4.4 version: 9.4.4 @@ -1533,6 +1536,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -1787,6 +1793,11 @@ packages: pako@2.0.3: resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==} + paneforge@1.0.2: + resolution: {integrity: sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==} + peerDependencies: + svelte: ^5.29.0 + panzoom@9.4.4: resolution: {integrity: sha512-r1KfkNZvsBw59IPq7Yy+GWZnZE1YCG/t7aG6caSkij/TBqdxTzmxNTm/lHf3h6qlVMFisIGIO+lgS2Ym23PIoA==} @@ -1967,6 +1978,16 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + runed@0.23.4: + resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==} + peerDependencies: + svelte: ^5.7.0 + + runed@0.29.2: + resolution: {integrity: sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==} + peerDependencies: + svelte: ^5.7.0 + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -2025,6 +2046,9 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -2046,6 +2070,12 @@ packages: '@tiptap/pm': ^3.0.0 svelte: ^5.0.0 + svelte-toolbelt@0.9.3: + resolution: {integrity: sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + svelte@5.56.3: resolution: {integrity: sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==} engines: {node: '>=18'} @@ -3680,6 +3710,8 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + internmap@1.0.1: {} internmap@2.0.3: {} @@ -3892,6 +3924,12 @@ snapshots: pako@2.0.3: {} + paneforge@1.0.2(svelte@5.56.3): + dependencies: + runed: 0.23.4(svelte@5.56.3) + svelte: 5.56.3 + svelte-toolbelt: 0.9.3(svelte@5.56.3) + panzoom@9.4.4: dependencies: amator: 1.1.0 @@ -4122,6 +4160,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + runed@0.23.4(svelte@5.56.3): + dependencies: + esm-env: 1.2.2 + svelte: 5.56.3 + + runed@0.29.2(svelte@5.56.3): + dependencies: + esm-env: 1.2.2 + svelte: 5.56.3 + rw@1.3.3: {} sade@1.8.1: @@ -4170,6 +4218,10 @@ snapshots: dependencies: min-indent: 1.0.1 + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + stylis@4.4.0: {} svelte-check@4.6.0(picomatch@4.0.4)(svelte@5.56.3)(typescript@6.0.3): @@ -4194,6 +4246,13 @@ snapshots: '@tiptap/pm': 3.27.1 svelte: 5.56.3 + svelte-toolbelt@0.9.3(svelte@5.56.3): + dependencies: + clsx: 2.1.1 + runed: 0.29.2(svelte@5.56.3) + style-to-object: 1.0.14 + svelte: 5.56.3 + svelte@5.56.3: dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6d6154d0..d1d38393 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -32,6 +32,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -43,14 +49,16 @@ dependencies = [ [[package]] name = "annot" -version = "0.13.1" +version = "0.14.0" dependencies = [ "arboard", "base64 0.22.1", "chrono", "clap", + "diffy", "dirs", "fs4", + "gix", "hex", "htmlescape", "ignore", @@ -58,8 +66,9 @@ dependencies = [ "insta", "languages", "parking_lot", + "proptest", "pulldown-cmark", - "rand", + "rand 0.10.1", "regex", "rmcp", "schemars 1.2.1", @@ -76,7 +85,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "unicode-width", - "unidiff", "urlencoding", "uuid", ] @@ -157,6 +165,21 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -451,6 +474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -487,6 +511,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bytesize" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" + [[package]] name = "cairo-rs" version = "0.18.5" @@ -605,7 +635,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -671,6 +701,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -922,6 +961,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dbus" version = "0.9.11" @@ -933,6 +986,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deranged" version = "0.5.8" @@ -964,6 +1048,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diffy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05264ab2aab4fb952fc4b0f3f6eff1ddfb4563064053a4ea174d91537584a769" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "digest" version = "0.10.7" @@ -1234,6 +1327,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1265,6 +1368,16 @@ dependencies = [ "rustc_version", ] +[[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.9" @@ -1606,41 +1719,934 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasip2", "wasip3", ] [[package]] -name = "gio" -version = "0.18.4" +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "gix" +version = "0.85.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8b2e38ebfc4484dfef8580ddcaf8abb7285e6f3eb6413ff6775d104ae96ca6" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-attributes", + "gix-blame", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-mailmap", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "nonempty", + "parking_lot", + "regex", + "signal-hook", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-actor" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-archive" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1303ed647e048d2bbecb9fd3a627d753e73f883a20ad5c039b30c7cbc85e217f" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + +[[package]] +name = "gix-attributes" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b40888d0ed415c0744a6cdc61eebf0304c9d26ab726725b718443c322e5ba4" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf49c828ad8a8a674d52caaf0b61fdee1f20cc46c15439ca3d875ef3b6f64bdc" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-chunk" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a29bf266c4cdaf759e535c24ad4ce655b987aeb6911075643403cc7cc5ade583" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "gix-path", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-credentials" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40cd22f0dd71988be12d6e78b1709de2370e1957c5f107ff31e56caeba3745d" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-path", + "gix-prompt", + "gix-sec", + "gix-trace", + "gix-url", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-date" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d63f9e28b59ddeb1a1eb9e5cf986a9222b5d484947445edbc20473939cc7fd0" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c6d56c94edf92d78203a1cd416f770e35e10b6955ede6b9d7d0c22ff88a5f3" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-dir" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20098fba2b9c6e29361ccb4c0379d42dfb81fc7f86f7ab11f2dff4528c0bf01f" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-discover" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d624d5b23b10c1d85337645227abe353ac95ab8ff66a7bdd5ce689b2db33a722" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-error" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57831e199be480af90dcd7e459abed8a174c09ec9a6e2cc8f7ca6c54598b06b" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" +dependencies = [ + "bytes", + "bytesize", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "thiserror 2.0.18", + "walkdir", + "zlib-rs", +] + +[[package]] +name = "gix-filter" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6644fb2ef97928c278675b239f366b457103d7e436f811d27331a8daf212759c" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-fs" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-glob" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-hashtable" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e261d54091f0d1c729bc83f54548c071bdec60a697de1e58e88bdfd7a99d24e" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d45f82ec5a4d7542ea595e9ad16e03e26c8cb4f221e5bc9fcdcf469f63a681" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-lock" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-mailmap" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "195fd20808055824531be2fd0d34136d900e5fbca3ffb0a3c07e8beeefb9c828" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-negotiate" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d6081882a5f575ace9f53924a7c85f69bbd0f96071b982df12f258ab338cc9" +dependencies = [ + "bitflags 2.11.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019b38afc3eac1e41f9fe09a327664b313ba4a120fa5f40e3678795d0e42783e" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-odb" +version = "0.82.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fadc59f6fa0f9dd445eceee61060a2b59ca557f48da9fc677f567db535b782a" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "memmap2", + "parking_lot", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pack" +version = "0.72.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3e7f1726cd2c0cd1cf1fc20be8a8e623f0b163f1f8d6fc836cfb9bc8cd758b" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror 2.0.18", + "uluru", +] + +[[package]] +name = "gix-packetline" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b217dd0ee0c4021ecf169a4a519b1b4f80d15e3f3765f3dc466223dc0ac891d7" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-path" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pathspec" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-prompt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee604d7746080ae7e1023bf47204bcc2c5f307bfbe2306a3c90b1bfd1a2c6d8" +dependencies = [ + "gix-command", + "gix-config-value", + "parking_lot", + "rustix", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-protocol" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978468bae4ea2df20c72db3b20d0bdb548a0c1090b85a83643b553e6e0e041f2" +dependencies = [ + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "maybe-async", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbfbce1dfd7d7f8469ddef6d3518376aff664348f153cbe0fc3e58ef993d24e" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-refspec" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc36a4fb1a1540b59cf2da498783080743fa274b02a3f19ca444fc4015a9d4f" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-revision" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885075c3c21eb9c06e0be3b3728ba5932c04e1c1011dcee7c81801980e3e986f" +dependencies = [ + "bitflags 2.11.1", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f11fe7ca2585193d3d70bbe0be175a2008d883a704cc7a55e454e113e689455" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-sec" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" +dependencies = [ + "bitflags 2.11.1", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-status" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aec3293f75db1212f99217832cbc70c30faeb95cefc97c7f1a17fd3bcf13a72e" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-submodule" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9f594f7cbda0b38ba6b633b3e9a7b7901acdc5d27bc186a16633800cd1ac8" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-tempfile" +version = "23.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "signal-hook", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" + +[[package]] +name = "gix-transport" +version = "0.57.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186874f7ad1fb2f9a2f2aa9c2dabc7f9dd087bef74c1a0eee2b4a9cf0248fcb3" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-traverse" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5062cca8f2977565bbaf666ec31dbdb9bc9d9293beb65f9bec52e6c1121b62a1" +dependencies = [ + "bitflags 2.11.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-url" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bb01ec69d55e82ccb7a19e264501ead4e6aac38463a8cebfdd81e22bb67ab2" +dependencies = [ + "bstr", + "gix-path", + "percent-encoding", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-utils" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771" +dependencies = [ + "bstr", + "fastrand", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92399ed66f259592050c6ed9dc80105e095a2f8e87e6b83d98aa2e21d8e27036" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-state" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +checksum = "c0f926b6a249bdb0086b307c704b7abd9d643e08f98040efe6467f00bb6d2ef5" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror 2.0.18", ] [[package]] -name = "gio-sys" -version = "0.18.1" +name = "gix-worktree-stream" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +checksum = "55f3a878c89a05470ad98c644b0015777c530da24854dd29e41fe4f41176840f" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", ] [[package]] @@ -1783,12 +2789,27 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1798,11 +2819,37 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] name = "heck" @@ -1883,6 +2930,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "human_format" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" + [[package]] name = "hybrid-array" version = "0.4.12" @@ -2158,6 +3211,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2218,6 +3281,48 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -2307,6 +3412,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + [[package]] name = "languages" version = "0.0.2" @@ -2426,12 +3540,32 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2529,6 +3663,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "num-conv" version = "0.2.1" @@ -3025,6 +4165,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3040,6 +4195,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3118,6 +4282,36 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "bytesize", + "human_format", + "parking_lot", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "num-traits", + "rand 0.9.4", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -3143,6 +4337,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -3179,6 +4379,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -3187,7 +4397,26 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -3196,6 +4425,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3374,6 +4612,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3660,6 +4910,27 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3682,12 +4953,28 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shell-words" +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 = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3801,6 +5088,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -4303,7 +5596,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -4652,6 +5945,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uluru" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4699,12 +6007,27 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -4723,16 +6046,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unidiff" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ae26d2e6582eb32eff85cffebf74d20b6510e8b558bbac3a23b48965cf952f" -dependencies = [ - "encoding_rs", - "regex", -] - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4832,6 +6145,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -5893,6 +7215,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c76fed8d..8eb141ef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "annot" -version = "0.13.1" # x-release-please-version +version = "0.14.0" # x-release-please-version description = "Human-in-the-loop annotation tool for AI workflows" authors = ["Denys Oliekhov"] license = "AGPL-3.0" @@ -29,11 +29,12 @@ syntect = "5" thiserror = "2" dirs = "6" fs4 = "1.1" +gix = "=0.85.0" rand = "0.10" rmcp = { version = "1.7.0", features = ["server", "transport-io"] } tokio = { version = "1.48.0", features = ["full"] } schemars = { version = "1.1.0", features = ["derive"] } -unidiff = "0.4" +diffy = "0.5" pulldown-cmark = "0.13" unicode-width = "0.2" base64 = "0.22.1" @@ -53,5 +54,6 @@ similar = { version = "3", features = ["inline"] } [dev-dependencies] insta = "1.45" +proptest = "1" tempfile = "3.23.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 92648ece..9aa97afc 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -24,8 +24,7 @@ fn main() { let syntax_set = builder.build(); // Dump to file for include_bytes! at runtime - dump_to_uncompressed_file(&syntax_set, &syntax_dump_path) - .expect("Failed to dump syntax set"); + dump_to_uncompressed_file(&syntax_set, &syntax_dump_path).expect("Failed to dump syntax set"); // Standard Tauri build tauri_build::build() diff --git a/src-tauri/src/anchor.rs b/src-tauri/src/anchor.rs new file mode 100644 index 00000000..89c62a91 --- /dev/null +++ b/src-tauri/src/anchor.rs @@ -0,0 +1,115 @@ +//! Annotation identity and position. +//! +//! An `Annotation`'s `id` is its identity; `anchor` is a mutable property that +//! can move (re-diff, thread re-anchoring) without changing what annotation it is. + +use serde::{Deserialize, Serialize}; + +use crate::source::Side; +use crate::state::ContentNode; + +/// One endpoint of a diff anchor: which side of the diff, and the 1-indexed +/// source line on that side. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Endpoint { + pub side: Side, + pub line: u32, +} + +/// A position in a file. `start == end` for single-line annotations. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Anchor { + /// File/content/markdown modes: plain line coordinates. Sides only exist + /// where a diff does. + Source { path: String, start: u32, end: u32 }, + /// Diff mode: each endpoint carries its side; mixed sides span a deletion + /// and its added replacement. + Diff { + path: String, + start: Endpoint, + end: Endpoint, + }, +} + +impl Anchor { + pub fn start_line(&self) -> u32 { + match self { + Anchor::Source { start, .. } => *start, + Anchor::Diff { start, .. } => start.line, + } + } + + pub fn end_line(&self) -> u32 { + match self { + Anchor::Source { end, .. } => *end, + Anchor::Diff { end, .. } => end.line, + } + } +} + +/// An annotation: a stable id plus the (mutable) anchor and content it carries. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Annotation { + pub id: String, + pub anchor: Anchor, + pub content: Vec, +} + +impl Annotation { + pub fn start_line(&self) -> u32 { + self.anchor.start_line() + } + + pub fn end_line(&self) -> u32 { + self.anchor.end_line() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mixed_side_anchor_round_trips_through_serde() { + let anchor = Anchor::Diff { + path: "test.rs".to_string(), + start: Endpoint { + side: Side::Old, + line: 10, + }, + end: Endpoint { + side: Side::New, + line: 12, + }, + }; + + let json = serde_json::to_string(&anchor).unwrap(); + let round_tripped: Anchor = serde_json::from_str(&json).unwrap(); + + assert_eq!(anchor, round_tripped); + let Anchor::Diff { start, end, .. } = round_tripped else { + panic!("variant changed in round trip"); + }; + assert_eq!(start.side, Side::Old); + assert_eq!(end.side, Side::New); + } + + #[test] + fn source_anchor_wire_shape_is_tagged_and_sideless() { + let anchor = Anchor::Source { + path: "notes.md".to_string(), + start: 3, + end: 7, + }; + + let json = serde_json::to_value(&anchor).unwrap(); + assert_eq!( + json, + serde_json::json!({ "type": "source", "path": "notes.md", "start": 3, "end": 7 }) + ); + + let round_tripped: Anchor = serde_json::from_value(json).unwrap(); + assert_eq!(anchor, round_tripped); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2f611a3b..60b11c0d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,6 +5,7 @@ use std::sync::atomic::Ordering; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager, State, WebviewWindow}; +use crate::anchor::Anchor; use crate::config::{self, Config, Theme}; use crate::lang::extension_to_fence_language; use crate::output::{export_content, export_section, format_json, format_output, OutputMode}; @@ -57,14 +58,14 @@ pub fn get_content( #[tauri::command] pub fn upsert_annotation( review_state: State, + id: String, path: String, - start_line: u32, - end_line: u32, + anchor: Anchor, content: Vec, ) -> Result<(), String> { with_review!(review_state, |review| { let target = review.resolve_target_mut(&path)?; - target.upsert_annotation(start_line, end_line, content); + target.upsert_annotation(id, anchor, content); Ok(()) }) } @@ -73,12 +74,11 @@ pub fn upsert_annotation( pub fn delete_annotation( review_state: State, path: String, - start_line: u32, - end_line: u32, + id: String, ) -> Result<(), String> { with_review!(review_state, |review| { let target = review.resolve_target_mut(&path)?; - target.delete_annotation(start_line, end_line); + target.delete_annotation(&id); Ok(()) }) } @@ -142,7 +142,7 @@ fn collect_tag_usage(review: &mut crate::review::Review) { let mut session_stats = TagUsageStats::default(); // Walk all annotation targets - for (_file_key, target) in &review.files { + for target in review.files.values() { // Get language for this file from metadata let language = target .metadata @@ -466,7 +466,7 @@ pub fn export_to_obsidian( // Use H1 title as note name if present, otherwise fall back to label let note_name = content_model - .lines + .flat_lines() .iter() .find(|l| l.content.starts_with("# ")) .map(|l| l.content.trim_start_matches("# ").trim()) @@ -488,7 +488,9 @@ pub fn export_to_obsidian( /// Sanitize a filename for Obsidian by removing characters that are invalid in filenames. /// Obsidian (and most filesystems) don't allow: \ / : fn sanitize_obsidian_filename(name: &str) -> String { - name.chars().filter(|c| !matches!(c, '\\' | '/' | ':')).collect() + name.chars() + .filter(|c| !matches!(c, '\\' | '/' | ':')) + .collect() } // --- Replace diff (word-level) --- diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 9a418dcc..38f5800d 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -22,6 +22,24 @@ pub enum Theme { Dark, } +/// Native webview background color matching the frontend's `--bg-main`. +/// Windows-only: WebView2 paints its default white before content renders, +/// which flashes on window-open and shows as a blank strip at the growing +/// edges during live resize. macOS ignores `background_color` (Tauri no-op) +/// and Linux/WebKitGTK never had the flash, so this is gated to Windows at the +/// call sites. +/// +/// System resolves to light: dark mode is an opt-in `[data-theme="dark"]` +/// override, so light is today's effective default. Light `--bg-main` is +/// `#fafaf9`; dark is `#1d1b16`. +#[cfg(windows)] +pub fn window_background_color(theme: Theme) -> tauri::webview::Color { + match theme { + Theme::Dark => tauri::webview::Color(0x1d, 0x1b, 0x16, 0xff), + Theme::Light | Theme::System => tauri::webview::Color(0xfa, 0xfa, 0xf9, 0xff), + } +} + /// Application configuration stored in config.json. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -159,7 +177,10 @@ pub fn config_dir() -> Option { /// Ensures the config directory exists. fn ensure_config_dir() -> io::Result { let dir = config_dir().ok_or_else(|| { - io::Error::new(io::ErrorKind::NotFound, "Could not determine config directory") + io::Error::new( + io::ErrorKind::NotFound, + "Could not determine config directory", + ) })?; fs::create_dir_all(&dir)?; Ok(dir) @@ -235,10 +256,7 @@ pub fn save_tag_usage(stats: &TagUsageStats) -> io::Result<()> { // Merge: add memory counts to disk counts let mut merged = disk_stats; for (tag_id, mem_usage) in &stats.tags { - let entry = merged - .tags - .entry(tag_id.clone()) - .or_insert_with(|| crate::state::TagUsage::default()); + let entry = merged.tags.entry(tag_id.clone()).or_default(); entry.count += mem_usage.count; // Take the more recent last_used if mem_usage.last_used > entry.last_used { @@ -344,7 +362,7 @@ pub fn discover_commands() -> Vec { // Assign order values (after transient and persisted modes) for (i, mode) in commands.iter_mut().enumerate() { mode.order = 1000 + i as u32; // High order to appear after persisted modes - // Assign color from palette + // Assign color from palette mode.color = COMMAND_COLORS[i % COMMAND_COLORS.len()].to_string(); } @@ -400,7 +418,9 @@ fn parse_command_file(path: &Path) -> Option { let name = path.file_stem()?.to_string_lossy().to_string(); // Description becomes instruction - let description = frontmatter.get("description").cloned() + let description = frontmatter + .get("description") + .cloned() .unwrap_or_else(|| format!("Run /{} command", name)); Some(ExitMode { @@ -409,7 +429,9 @@ fn parse_command_file(path: &Path) -> Option { color: String::new(), // Will be assigned later instruction: description, order: 0, // Will be assigned later - source: ExitModeSource::Command { path: path.to_path_buf() }, + source: ExitModeSource::Command { + path: path.to_path_buf(), + }, }) } @@ -607,6 +629,26 @@ mod tests { assert_eq!(config.theme, Theme::System); } + #[cfg(windows)] + #[test] + fn window_background_matches_css_bg_main() { + // Must match `--bg-main` in src/styles/tokens.css, or WebView2 flashes + // its default background on open/resize. System resolves to light + // because dark is an opt-in [data-theme="dark"] override. + assert_eq!( + window_background_color(Theme::Light), + tauri::webview::Color(0xfa, 0xfa, 0xf9, 0xff) + ); + assert_eq!( + window_background_color(Theme::System), + window_background_color(Theme::Light) + ); + assert_eq!( + window_background_color(Theme::Dark), + tauri::webview::Color(0x1d, 0x1b, 0x16, 0xff) + ); + } + #[test] fn config_deserializes_without_theme() { // Old configs without theme field should default to System @@ -646,7 +688,10 @@ description: My command without quotes Content "#; let fm = extract_yaml_frontmatter(content).unwrap(); - assert_eq!(fm.get("description"), Some(&"My command without quotes".to_string())); + assert_eq!( + fm.get("description"), + Some(&"My command without quotes".to_string()) + ); } #[test] @@ -690,12 +735,16 @@ Content fn discover_commands_parses_command_file() { let temp = TempDir::new().unwrap(); let cmd_file = temp.path().join("test-cmd.md"); - fs::write(&cmd_file, r#"--- + fs::write( + &cmd_file, + r#"--- description: "Test command description" --- # Test Command Instructions here -"#).unwrap(); +"#, + ) + .unwrap(); let modes = discover_commands_in_dir(temp.path()).unwrap(); assert_eq!(modes.len(), 1); @@ -708,7 +757,11 @@ Instructions here #[test] fn discover_commands_skips_files_without_frontmatter() { let temp = TempDir::new().unwrap(); - fs::write(temp.path().join("no-fm.md"), "# Just a file\nNo frontmatter").unwrap(); + fs::write( + temp.path().join("no-fm.md"), + "# Just a file\nNo frontmatter", + ) + .unwrap(); let modes = discover_commands_in_dir(temp.path()).unwrap(); assert!(modes.is_empty()); @@ -720,8 +773,9 @@ Instructions here for i in 0..3 { fs::write( temp.path().join(format!("cmd{}.md", i)), - format!("---\ndescription: Cmd {}\n---\nContent", i) - ).unwrap(); + format!("---\ndescription: Cmd {}\n---\nContent", i), + ) + .unwrap(); } // Use discover_commands with explicit dir diff --git a/src-tauri/src/diff.rs b/src-tauri/src/diff.rs index b24ab68e..bdd0720e 100644 --- a/src-tauri/src/diff.rs +++ b/src-tauri/src/diff.rs @@ -1,76 +1,49 @@ //! Unified diff parsing and detection. +//! +//! Parses raw `diff_content` patches into per-file `DiffDocument`s natively +//! via `diffy`'s git-aware parser (`FileOperation`/`FileMode`/`PatchKind`/ +//! `Hunk`). Plumbing rows (`index`/`---`/`+++`/mode/rename/similarity) have +//! no representation, matching what the frontend never renders and what +//! `pipeline.rs`'s git-native path already omits. -use std::collections::HashMap; +use std::borrow::Cow; -use serde::Serialize; -use unidiff::PatchSet; +use diffy::patch_set::{FileMode, FileOperation, ParseOptions, PatchKind, PatchSet}; use crate::error::AnnotError; +use crate::highlight::Highlighter; +use crate::state::{DiffDocument, HunkV2, LineHtml, Row}; +use crate::vcs::FileStatus; -/// Line type in a diff. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum DiffLineKind { - Context, - Added, - Deleted, - Header, -} - -/// Metadata for a hunk within a file. -#[derive(Clone, Debug, Serialize)] -pub struct HunkInfo { - /// Display line number of the @@ header (1-indexed). - pub display_line: u32, - /// Starting line in old file. - pub old_start: u32, - /// Number of lines from old file. - pub old_count: u32, - /// Starting line in new file. - pub new_start: u32, - /// Number of lines in new file. - pub new_count: u32, - /// Function/context from hunk header (e.g., "fn process()"). - pub function_context: Option, - /// Syntax-highlighted HTML of function context. - pub function_context_html: Option, -} - -/// Metadata for a single line in the flattened diff view. -#[derive(Clone, Debug, Serialize)] -pub struct DiffLineInfo { - pub kind: DiffLineKind, - /// Original file line number (None for added lines and headers). - pub old_line_num: Option, - /// New file line number (None for deleted lines and headers). - pub new_line_num: Option, - /// Index into the files array. - pub file_index: usize, +fn normalize_line_endings(content: &str) -> Cow<'_, str> { + if content.contains("\r\n") { + Cow::Owned(content.replace("\r\n", "\n")) + } else { + Cow::Borrowed(content) + } } -/// Metadata for a single file in the diff. -#[derive(Clone, Debug, Serialize)] -pub struct DiffFileInfo { - pub old_name: Option, - pub new_name: Option, - /// Detected language (from extension). - pub language: String, - /// 1-indexed start line in flattened view. - pub start_line: u32, - /// 1-indexed end line in flattened view. - pub end_line: u32, - /// Hunks within this file, ordered by display line. - pub hunks: Vec, +/// Display identity for a diff file: `path` is the new name (old for deleted +/// files); `old_path` only when the name actually changed. Shared by the +/// patch parser and the git pipeline. +pub(crate) fn display_identity(old: Option<&str>, new: Option<&str>) -> (String, Option) { + let path = new.or(old).unwrap_or_default().to_string(); + let old_path = match (old, new) { + (Some(old), Some(new)) if old != new => Some(old.to_string()), + _ => None, + }; + (path, old_path) } -/// Parsed diff metadata for rendering. -#[derive(Clone, Debug, Serialize)] -pub struct DiffMetadata { - pub files: Vec, - /// Map from display line number (1-indexed) to line info. - /// Used internally during Line construction, not serialized to frontend. - #[serde(skip)] - pub lines: HashMap, +/// Language identifier for a diff file: the extension of the new name (old +/// for deleted files). Feeds syntax highlighting for both the patch parser +/// and the git pipeline. +pub fn language_for(new_name: Option<&str>, old_name: Option<&str>) -> String { + new_name + .or(old_name) + .and_then(|name| std::path::Path::new(name).extension()?.to_str()) + .map(String::from) + .unwrap_or_default() } /// Check if content appears to be a unified diff. @@ -78,202 +51,208 @@ pub fn is_diff(content: &str) -> bool { if content.is_empty() { return false; } - let mut patch = PatchSet::new(); - patch.parse(content).is_ok() && !patch.files().is_empty() + let content = normalize_line_endings(content); + PatchSet::parse(content.as_ref(), ParseOptions::gitdiff()) + .collect::, _>>() + .is_ok_and(|files| !files.is_empty()) } -/// Parse unified diff content into metadata by iterating raw lines. -/// This ensures line numbers match the actual content display. -pub fn parse_diff(content: &str) -> Result { - // Validate it's a diff first - let mut patch = PatchSet::new(); - patch - .parse(content) - .map_err(|e| AnnotError::Diff(format!("Failed to parse diff: {:?}", e)))?; - - if patch.files().is_empty() { +/// Parse unified diff content into per-file documents. +pub(crate) fn parse_diff( + content: &str, + highlighter: &Highlighter, +) -> Result, AnnotError> { + let content = normalize_line_endings(content); + let files = PatchSet::parse(content.as_ref(), ParseOptions::gitdiff()) + .map(|result| { + result + .map_err(|e| AnnotError::Diff(format!("Failed to parse diff: {e}"))) + .map(|file_patch| build_file(&file_patch, highlighter)) + }) + .collect::, AnnotError>>()?; + + if files.is_empty() { return Err(AnnotError::Diff("Not a valid diff".into())); } - let mut metadata = DiffMetadata { - files: Vec::new(), - lines: HashMap::new(), + Ok(files) +} + +fn build_file( + fp: &diffy::patch_set::FilePatch<'_, str>, + highlighter: &Highlighter, +) -> DiffDocument { + let (old_path, new_path, status) = + resolve_operation(fp.operation(), fp.old_mode(), fp.new_mode()); + let language = language_for(new_path.as_deref(), old_path.as_deref()); + let unavailable = fp.patch().is_binary(); + + let hunks = match fp.patch() { + PatchKind::Text(patch) => { + let fake_path = format!("file.{language}"); + patch + .hunks() + .iter() + .map(|hunk| build_hunk(hunk, &language, highlighter, &fake_path)) + .collect() + } + PatchKind::Binary(_) => Vec::new(), }; - // Track state while iterating raw lines - let mut current_file_idx: usize = 0; - let mut current_old_line: u32 = 0; - let mut current_new_line: u32 = 0; - let mut in_hunk = false; - - // Build file info from parsed data - for file in patch.files() { - let new_name = if file.target_file == "/dev/null" { - None - } else { - Some(file.target_file.trim_start_matches("b/").to_string()) - }; - let old_name = if file.source_file == "/dev/null" { - None - } else { - Some(file.source_file.trim_start_matches("a/").to_string()) - }; - - let language = new_name - .as_ref() - .or(old_name.as_ref()) - .and_then(|name| { - std::path::Path::new(name) - .extension() - .and_then(|ext| ext.to_str()) - .map(|s| s.to_string()) - }) - .unwrap_or_default(); - - metadata.files.push(DiffFileInfo { - old_name, - new_name, - language, - start_line: 0, // Will be updated - end_line: 0, // Will be updated - hunks: Vec::new(), - }); + let (path, old_path) = display_identity(old_path.as_deref(), new_path.as_deref()); + + DiffDocument { + path, + old_path, + status, + unavailable, + language, + hunks, } +} - // Iterate raw content lines to build line metadata - for (idx, line_content) in content.lines().enumerate() { - let line_num = (idx + 1) as u32; - - // Detect line type by prefix - let (kind, old_num, new_num) = if line_content.starts_with("diff --git ") { - // New file header - update file tracking - if current_file_idx > 0 || in_hunk { - // Update previous file's end line - if let Some(f) = metadata.files.get_mut(current_file_idx) { - f.end_line = line_num - 1; - } - current_file_idx += 1; - } - if let Some(f) = metadata.files.get_mut(current_file_idx) { - f.start_line = line_num; - } - in_hunk = false; - (DiffLineKind::Header, None, None) - } else if line_content.starts_with("index ") - || line_content.starts_with("--- ") - || line_content.starts_with("+++ ") - || line_content.starts_with("new file mode") - || line_content.starts_with("deleted file mode") - || line_content.starts_with("old mode") - || line_content.starts_with("new mode") - || line_content.starts_with("similarity index") - || line_content.starts_with("rename from") - || line_content.starts_with("rename to") - || line_content.starts_with("Binary files") - { - (DiffLineKind::Header, None, None) - } else if line_content.starts_with("@@ ") { - // Hunk header - parse line numbers and function context - in_hunk = true; - if let Some(hunk_info) = parse_hunk_header(line_content, line_num) { - current_old_line = hunk_info.old_start; - current_new_line = hunk_info.new_start; - // Add hunk to current file - if let Some(f) = metadata.files.get_mut(current_file_idx) { - f.hunks.push(hunk_info); +/// Map a diffy `FileOperation` (+ mode headers) to display paths and +/// `FileStatus`. `Delete`/`Create`/`Modify` paths carry an `a/`/`b/` prefix +/// from the `---`/`+++` headers (or the bare `diff --git` line for +/// hunk-less creations) and need `strip_prefix(1)`; `Rename`/`Copy` paths +/// come from `rename from`/`rename to`/`copy from`/`copy to` headers, which +/// git never prefixes. +fn resolve_operation( + op: &FileOperation<'_, str>, + old_mode: Option<&FileMode>, + new_mode: Option<&FileMode>, +) -> (Option, Option, FileStatus) { + match op { + FileOperation::Rename { from, to } => ( + Some(from.to_string()), + Some(to.to_string()), + // diffy discards the real `similarity index NN%` value (only + // recognizes the line to skip past it); nothing downstream + // reads this field today, so a default costs nothing. + FileStatus::Renamed { similarity: 100 }, + ), + FileOperation::Copy { from, to } => ( + Some(from.to_string()), + Some(to.to_string()), + FileStatus::Copied, + ), + FileOperation::Delete(_) | FileOperation::Create(_) | FileOperation::Modify { .. } => { + match op.strip_prefix(1) { + FileOperation::Delete(path) => (Some(path.into_owned()), None, FileStatus::Deleted), + FileOperation::Create(path) => (None, Some(path.into_owned()), FileStatus::Added), + FileOperation::Modify { original, modified } => { + let old_path = original.into_owned(); + let new_path = modified.into_owned(); + let status = if old_path != new_path { + // Paths differ without explicit rename headers — + // shouldn't happen from real git output, but if it + // does, treat as a rename (diffy's own suggested + // fallback), similarity unknown. + FileStatus::Renamed { similarity: 100 } + } else if old_mode.is_some() && new_mode.is_some() && old_mode != new_mode { + FileStatus::TypeChanged + } else { + FileStatus::Modified + }; + (Some(old_path), Some(new_path), status) } + _ => unreachable!("strip_prefix preserves the operation's variant"), } - (DiffLineKind::Header, None, None) - } else if in_hunk { - // Inside a hunk - determine line type - if line_content.starts_with('+') { - let new_num = current_new_line; - current_new_line += 1; - (DiffLineKind::Added, None, Some(new_num)) - } else if line_content.starts_with('-') { - let old_num = current_old_line; - current_old_line += 1; - (DiffLineKind::Deleted, Some(old_num), None) - } else if line_content.starts_with(' ') || line_content.is_empty() { - let old_num = current_old_line; - let new_num = current_new_line; - current_old_line += 1; - current_new_line += 1; - (DiffLineKind::Context, Some(old_num), Some(new_num)) - } else { - // Unknown line in hunk - treat as context - let old_num = current_old_line; - let new_num = current_new_line; - current_old_line += 1; - current_new_line += 1; - (DiffLineKind::Context, Some(old_num), Some(new_num)) - } - } else { - // Outside hunk - treat as header - (DiffLineKind::Header, None, None) - }; - - metadata.lines.insert( - line_num, - DiffLineInfo { - kind, - old_line_num: old_num, - new_line_num: new_num, - file_index: current_file_idx, - }, - ); + } } +} - // Update last file's end line - let total_lines = content.lines().count() as u32; - if let Some(f) = metadata.files.get_mut(current_file_idx) { - f.end_line = total_lines; +fn build_hunk( + hunk: &diffy::Hunk<'_, str>, + language: &str, + highlighter: &Highlighter, + fake_path: &str, +) -> HunkV2 { + let old_range = hunk.old_range(); + let new_range = hunk.new_range(); + // diffy retains the header line's trailing newline in the context text. + let function_context = hunk.function_context().map(|ctx| ctx.trim_end().to_owned()); + let function_context_html = function_context + .as_deref() + .and_then(|ctx| highlighter.highlight_function_context(ctx, fake_path)); + + let mut old_line = old_range.start() as u32; + let mut new_line = new_range.start() as u32; + + let rows = hunk + .lines() + .iter() + .map(|line| { + build_row( + line, + &mut old_line, + &mut new_line, + language, + highlighter, + fake_path, + ) + }) + .collect(); + + // diffy ranges are already in git-printed convention (an empty side + // starts at the line before the position), matching HunkV2's contract. + let start = old_range.start() as u32; + let old_range = start..start + old_range.len() as u32; + let start = new_range.start() as u32; + let new_range = start..start + new_range.len() as u32; + + HunkV2 { + old_range, + new_range, + function_context, + function_context_html, + rows, } - - Ok(metadata) } -/// Parse hunk header like "@@ -1,10 +1,12 @@ fn example()" to extract metadata. -fn parse_hunk_header(header: &str, display_line: u32) -> Option { - // Format: @@ -old_start,old_count +new_start,new_count @@ [function_context] - let parts: Vec<&str> = header.split_whitespace().collect(); - if parts.len() < 3 { - return None; - } +fn build_row( + line: &diffy::Line<'_, str>, + old_line: &mut u32, + new_line: &mut u32, + language: &str, + highlighter: &Highlighter, + fake_path: &str, +) -> Row { + let (raw, old, new) = match *line { + diffy::Line::Context(text) => { + let old = *old_line; + let new = *new_line; + *old_line += 1; + *new_line += 1; + (text, Some(old), Some(new)) + } + diffy::Line::Delete(text) => { + let old = *old_line; + *old_line += 1; + (text, Some(old), None) + } + diffy::Line::Insert(text) => { + let new = *new_line; + *new_line += 1; + (text, None, Some(new)) + } + }; - let old_part = parts.get(1)?; // "-1,10" - let new_part = parts.get(2)?; // "+1,12" - - // Parse old range: -start,count or -start (count defaults to 1) - let old_trimmed = old_part.trim_start_matches('-'); - let old_parts: Vec<&str> = old_trimmed.split(',').collect(); - let old_start = old_parts.first()?.parse::().ok()?; - let old_count = old_parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1); - - // Parse new range: +start,count or +start (count defaults to 1) - let new_trimmed = new_part.trim_start_matches('+'); - let new_parts: Vec<&str> = new_trimmed.split(',').collect(); - let new_start = new_parts.first()?.parse::().ok()?; - let new_count = new_parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1); - - // Extract function context: everything after the closing @@ - // Header format: "@@ -1,10 +1,12 @@ fn example()" - let function_context = header - .find(" @@ ") - .or_else(|| header.find(" @@\t")) - .map(|pos| header[pos + 4..].trim()) - .filter(|s| !s.is_empty()) - .map(String::from); - - Some(HunkInfo { - display_line, - old_start, - old_count, - new_start, - new_count, - function_context, - function_context_html: None, // Filled in by state.rs with syntax highlighting - }) + // diffy's `Line` retains the trailing `\n` unless it's the file's final + // line without one — our internal model, like every other line-based + // representation in this codebase, is newline-free. + let code = raw.trim_end_matches('\n'); + + let html = (!language.is_empty()) + .then(|| highlighter.highlight_diff_row(code, fake_path)) + .flatten(); + + Row { + old_line: old, + new_line: new, + content: code.to_string(), + html: html.map(LineHtml::Full), + } } #[cfg(test)] @@ -303,119 +282,16 @@ index 0000000..9b710f3 + +Only for testing purposes."#; - #[test] - fn is_diff_returns_true_for_valid_diff() { - assert!(is_diff(SIMPLE_DIFF)); - } - - #[test] - fn is_diff_returns_true_for_new_file_diff() { - assert!(is_diff(NEW_FILE_DIFF)); - } - - #[test] - fn is_diff_returns_false_for_empty() { - assert!(!is_diff("")); - } - - #[test] - fn is_diff_returns_false_for_regular_content() { - assert!(!is_diff("fn main() {\n println!(\"hello\");\n}")); - } - - #[test] - fn is_diff_returns_false_for_partial_diff_like_content() { - // Content that looks like diff but isn't valid - // Note: unidiff is permissive, so we test truly invalid content - assert!(!is_diff("just some random text\nwith multiple lines")); - } - - #[test] - fn parse_diff_extracts_file_info() { - let meta = parse_diff(SIMPLE_DIFF).unwrap(); - - assert_eq!(meta.files.len(), 1); - assert_eq!(meta.files[0].old_name, Some("file.rs".to_string())); - assert_eq!(meta.files[0].new_name, Some("file.rs".to_string())); - assert_eq!(meta.files[0].language, "rs"); - } - - #[test] - fn parse_diff_handles_new_file() { - let meta = parse_diff(NEW_FILE_DIFF).unwrap(); - - assert_eq!(meta.files.len(), 1); - assert_eq!(meta.files[0].old_name, None); - assert_eq!(meta.files[0].new_name, Some("added_file".to_string())); - } - - #[test] - fn parse_diff_tracks_line_types() { - let meta = parse_diff(SIMPLE_DIFF).unwrap(); - - // Find the lines by type - let added_lines: Vec<_> = meta - .lines - .iter() - .filter(|(_, info)| info.kind == DiffLineKind::Added) - .collect(); - let deleted_lines: Vec<_> = meta - .lines - .iter() - .filter(|(_, info)| info.kind == DiffLineKind::Deleted) - .collect(); - let context_lines: Vec<_> = meta - .lines - .iter() - .filter(|(_, info)| info.kind == DiffLineKind::Context) - .collect(); - - assert_eq!(deleted_lines.len(), 1, "Should have 1 deleted line"); - assert_eq!(added_lines.len(), 2, "Should have 2 added lines"); - assert_eq!(context_lines.len(), 2, "Should have 2 context lines"); - } - - #[test] - fn parse_diff_tracks_line_numbers() { - let meta = parse_diff(SIMPLE_DIFF).unwrap(); - - // Find the deleted line - let deleted = meta - .lines - .iter() - .find(|(_, info)| info.kind == DiffLineKind::Deleted) - .map(|(_, info)| info) - .unwrap(); - - assert_eq!(deleted.old_line_num, Some(2), "Deleted line should have old line num"); - assert_eq!(deleted.new_line_num, None, "Deleted line should not have new line num"); - - // Find an added line - let added = meta - .lines - .iter() - .find(|(_, info)| info.kind == DiffLineKind::Added) - .map(|(_, info)| info) - .unwrap(); - - assert_eq!(added.old_line_num, None, "Added line should not have old line num"); - assert!(added.new_line_num.is_some(), "Added line should have new line num"); - } - - #[test] - fn parse_diff_context_has_both_line_nums() { - let meta = parse_diff(SIMPLE_DIFF).unwrap(); - - let context = meta - .lines - .iter() - .find(|(_, info)| info.kind == DiffLineKind::Context) - .map(|(_, info)| info) - .unwrap(); - - assert!(context.old_line_num.is_some(), "Context should have old line num"); - assert!(context.new_line_num.is_some(), "Context should have new line num"); - } + const DELETED_FILE_DIFF: &str = r#"diff --git a/old_file.rs b/old_file.rs +deleted file mode 100644 +index abcdef..0000000 +--- a/old_file.rs ++++ /dev/null +@@ -1,3 +0,0 @@ +-fn deprecated() { +- // removed +-} +"#; const MULTI_FILE_DIFF: &str = r#"diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs @@ -434,17 +310,6 @@ diff --git a/src/lib.rs b/src/lib.rs } "#; - const DELETED_FILE_DIFF: &str = r#"diff --git a/old_file.rs b/old_file.rs -deleted file mode 100644 -index abcdef..0000000 ---- a/old_file.rs -+++ /dev/null -@@ -1,3 +0,0 @@ --fn deprecated() { -- // removed --} -"#; - const MULTIPLE_HUNKS_DIFF: &str = r#"diff --git a/big_file.rs b/big_file.rs --- a/big_file.rs +++ b/big_file.rs @@ -460,236 +325,172 @@ index abcdef..0000000 } "#; - #[test] - fn parse_multi_file_diff() { - let meta = parse_diff(MULTI_FILE_DIFF).unwrap(); + const PURE_RENAME_DIFF: &str = r#"diff --git a/old.rs b/new.rs +similarity index 100% +rename from old.rs +rename to new.rs +"#; - assert_eq!(meta.files.len(), 2, "Should have 2 files"); - assert_eq!(meta.files[0].new_name, Some("src/main.rs".to_string())); - assert_eq!(meta.files[0].language, "rs"); - assert_eq!(meta.files[1].new_name, Some("src/lib.rs".to_string())); - assert_eq!(meta.files[1].language, "rs"); - } + const NO_NEWLINE_DIFF: &str = r#"diff --git a/file.rs b/file.rs +--- a/file.rs ++++ b/file.rs +@@ -1,2 +1,2 @@ + fn main() { +-} +\ No newline at end of file ++} +"#; - #[test] - fn parse_multi_file_diff_tracks_file_index() { - let meta = parse_diff(MULTI_FILE_DIFF).unwrap(); - - // Lines from file 0 should have file_index 0 - let file0_lines: Vec<_> = meta - .lines - .iter() - .filter(|(_, info)| info.file_index == 0) - .collect(); - assert!(!file0_lines.is_empty(), "Should have lines for file 0"); - - // Lines from file 1 should have file_index 1 - let file1_lines: Vec<_> = meta - .lines - .iter() - .filter(|(_, info)| info.file_index == 1) - .collect(); - assert!(!file1_lines.is_empty(), "Should have lines for file 1"); + fn parse(content: &str) -> Vec { + parse_diff(content, &Highlighter::new()).unwrap() } #[test] - fn parse_deleted_file_diff() { - let meta = parse_diff(DELETED_FILE_DIFF).unwrap(); - - assert_eq!(meta.files.len(), 1); - assert_eq!(meta.files[0].old_name, Some("old_file.rs".to_string())); - assert_eq!(meta.files[0].new_name, None, "Deleted file has no new name"); - - // All content lines should be deleted - let deleted: Vec<_> = meta - .lines - .iter() - .filter(|(_, info)| info.kind == DiffLineKind::Deleted) - .collect(); - assert_eq!(deleted.len(), 3, "Should have 3 deleted lines"); + fn is_diff_returns_true_for_valid_diff() { + assert!(is_diff(SIMPLE_DIFF)); } #[test] - fn parse_multiple_hunks() { - let meta = parse_diff(MULTIPLE_HUNKS_DIFF).unwrap(); - - assert_eq!(meta.files.len(), 1); - - // Should have 5 header lines: diff --git, ---, +++, @@ (hunk1), @@ (hunk2) - let headers: Vec<_> = meta - .lines - .iter() - .filter(|(_, info)| info.kind == DiffLineKind::Header) - .collect(); - assert_eq!(headers.len(), 5, "Should have 5 header lines (3 file headers + 2 hunk headers)"); - - // First hunk changes line 2, second hunk changes line 11 - let deleted: Vec<_> = meta - .lines - .iter() - .filter(|(_, info)| info.kind == DiffLineKind::Deleted) - .collect(); - assert_eq!(deleted.len(), 2, "Should have 2 deleted lines"); - - // Check line numbers span different ranges - let old_line_nums: Vec = deleted - .iter() - .filter_map(|(_, info)| info.old_line_num) - .collect(); - assert!(old_line_nums.contains(&2), "Should have deletion at line 2"); - assert!(old_line_nums.contains(&11), "Should have deletion at line 11"); + fn is_diff_returns_true_for_new_file_diff() { + assert!(is_diff(NEW_FILE_DIFF)); } #[test] - fn parse_diff_file_line_ranges() { - let meta = parse_diff(MULTI_FILE_DIFF).unwrap(); - - // File ranges should be contiguous and non-overlapping - assert!(meta.files[0].start_line < meta.files[0].end_line); - assert!(meta.files[0].end_line < meta.files[1].start_line); - assert!(meta.files[1].start_line < meta.files[1].end_line); + fn is_diff_returns_false_for_empty() { + assert!(!is_diff("")); } #[test] - fn parse_diff_display_line_numbers_are_sequential() { - let meta = parse_diff(SIMPLE_DIFF).unwrap(); - - // All display line numbers should be present from 1 to max - let max_line = *meta.lines.keys().max().unwrap(); - for i in 1..=max_line { - assert!( - meta.lines.contains_key(&i), - "Missing display line {}", - i - ); - } + fn is_diff_returns_false_for_regular_content() { + assert!(!is_diff("fn main() {\n println!(\"hello\");\n}")); } #[test] - fn parse_diff_error_on_invalid_content() { - let result = parse_diff("not a diff at all"); - assert!(result.is_err()); + fn parse_diff_extracts_file_info() { + let files = parse(SIMPLE_DIFF); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "file.rs"); + assert_eq!(files[0].old_path, None, "same name is not a rename"); + assert_eq!(files[0].language, "rs"); + assert_eq!(files[0].status, FileStatus::Modified); } #[test] - fn diff_line_kind_serializes_lowercase() { - // Verify serde serialization for frontend - let json = serde_json::to_string(&DiffLineKind::Added).unwrap(); - assert_eq!(json, "\"added\""); - - let json = serde_json::to_string(&DiffLineKind::Deleted).unwrap(); - assert_eq!(json, "\"deleted\""); - - let json = serde_json::to_string(&DiffLineKind::Context).unwrap(); - assert_eq!(json, "\"context\""); - - let json = serde_json::to_string(&DiffLineKind::Header).unwrap(); - assert_eq!(json, "\"header\""); + fn parse_diff_handles_new_file() { + let files = parse(NEW_FILE_DIFF); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "added_file"); + assert_eq!(files[0].old_path, None); + assert_eq!(files[0].status, FileStatus::Added); + assert_eq!(files[0].hunks[0].old_range, 0..0, "git-printed convention"); + assert_eq!(files[0].hunks[0].new_range, 1..5); } #[test] - fn parse_hunk_header_extracts_ranges() { - let hunk = parse_hunk_header("@@ -1,10 +1,12 @@", 5).unwrap(); - assert_eq!(hunk.display_line, 5); - assert_eq!(hunk.old_start, 1); - assert_eq!(hunk.old_count, 10); - assert_eq!(hunk.new_start, 1); - assert_eq!(hunk.new_count, 12); - assert_eq!(hunk.function_context, None); + fn parse_deleted_file_diff() { + let files = parse(DELETED_FILE_DIFF); + assert_eq!(files.len(), 1); + assert_eq!( + files[0].path, "old_file.rs", + "deleted files show the old name" + ); + assert_eq!(files[0].old_path, None); + assert_eq!(files[0].status, FileStatus::Deleted); } #[test] - fn parse_hunk_header_extracts_function_context() { - let hunk = parse_hunk_header("@@ -50,10 +52,12 @@ fn process_data()", 10).unwrap(); - assert_eq!(hunk.old_start, 50); - assert_eq!(hunk.old_count, 10); - assert_eq!(hunk.new_start, 52); - assert_eq!(hunk.new_count, 12); - assert_eq!(hunk.function_context, Some("fn process_data()".to_string())); + fn parse_multi_file_diff() { + let files = parse(MULTI_FILE_DIFF); + assert_eq!(files.len(), 2, "Should have 2 files"); + assert_eq!(files[0].path, "src/main.rs"); + assert_eq!(files[1].path, "src/lib.rs"); } #[test] - fn parse_hunk_header_handles_single_line_count() { - // When count is omitted, it defaults to 1 - let hunk = parse_hunk_header("@@ -1 +1 @@", 1).unwrap(); - assert_eq!(hunk.old_count, 1); - assert_eq!(hunk.new_count, 1); + fn parse_multiple_hunks() { + let files = parse(MULTIPLE_HUNKS_DIFF); + assert_eq!(files[0].hunks.len(), 2, "Should have 2 hunks"); + assert_eq!(files[0].hunks[0].old_range, 1..4); + assert_eq!(files[0].hunks[1].old_range, 10..13); } #[test] - fn parse_hunk_header_handles_zero_lines() { - // New file: @@ -0,0 +1,5 @@ - let hunk = parse_hunk_header("@@ -0,0 +1,5 @@", 1).unwrap(); - assert_eq!(hunk.old_start, 0); - assert_eq!(hunk.old_count, 0); - assert_eq!(hunk.new_start, 1); - assert_eq!(hunk.new_count, 5); - } + fn parse_diff_tracks_line_numbers() { + let files = parse(SIMPLE_DIFF); + let rows = &files[0].hunks[0].rows; - const DIFF_WITH_FUNCTION_CONTEXT: &str = r#"diff --git a/lib.rs b/lib.rs ---- a/lib.rs -+++ b/lib.rs -@@ -10,5 +10,6 @@ fn calculate_total() - fn calculate_total() { - let sum = 0; -- return sum; -+ let tax = sum * 0.1; -+ return sum + tax; - } -"#; + let deleted = rows.iter().find(|r| r.new_line.is_none()).unwrap(); + assert_eq!(deleted.old_line, Some(2)); + + let added = rows.iter().find(|r| r.old_line.is_none()).unwrap(); + assert!(added.new_line.is_some()); + } #[test] - fn parse_diff_extracts_hunks_with_function_context() { - let meta = parse_diff(DIFF_WITH_FUNCTION_CONTEXT).unwrap(); - - assert_eq!(meta.files.len(), 1); - assert_eq!(meta.files[0].hunks.len(), 1); - - let hunk = &meta.files[0].hunks[0]; - assert_eq!(hunk.old_start, 10); - assert_eq!(hunk.old_count, 5); - assert_eq!(hunk.new_start, 10); - assert_eq!(hunk.new_count, 6); - assert_eq!(hunk.function_context, Some("fn calculate_total()".to_string())); + fn pure_rename_is_not_dropped_and_does_not_misindex_following_files() { + let combined = format!( + "{PURE_RENAME_DIFF}diff --git a/foo b/foo\n--- a/foo\n+++ b/foo\n@@ -1 +1 @@\n-old\n+new\n" + ); + let files = parse(&combined); + assert_eq!(files.len(), 2, "pure rename must not be dropped"); + assert_eq!(files[0].path, "new.rs"); + assert_eq!(files[0].old_path.as_deref(), Some("old.rs")); + assert_eq!(files[0].status, FileStatus::Renamed { similarity: 100 }); + assert!(files[0].hunks.is_empty()); + + // The second file must resolve correctly — not misindexed. + assert_eq!(files[1].path, "foo"); + assert_eq!(files[1].hunks.len(), 1); } #[test] - fn parse_multiple_hunks_extracts_all_hunks() { - let meta = parse_diff(MULTIPLE_HUNKS_DIFF).unwrap(); + fn no_newline_marker_does_not_shift_line_numbers() { + let files = parse(NO_NEWLINE_DIFF); + let rows = &files[0].hunks[0].rows; - assert_eq!(meta.files[0].hunks.len(), 2, "Should have 2 hunks"); + // Exactly one deleted and one added row — the no-newline marker is + // not a phantom context row. + assert_eq!(rows.iter().filter(|r| r.new_line.is_none()).count(), 1); + assert_eq!(rows.iter().filter(|r| r.old_line.is_none()).count(), 1); - let hunk1 = &meta.files[0].hunks[0]; - assert_eq!(hunk1.old_start, 1); - assert_eq!(hunk1.old_count, 3); + let added = rows.iter().find(|r| r.old_line.is_none()).unwrap(); + assert_eq!(added.new_line, Some(2)); + } - let hunk2 = &meta.files[0].hunks[1]; - assert_eq!(hunk2.old_start, 10); - assert_eq!(hunk2.old_count, 3); + /// `pnpm demo:diff` opens this exact file — a native GUI window this + /// suite can't drive, so this is the closest automatable stand-in: + /// the same fixture parsed end-to-end. + #[test] + fn parses_the_demo_diff_fixture_end_to_end() { + let sample = include_str!("../../test-fixtures/sample.diff"); + let files = parse(sample); + assert_eq!(files.len(), 11, "sample.diff has 11 changed files"); + assert!(files.iter().all(|f| f.status == FileStatus::Modified)); + assert!(files.iter().all(|f| !f.hunks.is_empty())); } #[test] - fn parse_multi_file_diff_extracts_hunks_per_file() { - let meta = parse_diff(MULTI_FILE_DIFF).unwrap(); + fn parses_crlf_diff() { + let crlf = SIMPLE_DIFF.replace('\n', "\r\n"); + assert!(is_diff(&crlf)); - assert_eq!(meta.files[0].hunks.len(), 1, "First file should have 1 hunk"); - assert_eq!(meta.files[1].hunks.len(), 1, "Second file should have 1 hunk"); + let files = parse(&crlf); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "file.rs"); + assert_eq!(files[0].hunks[0].rows[0].content, "fn main() {"); } #[test] - fn hunk_info_serializes_correctly() { - let hunk = HunkInfo { - display_line: 5, - old_start: 10, - old_count: 5, - new_start: 12, - new_count: 7, - function_context: Some("fn example()".to_string()), - function_context_html: Some("fn example()".to_string()), - }; - - let json = serde_json::to_string(&hunk).unwrap(); - assert!(json.contains("\"display_line\":5")); - assert!(json.contains("\"function_context\":\"fn example()\"")); + fn rows_are_raw() { + let files = parse(SIMPLE_DIFF); + let rows = &files[0].hunks[0].rows; + + // No +/-/space prefix on content — the sign is derivable from the + // line-number pattern and re-attached only at presentation edges. + assert_eq!(rows[0].content, "fn main() {"); + let deleted = rows.iter().find(|r| r.new_line.is_none()).unwrap(); + assert_eq!(deleted.content, " old_code();"); + let added = rows.iter().find(|r| r.old_line.is_none()).unwrap(); + assert!(!added.content.starts_with('+')); } } diff --git a/src-tauri/src/engine/mod.rs b/src-tauri/src/engine/mod.rs new file mode 100644 index 00000000..5bce0b63 --- /dev/null +++ b/src-tauri/src/engine/mod.rs @@ -0,0 +1,497 @@ +//! In-process diff engine: hunks computed from two full texts. +//! +//! Hunks are a derived overlay over the two sides — the patch is not the +//! source of truth here. `diff.rs` stays the legacy parser for raw +//! `diff_content` input. The `similar` crate is an implementation detail +//! hidden behind `compute_hunks`; the signature is the contract. + +use std::ops::Range; + +use serde::Serialize; +use similar::{capture_diff_slices, group_diff_ops, Algorithm, ChangeTag, DiffOp, TextDiff}; + +/// Computed diff between two versions of one file. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct FileDiff { + pub hunks: Vec, +} + +/// A run of changes plus surrounding context. +/// +/// Ranges are half-open over 1-indexed line numbers, context included. +/// A pure insertion with zero context has an empty `old_range` positioned +/// where the lines were inserted (and vice versa for deletions). +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Hunk { + pub old_range: Range, + pub new_range: Range, + pub rows: Vec, +} + +/// One row of a hunk. Replaced blocks emit all deleted rows, then all added. +/// +/// `word_ranges` are byte ranges into the line content (terminator excluded), +/// always on char boundaries; non-empty only in word-diff-gated hunks. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum DiffRow { + Context { + old_line: u32, + new_line: u32, + }, + Deleted { + old_line: u32, + word_ranges: Vec>, + }, + Added { + new_line: u32, + word_ranges: Vec>, + }, +} + +/// Word ranges are computed only for hunks whose deleted/added line counts +/// are equal and at most this many lines — avoids noise-highlighting rewrites. +const WORD_DIFF_MAX_LINES: usize = 5; + +/// Compute hunks between two full texts with `context` lines around changes. +/// Adjacent hunks whose context overlaps are merged, mirroring git. +/// +/// Lines are compared with their terminators, so a missing trailing newline +/// is a change (git's `\ No newline at end of file`). +pub fn compute_hunks(old: &str, new: &str, context: u32) -> FileDiff { + let old_raw: Vec<&str> = old.split_inclusive('\n').collect(); + let new_raw: Vec<&str> = new.split_inclusive('\n').collect(); + let ops = capture_diff_slices(Algorithm::Myers, &old_raw, &new_raw); + + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + + let hunks = group_diff_ops(ops, context as usize) + .iter() + .map(|ops| build_hunk(ops, &old_lines, &new_lines)) + .collect(); + + FileDiff { hunks } +} + +fn build_hunk(ops: &[DiffOp], old_lines: &[&str], new_lines: &[&str]) -> Hunk { + let mut rows: Vec = ops.iter().flat_map(rows_for_op).collect(); + apply_word_diffs(&mut rows, old_lines, new_lines); + + let as_u32 = |r: Range| (r.start as u32 + 1)..(r.end as u32 + 1); + Hunk { + old_range: as_u32(span(ops.iter().map(|op| op.old_range()))), + new_range: as_u32(span(ops.iter().map(|op| op.new_range()))), + rows, + } +} + +/// Smallest range covering all input ranges (empty ones position the span). +fn span(ranges: impl Iterator> + Clone) -> Range { + let start = ranges.clone().map(|r| r.start).min().unwrap_or(0); + let end = ranges.map(|r| r.end).max().unwrap_or(0); + start..end.max(start) +} + +fn rows_for_op(op: &DiffOp) -> Vec { + let context = |old_index: usize, new_index: usize, len: usize| { + (0..len) + .map(|i| DiffRow::Context { + old_line: (old_index + i + 1) as u32, + new_line: (new_index + i + 1) as u32, + }) + .collect::>() + }; + let deleted = |old_index: usize, len: usize| { + (0..len).map(move |i| DiffRow::Deleted { + old_line: (old_index + i + 1) as u32, + word_ranges: Vec::new(), + }) + }; + let added = |new_index: usize, len: usize| { + (0..len).map(move |i| DiffRow::Added { + new_line: (new_index + i + 1) as u32, + word_ranges: Vec::new(), + }) + }; + + match *op { + DiffOp::Equal { + old_index, + new_index, + len, + } => context(old_index, new_index, len), + DiffOp::Delete { + old_index, old_len, .. + } => deleted(old_index, old_len).collect(), + DiffOp::Insert { + new_index, new_len, .. + } => added(new_index, new_len).collect(), + DiffOp::Replace { + old_index, + old_len, + new_index, + new_len, + } => deleted(old_index, old_len) + .chain(added(new_index, new_len)) + .collect(), + } +} + +/// Fill in `word_ranges` for gated hunks by pairing the i-th deleted row +/// with the i-th added row and diffing them token-wise. +fn apply_word_diffs(rows: &mut [DiffRow], old_lines: &[&str], new_lines: &[&str]) { + let deleted: Vec = positions(rows, |r| matches!(r, DiffRow::Deleted { .. })); + let added: Vec = positions(rows, |r| matches!(r, DiffRow::Added { .. })); + + let gated = + !deleted.is_empty() && deleted.len() == added.len() && deleted.len() <= WORD_DIFF_MAX_LINES; + if !gated { + return; + } + + for (&di, &ai) in deleted.iter().zip(&added) { + let (DiffRow::Deleted { old_line, .. }, DiffRow::Added { new_line, .. }) = + (&rows[di], &rows[ai]) + else { + unreachable!("positions() matched these variants"); + }; + let (del_ranges, add_ranges) = word_ranges( + old_lines[(*old_line - 1) as usize], + new_lines[(*new_line - 1) as usize], + ); + if let DiffRow::Deleted { word_ranges, .. } = &mut rows[di] { + *word_ranges = del_ranges; + } + if let DiffRow::Added { word_ranges, .. } = &mut rows[ai] { + *word_ranges = add_ranges; + } + } +} + +fn positions(rows: &[DiffRow], pred: impl Fn(&DiffRow) -> bool) -> Vec { + rows.iter() + .enumerate() + .filter_map(|(i, r)| pred(r).then_some(i)) + .collect() +} + +/// Byte ranges of the changed tokens on each side of a paired line. +/// Tokens are words + whitespace runs, so they concatenate back to the +/// original line and offsets land on char boundaries by construction. +fn word_ranges(old_line: &str, new_line: &str) -> (Vec>, Vec>) { + let diff = TextDiff::from_words(old_line, new_line); + let mut old_offset = 0; + let mut new_offset = 0; + let mut deleted = Vec::new(); + let mut added = Vec::new(); + + for change in diff.iter_all_changes() { + let len = change.value().len(); + match change.tag() { + ChangeTag::Equal => { + old_offset += len; + new_offset += len; + } + ChangeTag::Delete => { + deleted.push(old_offset..old_offset + len); + old_offset += len; + } + ChangeTag::Insert => { + added.push(new_offset..new_offset + len); + new_offset += len; + } + } + } + + (coalesce(deleted), coalesce(added)) +} + +/// Merge byte-adjacent ranges into one. +fn coalesce(ranges: Vec>) -> Vec> { + ranges.into_iter().fold(Vec::new(), |mut acc, r| { + match acc.last_mut() { + Some(last) if last.end == r.start => last.end = r.end, + _ => acc.push(r), + } + acc + }) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + /// (name, old, new) fixture pairs mirroring git diff situations. + const CORPUS: &[(&str, &str, &str)] = &[ + ("empty_to_content", "", "hello\nworld\n"), + ("content_to_empty", "hello\nworld\n", ""), + ( + "pure_add", + "alpha\nbeta\ngamma\n", + "alpha\nbeta\ninserted one\ninserted two\ngamma\n", + ), + ( + "pure_delete", + "alpha\nbeta\nremoved\ngamma\n", + "alpha\nbeta\ngamma\n", + ), + ( + "replacement", + "fn main() {\n old_code();\n}\n", + "fn main() {\n new_code();\n}\n", + ), + ( + "adjacent_hunk_merge", + "l1\nl2 old\nl3\nl4\nl5\nl6 old\nl7\n", + "l1\nl2 new\nl3\nl4\nl5\nl6 new\nl7\n", + ), + ("no_trailing_newline_added", "a\nb", "a\nb\n"), + ("no_trailing_newline_changed", "a\nb", "a\nc"), + ("crlf", "a\r\nb\r\nc\r\n", "a\r\nB\r\nc\r\n"), + ( + "unicode", + "café au lait ☕\nвітаю світ\n", + "café du lait ☕\nвітаю всесвіт\n", + ), + ( + "rewrite_beyond_word_gate", + "one\ntwo\nthree\nfour\nfive\nsix\n", + "uno\ndos\ntres\ncuatro\ncinco\nseis\n", + ), + ( + "unequal_counts_no_word_diff", + "keep\nold line\nkeep2\n", + "keep\nnew line\nsecond new\nkeep2\n", + ), + ]; + + /// Human-readable hunk dump; word ranges shown as »marked« spans. + /// Slicing at range bounds doubles as the char-boundary safeguard. + fn render(old: &str, new: &str, diff: &FileDiff) -> String { + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + diff.hunks + .iter() + .map(|h| { + let header = format!( + "@@ old {}..{} new {}..{} @@", + h.old_range.start, h.old_range.end, h.new_range.start, h.new_range.end + ); + let rows = h.rows.iter().map(|row| match row { + DiffRow::Context { old_line, new_line } => format!( + "ctx {:>3} {:>3} |{}", + old_line, + new_line, + old_lines[(old_line - 1) as usize] + ), + DiffRow::Deleted { + old_line, + word_ranges, + } => format!( + "del {:>3} |{}", + old_line, + mark(old_lines[(old_line - 1) as usize], word_ranges) + ), + DiffRow::Added { + new_line, + word_ranges, + } => format!( + "add {:>3} |{}", + new_line, + mark(new_lines[(new_line - 1) as usize], word_ranges) + ), + }); + std::iter::once(header) + .chain(rows) + .collect::>() + .join("\n") + }) + .collect::>() + .join("\n") + } + + fn mark(line: &str, ranges: &[Range]) -> String { + let mut out = String::new(); + let mut pos = 0; + for r in ranges { + out.push_str(&line[pos..r.start]); + out.push('»'); + out.push_str(&line[r.start..r.end]); + out.push('«'); + pos = r.end; + } + out.push_str(&line[pos..]); + out + } + + /// Rebuild `new` from `old` + hunks: gaps copy old lines, context rows + /// must match byte-for-byte on both sides, added rows come from `new`. + fn reconstruct(old: &str, new: &str, diff: &FileDiff) -> String { + let old_raw: Vec<&str> = old.split_inclusive('\n').collect(); + let new_raw: Vec<&str> = new.split_inclusive('\n').collect(); + let mut out = String::new(); + let mut next_old = 1u32; + for hunk in &diff.hunks { + (next_old..hunk.old_range.start).for_each(|l| out.push_str(old_raw[(l - 1) as usize])); + for row in &hunk.rows { + match row { + DiffRow::Context { old_line, new_line } => { + assert_eq!( + old_raw[(old_line - 1) as usize], + new_raw[(new_line - 1) as usize], + "context row differs between sides" + ); + out.push_str(new_raw[(*new_line - 1) as usize]); + } + DiffRow::Deleted { .. } => {} + DiffRow::Added { new_line, .. } => { + out.push_str(new_raw[(*new_line - 1) as usize]) + } + } + } + next_old = hunk.old_range.end; + } + (next_old..=old_raw.len() as u32).for_each(|l| out.push_str(old_raw[(l - 1) as usize])); + out + } + + fn word_ranges_of(diff: &FileDiff) -> Vec>> { + diff.hunks + .iter() + .flat_map(|h| &h.rows) + .filter_map(|row| match row { + DiffRow::Deleted { word_ranges, .. } | DiffRow::Added { word_ranges, .. } => { + Some(word_ranges.clone()) + } + DiffRow::Context { .. } => None, + }) + .collect() + } + + #[test] + fn corpus_snapshots() { + for (name, old, new) in CORPUS { + insta::assert_snapshot!(*name, render(old, new, &compute_hunks(old, new, 3))); + } + } + + #[test] + fn adjacent_hunks_stay_split_with_small_context() { + let (_, old, new) = CORPUS + .iter() + .find(|(n, ..)| *n == "adjacent_hunk_merge") + .unwrap(); + insta::assert_snapshot!( + "adjacent_hunk_split_context_1", + render(old, new, &compute_hunks(old, new, 1)) + ); + } + + #[test] + fn corpus_round_trips() { + for (name, old, new) in CORPUS { + for context in [0, 1, 3, 100] { + let diff = compute_hunks(old, new, context); + assert_eq!( + reconstruct(old, new, &diff), + *new, + "round trip failed: {name} (context {context})" + ); + } + } + } + + #[test] + fn identical_texts_produce_no_hunks() { + let text = "a\nb\nc\n"; + assert!(compute_hunks(text, text, 3).hunks.is_empty()); + assert!(compute_hunks("", "", 3).hunks.is_empty()); + } + + #[test] + fn gated_hunk_gets_word_ranges() { + let diff = compute_hunks("fn old_name() {\n", "fn new_name() {\n", 0); + let ranges = word_ranges_of(&diff); + assert_eq!(ranges.len(), 2); + assert!(ranges.iter().all(|r| !r.is_empty())); + } + + #[test] + fn rewrite_beyond_gate_gets_no_word_ranges() { + let (_, old, new) = CORPUS + .iter() + .find(|(n, ..)| *n == "rewrite_beyond_word_gate") + .unwrap(); + let diff = compute_hunks(old, new, 0); + assert!(word_ranges_of(&diff).iter().all(|r| r.is_empty())); + } + + #[test] + fn unequal_counts_get_no_word_ranges() { + let (_, old, new) = CORPUS + .iter() + .find(|(n, ..)| *n == "unequal_counts_no_word_diff") + .unwrap(); + let diff = compute_hunks(old, new, 0); + assert!(word_ranges_of(&diff).iter().all(|r| r.is_empty())); + } + + #[test] + fn multibyte_word_ranges_slice_at_char_boundaries() { + let old = "вітаю світ 🌍\n"; + let new = "вітаю всесвіт 🌍\n"; + let diff = compute_hunks(old, new, 0); + // mark() slices the line at every range bound — panics off-boundary. + render(old, new, &diff); + assert!(word_ranges_of(&diff).iter().any(|r| !r.is_empty())); + } + + /// Lines drawn from a tiny pool so LCS finds real structure; multibyte + /// entries keep char-boundary handling honest. + fn arb_text() -> impl Strategy { + let line = prop::sample::select(vec![ + "alpha", + "beta", + "gamma", + "délta", + "εψιλον", + "fn x() {", + "}", + "", + " indented", + ]); + (prop::collection::vec(line, 0..12), any::()).prop_map(|(lines, trailing)| { + let mut text = lines.join("\n"); + if trailing && !text.is_empty() { + text.push('\n'); + } + text + }) + } + + proptest! { + #[test] + fn round_trips_for_arbitrary_pairs( + old in arb_text(), + new in arb_text(), + context in 0u32..5, + ) { + let diff = compute_hunks(&old, &new, context); + prop_assert_eq!(reconstruct(&old, &new, &diff), new); + } + + #[test] + fn word_ranges_are_valid_char_boundary_slices( + old in arb_text(), + new in arb_text(), + context in 0u32..5, + ) { + let diff = compute_hunks(&old, &new, context); + // render() slices every word range — panics on invalid bounds. + render(&old, &new, &diff); + } + } +} diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__adjacent_hunk_merge.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__adjacent_hunk_merge.snap new file mode 100644 index 00000000..fdec31bc --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__adjacent_hunk_merge.snap @@ -0,0 +1,14 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..8 new 1..8 @@ +ctx 1 1 |l1 +del 2 |l2 »old« +add 2 |l2 »new« +ctx 3 3 |l3 +ctx 4 4 |l4 +ctx 5 5 |l5 +del 6 |l6 »old« +add 6 |l6 »new« +ctx 7 7 |l7 diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__adjacent_hunk_split_context_1.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__adjacent_hunk_split_context_1.snap new file mode 100644 index 00000000..6adcfb84 --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__adjacent_hunk_split_context_1.snap @@ -0,0 +1,14 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 1))" +--- +@@ old 1..4 new 1..4 @@ +ctx 1 1 |l1 +del 2 |l2 »old« +add 2 |l2 »new« +ctx 3 3 |l3 +@@ old 5..8 new 5..8 @@ +ctx 5 5 |l5 +del 6 |l6 »old« +add 6 |l6 »new« +ctx 7 7 |l7 diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__content_to_empty.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__content_to_empty.snap new file mode 100644 index 00000000..e1daa4ab --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__content_to_empty.snap @@ -0,0 +1,7 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..3 new 1..1 @@ +del 1 |hello +del 2 |world diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__crlf.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__crlf.snap new file mode 100644 index 00000000..3c002fd6 --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__crlf.snap @@ -0,0 +1,9 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..4 new 1..4 @@ +ctx 1 1 |a +del 2 |»b« +add 2 |»B« +ctx 3 3 |c diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__empty_to_content.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__empty_to_content.snap new file mode 100644 index 00000000..2b1425ea --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__empty_to_content.snap @@ -0,0 +1,7 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..1 new 1..3 @@ +add 1 |hello +add 2 |world diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__no_trailing_newline_added.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__no_trailing_newline_added.snap new file mode 100644 index 00000000..c240498c --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__no_trailing_newline_added.snap @@ -0,0 +1,8 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..3 new 1..3 @@ +ctx 1 1 |a +del 2 |b +add 2 |b diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__no_trailing_newline_changed.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__no_trailing_newline_changed.snap new file mode 100644 index 00000000..115915ba --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__no_trailing_newline_changed.snap @@ -0,0 +1,8 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..3 new 1..3 @@ +ctx 1 1 |a +del 2 |»b« +add 2 |»c« diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__pure_add.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__pure_add.snap new file mode 100644 index 00000000..1a54a963 --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__pure_add.snap @@ -0,0 +1,10 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..4 new 1..6 @@ +ctx 1 1 |alpha +ctx 2 2 |beta +add 3 |inserted one +add 4 |inserted two +ctx 3 5 |gamma diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__pure_delete.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__pure_delete.snap new file mode 100644 index 00000000..d4c958d0 --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__pure_delete.snap @@ -0,0 +1,9 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..5 new 1..4 @@ +ctx 1 1 |alpha +ctx 2 2 |beta +del 3 |removed +ctx 4 3 |gamma diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__replacement.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__replacement.snap new file mode 100644 index 00000000..1e089230 --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__replacement.snap @@ -0,0 +1,9 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..4 new 1..4 @@ +ctx 1 1 |fn main() { +del 2 | »old_code();« +add 2 | »new_code();« +ctx 3 3 |} diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__rewrite_beyond_word_gate.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__rewrite_beyond_word_gate.snap new file mode 100644 index 00000000..19213c8e --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__rewrite_beyond_word_gate.snap @@ -0,0 +1,17 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..7 new 1..7 @@ +del 1 |one +del 2 |two +del 3 |three +del 4 |four +del 5 |five +del 6 |six +add 1 |uno +add 2 |dos +add 3 |tres +add 4 |cuatro +add 5 |cinco +add 6 |seis diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__unequal_counts_no_word_diff.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__unequal_counts_no_word_diff.snap new file mode 100644 index 00000000..e5234b84 --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__unequal_counts_no_word_diff.snap @@ -0,0 +1,10 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..4 new 1..5 @@ +ctx 1 1 |keep +del 2 |old line +add 2 |new line +add 3 |second new +ctx 3 4 |keep2 diff --git a/src-tauri/src/engine/snapshots/annot_lib__engine__tests__unicode.snap b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__unicode.snap new file mode 100644 index 00000000..62cac2e0 --- /dev/null +++ b/src-tauri/src/engine/snapshots/annot_lib__engine__tests__unicode.snap @@ -0,0 +1,9 @@ +--- +source: src/engine.rs +expression: "render(old, new, &compute_hunks(old, new, 3))" +--- +@@ old 1..3 new 1..3 @@ +del 1 |café »au« lait ☕ +del 2 |вітаю »світ« +add 1 |café »du« lait ☕ +add 2 |вітаю »всесвіт« diff --git a/src-tauri/src/excalidraw_window.rs b/src-tauri/src/excalidraw_window.rs index 38d148ab..14afbe7d 100644 --- a/src-tauri/src/excalidraw_window.rs +++ b/src-tauri/src/excalidraw_window.rs @@ -32,8 +32,8 @@ pub enum ExcalidrawOrigin { pub struct ExcalidrawContext { /// JSON array of Excalidraw elements pub elements: String, - /// Annotation identifier (e.g., "45-52") - pub range_key: String, + /// Annotation id (opaque to the backend; unused for CodeBlock origin) + pub annotation_id: String, /// Reference to the TipTap node being edited pub node_ref: NodeRef, /// Parent window label for emitting results @@ -55,8 +55,8 @@ pub enum ExcalidrawOutcome { /// Result emitted back to the main window (for Annotation origin). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ExcalidrawResult { - /// Annotation identifier - pub range_key: String, + /// Annotation id + pub annotation_id: String, /// Reference to the node that was edited pub node_ref: NodeRef, /// Outcome of the session @@ -108,7 +108,7 @@ pub async fn open_excalidraw_window( window: WebviewWindow, excalidraw_state: State<'_, Mutex>, elements: String, - range_key: String, + annotation_id: String, node_ref: NodeRef, origin: Option, ) -> Result { @@ -120,7 +120,7 @@ pub async fn open_excalidraw_window( let context = ExcalidrawContext { elements, - range_key, + annotation_id, node_ref, parent_label, origin: origin.unwrap_or(ExcalidrawOrigin::Annotation), @@ -147,15 +147,17 @@ pub async fn open_excalidraw_window( // Create new window (hidden until frontend renders) let builder = { - let b = WebviewWindowBuilder::new( - &app, - &label, - tauri::WebviewUrl::App("excalidraw".into()), - ) - .title("Excalidraw") - .inner_size(width, height) - .min_inner_size(600.0, 400.0) - .visible(false); + let b = + WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::App("excalidraw".into())) + .title("Excalidraw") + .inner_size(width, height) + .min_inner_size(600.0, 400.0) + .visible(false); + // Windows-only: solid themed bg so WebView2 doesn't flash white. + #[cfg(windows)] + let b = b.background_color(crate::config::window_background_color( + crate::config::load_config().theme, + )); #[cfg(target_os = "macos")] let b = b .title_bar_style(tauri::TitleBarStyle::Overlay) @@ -200,7 +202,7 @@ pub async fn open_excalidraw_window( let _ = parent.emit( "excalidraw-result", ExcalidrawResult { - range_key: ctx.range_key, + annotation_id: ctx.annotation_id, node_ref: ctx.node_ref, outcome: ExcalidrawOutcome::Cancelled, }, @@ -260,17 +262,17 @@ pub fn excalidraw_save( .emit( "excalidraw-result", ExcalidrawResult { - range_key: ctx.range_key, + annotation_id: ctx.annotation_id, node_ref: ctx.node_ref, - outcome: ExcalidrawOutcome::Saved { - elements, - png, - }, + outcome: ExcalidrawOutcome::Saved { elements, png }, }, ) .map_err(|e| format!("Failed to emit result: {}", e))?; } - ExcalidrawOrigin::CodeBlock { start_line, end_line } => { + ExcalidrawOrigin::CodeBlock { + start_line, + end_line, + } => { parent .emit( "codeblock-excalidraw-result", @@ -318,7 +320,7 @@ pub fn excalidraw_cancel( .emit( "excalidraw-result", ExcalidrawResult { - range_key: ctx.range_key, + annotation_id: ctx.annotation_id, node_ref: ctx.node_ref, outcome: ExcalidrawOutcome::Cancelled, }, diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs index 6219054c..849204dc 100644 --- a/src-tauri/src/files.rs +++ b/src-tauri/src/files.rs @@ -26,6 +26,12 @@ pub struct FileCache { root: Option, } +impl Default for FileCache { + fn default() -> Self { + Self::new() + } +} + impl FileCache { pub fn new() -> Self { Self { @@ -64,11 +70,11 @@ impl FileCache { let root_path = Path::new(root); let walker = WalkBuilder::new(root_path) - .hidden(true) // Skip hidden files - .git_ignore(true) // Respect .gitignore - .git_global(true) // Respect global gitignore - .git_exclude(true) // Respect .git/info/exclude - .max_depth(Some(15)) // Reasonable depth limit + .hidden(true) // Skip hidden files + .git_ignore(true) // Respect .gitignore + .git_global(true) // Respect global gitignore + .git_exclude(true) // Respect .git/info/exclude + .max_depth(Some(15)) // Reasonable depth limit .build(); self.files = walker @@ -102,13 +108,20 @@ fn is_excluded_file(path: &str) -> bool { let name = path.rsplit('/').next().unwrap_or(path); // Exclude lock files, build artifacts, etc. - matches!(name, - "package-lock.json" | "yarn.lock" | "pnpm-lock.yaml" | - "Cargo.lock" | "go.sum" | "poetry.lock" | "composer.lock" | - ".DS_Store" | "Thumbs.db" + matches!( + name, + "package-lock.json" + | "yarn.lock" + | "pnpm-lock.yaml" + | "Cargo.lock" + | "go.sum" + | "poetry.lock" + | "composer.lock" + | ".DS_Store" + | "Thumbs.db" ) || name.ends_with(".min.js") - || name.ends_with(".min.css") - || name.ends_with(".map") + || name.ends_with(".min.css") + || name.ends_with(".map") } /// Fuzzy filter files with filename-first ranking. @@ -137,7 +150,7 @@ fn fuzzy_filter(files: &[String], query: &str, limit: usize) -> Vec { .collect(); // Sort by score descending - scored.sort_by(|a, b| b.0.cmp(&a.0)); + scored.sort_by_key(|entry| std::cmp::Reverse(entry.0)); scored .into_iter() @@ -192,8 +205,8 @@ mod tests { // When query matches filename directly, it should rank higher // than matching only in the path let files = vec![ - "src/types/utils.ts".to_string(), // "types" only in path - "types.ts".to_string(), // "types" in filename + "src/types/utils.ts".to_string(), // "types" only in path + "types.ts".to_string(), // "types" in filename ]; let result = fuzzy_filter(&files, "types", 10); // Filename match (2x boost) should beat path-only match diff --git a/src-tauri/src/highlight.rs b/src-tauri/src/highlight.rs index 6735be0f..1ab8deb7 100644 --- a/src-tauri/src/highlight.rs +++ b/src-tauri/src/highlight.rs @@ -9,8 +9,11 @@ use syntect::util::LinesWithEndings; /// Pre-compiled SyntaxSet loaded from build-time generated dump. /// This avoids the ~120ms cost of loading/parsing grammars at runtime. static SYNTAX_SET: LazyLock = LazyLock::new(|| { - from_uncompressed_data(include_bytes!(concat!(env!("OUT_DIR"), "/syntaxes.packdump"))) - .expect("Failed to load embedded syntax set") + from_uncompressed_data(include_bytes!(concat!( + env!("OUT_DIR"), + "/syntaxes.packdump" + ))) + .expect("Failed to load embedded syntax set") }); /// Syntax highlighter using syntect with embedded grammars. @@ -53,6 +56,19 @@ impl Highlighter { lines.into_iter().next().unwrap_or_default() } + /// Highlighted hunk-header function context; `None` when highlighting + /// yields nothing. Shared by the patch parser and the git pipeline. + pub fn highlight_function_context(&self, ctx: &str, path: &str) -> Option { + let html = self.highlight_snippet(ctx, path); + (!html.is_empty()).then_some(html) + } + + /// Highlight one diff row's raw code. Shared by the patch parser and the + /// git pipeline; the `+`/`-` sign is presentation, never part of the html. + pub fn highlight_diff_row(&self, code: &str, path: &str) -> Option { + self.highlight_lines(code, path).first().cloned() + } + /// Map file extensions that syntect doesn't support to ones it does. fn extension_fallback(ext: &str) -> &str { match ext { @@ -82,14 +98,14 @@ impl Highlighter { let syntax = self .syntax_set .find_syntax_by_extension(ext) - .or_else(|| self.syntax_set.find_syntax_by_extension(Self::extension_fallback(ext))) + .or_else(|| { + self.syntax_set + .find_syntax_by_extension(Self::extension_fallback(ext)) + }) .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); - let mut html_generator = ClassedHTMLGenerator::new_with_class_style( - syntax, - &self.syntax_set, - ClassStyle::Spaced, - ); + let mut html_generator = + ClassedHTMLGenerator::new_with_class_style(syntax, self.syntax_set, ClassStyle::Spaced); // Parse the entire content to maintain cross-line state for line in LinesWithEndings::from(content) { @@ -303,10 +319,17 @@ fn main() { println!("=== END ===\n"); // Should produce exactly 1 line of output - assert_eq!(lines.len(), 1, "Single line input should produce single line output"); + assert_eq!( + lines.len(), + 1, + "Single line input should produce single line output" + ); // The output should not contain literal newlines - assert!(!lines[0].contains('\n'), "Output should not contain newline characters"); + assert!( + !lines[0].contains('\n'), + "Output should not contain newline characters" + ); } /// Documents HTML output for JavaScript to show class naming patterns @@ -329,7 +352,10 @@ function greet(name) { println!("=== END ===\n"); // Verify we get highlighted output - assert!(lines[1].contains("class="), "Function should be highlighted"); + assert!( + lines[1].contains("class="), + "Function should be highlighted" + ); } // ========== MERMAID SYNTAX HIGHLIGHTING TESTS ========== diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 339243cc..1734e1e6 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -18,8 +18,8 @@ use std::path::PathBuf; use crate::diff; use crate::error::AnnotError; -use crate::review::FileKey; use crate::markdown; +use crate::review::FileKey; /// How the content should be rendered/processed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -74,8 +74,8 @@ pub enum McpSource { /// How a diff was obtained. #[derive(Debug, Clone)] pub enum DiffSource { - /// Generated from git with these args (e.g., `["--staged"]`). - Git { args: Vec }, + /// Generated via git from a structured target. + Target(crate::vcs::DiffTarget), /// Raw diff content provided directly. Raw, } @@ -99,14 +99,10 @@ impl ContentSource { pub fn label(&self) -> &str { match self { ContentSource::Cli(CliSource::File { path }) - | ContentSource::Mcp(McpSource::File { path }) => path - .to_str() - .unwrap_or("file"), + | ContentSource::Mcp(McpSource::File { path }) => path.to_str().unwrap_or("file"), ContentSource::Cli(CliSource::Stdin { label }) | ContentSource::Mcp(McpSource::Content { label }) => label, - ContentSource::Mcp(McpSource::Diff { label, .. }) => { - label.as_deref().unwrap_or("diff") - } + ContentSource::Mcp(McpSource::Diff { label, .. }) => label.as_deref().unwrap_or("diff"), } } @@ -128,11 +124,10 @@ impl ContentSource { pub fn base_dir(&self) -> PathBuf { match self { ContentSource::Cli(CliSource::File { path }) - | ContentSource::Mcp(McpSource::File { path }) => { - path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) - }) - } + | ContentSource::Mcp(McpSource::File { path }) => path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))), _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), } } @@ -234,7 +229,10 @@ impl InputMode { /// /// Returns the input mode and optionally a warning message. /// File argument takes priority over stdin when both are present. - pub fn detect(file: Option, label: String) -> Result<(InputMode, Option), AnnotError> { + pub fn detect( + file: Option, + label: String, + ) -> Result<(InputMode, Option), AnnotError> { let has_stdin = !io::stdin().is_terminal(); if let Some(path) = file { @@ -248,7 +246,8 @@ impl InputMode { Ok((InputMode::Stdin { label }, None)) } else { Err(AnnotError::Validation( - "no input provided\nUsage: annot or | annot\nTry: annot --help".into(), + "no input provided\nUsage: annot or | annot\nTry: annot --help" + .into(), )) } } @@ -264,13 +263,19 @@ mod tests { let file_path = dir.path().join("test.rs"); std::fs::write(&file_path, "fn main() {}").unwrap(); - let mode = InputMode::File { path: file_path.clone() }; + let mode = InputMode::File { + path: file_path.clone(), + }; let resolved = mode.resolve().unwrap(); // Label is full path (matches LineOrigin.path for consistency) assert_eq!(resolved.content_source.label(), file_path.to_str().unwrap()); assert_eq!(resolved.content, "fn main() {}"); - assert!(resolved.content_source.path_hint().unwrap().ends_with("test.rs")); + assert!(resolved + .content_source + .path_hint() + .unwrap() + .ends_with("test.rs")); } #[test] @@ -293,7 +298,9 @@ mod tests { std::fs::create_dir_all(file_path.parent().unwrap()).unwrap(); std::fs::write(&file_path, "package main").unwrap(); - let mode = InputMode::File { path: file_path.clone() }; + let mode = InputMode::File { + path: file_path.clone(), + }; let resolved = mode.resolve().unwrap(); // Label is full path (matches LineOrigin.path for consistency) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81768feb..d7297792 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,9 +5,11 @@ use parking_lot::Mutex; use tauri::WebviewWindowBuilder; +pub mod anchor; pub mod commands; pub mod config; pub mod diff; +pub mod engine; pub mod error; pub mod excalidraw_window; pub mod files; @@ -19,9 +21,14 @@ pub mod markdown; pub mod mcp; pub mod mermaid_window; pub mod output; +pub mod pipeline; pub mod portal; pub mod review; +pub mod source; pub mod state; +#[cfg(test)] +pub mod testutil; +pub mod vcs; pub mod window_state; use commands::{ @@ -130,7 +137,13 @@ pub fn run(state: AppState, context: tauri::Context, json_output: bool) { ) .title("annot") .inner_size(1000.0, 700.0) - .visible(false); // Will be shown after content loads + // Will be shown after content loads. + .visible(false); + // Windows-only: paint a solid themed bg so WebView2 doesn't + // flash its default white on open/resize (see config helper). + #[cfg(windows)] + let b = b + .background_color(config::window_background_color(config::load_config().theme)); #[cfg(target_os = "macos")] let b = b .title_bar_style(tauri::TitleBarStyle::Overlay) @@ -150,8 +163,10 @@ pub fn run(state: AppState, context: tauri::Context, json_output: bool) { let window_for_save = window.clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { .. } = event { - let _ = - window_state::save_window_state(&window_for_save, window_state::WindowType::Main); + let _ = window_state::save_window_state( + &window_for_save, + window_state::WindowType::Main, + ); } }); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9fec2e4b..055aa12c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -104,7 +104,9 @@ fn main() { // Parse CLI exit modes and prepend as transient if !cli.exit_modes.is_empty() { - let default_colors = ["#22c55e", "#eab308", "#ef4444", "#3b82f6", "#a855f7", "#f97316"]; + let default_colors = [ + "#22c55e", "#eab308", "#ef4444", "#3b82f6", "#a855f7", "#f97316", + ]; let transient_modes: Vec = cli .exit_modes .iter() diff --git a/src-tauri/src/markdown.rs b/src-tauri/src/markdown.rs index 25d64732..64024028 100644 --- a/src-tauri/src/markdown.rs +++ b/src-tauri/src/markdown.rs @@ -88,7 +88,9 @@ pub struct PortalInfo { #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MarkdownSemantics { - Header { level: u8 }, + Header { + level: u8, + }, CodeBlockStart { language: Option, color: Option, @@ -96,7 +98,9 @@ pub enum MarkdownSemantics { CodeBlockContent, CodeBlockEnd, TableRow, - ListItem { ordered: bool }, + ListItem { + ordered: bool, + }, BlockQuote, HorizontalRule, } @@ -116,14 +120,31 @@ pub struct RenderedLine { /// A parsing context we're currently inside. #[derive(Debug, Clone)] enum ParseContext { - Heading { line: u32, level: u8, text: String }, - CodeBlock { line: u32, lang: Option }, - Table { start_line: u32 }, + Heading { + line: u32, + level: u8, + text: String, + }, + CodeBlock { + line: u32, + lang: Option, + }, + Table { + start_line: u32, + }, /// Accumulates cells for a table row. - TableRow { cells: Vec }, + TableRow { + cells: Vec, + }, /// Accumulates text for a single table cell. - TableCell { text: String }, - PortalLink { line: u32, url: String, text: String }, + TableCell { + text: String, + }, + PortalLink { + line: u32, + url: String, + text: String, + }, } /// Stack-based parser state tracker. @@ -139,64 +160,56 @@ impl ParseState { /// Pop heading context if it's on top. fn pop_heading(&mut self) -> Option<(u32, u8, String)> { - match self.stack.last() { - Some(ParseContext::Heading { .. }) => { - if let Some(ParseContext::Heading { line, level, text }) = self.stack.pop() { - return Some((line, level, text)); - } + if let Some(ParseContext::Heading { .. }) = self.stack.last() { + if let Some(ParseContext::Heading { line, level, text }) = self.stack.pop() { + return Some((line, level, text)); } - _ => {} } None } /// Pop code block context if it's on top. fn pop_code_block(&mut self) -> Option<(u32, Option)> { - match self.stack.last() { - Some(ParseContext::CodeBlock { .. }) => { - if let Some(ParseContext::CodeBlock { line, lang }) = self.stack.pop() { - return Some((line, lang)); - } + if let Some(ParseContext::CodeBlock { .. }) = self.stack.last() { + if let Some(ParseContext::CodeBlock { line, lang }) = self.stack.pop() { + return Some((line, lang)); } - _ => {} } None } /// Pop table context if it's on top. fn pop_table(&mut self) -> Option { - match self.stack.last() { - Some(ParseContext::Table { .. }) => { - if let Some(ParseContext::Table { start_line }) = self.stack.pop() { - return Some(start_line); - } + if let Some(ParseContext::Table { .. }) = self.stack.last() { + if let Some(ParseContext::Table { start_line }) = self.stack.pop() { + return Some(start_line); } - _ => {} } None } /// Pop portal link context if it's on top. fn pop_portal_link(&mut self) -> Option<(u32, String, String)> { - match self.stack.last() { - Some(ParseContext::PortalLink { .. }) => { - if let Some(ParseContext::PortalLink { line, url, text }) = self.stack.pop() { - return Some((line, url, text)); - } + if let Some(ParseContext::PortalLink { .. }) = self.stack.last() { + if let Some(ParseContext::PortalLink { line, url, text }) = self.stack.pop() { + return Some((line, url, text)); } - _ => {} } None } /// Check if we're inside a code block. fn in_code_block(&self) -> bool { - self.stack.iter().any(|ctx| matches!(ctx, ParseContext::CodeBlock { .. })) + self.stack + .iter() + .any(|ctx| matches!(ctx, ParseContext::CodeBlock { .. })) } /// Check if we're inside a table. fn in_table(&self) -> bool { - self.stack.iter().any(|ctx| matches!(ctx, ParseContext::Table { .. })) + self.stack + .iter() + .any(|ctx| matches!(ctx, ParseContext::Table { .. })) } /// Get mutable reference to current heading's text accumulator. @@ -234,26 +247,20 @@ impl ParseState { /// Pop table cell context and return its accumulated text. fn pop_table_cell(&mut self) -> Option { - match self.stack.last() { - Some(ParseContext::TableCell { .. }) => { - if let Some(ParseContext::TableCell { text }) = self.stack.pop() { - return Some(text); - } + if let Some(ParseContext::TableCell { .. }) = self.stack.last() { + if let Some(ParseContext::TableCell { text }) = self.stack.pop() { + return Some(text); } - _ => {} } None } /// Pop table row context and return its accumulated cells. fn pop_table_row(&mut self) -> Option> { - match self.stack.last() { - Some(ParseContext::TableRow { .. }) => { - if let Some(ParseContext::TableRow { cells }) = self.stack.pop() { - return Some(cells); - } + if let Some(ParseContext::TableRow { .. }) = self.stack.last() { + if let Some(ParseContext::TableRow { cells }) = self.stack.pop() { + return Some(cells); } - _ => {} } None } @@ -329,7 +336,10 @@ pub fn parse_markdown(content: &str) -> MarkdownMetadata { } CodeBlockKind::Indented => None, }; - state.push(ParseContext::CodeBlock { line, lang: language }); + state.push(ParseContext::CodeBlock { + line, + lang: language, + }); } Event::End(TagEnd::CodeBlock) => { if let Some((start_line, language)) = state.pop_code_block() { @@ -367,17 +377,16 @@ pub fn parse_markdown(content: &str) -> MarkdownMetadata { Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => { if let Some(cells) = state.pop_table_row() { // Render each cell's content via render_inline - let html_cells: Vec = cells - .iter() - .map(|c| render_inline(c)) - .collect(); + let html_cells: Vec = cells.iter().map(|c| render_inline(c)).collect(); current_table_rows.push(html_cells); } } // Table cell tracking Event::Start(Tag::TableCell) => { - state.push(ParseContext::TableCell { text: String::new() }); + state.push(ParseContext::TableCell { + text: String::new(), + }); } Event::End(TagEnd::TableCell) => { if let Some(cell_text) = state.pop_table_cell() { @@ -387,7 +396,9 @@ pub fn parse_markdown(content: &str) -> MarkdownMetadata { // Portal link detection: [label](path#L42-L58) // Portals are forbidden in code blocks (literal text) and tables (can't expand inline) - Event::Start(Tag::Link { dest_url, .. }) if !state.in_code_block() && !state.in_table() => { + Event::Start(Tag::Link { dest_url, .. }) + if !state.in_code_block() && !state.in_table() => + { if parse_line_anchor(&dest_url).is_some() { state.push(ParseContext::PortalLink { line, @@ -613,10 +624,7 @@ pub fn format_table(lines: &[String]) -> Vec { let is_separator = |row: &Vec| { row.iter().all(|cell| { let trimmed = cell.trim(); - !trimmed.is_empty() - && trimmed - .chars() - .all(|c| c == '-' || c == ':' || c == ' ') + !trimmed.is_empty() && trimmed.chars().all(|c| c == '-' || c == ':' || c == ' ') }) }; @@ -628,15 +636,21 @@ pub fn format_table(lines: &[String]) -> Vec { let cell = row.get(i).map(|s| s.as_str()).unwrap_or(""); let width = col_widths.get(i).copied().unwrap_or(3); - if is_separator(&row) { + if is_separator(row) { // Separator row: preserve alignment markers let has_left = cell.starts_with(':'); let has_right = cell.ends_with(':'); let dashes = "-".repeat(width.max(3)); match (has_left, has_right) { - (true, true) => format!(":{:- format!(":{:- format!("{:- { + format!(":{:- { + format!(":{:- { + format!("{:- dashes, } } else { @@ -672,8 +686,8 @@ fn build_section_hierarchy(sections: &mut [SectionInfo]) { // Stack of (index, level) for finding parents let mut stack: Vec<(usize, u8)> = Vec::new(); - for i in 0..sections.len() { - let level = sections[i].level; + for (i, section) in sections.iter_mut().enumerate() { + let level = section.level; // Pop sections at same or deeper level while let Some(&(_, parent_level)) = stack.last() { @@ -685,7 +699,7 @@ fn build_section_hierarchy(sections: &mut [SectionInfo]) { } // Parent is top of stack (if any) - sections[i].parent_index = stack.last().map(|&(idx, _)| idx); + section.parent_index = stack.last().map(|&(idx, _)| idx); // Push current section stack.push((i, level)); @@ -746,12 +760,8 @@ pub fn render_line(line: &str) -> RenderedLine { } // Blockquotes: > text -> "> " + inline_render("text") - if trimmed.starts_with('>') { - let content = if trimmed.starts_with("> ") { - &trimmed[2..] - } else { - &trimmed[1..] - }; + if let Some(after_marker) = trimmed.strip_prefix('>') { + let content = after_marker.strip_prefix(' ').unwrap_or(after_marker); let marker = &trimmed[..trimmed.len() - content.len()]; let html = format!( "{}{}{}", @@ -836,11 +846,11 @@ pub fn render_inline(text: &str) -> String { // Token types for two-pass rendering enum Token { - Text(String), // Raw text (will be HTML-escaped on render) - Code(String), // Inline code content - Html(&'static str), // Static HTML fragment - HtmlOwned(String), // Owned HTML fragment - HighlightMarker, // == marker (paired during render) + Text(String), // Raw text (will be HTML-escaped on render) + Code(String), // Inline code content + Html(&'static str), // Static HTML fragment + HtmlOwned(String), // Owned HTML fragment + HighlightMarker, // == marker (paired during render) } let options = markdown_options(); @@ -1100,7 +1110,8 @@ mod tests { #[test] fn parse_markdown_extracts_code_blocks() { - let content = "# Title\n\n```rust\nfn main() {}\n```\n\nText\n\n```python\nprint('hi')\n```\n"; + let content = + "# Title\n\n```rust\nfn main() {}\n```\n\nText\n\n```python\nprint('hi')\n```\n"; let meta = parse_markdown(content); assert_eq!(meta.code_blocks.len(), 2); @@ -1172,7 +1183,11 @@ mod tests { let formatted = format_table(&lines); // Separator row should preserve alignment markers - assert!(formatted[1].contains(":"), "Should preserve colons: {:?}", formatted[1]); + assert!( + formatted[1].contains(":"), + "Should preserve colons: {:?}", + formatted[1] + ); } #[test] @@ -1267,7 +1282,10 @@ mod tests { #[test] fn render_line_heading() { let result = render_line("# Title"); - assert!(matches!(result.semantics, Some(MarkdownSemantics::Header { level: 1 }))); + assert!(matches!( + result.semantics, + Some(MarkdownSemantics::Header { level: 1 }) + )); assert!(result.html.contains("md-h1")); assert!(result.html.contains("Title")); } @@ -1275,35 +1293,50 @@ mod tests { #[test] fn render_line_heading_level_2() { let result = render_line("## Subtitle"); - assert!(matches!(result.semantics, Some(MarkdownSemantics::Header { level: 2 }))); + assert!(matches!( + result.semantics, + Some(MarkdownSemantics::Header { level: 2 }) + )); assert!(result.html.contains("md-h2")); } #[test] fn render_line_blockquote() { let result = render_line("> quoted text"); - assert!(matches!(result.semantics, Some(MarkdownSemantics::BlockQuote))); + assert!(matches!( + result.semantics, + Some(MarkdownSemantics::BlockQuote) + )); assert!(result.html.contains("md-blockquote")); } #[test] fn render_line_unordered_list() { let result = render_line("- list item"); - assert!(matches!(result.semantics, Some(MarkdownSemantics::ListItem { ordered: false }))); + assert!(matches!( + result.semantics, + Some(MarkdownSemantics::ListItem { ordered: false }) + )); assert!(result.html.contains("md-list")); } #[test] fn render_line_ordered_list() { let result = render_line("1. first item"); - assert!(matches!(result.semantics, Some(MarkdownSemantics::ListItem { ordered: true }))); + assert!(matches!( + result.semantics, + Some(MarkdownSemantics::ListItem { ordered: true }) + )); assert!(result.html.contains("md-list")); } #[test] fn render_line_horizontal_rule() { let result = render_line("---"); - assert!(matches!(result.semantics, Some(MarkdownSemantics::HorizontalRule))); + assert!(matches!( + result.semantics, + Some(MarkdownSemantics::HorizontalRule) + )); assert!(result.html.contains("md-hr")); } @@ -1318,7 +1351,11 @@ mod tests { fn render_line_with_2_space_indent() { // 4 spaces triggers code block in markdown, so use 2 spaces let result = render_line(" indented text"); - assert!(result.html.contains("indented text"), "HTML should contain 'indented text': {}", result.html); + assert!( + result.html.contains("indented text"), + "HTML should contain 'indented text': {}", + result.html + ); } #[test] @@ -1459,7 +1496,8 @@ mod tests { #[test] fn parse_markdown_portal_in_table_cell_is_forbidden() { - let content = "| Module | Entry Point |\n|---|---|\n| Auth | [auth](src/auth.rs#L42-L55) |\n"; + let content = + "| Module | Entry Point |\n|---|---|\n| Auth | [auth](src/auth.rs#L42-L55) |\n"; let meta = parse_markdown(content); // Portal inside table cell should be ignored (tables can't expand portals) @@ -1478,21 +1516,33 @@ mod tests { #[test] fn render_portal_ref_with_label() { let result = render_line("See [auth](src/auth.rs#L10-L20) for details"); - assert!(result.html.contains("portal-ref"), "Should have portal-ref class"); + assert!( + result.html.contains("portal-ref"), + "Should have portal-ref class" + ); assert!(result.html.contains("auth"), "Should contain label text"); } #[test] fn render_portal_ref_without_label_uses_filename() { let result = render_line("See [](src/auth.rs#L10-L20) for details"); - assert!(result.html.contains("portal-ref"), "Should have portal-ref class"); - assert!(result.html.contains("auth.rs"), "Should contain filename as fallback label"); + assert!( + result.html.contains("portal-ref"), + "Should have portal-ref class" + ); + assert!( + result.html.contains("auth.rs"), + "Should contain filename as fallback label" + ); } #[test] fn render_portal_ref_without_label_nested_path() { let result = render_line("[](deeply/nested/path/file.go#L1-L5)"); - assert!(result.html.contains("file.go"), "Should extract just the filename from nested path"); + assert!( + result.html.contains("file.go"), + "Should extract just the filename from nested path" + ); } // ========================================================================= diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index ba84029c..274f4d90 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use std::sync::mpsc; use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, Content, ServerInfo, ServerCapabilities}; +use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler, ServiceExt}; use tauri::{AppHandle, Manager, WebviewWindowBuilder}; @@ -17,9 +17,7 @@ use crate::review::{ActiveReview, Review}; use crate::state::AppState; use crate::window_state::{self, WindowType}; use crate::SessionLock; -use tools::{ - ReviewContentInput, ReviewDiffInput, ReviewFileInput, SessionImage, SessionOutput, -}; +use tools::{ReviewContentInput, ReviewDiffInput, ReviewFileInput, SessionImage, SessionOutput}; /// Instructions for AI agents using the MCP server. const MCP_INSTRUCTIONS: &str = r#"Human-in-the-loop annotation for AI workflows. Pull the human into the loop to provide located, specific feedback on content. @@ -71,7 +69,9 @@ impl AnnotServer { Self { app_handle } } - #[tool(description = "Opens a file for human review and annotation. Blocks until the window closes. The user can select line ranges to annotate, apply semantic tags (like [# SECURITY], [# TODO]), and add freeform comments. Returns line-anchored annotations with tags for systematic processing.")] + #[tool( + description = "Opens a file for human review and annotation. Blocks until the window closes. The user can select line ranges to annotate, apply semantic tags (like [# SECURITY], [# TODO]), and add freeform comments. Returns line-anchored annotations with tags for systematic processing." + )] async fn review_file( &self, params: Parameters, @@ -79,17 +79,17 @@ impl AnnotServer { let app_handle = self.app_handle.clone(); let input = params.0; - let output = tokio::task::spawn_blocking(move || { - run_file_session(&app_handle, input) - }) - .await - .map_err(|e| McpError::internal_error(format!("Task join error: {}", e), None))? - .map_err(|e| McpError::internal_error(e, None))?; + let output = tokio::task::spawn_blocking(move || run_file_session(&app_handle, input)) + .await + .map_err(|e| McpError::internal_error(format!("Task join error: {}", e), None))? + .map_err(|e| McpError::internal_error(e, None))?; Ok(build_mcp_response(output)) } - #[tool(description = "Opens agent-generated content (plans, drafts, analysis) for human review. Blocks until the window closes. Best for content you've generated that needs human steering before proceeding. Supports portal links to embed live code (`[label](path#L1-L20)`), highlights (`==important==`), and Mermaid diagrams. Returns line-anchored annotations for iterative refinement.")] + #[tool( + description = "Opens agent-generated content (plans, drafts, analysis) for human review. Blocks until the window closes. Best for content you've generated that needs human steering before proceeding. Supports portal links to embed live code (`[label](path#L1-L20)`), highlights (`==important==`), and Mermaid diagrams. Returns line-anchored annotations for iterative refinement." + )] async fn review_content( &self, params: Parameters, @@ -97,17 +97,17 @@ impl AnnotServer { let app_handle = self.app_handle.clone(); let input = params.0; - let output = tokio::task::spawn_blocking(move || { - run_content_session(&app_handle, input) - }) - .await - .map_err(|e| McpError::internal_error(format!("Task join error: {}", e), None))? - .map_err(|e| McpError::internal_error(e, None))?; + let output = tokio::task::spawn_blocking(move || run_content_session(&app_handle, input)) + .await + .map_err(|e| McpError::internal_error(format!("Task join error: {}", e), None))? + .map_err(|e| McpError::internal_error(e, None))?; Ok(build_mcp_response(output)) } - #[tool(description = "Opens a diff for human review. Blocks until the window closes. Supports git_diff_args (e.g. [\"--staged\"], [\"main...HEAD\"]) or raw diff_content. Returns annotations anchored to diff lines for targeted feedback on changes.")] + #[tool( + description = "Opens a diff for human review. Blocks until the window closes. `target` selects what to diff: working_tree (default; staged + unstaged + untracked vs HEAD), staged, or a range {from, to, merge_base}. Optional `pathspecs` limit files. Or pass raw diff_content. Returns annotations anchored to diff lines for targeted feedback on changes." + )] async fn review_diff( &self, params: Parameters, @@ -115,12 +115,10 @@ impl AnnotServer { let app_handle = self.app_handle.clone(); let input = params.0; - let output = tokio::task::spawn_blocking(move || { - run_diff_session(&app_handle, input) - }) - .await - .map_err(|e| McpError::internal_error(format!("Task join error: {}", e), None))? - .map_err(|e| McpError::internal_error(e, None))?; + let output = tokio::task::spawn_blocking(move || run_diff_session(&app_handle, input)) + .await + .map_err(|e| McpError::internal_error(format!("Task join error: {}", e), None))? + .map_err(|e| McpError::internal_error(e, None))?; Ok(build_mcp_response(output)) } @@ -135,7 +133,10 @@ impl ServerHandler for AnnotServer { } /// Run a file review session. -fn run_file_session(app_handle: &AppHandle, params: ReviewFileInput) -> Result { +fn run_file_session( + app_handle: &AppHandle, + params: ReviewFileInput, +) -> Result { // Read file content let path = Path::new(¶ms.file_path); let content = fs::read_to_string(path) @@ -157,7 +158,12 @@ fn run_content_session( label: params.label, }); - run_session(app_handle, params.content, params.exit_modes, content_source) + run_session( + app_handle, + params.content, + params.exit_modes, + content_source, + ) } /// Run a diff review session. @@ -165,49 +171,42 @@ fn run_diff_session( app_handle: &AppHandle, params: ReviewDiffInput, ) -> Result { - use std::process::Command; - - // Get diff content and derive label + source based on which input was provided - let (diff_text, derived_label, diff_source) = match (¶ms.git_diff_args, ¶ms.diff_content) { - (Some(args), None) => { - // Git diff mode - let output = Command::new("git") - .arg("diff") - .args(args) - .output() - .map_err(|e| format!("Failed to run git: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git diff failed: {}", stderr)); - } + if params.diff_content.is_some() && (params.target.is_some() || params.pathspecs.is_some()) { + return Err("Provide either target/pathspecs or diff_content, not both".to_string()); + } - let diff = String::from_utf8_lossy(&output.stdout).to_string(); - let label = args - .first() - .map(|s| s.trim_start_matches('-').to_string()) - .unwrap_or_else(|| "diff".to_string()); - let source = DiffSource::Git { args: args.clone() }; - (diff, label, source) - } - (None, Some(content)) => { - // Raw diff mode - (content.clone(), "diff".to_string(), DiffSource::Raw) - } - (Some(_), Some(_)) => { - return Err("Provide either git_diff_args or diff_content, not both".to_string()); + // Build the content model per input mode: raw patch text goes through + // the legacy parser (no full texts exist); a structured target renders + // in-process via the git pipeline. + let mcp_diff = |label: String, source: DiffSource| { + ContentSource::Mcp(McpSource::Diff { + label: Some(label), + source, + }) + }; + let content = match ¶ms.diff_content { + Some(diff_text) => { + let label = params.label.clone().unwrap_or_else(|| "diff".to_string()); + crate::state::ContentModel::from_diff(diff_text, mcp_diff(label, DiffSource::Raw)) + .map_err(|e| format!("Invalid diff: {}", e))? } - (None, None) => { - return Err("Provide either git_diff_args or diff_content".to_string()); + None => { + let target = params + .target + .clone() + .unwrap_or(crate::vcs::DiffTarget::WorkingTree); + let pathspecs = params.pathspecs.clone().unwrap_or_default(); + let label = params.label.clone().unwrap_or_else(|| target.label()); + let content_source = mcp_diff(label, DiffSource::Target(target.clone())); + // Same cwd semantics as the git CLI this replaced: the server + // process's working directory. + let cwd = std::env::current_dir() + .map_err(|e| format!("Failed to resolve working directory: {}", e))?; + crate::state::ContentModel::from_git(&cwd, &target, &pathspecs, content_source) + .map_err(|e| e.to_string())? } }; - let label = params.label.clone().unwrap_or(derived_label); - let content_source = ContentSource::Mcp(McpSource::Diff { - label: Some(label), - source: diff_source, - }); - // Load config let mut config = crate::state::UserConfig::load(); @@ -221,11 +220,7 @@ fn run_diff_session( config.prepend_transient_modes(transient); } - // Create state using from_diff - let content = crate::state::ContentModel::from_diff(&diff_text, content_source) - .map_err(|e| format!("Invalid diff: {}", e))?; let state = AppState::new(content, config); - run_session_with_state(app_handle, state) } @@ -313,7 +308,13 @@ fn run_session_with_state( ) .title("annot") .inner_size(1000.0, 700.0) - .visible(false); // Will be shown after content loads + // Will be shown after content loads. + .visible(false); + // Windows-only: solid themed bg so WebView2 doesn't flash white. + #[cfg(windows)] + let b = b.background_color(crate::config::window_background_color( + crate::config::load_config().theme, + )); #[cfg(target_os = "macos")] let b = b .title_bar_style(tauri::TitleBarStyle::Overlay) @@ -355,7 +356,9 @@ fn run_session_with_state( }); // Block until result received - let result = rx.recv().map_err(|e| format!("Failed to receive result: {}", e))?; + let result = rx + .recv() + .map_err(|e| format!("Failed to receive result: {}", e))?; // Hide dock icon after window closes #[cfg(target_os = "macos")] @@ -366,11 +369,15 @@ fn run_session_with_state( Ok(SessionOutput { text: result.text, - images: result.images.into_iter().map(|img| SessionImage { - figure: img.figure, - data: img.data, - mime_type: img.mime_type, - }).collect(), + images: result + .images + .into_iter() + .map(|img| SessionImage { + figure: img.figure, + data: img.data, + mime_type: img.mime_type, + }) + .collect(), }) } diff --git a/src-tauri/src/mcp/tools.rs b/src-tauri/src/mcp/tools.rs index 10db101c..e2fb8ffa 100644 --- a/src-tauri/src/mcp/tools.rs +++ b/src-tauri/src/mcp/tools.rs @@ -27,10 +27,19 @@ pub struct ReviewContentInput { /// Input for the review_diff tool. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ReviewDiffInput { - #[schemars(description = "git diff arguments (e.g. [\"--staged\"])")] - pub git_diff_args: Option>, - - #[schemars(description = "raw unified diff content")] + #[schemars( + description = "what to diff: working_tree (default; worktree vs HEAD, includes staged + unstaged + untracked), staged (index vs HEAD), or range {from, to, merge_base}" + )] + pub target: Option, + + #[schemars( + description = "optional git pathspecs limiting the diff (e.g. [\"src/\", \"*.rs\"])" + )] + pub pathspecs: Option>, + + #[schemars( + description = "raw unified diff content (mutually exclusive with target/pathspecs)" + )] pub diff_content: Option, #[schemars(description = "display name (default: diff)")] @@ -49,7 +58,9 @@ pub struct ExitModeInput { #[schemars(description = "context/instruction for downstream processing")] pub instruction: String, - #[schemars(description = "color name: green, yellow, red, blue, purple, orange (auto-assigned if empty)")] + #[schemars( + description = "color name: green, yellow, red, blue, purple, orange (auto-assigned if empty)" + )] pub color: Option, } @@ -75,7 +86,9 @@ pub struct SessionOutput { impl ExitModeInput { /// Map color name to CSS hex color. fn color_to_hex(color: &Option, index: usize) -> String { - let default_colors = ["#22c55e", "#eab308", "#ef4444", "#3b82f6", "#a855f7", "#f97316"]; + let default_colors = [ + "#22c55e", "#eab308", "#ef4444", "#3b82f6", "#a855f7", "#f97316", + ]; match color.as_deref() { Some("green") => "#22c55e".to_string(), diff --git a/src-tauri/src/mermaid_window.rs b/src-tauri/src/mermaid_window.rs index 6664f39a..591bca26 100644 --- a/src-tauri/src/mermaid_window.rs +++ b/src-tauri/src/mermaid_window.rs @@ -22,6 +22,12 @@ pub struct MermaidWindowState { windows: HashMap, } +impl Default for MermaidWindowState { + fn default() -> Self { + Self::new() + } +} + impl MermaidWindowState { pub fn new() -> Self { Self { @@ -96,15 +102,16 @@ pub async fn open_mermaid_window( // Note: We don't use .parent() because macOS child windows can't be // dragged to other displays. Instead, mermaid windows are independent. let builder = { - let b = WebviewWindowBuilder::new( - &app, - &label, - tauri::WebviewUrl::App("mermaid".into()), - ) - .title(format!("{}:{}-{}", filename, start_line, end_line)) - .inner_size(600.0, 500.0) - .min_inner_size(300.0, 200.0) - .visible(false); + let b = WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::App("mermaid".into())) + .title(format!("{}:{}-{}", filename, start_line, end_line)) + .inner_size(600.0, 500.0) + .min_inner_size(300.0, 200.0) + .visible(false); + // Windows-only: solid themed bg so WebView2 doesn't flash white. + #[cfg(windows)] + let b = b.background_color(crate::config::window_background_color( + crate::config::load_config().theme, + )); #[cfg(target_os = "macos")] let b = b .title_bar_style(tauri::TitleBarStyle::Overlay) diff --git a/src-tauri/src/output/builder.rs b/src-tauri/src/output/builder.rs index 8d3a9a64..eead98cc 100644 --- a/src-tauri/src/output/builder.rs +++ b/src-tauri/src/output/builder.rs @@ -127,16 +127,24 @@ impl OutputBuilder { /// Format code line: " 42 | content" pub fn code_line(&mut self, num: u32, content: &str) -> &mut Self { let width = self.line_num_width(); - self.buffer - .push_str(&format!("{:>width$} | {}\n", num, content, width = width + 3)); + self.buffer.push_str(&format!( + "{:>width$} | {}\n", + num, + content, + width = width + 3 + )); self } /// Format selected code line with ">" prefix: "> 42 | content" pub fn selected_code_line(&mut self, num: u32, content: &str) -> &mut Self { let width = self.line_num_width(); - self.buffer - .push_str(&format!("> {:>width$} | {}\n", num, content, width = width + 1)); + self.buffer.push_str(&format!( + "> {:>width$} | {}\n", + num, + content, + width = width + 1 + )); self } diff --git a/src-tauri/src/output/formatters.rs b/src-tauri/src/output/formatters.rs index 006af043..460d0fad 100644 --- a/src-tauri/src/output/formatters.rs +++ b/src-tauri/src/output/formatters.rs @@ -5,8 +5,10 @@ use std::collections::BTreeMap; +use crate::anchor::{Anchor, Annotation, Endpoint}; use crate::mcp::tools::SessionImage; -use crate::state::{Annotation, ContentMetadata, ContentModel, LineOrigin}; +use crate::source::Side; +use crate::state::{ContentModel, ContentView, DiffDocument, Row}; use super::builder::{BuilderMode, OutputBuilder}; use super::render::render_content; @@ -20,49 +22,46 @@ pub fn format_legend(out: &mut OutputBuilder, tags: &BTreeMap) { } /// Format a single annotation block with context lines and content. +/// `doc` is the anchor's diff document (diff mode only). +#[allow(clippy::too_many_arguments)] pub fn format_annotation( out: &mut OutputBuilder, content_model: &ContentModel, ann: &Annotation, file_path: &str, + doc: Option<&DiffDocument>, images: &mut Vec, figure_counter: &mut usize, mode: OutputMode, ) { - let is_diff = matches!(content_model.metadata, ContentMetadata::Diff(_)); - - // File header - if is_diff { - format_diff_header(out, content_model, ann, file_path); - } else if ann.start_line == ann.end_line { - out.raw_line(&format!("{}:{}", file_path, ann.start_line)); + if let Some(doc) = doc { + format_diff_block(out, doc, ann, file_path); } else { - out.raw_line(&format!( - "{}:{}-{}", - file_path, ann.start_line, ann.end_line - )); - } + // File header + if ann.start_line() == ann.end_line() { + out.raw_line(&format!("{}:{}", file_path, ann.start_line())); + } else { + out.raw_line(&format!( + "{}:{}-{}", + file_path, + ann.start_line(), + ann.end_line() + )); + } - // Context line (1 line before, if exists and non-empty) - if ann.start_line > 1 { - let context_line_num = ann.start_line - 1; - if let Some(line) = content_model.find_line(file_path, context_line_num) { - if !line.content.trim().is_empty() { - if is_diff { - format_diff_context_line(out, content_model, file_path, context_line_num, &line.content); - } else { + // Context line (1 line before, if exists and non-empty) + if ann.start_line() > 1 { + let context_line_num = ann.start_line() - 1; + if let Some(line) = content_model.find_line(file_path, context_line_num) { + if !line.content.trim().is_empty() { out.code_line(context_line_num, &line.content); } } } - } - // Selected lines - for line_num in ann.start_line..=ann.end_line { - if let Some(line) = content_model.find_line(file_path, line_num) { - if is_diff { - format_diff_selected_line(out, content_model, file_path, line_num, &line.content); - } else { + // Selected lines + for line_num in ann.start_line()..=ann.end_line() { + if let Some(line) = content_model.find_line(file_path, line_num) { out.selected_code_line(line_num, &line.content); } } @@ -80,31 +79,103 @@ pub fn format_annotation( } } -/// Format diff header with file info from annotation range. -fn format_diff_header( +/// Format the header, context row, and selected rows for a diff annotation. +/// +/// Both anchor endpoints resolve side-aware within the document's rows +/// (hunks flattened in order), and the contiguous row slice between them is +/// what renders — for a mixed-side range that slice covers a deletion and +/// its added replacement. +fn format_diff_block( out: &mut OutputBuilder, - content: &ContentModel, + doc: &DiffDocument, ann: &Annotation, file_path: &str, ) { - // Collect old/new line ranges from the annotated lines - let mut old_lines: Vec = Vec::new(); - let mut new_lines: Vec = Vec::new(); - - for line_num in ann.start_line..=ann.end_line { - if let Some(line) = content.find_line(file_path, line_num) { - if let LineOrigin::Diff { old_line, new_line, .. } = &line.origin { - if let Some(old) = old_line { - old_lines.push(*old); - } - if let Some(new) = new_line { - new_lines.push(*new); - } - } + let Anchor::Diff { start, end, .. } = &ann.anchor else { + // A side-less anchor can't resolve against a diff: header only. + out.raw_line(&format!("{}:", file_path)); + return; + }; + + // (hunk index, row) in document order — the hunk index bounds context. + let rows: Vec<(usize, &Row)> = doc + .hunks + .iter() + .enumerate() + .flat_map(|(hunk, h)| h.rows.iter().map(move |row| (hunk, row))) + .collect(); + let find = |ep: &Endpoint| { + rows.iter().position(|(_, row)| match ep.side { + Side::Old => row.old_line == Some(ep.line), + Side::New => row.new_line == Some(ep.line), + }) + }; + + let Some((first, last)) = find(start) + .zip(find(end)) + .map(|(s, e)| (s.min(e), s.max(e))) + else { + // Anchor doesn't resolve against this diff: header only. + out.raw_line(&format!("{}:", file_path)); + return; + }; + + format_diff_header(out, &rows[first..=last], (start, end), file_path); + + // Context: the previous row within the same hunk, if renderable — an + // anchor at a hunk's first row gets none. + if first > 0 && rows[first - 1].0 == rows[first].0 { + let (_, row) = rows[first - 1]; + let content = prefixed(row); + if !content.trim().is_empty() { + out.diff_line(row.old_line, row.new_line, &content, false); } } - // Format header with available line info + // Selected rows + for (_, row) in &rows[first..=last] { + out.diff_line(row.old_line, row.new_line, &prefixed(row), true); + } +} + +/// A row's emit form: the `+`/`-`/` ` sign — derived from the line-number +/// pattern — re-prepended to the raw content. Byte-identical to what the +/// flattened wire used to carry. Shared with the patch-shaped content export. +pub(super) fn prefixed(row: &Row) -> String { + let prefix = match (row.old_line, row.new_line) { + (Some(_), Some(_)) => ' ', + (Some(_), None) => '-', + (None, Some(_)) => '+', + (None, None) => unreachable!("a row always belongs to at least one side"), + }; + format!("{prefix}{}", row.content) +} + +/// Format diff header with file info from the resolved row slice. +fn format_diff_header( + out: &mut OutputBuilder, + rows: &[(usize, &Row)], + (start, end): (&Endpoint, &Endpoint), + file_path: &str, +) { + // Mixed-side range: name the endpoints with their sides. This shape is + // additive — no single-side or context annotation can produce it. + if start.side != end.side { + out.raw_line(&format!( + "{} ({}:{} → {}:{}):", + file_path, + side_label(start.side), + start.line, + side_label(end.side), + end.line + )); + return; + } + + // Single-side: collect old/new line ranges from the rendered rows. + let old_lines: Vec = rows.iter().filter_map(|(_, row)| row.old_line).collect(); + let new_lines: Vec = rows.iter().filter_map(|(_, row)| row.new_line).collect(); + let old_range = format_line_range(&old_lines); let new_range = format_line_range(&new_lines); @@ -117,6 +188,13 @@ fn format_diff_header( out.raw_line(&header); } +fn side_label(side: Side) -> &'static str { + match side { + Side::Old => "old", + Side::New => "new", + } +} + /// Format a line range like "10" or "10-15". fn format_line_range(lines: &[u32]) -> String { if lines.is_empty() { @@ -131,54 +209,9 @@ fn format_line_range(lines: &[u32]) -> String { } } -/// Format a diff context line (not selected). -fn format_diff_context_line( - out: &mut OutputBuilder, - content_model: &ContentModel, - file_path: &str, - line_num: u32, - content: &str, -) { - let (old, new) = extract_diff_line_nums(content_model, file_path, line_num); - out.diff_line(old, new, content, false); -} - -/// Format a diff selected line. -fn format_diff_selected_line( - out: &mut OutputBuilder, - content_model: &ContentModel, - file_path: &str, - line_num: u32, - content: &str, -) { - let (old, new) = extract_diff_line_nums(content_model, file_path, line_num); - out.diff_line(old, new, content, true); -} - -/// Extract old/new line numbers from a diff line. -fn extract_diff_line_nums( - content_model: &ContentModel, - file_path: &str, - line_num: u32, -) -> (Option, Option) { - content_model - .find_line(file_path, line_num) - .and_then(|line| { - if let LineOrigin::Diff { old_line, new_line, .. } = &line.origin { - Some((*old_line, *new_line)) - } else { - None - } - }) - .unwrap_or((None, None)) -} - /// Calculate the BuilderMode from annotations. -pub fn calculate_builder_mode( - content: &ContentModel, - max_line: u32, -) -> BuilderMode { - let is_diff = matches!(content.metadata, ContentMetadata::Diff(_)); +pub fn calculate_builder_mode(content: &ContentModel, max_line: u32) -> BuilderMode { + let is_diff = matches!(content.view, ContentView::Diff { .. }); let line_num_width = max_line.to_string().len(); if is_diff { diff --git a/src-tauri/src/output/mod.rs b/src-tauri/src/output/mod.rs index 6b982e0d..e971668c 100644 --- a/src-tauri/src/output/mod.rs +++ b/src-tauri/src/output/mod.rs @@ -13,11 +13,12 @@ mod snapshot_tests; use std::collections::{BTreeMap, HashMap}; +use crate::anchor::Annotation; use crate::lang; use crate::mcp::tools::SessionImage; use crate::portal::LoadedPortal; use crate::review::{FileKey, Review}; -use crate::state::{Annotation, ContentModel, ContentNode, LineSemantics, PortalSemantics}; +use crate::state::{ContentModel, ContentNode, LineSemantics, PortalSemantics}; pub use builder::{BuilderMode, OutputBuilder, SECTION_DIVIDER, SEPARATOR}; pub use render::render_content; @@ -74,12 +75,17 @@ pub fn format_json(result: &FormatResult) -> String { /// /// When content contains portal links (e.g., `[label](file.rs#L10-L20)`), /// the exported text includes the portal content as fenced code blocks -/// immediately after the source line containing the link. +/// immediately after the source line containing the link. Diff content +/// exports as patch-shaped text synthesized from the documents. pub fn export_content(content: &ContentModel) -> String { + if let crate::state::ContentView::Diff { documents } = &content.view { + return export_diff(documents); + } + // If no portals, just join all lines if content.portals.is_empty() { return content - .lines + .flat_lines() .iter() .filter(|line| !matches!(line.semantics, LineSemantics::Portal(_))) .map(|l| l.content.as_str()) @@ -99,7 +105,7 @@ pub fn export_content(content: &ContentModel) -> String { let mut result = String::new(); let mut original_line_num: u32 = 0; - for line in &content.lines { + for line in content.flat_lines() { // Skip portal lines (they're interleaved; we'll re-emit them as code blocks) if matches!(line.semantics, LineSemantics::Portal(_)) { continue; @@ -131,6 +137,49 @@ pub fn export_content(content: &ContentModel) -> String { result } +/// Patch-shaped text for a diff view — clipboard copy and save. Headers and +/// `+`/`-` signs are presentation synthesized at this edge, like the +/// annotation emit; ranges arrive in git-printed convention and read off +/// verbatim. +fn export_diff(documents: &[crate::state::DiffDocument]) -> String { + let side = |sign: char, range: &std::ops::Range| { + let count = range.end - range.start; + if count == 1 { + format!("{sign}{}", range.start) + } else { + format!("{sign}{},{count}", range.start) + } + }; + + documents + .iter() + .flat_map(|doc| { + let a = doc.old_path.as_deref().unwrap_or(&doc.path); + let header = format!("diff --git a/{a} b/{}", doc.path); + let binary = doc + .unavailable + .then(|| format!("Binary files a/{a} and b/{} differ", doc.path)); + + std::iter::once(header) + .chain(binary) + .chain(doc.hunks.iter().flat_map(|hunk| { + let marker = format!( + "@@ {} {} @@", + side('-', &hunk.old_range), + side('+', &hunk.new_range) + ); + let header = match hunk.function_context.as_deref() { + Some(ctx) => format!("{marker} {ctx}"), + None => marker, + }; + std::iter::once(header).chain(hunk.rows.iter().map(formatters::prefixed)) + })) + .collect::>() + }) + .collect::>() + .join("\n") +} + /// Format a portal as a fenced code block with language hint. fn format_portal_code_block(portal: &LoadedPortal) -> String { // Collect only content lines (skip header/footer) @@ -138,7 +187,10 @@ fn format_portal_code_block(portal: &LoadedPortal) -> String { .lines .iter() .filter_map(|line| { - if matches!(line.semantics, LineSemantics::Portal(PortalSemantics::Content)) { + if matches!( + line.semantics, + LineSemantics::Portal(PortalSemantics::Content) + ) { Some(line.content.as_str()) } else { None @@ -197,7 +249,7 @@ pub fn export_section(content: &ContentModel, start_line: u32, end_line: u32) -> let mut result = String::new(); let mut current_line: u32 = 0; - for line in &content.lines { + for line in content.flat_lines() { // Skip portal-interleaved lines (we'll re-emit them as code blocks) if matches!(line.semantics, LineSemantics::Portal(_)) { continue; @@ -367,7 +419,12 @@ pub fn format_output(review: &Review, mode: OutputMode) -> FormatResult { if comment.is_empty() { None } else { - Some(render_content(comment, &mut images, &mut figure_counter, mode)) + Some(render_content( + comment, + &mut images, + &mut figure_counter, + mode, + )) } }); @@ -420,12 +477,19 @@ pub fn format_output(review: &Review, mode: OutputMode) -> FormatResult { // Build annotation blocks (if any) if has_annotations { let files_with_annotations = collect_files_with_annotations(review); + let documents = match &content.view { + crate::state::ContentView::Diff { documents } => documents.as_slice(), + _ => &[], + }; let mut first_block = true; - for (display_path, target) in &files_with_annotations { + for (display_path, doc_index, target) in &files_with_annotations { + // The anchor's document — `FileKey::diff_file(index)` ⇔ `documents[index]`. + let doc = doc_index.and_then(|index| documents.get(index)); + // Sort annotations within this file by start line let mut sorted_annotations: Vec<&Annotation> = target.annotations.values().collect(); - sorted_annotations.sort_by_key(|a| a.start_line); + sorted_annotations.sort_by_key(|a| a.start_line()); for ann in sorted_annotations { if !first_block { @@ -437,6 +501,7 @@ pub fn format_output(review: &Review, mode: OutputMode) -> FormatResult { content, ann, display_path, + doc, &mut images, &mut figure_counter, mode, @@ -460,15 +525,27 @@ pub fn format_output(review: &Review, mode: OutputMode) -> FormatResult { .map(|target| target.annotations.len()) .sum(); - let general_comment_count = if review.session_comment.as_ref() + let general_comment_count = if review + .session_comment + .as_ref() .map(|c| !c.is_empty()) - .unwrap_or(false) { 1 } else { 0 }; + .unwrap_or(false) + { + 1 + } else { + 0 + }; let general_comment = review.session_comment.as_ref().and_then(|comment| { if comment.is_empty() { None } else { - Some(render_content(comment, &mut Vec::new(), &mut 0usize, OutputMode::Cli)) + Some(render_content( + comment, + &mut Vec::new(), + &mut 0usize, + OutputMode::Cli, + )) } }); @@ -499,13 +576,16 @@ fn calculate_max_line(review: &Review) -> u32 { .files .values() .flat_map(|target| target.annotations.values()) - .map(|a| a.end_line) + .map(|a| a.end_line()) .max() .unwrap_or(0) } -/// Collect files with annotations in display order. -fn collect_files_with_annotations(review: &Review) -> Vec<(String, &crate::review::AnnotationTarget)> { +/// Collect files with annotations in display order; diff-mode entries carry +/// their document index. +fn collect_files_with_annotations( + review: &Review, +) -> Vec<(String, Option, &crate::review::AnnotationTarget)> { if let Some(diff_files) = review.root_view.diff_files() { // Diff mode: use DiffFileView for display paths, enumerate for index diff_files @@ -517,7 +597,7 @@ fn collect_files_with_annotations(review: &Review) -> Vec<(String, &crate::revie if target.annotations.is_empty() { None } else { - Some((df.path.display().to_string(), target)) + Some((df.path.display().to_string(), Some(index), target)) } }) }) @@ -529,8 +609,8 @@ fn collect_files_with_annotations(review: &Review) -> Vec<(String, &crate::revie .iter() .filter(|(_, target)| !target.annotations.is_empty()) .filter_map(|(key, target)| match key { - FileKey::Path(p) => Some((p.display().to_string(), target)), - FileKey::Ephemeral { label } => Some((label.clone(), target)), + FileKey::Path(p) => Some((p.display().to_string(), None, target)), + FileKey::Ephemeral { label } => Some((label.clone(), None, target)), FileKey::DiffFile { .. } => None, // Should not happen in file mode }) .collect() @@ -540,9 +620,12 @@ fn collect_files_with_annotations(review: &Review) -> Vec<(String, &crate::revie #[cfg(test)] mod tests { use super::*; + use crate::anchor::{Anchor, Endpoint}; use crate::input::{CliSource, ContentSource}; - use crate::state::{ContentMetadata, ContentModel, ExitMode, ExitModeSource, Line, LineRange, UserConfig}; - use std::collections::HashMap; + use crate::output::snapshot_tests::ann; + use crate::source::Side; + use crate::state::{ContentMetadata, ContentModel, ExitMode, ExitModeSource, Line, UserConfig}; + use indexmap::IndexMap; use std::path::PathBuf; fn make_line(number: u32, content: &str) -> Line { @@ -557,16 +640,21 @@ mod tests { } } - fn make_review(label: &str, lines: Vec, annotations: HashMap) -> Review { + fn make_review( + label: &str, + lines: Vec, + annotations: IndexMap, + ) -> Review { let source = ContentSource::Cli(CliSource::File { path: PathBuf::from(label), }); let content = ContentModel { label: label.to_string(), - lines, + view: crate::state::ContentView::Flat { lines }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let config = UserConfig::empty(); let mut review = Review::cli(content, config, "main".to_string()); @@ -579,23 +667,21 @@ mod tests { #[test] fn empty_annotations_returns_empty_string() { - let review = make_review("test.rs", vec![], HashMap::new()); + let review = make_review("test.rs", vec![], IndexMap::new()); assert_eq!(format_output(&review, OutputMode::Cli).text, ""); } #[test] fn single_line_annotation() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "Fix this".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "Fix this".to_string(), + }], ); + annotations.insert(id, annotation); let lines: Vec = (1..=10) .map(|n| make_line(n, &format!("line {}", n))) @@ -612,17 +698,15 @@ mod tests { #[test] fn multi_line_annotation() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(10, 15), - Annotation { - start_line: 10, - end_line: 15, - content: vec![ContentNode::Text { - text: "Review these lines".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 10, + 15, + vec![ContentNode::Text { + text: "Review these lines".to_string(), + }], ); + annotations.insert(id, annotation); let lines: Vec = (1..=20) .map(|n| make_line(n, &format!("line {}", n))) @@ -642,27 +726,23 @@ mod tests { #[test] fn multiple_annotations_sorted_by_line() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(20, 20), - Annotation { - start_line: 20, - end_line: 20, - content: vec![ContentNode::Text { - text: "Second".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 20, + 20, + vec![ContentNode::Text { + text: "Second".to_string(), + }], ); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "First".to_string(), - }], - }, + annotations.insert(id, annotation); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "First".to_string(), + }], ); + annotations.insert(id, annotation); let lines: Vec = (1..=25) .map(|n| make_line(n, &format!("line {}", n))) @@ -682,17 +762,15 @@ mod tests { #[test] fn context_line_excluded_when_empty() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(3, 3), - Annotation { - start_line: 3, - end_line: 3, - content: vec![ContentNode::Text { - text: "Note".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 3, + 3, + vec![ContentNode::Text { + text: "Note".to_string(), + }], ); + annotations.insert(id, annotation); let lines = vec![ make_line(1, "first"), @@ -709,17 +787,15 @@ mod tests { #[test] fn multiline_content_properly_indented() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "Line one\nLine two\nLine three".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "Line one\nLine two\nLine three".to_string(), + }], ); + annotations.insert(id, annotation); let lines: Vec = (1..=10) .map(|n| make_line(n, &format!("line {}", n))) @@ -753,10 +829,11 @@ mod tests { ); let content = ContentModel { label: "test.rs".to_string(), - lines: vec![], + view: crate::state::ContentView::Flat { lines: vec![] }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, config, "main".to_string()); review.selected_exit_mode_id = Some("apply".to_string()); @@ -783,17 +860,15 @@ mod tests { }], ); - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "Note".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "Note".to_string(), + }], ); + annotations.insert(id, annotation); let lines: Vec = (1..=10) .map(|n| make_line(n, &format!("line {}", n))) @@ -801,10 +876,11 @@ mod tests { let content = ContentModel { label: "test.rs".to_string(), - lines, + view: crate::state::ContentView::Flat { lines }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, config, "main".to_string()); review.selected_exit_mode_id = Some("reject".to_string()); @@ -830,10 +906,11 @@ mod tests { }); let content = ContentModel { label: "test.rs".to_string(), - lines: vec![], + view: crate::state::ContentView::Flat { lines: vec![] }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, UserConfig::empty(), "main".to_string()); review.session_comment = Some(vec![ContentNode::Text { @@ -864,10 +941,11 @@ mod tests { ); let content = ContentModel { label: "test.rs".to_string(), - lines: vec![], + view: crate::state::ContentView::Flat { lines: vec![] }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, config, "main".to_string()); review.session_comment = Some(vec![ContentNode::Text { @@ -890,10 +968,11 @@ mod tests { }); let content = ContentModel { label: "test.rs".to_string(), - lines: vec![], + view: crate::state::ContentView::Flat { lines: vec![] }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, UserConfig::empty(), "main".to_string()); review.session_comment = Some(vec![]); @@ -906,24 +985,22 @@ mod tests { #[test] fn legend_block_with_tags() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ - ContentNode::Tag { - id: "sec001".to_string(), - name: "SECURITY".to_string(), - instruction: "Review for vulnerabilities".to_string(), - }, - ContentNode::Text { - text: " Use constant-time comparison".to_string(), - }, - ], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ + ContentNode::Tag { + id: "sec001".to_string(), + name: "SECURITY".to_string(), + instruction: "Review for vulnerabilities".to_string(), + }, + ContentNode::Text { + text: " Use constant-time comparison".to_string(), + }, + ], ); + annotations.insert(id, annotation); let lines: Vec = (1..=10) .map(|n| make_line(n, &format!("line {}", n))) @@ -942,26 +1019,24 @@ mod tests { #[test] fn tags_alphabetically_sorted() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ - ContentNode::Tag { - id: "sec001".to_string(), - name: "SECURITY".to_string(), - instruction: "Security check".to_string(), - }, - ContentNode::Tag { - id: "bug001".to_string(), - name: "BUG".to_string(), - instruction: "Bug fix".to_string(), - }, - ], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ + ContentNode::Tag { + id: "sec001".to_string(), + name: "SECURITY".to_string(), + instruction: "Security check".to_string(), + }, + ContentNode::Tag { + id: "bug001".to_string(), + name: "BUG".to_string(), + instruction: "Bug fix".to_string(), + }, + ], ); + annotations.insert(id, annotation); let lines: Vec = (1..=10) .map(|n| make_line(n, &format!("line {}", n))) @@ -978,31 +1053,27 @@ mod tests { #[test] fn tag_deduplication_in_legend() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Tag { - id: "sec001".to_string(), - name: "SECURITY".to_string(), - instruction: "Security check".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Tag { + id: "sec001".to_string(), + name: "SECURITY".to_string(), + instruction: "Security check".to_string(), + }], ); - annotations.insert( - LineRange::new(10, 10), - Annotation { - start_line: 10, - end_line: 10, - content: vec![ContentNode::Tag { - id: "sec001".to_string(), - name: "SECURITY".to_string(), - instruction: "Security check".to_string(), - }], - }, + annotations.insert(id, annotation); + let (id, annotation) = ann( + 10, + 10, + vec![ContentNode::Tag { + id: "sec001".to_string(), + name: "SECURITY".to_string(), + instruction: "Security check".to_string(), + }], ); + annotations.insert(id, annotation); let lines: Vec = (1..=15) .map(|n| make_line(n, &format!("line {}", n))) @@ -1067,39 +1138,49 @@ mod tests { }); let content = ContentModel { label: "test.rs".to_string(), - lines, + view: crate::state::ContentView::Flat { lines }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let config = UserConfig::empty(); let mut review = Review::cli(content, config, "main".to_string()); // Register the portal file as an annotation target let portal_key = FileKey::path("/path/to/portal.rs"); - review.files.insert(portal_key.clone(), crate::review::AnnotationTarget::new()); + review + .files + .insert(portal_key.clone(), crate::review::AnnotationTarget::new()); // Add annotation on portal line 101 (which is at array index 6, not 100) let portal_target = review.files.get_mut(&portal_key).unwrap(); - portal_target.annotations.insert( - LineRange::new(101, 101), - Annotation { - start_line: 101, - end_line: 101, - content: vec![ContentNode::Text { - text: "Check this portal line".to_string(), - }], - }, + let (id, annotation) = ann( + 101, + 101, + vec![ContentNode::Text { + text: "Check this portal line".to_string(), + }], ); + portal_target.annotations.insert(id, annotation); let output = format_output(&review, OutputMode::Cli).text; // The output should contain the portal file path and line number - assert!(output.contains("/path/to/portal.rs:101"), "Should have portal file header"); + assert!( + output.contains("/path/to/portal.rs:101"), + "Should have portal file header" + ); // The output should contain the actual portal line content (found via find_line) - assert!(output.contains("portal code line 101"), "Should have portal line content"); + assert!( + output.contains("portal code line 101"), + "Should have portal line content" + ); // The annotation should be present - assert!(output.contains("Check this portal line"), "Should have annotation text"); + assert!( + output.contains("Check this portal line"), + "Should have annotation text" + ); } // ========== export_content tests ========== @@ -1120,22 +1201,45 @@ mod tests { fn export_content_without_portals() { let content = ContentModel { label: "test.md".to_string(), - lines: vec![ - make_line(1, "# Title"), - make_line(2, "Some text"), - make_line(3, "More text"), - ], + view: crate::state::ContentView::Flat { + lines: vec![ + make_line(1, "# Title"), + make_line(2, "Some text"), + make_line(3, "More text"), + ], + }, source: ContentSource::Cli(CliSource::File { path: PathBuf::from("test.md"), }), metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let output = export_content(&content); assert_eq!(output, "# Title\nSome text\nMore text"); } + #[test] + fn export_content_reconstructs_patch_text_for_diffs() { + let diff = "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,4 @@ fn main()\n fn main() {\n- old();\n+ new();\n+ more();\n }\n"; + let source = ContentSource::Cli(CliSource::File { + path: PathBuf::from("changes.diff"), + }); + let content = crate::state::ContentModel::from_diff(diff, source).unwrap(); + + // Copy-content / save in diff mode export patch-shaped text — headers + // and signs re-synthesized from the documents, plumbing lines omitted. + let expected = "diff --git a/file.rs b/file.rs\n\ + @@ -1,3 +1,4 @@ fn main()\n \ + fn main() {\n\ + - old();\n\ + + new();\n\ + + more();\n \ + }"; + assert_eq!(export_content(&content), expected); + } + #[test] fn export_content_with_single_portal() { // Simulate markdown with a portal link on line 2 @@ -1159,7 +1263,10 @@ mod tests { }, )); lines.push(make_portal_line("fn hello() {", PortalSemantics::Content)); - lines.push(make_portal_line(" println!(\"hi\");", PortalSemantics::Content)); + lines.push(make_portal_line( + " println!(\"hi\");", + PortalSemantics::Content, + )); lines.push(make_portal_line("}", PortalSemantics::Content)); lines.push(make_portal_line("", PortalSemantics::Footer)); @@ -1189,20 +1296,27 @@ mod tests { let content = ContentModel { label: "test.md".to_string(), - lines, + view: crate::state::ContentView::Flat { lines }, source: ContentSource::Cli(CliSource::File { path: PathBuf::from("test.md"), }), metadata: ContentMetadata::Plain, portals: vec![portal], + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let output = export_content(&content); // Should contain original markdown lines assert!(output.contains("# Title"), "Should have title"); - assert!(output.contains("Check [code](src/lib.rs#L10-L12)"), "Should have portal link"); - assert!(output.contains("More text"), "Should have text after portal"); + assert!( + output.contains("Check [code](src/lib.rs#L10-L12)"), + "Should have portal link" + ); + assert!( + output.contains("More text"), + "Should have text after portal" + ); // Should contain portal comment and code fence assert!( @@ -1210,7 +1324,10 @@ mod tests { "Should have portal comment" ); assert!(output.contains("```rust"), "Should have rust code fence"); - assert!(output.contains("fn hello() {"), "Should have portal code content"); + assert!( + output.contains("fn hello() {"), + "Should have portal code content" + ); assert!(output.contains("```\n"), "Should close code fence"); } @@ -1254,34 +1371,41 @@ mod tests { let content = ContentModel { label: "test.md".to_string(), - lines, + view: crate::state::ContentView::Flat { lines }, source: ContentSource::Cli(CliSource::File { path: PathBuf::from("test.md"), }), metadata: ContentMetadata::Plain, portals: vec![portal], + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let output = export_content(&content); // Should NOT contain portal code block for empty portal - assert!(!output.contains(""), "Should have first portal comment"); - assert!(output.contains(""), "Should have second portal comment"); + assert!( + output.contains(""), + "Should have first portal comment" + ); + assert!( + output.contains(""), + "Should have second portal comment" + ); } #[test] @@ -1398,8 +1538,18 @@ mod tests { // Add annotation at line 3 target.upsert_annotation( - 3, - 3, + "3".to_string(), + Anchor::Diff { + path: "file.rs".to_string(), + start: Endpoint { + side: Side::New, + line: 3, + }, + end: Endpoint { + side: Side::New, + line: 3, + }, + }, vec![ContentNode::Text { text: "Review this change".to_string(), }], @@ -1456,10 +1606,20 @@ mod tests { let diff_file_key = FileKey::diff_file(0); let target = review.files.get_mut(&diff_file_key).unwrap(); - // Add annotation at line 2 + // Add annotation at old-side line 2 target.upsert_annotation( - 2, - 2, + "2".to_string(), + Anchor::Diff { + path: "file.rs".to_string(), + start: Endpoint { + side: Side::Old, + line: 2, + }, + end: Endpoint { + side: Side::Old, + line: 2, + }, + }, vec![ContentNode::Text { text: "This was removed".to_string(), }], @@ -1505,8 +1665,18 @@ mod tests { // Add annotation at line 1 target.upsert_annotation( - 1, - 1, + "1".to_string(), + Anchor::Diff { + path: "file.rs".to_string(), + start: Endpoint { + side: Side::New, + line: 1, + }, + end: Endpoint { + side: Side::New, + line: 1, + }, + }, vec![ContentNode::Text { text: "Check function signature".to_string(), }], @@ -1526,7 +1696,7 @@ mod tests { #[test] fn saved_to_only_produces_output() { - let mut review = make_review("test.rs", vec![], HashMap::new()); + let mut review = make_review("test.rs", vec![], IndexMap::new()); review.saved_to = Some(PathBuf::from("/tmp/saved-file.md")); let output = format_output(&review, OutputMode::Cli).text; @@ -1536,17 +1706,15 @@ mod tests { #[test] fn saved_to_with_annotations() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "Fix this".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "Fix this".to_string(), + }], ); + annotations.insert(id, annotation); let lines: Vec = (1..=10) .map(|n| make_line(n, &format!("line {}", n))) @@ -1558,7 +1726,10 @@ mod tests { let output = format_output(&review, OutputMode::Cli).text; // Should have annotation content - assert!(output.contains("test.rs:5"), "Should have annotation header"); + assert!( + output.contains("test.rs:5"), + "Should have annotation header" + ); assert!(output.contains("Fix this"), "Should have annotation text"); // Should end with saved_to line assert!( @@ -1575,10 +1746,11 @@ mod tests { }); let content = ContentModel { label: "test.rs".to_string(), - lines: vec![], + view: crate::state::ContentView::Flat { lines: vec![] }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, UserConfig::empty(), "main".to_string()); review.session_comment = Some(vec![ContentNode::Text { @@ -1589,7 +1761,10 @@ mod tests { let output = format_output(&review, OutputMode::Cli).text; assert!(output.contains("GENERAL:"), "Should have GENERAL block"); - assert!(output.contains("Overall looks good"), "Should have session comment"); + assert!( + output.contains("Overall looks good"), + "Should have session comment" + ); assert!( output.ends_with("Saved to /tmp/review.md\n"), "Should end with saved_to. Got:\n{}", @@ -1604,10 +1779,11 @@ mod tests { }); let content = ContentModel { label: "test.rs".to_string(), - lines: vec![], + view: crate::state::ContentView::Flat { lines: vec![] }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, UserConfig::empty(), "main".to_string()); review.session_comment = Some(vec![ContentNode::Text { @@ -1617,7 +1793,10 @@ mod tests { let output = format_output(&review, OutputMode::Cli).text; - assert!(!output.contains("Saved to"), "Should not have saved_to line"); + assert!( + !output.contains("Saved to"), + "Should not have saved_to line" + ); } #[test] @@ -1666,19 +1845,23 @@ mod tests { #[test] fn command_exit_mode_includes_path_and_content() { - use tempfile::TempDir; use std::fs; + use tempfile::TempDir; // Create a temp command file let temp = TempDir::new().unwrap(); let cmd_path = temp.path().join("test-cmd.md"); - fs::write(&cmd_path, r#"--- + fs::write( + &cmd_path, + r#"--- description: "Test command" --- ## Instructions Do something useful. -"#).unwrap(); +"#, + ) + .unwrap(); let source = ContentSource::Cli(CliSource::File { path: PathBuf::from("test.rs"), @@ -1691,15 +1874,18 @@ Do something useful. color: "#8b5cf6".to_string(), instruction: "Test command".to_string(), order: 0, - source: crate::state::ExitModeSource::Command { path: cmd_path.clone() }, + source: crate::state::ExitModeSource::Command { + path: cmd_path.clone(), + }, }], ); let content = ContentModel { label: "test.rs".to_string(), - lines: vec![], + view: crate::state::ContentView::Flat { lines: vec![] }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, config, "main".to_string()); review.selected_exit_mode_id = Some("cmd-test".to_string()); @@ -1707,13 +1893,25 @@ Do something useful. let output = format_output(&review, OutputMode::Cli).text; // Should include NEXT with exit mode name and instruction - assert!(output.contains("NEXT: /test-cmd — Test command"), "Should have NEXT header"); + assert!( + output.contains("NEXT: /test-cmd — Test command"), + "Should have NEXT header" + ); // Should include the command path assert!(output.contains("Command:"), "Should have Command: line"); - assert!(output.contains("test-cmd.md"), "Should include command file name"); + assert!( + output.contains("test-cmd.md"), + "Should include command file name" + ); // Should include the command content - assert!(output.contains("## Instructions"), "Should include command content heading"); - assert!(output.contains("Do something useful"), "Should include command content body"); + assert!( + output.contains("## Instructions"), + "Should include command content heading" + ); + assert!( + output.contains("Do something useful"), + "Should include command content body" + ); // Should have separator lines assert!(output.contains(SEPARATOR), "Should have content separators"); } @@ -1736,10 +1934,11 @@ Do something useful. ); let content = ContentModel { label: "test.rs".to_string(), - lines: vec![], + view: crate::state::ContentView::Flat { lines: vec![] }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, config, "main".to_string()); review.selected_exit_mode_id = Some("apply".to_string()); @@ -1747,8 +1946,17 @@ Do something useful. let output = format_output(&review, OutputMode::Cli).text; // Should have NEXT but NOT command-specific content - assert!(output.contains("NEXT: Apply — Apply changes"), "Should have NEXT"); - assert!(!output.contains("Command:"), "Should NOT have Command: line for regular exit mode"); - assert!(!output.contains(SEPARATOR), "Should NOT have content separators"); + assert!( + output.contains("NEXT: Apply — Apply changes"), + "Should have NEXT" + ); + assert!( + !output.contains("Command:"), + "Should NOT have Command: line for regular exit mode" + ); + assert!( + !output.contains(SEPARATOR), + "Should NOT have content separators" + ); } } diff --git a/src-tauri/src/output/render.rs b/src-tauri/src/output/render.rs index 92f7abd0..4e1cb4f1 100644 --- a/src-tauri/src/output/render.rs +++ b/src-tauri/src/output/render.rs @@ -96,16 +96,14 @@ fn render_node( // Output pasted content as plain text content.clone() } - ContentNode::Ref { snapshot, .. } => { - match snapshot { - RefSnapshot::Annotation(snap) => { - format!("[ANNOTATION L{}]", snap.source_key) - } - RefSnapshot::Heading(snap) => { - format!("[H{} {}]", snap.level, snap.title) - } + ContentNode::Ref { snapshot, .. } => match snapshot { + RefSnapshot::Annotation(snap) => { + format!("[ANNOTATION L{}]", snap.source_key) } - } + RefSnapshot::Heading(snap) => { + format!("[H{} {}]", snap.level, snap.title) + } + }, ContentNode::File { path } => { // File reference format: @ref:file:path/to/file.ts format!("@ref:file:{}", path) diff --git a/src-tauri/src/output/snapshot_tests.rs b/src-tauri/src/output/snapshot_tests.rs index 05350cfb..d1e415d2 100644 --- a/src-tauri/src/output/snapshot_tests.rs +++ b/src-tauri/src/output/snapshot_tests.rs @@ -6,17 +6,19 @@ //! These tests validate the complete output format for various scenarios, //! making it easy to catch unintended format changes. -use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; +use indexmap::IndexMap; use tempfile::NamedTempFile; +use crate::anchor::{Anchor, Annotation, Endpoint}; use crate::input::{CliSource, ContentSource, DiffSource, McpSource}; use crate::review::Review; +use crate::source::Side; use crate::state::{ - AnnotationRefSnapshot, Annotation, ContentMetadata, ContentModel, ContentNode, ExitMode, - ExitModeSource, Line, LineOrigin, LineRange, LineSemantics, RefSnapshot, UserConfig, + AnnotationRefSnapshot, ContentMetadata, ContentModel, ContentNode, ContentView, ExitMode, + ExitModeSource, Line, LineOrigin, LineSemantics, RefSnapshot, UserConfig, }; use super::{format_output, OutputMode}; @@ -41,16 +43,48 @@ fn make_lines(path: &str, start: u32, end: u32) -> Vec { .collect() } -fn make_review(label: &str, lines: Vec, annotations: HashMap) -> Review { +/// Build a (id, Annotation) fixture for a given line range. Tests are all +/// non-diff (file/content mode), so the side-less `Anchor::Source` variant is +/// the correct shape. The path is inert for output — +/// formatting always resolves the file path via `FileKey`/`DiffFileView`, +/// never via the anchor's path — so a fixed placeholder is fine here. +/// Shared with `mod::tests` (both are children of `output`) to avoid duplication. +pub(super) fn ann(start: u32, end: u32, content: Vec) -> (String, Annotation) { + ann_at("test.rs", start, end, content) +} + +pub(super) fn ann_at( + path: &str, + start: u32, + end: u32, + content: Vec, +) -> (String, Annotation) { + let id = format!("{start}-{end}"); + ( + id.clone(), + Annotation { + id, + anchor: Anchor::Source { + path: path.to_string(), + start, + end, + }, + content, + }, + ) +} + +fn make_review(label: &str, lines: Vec, annotations: IndexMap) -> Review { let source = ContentSource::Cli(CliSource::File { path: PathBuf::from(label), }); let content = ContentModel { label: label.to_string(), - lines, + view: ContentView::Flat { lines }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let config = UserConfig::empty(); let mut review = Review::cli(content, config, "main".to_string()); @@ -63,7 +97,7 @@ fn make_review(label: &str, lines: Vec, annotations: HashMap, - annotations: HashMap, + annotations: IndexMap, config: UserConfig, ) -> Review { let source = ContentSource::Cli(CliSource::File { @@ -71,10 +105,11 @@ fn make_review_with_config( }); let content = ContentModel { label: label.to_string(), - lines, + view: ContentView::Flat { lines }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: std::sync::Arc::new(crate::source::RawPatchSource), }; let mut review = Review::cli(content, config, "main".to_string()); if let Some(file) = review.files.values_mut().next() { @@ -87,7 +122,7 @@ fn make_review_with_config( #[test] fn empty_review() { - let review = make_review("test.rs", vec![], HashMap::new()); + let review = make_review("test.rs", vec![], IndexMap::new()); let output = format_output(&review, OutputMode::Cli).text; insta::assert_snapshot!(output); } @@ -96,17 +131,15 @@ fn empty_review() { #[test] fn single_line_annotation() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "Fix this bug".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "Fix this bug".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("src/lib.rs", make_lines("src/lib.rs", 1, 10), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -115,17 +148,15 @@ fn single_line_annotation() { #[test] fn multi_line_annotation() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(10, 15), - Annotation { - start_line: 10, - end_line: 15, - content: vec![ContentNode::Text { - text: "This entire block needs refactoring".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 10, + 15, + vec![ContentNode::Text { + text: "This entire block needs refactoring".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("src/main.rs", make_lines("src/main.rs", 1, 20), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -134,17 +165,15 @@ fn multi_line_annotation() { #[test] fn annotation_multiline_content() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "First line of feedback\nSecond line continues\nThird line concludes".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "First line of feedback\nSecond line continues\nThird line concludes".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("file.rs", make_lines("file.rs", 1, 10), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -155,28 +184,24 @@ fn annotation_multiline_content() { #[test] fn multiple_annotations_sorted() { - let mut annotations = HashMap::new(); + let mut annotations = IndexMap::new(); // Insert in reverse order to verify sorting - annotations.insert( - LineRange::new(20, 20), - Annotation { - start_line: 20, - end_line: 20, - content: vec![ContentNode::Text { - text: "Second annotation".to_string(), - }], - }, + let (id, annotation) = ann( + 20, + 20, + vec![ContentNode::Text { + text: "Second annotation".to_string(), + }], ); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "First annotation".to_string(), - }], - }, + annotations.insert(id, annotation); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "First annotation".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("test.rs", make_lines("test.rs", 1, 25), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -187,24 +212,22 @@ fn multiple_annotations_sorted() { #[test] fn single_tag() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ - ContentNode::Tag { - id: "sec001".to_string(), - name: "SECURITY".to_string(), - instruction: "Review for security vulnerabilities".to_string(), - }, - ContentNode::Text { - text: " Validate user input here".to_string(), - }, - ], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ + ContentNode::Tag { + id: "sec001".to_string(), + name: "SECURITY".to_string(), + instruction: "Review for security vulnerabilities".to_string(), + }, + ContentNode::Text { + text: " Validate user input here".to_string(), + }, + ], ); + annotations.insert(id, annotation); let review = make_review("auth.rs", make_lines("auth.rs", 1, 10), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -213,31 +236,29 @@ fn single_tag() { #[test] fn multiple_tags_alphabetized() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ - ContentNode::Tag { - id: "todo001".to_string(), - name: "TODO".to_string(), - instruction: "Mark items for follow-up".to_string(), - }, - ContentNode::Tag { - id: "bug001".to_string(), - name: "BUG".to_string(), - instruction: "Known bug to fix".to_string(), - }, - ContentNode::Tag { - id: "sec001".to_string(), - name: "SECURITY".to_string(), - instruction: "Security concern".to_string(), - }, - ], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ + ContentNode::Tag { + id: "todo001".to_string(), + name: "TODO".to_string(), + instruction: "Mark items for follow-up".to_string(), + }, + ContentNode::Tag { + id: "bug001".to_string(), + name: "BUG".to_string(), + instruction: "Known bug to fix".to_string(), + }, + ContentNode::Tag { + id: "sec001".to_string(), + name: "SECURITY".to_string(), + instruction: "Security concern".to_string(), + }, + ], ); + annotations.insert(id, annotation); let review = make_review("code.rs", make_lines("code.rs", 1, 10), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -248,7 +269,7 @@ fn multiple_tags_alphabetized() { #[test] fn general_comment_only() { - let mut review = make_review("test.rs", vec![], HashMap::new()); + let mut review = make_review("test.rs", vec![], IndexMap::new()); review.session_comment = Some(vec![ContentNode::Text { text: "Overall the code looks good, just a few minor issues".to_string(), }]); @@ -259,9 +280,10 @@ fn general_comment_only() { #[test] fn general_comment_multiline() { - let mut review = make_review("test.rs", vec![], HashMap::new()); + let mut review = make_review("test.rs", vec![], IndexMap::new()); review.session_comment = Some(vec![ContentNode::Text { - text: "First paragraph of feedback.\n\nSecond paragraph with more details.\n\nConclusion.".to_string(), + text: "First paragraph of feedback.\n\nSecond paragraph with more details.\n\nConclusion." + .to_string(), }]); let output = format_output(&review, OutputMode::Cli).text; @@ -283,7 +305,7 @@ fn next_apply() { source: ExitModeSource::Persisted, }], ); - let mut review = make_review_with_config("plan.md", vec![], HashMap::new(), config); + let mut review = make_review_with_config("plan.md", vec![], IndexMap::new(), config); review.selected_exit_mode_id = Some("apply".to_string()); let output = format_output(&review, OutputMode::Cli).text; @@ -303,7 +325,7 @@ fn next_reject() { source: ExitModeSource::Persisted, }], ); - let mut review = make_review_with_config("proposal.md", vec![], HashMap::new(), config); + let mut review = make_review_with_config("proposal.md", vec![], IndexMap::new(), config); review.selected_exit_mode_id = Some("reject".to_string()); let output = format_output(&review, OutputMode::Cli).text; @@ -325,7 +347,7 @@ fn general_and_next() { source: ExitModeSource::Persisted, }], ); - let mut review = make_review_with_config("test.rs", vec![], HashMap::new(), config); + let mut review = make_review_with_config("test.rs", vec![], IndexMap::new(), config); review.session_comment = Some(vec![ContentNode::Text { text: "Looks good with minor suggestions below".to_string(), }]); @@ -349,43 +371,44 @@ fn tags_general_next_annotations() { }], ); - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ - ContentNode::Tag { - id: "sec001".to_string(), - name: "SECURITY".to_string(), - instruction: "Security review needed".to_string(), - }, - ContentNode::Text { - text: " Sanitize this input".to_string(), - }, - ], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ + ContentNode::Tag { + id: "sec001".to_string(), + name: "SECURITY".to_string(), + instruction: "Security review needed".to_string(), + }, + ContentNode::Text { + text: " Sanitize this input".to_string(), + }, + ], ); - annotations.insert( - LineRange::new(12, 14), - Annotation { - start_line: 12, - end_line: 14, - content: vec![ - ContentNode::Tag { - id: "perf001".to_string(), - name: "PERF".to_string(), - instruction: "Performance optimization".to_string(), - }, - ContentNode::Text { - text: " Consider caching this".to_string(), - }, - ], - }, + annotations.insert(id, annotation); + let (id, annotation) = ann( + 12, + 14, + vec![ + ContentNode::Tag { + id: "perf001".to_string(), + name: "PERF".to_string(), + instruction: "Performance optimization".to_string(), + }, + ContentNode::Text { + text: " Consider caching this".to_string(), + }, + ], ); + annotations.insert(id, annotation); - let mut review = make_review_with_config("handler.rs", make_lines("handler.rs", 1, 20), annotations, config); + let mut review = make_review_with_config( + "handler.rs", + make_lines("handler.rs", 1, 20), + annotations, + config, + ); review.session_comment = Some(vec![ContentNode::Text { text: "Good progress, but security and performance need attention".to_string(), }]); @@ -399,7 +422,7 @@ fn tags_general_next_annotations() { #[test] fn saved_to_only() { - let mut review = make_review("test.rs", vec![], HashMap::new()); + let mut review = make_review("test.rs", vec![], IndexMap::new()); review.saved_to = Some(PathBuf::from("/tmp/review-output.md")); let output = format_output(&review, OutputMode::Cli).text; @@ -408,17 +431,15 @@ fn saved_to_only() { #[test] fn annotations_with_saved_to() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "Note here".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "Note here".to_string(), + }], ); + annotations.insert(id, annotation); let mut review = make_review("code.rs", make_lines("code.rs", 1, 10), annotations); review.saved_to = Some(PathBuf::from("/home/user/reviews/code-review.md")); @@ -454,8 +475,18 @@ fn diff_annotation_added_line() { let diff_file_key = crate::review::FileKey::diff_file(0); let target = review.files.get_mut(&diff_file_key).unwrap(); target.upsert_annotation( - 4, // line with more_code() - 4, + "4".to_string(), // line with more_code() + Anchor::Diff { + path: "file.rs".to_string(), + start: Endpoint { + side: Side::New, + line: 4, + }, + end: Endpoint { + side: Side::New, + line: 4, + }, + }, vec![ContentNode::Text { text: "Review this new addition".to_string(), }], @@ -487,8 +518,18 @@ fn diff_annotation_deleted_line() { let diff_file_key = crate::review::FileKey::diff_file(0); let target = review.files.get_mut(&diff_file_key).unwrap(); target.upsert_annotation( - 2, // deleted line - 2, + "2".to_string(), // deleted line — lives on the old side + Anchor::Diff { + path: "file.rs".to_string(), + start: Endpoint { + side: Side::Old, + line: 2, + }, + end: Endpoint { + side: Side::Old, + line: 2, + }, + }, vec![ContentNode::Text { text: "Why was this removed?".to_string(), }], @@ -498,22 +539,124 @@ fn diff_annotation_deleted_line() { insta::assert_snapshot!(output); } +// ========== Diff Mode: side-aware range corpus ========== +// +// One replacement hunk, annotated from every side combination. Display rows: +// fn main() { (old:1 new:1) +// - old_one(); (old:2) +// - old_two(); (old:3) +// + new_one(); (new:2) +// + new_two(); (new:3) +// shared(); (old:4 new:4) +// } (old:5 new:5) + +const REPLACEMENT_DIFF: &str = r#"diff --git a/file.rs b/file.rs +--- a/file.rs ++++ b/file.rs +@@ -1,5 +1,5 @@ + fn main() { +- old_one(); +- old_two(); ++ new_one(); ++ new_two(); + shared(); + } +"#; + +fn replacement_review(start: (Side, u32), end: (Side, u32), text: &str) -> Review { + let source = ContentSource::Mcp(McpSource::Diff { + label: Some("test.diff".to_string()), + source: DiffSource::Raw, + }); + let content = ContentModel::from_diff(REPLACEMENT_DIFF, source).unwrap(); + let mut review = Review::cli(content, UserConfig::empty(), "main".to_string()); + + let target = review + .files + .get_mut(&crate::review::FileKey::diff_file(0)) + .unwrap(); + target.upsert_annotation( + "corpus".to_string(), + Anchor::Diff { + path: "file.rs".to_string(), + start: Endpoint { + side: start.0, + line: start.1, + }, + end: Endpoint { + side: end.0, + line: end.1, + }, + }, + vec![ContentNode::Text { + text: text.to_string(), + }], + ); + review +} + +#[test] +fn diff_annotation_old_only_single() { + let review = replacement_review((Side::Old, 2), (Side::Old, 2), "Why drop old_one?"); + insta::assert_snapshot!(format_output(&review, OutputMode::Cli).text); +} + +#[test] +fn diff_annotation_old_only_multiline() { + let review = replacement_review((Side::Old, 2), (Side::Old, 3), "Both of these were dropped"); + insta::assert_snapshot!(format_output(&review, OutputMode::Cli).text); +} + +#[test] +fn diff_annotation_new_only_single() { + let review = replacement_review((Side::New, 2), (Side::New, 2), "Name this better"); + insta::assert_snapshot!(format_output(&review, OutputMode::Cli).text); +} + +#[test] +fn diff_annotation_new_only_multiline() { + let review = replacement_review( + (Side::New, 2), + (Side::New, 3), + "Review the replacement pair", + ); + insta::assert_snapshot!(format_output(&review, OutputMode::Cli).text); +} + +#[test] +fn diff_annotation_mixed_replacement() { + let review = replacement_review( + (Side::Old, 2), + (Side::New, 3), + "This replacement changes behavior", + ); + insta::assert_snapshot!(format_output(&review, OutputMode::Cli).text); +} + +#[test] +fn diff_annotation_mixed_multiline() { + let review = replacement_review( + (Side::Old, 3), + (Side::New, 4), + "From the second deletion through the shared context", + ); + insta::assert_snapshot!(format_output(&review, OutputMode::Cli).text); +} + // ========== Replace Blocks ========== #[test] fn replace_block() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Replace { - original: "let x = dangerous_call(input);".to_string(), - replacement: "let x = safe_call(sanitize(input));".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Replace { + original: "let x = dangerous_call(input);".to_string(), + replacement: "let x = safe_call(sanitize(input));".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("security.rs", make_lines("security.rs", 1, 10), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -524,18 +667,16 @@ fn replace_block() { #[test] fn error_node() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Error { - source: "parser".to_string(), - message: "Failed to parse embedded code block".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Error { + source: "parser".to_string(), + message: "Failed to parse embedded code block".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("broken.rs", make_lines("broken.rs", 1, 10), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -550,17 +691,15 @@ fn large_line_numbers() { .map(|n| make_line("large_file.rs", n, &format!(" content at line {}", n))) .collect(); - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(1000, 1005), - Annotation { - start_line: 1000, - end_line: 1005, - content: vec![ContentNode::Text { - text: "Wide line numbers should align properly".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 1000, + 1005, + vec![ContentNode::Text { + text: "Wide line numbers should align properly".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("large_file.rs", lines, annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -571,17 +710,15 @@ fn large_line_numbers() { #[test] fn annotation_at_line_one() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(1, 1), - Annotation { - start_line: 1, - end_line: 1, - content: vec![ContentNode::Text { - text: "No context line above".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 1, + 1, + vec![ContentNode::Text { + text: "No context line above".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("first.rs", make_lines("first.rs", 1, 5), annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -593,17 +730,15 @@ fn context_line_whitespace_only() { let mut lines = make_lines("whitespace.rs", 1, 5); lines[1].content = " ".to_string(); // Line 2 is whitespace only - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(3, 3), - Annotation { - start_line: 3, - end_line: 3, - content: vec![ContentNode::Text { - text: "Context line 2 should be skipped".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 3, + 3, + vec![ContentNode::Text { + text: "Context line 2 should be skipped".to_string(), + }], ); + annotations.insert(id, annotation); let review = make_review("whitespace.rs", lines, annotations); let output = format_output(&review, OutputMode::Cli).text; @@ -641,134 +776,124 @@ fn kitchen_sink_everything() { }], ); - let mut annotations = HashMap::new(); + let mut annotations = IndexMap::new(); // Annotation 1: Tags + text - annotations.insert( - LineRange::new(10, 12), - Annotation { - start_line: 10, - end_line: 12, - content: vec![ - ContentNode::Tag { - id: "sec001".to_string(), - name: "SECURITY".to_string(), - instruction: "Review for security vulnerabilities".to_string(), - }, - ContentNode::Text { - text: " This authentication logic needs review.".to_string(), - }, - ], - }, + let (id, annotation) = ann( + 10, + 12, + vec![ + ContentNode::Tag { + id: "sec001".to_string(), + name: "SECURITY".to_string(), + instruction: "Review for security vulnerabilities".to_string(), + }, + ContentNode::Text { + text: " This authentication logic needs review.".to_string(), + }, + ], ); + annotations.insert(id, annotation); // Annotation 2: Replace block - annotations.insert( - LineRange::new(25, 25), - Annotation { - start_line: 25, - end_line: 25, - content: vec![ - ContentNode::Tag { - id: "refactor001".to_string(), - name: "REFACTOR".to_string(), - instruction: "Code improvement suggestion".to_string(), - }, - ContentNode::Text { - text: " ".to_string(), - }, - ContentNode::Replace { - original: "let result = unsafe_operation(input);".to_string(), - replacement: "let result = safe_operation(sanitize(input))?;".to_string(), - }, - ], - }, + let (id, annotation) = ann( + 25, + 25, + vec![ + ContentNode::Tag { + id: "refactor001".to_string(), + name: "REFACTOR".to_string(), + instruction: "Code improvement suggestion".to_string(), + }, + ContentNode::Text { + text: " ".to_string(), + }, + ContentNode::Replace { + original: "let result = unsafe_operation(input);".to_string(), + replacement: "let result = safe_operation(sanitize(input))?;".to_string(), + }, + ], ); + annotations.insert(id, annotation); // Annotation 3: Annotation ref + file ref - annotations.insert( - LineRange::new(40, 42), - Annotation { - start_line: 40, - end_line: 42, - content: vec![ - ContentNode::Tag { - id: "todo001".to_string(), - name: "TODO".to_string(), - instruction: "Action item for follow-up".to_string(), - }, - ContentNode::Text { - text: " Cross-reference: see ".to_string(), - }, - ContentNode::Ref { - ref_type: "annotation".to_string(), - snapshot: RefSnapshot::Annotation(AnnotationRefSnapshot { - source_key: "10-12".to_string(), - source_file: None, - preview: "[# SECURITY] This authentication...".to_string(), - content: vec![ContentNode::Text { - text: "Referenced annotation content".to_string(), - }], - }), - }, - ContentNode::Text { - text: " and ".to_string(), - }, - ContentNode::File { - path: "src/handlers/api.rs".to_string(), - }, - ContentNode::Text { - text: ".".to_string(), - }, - ], - }, + let (id, annotation) = ann( + 40, + 42, + vec![ + ContentNode::Tag { + id: "todo001".to_string(), + name: "TODO".to_string(), + instruction: "Action item for follow-up".to_string(), + }, + ContentNode::Text { + text: " Cross-reference: see ".to_string(), + }, + ContentNode::Ref { + ref_type: "annotation".to_string(), + snapshot: RefSnapshot::Annotation(AnnotationRefSnapshot { + source_key: "10-12".to_string(), + source_file: None, + preview: "[# SECURITY] This authentication...".to_string(), + content: vec![ContentNode::Text { + text: "Referenced annotation content".to_string(), + }], + }), + }, + ContentNode::Text { + text: " and ".to_string(), + }, + ContentNode::File { + path: "src/handlers/api.rs".to_string(), + }, + ContentNode::Text { + text: ".".to_string(), + }, + ], ); + annotations.insert(id, annotation); // Annotation 4: Error node + paste - annotations.insert( - LineRange::new(55, 55), - Annotation { - start_line: 55, - end_line: 55, - content: vec![ - ContentNode::Error { - source: "mermaid".to_string(), - message: "Failed to parse diagram syntax".to_string(), - }, - ContentNode::Text { - text: " Intended diagram:\n".to_string(), - }, - ContentNode::Paste { - content: "graph LR\n A --> B --> C".to_string(), - }, - ], - }, + let (id, annotation) = ann( + 55, + 55, + vec![ + ContentNode::Error { + source: "mermaid".to_string(), + message: "Failed to parse diagram syntax".to_string(), + }, + ContentNode::Text { + text: " Intended diagram:\n".to_string(), + }, + ContentNode::Paste { + content: "graph LR\n A --> B --> C".to_string(), + }, + ], ); + annotations.insert(id, annotation); // Annotation 5: Excalidraw + Media - annotations.insert( - LineRange::new(70, 70), - Annotation { - start_line: 70, - end_line: 70, - content: vec![ - ContentNode::Text { - text: "Architecture diagram: ".to_string(), - }, - ContentNode::Excalidraw { - elements: r#"[{"type":"rectangle","x":0,"y":0}]"#.to_string(), - image: Some("data:image/png;base64,iVBORw0KGgo=".to_string()), - }, - ContentNode::Text { - text: "\nScreenshot of expected UI: ".to_string(), - }, - ContentNode::Media { - image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==".to_string(), - mime_type: "image/png".to_string(), - }, - ], - }, + let (id, annotation) = ann( + 70, + 70, + vec![ + ContentNode::Text { + text: "Architecture diagram: ".to_string(), + }, + ContentNode::Excalidraw { + elements: r#"[{"type":"rectangle","x":0,"y":0}]"#.to_string(), + image: Some("data:image/png;base64,iVBORw0KGgo=".to_string()), + }, + ContentNode::Text { + text: "\nScreenshot of expected UI: ".to_string(), + }, + ContentNode::Media { + image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==".to_string(), + mime_type: "image/png".to_string(), + }, + ], ); + annotations.insert(id, annotation); // Build the review let mut review = make_review_with_config( @@ -861,24 +986,22 @@ description: "Create a well-structured git commit" }], ); - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(10, 12), - Annotation { - start_line: 10, - end_line: 12, - content: vec![ - ContentNode::Tag { - id: "commit001".to_string(), - name: "COMMIT".to_string(), - instruction: "Include in commit message".to_string(), - }, - ContentNode::Text { - text: " This refactors the auth module for clarity".to_string(), - }, - ], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 10, + 12, + vec![ + ContentNode::Tag { + id: "commit001".to_string(), + name: "COMMIT".to_string(), + instruction: "Include in commit message".to_string(), + }, + ContentNode::Text { + text: " This refactors the auth module for clarity".to_string(), + }, + ], ); + annotations.insert(id, annotation); let mut review = make_review_with_config( "src/auth.rs", @@ -908,7 +1031,7 @@ description: "Create a well-structured git commit" #[test] fn json_output_empty_review() { - let review = make_review("test.rs", vec![], HashMap::new()); + let review = make_review("test.rs", vec![], IndexMap::new()); let result = format_output(&review, OutputMode::Mcp); let json_str = super::format_json(&result); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); @@ -919,17 +1042,15 @@ fn json_output_empty_review() { #[test] fn json_output_text_only() { - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ContentNode::Text { - text: "Fix this".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ContentNode::Text { + text: "Fix this".to_string(), + }], ); + annotations.insert(id, annotation); let lines = make_lines("handler.rs", 1, 10); let review = make_review("handler.rs", lines, annotations); @@ -939,8 +1060,14 @@ fn json_output_text_only() { // Text should contain the annotation let text = parsed["text"].as_str().unwrap(); - assert!(text.contains("Fix this"), "JSON text should contain annotation"); - assert!(text.contains("handler.rs:5"), "JSON text should contain file location"); + assert!( + text.contains("Fix this"), + "JSON text should contain annotation" + ); + assert!( + text.contains("handler.rs:5"), + "JSON text should contain file location" + ); // No images assert_eq!(parsed["images"].as_array().unwrap().len(), 0); @@ -1009,17 +1136,15 @@ fn json_output_with_multiple_images() { #[test] fn json_output_is_valid_json() { // Test with a realistic review containing special characters - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(3, 3), - Annotation { - start_line: 3, - end_line: 3, - content: vec![ContentNode::Text { - text: "Contains \"quotes\" and\nnewlines and ".to_string(), - }], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 3, + 3, + vec![ContentNode::Text { + text: "Contains \"quotes\" and\nnewlines and ".to_string(), + }], ); + annotations.insert(id, annotation); let lines = make_lines("test.rs", 1, 5); let review = make_review("test.rs", lines, annotations); @@ -1039,23 +1164,21 @@ fn json_output_is_valid_json() { #[test] fn json_output_mcp_mode_collects_media_as_images() { // When a Media node is in an annotation, MCP mode collects it as an image - let mut annotations = HashMap::new(); - annotations.insert( - LineRange::new(5, 5), - Annotation { - start_line: 5, - end_line: 5, - content: vec![ - ContentNode::Text { - text: "Screenshot: ".to_string(), - }, - ContentNode::Media { - image: "data:image/png;base64,AAAA".to_string(), - mime_type: "image/png".to_string(), - }, - ], - }, + let mut annotations = IndexMap::new(); + let (id, annotation) = ann( + 5, + 5, + vec![ + ContentNode::Text { + text: "Screenshot: ".to_string(), + }, + ContentNode::Media { + image: "data:image/png;base64,AAAA".to_string(), + mime_type: "image/png".to_string(), + }, + ], ); + annotations.insert(id, annotation); let lines = make_lines("app.rs", 1, 10); let review = make_review("app.rs", lines, annotations); @@ -1074,4 +1197,3 @@ fn json_output_mcp_mode_collects_media_as_images() { assert!(parsed["text"].as_str().unwrap().contains("[Figure 1]")); assert_eq!(parsed["images"].as_array().unwrap().len(), 1); } - diff --git a/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_mixed_multiline.snap b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_mixed_multiline.snap new file mode 100644 index 00000000..21692611 --- /dev/null +++ b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_mixed_multiline.snap @@ -0,0 +1,11 @@ +--- +source: src/output/snapshot_tests.rs +expression: "format_output(&review, OutputMode::Cli).text" +--- +file.rs (old:3 → new:4): + 2: | - old_one(); +> 3: | - old_two(); +> :2 | + new_one(); +> :3 | + new_two(); +> 4:4 | shared(); + └──> From the second deletion through the shared context diff --git a/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_mixed_replacement.snap b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_mixed_replacement.snap new file mode 100644 index 00000000..819f84bd --- /dev/null +++ b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_mixed_replacement.snap @@ -0,0 +1,11 @@ +--- +source: src/output/snapshot_tests.rs +expression: "format_output(&review, OutputMode::Cli).text" +--- +file.rs (old:2 → new:3): + 1:1 | fn main() { +> 2: | - old_one(); +> 3: | - old_two(); +> :2 | + new_one(); +> :3 | + new_two(); + └──> This replacement changes behavior diff --git a/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_new_only_multiline.snap b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_new_only_multiline.snap new file mode 100644 index 00000000..736afbb5 --- /dev/null +++ b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_new_only_multiline.snap @@ -0,0 +1,9 @@ +--- +source: src/output/snapshot_tests.rs +expression: "format_output(&review, OutputMode::Cli).text" +--- +file.rs (new:2-3): + 3: | - old_two(); +> :2 | + new_one(); +> :3 | + new_two(); + └──> Review the replacement pair diff --git a/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_new_only_single.snap b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_new_only_single.snap new file mode 100644 index 00000000..2176ded6 --- /dev/null +++ b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_new_only_single.snap @@ -0,0 +1,8 @@ +--- +source: src/output/snapshot_tests.rs +expression: "format_output(&review, OutputMode::Cli).text" +--- +file.rs (new:2): + 3: | - old_two(); +> :2 | + new_one(); + └──> Name this better diff --git a/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_old_only_multiline.snap b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_old_only_multiline.snap new file mode 100644 index 00000000..b60709a1 --- /dev/null +++ b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_old_only_multiline.snap @@ -0,0 +1,9 @@ +--- +source: src/output/snapshot_tests.rs +expression: "format_output(&review, OutputMode::Cli).text" +--- +file.rs (old:2-3): + 1:1 | fn main() { +> 2: | - old_one(); +> 3: | - old_two(); + └──> Both of these were dropped diff --git a/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_old_only_single.snap b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_old_only_single.snap new file mode 100644 index 00000000..02766b87 --- /dev/null +++ b/src-tauri/src/output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_old_only_single.snap @@ -0,0 +1,8 @@ +--- +source: src/output/snapshot_tests.rs +expression: "format_output(&review, OutputMode::Cli).text" +--- +file.rs (old:2): + 1:1 | fn main() { +> 2: | - old_one(); + └──> Why drop old_one? diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs new file mode 100644 index 00000000..0b26941d --- /dev/null +++ b/src-tauri/src/pipeline.rs @@ -0,0 +1,568 @@ +//! Git-mode render pipeline: enumerated files + full texts + computed hunks +//! → per-file `DiffDocument`s. +//! +//! Headers and `+`/`-` signs are presentation synthesized at the edges +//! (frontend walk, output emit) — this module produces only structure. +//! Plumbing (`index`/`---`/`+++`/mode lines, `\ No newline at end of file` +//! markers) has no representation at all. + +use std::collections::HashMap; +use std::ops::Range; + +use crate::engine::{compute_hunks, DiffRow}; +use crate::error::AnnotError; +use crate::highlight::Highlighter; +use crate::source::{FileSource, Side}; +use crate::state::{DiffDocument, HunkV2, LineHtml, Row}; +use crate::vcs::{BlobRef, FileEntry}; + +/// Lines of context around changes — git's default; unfold is the +/// mechanism for seeing more, not a wider default. +pub const CONTEXT_LINES: u32 = 3; + +/// Hunk-header function context is capped like git's (80 bytes). +const FUNCTION_CONTEXT_MAX_BYTES: usize = 80; + +/// Build `GixSource`'s oid map from enumerated entries. Each entry yields up +/// to two insertions keyed by the side-appropriate path (renames differ per +/// side); a `None` oid means the side doesn't exist and gets no key. +pub fn build_oid_map(entries: &[FileEntry]) -> HashMap<(String, Side), BlobRef> { + entries + .iter() + .flat_map(|entry| { + let old = entry + .old_path + .clone() + .zip(entry.old_oid.clone()) + .map(|(path, oid)| ((path, Side::Old), BlobRef::Oid(oid))); + let new = entry + .new_path + .clone() + .zip(entry.new_oid.clone()) + .map(|(path, blob)| ((path, Side::New), blob)); + old.into_iter().chain(new) + }) + .collect() +} + +/// Render enumerated files into per-file diff documents. +pub fn render( + entries: &[FileEntry], + source: &dyn FileSource, + highlighter: &Highlighter, + context: u32, +) -> Result, AnnotError> { + entries + .iter() + .map(|entry| render_file(entry, source, highlighter, context)) + .collect() +} + +fn render_file( + entry: &FileEntry, + source: &dyn FileSource, + highlighter: &Highlighter, + context: u32, +) -> Result { + let (display_path, old_path) = + crate::diff::display_identity(entry.old_path.as_deref(), entry.new_path.as_deref()); + let language = crate::diff::language_for(entry.new_path.as_deref(), entry.old_path.as_deref()); + + // A side the entry says exists but yields no text is binary/oversize/ + // non-UTF-8 (`Ok(None)` capability signal); a nonexistent side diffs + // against the empty string. + let old_text = fetch(source, entry, Side::Old)?; + let new_text = fetch(source, entry, Side::New)?; + let unavailable = (entry.old_oid.is_some() && old_text.is_none()) + || (entry.new_oid.is_some() && new_text.is_none()); + + let mut hunks = Vec::new(); + if !unavailable { + let old = old_text.as_deref().unwrap_or(""); + let new = new_text.as_deref().unwrap_or(""); + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + let fake_path = format!("file.{language}"); + + for hunk in compute_hunks(old, new, context).hunks { + let (old_start, old_count) = printed_range(&hunk.old_range); + let (new_start, new_count) = printed_range(&hunk.new_range); + let function_context = function_context(&old_lines, hunk.old_range.start); + hunks.push(HunkV2 { + old_range: old_start..old_start + old_count, + new_range: new_start..new_start + new_count, + function_context_html: function_context + .as_deref() + .and_then(|ctx| highlighter.highlight_function_context(ctx, &fake_path)), + function_context, + rows: hunk + .rows + .iter() + .map(|row| { + render_row( + row, + &old_lines, + &new_lines, + &language, + &fake_path, + highlighter, + ) + }) + .collect(), + }); + } + } + + Ok(DiffDocument { + path: display_path, + old_path, + status: entry.status.clone(), + unavailable, + language, + hunks, + }) +} + +fn fetch( + source: &dyn FileSource, + entry: &FileEntry, + side: Side, +) -> Result>, AnnotError> { + let (path, exists) = match side { + Side::Old => (entry.old_path.as_deref(), entry.old_oid.is_some()), + Side::New => (entry.new_path.as_deref(), entry.new_oid.is_some()), + }; + match path { + Some(p) if exists => source.full_text(p, side), + _ => Ok(None), + } +} + +fn render_row( + row: &DiffRow, + old_lines: &[&str], + new_lines: &[&str], + language: &str, + fake_path: &str, + highlighter: &Highlighter, +) -> Row { + // `word_ranges` deliberately dropped: no wire field exists yet; + // word-level highlights recompute from the session's retained FileSource. + let (text, old_line, new_line) = match *row { + DiffRow::Context { old_line, new_line } => { + (line_at(old_lines, old_line), Some(old_line), Some(new_line)) + } + DiffRow::Deleted { old_line, .. } => (line_at(old_lines, old_line), Some(old_line), None), + DiffRow::Added { new_line, .. } => (line_at(new_lines, new_line), None, Some(new_line)), + }; + + let html = (!language.is_empty()) + .then(|| highlighter.highlight_diff_row(text, fake_path)) + .flatten(); + + Row { + old_line, + new_line, + content: text.to_string(), + html: html.map(LineHtml::Full), + } +} + +/// 1-indexed line from a pre-split side, empty for out-of-range (defensive: +/// engine row numbers are always in range). +fn line_at<'a>(lines: &[&'a str], number: u32) -> &'a str { + lines + .get(number.saturating_sub(1) as usize) + .copied() + .unwrap_or("") +} + +/// Half-open 1-indexed engine range → the numbers git prints in `@@` headers: +/// count is the length; an empty range prints the line *before* the position +/// (`@@ -0,0 +1,3 @@` for a new file). +fn printed_range(range: &Range) -> (u32, u32) { + let count = range.end - range.start; + let start = if count == 0 { + range.start.saturating_sub(1) + } else { + range.start + }; + (start, count) +} + +/// Git's default funcname rule (xdiff `def_ff`, used when no userdiff driver +/// matches): nearest line above the hunk — old side, like git — whose first +/// character is alphabetic, `_`, or `$`; capped at 80 bytes. +fn function_context(old_lines: &[&str], hunk_old_start: u32) -> Option { + let above = (hunk_old_start as usize) + .saturating_sub(1) + .min(old_lines.len()); + old_lines[..above] + .iter() + .rev() + .find(|line| { + line.chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_' || c == '$') + }) + .map(|line| { + let end = (0..=FUNCTION_CONTEXT_MAX_BYTES.min(line.len())) + .rev() + .find(|&i| line.is_char_boundary(i)) + .unwrap_or(0); + line[..end].trim_end().to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::{CliSource, ContentSource}; + use crate::source::GixSource; + use crate::state::{ContentModel, ContentView, LineHtml}; + use crate::testutil::git; + use crate::vcs::{enumerate, DiffTarget}; + use std::path::Path; + + fn render_repo(p: &Path, target: &DiffTarget) -> Vec { + let entries = enumerate(p, target, &[]).unwrap(); + let source = GixSource::new(gix::discover(p).unwrap(), build_oid_map(&entries)); + render(&entries, &source, &Highlighter::new(), CONTEXT_LINES).unwrap() + } + + /// Compact textual form of the documents: identity, hunk ranges, row + /// numbers, raw content — the full wire-relevant surface except html. + fn dump(docs: &[DiffDocument]) -> String { + let num = |n: Option| n.map_or("·".to_string(), |n| n.to_string()); + docs.iter() + .flat_map(|doc| { + let renamed = doc + .old_path + .as_deref() + .map(|p| format!(" (from {p})")) + .unwrap_or_default(); + let unavailable = if doc.unavailable { " unavailable" } else { "" }; + let header = format!( + "=== {}{renamed} [{:?}] lang={}{unavailable}", + doc.path, doc.status, doc.language + ); + std::iter::once(header).chain(doc.hunks.iter().flat_map(|hunk| { + let ctx = hunk + .function_context + .as_deref() + .map(|c| format!(" {c}")) + .unwrap_or_default(); + let ranges = format!( + "@@ -{},{} +{},{} @@{ctx}", + hunk.old_range.start, + hunk.old_range.end - hunk.old_range.start, + hunk.new_range.start, + hunk.new_range.end - hunk.new_range.start, + ); + std::iter::once(ranges).chain(hunk.rows.iter().map(|row| { + format!( + "{:>4} {:>4} |{}", + num(row.old_line), + num(row.new_line), + row.content + ) + })) + })) + }) + .collect::>() + .join("\n") + } + + /// Every row's line numbers land inside its hunk's declared ranges. + fn assert_rows_within_ranges(docs: &[DiffDocument]) { + for doc in docs { + for hunk in &doc.hunks { + for row in &hunk.rows { + if let Some(old) = row.old_line { + assert!( + hunk.old_range.contains(&old), + "{old} ∉ {:?}", + hunk.old_range + ); + } + if let Some(new) = row.new_line { + assert!( + hunk.new_range.contains(&new), + "{new} ∉ {:?}", + hunk.new_range + ); + } + } + } + } + } + + const MAIN_RS_V1: &str = "fn main() {\n let a = 1;\n let b = 2;\n let c = 3;\n let d = 4;\n let e = 5;\n println!(\"{}\", a + b);\n}\n"; + const MAIN_RS_V2: &str = "fn main() {\n let a = 1;\n let b = 2;\n let c = 3;\n let d = 40;\n let e = 5;\n println!(\"{}\", a + b);\n}\n"; + + /// Whether the committed fixture includes a pure rename. `LegacySafe` + /// exists because the legacy parser cannot survive one: unidiff drops + /// the hunk-less file, and `parse_diff`'s raw-line walk then misindexes + /// every file after it — part of why this pipeline exists. + enum Fixture { + WithRename, + LegacySafe, + } + + /// Committed matrix: modified (with funcname), added, deleted, a + /// no-trailing-newline change, and (unless `LegacySafe`) a pure rename. + fn range_fixture(kind: Fixture) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + git(p, &["init"]); + std::fs::write(p.join("main.rs"), MAIN_RS_V1).unwrap(); + std::fs::write(p.join("deleted.txt"), "doomed\ncontent\n").unwrap(); + std::fs::write(p.join("old_name.txt"), "renamed content\n").unwrap(); + std::fs::write(p.join("noeol.txt"), "alpha\nbeta").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "one"]); + std::fs::write(p.join("main.rs"), MAIN_RS_V2).unwrap(); + std::fs::write(p.join("added.txt"), "fresh\nfile\n").unwrap(); + std::fs::write(p.join("noeol.txt"), "alpha\nbeta\n").unwrap(); + git(p, &["rm", "-q", "deleted.txt"]); + if matches!(kind, Fixture::WithRename) { + git(p, &["mv", "old_name.txt", "new_name.txt"]); + } + git(p, &["add", "."]); + git(p, &["commit", "-m", "two"]); + dir + } + + fn head_range() -> DiffTarget { + DiffTarget::Range { + from: "HEAD~1".into(), + to: "HEAD".into(), + merge_base: false, + } + } + + #[test] + fn range_stream_snapshot() { + let dir = range_fixture(Fixture::WithRename); + let docs = render_repo(dir.path(), &head_range()); + insta::assert_snapshot!(dump(&docs)); + assert_rows_within_ranges(&docs); + } + + /// The strangler bar: the new pipeline's changed rows must be exactly the + /// ones the legacy path (git CLI patch → parse_diff) produces. + #[test] + fn parity_with_legacy_parser_on_fixture() { + let dir = range_fixture(Fixture::LegacySafe); + let p = dir.path(); + let new_docs = render_repo(p, &head_range()); + + let patch = git(p, &["diff", "HEAD~1..HEAD"]); + let cli_source = ContentSource::Cli(CliSource::Stdin { + label: "diff".into(), + }); + let legacy = ContentModel::from_diff(&patch, cli_source).unwrap(); + let ContentView::Diff { + documents: legacy_docs, + } = &legacy.view + else { + panic!("legacy model is not a diff"); + }; + + let names = |docs: &[DiffDocument]| { + docs.iter() + .map(|d| (d.path.clone(), d.old_path.clone())) + .collect::>() + }; + assert_eq!(names(&new_docs), names(legacy_docs)); + + // Changed rows: identical content and line numbers. Context rows are + // excluded — hunk boundaries may differ cosmetically between engines + // (accepted at design time). noeol.txt is excluded from the + // side-by-side because the legacy parser miscounts there: it treats + // the `\ No newline at end of file` marker as a context line, shifting + // every following new-side number by one (asserted correct below). + let changed = |docs: &[DiffDocument]| { + docs.iter() + .filter(|doc| doc.path != "noeol.txt") + .flat_map(|doc| { + doc.hunks + .iter() + .flat_map(|h| &h.rows) + .filter(|r| r.old_line.is_none() || r.new_line.is_none()) + .map(|r| (doc.path.clone(), r.old_line, r.new_line, r.content.clone())) + }) + .collect::>() + }; + assert_eq!(changed(&new_docs), changed(legacy_docs)); + + // The re-added `beta` really is line 2 of the new file — the number + // the legacy parser gets wrong. + assert!(new_docs + .iter() + .flat_map(|d| d.hunks.iter().flat_map(|h| &h.rows)) + .any(|r| r.content == "beta" && r.old_line.is_none() && r.new_line == Some(2))); + } + + #[test] + fn working_tree_stream_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + git(p, &["init"]); + std::fs::write(p.join("main.rs"), MAIN_RS_V1).unwrap(); + std::fs::write(p.join("bin.dat"), b"\x00\x01old").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "one"]); + std::fs::write(p.join("main.rs"), MAIN_RS_V2).unwrap(); + std::fs::write(p.join("bin.dat"), b"\x00\x02new").unwrap(); + std::fs::write(p.join("untracked.txt"), "brand new\n").unwrap(); + + let docs = render_repo(p, &DiffTarget::WorkingTree); + insta::assert_snapshot!(dump(&docs)); + } + + #[test] + fn rows_are_highlighted_raw() { + let dir = range_fixture(Fixture::WithRename); + let docs = render_repo(dir.path(), &head_range()); + + let added_rs = docs + .iter() + .flat_map(|d| d.hunks.iter().flat_map(|h| &h.rows)) + .find(|r| r.old_line.is_none() && r.content.contains("let d = 40;")) + .unwrap(); + match &added_rs.html { + // Highlighted, and no textual sign — the sign is presentation. + Some(LineHtml::Full(html)) => assert!(!html.starts_with('+')), + other => panic!("expected highlighted row, got {other:?}"), + } + + let main_doc = docs.iter().find(|d| d.path == "main.rs").unwrap(); + assert!(main_doc.hunks[0].function_context_html.is_some()); + } + + #[test] + fn empty_enumeration_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + git(p, &["init"]); + std::fs::write(p.join("a.txt"), "x\n").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "one"]); + + let source = ContentSource::Cli(CliSource::Stdin { + label: "diff".into(), + }); + let Err(err) = ContentModel::from_git(p, &DiffTarget::WorkingTree, &[], source) else { + panic!("expected an error for an empty enumeration"); + }; + assert!(err.to_string().contains("no changes")); + } + + #[test] + fn oid_map_keys_renames_by_side_appropriate_path() { + let entry = FileEntry { + status: crate::vcs::FileStatus::Renamed { similarity: 100 }, + old_path: Some("old.rs".into()), + new_path: Some("new.rs".into()), + old_oid: Some("aaaa".into()), + new_oid: Some(BlobRef::WorkingTree), + }; + let map = build_oid_map(&[entry]); + assert_eq!( + map.get(&("old.rs".into(), Side::Old)), + Some(&BlobRef::Oid("aaaa".into())) + ); + assert_eq!( + map.get(&("new.rs".into(), Side::New)), + Some(&BlobRef::WorkingTree) + ); + assert_eq!(map.len(), 2); + } + + #[test] + fn printed_range_matches_git_header_conventions() { + assert_eq!(printed_range(&(1..4)), (1, 3)); // @@ -1,3 + assert_eq!(printed_range(&(3..4)), (3, 1)); // @@ -3 (count omitted) + assert_eq!(printed_range(&(1..1)), (0, 0)); // new file: @@ -0,0 + assert_eq!(printed_range(&(6..6)), (5, 0)); // insertion after line 5 + } + + /// Manual corpus eyeball (the spec's verification bar): render the + /// enclosing repository's working-tree diff through both pipelines and + /// report row-level divergences plus the untracked files only the new + /// pipeline can show. + /// Run: `cargo test --lib side_by_side_on_this_repo -- --ignored --nocapture` + #[test] + #[ignore = "manual eyeball against the enclosing repo's working tree"] + fn side_by_side_on_this_repo() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let new_docs = render_repo(repo_root, &DiffTarget::WorkingTree); + + let patch = git(repo_root, &["diff", "HEAD"]); + if patch.is_empty() { + println!("working tree clean — nothing to compare"); + return; + } + let legacy = ContentModel::from_diff( + &patch, + ContentSource::Cli(CliSource::Stdin { + label: "diff".into(), + }), + ) + .unwrap(); + let ContentView::Diff { + documents: legacy_docs, + } = &legacy.view + else { + panic!("legacy model is not a diff"); + }; + + let changed = |docs: &[DiffDocument]| { + docs.iter() + .flat_map(|doc| { + doc.hunks + .iter() + .flat_map(|h| &h.rows) + .filter(|r| r.old_line.is_none() || r.new_line.is_none()) + .map(|r| { + format!( + "{} {:?}/{:?} {}", + doc.path, r.old_line, r.new_line, r.content + ) + }) + }) + .collect::>() + }; + let (ours, theirs) = (changed(&new_docs), changed(legacy_docs)); + for row in ours.difference(&theirs) { + println!("only new pipeline: {row}"); + } + for row in theirs.difference(&ours) { + println!("only legacy: {row}"); + } + println!( + "{} changed rows in new pipeline, {} in legacy", + ours.len(), + theirs.len() + ); + } + + #[test] + fn function_context_follows_default_driver_rule() { + let lines = ["fn outer() {", " inner();", "}", "", " indented"]; + // Nearest line above line 5 starting with [alpha_$]: skips the + // indented line, the blank, and the closing brace. + assert_eq!( + function_context(&lines, 5), + Some("fn outer() {".to_string()) + ); + // Nothing above the first line. + assert_eq!(function_context(&lines, 1), None); + // 80-byte cap lands on a char boundary. + let long = format!("f{}", "é".repeat(60)); + let capped = function_context(&[&long], 2).unwrap(); + assert!(capped.len() <= FUNCTION_CONTEXT_MAX_BYTES); + assert!(long.starts_with(&capped)); + } +} diff --git a/src-tauri/src/portal.rs b/src-tauri/src/portal.rs index e558aec3..4f3cb8df 100644 --- a/src-tauri/src/portal.rs +++ b/src-tauri/src/portal.rs @@ -75,7 +75,11 @@ impl std::fmt::Display for PortalError { PortalError::InvalidUtf8 => write!(f, "File is not valid UTF-8"), PortalError::TooManyPortals => write!(f, "Too many portals (max {})", MAX_PORTALS), PortalError::TooManyLines => { - write!(f, "Portal exceeds line limit (max {})", MAX_LINES_PER_PORTAL) + write!( + f, + "Portal exceeds line limit (max {})", + MAX_LINES_PER_PORTAL + ) } PortalError::IoError(msg) => write!(f, "I/O error: {}", msg), } @@ -144,7 +148,9 @@ pub fn validate_portal(raw_path: &str, base_dir: &Path) -> Result Result) -> Self { - FileKey::Ephemeral { label: label.into() } + FileKey::Ephemeral { + label: label.into(), + } } /// Get the routing path string for this key. @@ -59,7 +63,7 @@ impl FileKey { FileKey::Ephemeral { label } => label.clone(), FileKey::DiffFile { .. } => { // Diff files use index-based routing, not path-based - unreachable!("DiffFile uses index-based routing via LineOrigin::Diff") + unreachable!("DiffFile routes by index into the diff view's documents") } } } @@ -111,17 +115,23 @@ pub struct Review { /// Contains annotations and file-specific metadata, but NOT content. /// Content lives in `View` (the root_view field on Review). pub struct AnnotationTarget { - /// Annotations keyed by normalized line range. - pub annotations: HashMap, + /// Annotations keyed by id, in insertion order. + pub annotations: IndexMap, /// File-specific metadata (language, etc.). pub metadata: FileMetadata, } +impl Default for AnnotationTarget { + fn default() -> Self { + Self::new() + } +} + impl AnnotationTarget { /// Create an empty annotation target. pub fn new() -> Self { Self { - annotations: HashMap::new(), + annotations: IndexMap::new(), metadata: FileMetadata::default(), } } @@ -220,17 +230,12 @@ impl Review { root_window: String, result_channel: Option>, ) -> Self { - // Extract diff metadata before moving content - let diff_meta = match &content.metadata { - ContentMetadata::Diff(dm) => Some(dm.clone()), - _ => None, - }; - - let (root_view, files, window_view) = if let Some(dm) = diff_meta { - Self::build_diff_state(content, dm) - } else { - Self::build_file_state(content) - }; + let (root_view, files, window_view) = + if matches!(content.view, crate::state::ContentView::Diff { .. }) { + Self::build_diff_state(content) + } else { + Self::build_file_state(content) + }; let mut windows = HashMap::new(); windows.insert(root_window.clone(), window_view); @@ -271,7 +276,7 @@ impl Review { // Register portal source files as annotation targets for portal in &content.portals { let portal_key = FileKey::path(portal.source_path.clone()); - if !files.contains_key(&portal_key) { + files.entry(portal_key).or_insert_with(|| { // Extract extension from portal source path let portal_ext = portal .source_path @@ -280,8 +285,8 @@ impl Review { .map(|s| s.to_string()); let mut portal_target = AnnotationTarget::new(); portal_target.metadata.language = portal_ext; - files.insert(portal_key, portal_target); - } + portal_target + }); } // Note: View::File.path is not used anywhere, passing label as placeholder @@ -296,34 +301,25 @@ impl Review { /// Build state for a diff (multiple files). fn build_diff_state( content: ContentModel, - diff_meta: crate::diff::DiffMetadata, ) -> (View, HashMap, WindowView) { let window_label = content.label.clone(); let mut diff_files = Vec::new(); let mut files = HashMap::new(); - for (index, file_info) in diff_meta.files.iter().enumerate() { - // Use new_name if available, otherwise old_name (for display) - let display_path = file_info - .new_name - .as_ref() - .or(file_info.old_name.as_ref()) - .map(|s| PathBuf::from(s)) - .unwrap_or_else(|| PathBuf::from("unknown")); - - let old_path = file_info.old_name.as_ref().map(PathBuf::from); - + let crate::state::ContentView::Diff { documents } = &content.view else { + unreachable!("build_diff_state requires a diff view"); + }; + for (index, doc) in documents.iter().enumerate() { diff_files.push(DiffFileView { - path: display_path, - old_path, + path: PathBuf::from(&doc.path), + old_path: doc.old_path.as_ref().map(PathBuf::from), }); - // Key by index (type-safe) + // Key by index (type-safe): `documents[index]` is the identity. let key = FileKey::diff_file(index); - // Create annotation target for this file let mut target = AnnotationTarget::new(); - target.metadata.language = Some(file_info.language.clone()); + target.metadata.language = Some(doc.language.clone()); files.insert(key, target); } @@ -388,20 +384,28 @@ impl Review { /// Get the annotation target for a single-file window with detailed errors. /// For diff windows, use resolve_target_mut() which accepts explicit file_index. pub fn target_for_window(&self, window_label: &str) -> Result<&AnnotationTarget, String> { - let view = self.windows.get(window_label) + let view = self + .windows + .get(window_label) .ok_or_else(|| format!("Unknown window: {}", window_label))?; match view { - WindowView::File { key } => { - self.files.get(key).ok_or_else(|| "Target not loaded".into()) + WindowView::File { key } => self + .files + .get(key) + .ok_or_else(|| "Target not loaded".into()), + WindowView::Diff { .. } => { + Err("Diff window: use resolve_target_mut with file_index".into()) } - WindowView::Diff { .. } => Err("Diff window: use resolve_target_mut with file_index".into()), _ => Err("Window type does not have a single target".into()), } } /// Get mutable annotation target for a single-file window. /// Returns None for diff/mermaid windows — use resolve_target_mut() for commands. - pub fn get_target_for_window_mut(&mut self, window_label: &str) -> Option<&mut AnnotationTarget> { + pub fn get_target_for_window_mut( + &mut self, + window_label: &str, + ) -> Option<&mut AnnotationTarget> { let view = self.windows.get(window_label)?; match view { WindowView::File { key } => { @@ -473,7 +477,7 @@ impl Review { let content = self.root_view.content(); Some(ContentResponse { label: content.label.clone(), - lines: content.lines.clone(), + view: content.view.clone(), tags: self.config.tags().to_vec(), exit_modes: self.config.exit_modes().to_vec(), selected_exit_mode_id: self.selected_exit_mode_id.clone(), @@ -488,22 +492,25 @@ impl Review { } impl AnnotationTarget { - /// Insert or update an annotation. - pub fn upsert_annotation(&mut self, start_line: u32, end_line: u32, content: Vec) { - let key = LineRange::new(start_line, end_line); + /// Insert or update an annotation by id. An anchor can only be claimed by + /// one id at a time — upserting a new id at an anchor already held by a + /// different id displaces the old one. + pub fn upsert_annotation(&mut self, id: String, anchor: Anchor, content: Vec) { + self.annotations + .retain(|existing_id, ann| *existing_id == id || ann.anchor != anchor); self.annotations.insert( - key, + id.clone(), Annotation { - start_line: key.start, - end_line: key.end, + id, + anchor, content, }, ); } - /// Delete an annotation by range. - pub fn delete_annotation(&mut self, start_line: u32, end_line: u32) { - self.annotations.remove(&LineRange::new(start_line, end_line)); + /// Delete an annotation by id. + pub fn delete_annotation(&mut self, id: &str) { + self.annotations.shift_remove(id); } } @@ -516,3 +523,47 @@ impl ContentModel { /// Type alias for the managed state. pub type ActiveReview = parking_lot::Mutex>; + +#[cfg(test)] +mod tests { + use super::*; + fn anchor(line: u32) -> Anchor { + Anchor::Source { + path: "test.rs".to_string(), + start: line, + end: line, + } + } + + #[test] + fn upserting_a_new_id_at_an_existing_anchor_displaces_the_old_one() { + let mut target = AnnotationTarget::new(); + target.upsert_annotation("a".to_string(), anchor(5), vec![]); + target.upsert_annotation("b".to_string(), anchor(5), vec![]); + + assert_eq!(target.annotations.len(), 1); + assert!(target.annotations.contains_key("b")); + assert!(!target.annotations.contains_key("a")); + } + + #[test] + fn upserting_the_same_id_at_a_new_anchor_moves_it_without_displacing_others() { + let mut target = AnnotationTarget::new(); + target.upsert_annotation("a".to_string(), anchor(5), vec![]); + target.upsert_annotation("b".to_string(), anchor(10), vec![]); + target.upsert_annotation("a".to_string(), anchor(20), vec![]); + + assert_eq!(target.annotations.len(), 2); + assert_eq!(target.annotations["a"].anchor.start_line(), 20); + assert_eq!(target.annotations["b"].anchor.start_line(), 10); + } + + #[test] + fn delete_annotation_removes_by_id() { + let mut target = AnnotationTarget::new(); + target.upsert_annotation("a".to_string(), anchor(5), vec![]); + target.delete_annotation("a"); + + assert!(target.annotations.is_empty()); + } +} diff --git a/src-tauri/src/snapshots/annot_lib__pipeline__tests__range_stream_snapshot.snap b/src-tauri/src/snapshots/annot_lib__pipeline__tests__range_stream_snapshot.snap new file mode 100644 index 00000000..985a7492 --- /dev/null +++ b/src-tauri/src/snapshots/annot_lib__pipeline__tests__range_stream_snapshot.snap @@ -0,0 +1,28 @@ +--- +source: src/pipeline.rs +expression: dump(&docs) +--- +=== added.txt [Added] lang=txt +@@ -0,0 +1,2 @@ + · 1 |fresh + · 2 |file +=== deleted.txt [Deleted] lang=txt +@@ -1,2 +0,0 @@ + 1 · |doomed + 2 · |content +=== main.rs [Modified] lang=rs +@@ -2,7 +2,7 @@ fn main() { + 2 2 | let a = 1; + 3 3 | let b = 2; + 4 4 | let c = 3; + 5 · | let d = 4; + · 5 | let d = 40; + 6 6 | let e = 5; + 7 7 | println!("{}", a + b); + 8 8 |} +=== new_name.txt (from old_name.txt) [Renamed { similarity: 100 }] lang=txt +=== noeol.txt [Modified] lang=txt +@@ -1,2 +1,2 @@ + 1 1 |alpha + 2 · |beta + · 2 |beta diff --git a/src-tauri/src/snapshots/annot_lib__pipeline__tests__working_tree_stream_snapshot.snap b/src-tauri/src/snapshots/annot_lib__pipeline__tests__working_tree_stream_snapshot.snap new file mode 100644 index 00000000..ab18f146 --- /dev/null +++ b/src-tauri/src/snapshots/annot_lib__pipeline__tests__working_tree_stream_snapshot.snap @@ -0,0 +1,18 @@ +--- +source: src/pipeline.rs +expression: dump(&docs) +--- +=== bin.dat [Modified] lang=dat unavailable +=== main.rs [Modified] lang=rs +@@ -2,7 +2,7 @@ fn main() { + 2 2 | let a = 1; + 3 3 | let b = 2; + 4 4 | let c = 3; + 5 · | let d = 4; + · 5 | let d = 40; + 6 6 | let e = 5; + 7 7 | println!("{}", a + b); + 8 8 |} +=== untracked.txt [Added] lang=txt +@@ -0,0 +1,1 @@ + · 1 |brand new diff --git a/src-tauri/src/source.rs b/src-tauri/src/source.rs new file mode 100644 index 00000000..7a63ba50 --- /dev/null +++ b/src-tauri/src/source.rs @@ -0,0 +1,380 @@ +//! Content-source seam: `side -> full file text | None`. +//! +//! Fetches *whole files*, cached, never gap slices — every later unfold is a +//! local slice. Consumed by the diff pipeline (loads both sides) and unfold +//! IPC (slices gap lines). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; + +use crate::error::AnnotError; +use crate::vcs::BlobRef; + +/// Which side of a diff content belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Side { + Old, + New, +} + +/// Files larger than this are treated as unavailable — the unfold affordance +/// simply won't render for them. +const MAX_FILE_SIZE: u64 = 1024 * 1024; + +pub trait FileSource: Send + Sync { + /// Full text of the file at `path` on `side`. + /// + /// `Ok(None)` means unavailable: raw patch mode, binary or oversize + /// content, or the side doesn't exist (e.g. `Old` of an added file). + /// `Ok(None)` is the capability signal — the UI derives "can unfold?" + /// from it. Current implementations degrade to `Ok(None)` rather than + /// erroring; the `Result` exists for future sources whose failures + /// callers should surface. + fn full_text(&self, path: &str, side: Side) -> Result>, AnnotError>; +} + +/// Raw patch mode: only the patch text exists, full files are never available. +pub struct RawPatchSource; + +impl FileSource for RawPatchSource { + fn full_text(&self, _path: &str, _side: Side) -> Result>, AnnotError> { + Ok(None) + } +} + +/// Serves full file texts from a git repo via gix: blobs from the object +/// database, working-tree content from raw fs reads. +pub struct GixSource { + /// `gix::Repository` is `Send` but `!Sync`; `FileSource` needs `Sync`. + /// A held repository keeps its pack caches warm across lookups. + repo: Mutex, + workdir: Option, + /// (path, side) -> blob reference. Entry absent = side nonexistent + /// (e.g. `Old` of an added file). Built by the pipeline from + /// `FileEntry.{old_oid,new_oid}`. + oids: HashMap<(String, Side), BlobRef>, + /// Blobs keyed by oid (content-addressed, never stale); working-tree + /// files keyed by `"wt:{path}"` — stale if the file changes mid-session, + /// which is fine: the session sees one consistent snapshot. + cache: Mutex>>, +} + +impl GixSource { + pub fn new(repo: gix::Repository, oids: HashMap<(String, Side), BlobRef>) -> Self { + Self { + workdir: repo.workdir().map(|p| p.to_path_buf()), + repo: Mutex::new(repo), + oids, + cache: Mutex::new(HashMap::new()), + } + } + + /// Raw bytes, deliberately unfiltered (no smudge/CRLF normalization): + /// display should show the real file, and the diff engine handles CRLF. + fn read_working_tree(&self, path: &str) -> Option> { + let full = self.workdir.as_ref()?.join(path); + let meta = std::fs::metadata(&full).ok()?; + if meta.len() > MAX_FILE_SIZE { + return None; + } + std::fs::read(&full).ok() + } + + fn read_blob(&self, hex: &str) -> Option> { + let id = gix::ObjectId::from_hex(hex.as_bytes()).ok()?; + let repo = self.repo.lock(); + let header = repo.find_header(id).ok()?; + if header.kind() != gix::object::Kind::Blob || header.size() > MAX_FILE_SIZE { + return None; + } + let object = repo.find_object(id).ok()?; + Some(object.detach().data) + } +} + +/// Binary gate shared by blob and working-tree reads: a NUL byte means binary +/// (NUL is valid UTF-8, so this isn't subsumed by the UTF-8 check); invalid +/// UTF-8 also yields `None`. +fn bytes_to_text(bytes: Vec) -> Option> { + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok().map(Arc::from) +} + +impl FileSource for GixSource { + fn full_text(&self, path: &str, side: Side) -> Result>, AnnotError> { + let Some(blob_ref) = self.oids.get(&(path.to_string(), side)) else { + return Ok(None); + }; + // Never hold `cache` and `repo` at once: check cache, unlock, fetch, + // re-lock to insert. + let (cache_key, bytes) = match blob_ref { + BlobRef::WorkingTree => { + let key = format!("wt:{path}"); + if let Some(hit) = self.cache.lock().get(&key) { + return Ok(Some(hit.clone())); + } + (key, self.read_working_tree(path)) + } + BlobRef::Oid(hex) => { + if let Some(hit) = self.cache.lock().get(hex.as_str()) { + return Ok(Some(hit.clone())); + } + (hex.clone(), self.read_blob(hex)) + } + }; + let Some(text) = bytes.and_then(bytes_to_text) else { + return Ok(None); + }; + self.cache.lock().insert(cache_key, text.clone()); + Ok(Some(text)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::{git, hash_object}; + use proptest::prelude::*; + use std::path::Path; + + /// Two commits + one uncommitted file: + /// - commit 1: modified.txt v1, deleted.txt, old_name.txt, big.txt (>1 MB), bin.dat + /// - commit 2: modified.txt v2, added.txt, rm deleted.txt, mv old_name -> new_name + /// - working tree: wt.txt (uncommitted) + fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + git(p, &["init"]); + std::fs::write(p.join("modified.txt"), "one\ntwo\n").unwrap(); + std::fs::write(p.join("deleted.txt"), "doomed\n").unwrap(); + std::fs::write(p.join("old_name.txt"), "renamed content\n").unwrap(); + std::fs::write(p.join("big.txt"), "x".repeat(1_100_000)).unwrap(); + std::fs::write(p.join("bin.dat"), b"\x00\x01binary").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "one"]); + std::fs::write(p.join("modified.txt"), "one\nTWO\n").unwrap(); + std::fs::write(p.join("added.txt"), "fresh\n").unwrap(); + git(p, &["rm", "-q", "deleted.txt"]); + git(p, &["mv", "old_name.txt", "new_name.txt"]); + git(p, &["add", "."]); + git(p, &["commit", "-m", "two"]); + std::fs::write(p.join("wt.txt"), "working tree\n").unwrap(); + dir + } + + fn source(dir: &tempfile::TempDir) -> GixSource { + let p = dir.path(); + let oid = |rev_path: &str| BlobRef::Oid(git(p, &["rev-parse", rev_path])); + let mut oids = HashMap::new(); + oids.insert( + ("modified.txt".into(), Side::Old), + oid("HEAD~1:modified.txt"), + ); + oids.insert(("modified.txt".into(), Side::New), oid("HEAD:modified.txt")); + oids.insert(("deleted.txt".into(), Side::Old), oid("HEAD~1:deleted.txt")); + oids.insert(("added.txt".into(), Side::New), oid("HEAD:added.txt")); + oids.insert( + ("old_name.txt".into(), Side::Old), + oid("HEAD~1:old_name.txt"), + ); + oids.insert(("new_name.txt".into(), Side::New), oid("HEAD:new_name.txt")); + oids.insert(("big.txt".into(), Side::Old), oid("HEAD~1:big.txt")); + oids.insert(("bin.dat".into(), Side::Old), oid("HEAD~1:bin.dat")); + oids.insert(("wt.txt".into(), Side::New), BlobRef::WorkingTree); + oids.insert( + ("bogus.txt".into(), Side::New), + BlobRef::Oid("deadbeef".repeat(5)), + ); + oids.insert( + ("garbage-hex.txt".into(), Side::New), + BlobRef::Oid("not-a-hex-oid".into()), + ); + GixSource::new(gix::discover(p).unwrap(), oids) + } + + fn text(src: &impl FileSource, path: &str, side: Side) -> Option> { + src.full_text(path, side).unwrap() + } + + #[test] + fn raw_patch_source_is_always_none() { + assert!(text(&RawPatchSource, "anything.txt", Side::Old).is_none()); + assert!(text(&RawPatchSource, "anything.txt", Side::New).is_none()); + } + + #[test] + fn added_deleted_modified_renamed_matrix() { + let dir = fixture(); + let src = source(&dir); + + assert!(text(&src, "added.txt", Side::Old).is_none()); + assert_eq!( + text(&src, "added.txt", Side::New).as_deref(), + Some("fresh\n") + ); + + assert_eq!( + text(&src, "deleted.txt", Side::Old).as_deref(), + Some("doomed\n") + ); + assert!(text(&src, "deleted.txt", Side::New).is_none()); + + assert_eq!( + text(&src, "modified.txt", Side::Old).as_deref(), + Some("one\ntwo\n") + ); + assert_eq!( + text(&src, "modified.txt", Side::New).as_deref(), + Some("one\nTWO\n") + ); + + assert_eq!( + text(&src, "old_name.txt", Side::Old).as_deref(), + Some("renamed content\n") + ); + assert_eq!( + text(&src, "new_name.txt", Side::New).as_deref(), + Some("renamed content\n") + ); + } + + #[test] + fn oversize_is_none() { + let dir = fixture(); + let src = source(&dir); + assert!(text(&src, "big.txt", Side::Old).is_none()); + // a normal lookup afterwards still works + assert_eq!( + text(&src, "modified.txt", Side::Old).as_deref(), + Some("one\ntwo\n") + ); + } + + #[test] + fn size_cap_boundary() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + git(p, &["init"]); + let at_cap = "x".repeat(MAX_FILE_SIZE as usize); + let mut oids = HashMap::new(); + oids.insert( + ("at-cap.txt".into(), Side::New), + BlobRef::Oid(hash_object(p, at_cap.as_bytes())), + ); + oids.insert( + ("over-cap.txt".into(), Side::New), + BlobRef::Oid(hash_object( + p, + "x".repeat(MAX_FILE_SIZE as usize + 1).as_bytes(), + )), + ); + let src = GixSource::new(gix::discover(p).unwrap(), oids); + assert!(text(&src, "over-cap.txt", Side::New).is_none()); + assert_eq!( + text(&src, "at-cap.txt", Side::New).as_deref(), + Some(at_cap.as_str()) + ); + } + + #[test] + fn empty_file_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + git(p, &["init"]); + let mut oids = HashMap::new(); + oids.insert( + ("empty.txt".into(), Side::New), + BlobRef::Oid(hash_object(p, b"")), + ); + let src = GixSource::new(gix::discover(p).unwrap(), oids); + assert_eq!(text(&src, "empty.txt", Side::New).as_deref(), Some("")); + } + + #[test] + fn binary_is_none() { + let dir = fixture(); + let src = source(&dir); + assert!(text(&src, "bin.dat", Side::Old).is_none()); + } + + #[test] + fn working_tree_reads() { + let dir = fixture(); + let src = source(&dir); + assert_eq!( + text(&src, "wt.txt", Side::New).as_deref(), + Some("working tree\n") + ); + } + + #[test] + fn missing_oid_and_unmapped_path_are_none() { + let dir = fixture(); + let src = source(&dir); + assert!(text(&src, "bogus.txt", Side::New).is_none()); // oid not in odb + assert!(text(&src, "garbage-hex.txt", Side::New).is_none()); // unparseable oid + assert!(text(&src, "never-mapped.txt", Side::New).is_none()); // no map entry + assert!(text(&src, "deleted.txt", Side::New).is_none()); // absent side + } + + #[test] + fn cache_returns_the_same_allocation() { + let dir = fixture(); + let src = source(&dir); + let a = text(&src, "modified.txt", Side::New).unwrap(); + let b = text(&src, "modified.txt", Side::New).unwrap(); + assert!(Arc::ptr_eq(&a, &b)); + + let wa = text(&src, "wt.txt", Side::New).unwrap(); + let wb = text(&src, "wt.txt", Side::New).unwrap(); + assert!(Arc::ptr_eq(&wa, &wb)); + } + + fn blob_content() -> impl Strategy> { + prop_oneof![ + // arbitrary bytes: binary, invalid UTF-8, NULs, no trailing newline + proptest::collection::vec(any::(), 0..512), + // valid UTF-8 + ".*".prop_map(String::into_bytes), + ] + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(16))] + + /// Blobs round-trip byte-exact through the odb; NUL-bearing or + /// invalid-UTF-8 content gates to None. + #[test] + fn odb_roundtrips_arbitrary_contents( + contents in proptest::collection::vec(blob_content(), 1..6) + ) { + let dir = tempfile::tempdir().unwrap(); + let p: &Path = dir.path(); + git(p, &["init"]); + let mut oids = HashMap::new(); + for (i, bytes) in contents.iter().enumerate() { + oids.insert( + (format!("f{i}"), Side::New), + BlobRef::Oid(hash_object(p, bytes)), + ); + } + let src = GixSource::new(gix::discover(p).unwrap(), oids); + for (i, bytes) in contents.iter().enumerate() { + let got = src.full_text(&format!("f{i}"), Side::New).unwrap(); + let expected = if bytes.contains(&0) { + None + } else { + std::str::from_utf8(bytes).ok() + }; + prop_assert_eq!(got.as_deref(), expected); + } + } + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b77f7739..acd85567 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,4 +1,7 @@ use std::collections::{HashMap, HashSet}; +use std::ops::Range; +use std::path::Path; +use std::sync::Arc; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -44,10 +47,7 @@ impl Default for TagUsage { impl TagUsageStats { /// Increment usage for a tag, optionally with a language. pub fn increment(&mut self, tag_id: &str, language: Option<&str>) { - let usage = self - .tags - .entry(tag_id.to_string()) - .or_insert_with(TagUsage::default); + let usage = self.tags.entry(tag_id.to_string()).or_default(); usage.count += 1; usage.last_used = Utc::now(); if let Some(lang) = language { @@ -56,12 +56,14 @@ impl TagUsageStats { } } -use crate::diff::{self, DiffMetadata}; +use crate::diff; use crate::error::AnnotError; use crate::highlight::Highlighter; use crate::input::ContentSource; use crate::markdown::{self, html_escape, MarkdownMetadata, MarkdownSemantics}; use crate::portal::{self, LoadedPortal, MAX_PORTALS}; +use crate::source::{FileSource, GixSource, RawPatchSource}; +use crate::vcs::{DiffTarget, FileStatus}; // ============================================================================= // Unified line model (LineOrigin + LineSemantics) @@ -79,15 +81,6 @@ pub enum LineOrigin { /// 1-indexed line number in the source file. line: u32, }, - /// Line from a diff (maps to old/new file versions). - Diff { - /// Path to the file in the diff. - path: String, - /// Line number in old file (None if added line or header). - old_line: Option, - /// Line number in new file (None if deleted line or header). - new_line: Option, - }, /// Synthetic line with no source (portal headers/footers, decorators). Virtual, } @@ -99,23 +92,11 @@ pub enum LineSemantics { #[default] Plain, Markdown(MarkdownSemantics), - Diff(DiffSemantics), Portal(PortalSemantics), } // MarkdownSemantics is imported from crate::markdown -/// Diff line semantics. -#[derive(Clone, Debug, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum DiffSemantics { - FileHeader, - HunkHeader { context: Option }, - Added, - Deleted, - Context, -} - /// Portal line semantics. #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -189,15 +170,35 @@ impl Tag { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ContentNode { - Text { text: String }, - Tag { id: String, name: String, instruction: String }, - Media { image: String, mime_type: String }, - Excalidraw { elements: String, image: Option }, - Replace { original: String, replacement: String }, + Text { + text: String, + }, + Tag { + id: String, + name: String, + instruction: String, + }, + Media { + image: String, + mime_type: String, + }, + Excalidraw { + elements: String, + image: Option, + }, + Replace { + original: String, + replacement: String, + }, /// System-generated error node (e.g., Mermaid syntax error). - Error { source: String, message: String }, + Error { + source: String, + message: String, + }, /// Pasted text content collapsed into a chip (large paste). - Paste { content: String }, + Paste { + content: String, + }, /// Unified reference (annotation or heading). /// Supports referencing other annotations within the session. Ref { @@ -249,32 +250,6 @@ pub enum RefSnapshot { Heading(HeadingRefSnapshot), } -/// A normalized line range (start ≤ end). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct LineRange { - pub start: u32, - pub end: u32, -} - -impl LineRange { - /// Create a normalized range (swaps if start > end). - #[must_use] - pub fn new(a: u32, b: u32) -> Self { - Self { - start: a.min(b), - end: a.max(b), - } - } -} - -/// An annotation attached to a line range. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Annotation { - pub start_line: u32, - pub end_line: u32, - pub content: Vec, -} - /// Where an exit mode was defined. /// /// ```text @@ -366,23 +341,81 @@ impl ExitMode { #[derive(Clone)] pub struct ContentModel { pub label: String, - pub lines: Vec, + pub view: ContentView, pub source: ContentSource, + /// Non-diff extras (markdown sections/code blocks); `Plain` for diffs — + /// mode discrimination lives on `view`. pub metadata: ContentMetadata, /// Loaded portals for file registration (empty for non-markdown content). pub portals: Vec, + /// Source of full file texts backing the lines. `RawPatchSource` (always + /// `None`) except git-mode diffs, whose `GixSource` must stay alive for + /// the session — unfold slices it; `Ok(None)` is the capability signal. + pub file_source: Arc, } /// Type-safe representation of content-specific metadata. -/// Replaces the two Option fields that were mutually exclusive. #[derive(Clone, Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentMetadata { Plain, - Diff(DiffMetadata), Markdown(MarkdownMetadata), } +// ════════════════════════════════════════════════════════════════════════════ +// CONTENT VIEW — the serialized shape of the content itself +// ════════════════════════════════════════════════════════════════════════════ + +/// The wire shape of the content: a flat line stream (file/markdown/content +/// modes) or per-file diff documents. Mode discrimination = `view.type`. +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentView { + Flat { lines: Vec }, + Diff { documents: Vec }, +} + +/// One file's comparison in a diff view. Document order is producer order; +/// `FileKey::diff_file(index)` indexes into `documents` — the annotation +/// identity contract. +#[derive(Clone, Debug, Serialize)] +pub struct DiffDocument { + /// Display identity: new name (old for deleted files). + pub path: String, + /// Present ⇒ the file was renamed/copied; render as "old → new". + pub old_path: Option, + pub status: FileStatus, + /// Binary/oversize/non-UTF-8 — no rows to show, badge instead. + pub unavailable: bool, + pub language: String, + pub hunks: Vec, +} + +/// A hunk owning its rows — unfold extends one hunk's `rows` and ranges. +/// +/// Ranges are half-open, 1-indexed, in git-printed convention: an empty side +/// prints the line *before* the position (`0..0` for a new file's old side), +/// so `@@ -{start},{len} +{start},{len} @@` reads off the range verbatim. +#[derive(Clone, Debug, Serialize)] +pub struct HunkV2 { + pub old_range: Range, + pub new_range: Range, + pub function_context: Option, + pub function_context_html: Option, + pub rows: Vec, +} + +/// One diff row. Side pattern is the kind: old-only = deleted, new-only = +/// added, both = context. +#[derive(Clone, Debug, Serialize)] +pub struct Row { + pub old_line: Option, + pub new_line: Option, + /// Raw source line — no `+`/`-`/` ` prefix; the sign is presentation. + pub content: String, + pub html: Option, +} + /// Per-file metadata for annotation targets. /// Contains file-level info that's NOT content (e.g., language for syntax highlighting). #[derive(Clone, Debug, Default, Serialize)] @@ -398,8 +431,6 @@ pub struct FileMetadata { /// Session state: mutable data during annotation session. #[derive(Default)] pub struct SessionState { - /// Annotations keyed by normalized line range. - pub annotations: HashMap, /// Session-level comment (not tied to specific lines). pub comment: Option>, /// Currently selected exit mode ID (None if no mode selected). @@ -629,12 +660,12 @@ pub struct AppState { #[derive(Serialize)] pub struct ContentResponse { pub label: String, - pub lines: Vec, + pub view: ContentView, pub tags: Vec, pub exit_modes: Vec, pub selected_exit_mode_id: Option, pub session_comment: Option>, - /// Content-specific metadata (diff info, markdown sections, or plain). + /// Non-diff extras (markdown sections, or plain). pub metadata: ContentMetadata, /// Whether image paste is allowed (MCP mode only). pub allows_image_paste: bool, @@ -673,121 +704,68 @@ impl ContentModel { Self { label, - lines, + view: ContentView::Flat { lines }, source, metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: Arc::new(RawPatchSource), } } - /// Parse diff content into structured lines with diff metadata. - #[must_use] + /// Parse diff content into per-file documents. pub fn from_diff(content: &str, source: ContentSource) -> Result { let label = source.label().to_string(); - let mut diff_metadata = diff::parse_diff(content)?; let highlighter = Highlighter::new(); + let documents = diff::parse_diff(content, &highlighter)?; - // Highlight function contexts in hunk headers - for file in &mut diff_metadata.files { - let fake_path = format!("file.{}", file.language); - for hunk in &mut file.hunks { - if let Some(ref ctx) = hunk.function_context { - let html = highlighter.highlight_snippet(ctx, &fake_path); - if !html.is_empty() { - hunk.function_context_html = Some(html); - } - } - } - } - - // For diffs, we create lines from the raw content - // Each line gets its display number (1-indexed) - let lines: Vec = content - .lines() - .enumerate() - .map(|(i, line_content)| { - let line_num = (i + 1) as u32; - - // Get file language for this line from diff metadata - let language = diff_metadata - .lines - .get(&line_num) - .and_then(|info| diff_metadata.files.get(info.file_index)) - .map(|f| f.language.as_str()) - .unwrap_or(""); - - // Only highlight non-header lines with actual code - let html = if !language.is_empty() - && !line_content.starts_with("diff ") - && !line_content.starts_with("---") - && !line_content.starts_with("+++") - && !line_content.starts_with("@@") - && !line_content.starts_with("index ") - { - // Strip the +/- prefix for highlighting, then add it back - let (prefix, code) = if line_content.starts_with('+') - || line_content.starts_with('-') - || line_content.starts_with(' ') - { - (&line_content[..1], &line_content[1..]) - } else { - ("", line_content) - }; - - let fake_path = format!("file.{}", language); - let highlighted = highlighter.highlight_lines(code, &fake_path); - highlighted.first().map(|h| format!("{}{}", prefix, h)) - } else { - None - }; - - // Get diff line info for origin and semantics - let diff_info = diff_metadata.lines.get(&line_num); - - let (origin, semantics) = match diff_info { - Some(info) => { - // Get the file path from the diff file info - let file_path = diff_metadata - .files - .get(info.file_index) - .and_then(|f| f.new_name.as_ref().or(f.old_name.as_ref())) - .cloned() - .unwrap_or_default(); - - let origin = LineOrigin::Diff { - path: file_path, - old_line: info.old_line_num, - new_line: info.new_line_num, - }; - let semantics = LineSemantics::Diff(match info.kind { - diff::DiffLineKind::Context => DiffSemantics::Context, - diff::DiffLineKind::Added => DiffSemantics::Added, - diff::DiffLineKind::Deleted => DiffSemantics::Deleted, - diff::DiffLineKind::Header => DiffSemantics::FileHeader, - }); - (origin, semantics) - } - None => { - // Lines not in diff metadata (shouldn't happen, but fallback) - (LineOrigin::Virtual, LineSemantics::Plain) - } - }; + Ok(Self { + label, + view: ContentView::Diff { documents }, + source, + metadata: ContentMetadata::Plain, + portals: Vec::new(), + file_source: Arc::new(RawPatchSource), + }) + } - Line { - content: line_content.to_string(), - html: html.map(LineHtml::Full), - origin, - semantics, - } - }) - .collect(); + /// Render a git diff in-process: enumerate → `GixSource` full texts → + /// computed hunks → the same per-file documents `from_diff` produces + /// from patch text. The model retains the `GixSource` so the session + /// keeps the full texts (unfold and re-diff need them). + pub fn from_git( + cwd: &Path, + target: &DiffTarget, + pathspecs: &[String], + source: ContentSource, + ) -> Result { + let repo = crate::vcs::discover(cwd)?; + let entries = crate::vcs::enumerate_in(&repo, target, pathspecs)?; + if entries.is_empty() { + return Err(AnnotError::Diff(format!( + "no changes to review for {}", + target.label() + ))); + } + let file_source = Arc::new(GixSource::new( + repo, + crate::pipeline::build_oid_map(&entries), + )); + let highlighter = Highlighter::new(); + let documents = crate::pipeline::render( + &entries, + file_source.as_ref(), + &highlighter, + crate::pipeline::CONTEXT_LINES, + )?; + let label = source.label().to_string(); Ok(Self { label, - lines, + view: ContentView::Diff { documents }, source, - metadata: ContentMetadata::Diff(diff_metadata), + metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source, }) } @@ -952,7 +930,10 @@ impl ContentModel { use std::collections::BTreeMap; let mut portals_by_line: BTreeMap> = BTreeMap::new(); for portal in &loaded_portals { - portals_by_line.entry(portal.insert_at).or_default().push(portal); + portals_by_line + .entry(portal.insert_at) + .or_default() + .push(portal); } // Interleave in reverse order (highest insert_at first) to preserve indices @@ -976,29 +957,31 @@ impl ContentModel { Self { label, - lines, + view: ContentView::Flat { lines }, source, metadata: ContentMetadata::Markdown(md_metadata), portals: loaded_portals, + file_source: Arc::new(RawPatchSource), } } - /// Find a line by its source path and line number. + /// The flat line stream — file/markdown/content modes. Empty for diffs, + /// whose content lives in `ContentView::Diff` documents. + pub fn flat_lines(&self) -> &[Line] { + match &self.view { + ContentView::Flat { lines } => lines, + ContentView::Diff { .. } => &[], + } + } + + /// Find a flat line by its source path and line number. /// /// This searches the lines array by `LineOrigin` rather than by array index, /// which is necessary because portal lines are interleaved at their markdown /// insertion point, not at their source file line number position. pub fn find_line(&self, path: &str, line_num: u32) -> Option<&Line> { - self.lines.iter().find(|l| match &l.origin { + self.flat_lines().iter().find(|l| match &l.origin { LineOrigin::Source { path: p, line } => p == path && *line == line_num, - LineOrigin::Diff { - path: p, - new_line, - old_line, - } => { - // Match by path and line number (prefer new_line, fallback to old_line) - p == path && (new_line == &Some(line_num) || old_line == &Some(line_num)) - } LineOrigin::Virtual => false, }) } @@ -1020,12 +1003,13 @@ impl AppState { Self { content: ContentModel { label: String::new(), - lines: Vec::new(), + view: ContentView::Flat { lines: Vec::new() }, source: ContentSource::Cli(CliSource::Stdin { label: String::new(), }), metadata: ContentMetadata::Plain, portals: Vec::new(), + file_source: Arc::new(RawPatchSource), }, session: SessionState::default(), config: UserConfig::empty(), @@ -1036,7 +1020,7 @@ impl AppState { pub fn to_response(&self) -> ContentResponse { ContentResponse { label: self.content.label.clone(), - lines: self.content.lines.clone(), + view: self.content.view.clone(), tags: self.config.tags().to_vec(), exit_modes: self.config.exit_modes().to_vec(), selected_exit_mode_id: self.session.selected_exit_mode_id.clone(), @@ -1045,24 +1029,6 @@ impl AppState { allows_image_paste: self.content.source.allows_image_paste(), } } - - /// Insert or update an annotation. - pub fn upsert_annotation(&mut self, start_line: u32, end_line: u32, content: Vec) { - let key = LineRange::new(start_line, end_line); - self.session.annotations.insert( - key, - Annotation { - start_line: key.start, - end_line: key.end, - content, - }, - ); - } - - /// Delete an annotation by range. - pub fn delete_annotation(&mut self, start_line: u32, end_line: u32) { - self.session.annotations.remove(&LineRange::new(start_line, end_line)); - } } #[cfg(test)] @@ -1099,14 +1065,38 @@ mod tests { AppState::new(content_model, UserConfig::empty()) } + fn flat(response: &ContentResponse) -> &[Line] { + match &response.view { + ContentView::Flat { lines } => lines, + ContentView::Diff { .. } => panic!("expected a flat view"), + } + } + + fn documents(view: &ContentView) -> &[DiffDocument] { + match view { + ContentView::Diff { documents } => documents, + ContentView::Flat { .. } => panic!("expected a diff view"), + } + } + #[test] fn content_response_has_1_indexed_line_numbers() { let state = test_state("a\nb\nc", "test.rs"); let response = state.to_response(); - assert!(matches!(response.lines[0].origin, LineOrigin::Source { line: 1, .. })); - assert!(matches!(response.lines[1].origin, LineOrigin::Source { line: 2, .. })); - assert!(matches!(response.lines[2].origin, LineOrigin::Source { line: 3, .. })); + let lines = flat(&response); + assert!(matches!( + lines[0].origin, + LineOrigin::Source { line: 1, .. } + )); + assert!(matches!( + lines[1].origin, + LineOrigin::Source { line: 2, .. } + )); + assert!(matches!( + lines[2].origin, + LineOrigin::Source { line: 3, .. } + )); } #[test] @@ -1122,8 +1112,8 @@ mod tests { let state = test_state(" indented\n\ttabbed", "test.rs"); let response = state.to_response(); - assert_eq!(response.lines[0].content, " indented"); - assert_eq!(response.lines[1].content, "\ttabbed"); + assert_eq!(flat(&response)[0].content, " indented"); + assert_eq!(flat(&response)[1].content, "\ttabbed"); } #[test] @@ -1132,8 +1122,8 @@ mod tests { let response = state.to_response(); // Should have HTML highlighting for Rust - assert!(response.lines[0].html.is_some()); - let html = match response.lines[0].html.as_ref().unwrap() { + assert!(flat(&response)[0].html.is_some()); + let html = match flat(&response)[0].html.as_ref().unwrap() { LineHtml::Full(s) => s.as_str(), LineHtml::Cells(_) => panic!("Expected Full HTML for source file"), }; @@ -1148,7 +1138,7 @@ mod tests { let response = state.to_response(); // Plain text should still have html (just escaped text) - assert_eq!(response.lines.len(), 2); + assert_eq!(flat(&response).len(), 2); } #[test] @@ -1201,56 +1191,40 @@ mod tests { "#; #[test] - fn from_diff_creates_state_with_metadata() { + fn from_diff_creates_documents() { let state = test_diff_state(SIMPLE_DIFF, test_diff_source("changes.diff")); - match &state.content.metadata { - ContentMetadata::Diff(meta) => { - assert_eq!(meta.files.len(), 1); - assert_eq!(meta.files[0].new_name, Some("file.rs".to_string())); - } - _ => panic!("Expected Diff metadata"), - } + let docs = documents(&state.content.view); + assert_eq!(docs.len(), 1); + assert_eq!(docs[0].path, "file.rs"); + assert_eq!(docs[0].hunks.len(), 1); } #[test] - fn from_diff_creates_lines_from_content() { + fn from_diff_rows_carry_sides_and_raw_content() { let state = test_diff_state(SIMPLE_DIFF, test_diff_source("changes.diff")); - // Should have lines matching the diff content - assert!(!state.content.lines.is_empty()); - - // First line should be the diff header - assert!(state.content.lines[0].content.starts_with("diff --git")); + let rows = &documents(&state.content.view)[0].hunks[0].rows; + assert_eq!(rows[0].old_line, Some(1), "row numbers are 1-indexed"); - // Check that +/- lines are preserved - let has_added = state.content.lines.iter().any(|l| l.content.starts_with('+')); - let has_deleted = state.content.lines.iter().any(|l| l.content.starts_with('-')); - assert!(has_added, "Should have added lines"); - assert!(has_deleted, "Should have deleted lines"); + let deleted = rows.iter().find(|r| r.new_line.is_none()).unwrap(); + assert_eq!(deleted.content, " old_code();", "content is raw"); + assert!(rows.iter().any(|r| r.old_line.is_none()), "has added rows"); } #[test] - fn from_diff_line_numbers_are_1_indexed() { - let state = test_diff_state(SIMPLE_DIFF, test_diff_source("changes.diff")); - - // Diff lines have LineOrigin::Diff with old_line/new_line info - // Just verify lines exist and have Diff origin - assert!(matches!(state.content.lines[0].origin, LineOrigin::Diff { .. })); - assert!(matches!(state.content.lines[1].origin, LineOrigin::Diff { .. })); - } - - #[test] - fn from_diff_response_includes_metadata() { + fn from_diff_response_serializes_the_diff_view() { let state = test_diff_state(SIMPLE_DIFF, test_diff_source("changes.diff")); let response = state.to_response(); - assert!(matches!(response.metadata, ContentMetadata::Diff(_))); + assert!(matches!(response.view, ContentView::Diff { .. })); + assert!(matches!(response.metadata, ContentMetadata::Plain)); } #[test] fn from_diff_error_on_invalid_content() { - let result = ContentModel::from_diff("just regular text", test_diff_source("not-a-diff.txt")); + let result = + ContentModel::from_diff("just regular text", test_diff_source("not-a-diff.txt")); assert!(result.is_err()); } @@ -1269,7 +1243,12 @@ mod tests { let content_model = ContentModel::from_diff(SIMPLE_DIFF, source).unwrap(); let config = UserConfig::with_data( vec![Tag::new("TEST".into(), "instruction".into())], - vec![ExitMode::new("Apply".into(), "#22c55e".into(), "Apply it".into(), 0)], + vec![ExitMode::new( + "Apply".into(), + "#22c55e".into(), + "Apply it".into(), + 0, + )], ); let state = AppState::new(content_model, config); @@ -1283,7 +1262,7 @@ mod tests { let diff_with_doc_comment = r#"diff --git a/lib.rs b/lib.rs --- a/lib.rs +++ b/lib.rs -@@ -1,3 +1,4 @@ +@@ -1,3 +1,3 @@ -/// Old doc comment +/// New doc comment fn main() { @@ -1292,39 +1271,35 @@ mod tests { let state = test_diff_state(diff_with_doc_comment, test_diff_source("changes.diff")); - println!("\n=== DIFF DOC COMMENT LINES ==="); - for (i, line) in state.content.lines.iter().enumerate() { - println!("Line {}: content={:?}", i + 1, line.content); - if let Some(ref html) = line.html { - let html_str = match html { - LineHtml::Full(s) => s.as_str(), - LineHtml::Cells(cells) => { - println!(" cells={:?}", cells); - continue; - } - }; - println!(" html={:?}", html_str); - // Check for newlines - if html_str.contains('\n') { - println!(" WARNING: HTML contains newline!"); - } - } - } - println!("=== END ===\n"); - - // Find the deleted doc comment line - let deleted_line = state.content.lines.iter().find(|l| l.content.starts_with("-///")).unwrap(); - assert!(deleted_line.html.is_some(), "Deleted doc comment should have HTML"); - let html = match deleted_line.html.as_ref().unwrap() { + // Find the deleted doc comment row + let deleted_row = documents(&state.content.view)[0] + .hunks + .iter() + .flat_map(|h| &h.rows) + .find(|r| r.content.starts_with("///") && r.new_line.is_none()) + .unwrap(); + assert!( + deleted_row.html.is_some(), + "Deleted doc comment should have HTML" + ); + let html = match deleted_row.html.as_ref().unwrap() { LineHtml::Full(s) => s.as_str(), LineHtml::Cells(_) => panic!("Expected Full HTML, got Cells"), }; // HTML should not contain newlines - assert!(!html.contains('\n'), "HTML should not contain newline. Got: {:?}", html); + assert!( + !html.contains('\n'), + "HTML should not contain newline. Got: {:?}", + html + ); - // HTML should start with the prefix - assert!(html.starts_with('-'), "HTML should start with '-' prefix. Got: {:?}", html); + // HTML carries no textual sign — the sign is presentation. + assert!( + !html.starts_with('-'), + "HTML must not carry a '-' prefix. Got: {:?}", + html + ); } #[test] @@ -1338,8 +1313,7 @@ mod tests { let response = state.to_response(); // Find the data row (row with "highlighted") - let data_row = response - .lines + let data_row = flat(&response) .iter() .find(|l| l.content.contains("highlighted")) .expect("Should have a row with 'highlighted'"); diff --git a/src-tauri/src/testutil.rs b/src-tauri/src/testutil.rs new file mode 100644 index 00000000..2dadeded --- /dev/null +++ b/src-tauri/src/testutil.rs @@ -0,0 +1,59 @@ +//! Shared test fixtures: hermetic git helpers for fixture-repo tests. +//! Registered in lib.rs under `#[cfg(test)]` — never compiled into the app. + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output, Stdio}; + +/// Run hermetic git, ignoring system/global config and pinning identity and +/// autocrlf. Unlike [`git`], this returns non-zero exits to the caller. +pub fn git_output(dir: &Path, args: &[&str]) -> Output { + Command::new("git") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", dir.join("no-such-gitconfig")) + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t.io", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + "-c", + "init.defaultBranch=main", + ]) + .args(args) + .current_dir(dir) + .output() + .expect("failed to run git") +} + +/// Hermetic git: asserts success and returns trimmed stdout. +pub fn git(dir: &Path, args: &[&str]) -> String { + let out = git_output(dir, args); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +/// Writes `bytes` as a blob into the repo's object store, returns its oid. +pub fn hash_object(dir: &Path, bytes: &[u8]) -> String { + let mut child = Command::new("git") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", dir.join("no-such-gitconfig")) + .args(["hash-object", "-w", "--stdin"]) + .current_dir(dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to run git"); + child.stdin.take().unwrap().write_all(bytes).unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success(), "git hash-object failed"); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} diff --git a/src-tauri/src/vcs.rs b/src-tauri/src/vcs.rs new file mode 100644 index 00000000..fefd9c42 --- /dev/null +++ b/src-tauri/src/vcs.rs @@ -0,0 +1,1466 @@ +//! Structured diff enumeration backed by gitoxide (`gix`). +//! +//! `review_diff` accepts a `DiffTarget` — not arbitrary git CLI args — so the +//! revision semantics annot owns are exactly three comparisons: worktree vs +//! HEAD, index vs HEAD, and tree vs tree. Everything else (revspec grammar, +//! rename detection thresholds, pathspec magic) is delegated to gix. +//! +//! Non-UTF-8 paths are a hard error — a lossily-converted path would silently +//! fail content lookups downstream, hiding a file from review. Unmerged +//! (conflicted) paths are an error too, never a silent skip. + +use std::collections::BTreeMap; +use std::path::Path; + +use gix::bstr::{BString, ByteSlice}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::error::AnnotError; + +/// What to diff. The MCP schema for `review_diff`'s `target` field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DiffTarget { + /// Worktree vs HEAD: staged + unstaged combined, untracked files included. + WorkingTree, + /// Index vs HEAD. + Staged, + /// Two-revision diff. + Range { + from: String, + to: String, + /// If true, diff from merge_base(from, to) to `to` (like `from...to`). + #[serde(default)] + merge_base: bool, + }, +} + +impl DiffTarget { + /// Display label for the review window. + pub fn label(&self) -> String { + match self { + DiffTarget::WorkingTree => "diff".into(), + DiffTarget::Staged => "staged".into(), + DiffTarget::Range { + from, + to, + merge_base, + } => format!("{from}{}{to}", if *merge_base { "..." } else { ".." }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileStatus { + Modified, + Added, + Deleted, + Renamed { similarity: u8 }, + Copied, + TypeChanged, +} + +/// Serializes as a plain string — the wire doesn't carry `similarity`. +impl serde::Serialize for FileStatus { + fn serialize(&self, s: S) -> Result { + s.serialize_str(match self { + FileStatus::Modified => "modified", + FileStatus::Added => "added", + FileStatus::Deleted => "deleted", + FileStatus::Renamed { .. } => "renamed", + FileStatus::Copied => "copied", + FileStatus::TypeChanged => "type_changed", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BlobRef { + Oid(String), + WorkingTree, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileEntry { + pub status: FileStatus, + /// `None` for added files. + pub old_path: Option, + /// `None` for deleted files. + pub new_path: Option, + /// `None` = old side nonexistent (added file). + pub old_oid: Option, + /// `None` = new side nonexistent (deleted file). + pub new_oid: Option, +} + +/// Locate the enclosing git repository for `cwd`. +pub fn discover(cwd: &Path) -> Result { + gix::discover(cwd).map_err(|e| AnnotError::Diff(format!("failed to open git repository: {e}"))) +} + +/// Enumerate changed files for `target` in the repository containing `cwd`. +pub fn enumerate( + cwd: &Path, + target: &DiffTarget, + pathspecs: &[String], +) -> Result, AnnotError> { + enumerate_in(&discover(cwd)?, target, pathspecs) +} + +/// Enumerate against an already-discovered repository — callers that also +/// read blobs (the render pipeline) discover once and reuse it. +pub fn enumerate_in( + repo: &gix::Repository, + target: &DiffTarget, + pathspecs: &[String], +) -> Result, AnnotError> { + let mut entries = match target { + DiffTarget::Range { + from, + to, + merge_base, + } => range_entries(repo, from, to, *merge_base, pathspecs)?, + DiffTarget::Staged => staged_entries(repo, pathspecs)?, + DiffTarget::WorkingTree => working_tree_entries(repo, pathspecs)?, + }; + // Status items arrive interleaved from two producer threads — the sort is + // mandatory for deterministic output, not cosmetic. + entries.sort_by(|a, b| entry_path(a).cmp(entry_path(b))); + Ok(entries) +} + +fn entry_path(e: &FileEntry) -> &str { + e.new_path + .as_deref() + .or(e.old_path.as_deref()) + .unwrap_or("") +} + +fn diff_err(e: impl std::fmt::Display) -> AnnotError { + AnnotError::Diff(e.to_string()) +} + +fn path_string(loc: &[u8]) -> Result { + std::str::from_utf8(loc) + .map(str::to_string) + .map_err(|_| AnnotError::Diff("non-UTF-8 path in git repository is not supported".into())) +} + +/// Broad type class for TypeChanged detection: exec-bit changes stay +/// Modified; blob <-> symlink <-> submodule flips are TypeChanged (git's `T`). +fn kind_class(kind: gix::objs::tree::EntryKind) -> u8 { + use gix::objs::tree::EntryKind; + match kind { + EntryKind::Blob | EntryKind::BlobExecutable => 0, + EntryKind::Link => 1, + EntryKind::Commit => 2, + EntryKind::Tree => 3, + } +} + +fn tree_mode_class(mode: gix::objs::tree::EntryMode) -> u8 { + kind_class(mode.kind()) +} + +fn index_mode_class(mode: gix::index::entry::Mode) -> Option { + mode.to_tree_entry_mode().map(|m| kind_class(m.kind())) +} + +/// Similarity percentage for a rename/copy detected by the index diff, which +/// (unlike the tree diff) carries no line stats. Identity => 100; otherwise a +/// best-effort content ratio; 50 (the detection threshold) if unreadable. +fn rename_similarity( + repo: &gix::Repository, + source_id: &gix::hash::oid, + id: &gix::hash::oid, +) -> u8 { + if source_id == id { + return 100; + } + let load = |oid: &gix::hash::oid| -> Option { + let data = repo.find_object(oid.to_owned()).ok()?.detach().data; + String::from_utf8(data).ok() + }; + match (load(source_id), load(id)) { + (Some(old), Some(new)) => { + (similar::TextDiff::from_lines(&old, &new).ratio() * 100.0).round() as u8 + } + _ => 50, + } +} + +// --------------------------------------------------------------------------- +// Range: tree vs tree +// --------------------------------------------------------------------------- + +fn peel_to_tree<'r>(id: gix::Id<'r>, rev: &str) -> Result, AnnotError> { + id.object() + .map_err(|e| AnnotError::Diff(format!("failed to load '{rev}': {e}")))? + .peel_to_tree() + .map_err(|e| AnnotError::Diff(format!("'{rev}' does not point to a tree: {e}"))) +} + +fn range_entries( + repo: &gix::Repository, + from: &str, + to: &str, + merge_base: bool, + pathspecs: &[String], +) -> Result, AnnotError> { + let resolve = |rev: &str| { + repo.rev_parse_single(rev) + .map_err(|e| AnnotError::Diff(format!("failed to resolve '{rev}': {e}"))) + }; + let mut from_id = resolve(from)?; + let to_id = resolve(to)?; + if merge_base { + from_id = repo.merge_base(from_id, to_id).map_err(|e| { + AnnotError::Diff(format!( + "failed to compute merge base of '{from}' and '{to}': {e}" + )) + })?; + } + let old_tree = peel_to_tree(from_id, from)?; + let new_tree = peel_to_tree(to_id, to)?; + + let changes = repo + .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None) + .map_err(|e| AnnotError::Diff(format!("tree diff failed: {e}")))?; + + let mut entries = Vec::new(); + for change in changes { + if let Some(entry) = range_entry(change)? { + entries.push(entry); + } + } + filter_by_pathspec(repo, entries, pathspecs) +} + +fn range_entry( + change: gix::object::tree::diff::ChangeDetached, +) -> Result, AnnotError> { + use gix::object::tree::diff::ChangeDetached as Change; + Ok(match change { + Change::Addition { + location, + entry_mode, + id, + .. + } => { + if entry_mode.is_tree() { + return Ok(None); + } + let path = path_string(&location)?; + Some(FileEntry { + status: FileStatus::Added, + old_path: None, + new_path: Some(path), + old_oid: None, + new_oid: Some(BlobRef::Oid(id.to_string())), + }) + } + Change::Deletion { + location, + entry_mode, + id, + .. + } => { + if entry_mode.is_tree() { + return Ok(None); + } + let path = path_string(&location)?; + Some(FileEntry { + status: FileStatus::Deleted, + old_path: Some(path), + new_path: None, + old_oid: Some(id.to_string()), + new_oid: None, + }) + } + Change::Modification { + location, + previous_entry_mode, + previous_id, + entry_mode, + id, + } => { + if entry_mode.is_tree() && previous_entry_mode.is_tree() { + return Ok(None); + } + let path = path_string(&location)?; + let status = if tree_mode_class(previous_entry_mode) != tree_mode_class(entry_mode) { + FileStatus::TypeChanged + } else { + FileStatus::Modified + }; + Some(FileEntry { + status, + old_path: Some(path.clone()), + new_path: Some(path), + old_oid: Some(previous_id.to_string()), + new_oid: Some(BlobRef::Oid(id.to_string())), + }) + } + Change::Rewrite { + source_location, + source_id, + diff, + id, + location, + copy, + .. + } => { + let similarity = diff + .map(|d| (d.similarity * 100.0).round() as u8) + .unwrap_or(100); + let status = if copy { + FileStatus::Copied + } else { + FileStatus::Renamed { similarity } + }; + Some(FileEntry { + status, + old_path: Some(path_string(&source_location)?), + new_path: Some(path_string(&location)?), + old_oid: Some(source_id.to_string()), + new_oid: Some(BlobRef::Oid(id.to_string())), + }) + } + }) +} + +fn filter_by_pathspec( + repo: &gix::Repository, + entries: Vec, + pathspecs: &[String], +) -> Result, AnnotError> { + if pathspecs.is_empty() { + return Ok(entries); + } + let mut search = repo + .pathspec( + true, + pathspecs.iter().map(|s| s.as_str()), + true, + &gix::index::State::new(repo.object_hash()), + gix::worktree::stack::state::attributes::Source::IdMapping, + ) + .map_err(|e| AnnotError::Diff(format!("invalid pathspec: {e}")))?; + let mut included = |path: &Option| { + path.as_deref() + .is_some_and(|p| search.is_included(p.as_bytes().as_bstr(), Some(false))) + }; + Ok(entries + .into_iter() + .filter(|e| included(&e.old_path) || included(&e.new_path)) + .collect()) +} + +// --------------------------------------------------------------------------- +// Staged / WorkingTree: index- and status-based layers + merge +// --------------------------------------------------------------------------- + +/// Reduced, gix-free change descriptions — `merge::merge` is a pure function +/// over these so the combination table is unit-testable without fixtures. +mod merge { + use super::{BlobRef, FileEntry, FileStatus}; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub(super) enum Staged { + Added { + index_oid: String, + intent_to_add: bool, + }, + Modified { + head_oid: String, + index_oid: String, + type_changed: bool, + }, + Deleted { + head_oid: String, + }, + Renamed { + old_path: String, + head_oid: String, + index_oid: String, + similarity: u8, + copy: bool, + }, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(super) enum WtKind { + Modified, + TypeChanged, + Deleted, + IntentToAdd, + Untracked, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub(super) struct Wt { + pub kind: WtKind, + /// The index entry's oid (== HEAD oid when the path has no staged + /// change). `None` for untracked paths. + pub index_oid: Option, + } + + /// Fuse the staged (HEAD vs index) and worktree (index vs worktree) + /// layers into one HEAD-vs-worktree entry for `path`. `None` = no row. + pub(super) fn merge(path: &str, staged: Option, wt: Option) -> Option { + let p = || Some(path.to_string()); + let entry = + |status, old_path: Option, old_oid, new_path: Option, new_oid| { + Some(FileEntry { + status, + old_path, + new_path, + old_oid, + new_oid, + }) + }; + match (staged, wt) { + (None, None) => None, + + // Unstaged-only rows: the index oid IS the HEAD oid (no staged change). + (None, Some(w)) => match w.kind { + WtKind::Modified => entry( + FileStatus::Modified, + p(), + w.index_oid, + p(), + Some(BlobRef::WorkingTree), + ), + WtKind::TypeChanged => entry( + FileStatus::TypeChanged, + p(), + w.index_oid, + p(), + Some(BlobRef::WorkingTree), + ), + WtKind::Deleted => entry(FileStatus::Deleted, p(), w.index_oid, None, None), + WtKind::IntentToAdd | WtKind::Untracked => entry( + FileStatus::Added, + None, + None, + p(), + Some(BlobRef::WorkingTree), + ), + }, + + // Staged-only rows: worktree matches the index, so the new side + // is the real index oid — the pinned `git diff HEAD` semantics. + (Some(s), None) => match s { + Staged::Added { + index_oid, + intent_to_add, + } => { + let new = if intent_to_add { + BlobRef::WorkingTree + } else { + BlobRef::Oid(index_oid) + }; + entry(FileStatus::Added, None, None, p(), Some(new)) + } + Staged::Modified { + head_oid, + index_oid, + type_changed, + } => entry( + if type_changed { + FileStatus::TypeChanged + } else { + FileStatus::Modified + }, + p(), + Some(head_oid), + p(), + Some(BlobRef::Oid(index_oid)), + ), + Staged::Deleted { head_oid } => { + entry(FileStatus::Deleted, p(), Some(head_oid), None, None) + } + Staged::Renamed { + old_path, + head_oid, + index_oid, + similarity, + copy, + } => entry( + if copy { + FileStatus::Copied + } else { + FileStatus::Renamed { similarity } + }, + Some(old_path), + Some(head_oid), + p(), + Some(BlobRef::Oid(index_oid)), + ), + }, + + // Both layers: old side from the staged change, new side from the + // worktree (WorkingTree unless the file is gone). + (Some(s), Some(w)) => { + let wt_deleted = w.kind == WtKind::Deleted; + match s { + Staged::Added { .. } => { + if wt_deleted { + None // added to index, deleted in worktree: absent on both sides + } else { + entry( + FileStatus::Added, + None, + None, + p(), + Some(BlobRef::WorkingTree), + ) + } + } + Staged::Modified { + head_oid, + type_changed, + .. + } => { + if wt_deleted { + entry(FileStatus::Deleted, p(), Some(head_oid), None, None) + } else { + entry( + if type_changed || w.kind == WtKind::TypeChanged { + FileStatus::TypeChanged + } else { + FileStatus::Modified + }, + p(), + Some(head_oid), + p(), + Some(BlobRef::WorkingTree), + ) + } + } + // Staged deletion + untracked recreation: vs HEAD that's a + // modification (git would omit it if content is identical + // — accepted divergence, the diff simply renders empty). + Staged::Deleted { head_oid } => entry( + FileStatus::Modified, + p(), + Some(head_oid), + p(), + Some(BlobRef::WorkingTree), + ), + Staged::Renamed { + old_path, + head_oid, + similarity, + copy, + .. + } => { + if wt_deleted { + entry( + FileStatus::Deleted, + Some(old_path), + Some(head_oid), + None, + None, + ) + } else { + entry( + if copy { + FileStatus::Copied + } else { + FileStatus::Renamed { similarity } + }, + Some(old_path), + Some(head_oid), + p(), + Some(BlobRef::WorkingTree), + ) + } + } + } + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + fn wt(kind: WtKind) -> Option { + Some(Wt { + kind, + index_oid: Some("idx".into()), + }) + } + + fn untracked() -> Option { + Some(Wt { + kind: WtKind::Untracked, + index_oid: None, + }) + } + + fn staged_modified() -> Option { + Some(Staged::Modified { + head_oid: "head".into(), + index_oid: "idx".into(), + type_changed: false, + }) + } + + #[test] + fn nothing_yields_nothing() { + assert_eq!(merge("f", None, None), None); + } + + #[test] + fn unstaged_only_rows() { + let m = merge("f", None, wt(WtKind::Modified)).unwrap(); + assert_eq!(m.status, FileStatus::Modified); + assert_eq!(m.old_oid.as_deref(), Some("idx")); + assert_eq!(m.new_oid, Some(BlobRef::WorkingTree)); + + let d = merge("f", None, wt(WtKind::Deleted)).unwrap(); + assert_eq!(d.status, FileStatus::Deleted); + assert_eq!(d.new_path, None); + assert_eq!(d.new_oid, None); + + let t = merge("f", None, wt(WtKind::TypeChanged)).unwrap(); + assert_eq!(t.status, FileStatus::TypeChanged); + + let u = merge("f", None, untracked()).unwrap(); + assert_eq!(u.status, FileStatus::Added); + assert_eq!(u.old_oid, None); + assert_eq!(u.new_oid, Some(BlobRef::WorkingTree)); + + let ita = merge("f", None, wt(WtKind::IntentToAdd)).unwrap(); + assert_eq!(ita.status, FileStatus::Added); + assert_eq!(ita.new_oid, Some(BlobRef::WorkingTree)); + } + + #[test] + fn staged_only_rows() { + let a = merge( + "f", + Some(Staged::Added { + index_oid: "idx".into(), + intent_to_add: false, + }), + None, + ) + .unwrap(); + assert_eq!(a.status, FileStatus::Added); + assert_eq!(a.new_oid, Some(BlobRef::Oid("idx".into()))); + + // the pinned case: staged change, clean worktree => real index oid + let m = merge("f", staged_modified(), None).unwrap(); + assert_eq!(m.old_oid.as_deref(), Some("head")); + assert_eq!(m.new_oid, Some(BlobRef::Oid("idx".into()))); + + let r = merge( + "new", + Some(Staged::Renamed { + old_path: "old".into(), + head_oid: "head".into(), + index_oid: "idx".into(), + similarity: 100, + copy: false, + }), + None, + ) + .unwrap(); + assert_eq!(r.status, FileStatus::Renamed { similarity: 100 }); + assert_eq!(r.old_path.as_deref(), Some("old")); + assert_eq!(r.new_path.as_deref(), Some("new")); + + let ita = merge( + "f", + Some(Staged::Added { + index_oid: "empty".into(), + intent_to_add: true, + }), + None, + ) + .unwrap(); + assert_eq!(ita.new_oid, Some(BlobRef::WorkingTree)); + } + + #[test] + fn both_layer_rows() { + // staged-A + worktree-M => Added with worktree content + let a = merge( + "f", + Some(Staged::Added { + index_oid: "idx".into(), + intent_to_add: false, + }), + wt(WtKind::Modified), + ) + .unwrap(); + assert_eq!(a.status, FileStatus::Added); + assert_eq!(a.new_oid, Some(BlobRef::WorkingTree)); + + // staged-A + worktree-D => absent on both sides + assert_eq!( + merge( + "f", + Some(Staged::Added { + index_oid: "idx".into(), + intent_to_add: false, + }), + wt(WtKind::Deleted), + ), + None + ); + + // staged-M + worktree-M => one Modified row vs HEAD + let m = merge("f", staged_modified(), wt(WtKind::Modified)).unwrap(); + assert_eq!(m.status, FileStatus::Modified); + assert_eq!(m.old_oid.as_deref(), Some("head")); + assert_eq!(m.new_oid, Some(BlobRef::WorkingTree)); + + // staged-M + worktree-rm => Deleted vs HEAD + let d = merge("f", staged_modified(), wt(WtKind::Deleted)).unwrap(); + assert_eq!(d.status, FileStatus::Deleted); + assert_eq!(d.old_oid.as_deref(), Some("head")); + + // staged-D + untracked recreation => Modified vs HEAD + let re = merge( + "f", + Some(Staged::Deleted { + head_oid: "head".into(), + }), + untracked(), + ) + .unwrap(); + assert_eq!(re.status, FileStatus::Modified); + assert_eq!(re.new_oid, Some(BlobRef::WorkingTree)); + + // staged-R + worktree-M of destination => rename with WT content + let r = merge( + "new", + Some(Staged::Renamed { + old_path: "old".into(), + head_oid: "head".into(), + index_oid: "idx".into(), + similarity: 90, + copy: false, + }), + wt(WtKind::Modified), + ) + .unwrap(); + assert_eq!(r.status, FileStatus::Renamed { similarity: 90 }); + assert_eq!(r.old_path.as_deref(), Some("old")); + assert_eq!(r.new_oid, Some(BlobRef::WorkingTree)); + + // staged-R + worktree-rm of destination => Deleted from old path + let rd = merge( + "new", + Some(Staged::Renamed { + old_path: "old".into(), + head_oid: "head".into(), + index_oid: "idx".into(), + similarity: 100, + copy: false, + }), + wt(WtKind::Deleted), + ) + .unwrap(); + assert_eq!(rd.status, FileStatus::Deleted); + assert_eq!(rd.old_path.as_deref(), Some("old")); + assert_eq!(rd.new_path, None); + } + } +} + +/// Convert one HEAD-vs-index change into `(dest_path, Staged)`. +/// `worktree_index` is consulted for the intent-to-add flag on additions. +fn staged_change( + repo: &gix::Repository, + change: gix::diff::index::Change, + worktree_index: &gix::index::State, +) -> Result<(String, merge::Staged), AnnotError> { + use gix::diff::index::Change; + Ok(match change { + Change::Addition { + location, + index, + id, + .. + } => { + let intent_to_add = worktree_index + .entries() + .get(index) + .is_some_and(|e| e.flags.contains(gix::index::entry::Flags::INTENT_TO_ADD)); + ( + path_string(&location)?, + merge::Staged::Added { + index_oid: id.to_hex().to_string(), + intent_to_add, + }, + ) + } + Change::Deletion { location, id, .. } => ( + path_string(&location)?, + merge::Staged::Deleted { + head_oid: id.to_hex().to_string(), + }, + ), + Change::Modification { + location, + previous_entry_mode, + previous_id, + entry_mode, + id, + .. + } => { + let type_changed = match ( + index_mode_class(previous_entry_mode), + index_mode_class(entry_mode), + ) { + (Some(a), Some(b)) => a != b, + _ => false, + }; + ( + path_string(&location)?, + merge::Staged::Modified { + head_oid: previous_id.to_hex().to_string(), + index_oid: id.to_hex().to_string(), + type_changed, + }, + ) + } + Change::Rewrite { + source_location, + source_id, + location, + id, + copy, + .. + } => { + let similarity = rename_similarity(repo, &source_id, &id); + ( + path_string(&location)?, + merge::Staged::Renamed { + old_path: path_string(&source_location)?, + head_oid: source_id.to_hex().to_string(), + index_oid: id.to_hex().to_string(), + similarity, + copy, + }, + ) + } + }) +} + +fn staged_entries( + repo: &gix::Repository, + pathspecs: &[String], +) -> Result, AnnotError> { + let head_tree = repo.head_tree_id_or_empty().map_err(diff_err)?; + let index = repo.index_or_empty().map_err(diff_err)?; + let mut pathspec = repo + .pathspec( + true, + pathspecs.iter().map(|s| s.as_str()), + true, + &index, + gix::worktree::stack::state::attributes::Source::IdMapping, + ) + .map_err(|e| AnnotError::Diff(format!("invalid pathspec: {e}")))?; + + let mut changes = Vec::new(); + repo.tree_index_status( + &head_tree, + &index, + Some(&mut pathspec), + gix::status::tree_index::TrackRenames::AsConfigured, + |change, _tree_index, worktree_index| -> Result<_, std::convert::Infallible> { + changes.push(staged_change(repo, change.into_owned(), worktree_index)); + Ok(gix::diff::index::Action::Continue(())) + }, + ) + .map_err(diff_err)?; + + let mut entries = Vec::new(); + for change in changes { + let (path, staged) = change?; + // git diff --staged omits intent-to-add entries: nothing is staged yet. + if matches!( + staged, + merge::Staged::Added { + intent_to_add: true, + .. + } + ) { + continue; + } + if let Some(entry) = merge::merge(&path, Some(staged), None) { + entries.push(entry); + } + } + Ok(entries) +} + +fn working_tree_entries( + repo: &gix::Repository, + pathspecs: &[String], +) -> Result, AnnotError> { + use gix::status::plumbing::index_as_worktree::{Change as IwChange, EntryStatus}; + + let iter = repo + .status(gix::progress::Discard) + .map_err(diff_err)? + .untracked_files(gix::status::UntrackedFiles::Files) + .index_worktree_submodules(None) + .into_iter(pathspecs.iter().map(|s| BString::from(s.as_str()))) + .map_err(diff_err)?; + + let mut staged: BTreeMap = BTreeMap::new(); + let mut worktree: BTreeMap = BTreeMap::new(); + // The worktree index isn't accessible per-item here; intent-to-add + // additions are detected via the IndexWorktree layer instead, which + // always reports them as EntryStatus::IntentToAdd. + let empty_index = gix::index::State::new(repo.object_hash()); + + for item in iter { + let item = item.map_err(diff_err)?; + match item { + gix::status::Item::TreeIndex(change) => { + let (path, s) = staged_change(repo, change, &empty_index)?; + staged.insert(path, s); + } + gix::status::Item::IndexWorktree(item) => { + use gix::status::index_worktree::Item; + match item { + Item::Modification { + entry, + rela_path, + status, + .. + } => { + let path = path_string(&rela_path)?; + let kind = match status { + EntryStatus::Conflict { .. } => { + return Err(AnnotError::Diff(format!( + "unmerged path (unresolved conflict): {path}" + ))); + } + EntryStatus::NeedsUpdate(_) => continue, + EntryStatus::IntentToAdd => merge::WtKind::IntentToAdd, + EntryStatus::Change(change) => match change { + IwChange::Removed => merge::WtKind::Deleted, + IwChange::Type { .. } => merge::WtKind::TypeChanged, + IwChange::Modification { .. } => merge::WtKind::Modified, + IwChange::SubmoduleModification(_) => continue, + }, + }; + let index_oid = + (kind != merge::WtKind::IntentToAdd).then(|| entry.id.to_string()); + worktree.insert(path, merge::Wt { kind, index_oid }); + } + Item::DirectoryContents { entry, .. } => { + if entry.status == gix::dir::entry::Status::Untracked + && entry + .disk_kind + .is_some_and(|k| !matches!(k, gix::dir::entry::Kind::Directory)) + { + worktree.insert( + path_string(&entry.rela_path)?, + merge::Wt { + kind: merge::WtKind::Untracked, + index_oid: None, + }, + ); + } + } + Item::Rewrite { .. } => { + // index-worktree rename tracking is disabled; loud is + // better than a silently mis-merged rename. + return Err(AnnotError::Diff( + "unexpected worktree rename item from git status".into(), + )); + } + } + } + } + } + + let mut entries = Vec::new(); + for (path, s) in staged { + let wt = worktree.remove(&path); + if let Some(entry) = merge::merge(&path, Some(s), wt) { + entries.push(entry); + } + } + for (path, wt) in worktree { + if let Some(entry) = merge::merge(&path, None, Some(wt)) { + entries.push(entry); + } + } + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::{git, git_output}; + use std::fs; + + fn strs(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() + } + + fn oid(dir: &Path, rev_path: &str) -> String { + git(dir, &["rev-parse", rev_path]) + } + + const WT: DiffTarget = DiffTarget::WorkingTree; + const STAGED: DiffTarget = DiffTarget::Staged; + + fn range(from: &str, to: &str) -> DiffTarget { + DiffTarget::Range { + from: from.into(), + to: to.into(), + merge_base: false, + } + } + + /// One commit: a.txt, b.txt, old.txt, .gitignore (ignoring ignored.txt). + /// Local `diff.renames=true` pins rename detection regardless of the + /// developer's global config (`enumerate` runs without the hermetic env). + fn repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + git(p, &["init"]); + git(p, &["config", "diff.renames", "true"]); + fs::write(p.join("a.txt"), "alpha\n").unwrap(); + fs::write(p.join("b.txt"), "bravo\n").unwrap(); + fs::write(p.join("old.txt"), "stable rename content\n").unwrap(); + fs::write(p.join(".gitignore"), "ignored.txt\n").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "base"]); + dir + } + + /// `repo()` plus a second commit: modify a.txt, add new.txt, + /// delete b.txt, rename old.txt -> renamed.txt. + fn two_commit_repo() -> tempfile::TempDir { + let dir = repo(); + let p = dir.path(); + fs::write(p.join("a.txt"), "alpha2\n").unwrap(); + fs::write(p.join("new.txt"), "fresh\n").unwrap(); + git(p, &["rm", "-q", "b.txt"]); + git(p, &["mv", "old.txt", "renamed.txt"]); + git(p, &["add", "."]); + git(p, &["commit", "-m", "second"]); + dir + } + + fn find<'a>(entries: &'a [FileEntry], path: &str) -> &'a FileEntry { + entries + .iter() + .find(|e| e.new_path.as_deref() == Some(path) || e.old_path.as_deref() == Some(path)) + .unwrap_or_else(|| panic!("no entry for {path}: {entries:?}")) + } + + // --- DiffTarget --- + + #[test] + fn diff_target_serde_roundtrip() { + let range = DiffTarget::Range { + from: "main".into(), + to: "HEAD".into(), + merge_base: true, + }; + for target in [DiffTarget::WorkingTree, DiffTarget::Staged, range] { + let json = serde_json::to_string(&target).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), target); + } + // wire shape + merge_base default + assert_eq!( + serde_json::from_str::(r#"{"kind":"range","from":"a","to":"b"}"#).unwrap(), + DiffTarget::Range { + from: "a".into(), + to: "b".into(), + merge_base: false, + } + ); + assert_eq!( + serde_json::to_string(&DiffTarget::WorkingTree).unwrap(), + r#"{"kind":"working_tree"}"# + ); + } + + #[test] + fn labels() { + assert_eq!(WT.label(), "diff"); + assert_eq!(STAGED.label(), "staged"); + assert_eq!(range("a", "b").label(), "a..b"); + } + + // --- WorkingTree --- + + #[test] + fn empty_diff_is_empty_vec() { + let dir = repo(); + assert_eq!(enumerate(dir.path(), &WT, &[]).unwrap(), vec![]); + } + + #[test] + fn unstaged_worktree_diff() { + let dir = repo(); + let p = dir.path(); + fs::write(p.join("a.txt"), "alpha2\n").unwrap(); + fs::remove_file(p.join("b.txt")).unwrap(); + let entries = enumerate(p, &WT, &[]).unwrap(); + assert_eq!( + entries, + vec![ + FileEntry { + status: FileStatus::Modified, + old_path: Some("a.txt".into()), + new_path: Some("a.txt".into()), + old_oid: Some(oid(p, "HEAD:a.txt")), + new_oid: Some(BlobRef::WorkingTree), + }, + FileEntry { + status: FileStatus::Deleted, + old_path: Some("b.txt".into()), + new_path: None, + old_oid: Some(oid(p, "HEAD:b.txt")), + new_oid: None, + }, + ] + ); + } + + #[test] + fn staged_change_via_head_shows_index_oid() { + let dir = repo(); + let p = dir.path(); + fs::write(p.join("a.txt"), "alpha2\n").unwrap(); + git(p, &["add", "a.txt"]); + let entries = enumerate(p, &WT, &[]).unwrap(); + // Worktree matches the index, so the new side is the real index oid — + // NOT WorkingTree. The merge keys off layer presence, not mode. + assert_eq!( + entries, + vec![FileEntry { + status: FileStatus::Modified, + old_path: Some("a.txt".into()), + new_path: Some("a.txt".into()), + old_oid: Some(oid(p, "HEAD:a.txt")), + new_oid: Some(BlobRef::Oid(oid(p, ":a.txt"))), + }] + ); + } + + #[test] + fn untracked_files_appear_as_added() { + let dir = repo(); + let p = dir.path(); + fs::write(p.join("brand-new.txt"), "hello\n").unwrap(); + fs::create_dir(p.join("nested")).unwrap(); + fs::write(p.join("nested/inner.txt"), "deep\n").unwrap(); + let entries = enumerate(p, &WT, &[]).unwrap(); + assert_eq!( + find(&entries, "brand-new.txt"), + &FileEntry { + status: FileStatus::Added, + old_path: None, + new_path: Some("brand-new.txt".into()), + old_oid: None, + new_oid: Some(BlobRef::WorkingTree), + } + ); + // UntrackedFiles::Files expands directories to per-file entries + assert_eq!(find(&entries, "nested/inner.txt").status, FileStatus::Added); + } + + #[test] + fn gitignored_files_do_not_appear() { + let dir = repo(); + let p = dir.path(); + fs::write(p.join("ignored.txt"), "invisible\n").unwrap(); + assert_eq!(enumerate(p, &WT, &[]).unwrap(), vec![]); + } + + #[test] + fn staged_plus_worktree_combinations() { + let dir = repo(); + let p = dir.path(); + // staged-A + worktree-M + fs::write(p.join("new.txt"), "v1\n").unwrap(); + git(p, &["add", "new.txt"]); + fs::write(p.join("new.txt"), "v2\n").unwrap(); + // staged-M + worktree-rm + fs::write(p.join("a.txt"), "staged\n").unwrap(); + git(p, &["add", "a.txt"]); + fs::remove_file(p.join("a.txt")).unwrap(); + // staged-R + worktree-M of destination + git(p, &["mv", "old.txt", "moved.txt"]); + fs::write(p.join("moved.txt"), "stable rename content\nplus\n").unwrap(); + + let entries = enumerate(p, &WT, &[]).unwrap(); + assert_eq!( + find(&entries, "new.txt"), + &FileEntry { + status: FileStatus::Added, + old_path: None, + new_path: Some("new.txt".into()), + old_oid: None, + new_oid: Some(BlobRef::WorkingTree), + } + ); + assert_eq!( + find(&entries, "a.txt"), + &FileEntry { + status: FileStatus::Deleted, + old_path: Some("a.txt".into()), + new_path: None, + old_oid: Some(oid(p, "HEAD:a.txt")), + new_oid: None, + } + ); + let moved = find(&entries, "moved.txt"); + assert!( + matches!(moved.status, FileStatus::Renamed { .. }), + "{moved:?}" + ); + assert_eq!(moved.old_path.as_deref(), Some("old.txt")); + assert_eq!(moved.new_oid, Some(BlobRef::WorkingTree)); + } + + #[test] + fn conflict_is_an_error() { + let dir = repo(); + let p = dir.path(); + git(p, &["switch", "-c", "side"]); + fs::write(p.join("a.txt"), "side\n").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "side"]); + git(p, &["switch", "main"]); + fs::write(p.join("a.txt"), "main\n").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "main"]); + let out = git_output(p, &["merge", "side"]); + assert_eq!( + out.status.code(), + Some(1), + "git merge did not produce a conflict: {}", + String::from_utf8_lossy(&out.stderr) + ); + let err = enumerate(p, &WT, &[]).unwrap_err(); + assert!(err.to_string().contains("unmerged"), "{err}"); + } + + // --- Staged --- + + #[test] + fn staged_diff() { + let dir = repo(); + let p = dir.path(); + fs::write(p.join("a.txt"), "alpha2\n").unwrap(); + fs::write(p.join("new.txt"), "fresh\n").unwrap(); + git(p, &["rm", "-q", "b.txt"]); + git(p, &["mv", "old.txt", "renamed.txt"]); + git(p, &["add", "."]); + let entries = enumerate(p, &STAGED, &[]).unwrap(); + assert_eq!(entries.len(), 4); + assert_eq!( + find(&entries, "a.txt"), + &FileEntry { + status: FileStatus::Modified, + old_path: Some("a.txt".into()), + new_path: Some("a.txt".into()), + old_oid: Some(oid(p, "HEAD:a.txt")), + new_oid: Some(BlobRef::Oid(oid(p, ":a.txt"))), + } + ); + assert_eq!( + find(&entries, "new.txt"), + &FileEntry { + status: FileStatus::Added, + old_path: None, + new_path: Some("new.txt".into()), + old_oid: None, + new_oid: Some(BlobRef::Oid(oid(p, ":new.txt"))), + } + ); + assert_eq!( + find(&entries, "b.txt"), + &FileEntry { + status: FileStatus::Deleted, + old_path: Some("b.txt".into()), + new_path: None, + old_oid: Some(oid(p, "HEAD:b.txt")), + new_oid: None, + } + ); + assert_eq!( + find(&entries, "renamed.txt"), + &FileEntry { + status: FileStatus::Renamed { similarity: 100 }, + old_path: Some("old.txt".into()), + new_path: Some("renamed.txt".into()), + old_oid: Some(oid(p, "HEAD:old.txt")), + new_oid: Some(BlobRef::Oid(oid(p, ":renamed.txt"))), + } + ); + } + + #[test] + fn paths_with_spaces_and_unicode() { + let dir = repo(); + let p = dir.path(); + let tricky = "spa ce δοκιμή 试.txt"; + git(p, &["mv", "old.txt", tricky]); + let entries = enumerate(p, &STAGED, &[]).unwrap(); + assert_eq!( + entries, + vec![FileEntry { + status: FileStatus::Renamed { similarity: 100 }, + old_path: Some("old.txt".into()), + new_path: Some(tricky.into()), + old_oid: Some(oid(p, "HEAD:old.txt")), + new_oid: Some(BlobRef::Oid(oid(p, "HEAD:old.txt"))), + }] + ); + } + + #[cfg(unix)] + #[test] + fn typechange() { + let dir = repo(); + let p = dir.path(); + fs::remove_file(p.join("a.txt")).unwrap(); + std::os::unix::fs::symlink("b.txt", p.join("a.txt")).unwrap(); + git(p, &["add", "a.txt"]); + let entries = enumerate(p, &STAGED, &[]).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].status, FileStatus::TypeChanged); + assert_eq!(entries[0].old_path.as_deref(), Some("a.txt")); + assert_eq!(entries[0].new_path.as_deref(), Some("a.txt")); + } + + // --- Range --- + + #[test] + fn rev_range() { + let dir = two_commit_repo(); + let p = dir.path(); + let entries = enumerate(p, &range("HEAD~1", "HEAD"), &[]).unwrap(); + assert_eq!(entries.len(), 4); + assert_eq!( + find(&entries, "a.txt").old_oid, + Some(oid(p, "HEAD~1:a.txt")) + ); + assert_eq!( + find(&entries, "a.txt").new_oid, + Some(BlobRef::Oid(oid(p, "HEAD:a.txt"))) + ); + assert_eq!(find(&entries, "new.txt").status, FileStatus::Added); + assert_eq!(find(&entries, "b.txt").status, FileStatus::Deleted); + assert_eq!( + find(&entries, "renamed.txt"), + &FileEntry { + status: FileStatus::Renamed { similarity: 100 }, + old_path: Some("old.txt".into()), + new_path: Some("renamed.txt".into()), + old_oid: Some(oid(p, "HEAD~1:old.txt")), + new_oid: Some(BlobRef::Oid(oid(p, "HEAD:renamed.txt"))), + } + ); + } + + #[test] + fn merge_base_range() { + let dir = repo(); + let p = dir.path(); + git(p, &["switch", "-c", "feature"]); + fs::write(p.join("feature.txt"), "feat\n").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "feature work"]); + git(p, &["switch", "main"]); + fs::write(p.join("a.txt"), "moved ahead\n").unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "main moved on"]); + + // plain two-dot from main..feature includes main's later change (as a + // reverse-modification); merge-base mode shows only the branch's work + let plain = enumerate(p, &range("main", "feature"), &[]).unwrap(); + assert!(plain.iter().any(|e| e.new_path.as_deref() == Some("a.txt"))); + + let mb = enumerate( + p, + &DiffTarget::Range { + from: "main".into(), + to: "feature".into(), + merge_base: true, + }, + &[], + ) + .unwrap(); + assert_eq!(mb.len(), 1); + assert_eq!(mb[0].new_path.as_deref(), Some("feature.txt")); + assert_eq!(mb[0].status, FileStatus::Added); + } + + #[test] + fn pathspec_filter() { + let dir = two_commit_repo(); + let p = dir.path(); + let entries = enumerate(p, &range("HEAD~1", "HEAD"), &strs(&["a.txt"])).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].new_path.as_deref(), Some("a.txt")); + } + + #[test] + fn pathspec_filter_working_tree() { + let dir = repo(); + let p = dir.path(); + fs::write(p.join("a.txt"), "alpha2\n").unwrap(); + fs::write(p.join("b.txt"), "bravo2\n").unwrap(); + let entries = enumerate(p, &WT, &strs(&["b.txt"])).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].new_path.as_deref(), Some("b.txt")); + } + + #[test] + fn copied_status_via_config() { + let dir = repo(); + let p = dir.path(); + git(p, &["config", "diff.renames", "copies"]); + let content: String = (1..=10).map(|i| format!("line {i}\n")).collect(); + fs::write(p.join("src.txt"), &content).unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "add src"]); + // copy detection sources come from the modified set, so modify the + // source file in the same change-set (no --find-copies-harder analog); + // multi-line content keeps similarity above the 50% threshold + fs::write(p.join("src-copy.txt"), &content).unwrap(); + fs::write(p.join("src.txt"), content.replace("line 1\n", "line one\n")).unwrap(); + git(p, &["add", "."]); + git(p, &["commit", "-m", "copy"]); + let entries = enumerate(p, &range("HEAD~1", "HEAD"), &[]).unwrap(); + let copy = find(&entries, "src-copy.txt"); + assert_eq!(copy.status, FileStatus::Copied); + assert_eq!(copy.old_path.as_deref(), Some("src.txt")); + } + + #[test] + fn bogus_rev_is_err() { + let dir = repo(); + let err = enumerate(dir.path(), &range("no-such-rev-zzz", "HEAD"), &[]).unwrap_err(); + assert!(err.to_string().contains("no-such-rev-zzz"), "{err}"); + } + + #[test] + fn non_repo_dir_is_err() { + let dir = tempfile::tempdir().unwrap(); + let err = enumerate(dir.path(), &WT, &[]).unwrap_err(); + assert!(err.to_string().contains("repository"), "{err}"); + } +} diff --git a/src-tauri/src/window_state.rs b/src-tauri/src/window_state.rs index d74657ac..ba5b2075 100644 --- a/src-tauri/src/window_state.rs +++ b/src-tauri/src/window_state.rs @@ -242,8 +242,13 @@ fn is_position_on_monitor(app: &AppHandle, x: i32, y: i32) -> bool { monitors.iter().any(|monitor| { let pos = monitor.position(); let size = monitor.size(); - let (left, top, right, bottom) = - monitor_compare_bounds(pos.x, pos.y, size.width, size.height, monitor.scale_factor()); + let (left, top, right, bottom) = monitor_compare_bounds( + pos.x, + pos.y, + size.width, + size.height, + monitor.scale_factor(), + ); point_in_rect(x, y, left, top, right, bottom) }) } @@ -306,8 +311,13 @@ fn saved_monitor(app: &AppHandle, window_type: WindowType) -> Option { monitors.into_iter().find(|monitor| { let pos = monitor.position(); let size = monitor.size(); - let (left, top, right, bottom) = - monitor_compare_bounds(pos.x, pos.y, size.width, size.height, monitor.scale_factor()); + let (left, top, right, bottom) = monitor_compare_bounds( + pos.x, + pos.y, + size.width, + size.height, + monitor.scale_factor(), + ); point_in_rect(state.x, state.y, left, top, right, bottom) }) } @@ -396,8 +406,8 @@ fn load_state(app: &AppHandle, window_type: WindowType) -> Option { /// Save state for a window type under the current display configuration. fn save_state(app: &AppHandle, window_type: WindowType, state: &WindowState) -> Result<(), String> { - let config_id = - display_config_id(app).ok_or_else(|| "no connected monitors to key state under".to_string())?; + let config_id = display_config_id(app) + .ok_or_else(|| "no connected monitors to key state under".to_string())?; let mut file = load_state_file(app); file.configs @@ -515,7 +525,10 @@ mod tests { #[test] fn center_in_centers() { // 1000x700 window on a 1512x982 monitor at the origin. - assert_eq!(center_in(0.0, 0.0, 1512.0, 982.0, 1000.0, 700.0), (256.0, 141.0)); + assert_eq!( + center_in(0.0, 0.0, 1512.0, 982.0, 1000.0, 700.0), + (256.0, 141.0) + ); // Same window on a monitor offset to logical x=1512. assert_eq!( center_in(1512.0, 0.0, 2560.0, 1440.0, 1000.0, 700.0), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 25728206..364164c0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "annot", - "version": "0.13.1", + "version": "0.14.0", "identifier": "com.denolehov.annot", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/lib/AnnotationEditor.svelte b/src/lib/AnnotationEditor.svelte index 79880514..c30773b4 100644 --- a/src/lib/AnnotationEditor.svelte +++ b/src/lib/AnnotationEditor.svelte @@ -22,7 +22,7 @@ | { type: 'Cancelled' }; interface ExcalidrawResult { - range_key: string; + annotation_id: string; node_ref: NodeRef; outcome: ExcalidrawOutcome; } @@ -108,13 +108,14 @@ annotationEntries?: Record; allowsImagePaste?: boolean; onImagePasteBlocked?: () => void; + onFileRefCopied?: (path: string) => void; onRequestCreateTag?: (text: string, from: number, to: number) => void; pendingTagInsertion?: { from: number; to: number; tag: Tag } | null; - rangeKey?: string; // Annotation line range key like "45-52" + annotationId?: string; // Annotation id (saved entry or draft); '' for the session editor getOriginalLines?: () => string; // Returns original lines content for /replace } - let { content, onUpdate, sealed = false, onUnseal, onDismiss, tags = [], annotationEntries = {}, allowsImagePaste = false, onImagePasteBlocked, onRequestCreateTag, pendingTagInsertion, rangeKey = '', getOriginalLines }: Props = $props(); + let { content, onUpdate, sealed = false, onUnseal, onDismiss, tags = [], annotationEntries = {}, allowsImagePaste = false, onImagePasteBlocked, onFileRefCopied, onRequestCreateTag, pendingTagInsertion, annotationId = '', getOriginalLines }: Props = $props(); // Get zoom level from context for floating elements const ctx = getAnnotContext(); @@ -134,7 +135,7 @@ getSealed: () => sealed, getTags: () => tags, getAnnotationEntries: () => annotationEntries, - getCurrentRangeKey: () => rangeKey, + getCurrentId: () => annotationId, getAllowsImagePaste: () => allowsImagePaste, getOnUpdate: () => onUpdate, getOnDismiss: () => () => onDismiss?.(), @@ -163,7 +164,7 @@ // Derive excalidraw open state from modal lock const isExcalidrawOpen = $derived( ctx.interaction.modalLock?.kind === 'excalidraw' && - ctx.interaction.modalLock.editorKey === rangeKey + ctx.interaction.modalLock.editorKey === annotationId ); // Sync Excalidraw window state with composable (prevents blur dismiss) @@ -216,11 +217,11 @@ // Open Excalidraw window for creating new diagram async function openExcalidrawCreate(placeholderId: string) { - ctx.interaction.setModalLock({ kind: 'excalidraw', editorKey: rangeKey }); + ctx.interaction.setModalLock({ kind: 'excalidraw', editorKey: annotationId }); try { await invoke('open_excalidraw_window', { elements: '[]', - rangeKey: rangeKey, + annotationId, nodeRef: { type: 'Placeholder', id: placeholderId }, }); } catch (e) { @@ -231,11 +232,11 @@ // Open Excalidraw window for editing existing diagram async function openExcalidrawEdit(nodeId: string, elements: string) { - ctx.interaction.setModalLock({ kind: 'excalidraw', editorKey: rangeKey }); + ctx.interaction.setModalLock({ kind: 'excalidraw', editorKey: annotationId }); try { await invoke('open_excalidraw_window', { elements: elements || '[]', - rangeKey: rangeKey, + annotationId, nodeRef: { type: 'Chip', id: nodeId }, }); } catch (e) { @@ -247,7 +248,7 @@ // Handle Excalidraw result from window function handleExcalidrawResult(result: ExcalidrawResult) { // Only handle results for our annotation - if (result.range_key !== rangeKey) return; + if (result.annotation_id !== annotationId) return; ctx.interaction.setModalLock(null); @@ -315,7 +316,7 @@ // Check if we have an active excalidraw modal for this editor if (ctx.interaction.modalLock?.kind === 'excalidraw' && - ctx.interaction.modalLock.editorKey === rangeKey) { + ctx.interaction.modalLock.editorKey === annotationId) { // Close the orphaned excalidraw window try { await invoke('close_excalidraw_by_placeholder', { @@ -341,8 +342,14 @@ openExcalidrawEdit(detail.nodeId, detail.elements); }; + const handleFileRefCopied = (e: Event) => { + const detail = (e as CustomEvent).detail as { path: string }; + onFileRefCopied?.(detail.path); + }; + element?.addEventListener('excalidraw-create', handleExcalidrawCreate); element?.addEventListener('excalidraw-edit', handleExcalidrawEdit); + element?.addEventListener('file-ref-copied', handleFileRefCopied); // Listen for placeholder destruction (dispatched on document) document.addEventListener('excalidraw-placeholder-destroyed', handlePlaceholderDestroyed); @@ -356,8 +363,8 @@ // Listen for mermaid button requests to open excalidraw // This allows mermaid to tap into TipTap's fresh state instead of stale annotationState - listen<{ rangeKey: string }>('mermaid-open-excalidraw', (event) => { - if (event.payload.rangeKey !== rangeKey) return; + listen<{ annotationId: string }>('mermaid-open-excalidraw', (event) => { + if (event.payload.annotationId !== annotationId) return; // Find excalidraw chip in TipTap's current state if (!ann.editor) return; @@ -392,6 +399,7 @@ return () => { element?.removeEventListener('excalidraw-create', handleExcalidrawCreate); element?.removeEventListener('excalidraw-edit', handleExcalidrawEdit); + element?.removeEventListener('file-ref-copied', handleFileRefCopied); document.removeEventListener('excalidraw-placeholder-destroyed', handlePlaceholderDestroyed); }; }); @@ -430,6 +438,15 @@ } selectionDebounceTimer = setTimeout(() => { + // The doc may have changed under the debounce (e.g. select-all → + // delete) — the captured positions can point past the end of the + // current doc. Re-read the live selection instead. + if (editor.isDestroyed) return; + const { from, to, empty } = editor.state.selection; + if (empty || to - from < 2) { + selectionPopover = null; + return; + } const text = editor.state.doc.textBetween(from, to, ' '); const coords = editor.view.coordsAtPos(from); const endCoords = editor.view.coordsAtPos(to); diff --git a/src/lib/CommandPalette/CommandPalette.svelte b/src/lib/CommandPalette/CommandPalette.svelte index 0d745a90..03b1c530 100644 --- a/src/lib/CommandPalette/CommandPalette.svelte +++ b/src/lib/CommandPalette/CommandPalette.svelte @@ -4,10 +4,11 @@ import { invoke } from '@tauri-apps/api/core'; import { openUrl } from '@tauri-apps/plugin-opener'; import { reduce, computeItemList } from './engine/reducer'; - import { createQueryContext, setTagItems, setExitModeItems, saveTagItem, deleteTagItem, saveExitModeItem, deleteExitModeItem, reorderExitModeItems, generateTagId, generateExitModeId, setObsidianVaults, saveObsidianVault, deleteObsidianVault, getVaultNames, generateVaultId } from './namespaces'; + import { createQueryContext, setTagItems, setExitModeItems, setFileItems, saveTagItem, deleteTagItem, saveExitModeItem, deleteExitModeItem, reorderExitModeItems, generateTagId, generateExitModeId, setObsidianVaults, saveObsidianVault, deleteObsidianVault, getVaultNames, generateVaultId } from './namespaces'; import type { State, Action, Command, Item, Namespace, InitialState } from './engine/types'; import { getFilterPlaceholder, canDelete, isItemEditable } from './engine/types'; import type { Tag, ExitMode } from '$lib/types'; + import type { DocView } from '$lib/display-rows'; import Icon from './Icon.svelte'; // Config type matching Rust @@ -20,6 +21,7 @@ interface Props { tags: Tag[]; exitModes: ExitMode[]; + files?: DocView[]; zoomLevel?: number; onClose: () => void; onSetExitMode: (modeId: string) => void; @@ -32,7 +34,7 @@ onEvent?: (event: string, payload: unknown) => void; } - let { tags, exitModes, zoomLevel = 1, onClose, onSetExitMode, onTagsChange, onExitModesChange, showToast, onOpenSaveModal, initialState, onItemCreated, onEvent }: Props = $props(); + let { tags, exitModes, files = [], zoomLevel = 1, onClose, onSetExitMode, onTagsChange, onExitModesChange, showToast, onOpenSaveModal, initialState, onItemCreated, onEvent }: Props = $props(); // Convert domain types to Item format function tagToItem(tag: Tag): Item { @@ -67,6 +69,10 @@ setExitModeItems(exitModes.map(exitModeToItem)); }); + $effect(() => { + setFileItems(files); + }); + // State machine let machineState: State = $state({ type: 'IDLE' }); let ctx = $derived(createQueryContext()); diff --git a/src/lib/CommandPalette/Icon.svelte b/src/lib/CommandPalette/Icon.svelte index 38dd0467..1cab7875 100644 --- a/src/lib/CommandPalette/Icon.svelte +++ b/src/lib/CommandPalette/Icon.svelte @@ -22,7 +22,8 @@ ChatBubbleIcon, HeadingH1Icon, HeadingH2Icon, - HeadingH3Icon + HeadingH3Icon, + FileIcon } from '$lib/icons'; interface Props { @@ -55,7 +56,8 @@ 'chat-bubble': ChatBubbleIcon, 'heading-h1': HeadingH1Icon, 'heading-h2': HeadingH2Icon, - 'heading-h3': HeadingH3Icon + 'heading-h3': HeadingH3Icon, + file: FileIcon }; const IconComponent = $derived(icons[name]); diff --git a/src/lib/CommandPalette/namespaces/files.test.ts b/src/lib/CommandPalette/namespaces/files.test.ts new file mode 100644 index 00000000..608c956b --- /dev/null +++ b/src/lib/CommandPalette/namespaces/files.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { setFileItems, getFileItems, filterFileItems } from './files'; +import { createQueryContext } from './index'; +import type { DocView } from '$lib/display-rows'; + +function entry(index: number, path: string, headerDisplayIndex: number): DocView { + const slash = path.lastIndexOf('/'); + return { + index, + doc: { path, old_path: null, status: 'modified', unavailable: false, language: '', hunks: [] }, + path, + dir: path.slice(0, slash + 1), + name: path.slice(slash + 1), + added: 1, + deleted: 0, + headerDisplayIndex, + endDisplayIndex: headerDisplayIndex + 5, + hunks: [], + }; +} + +describe('files namespace', () => { + beforeEach(() => { + setFileItems([]); + }); + + it('turns each file into a jump action carrying its display index', () => { + setFileItems([entry(0, 'src/lib/types.ts', 12)]); + + expect(getFileItems()[0]).toMatchObject({ + id: 'file-0', + name: 'src/lib/types.ts', + action: { type: 'EMIT_EVENT', event: 'JUMP_TO_FILE', payload: 12 }, + }); + }); + + it('fuzzy-matches on the full path', () => { + setFileItems([entry(0, 'src/lib/types.ts', 1), entry(1, 'src-tauri/src/diff.rs', 40)]); + + expect(filterFileItems('lib/typ').map((i) => i.name)).toEqual(['src/lib/types.ts']); + }); + + it('is hidden from the palette when there are no files', () => { + expect(createQueryContext().namespaces.map((n) => n.id)).not.toContain('files'); + + setFileItems([entry(0, 'a.ts', 1)]); + + expect(createQueryContext().namespaces.map((n) => n.id)).toContain('files'); + }); +}); diff --git a/src/lib/CommandPalette/namespaces/files.ts b/src/lib/CommandPalette/namespaces/files.ts new file mode 100644 index 00000000..7d26608b --- /dev/null +++ b/src/lib/CommandPalette/namespaces/files.ts @@ -0,0 +1,37 @@ +// Files namespace for CommandPalette +// Action-only namespace — items jump the viewport to a file in the diff + +import type { Namespace, Item } from '../engine/types'; +import type { DocView } from '$lib/display-rows'; +import { fuzzySearch } from '$lib/fuzzy'; +import { SimpleItem } from '../items'; + +export const filesNamespace: Namespace = { + id: 'files', + label: 'Files', + icon: 'file', + ItemComponent: SimpleItem, + fields: [], + hotkeys: [], + capabilities: { delete: false }, +}; + +// Seeded from the session's diff display walk; empty for non-diff content +let fileItems: Item[] = []; + +export function setFileItems(docs: DocView[]): void { + fileItems = docs.map((dv) => ({ + id: `file-${dv.index}`, + name: dv.path, + values: {}, + action: { type: 'EMIT_EVENT' as const, event: 'JUMP_TO_FILE', payload: dv.headerDisplayIndex }, + })); +} + +export function getFileItems(): Item[] { + return fileItems; +} + +export function filterFileItems(query: string): Item[] { + return fuzzySearch(fileItems, query, [{ name: 'name', weight: 1 }]); +} diff --git a/src/lib/CommandPalette/namespaces/index.ts b/src/lib/CommandPalette/namespaces/index.ts index 09029914..128aeed8 100644 --- a/src/lib/CommandPalette/namespaces/index.ts +++ b/src/lib/CommandPalette/namespaces/index.ts @@ -8,11 +8,13 @@ import { copyNamespace, getCopyItems, filterCopyItems } from './copy'; import { saveNamespace, getSaveItems, filterSaveItems } from './save'; import { obsidianNamespace, getObsidianItems, filterObsidianItems } from './obsidian'; import { themeNamespace, getThemeItems, filterThemeItems } from './theme'; +import { filesNamespace, getFileItems, filterFileItems } from './files'; -const namespaces: Namespace[] = [tagsNamespace, exitModesNamespace, copyNamespace, obsidianNamespace, saveNamespace, themeNamespace]; +const namespaces: Namespace[] = [tagsNamespace, exitModesNamespace, filesNamespace, copyNamespace, obsidianNamespace, saveNamespace, themeNamespace]; const getItemsMap: Record Item[]> = { tags: getTagItems, + files: getFileItems, 'exit-modes': getExitModeItems, copy: getCopyItems, save: getSaveItems, @@ -22,6 +24,7 @@ const getItemsMap: Record Item[]> = { const filterItemsMap: Record Item[]> = { tags: filterTagItems, + files: filterFileItems, 'exit-modes': filterExitModeItems, copy: filterCopyItems, save: filterSaveItems, @@ -29,12 +32,20 @@ const filterItemsMap: Record Item[]> = { theme: filterThemeItems, }; +/** Files only exist in diff sessions — don't surface an empty namespace elsewhere. */ +function activeNamespaces(): Namespace[] { + return namespaces.filter((n) => n.id !== 'files' || getFileItems().length > 0); +} + export function createQueryContext(): QueryContext { return { - namespaces, + // Getter, not a snapshot: items are seeded after this context is built. + get namespaces() { + return activeNamespaces(); + }, filterNamespaces(query: string): Namespace[] { - return fuzzySearch(namespaces, query, [{ name: 'label', weight: 1 }]); + return fuzzySearch(activeNamespaces(), query, [{ name: 'label', weight: 1 }]); }, getItems(namespace: Namespace) { @@ -54,3 +65,4 @@ export { copyNamespace, getCopyItems, filterCopyItems } from './copy'; export { saveNamespace, getSaveItems, filterSaveItems } from './save'; export { obsidianNamespace, getObsidianItems, filterObsidianItems, setObsidianVaults, saveObsidianVault, deleteObsidianVault, getVaultNames, generateVaultId, getRawVaultItems } from './obsidian'; export { themeNamespace, getThemeItems, filterThemeItems } from './theme'; +export { filesNamespace, getFileItems, setFileItems, filterFileItems } from './files'; diff --git a/src/lib/HelpOverlay.svelte b/src/lib/HelpOverlay.svelte index 146e5424..cdee5f66 100644 --- a/src/lib/HelpOverlay.svelte +++ b/src/lib/HelpOverlay.svelte @@ -55,6 +55,7 @@ { category: 'View', items: [ + { keys: [keys.cmd, 'B'], description: 'Toggle file tree (diffs)' }, { keys: [keys.cmd, '+'], description: 'Zoom in' }, { keys: [keys.cmd, '-'], description: 'Zoom out' }, { keys: [keys.cmd, '0'], description: 'Reset zoom' }, diff --git a/src/lib/anchor.test.ts b/src/lib/anchor.test.ts new file mode 100644 index 00000000..d8e1573f --- /dev/null +++ b/src/lib/anchor.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { selectionToAnchor, endpointKeys, anchorKeys, anchorLines, anchorLabel, type Anchor } from './anchor'; +import type { Line } from './types'; + +// Helper to create mock lines +function makeLine(origin: Line['origin']): Line { + return { + content: 'test', + html: null, + origin, + semantics: { type: 'plain' }, + }; +} + +describe('selectionToAnchor', () => { + it('builds a source anchor from file mode lines', () => { + const lines: Line[] = [ + makeLine({ type: 'source', path: 'test.rs', line: 10 }), + makeLine({ type: 'source', path: 'test.rs', line: 11 }), + makeLine({ type: 'source', path: 'test.rs', line: 12 }), + ]; + + const anchor = selectionToAnchor({ start: 1, end: 3 }, lines); + expect(anchor).toEqual({ + type: 'source', + path: 'test.rs', + start: 10, + end: 12, + }); + }); + + it('returns null for virtual lines', () => { + const lines: Line[] = [ + makeLine({ type: 'virtual' }), + makeLine({ type: 'source', path: 'test.rs', line: 10 }), + ]; + + // Range starts at virtual line + const anchor = selectionToAnchor({ start: 1, end: 2 }, lines); + expect(anchor).toBeNull(); + }); + + it('returns null for out of bounds range', () => { + const lines: Line[] = [ + makeLine({ type: 'source', path: 'test.rs', line: 10 }), + ]; + + const anchor = selectionToAnchor({ start: 1, end: 5 }, lines); + expect(anchor).toBeNull(); + }); + + it('returns null when lines have different paths', () => { + const lines: Line[] = [ + makeLine({ type: 'source', path: 'file1.rs', line: 10 }), + makeLine({ type: 'source', path: 'file2.rs', line: 11 }), + ]; + + const anchor = selectionToAnchor({ start: 1, end: 2 }, lines); + expect(anchor).toBeNull(); + }); + + it('returns null when line numbers have gap > 1', () => { + const lines: Line[] = [ + makeLine({ type: 'source', path: 'test.rs', line: 10 }), + makeLine({ type: 'source', path: 'test.rs', line: 15 }), // gap of 5 + ]; + + const anchor = selectionToAnchor({ start: 1, end: 2 }, lines); + expect(anchor).toBeNull(); + }); + + it('normalizes source line order', () => { + // Lines might be in display order but source lines could be reversed + // (though unusual, the function handles it) + const lines: Line[] = [ + makeLine({ type: 'source', path: 'test.rs', line: 15 }), + makeLine({ type: 'source', path: 'test.rs', line: 14 }), + ]; + + const anchor = selectionToAnchor({ start: 1, end: 2 }, lines); + expect(anchor).toEqual({ + type: 'source', + path: 'test.rs', + start: 14, + end: 15, + }); + }); +}); + +describe('endpointKeys', () => { + it('registers one side-less key for source lines', () => { + const keys = endpointKeys(makeLine({ type: 'source', path: 'a.rs', line: 7 })); + expect(keys).toHaveLength(1); + }); + + it('registers nothing for virtual lines', () => { + expect(endpointKeys(makeLine({ type: 'virtual' }))).toEqual([]); + }); + + it('resolves source anchors against the keys lines register', () => { + const sourceLine = makeLine({ type: 'source', path: 'a.rs', line: 7 }); + const source: Anchor = { type: 'source', path: 'a.rs', start: 7, end: 7 }; + expect(endpointKeys(sourceLine)).toContain(anchorKeys(source)[0]); + }); +}); + +describe('anchorLines / anchorLabel', () => { + it('extracts lines from both variants', () => { + expect(anchorLines({ type: 'source', path: 'a', start: 3, end: 7 })).toEqual({ start: 3, end: 7 }); + expect( + anchorLines({ type: 'diff', path: 'a', start: { side: 'old', line: 2 }, end: { side: 'new', line: 4 } }) + ).toEqual({ start: 2, end: 4 }); + }); + + it('labels single-line and multi-line anchors', () => { + expect(anchorLabel({ type: 'source', path: 'a', start: 5, end: 5 })).toBe('5'); + expect(anchorLabel({ type: 'source', path: 'a', start: 5, end: 9 })).toBe('5-9'); + }); +}); diff --git a/src/lib/anchor.ts b/src/lib/anchor.ts new file mode 100644 index 00000000..5ec6a2bb --- /dev/null +++ b/src/lib/anchor.ts @@ -0,0 +1,119 @@ +import type { Line } from './types'; +import type { Range } from './range'; +import { getLineNumber, getFilePath } from './line-utils'; + +/** + * Annotation identity and position. + * + * An annotation's `id` is its identity; the `anchor` is where it sits, in + * source coordinates. Display rows are resolved from anchors at render time + * (see useAnnotations) and never persisted. + */ + +export type Side = 'old' | 'new'; + +/** One endpoint of a diff anchor: which side, and the 1-indexed source line. */ +export type Endpoint = { side: Side; line: number }; + +/** Mirrors the backend `Anchor` enum: sides only exist where a diff does. */ +export type Anchor = + | { type: 'source'; path: string; start: number; end: number } + | { type: 'diff'; path: string; start: Endpoint; end: Endpoint }; + +/** Identity + position of an annotation slot (saved entry or draft). */ +export type SlotRef = { id: string; anchor: Anchor }; + +/** Separator for coordinate keys — cannot occur in paths. */ +const SEP = '\u0000'; + +/** Lookup key for a side-less source coordinate. */ +export function sourceKey(path: string, line: number): string { + return `${path}${SEP}${line}`; +} + +/** Lookup key for a diff coordinate. Shared with the display walk's byEndpoint. */ +export function diffKey(path: string, side: Side, line: number): string { + return `${path}${SEP}${side}${SEP}${line}`; +} + +/** + * Coordinate keys a line answers to when resolving anchors to display rows + * (non-diff modes; the display walk owns diff coordinates). Virtual lines + * (portal headers/footers) answer on none. + */ +export function endpointKeys(line: Line): string[] { + return line.origin.type === 'source' ? [sourceKey(line.origin.path, line.origin.line)] : []; +} + +/** The anchor's two lookup keys (start, end). Source anchors are side-less. */ +export function anchorKeys(anchor: Anchor): [string, string] { + if (anchor.type === 'source') { + return [sourceKey(anchor.path, anchor.start), sourceKey(anchor.path, anchor.end)]; + } + return [ + diffKey(anchor.path, anchor.start.side, anchor.start.line), + diffKey(anchor.path, anchor.end.side, anchor.end.line), + ]; +} + +/** Start/end source lines regardless of variant (labels, ordering). */ +export function anchorLines(anchor: Anchor): { start: number; end: number } { + return anchor.type === 'source' + ? { start: anchor.start, end: anchor.end } + : { start: anchor.start.line, end: anchor.end.line }; +} + +/** Human-readable line label for an anchor, e.g. "50" or "50-55". */ +export function anchorLabel(anchor: Anchor): string { + const { start, end } = anchorLines(anchor); + return start === end ? `${start}` : `${start}-${end}`; +} + +/** + * Convert a display selection into a source anchor at creation time + * (non-diff modes; diff selections resolve through the display walk — + * see display-rows.ts selectionToDiffAnchor). + * + * Validates: + * 1. All lines in range have non-virtual origin + * 2. All lines share the same origin.path + * 3. No line number discontinuities (for portal boundary detection) + * + * Returns null if the selection is not annotatable. + */ +export function selectionToAnchor(range: Range, lines: Line[]): Anchor | null { + const min = Math.min(range.start, range.end); + const max = Math.max(range.start, range.end); + + const startLine = lines[min - 1]; + const endLine = lines[max - 1]; + if (!startLine || !endLine) return null; + + // Get path from start line - must be non-virtual + const path = getFilePath(startLine); + if (path === null) return null; + + // Check all lines in range share the same path and have no gaps + let prevLineNum: number | null = null; + for (let i = min - 1; i < max; i++) { + const line = lines[i]; + const linePath = getFilePath(line); + const lineNum = getLineNumber(line); + + // All lines must have same path + if (linePath !== path) return null; + + // All lines must have line numbers (non-virtual) + if (lineNum === null) return null; + + // Check for line number discontinuity (gap > 1 indicates portal boundary) + if (prevLineNum !== null && Math.abs(lineNum - prevLineNum) > 1) { + return null; + } + prevLineNum = lineNum; + } + + const start = getLineNumber(startLine)!; + const end = getLineNumber(endLine)!; + return { type: 'source', path, start: Math.min(start, end), end: Math.max(start, end) }; +} diff --git a/src/lib/components/AnnotationSlot.svelte b/src/lib/components/AnnotationSlot.svelte index 958dbdf6..41bdefd9 100644 --- a/src/lib/components/AnnotationSlot.svelte +++ b/src/lib/components/AnnotationSlot.svelte @@ -1,20 +1,24 @@ @@ -23,46 +27,55 @@ * AnnotationSlot - Wrapper component for AnnotationEditor in embedded contexts. * * Handles the conditional rendering, keying, and prop threading for annotations - * in Portal, CodeBlock, Table, and regular line contexts. + * in Portal, CodeBlock, Table, and regular line contexts. Keyed by annotation + * id, which is stable across the draft→saved transition — the editor must not + * remount on the first keystroke. * * Uses context for: annotations, interaction, tags, allowsImagePaste, getOriginalLinesForRange */ import AnnotationEditor from '$lib/AnnotationEditor.svelte'; - import { keyToRange } from '$lib/range'; + import type { Anchor } from '$lib/anchor'; import { getAnnotContext } from '$lib/context'; let { - rangeKey, + slotRef, pendingTagInsertion, onUpdate, + onUnseal, onDismiss, onRequestCreateTag, onImagePasteBlocked, + onFileRefCopied, }: AnnotationSlotProps = $props(); const ctx = getAnnotContext(); + + function originalLines(anchor: Anchor): string { + const span = ctx.annotations.spanOfAnchor(anchor); + return span ? ctx.getOriginalLinesForRange(span) : ''; + } -{#if rangeKey} - {#key rangeKey} +{#if slotRef} + {@const s = slotRef} + {#key s.id} onUpdate(rangeKey, content)} - onUnseal={() => { - ctx.interaction.openEditor({ kind: 'annotation', rangeKey }); - }} + annotationId={s.id} + content={ctx.annotations.getById(s.id)?.content} + sealed={ctx.interaction.isAnnotationSealed(s.id)} + onUpdate={(content) => onUpdate(s.id, content)} + onUnseal={() => onUnseal(s)} {onDismiss} tags={ctx.tags} annotationEntries={ctx.annotations.allEntries()} allowsImagePaste={ctx.allowsImagePaste} {onImagePasteBlocked} - onRequestCreateTag={(text, from, to) => onRequestCreateTag(rangeKey, text, from, to)} - pendingTagInsertion={pendingTagInsertion?.editorKey === rangeKey + {onFileRefCopied} + onRequestCreateTag={(text, from, to) => onRequestCreateTag(s.id, text, from, to)} + pendingTagInsertion={pendingTagInsertion?.editorKey === s.id ? { from: pendingTagInsertion.from, to: pendingTagInsertion.to, tag: pendingTagInsertion.tag } : null} - getOriginalLines={() => ctx.getOriginalLinesForRange(keyToRange(rangeKey))} + getOriginalLines={() => originalLines(s.anchor)} /> {/key} {/if} diff --git a/src/lib/components/FileTree.svelte b/src/lib/components/FileTree.svelte new file mode 100644 index 00000000..2e7242ec --- /dev/null +++ b/src/lib/components/FileTree.svelte @@ -0,0 +1,53 @@ + + + diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 4de3c8bf..445ea8b0 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,15 +1,17 @@
- {#if diffMetadata && currentFile} + {#if currentFile} - {@const fileName = currentFile.new_name ?? currentFile.old_name ?? 'unknown'} - {@const fileCount = diffMetadata.files.length} + {@const fileName = currentFile.path || 'unknown'} + {@const fileCount = docs.length} · - -{currentHunk.old_start},{currentHunk.old_count} - +{currentHunk.new_start},{currentHunk.new_count} + -{currentHunk.old_range.start},{currentHunk.old_range.end - currentHunk.old_range.start} + +{currentHunk.new_range.start},{currentHunk.new_range.end - currentHunk.new_range.start} {#if currentHunk.function_context} @@ -113,6 +127,25 @@ {/if}
+ {#if docs.length > 0} + + + +{totals.added} + −{totals.deleted} + + + + {/if} {#if zoomLevel !== 1.0} {Math.round(zoomLevel * 100)}% {/if} diff --git a/src/lib/components/SessionEditor.svelte b/src/lib/components/SessionEditor.svelte index 3454e6db..a43dee59 100644 --- a/src/lib/components/SessionEditor.svelte +++ b/src/lib/components/SessionEditor.svelte @@ -16,6 +16,7 @@ onClose: () => void; onRequestCreateTag: (text: string, from: number, to: number) => void; onImagePasteBlocked: () => void; + onFileRefCopied?: (path: string) => void; } let { @@ -26,7 +27,8 @@ onOpen, onClose, onRequestCreateTag, - onImagePasteBlocked + onImagePasteBlocked, + onFileRefCopied }: Props = $props(); const ctx = getAnnotContext(); @@ -44,6 +46,7 @@ annotationEntries={ctx.annotations.allEntries()} allowsImagePaste={ctx.allowsImagePaste} {onImagePasteBlocked} + {onFileRefCopied} {onRequestCreateTag} {pendingTagInsertion} /> diff --git a/src/lib/components/embedded/CodeBlock.svelte b/src/lib/components/embedded/CodeBlock.svelte index 836f7f7a..979383bf 100644 --- a/src/lib/components/embedded/CodeBlock.svelte +++ b/src/lib/components/embedded/CodeBlock.svelte @@ -4,6 +4,7 @@ * Uses LineRow for shared line-rendering logic and adds codeblock-specific styling. */ import type { Snippet } from 'svelte'; + import type { SlotRef } from '$lib/anchor'; import type { Line } from '$lib/types'; import { getLineNumber, isCodeBlockFence } from '$lib/line-utils'; import { computePosition, offset, flip, shift } from '@floating-ui/dom'; @@ -21,7 +22,7 @@ excalidrawSupported?: boolean; mermaidError?: string | null; onReportMermaidError?: (error: string) => void; - annotationSlot: Snippet<[displayIndex: number, rangeKey: string | null]>; + annotationSlot: Snippet<[displayIndex: number, slot: SlotRef | null]>; } let { @@ -186,7 +187,7 @@
{#each lines as { line, displayIndex }} {@const sourceLineNum = getLineNumber(line)} - {@const rangeKey = ctx.getRangeKeyForLine(displayIndex)} + {@const slot = ctx.slotForRow(displayIndex)} {@const fence = isFence(line)} {@const startFence = isStartFence(line)} {@const endFence = isEndFence(line)} @@ -298,7 +299,7 @@ {#if !fence}
- {@render annotationSlot(displayIndex, rangeKey)} + {@render annotationSlot(displayIndex, slot)}
{/if} {/each} diff --git a/src/lib/components/embedded/FileHeaderRow.svelte b/src/lib/components/embedded/FileHeaderRow.svelte new file mode 100644 index 00000000..5e37bd9b --- /dev/null +++ b/src/lib/components/embedded/FileHeaderRow.svelte @@ -0,0 +1,39 @@ + + + diff --git a/src/lib/components/embedded/LineRow.svelte b/src/lib/components/embedded/LineRow.svelte index b670eb8c..96e47d25 100644 --- a/src/lib/components/embedded/LineRow.svelte +++ b/src/lib/components/embedded/LineRow.svelte @@ -17,7 +17,8 @@ import { getAnnotContext } from '$lib/context'; interface Props { - line: Line; + /** Unused by LineRow itself; optional so walk-driven diff rows can omit it. */ + line?: Line; displayIndex: number; additionalClasses?: Record; gutterClass?: string; @@ -29,7 +30,7 @@ } let { - line, + line: _line, displayIndex, additionalClasses = {}, gutterClass = '', diff --git a/src/lib/components/embedded/Portal.svelte b/src/lib/components/embedded/Portal.svelte index fb8cc009..ef7041e0 100644 --- a/src/lib/components/embedded/Portal.svelte +++ b/src/lib/components/embedded/Portal.svelte @@ -4,6 +4,7 @@ * Uses LineRow for shared line-rendering logic and adds portal-specific styling. */ import type { Snippet } from 'svelte'; + import type { SlotRef } from '$lib/anchor'; import type { Line, PortalSemantics } from '$lib/types'; import { getLineNumber } from '$lib/line-utils'; import { getAnnotContext } from '$lib/context'; @@ -12,7 +13,7 @@ interface Props { lines: Array<{ line: Line; displayIndex: number }>; - annotationSlot: Snippet<[displayIndex: number, rangeKey: string | null]>; + annotationSlot: Snippet<[displayIndex: number, slot: SlotRef | null]>; } let { lines, annotationSlot }: Props = $props(); @@ -65,7 +66,7 @@ {#each lines as { line, displayIndex }} {@const sourceLineNum = getLineNumber(line)} {@const portalSemantics = getPortalSemantics(line)} - {@const rangeKey = ctx.getRangeKeyForLine(displayIndex)} + {@const slot = ctx.slotForRow(displayIndex)} {/snippet} - {@render annotationSlot(displayIndex, rangeKey)} + {@render annotationSlot(displayIndex, slot)} {/each}
diff --git a/src/lib/components/embedded/RegularLines.svelte b/src/lib/components/embedded/RegularLines.svelte index 0058669f..48bd34d3 100644 --- a/src/lib/components/embedded/RegularLines.svelte +++ b/src/lib/components/embedded/RegularLines.svelte @@ -5,28 +5,27 @@ * Handles regular markdown lines, diff lines, and their annotations. * Uses LineRow for shared line-rendering logic and adds search highlighting via codeWrapper. */ - import type { Line, SectionInfo } from '$lib/types'; - import { getLineNumber, getDiffKind } from '$lib/line-utils'; + import type { SectionInfo } from '$lib/types'; + import { getLineNumber } from '$lib/line-utils'; import { highlightMatches, clearHighlights } from '$lib/search-highlight'; import { injectColorSwatches, clearColorSwatches } from '$lib/color-preview'; import { invoke } from '@tauri-apps/api/core'; import CopyButton from '$lib/components/CopyButton.svelte'; import AnnotationSlot, { type AnnotationSlotProps } from '$lib/components/AnnotationSlot.svelte'; import LineRow from './LineRow.svelte'; + import FileHeaderRow from './FileHeaderRow.svelte'; import { getAnnotContext } from '$lib/context'; - - interface DisplayLine { - line: Line; - displayIndex: number; - } + import { hunkHeaderText, type DisplayRow } from '$lib/display-rows'; + import type { DisplayLine } from '$lib/composables/useLineSegments.svelte'; interface Props { - lines: DisplayLine[]; - annotationSlotProps: Omit; + /** Flat-mode segment lines; unused when the diff walk drives rendering. */ + lines?: DisplayLine[]; + annotationSlotProps: Omit; } let { - lines, + lines = [], annotationSlotProps, }: Props = $props(); @@ -36,6 +35,25 @@ const markdownMetadata = $derived(ctx.markdownMetadata); const searchMatches = $derived(ctx.search.matches); + // Diff mode: the DisplayRow walk drives per-file sections for collapse + + // sticky headers. Null for non-diff content — the flat render path below + // stays untouched. + const display = $derived(ctx.diffDisplay); + + // Body entries (hunk headers + rows) per document; file headers render + // structurally via FileHeaderRow. + const docBodies = $derived.by(() => { + const map = new Map(); + if (!display) return map; + for (const entry of display.rows) { + if (entry.kind === 'file-header') continue; + const body = map.get(entry.docIdx); + if (body) body.push(entry); + else map.set(entry.docIdx, [entry]); + } + return map; + }); + // Map of display indices to code element refs for search highlighting let codeRefs: Map = new Map(); @@ -69,8 +87,9 @@ // Inject color swatches for HEX values $effect(() => { - // Track lines to re-run when content changes + // Track the rendered content source to re-run when it changes void lines; + void display; // Use microtask to ensure DOM is updated after render queueMicrotask(() => { for (const el of codeRefs.values()) { @@ -101,26 +120,16 @@ }); -{#each lines as { line, displayIndex }} +{#snippet row({ line, displayIndex }: DisplayLine)} {@const sourceLineNum = getLineNumber(line)} - {@const diffKind = getDiffKind(line)} {@const mermaidBlock = sourceLineNum !== null ? ctx.mermaid.getMermaidBlockAt(sourceLineNum) : null} {@const sectionInfo = sourceLineNum !== null ? getSectionAt(sourceLineNum) : null} {#snippet gutter()} - {#if line.origin.type === 'diff'} - {line.origin.old_line ?? ''} - {line.origin.new_line ?? ''} - {:else if sourceLineNum !== null} + {#if sourceLineNum !== null} {sourceLineNum} {/if} {/snippet} @@ -157,9 +166,75 @@ {/if} {/snippet} - {@const rangeKey = ctx.getRangeKeyForLine(displayIndex)} - -{/each} + {@const slot = ctx.slotForRow(displayIndex)} + +{/snippet} + +{#snippet walkEntry(entry: DisplayRow)} + {#if entry.kind === 'hunk-header'} + {@const hunk = display!.docs[entry.docIdx].doc.hunks[entry.hunkIdx]} + + {#snippet gutter()} + + + + {/snippet} + + {#snippet codeWrapper(innerContent)} + + {@render innerContent()} + + {/snippet} + + {#snippet code()}{hunkHeaderText(hunk)}{/snippet} + + {:else if entry.kind === 'row'} + + {#snippet gutter()} + {entry.row.old_line ?? ''} + {entry.row.new_line ?? ''} + {entry.rowKind === 'added' ? '+' : entry.rowKind === 'deleted' ? '-' : ''} + {/snippet} + + {#snippet codeWrapper(innerContent)} + + {@render innerContent()} + + {/snippet} + + {#snippet code()} + {#if entry.row.html?.type === 'full'}{@html entry.row.html.value}{:else}{entry.row.content}{/if} + {/snippet} + + {/if} + {@const slot = ctx.slotForRow(entry.displayIndex)} + +{/snippet} + +{#if display} + {#each display.docs as dv (dv.index)} + {@const collapsed = ctx.fileCollapse.isCollapsed(dv.index)} +
+ ctx.fileCollapse.toggle(dv.index)} /> + {#if !collapsed} + {#each docBodies.get(dv.index) ?? [] as entry (entry.displayIndex)} + {@render walkEntry(entry)} + {/each} + {/if} +
+ {/each} +{:else} + {#each lines as dl (dl.displayIndex)} + {@render row(dl)} + {/each} +{/if}