From dad0c9bd8ba79663da77993a19314e4df31391e5 Mon Sep 17 00:00:00 2001 From: Denis Olehov Date: Fri, 10 Jul 2026 16:27:47 +0200 Subject: [PATCH 01/34] fix(diff): accept annotation ranges spanning removed and added lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateRange rejects any range whose consecutive line numbers jump by more than 1 — a check that exists to detect portal boundaries in markdown mode. But diff lines number removed rows in old-file coordinates and added/context rows in new-file coordinates (getLineNumber returns new_line ?? old_line), so a hunk like context new=124, removed old=123, added new=125 reads as a gap of 2. Any annotation covering a removed line next to an added one was silently dropped with only a console warning — broken since the initial commit. Skip the discontinuity check for diff-origin lines. Diffs have no portals to guard against, and cross-hunk or cross-file selections are still rejected: hunk and file header lines carry no line numbers, and all lines in a range must share one path. --- .specs.local/diff-redesign/00-overview.md | 82 ++++++++++++++++++ .../diff-redesign/a1-annotation-entities.md | 66 ++++++++++++++ .../diff-redesign/a2-frontend-rekey.md | 27 ++++++ .specs.local/diff-redesign/b1-file-source.md | 78 +++++++++++++++++ .../diff-redesign/b2-raw-enumerator.md | 69 +++++++++++++++ .specs.local/diff-redesign/b3-diff-engine.md | 86 +++++++++++++++++++ .../diff-redesign/b4-pipeline-swap.md | 25 ++++++ .../diff-redesign/c1-wire-model-v2.md | 25 ++++++ .../diff-redesign/o1-output-mixed-ranges.md | 21 +++++ .specs.local/diff-redesign/s1-file-tree.md | 59 +++++++++++++ .../diff-redesign/s2-file-collapse.md | 61 +++++++++++++ .specs.local/diff-redesign/s3-unfold.md | 24 ++++++ .specs.local/diff-redesign/s4-split-view.md | 21 +++++ .../diff-redesign/s5-word-highlights.md | 20 +++++ src/lib/range.test.ts | 18 ++++ src/lib/range.ts | 9 +- 16 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 .specs.local/diff-redesign/00-overview.md create mode 100644 .specs.local/diff-redesign/a1-annotation-entities.md create mode 100644 .specs.local/diff-redesign/a2-frontend-rekey.md create mode 100644 .specs.local/diff-redesign/b1-file-source.md create mode 100644 .specs.local/diff-redesign/b2-raw-enumerator.md create mode 100644 .specs.local/diff-redesign/b3-diff-engine.md create mode 100644 .specs.local/diff-redesign/b4-pipeline-swap.md create mode 100644 .specs.local/diff-redesign/c1-wire-model-v2.md create mode 100644 .specs.local/diff-redesign/o1-output-mixed-ranges.md create mode 100644 .specs.local/diff-redesign/s1-file-tree.md create mode 100644 .specs.local/diff-redesign/s2-file-collapse.md create mode 100644 .specs.local/diff-redesign/s3-unfold.md create mode 100644 .specs.local/diff-redesign/s4-split-view.md create mode 100644 .specs.local/diff-redesign/s5-word-highlights.md diff --git a/.specs.local/diff-redesign/00-overview.md b/.specs.local/diff-redesign/00-overview.md new file mode 100644 index 00000000..82faf7cb --- /dev/null +++ b/.specs.local/diff-redesign/00-overview.md @@ -0,0 +1,82 @@ +--- +id: overview +status: living +--- + +# Diff Redesign — DAG Overview + +GitHub-shaped diff review: file tree, per-file collapse, unfold between hunks, +unified/split toggle, word-level highlights. Full settled context: see the +grill session restatement (2026-07-10). Anchor: getting lost in big multi-file diffs. + +## Conventions + +- One file per node. Frontmatter: `id`, `kind` (refactor|story), `wave`, + `depends_on` (list of node ids), `status`. +- `status: ready` — full /design-spec shape, implementable by a fresh session. +- `status: fogged` — primer only: goal, edge rationale, settled constraints, + pointers. **Clear the fog before starting**: when a fogged node's deps are + done, rewrite it into full spec shape (aim may have shifted — that's the point). +- `status: done` — landed; keep for archaeology. + +## Settled decisions (do not relitigate in node sessions) + +1. **Substrate (b)**: git mode loads full texts per side (`git diff --raw` → + OIDs → `git cat-file --batch`; zero OID = working tree = fs read) and + computes hunks in-process. Patch parsing (`unidiff` in `src-tauri/src/diff.rs`) + survives only for raw `diff_content` — which gets **no unfold affordance**. +2. **Annotation identity**: id-keyed entities; anchor is two-endpoint + `{path, start: {side, line}, end: {side, line}}` (mixed-side ranges cover + replacements, GitHub's `start_side`/`side` model). Context lines anchor + new-side (old for deleted files). Display index = ephemeral selection only. +3. **Content-source seam**: `side → full text | None` trait. Tiers: + RawPatch → GitShell → (parked) JjLib. +4. **Chrome**: tree = toggleable sidebar + palette fuzzy-jump, hidden by default; + files expanded by default, auto-collapse huge ones; unified default, split + behind persisted toggle. + +**Parked (seams only, no nodes)**: jj-lib tier, stateful reviews / +changed-since-amend, detached reviews + agent threads, in-window file editing, +commit-metadata amending, viewed-checkboxes, staging. + +## The DAG + +```mermaid +graph TD + S1[S1 file tree + palette jump] + S2[S2 per-file collapse + header] + A1[A1 backend annotation entities] --> A2[A2 frontend id-keyed store] + A1 --> O1[O1 output mixed-side ranges] + B1[B1 FileSource trait] --> B4[B4 git pipeline swap] + B2[B2 git diff --raw enumerator] --> B4 + B3[B3 diff engine + word diffs] --> B4 + A2 --> C1[C1 wire model v2: per-file docs] + B4 --> C1 + C1 --> S3[S3 unfold] + C1 --> S4[S4 split view] + C1 --> S5[S5 word-level highlights] + B1 --> S3 + classDef story fill:#1f6feb22,stroke:#1f6feb + class S1,S2,S3,S4,S5 story +``` + +## Waves + +| Wave | Lanes (parallel) | +|---|---| +| 0 | S1, S2 · A1 · B1, B2, B3 | +| 1 | A2 · B4 · O1 | +| 2 | C1 (the join — keep it thin: mostly reshaping + deletion) | +| 3 | S5 (cheapest) · S3 · S4 | + +Critical path: **B3 → B4 → C1 → S4**. Trunk A has slack. + +## Method (why the graph looks like this) + +- Nodes are the codebase's current *lies* flipped true (T1 identity, T2 full + texts, T3 computed hunks, T4 per-file wire model); stories are thin leaves + consuming truths. +- An edge exists only if "this diff gets smaller/safer if that lands first". +- Every node leaves the repo green and shippable. B4 is the strangler swap: + new producer, old contract. C1 changes the contract once, after both trunks. +- S1/S2 ride the *current* model for early value; they take a small refit at C1. diff --git a/.specs.local/diff-redesign/a1-annotation-entities.md b/.specs.local/diff-redesign/a1-annotation-entities.md new file mode 100644 index 00000000..0cb743a9 --- /dev/null +++ b/.specs.local/diff-redesign/a1-annotation-entities.md @@ -0,0 +1,66 @@ +--- +id: A1 +kind: refactor +wave: 0 +depends_on: [] +status: ready +--- + +# [Spec]: A1 — Backend annotation entities (id + anchor) + +## Requirements +- **Problem:** Annotations are keyed by position — `HashMap` per target (`src-tauri/src/review.rs:113-115`), no identity, no side. Unfold/split/threads/stateful reviews all need annotations that survive position changes. +- **Beneficiary:** Every downstream node (A2, C1, S3, S4) plus parked features (threads need ids, stateful reviews need source anchors). +- **Done when:** Backend stores `Annotation { id, anchor, content }` keyed by id; IPC takes id + anchor; `pnpm test:rust` green with **unchanged** output snapshots. + +## Entities + +``` +Side = Old | New +Endpoint = { side: Side, line: u32 } // line = 1-indexed source line +Anchor = { path: String, start: Endpoint, end: Endpoint } +Annotation = { id: String /* uuid v4, dep exists */, anchor: Anchor, content: Vec } + +AnnotationTarget.annotations: HashMap // current +AnnotationTarget.annotations: IndexMap // proposed (indexmap is a dep; preserves insertion order for output) +``` + +Degenerate cases: file/content/markdown modes use `side: New` everywhere — one +anchor type covers all three review modes. `start == end` for single-line. + +## Approach + +**Keystone:** the id is the identity; the anchor is a mutable property. +Rejected alternative: keeping position-composite keys (`LineRange`) — that +makes "same annotation, moved anchor" unrepresentable, which kills threads and +re-anchoring after re-diff. + +Sequence: +1. New types (`Side`, `Endpoint`, `Anchor`) in `review.rs` (or new `anchor.rs`), serde-derived. +2. Re-key `AnnotationTarget.annotations`; rewrite `upsert_annotation`/`delete_annotation` (`review.rs:490-508`) to take `(id, anchor, content)` / `(id)`. +3. IPC: `upsert_annotation`/`delete_annotation` commands (`src-tauri/src/commands.rs:58-82`) accept the new shape. Frontend call site (`src/lib/composables/useAnnotations.svelte.ts` pending-sync block) adapts *minimally*: generate uuid client-side at creation, map its existing `coords` (path/start/end source lines) into an Anchor with `side` from `line.origin` (old_line-only lines → Old, else New). Full frontend re-keying is A2, not here. +4. Output builder (`src-tauri/src/output/`) iterates entities instead of range-keyed map. Ordering: sort by (file, anchor position) to keep snapshots byte-identical. +5. Tag-usage walk (`commands.rs:140-160`) — mechanical update. + +**Seams:** +- `id` field → detached-review threads (parked): replies will reference annotation ids. +- `Anchor.side` → S4 split view annotates either column with no model change. + +## Structure +- `src-tauri/src/review.rs` — types, storage, upsert/delete +- `src-tauri/src/commands.rs` — IPC signatures +- `src-tauri/src/output/builder.rs` — consume entities, keep rendering identical +- `src/lib/composables/useAnnotations.svelte.ts` — thin adapter only (id generation + side mapping) + +## Norms +- Declarative style (map/collect) per CLAUDE.md. +- Insta snapshot workflow: `cargo test` → `cargo insta review` — but the bar here is **zero snapshot churn** (O1 is where output changes). + +## Safeguards +- Two annotations on the same range must coexist (new capability — add a test). +- Mixed-side anchors (`start.side != end.side`) must serialize/deserialize round-trip (test now, rendered in O1). +- Frontend behavior unchanged: create/edit/delete annotation in `pnpm demo:diff` works as before. + +## Scope +- In: backend model, IPC shape, minimal frontend adapter, tests. +- Out: frontend store re-keying (A2), output format changes (O1), any UI. diff --git a/.specs.local/diff-redesign/a2-frontend-rekey.md b/.specs.local/diff-redesign/a2-frontend-rekey.md new file mode 100644 index 00000000..ec7806f3 --- /dev/null +++ b/.specs.local/diff-redesign/a2-frontend-rekey.md @@ -0,0 +1,27 @@ +--- +id: A2 +kind: refactor +wave: 1 +depends_on: [A1] +status: fogged +--- + +# Primer: A2 — Frontend id-keyed annotation store + +> Fogged. Clear before starting: rewrite into full spec shape once A1 has landed +> (its final IPC shape and any surprises feed this). + +**Goal:** Frontend annotations keyed by id with `{path, start:{side,line}, end:{side,line}}` anchors; display index demoted to ephemeral selection state. + +**Why after A1:** the backend contract (id + anchor IPC) must exist to key against; A1 also leaves a thin frontend adapter marking exactly the seams this node replaces. + +**Why before C1:** lands against the *current flat model* so the C1 join doesn't change identity and wire shape simultaneously. `line.origin` already carries `old_line`/`new_line` (`src/lib/types.ts:6-9`) — anchors are computable today. + +**Settled constraints:** +- Anchor computed from selection at *creation*; resolved anchor → row at *render* (needs an anchor→displayIndex map derived from the lines array). +- Context lines anchor new-side; old-side only for deleted files. +- Mixed-side ranges (replacement spans) must be creatable from a unified-view selection. + +**Blast radius (from A1-era code):** `src/lib/range.ts` (display-index Range dies here), `useAnnotations.svelte.ts` (rangeToKey store → id map), `useAnnotationEditor`, `useHistory` (undo/redo references), `AnnotationSlot.svelte` / `LineRow.svelte` (render lookup), selection plumbing in `useInteraction`. + +**Risk to plan around:** undo/redo history currently stores range keys — decide entity-level history semantics before coding. diff --git a/.specs.local/diff-redesign/b1-file-source.md b/.specs.local/diff-redesign/b1-file-source.md new file mode 100644 index 00000000..f21cf471 --- /dev/null +++ b/.specs.local/diff-redesign/b1-file-source.md @@ -0,0 +1,78 @@ +--- +id: B1 +kind: refactor +wave: 0 +depends_on: [] +status: ready +--- + +# [Spec]: B1 — FileSource trait (content-source seam) + +## Requirements +- **Problem:** Annot only ever holds patch text; nothing can answer "give me the full file at side X", which unfold (S3) and substrate (B4) require. +- **Beneficiary:** B4 (loads both sides), S3 (slices gap lines), parked jj tier (this trait is its front door). +- **Done when:** `GitShellSource` returns full text for old/new sides of files in a real repo (unit-tested against a fixture repo); `RawPatchSource` returns `None`. + +## Entities + +```rust +pub enum Side { Old, New } // shared with A1's anchor types + +pub trait FileSource: Send + Sync { + /// Full text of the file at `path` on `side`. Ok(None) = unavailable + /// (raw patch mode, binary, or side doesn't exist e.g. Old of an added file). + fn full_text(&self, path: &str, side: Side) -> Result>, AnnotError>; +} + +pub struct GitShellSource { + repo_root: PathBuf, + /// (path, side) -> oid, from B2's enumerator. Zero oid => working tree (fs read). + oids: HashMap<(String, Side), Option>, + cache: Mutex>>, +} + +pub struct RawPatchSource; // full_text => Ok(None), always +``` + +## Approach + +**Keystone:** fetch *whole files*, cached, never gap slices — every later +unfold is then a local slice (Zed and hunk both converged here). Rejected +alternative: per-gap `git show file:40-60`-style queries — N round trips, +no reuse, and git can't even express line ranges for blobs. + +- Blob retrieval: single `git cat-file --batch` child process, lazily spawned, + fed `\n` lines, response header ` ` then payload. + One process for the whole session (Zed's pattern), not one `git show` per file. +- Working-tree side (zero oid): `std::fs::read_to_string(repo_root.join(path))`. +- Size cap ~1 MB per file (hunk's guard) → treat oversize as `Ok(None)` + (unfold affordance simply won't render). +- `Ok(None)` is the **capability signal**: UI derives "can unfold?" from it — + no separate mode flag to keep in sync. + +**Seams:** +- Parked JjLib tier = third impl backed by `materialize_tree_value` (returns + full file contents — plugs in directly). +- `Arc` return → B4 holds the same allocation for diffing; no copies. + +## Structure +- New: `src-tauri/src/source.rs` (trait + both impls + tests) +- `src-tauri/src/lib.rs` — module registration +- Consumed later by B4 (pipeline) and S3 (unfold IPC); no call sites yet — this node is deliberately standalone. + +## Operations +1. `Side` + trait + `RawPatchSource` (trivial) + unit test. +2. `GitShellSource::new(repo_root, oids)`; batch process management (spawn on first use, restart on death). +3. Fixture-repo tests: added/deleted/modified/renamed file × old/new side; oversize file → `None`; binary → `None`. + +## Norms +- Errors via `AnnotError` (`src-tauri/src/error.rs`), thiserror. +- No tokio needed — synchronous child-process I/O is fine here (commands calling it are already async or on the blocking pool). + +## Safeguards +- Never panic on missing oid/path — `Ok(None)`. +- Batch process death mid-session must not poison the session: respawn once, then degrade to `None`. + +## Scope +- In: trait, two impls, fixture tests. +- Out: enumeration of oids (B2 provides), any UI, jj impl (parked). diff --git a/.specs.local/diff-redesign/b2-raw-enumerator.md b/.specs.local/diff-redesign/b2-raw-enumerator.md new file mode 100644 index 00000000..2f0cf977 --- /dev/null +++ b/.specs.local/diff-redesign/b2-raw-enumerator.md @@ -0,0 +1,69 @@ +--- +id: B2 +kind: refactor +wave: 0 +depends_on: [] +status: ready +--- + +# [Spec]: B2 — `git diff --raw` enumerator + +## Requirements +- **Problem:** Substrate (b) needs, per changed file, the two blob identities — but `git_diff_args` are arbitrary user/agent strings (`review_diff` just appends them: `src-tauri/src/mcp/mod.rs:171-191`). Annot must not parse revision semantics itself. +- **Beneficiary:** B4 (file list + oids feed B1), and the file tree gets rename/status data better than patch parsing gives. +- **Done when:** `enumerate(args)` returns correct entries for modified/added/deleted/renamed/working-tree cases against a fixture repo. + +## Entities + +```rust +pub enum FileStatus { Modified, Added, Deleted, Renamed { similarity: u8 }, Copied, TypeChanged } + +pub struct FileEntry { + pub status: FileStatus, + pub old_path: Option, // None for Added + pub new_path: Option, // None for Deleted + pub old_oid: Option, // None = nonexistent side; Some(ZERO) normalized to WorkingTree below + pub new_oid: Option, +} +pub enum BlobRef { Oid(String), WorkingTree } // zero oid => WorkingTree +``` + +## Approach + +**Keystone:** run `git diff --raw -z ` and let git +resolve every revision/pathspec question; annot only parses the stable `--raw` +record format. Rejected alternative: interpreting `` (revs, ranges, +`--staged`, pathspecs) ourselves — an open-ended reimplementation of git CLI +semantics with permanent drift risk. + +Record format (NUL-separated with `-z`): +`: [score]\0[\0]\0` +— R/C carry two paths; abbreviated oids avoided via `--no-abbrev`. + +Flow: `git diff --raw -z --no-abbrev ` → parse → `Vec` → +(B4) build B1's oid map + drive per-file diffing. + +**Seams:** +- `FileStatus::Renamed` → file tree shows `old → new` (S1 refit at C1). +- Same enumerator later serves "changed since op X" listings in the parked jj tier (different producer, same `FileEntry`). + +## Structure +- New: `src-tauri/src/vcs.rs` (or fold into `source.rs`'s sibling — implementer's call, keep it out of `diff.rs` which is the legacy patch parser) +- Callers: none yet (B4 wires it). Standalone + tests, like B1. + +## Operations +1. Runner: `Command::new("git").args(["diff","--raw","-z","--no-abbrev"]).args(user_args)` with cwd = session cwd; capture stderr for error surfacing (mirror `mcp/mod.rs:180-183`). +2. `-z` record parser (NUL split; R/C two-path handling; score suffix on status letter). +3. Zero-oid normalization → `BlobRef::WorkingTree`. +4. Fixture tests: M/A/D/R cases; `--staged`; rev-range (`HEAD~1..HEAD`); pathspec filter; empty diff. + +## Norms +- Same subprocess discipline as existing `run_diff_session` (error string from stderr). + +## Safeguards +- Unparseable record → error, not silent skip (a missing file in review is worse than a failed session). +- Must handle paths with spaces/unicode (that's what `-z` is for — test it). + +## Scope +- In: runner, parser, fixture tests. +- Out: fetching content (B1), diffing (B3), wiring (B4), submodule/binary special-casing beyond "mark and pass through". diff --git a/.specs.local/diff-redesign/b3-diff-engine.md b/.specs.local/diff-redesign/b3-diff-engine.md new file mode 100644 index 00000000..f56b0eba --- /dev/null +++ b/.specs.local/diff-redesign/b3-diff-engine.md @@ -0,0 +1,86 @@ +--- +id: B3 +kind: refactor +wave: 0 +depends_on: [] +status: ready +--- + +# [Spec]: B3 — In-process diff engine + word diffs + +## Requirements +- **Problem:** Hunks are parsed from `git diff` text (`unidiff` in `src-tauri/src/diff.rs:87`), so annot can only know what the patch says — no re-diff, no word-level ranges, no control over context. +- **Beneficiary:** B4 (hunk computation), S5 (word highlights fall out), S4 (old↔new line mapping for split pairing), future re-diff on file change. +- **Done when:** `compute_hunks(old, new, context)` matches `git diff` semantics on a corpus of fixture pairs (insta-snapshotted), word diffs emitted for small hunks. + +## Entities + +```rust +pub struct FileDiff { + pub hunks: Vec, +} +pub struct Hunk { + pub old_range: Range, // 1-indexed lines in old text (incl. context) + pub new_range: Range, + pub rows: Vec, // ordered: context | deleted | added +} +pub enum DiffRow { + Context { old_line: u32, new_line: u32 }, + Deleted { old_line: u32, word_ranges: Vec> }, // byte ranges within the line + Added { new_line: u32, word_ranges: Vec> }, +} +``` + +Pure function, no I/O: +`pub fn compute_hunks(old: &str, new: &str, context: u32) -> FileDiff` + +## Approach + +**Keystone:** hunks become a *derived overlay over two full texts* (Zed's +model), computed by us — the patch is no longer the source of truth for git +mode. Rejected alternative: keep parsing git's patch and bolt word-diffs on +top — leaves T3 false, blocks re-diff, and word alignment against parsed +text is guess-work. + +**Engine choice — UNSETTLED, decide at implementation start:** +- `similar = "3"` is **already a dependency with the `inline` feature** + (Cargo.toml) — `TextDiff` line diffs + built-in inline (word-level) change + ranges. No new dep. Algorithms: Myers/Patience/LCS (no Histogram). +- `imara-diff` — what Zed uses; Histogram algorithm (better hunk quality on + code), faster; new dep, word-diff hand-rolled (token-level second pass). +- Lean: **similar-first** — the engine hides behind `compute_hunks`, so a swap + is contained if hunk quality or perf disappoints. The signature is the + contract; the crate is an implementation detail. + +Word-diff gate (Zed's discipline): only when a hunk's deleted/added line +counts are equal and ≤ 5 lines; token-level, word boundaries. Prevents +noise-highlighting on rewrites. + +Note: `--diff-algorithm` differences mean output may diverge cosmetically from +`git diff`. Accepted at design time (grill session). + +**Seams:** +- `context` param → S3 unfold and a future "more context" setting share the machinery. +- Old↔new line mapping implicit in `rows` → S4 split pairing walks it directly. + +## Structure +- New: `src-tauri/src/engine.rs` (name TBD; NOT in `diff.rs` — that stays the legacy patch parser until C1 shrinks it to raw-mode-only) +- Heavy unit + insta tests: `src-tauri/src/engine.rs` tests module, snapshots beside existing `output/snapshots/` pattern. + +## Operations +1. Line diff → grouped ops → hunk assembly with `context` merging (adjacent hunks whose context overlaps merge — mirrors git). +2. Line-number bookkeeping (the four running counters: old/new × index/line-number). +3. Word-diff pass on gated hunks; byte ranges per line. +4. Corpus tests: empty→content, content→empty, pure add/delete, replacement, adjacent-hunk merge, no-trailing-newline, CRLF, unicode. + +## Norms +- Pure function, zero I/O — the most unit-testable node in the graph; build and trust it first. +- Declarative style per CLAUDE.md. + +## Safeguards +- Property test worth writing: reconstructing `new` from `old` + hunks round-trips exactly (including trailing-newline edge cases). +- Word ranges are byte offsets into the line — must slice at char boundaries (test with multibyte). + +## Scope +- In: engine, word diffs, corpus tests. +- Out: file loading (B1), enumeration (B2), rendering/HTML, syntax highlighting, wiring (B4). diff --git a/.specs.local/diff-redesign/b4-pipeline-swap.md b/.specs.local/diff-redesign/b4-pipeline-swap.md new file mode 100644 index 00000000..970a376c --- /dev/null +++ b/.specs.local/diff-redesign/b4-pipeline-swap.md @@ -0,0 +1,25 @@ +--- +id: B4 +kind: refactor +wave: 1 +depends_on: [B1, B2, B3] +status: fogged +--- + +# Primer: B4 — Git pipeline swap (strangler node) + +> Fogged. Clear before starting: B1/B2/B3's real APIs replace the sketches here. + +**Goal:** Git mode (`git_diff_args`) stops parsing patch text. New pipeline: B2 enumerates files/oids → B1 fetches both sides' full text → B3 computes hunks → **render into the existing flat `Line` stream** (`LineOrigin::Diff` + `DiffSemantics` rows, `DiffMetadata` populated as today). The wire contract does not change; if B4 lands and nobody notices, it worked. + +**Why after B1+B2+B3:** it is pure composition of the three; doing any of their work inline here fattens the riskiest kind of node (a producer swap). + +**Settled constraints:** +- `unidiff`/`parse_diff` (`src-tauri/src/diff.rs:87`) survives, but only reachable from raw `diff_content` mode (and CLI stdin patches). +- Entry point today: `run_diff_session` (`src-tauri/src/mcp/mod.rs:164`) and the CLI diff path in `lib.rs` — both route through `ContentModel::from_diff`; this node forks git-args mode to `ContentModel::from_git(...)`. +- Full texts + `FileSource` stay alive in session state after load — S3 (unfold) and re-diff need them; don't drop after rendering. +- Syntax highlighting: current pipeline highlights via `highlight.rs`/`syntect` — new pipeline must produce equivalent `html` per line. + +**Verification bar:** side-by-side session outputs (old parser vs new pipeline) on real repos agree on file list, hunk boundaries (modulo settled cosmetic divergence), line origins. Existing insta output snapshots stay green. + +**Risk to plan around:** hunk-boundary cosmetic divergence from `git diff` (different algorithm defaults) — accepted at design time, but eyeball a corpus before trusting it. diff --git a/.specs.local/diff-redesign/c1-wire-model-v2.md b/.specs.local/diff-redesign/c1-wire-model-v2.md new file mode 100644 index 00000000..0ef78507 --- /dev/null +++ b/.specs.local/diff-redesign/c1-wire-model-v2.md @@ -0,0 +1,25 @@ +--- +id: C1 +kind: refactor +wave: 2 +depends_on: [A2, B4] +status: fogged +--- + +# Primer: C1 — Wire model v2 (per-file documents) — THE JOIN + +> Fogged, deliberately: this node is shaped by what A2 and B4 leave behind. +> Keep it starved — mostly reshaping and deletion. If it's accumulating logic, +> something belonged in A2/B4 and should be pushed back. + +**Goal:** `ContentResponse` for diff mode becomes per-file documents — `{ path, status, hunks, rows }`, rows carrying `(side, old_line, new_line)` — replacing the single flat `Vec` for diffs. Frontend renders per-file sections. Flat contract retired for diff mode (file/markdown modes keep theirs). + +**Why after A2:** annotations no longer key on display index, so restructuring the render array breaks nothing. +**Why after B4:** backend already *has* the per-file model internally; this node exposes it instead of flattening it. + +**Settled constraints:** +- S1/S2 refit here: tree binds to documents (rename `old → new` display arrives), collapse becomes structural instead of render-skip. Their specs name the single derivation points to rebind. +- `review.rs` already tracks per-file `AnnotationTarget`s (`FileKey::diff_file(index)`) — backend identity mostly survives; it's the wire + frontend spine that changes. +- Virtual scrolling consideration: per-file sections change the scroll container geometry — check `adaptiveScrollOverscan`-style logic if any exists frontend-side before assuming free. + +**Exit criteria to write when clearing fog:** demo:diff renders identically (modulo settled cosmetics) through the new model; annotations created pre-C1 sessions aren't a concern (sessions are ephemeral — no migration). diff --git a/.specs.local/diff-redesign/o1-output-mixed-ranges.md b/.specs.local/diff-redesign/o1-output-mixed-ranges.md new file mode 100644 index 00000000..e30ed51f --- /dev/null +++ b/.specs.local/diff-redesign/o1-output-mixed-ranges.md @@ -0,0 +1,21 @@ +--- +id: O1 +kind: refactor +wave: 1 +depends_on: [A1] +status: fogged +--- + +# Primer: O1 — Output rendering for mixed-side ranges + +> Fogged. Clear before starting once A1's entity shape is final. + +**Goal:** Structured output (`src-tauri/src/output/`) renders two-endpoint anchors, including mixed-side ranges (annotation spanning deleted+added replacement). The existing `old:new` gutter format (`file.rs (old:2)` style — see `output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_deleted_line.snap`) already speaks sides; this extends it to ranges whose endpoints sit on different sides. + +**Why after A1:** consumes the entity model; A1 deliberately kept snapshots byte-identical, this node is where snapshot churn is *allowed*. + +**Settled constraints:** +- Agents parse this output — the format is a contract. Additive/unambiguous changes only; keep single-side annotations rendering exactly as today. +- Insta workflow: `cargo test` → `cargo insta review` → commit `.snap`. + +**Design question to settle when clearing fog:** how a mixed-side range names itself in the header — e.g. `file.rs (old:2 → new:5)` — pick something an LLM can't misread, add corpus snapshots for: old-only, new-only, mixed, multi-line each. diff --git a/.specs.local/diff-redesign/s1-file-tree.md b/.specs.local/diff-redesign/s1-file-tree.md new file mode 100644 index 00000000..54f57ca3 --- /dev/null +++ b/.specs.local/diff-redesign/s1-file-tree.md @@ -0,0 +1,59 @@ +--- +id: S1 +kind: story +wave: 0 +depends_on: [] +status: done +--- + +# [Spec]: S1 — File tree sidebar + palette fuzzy-jump + +## Requirements +- **Problem:** In a big multi-file diff there is no way to see which files changed or jump to one — the anchor pain ("I get lost") in its purest form. +- **Beneficiary:** Anyone reviewing multi-file diffs; ships on the *current* model, no trunk dependency. +- **Done when:** In `pnpm demo:diff` (and a real multi-file `git_diff_args` session): sidebar toggles via shortcut, clicking a file scrolls to its header; `:` palette has a files namespace with fuzzy jump. + +## Entities +N/A — consumes existing `DiffMetadata.files: DiffFileInfo[]` (`src/lib/types.ts:112-119`; `start_line`/`end_line` per file), which is currently computed and unused by any navigation. + +## Approach + +**Keystone:** navigation reads existing metadata; zero backend changes. +Rejected alternative: waiting for C1's per-file documents — weeks of delay for +data that's already on the wire. + +- Sidebar: new `FileTree.svelte`, hidden by default, toggled by shortcut + + palette action. Flat list with directory-prefix grouping (GitHub-style + nested/collapsed-dir tree is a later nicety — flat first). +- Row: filename, dimmed dir prefix, +/− counts (derivable by counting + added/deleted semantics lines within `start_line..end_line`). +- Click → scroll the line list to the file's `start_line` (same scroll + mechanism the search feature uses — see `useSearch.svelte.ts`). +- Palette: new `files` namespace following the existing pattern + (`src/lib/CommandPalette/namespaces/theme.ts` — `Namespace` + `Item[]` with + `EMIT_EVENT` actions, `fuzzySearch` from `$lib/fuzzy`). +- Current-file tracking (highlight in tree while scrolling): IntersectionObserver + or scroll-position → binary search over `start_line`s. + +**Seams:** +- Refit at C1: tree rebinds from `metadata.files` to per-file documents; keep data access behind one derivation function so the refit touches one place. +- Rename display (`old → new`) arrives with B2/C1 data; flat name until then. + +## Structure +- New: `src/lib/components/FileTree.svelte` +- New: `src/lib/CommandPalette/namespaces/files.ts` (+ register in `namespaces/index.ts`) +- `src/routes/+page.svelte` — layout slot for sidebar +- `src/lib/HelpOverlay.svelte` + `docs/features.md` — shortcut + feature docs (CLAUDE.md requires both) + +## Norms +- Composables pattern for any state (`src/lib/composables/`). +- Frontend tests mock Tauri IPC via `vi.mock("@tauri-apps/api/core")`. +- Only render for `metadata.type === 'diff'`. + +## Safeguards +- Sidebar must not steal keyboard focus from the line list (annot is keyboard-driven — verify j/k/selection still work with sidebar open). +- No layout shift of the line list content that would confuse in-flight selection. + +## Scope +- In: sidebar, palette namespace, scroll-jump, current-file highlight, shortcut, help/docs updates. +- Out: collapse (S2), viewed-state (parked), nested dir tree, rename arrows (post-C1 refit). diff --git a/.specs.local/diff-redesign/s2-file-collapse.md b/.specs.local/diff-redesign/s2-file-collapse.md new file mode 100644 index 00000000..66b3d184 --- /dev/null +++ b/.specs.local/diff-redesign/s2-file-collapse.md @@ -0,0 +1,61 @@ +--- +id: S2 +kind: story +wave: 0 +depends_on: [] +status: ready +--- + +# [Spec]: S2 — Per-file collapse + "N files changed" header + +## Requirements +- **Problem:** A 15-file diff is one undifferentiated wall; you can't set aside files you're done with or don't care about, and nothing summarizes the changeset. +- **Beneficiary:** Multi-file diff reviewers; ships on the current model. +- **Done when:** File headers show a collapse chevron; collapsing hides the file's lines; a summary header shows "N files changed, +A −D"; existing annotations still resolve correctly after collapse/expand cycles. + +## Entities +N/A — presentation state only: `collapsedFiles: Set` in a small composable. + +## Approach + +**Keystone:** collapse is **render-skip, never array mutation** — the `lines` +array and therefore every display index stays byte-identical, because +annotations are still display-index-keyed until A2 lands. Rejected +alternative: filtering the lines array — silently detaches every annotation +below the first collapsed file. + +- File boundaries from `DiffFileInfo.start_line/end_line` (same data as S1). +- Render loop (`RegularLines.svelte`) skips rows whose index falls inside a + collapsed file's range (keep the `file_header` row visible as the collapsed + bar, GitHub-style: path, +/− counts, chevron). +- Summary header: derive counts once from `metadata.files` + line semantics + (`added`/`deleted`), render above the first file. +- Auto-collapse: files whose changed-line count exceeds a threshold (~500) + start collapsed, like GitHub's "Load diff" barrier. Threshold is a constant, + not config, until someone asks. +- Selection interaction: if the cursor/selection sits inside a file being + collapsed, move selection to the file header row. + +**Seams:** +- At C1 collapse becomes structural (per-file document sections) — keep the + collapsed-set composable; only the render-skip mechanism gets replaced. +- Collapsed bar is where a parked "viewed" checkbox would live later. + +## Structure +- New: `src/lib/composables/useFileCollapse.svelte.ts` +- `src/lib/components/embedded/RegularLines.svelte` — render-skip + collapsed bar +- `src/lib/components/embedded/LineRow.svelte` — chevron on `file_header` rows (inside the `{#if trailing}` block per CLAUDE.md UI patterns) +- `src/lib/HelpOverlay.svelte`, `docs/features.md` — shortcut + docs + +## Norms +- `.line-action` class for the chevron button (CLAUDE.md UI patterns). +- Composables pattern; runes. + +## Safeguards +- **Invariant: `lines` array is never mutated by collapse** — test: annotate line in file 3, collapse file 1, annotation still renders on the same content. +- Keyboard nav (j/k) must skip hidden rows without getting stuck. +- Search hits inside a collapsed file: either auto-expand on jump or skip — pick auto-expand (GitHub behavior), test it. + +## Scope +- In: collapse/expand per file, auto-collapse threshold, summary header, selection/search interaction, docs. +- Out: viewed-state (parked), remembering collapse across sessions, S1's sidebar (independent — no edge between S1 and S2). diff --git a/.specs.local/diff-redesign/s3-unfold.md b/.specs.local/diff-redesign/s3-unfold.md new file mode 100644 index 00000000..3c340cae --- /dev/null +++ b/.specs.local/diff-redesign/s3-unfold.md @@ -0,0 +1,24 @@ +--- +id: S3 +kind: story +wave: 3 +depends_on: [C1, B1] +status: fogged +--- + +# Primer: S3 — Unfold context between hunks + +> Fogged. Clear after C1: the row/section model it splices into is C1's output. + +**Goal:** GitHub-style gap bars between hunks ("⋯ 20 unchanged lines" with expand up/down/all); clicking slices rows from B1's cached full text and splices them in. + +**Settled constraints:** +- Affordance renders **only when `FileSource::full_text` can return content** — raw `diff_content` mode shows no arrows at all (settled: no unfold there; avoid hunk-the-tool's silent-failure bug). +- Fetch whole file once (B1 caches); every unfold is a local slice. Loading/error/too-large states on the gap bar (hunk's state machine: `loading | loaded | error | too-large`). +- Expansion rows are **tagged** and excluded from hunk bounds / annotation-anchor derivation (hunk's `isExpansionRow` discipline) — anchors must not drift when context is unfolded. +- Annotating an expanded (context) row is allowed and anchors new-side like any context line. +- Gap identity: `(file, position before/after hunkIndex)` — expansion state is per-session, ephemeral. + +**Mechanics reference:** hunk's `expandCollapsedRows.ts` splice (keep the gap bar in place, rewrite label, insert synthesized rows keyed separately) and Zed's merge-adjacent-regions rule (fully unfolded gap disappears; adjacent expansions merge). + +**IPC to design when clearing fog:** frontend asks backend for gap lines (`expand_gap(file, old_range, new_range) → rows` with html-highlighted lines) vs shipping full texts to the frontend at load. Lean backend-slicing — keeps highlighting (`syntect`) and memory in one place. diff --git a/.specs.local/diff-redesign/s4-split-view.md b/.specs.local/diff-redesign/s4-split-view.md new file mode 100644 index 00000000..94b65d04 --- /dev/null +++ b/.specs.local/diff-redesign/s4-split-view.md @@ -0,0 +1,21 @@ +--- +id: S4 +kind: story +wave: 3 +depends_on: [C1] +status: fogged +--- + +# Primer: S4 — Split (side-by-side) view + persisted toggle + +> Fogged. Clear after C1; likely the fattest leaf — consider slicing when specing. + +**Goal:** Two-column view: old left, new right; context rows span-aligned; deleted/added rows paired within a change run, shorter side padded with filler cells. Toggle via shortcut + palette; **unified stays default**; choice persisted in config (`src-tauri/src/config.rs` — same persistence path as tags/exit-modes). + +**Settled constraints:** +- Split is a **projection of the same rows** (C1's `(side, old_line, new_line)` model) — no second data pipeline, no new wire format. Pairing walks hunk rows: context → one row both cells; change runs → pair deletions/additions by index up to `max(dels, adds)` (hunk's `buildSplitRows` pattern), padding with empty cells. +- Annotations: side is implicit from the column clicked; anchors are already side-aware (A1/A2), so the model needs zero changes — this story is UI only. +- Selection model in split view: column-scoped ranges; mixed-side range creation stays a unified-view gesture (settled — replacements are selected in unified). +- Word-level highlight spans (S5) must render in both views if S5 lands first. + +**Risks to plan around when clearing fog:** keyboard nav semantics across two columns (j/k walks rows; h/l or focus model for columns?); `LineRow.svelte` reuse vs a `SplitRow` sibling; line-wrap alignment between cells (CSS grid row auto-height keeps pairs aligned — hunk pads with terminal cells, DOM can align naturally). diff --git a/.specs.local/diff-redesign/s5-word-highlights.md b/.specs.local/diff-redesign/s5-word-highlights.md new file mode 100644 index 00000000..c023c26b --- /dev/null +++ b/.specs.local/diff-redesign/s5-word-highlights.md @@ -0,0 +1,20 @@ +--- +id: S5 +kind: story +wave: 3 +depends_on: [C1] +status: fogged +--- + +# Primer: S5 — Word-level (intra-line) diff highlights + +> Fogged, but thin: cheapest leaf, ship first of wave 3. + +**Goal:** Within changed line pairs, the changed tokens get a stronger background (GitHub's darker red/green spans). Data already exists: B3 emits `word_ranges: Vec>` (byte offsets per line) on `Deleted`/`Added` rows, gated to hunks ≤ ~5 equal lines. + +**Settled constraints:** +- Rendering only — no computation frontend-side. Backend already merges word ranges into the line HTML (`highlight.rs` produces per-line html; word-diff spans must compose with syntect spans — nested `` around highlighted tokens) **or** ships ranges for frontend wrapping. Decide when clearing fog; lean backend-composited (frontend stays a dumb renderer, consistent with the rest of the pipeline). +- Byte ranges slice at char boundaries (B3 safeguard) — trust but verify with multibyte fixture. +- Must render in unified now and split (S4) later without rework — style via a class on spans, not view-specific markup. + +**Reference:** Zed gates at ≤5-line hunks with equal add/del counts (`MAX_WORD_DIFF_LINE_COUNT`) — the gate lives in B3; if tuning is needed, tune there, not here. diff --git a/src/lib/range.test.ts b/src/lib/range.test.ts index 478810ce..bdf8e904 100644 --- a/src/lib/range.test.ts +++ b/src/lib/range.test.ts @@ -153,6 +153,24 @@ describe('validateRange', () => { expect(coords).toBeNull(); }); + it('accepts diff ranges mixing removed and added lines', () => { + // Real hunk shape: removed lines carry old-file numbers, added/context + // lines carry new-file numbers, so consecutive rows "jump" numerically. + const lines: Line[] = [ + makeLine({ type: 'diff', path: 'output.rs', old_line: 122, new_line: 124 }), // context + makeLine({ type: 'diff', path: 'output.rs', old_line: 123, new_line: null }), // removed + makeLine({ type: 'diff', path: 'output.rs', old_line: null, new_line: 125 }), // added + makeLine({ type: 'diff', path: 'output.rs', old_line: null, new_line: 126 }), // added + ]; + + const coords = validateRange({ start: 1, end: 4 }, lines); + expect(coords).toEqual({ + path: 'output.rs', + startLine: 124, + endLine: 126, + }); + }); + it('returns null when line numbers have gap > 1', () => { const lines: Line[] = [ makeLine({ type: 'source', path: 'test.rs', line: 10 }), diff --git a/src/lib/range.ts b/src/lib/range.ts index c6c195bb..75f8c9af 100644 --- a/src/lib/range.ts +++ b/src/lib/range.ts @@ -90,8 +90,13 @@ export function validateRange( // 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) { + // Check for line number discontinuity (gap > 1 indicates portal boundary). + // Skip for diff lines: removed lines number in old-file coordinates while + // added/context lines use new-file coordinates, so adjacent rows in a hunk + // legitimately jump (e.g. context new=124, removed old=123, added new=125). + // Diffs have no portals; hunk/file boundaries are already rejected above + // because their header lines have no line numbers. + if (line.origin.type !== 'diff' && prevLineNum !== null && Math.abs(lineNum - prevLineNum) > 1) { return null; } prevLineNum = lineNum; From bf07f1d1a3fb88694e372cb0d1b086228702e43b Mon Sep 17 00:00:00 2001 From: Denis Olehov <4748206+denolehov@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:09:36 +0200 Subject: [PATCH 02/34] feat(diff): file tree sidebar + palette fuzzy-jump (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd/Ctrl+B toggles a sidebar listing every changed file in a diff with its +/- counts. Clicking a file scrolls to it; the row for the file currently in view stays highlighted. The `:` palette gains a Files namespace that does the same jump by fuzzy search, hidden outside diff sessions. Navigation reads DiffMetadata.files, which was already on the wire and unused. deriveFileEntries() in src/lib/file-tree.ts is the single seam binding the sidebar to the diff wire model. Also fixes current-file tracking, which was wrong before this feature existed: useContentTracking derived the current file from hunk boundaries alone, but a file's header lines sit above its first hunk, so any header line resolved to the *previous* file — visible in the Header breadcrumb when scrolling a file header to the top of the viewport. A separate file-only ContentTracker now resolves currentFileIndex. hunkTracker is left hunk-only because useSelectionBounds walks its boundaries pairwise to clamp selections to a hunk. --- .gitignore | 3 + docs/features.md | 8 +- src/lib/CommandPalette/CommandPalette.svelte | 10 +- src/lib/CommandPalette/Icon.svelte | 6 +- .../CommandPalette/namespaces/files.test.ts | 47 ++++++++ src/lib/CommandPalette/namespaces/files.ts | 37 +++++++ src/lib/CommandPalette/namespaces/index.ts | 18 ++- src/lib/HelpOverlay.svelte | 1 + src/lib/components/FileTree.svelte | 51 +++++++++ .../composables/useContentTracking.svelte.ts | 21 +++- .../composables/useContentTracking.test.ts | 47 ++++++++ src/lib/composables/useFileTree.svelte.ts | 11 ++ src/lib/composables/useKeyboard.svelte.ts | 8 ++ src/lib/composables/useKeyboard.test.ts | 32 ++++++ src/lib/file-tree.test.ts | 73 +++++++++++++ src/lib/file-tree.ts | 47 ++++++++ src/lib/icons/FileIcon.svelte | 10 ++ src/lib/icons/index.ts | 1 + src/routes/+page.svelte | 32 +++++- src/styles/components/code-viewer.css | 1 + src/styles/components/file-tree.css | 103 ++++++++++++++++++ src/styles/index.css | 1 + 22 files changed, 556 insertions(+), 12 deletions(-) create mode 100644 src/lib/CommandPalette/namespaces/files.test.ts create mode 100644 src/lib/CommandPalette/namespaces/files.ts create mode 100644 src/lib/components/FileTree.svelte create mode 100644 src/lib/composables/useFileTree.svelte.ts create mode 100644 src/lib/file-tree.test.ts create mode 100644 src/lib/file-tree.ts create mode 100644 src/lib/icons/FileIcon.svelte create mode 100644 src/styles/components/file-tree.css 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/docs/features.md b/docs/features.md index 6d2749d6..12872bf5 100644 --- a/docs/features.md +++ b/docs/features.md @@ -25,6 +25,8 @@ 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; the row for the file currently in view stays highlighted. The `:` palette's **Files** namespace does the same jump by fuzzy search. + ### Content Review Review agent-generated content — plans, drafts, analysis. Markdown rendering with Mermaid diagrams and portal links that embed live code. @@ -125,7 +127,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 +140,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 +174,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) | diff --git a/src/lib/CommandPalette/CommandPalette.svelte b/src/lib/CommandPalette/CommandPalette.svelte index 0d745a90..40321e8d 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 { FileEntry } from '$lib/file-tree'; import Icon from './Icon.svelte'; // Config type matching Rust @@ -20,6 +21,7 @@ interface Props { tags: Tag[]; exitModes: ExitMode[]; + files?: FileEntry[]; 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..e4afc8d1 --- /dev/null +++ b/src/lib/CommandPalette/namespaces/files.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { setFileItems, getFileItems, filterFileItems } from './files'; +import { createQueryContext } from './index'; +import type { FileEntry } from '$lib/file-tree'; + +function entry(index: number, path: string, startLine: number): FileEntry { + const slash = path.lastIndexOf('/'); + return { + index, + path, + dir: path.slice(0, slash + 1), + name: path.slice(slash + 1), + added: 1, + deleted: 0, + startLine, + }; +} + +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..d0493cc5 --- /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 { FileEntry } from '$lib/file-tree'; +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 metadata; empty for non-diff content +let fileItems: Item[] = []; + +export function setFileItems(entries: FileEntry[]): void { + fileItems = entries.map((entry) => ({ + id: `file-${entry.index}`, + name: entry.path, + values: {}, + action: { type: 'EMIT_EVENT' as const, event: 'JUMP_TO_FILE', payload: entry.startLine }, + })); +} + +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/components/FileTree.svelte b/src/lib/components/FileTree.svelte new file mode 100644 index 00000000..437ec949 --- /dev/null +++ b/src/lib/components/FileTree.svelte @@ -0,0 +1,51 @@ + + + diff --git a/src/lib/composables/useContentTracking.svelte.ts b/src/lib/composables/useContentTracking.svelte.ts index 3e781786..84d898ee 100644 --- a/src/lib/composables/useContentTracking.svelte.ts +++ b/src/lib/composables/useContentTracking.svelte.ts @@ -3,6 +3,10 @@ import type { DiffMetadata, MarkdownMetadata } from '$lib/types'; export function useContentTracking() { let hunkTracker: ContentTracker | null = $state(null); + // Separate from hunkTracker: a file's header lines sit *before* its first hunk, so + // hunk boundaries alone resolve them to the previous file. hunkTracker stays + // hunk-only because useSelectionBounds walks its boundaries to clamp selections. + let fileTracker: ContentTracker<{ fileIndex: number }> | null = $state(null); let sectionTracker: ContentTracker | null = $state(null); let currentFileIndex = $state(0); let currentHunkIndex = $state(0); @@ -21,6 +25,9 @@ export function useContentTracking() { } } hunkTracker = new ContentTracker(boundaries); + fileTracker = new ContentTracker( + meta.files.map((file, fileIndex) => ({ line: file.start_line, data: { fileIndex } })), + ); } function initializeMarkdown(meta: MarkdownMetadata): void { @@ -32,11 +39,21 @@ export function useContentTracking() { } function updateFromLine(lineNum: number): void { + if (fileTracker) { + const boundary = fileTracker.findAt(lineNum); + if (boundary) currentFileIndex = boundary.data.fileIndex; + } if (hunkTracker) { const boundary = hunkTracker.findAt(lineNum); if (boundary) { - currentFileIndex = boundary.data.fileIndex; - currentHunkIndex = boundary.data.hunkIndex; + if (!fileTracker) { + currentFileIndex = boundary.data.fileIndex; + currentHunkIndex = boundary.data.hunkIndex; + } else { + // A hunk belonging to another file means we're in this file's header, + // above its first hunk. + currentHunkIndex = boundary.data.fileIndex === currentFileIndex ? boundary.data.hunkIndex : 0; + } } } if (sectionTracker) { diff --git a/src/lib/composables/useContentTracking.test.ts b/src/lib/composables/useContentTracking.test.ts index 798d577b..768f9eef 100644 --- a/src/lib/composables/useContentTracking.test.ts +++ b/src/lib/composables/useContentTracking.test.ts @@ -85,6 +85,53 @@ describe('useContentTracking', () => { expect(tracking.currentHunkIndex).toBe(1); }); + it('resolves a file header line to its own file, not the file above it', () => { + const tracking = useContentTracking(); + const meta: DiffMetadata = { + files: [ + { + old_name: 'lib.rs', + new_name: 'lib.rs', + language: 'rust', + start_line: 1, + end_line: 20, + hunks: [ + { display_line: 5, old_start: 1, old_count: 5, new_start: 1, new_count: 6, function_context: null, function_context_html: null }, + ], + }, + { + old_name: 'main.rs', + new_name: 'main.rs', + language: 'rust', + start_line: 21, + end_line: 40, + hunks: [ + { display_line: 25, old_start: 1, old_count: 5, new_start: 1, new_count: 5, function_context: null, function_context_html: null }, + ], + }, + ], + }; + + flushSync(() => { + tracking.initializeDiff(meta); + }); + + // Line 21 is main.rs's `diff --git` header — it sits above main.rs's first hunk, + // so hunk boundaries alone would resolve it to lib.rs. + flushSync(() => { + tracking.updateFromLine(21); + }); + expect(tracking.currentFileIndex).toBe(1); + expect(tracking.currentHunkIndex).toBe(0); + + // Inside main.rs's hunk, tracking is unchanged. + flushSync(() => { + tracking.updateFromLine(30); + }); + expect(tracking.currentFileIndex).toBe(1); + expect(tracking.currentHunkIndex).toBe(0); + }); + it('initializes markdown tracker from metadata', () => { const tracking = useContentTracking(); const meta: MarkdownMetadata = { diff --git a/src/lib/composables/useFileTree.svelte.ts b/src/lib/composables/useFileTree.svelte.ts new file mode 100644 index 00000000..4725966d --- /dev/null +++ b/src/lib/composables/useFileTree.svelte.ts @@ -0,0 +1,11 @@ +/** Sidebar visibility. Hidden by default; not persisted across sessions. */ +export function useFileTree() { + let isOpen = $state(false); + + return { + get isOpen() { return isOpen; }, + toggle() { isOpen = !isOpen; }, + open() { isOpen = true; }, + close() { isOpen = false; }, + }; +} diff --git a/src/lib/composables/useKeyboard.svelte.ts b/src/lib/composables/useKeyboard.svelte.ts index 0267a9cc..10dc9486 100644 --- a/src/lib/composables/useKeyboard.svelte.ts +++ b/src/lib/composables/useKeyboard.svelte.ts @@ -16,6 +16,7 @@ export interface KeyboardHandlers { onZoomReset?: () => void; onCommentHoveredLine?: () => void; onSelectAllContent?: () => void; + onToggleFileTree?: () => void; } export interface KeyboardState { @@ -130,6 +131,13 @@ export function useKeyboard(handlers: KeyboardHandlers, state: KeyboardState) { return; } + // Cmd+B to toggle the file tree sidebar + if (e.key === 'b' && (e.metaKey || e.ctrlKey) && !e.altKey && !state.isEditorActive() && !state.isCommandPaletteOpen()) { + e.preventDefault(); + handlers.onToggleFileTree?.(); + return; + } + // Zoom controls if ((e.metaKey || e.ctrlKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); diff --git a/src/lib/composables/useKeyboard.test.ts b/src/lib/composables/useKeyboard.test.ts index 5affef6f..1343aafc 100644 --- a/src/lib/composables/useKeyboard.test.ts +++ b/src/lib/composables/useKeyboard.test.ts @@ -216,4 +216,36 @@ describe('useKeyboard', () => { expect(onCommentHoveredLine).not.toHaveBeenCalled(); }); + + it('calls onToggleFileTree on Cmd+B', () => { + const onToggleFileTree = vi.fn(); + const keyboard = useKeyboard({ onToggleFileTree }, defaultState); + + const event = createKeyboardEvent('b', { metaKey: true }); + keyboard.handleKeyDown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(onToggleFileTree).toHaveBeenCalled(); + }); + + it('does not toggle the file tree while an editor is active', () => { + const onToggleFileTree = vi.fn(); + const keyboard = useKeyboard({ onToggleFileTree }, { + ...defaultState, + isEditorActive: () => true, + }); + + keyboard.handleKeyDown(createKeyboardEvent('b', { metaKey: true })); + + expect(onToggleFileTree).not.toHaveBeenCalled(); + }); + + it('does not toggle the file tree on a bare b', () => { + const onToggleFileTree = vi.fn(); + const keyboard = useKeyboard({ onToggleFileTree }, defaultState); + + keyboard.handleKeyDown(createKeyboardEvent('b')); + + expect(onToggleFileTree).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/file-tree.test.ts b/src/lib/file-tree.test.ts new file mode 100644 index 00000000..4b8c6ab3 --- /dev/null +++ b/src/lib/file-tree.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { deriveFileEntries } from './file-tree'; +import type { DiffFileInfo, DiffMetadata, Line } from './types'; + +function diffLine(kind: 'file_header' | 'added' | 'deleted' | 'context', path = 'a'): Line { + return { + content: '', + html: null, + origin: { type: 'diff', path, old_line: null, new_line: null }, + semantics: { type: 'diff', kind }, + }; +} + +function file(partial: Partial): DiffFileInfo { + return { + old_name: null, + new_name: null, + language: 'ts', + start_line: 1, + end_line: 1, + hunks: [], + ...partial, + }; +} + +describe('deriveFileEntries', () => { + it('returns [] without diff metadata', () => { + expect(deriveFileEntries([], null)).toEqual([]); + }); + + it('splits the path into a dimmable directory prefix and a basename', () => { + const meta: DiffMetadata = { + files: [ + file({ new_name: 'src/lib/types.ts', start_line: 1, end_line: 1 }), + file({ new_name: 'README.md', start_line: 2, end_line: 2 }), + ], + }; + + const entries = deriveFileEntries([diffLine('file_header'), diffLine('file_header')], meta); + + expect(entries[0]).toMatchObject({ path: 'src/lib/types.ts', dir: 'src/lib/', name: 'types.ts' }); + expect(entries[1]).toMatchObject({ path: 'README.md', dir: '', name: 'README.md' }); + }); + + it('falls back to old_name for deleted files', () => { + const meta: DiffMetadata = { files: [file({ old_name: 'gone.ts', new_name: null })] }; + + expect(deriveFileEntries([diffLine('file_header')], meta)[0].path).toBe('gone.ts'); + }); + + it('counts added/deleted lines within each file range', () => { + // display: 1 header, 2 added, 3 deleted, 4 context | 5 header, 6 added + const lines = [ + diffLine('file_header'), + diffLine('added'), + diffLine('deleted'), + diffLine('context'), + diffLine('file_header'), + diffLine('added'), + ]; + const meta: DiffMetadata = { + files: [ + file({ new_name: 'a.ts', start_line: 1, end_line: 4 }), + file({ new_name: 'b.ts', start_line: 5, end_line: 6 }), + ], + }; + + const entries = deriveFileEntries(lines, meta); + + expect(entries[0]).toMatchObject({ index: 0, added: 1, deleted: 1, startLine: 1 }); + expect(entries[1]).toMatchObject({ index: 1, added: 1, deleted: 0, startLine: 5 }); + }); +}); diff --git a/src/lib/file-tree.ts b/src/lib/file-tree.ts new file mode 100644 index 00000000..2890a018 --- /dev/null +++ b/src/lib/file-tree.ts @@ -0,0 +1,47 @@ +import type { DiffMetadata, Line } from './types'; +import { getDiffKind } from './line-utils'; + +/** A changed file in a diff, ready for display in the file tree / palette. */ +export interface FileEntry { + /** Index into DiffMetadata.files */ + index: number; + /** Full path — new_name, falling back to old_name for deletions */ + path: string; + /** Directory prefix, with trailing slash, or '' for root-level files */ + dir: string; + /** Basename */ + name: string; + added: number; + deleted: number; + /** Display index of the file header row */ + startLine: number; +} + +/** + * Derive the file list from diff metadata + rendered lines. + * + * The single place that binds navigation to the diff wire model — when the wire + * model becomes per-file documents, only this function moves. + */ +export function deriveFileEntries(lines: Line[], meta: DiffMetadata | null): FileEntry[] { + if (!meta) return []; + + return meta.files.map((file, index) => { + const path = file.new_name ?? file.old_name ?? ''; + const slash = path.lastIndexOf('/'); + + const kinds = lines + .slice(file.start_line - 1, file.end_line) + .map(getDiffKind); + + return { + index, + path, + dir: path.slice(0, slash + 1), + name: path.slice(slash + 1), + added: kinds.filter((k) => k === 'added').length, + deleted: kinds.filter((k) => k === 'deleted').length, + startLine: file.start_line, + }; + }); +} diff --git a/src/lib/icons/FileIcon.svelte b/src/lib/icons/FileIcon.svelte new file mode 100644 index 00000000..e6f20ada --- /dev/null +++ b/src/lib/icons/FileIcon.svelte @@ -0,0 +1,10 @@ + + + + + diff --git a/src/lib/icons/index.ts b/src/lib/icons/index.ts index 9469ed5b..fa698d82 100644 --- a/src/lib/icons/index.ts +++ b/src/lib/icons/index.ts @@ -25,3 +25,4 @@ export { default as ChatBubbleIcon } from './ChatBubbleIcon.svelte'; export { default as HeadingH1Icon } from './HeadingH1Icon.svelte'; export { default as HeadingH2Icon } from './HeadingH2Icon.svelte'; export { default as HeadingH3Icon } from './HeadingH3Icon.svelte'; +export { default as FileIcon } from './FileIcon.svelte'; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a0efd104..0c606292 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -18,6 +18,9 @@ import Table from "$lib/components/embedded/Table.svelte"; import RegularLines from "$lib/components/embedded/RegularLines.svelte"; import { Header, StatusBar, SessionEditor, WindowResizeHandles } from "$lib/components"; + import FileTree from "$lib/components/FileTree.svelte"; + import { deriveFileEntries } from "$lib/file-tree"; + import { useFileTree } from "$lib/composables/useFileTree.svelte"; import { useExitModes } from "$lib/composables/useExitModes.svelte"; import { useContentTracking } from "$lib/composables/useContentTracking.svelte"; import { useInteraction } from "$lib/composables/useInteraction.svelte"; @@ -81,6 +84,10 @@ let contentEl: HTMLDivElement | null = $state(null); let scrollRafId: number | null = null; + // File tree sidebar (composable) — diff mode only + const fileTree = useFileTree(); + let fileEntries = $derived(deriveFileEntries(lines, diffMetadata)); + // Current file/hunk derived from indices (diff mode) let currentFile = $derived.by(() => { if (!diffMetadata || diffMetadata.files.length === 0) return null; @@ -191,10 +198,16 @@ const lineSegmentation = useLineSegments(() => lines); // Search (composable) - function scrollToDisplayIndex(displayIndex: number) { + function scrollToDisplayIndex(displayIndex: number, block: ScrollLogicalPosition = 'center') { contentEl ?.querySelector(`[data-display-idx="${displayIndex}"]`) - ?.scrollIntoView({ block: 'center' }); + ?.scrollIntoView({ block }); + } + + // File jumps land the file header at the top of the viewport. Centering it would + // leave the *previous* file at the top, which is what current-file tracking reads. + function jumpToFile(startLine: number) { + scrollToDisplayIndex(startLine, 'start'); } const search = useSearch(() => lines, scrollToDisplayIndex); @@ -376,6 +389,9 @@ if (event === 'SET_THEME') { setTheme(payload as ThemePreference); overlay.close(); + } else if (event === 'JUMP_TO_FILE') { + overlay.close(); + jumpToFile(payload as number); } } @@ -575,6 +591,7 @@ onCloseWindow: () => getCurrentWindow().close(), onOpenSearch: () => search.open(), onOpenHelp: () => overlay.openHelp(), + onToggleFileTree: () => { if (diffMetadata) fileTree.toggle(); }, onZoomIn: () => contentZoom = Math.min(contentZoom + 0.1, 3.0), onZoomOut: () => contentZoom = Math.max(contentZoom - 0.1, 0.5), onZoomReset: () => contentZoom = 1.0, @@ -752,6 +769,15 @@ +
+ {#if fileTree.isOpen && diffMetadata} + + {/if} +
+
@@ -843,6 +870,7 @@ Date: Fri, 10 Jul 2026 17:53:28 +0200 Subject: [PATCH 03/34] feat(diff): per-file collapse with sticky headers + changeset summary (#88) * chore: untrack specs * feat(diff): per-file collapse with sticky headers + changeset summary Every file in a diff gets a sticky header bar (chevron, dir-muted path, +/- counts) that collapses the file to just its header. Files with more than 500 changed lines start collapsed. The titlebar shows 'N files changed, +A -D' with a fold-all/unfold-all toggle; search hits and file jumps auto-expand collapsed files. Collapse is pure presentation: the lines array is never mutated, so display-index-keyed annotations keep resolving to the same content. Backend: split the lumped DiffLineKind::Header into FileHeader (the diff --git line), HunkHeader (@@, now constructing DiffSemantics:: HunkHeader with function context), and Meta (index/---/+++/mode lines, never rendered, skipped by search, non-selectable). Fix current-hunk tracking under sticky headers: the position probe now uses elementsFromPoint to prefer the line row covered by a stuck header instead of freezing on the file's first hunk. --- .specs.local/diff-redesign/00-overview.md | 82 ----------- .../diff-redesign/a1-annotation-entities.md | 66 --------- .../diff-redesign/a2-frontend-rekey.md | 27 ---- .specs.local/diff-redesign/b1-file-source.md | 78 ----------- .../diff-redesign/b2-raw-enumerator.md | 69 --------- .specs.local/diff-redesign/b3-diff-engine.md | 86 ------------ .../diff-redesign/b4-pipeline-swap.md | 25 ---- .../diff-redesign/c1-wire-model-v2.md | 25 ---- .../diff-redesign/o1-output-mixed-ranges.md | 21 --- .specs.local/diff-redesign/s1-file-tree.md | 59 -------- .../diff-redesign/s2-file-collapse.md | 61 -------- .specs.local/diff-redesign/s3-unfold.md | 24 ---- .specs.local/diff-redesign/s4-split-view.md | 21 --- .../diff-redesign/s5-word-highlights.md | 20 --- docs/features.md | 4 +- src-tauri/src/diff.rs | 73 ++++++++-- src-tauri/src/state.rs | 14 +- .../CommandPalette/namespaces/files.test.ts | 1 + src/lib/components/FileTree.svelte | 9 +- src/lib/components/Header.svelte | 30 ++++ .../components/embedded/FileHeaderRow.svelte | 40 ++++++ .../components/embedded/RegularLines.svelte | 44 ++++-- src/lib/composables/useFileCollapse.svelte.ts | 58 ++++++++ src/lib/composables/useFileCollapse.test.ts | 77 ++++++++++ src/lib/composables/useSearch.svelte.ts | 3 + src/lib/context/AnnotProvider.svelte | 8 ++ src/lib/context/annot-context.svelte.ts | 5 + src/lib/file-collapse.test.ts | 131 ++++++++++++++++++ src/lib/file-collapse.ts | 67 +++++++++ src/lib/file-tree.test.ts | 26 +++- src/lib/file-tree.ts | 11 ++ src/lib/icons/ChevronDownUpIcon.svelte | 11 ++ src/lib/icons/ChevronUpDownIcon.svelte | 10 ++ src/lib/icons/index.ts | 2 + src/lib/line-utils.ts | 4 +- src/lib/types.ts | 1 + src/routes/+page.svelte | 61 +++++++- src/styles/components/diff-file-header.css | 89 ++++++++++++ src/styles/index.css | 1 + 39 files changed, 738 insertions(+), 706 deletions(-) delete mode 100644 .specs.local/diff-redesign/00-overview.md delete mode 100644 .specs.local/diff-redesign/a1-annotation-entities.md delete mode 100644 .specs.local/diff-redesign/a2-frontend-rekey.md delete mode 100644 .specs.local/diff-redesign/b1-file-source.md delete mode 100644 .specs.local/diff-redesign/b2-raw-enumerator.md delete mode 100644 .specs.local/diff-redesign/b3-diff-engine.md delete mode 100644 .specs.local/diff-redesign/b4-pipeline-swap.md delete mode 100644 .specs.local/diff-redesign/c1-wire-model-v2.md delete mode 100644 .specs.local/diff-redesign/o1-output-mixed-ranges.md delete mode 100644 .specs.local/diff-redesign/s1-file-tree.md delete mode 100644 .specs.local/diff-redesign/s2-file-collapse.md delete mode 100644 .specs.local/diff-redesign/s3-unfold.md delete mode 100644 .specs.local/diff-redesign/s4-split-view.md delete mode 100644 .specs.local/diff-redesign/s5-word-highlights.md create mode 100644 src/lib/components/embedded/FileHeaderRow.svelte create mode 100644 src/lib/composables/useFileCollapse.svelte.ts create mode 100644 src/lib/composables/useFileCollapse.test.ts create mode 100644 src/lib/file-collapse.test.ts create mode 100644 src/lib/file-collapse.ts create mode 100644 src/lib/icons/ChevronDownUpIcon.svelte create mode 100644 src/lib/icons/ChevronUpDownIcon.svelte create mode 100644 src/styles/components/diff-file-header.css diff --git a/.specs.local/diff-redesign/00-overview.md b/.specs.local/diff-redesign/00-overview.md deleted file mode 100644 index 82faf7cb..00000000 --- a/.specs.local/diff-redesign/00-overview.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -id: overview -status: living ---- - -# Diff Redesign — DAG Overview - -GitHub-shaped diff review: file tree, per-file collapse, unfold between hunks, -unified/split toggle, word-level highlights. Full settled context: see the -grill session restatement (2026-07-10). Anchor: getting lost in big multi-file diffs. - -## Conventions - -- One file per node. Frontmatter: `id`, `kind` (refactor|story), `wave`, - `depends_on` (list of node ids), `status`. -- `status: ready` — full /design-spec shape, implementable by a fresh session. -- `status: fogged` — primer only: goal, edge rationale, settled constraints, - pointers. **Clear the fog before starting**: when a fogged node's deps are - done, rewrite it into full spec shape (aim may have shifted — that's the point). -- `status: done` — landed; keep for archaeology. - -## Settled decisions (do not relitigate in node sessions) - -1. **Substrate (b)**: git mode loads full texts per side (`git diff --raw` → - OIDs → `git cat-file --batch`; zero OID = working tree = fs read) and - computes hunks in-process. Patch parsing (`unidiff` in `src-tauri/src/diff.rs`) - survives only for raw `diff_content` — which gets **no unfold affordance**. -2. **Annotation identity**: id-keyed entities; anchor is two-endpoint - `{path, start: {side, line}, end: {side, line}}` (mixed-side ranges cover - replacements, GitHub's `start_side`/`side` model). Context lines anchor - new-side (old for deleted files). Display index = ephemeral selection only. -3. **Content-source seam**: `side → full text | None` trait. Tiers: - RawPatch → GitShell → (parked) JjLib. -4. **Chrome**: tree = toggleable sidebar + palette fuzzy-jump, hidden by default; - files expanded by default, auto-collapse huge ones; unified default, split - behind persisted toggle. - -**Parked (seams only, no nodes)**: jj-lib tier, stateful reviews / -changed-since-amend, detached reviews + agent threads, in-window file editing, -commit-metadata amending, viewed-checkboxes, staging. - -## The DAG - -```mermaid -graph TD - S1[S1 file tree + palette jump] - S2[S2 per-file collapse + header] - A1[A1 backend annotation entities] --> A2[A2 frontend id-keyed store] - A1 --> O1[O1 output mixed-side ranges] - B1[B1 FileSource trait] --> B4[B4 git pipeline swap] - B2[B2 git diff --raw enumerator] --> B4 - B3[B3 diff engine + word diffs] --> B4 - A2 --> C1[C1 wire model v2: per-file docs] - B4 --> C1 - C1 --> S3[S3 unfold] - C1 --> S4[S4 split view] - C1 --> S5[S5 word-level highlights] - B1 --> S3 - classDef story fill:#1f6feb22,stroke:#1f6feb - class S1,S2,S3,S4,S5 story -``` - -## Waves - -| Wave | Lanes (parallel) | -|---|---| -| 0 | S1, S2 · A1 · B1, B2, B3 | -| 1 | A2 · B4 · O1 | -| 2 | C1 (the join — keep it thin: mostly reshaping + deletion) | -| 3 | S5 (cheapest) · S3 · S4 | - -Critical path: **B3 → B4 → C1 → S4**. Trunk A has slack. - -## Method (why the graph looks like this) - -- Nodes are the codebase's current *lies* flipped true (T1 identity, T2 full - texts, T3 computed hunks, T4 per-file wire model); stories are thin leaves - consuming truths. -- An edge exists only if "this diff gets smaller/safer if that lands first". -- Every node leaves the repo green and shippable. B4 is the strangler swap: - new producer, old contract. C1 changes the contract once, after both trunks. -- S1/S2 ride the *current* model for early value; they take a small refit at C1. diff --git a/.specs.local/diff-redesign/a1-annotation-entities.md b/.specs.local/diff-redesign/a1-annotation-entities.md deleted file mode 100644 index 0cb743a9..00000000 --- a/.specs.local/diff-redesign/a1-annotation-entities.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -id: A1 -kind: refactor -wave: 0 -depends_on: [] -status: ready ---- - -# [Spec]: A1 — Backend annotation entities (id + anchor) - -## Requirements -- **Problem:** Annotations are keyed by position — `HashMap` per target (`src-tauri/src/review.rs:113-115`), no identity, no side. Unfold/split/threads/stateful reviews all need annotations that survive position changes. -- **Beneficiary:** Every downstream node (A2, C1, S3, S4) plus parked features (threads need ids, stateful reviews need source anchors). -- **Done when:** Backend stores `Annotation { id, anchor, content }` keyed by id; IPC takes id + anchor; `pnpm test:rust` green with **unchanged** output snapshots. - -## Entities - -``` -Side = Old | New -Endpoint = { side: Side, line: u32 } // line = 1-indexed source line -Anchor = { path: String, start: Endpoint, end: Endpoint } -Annotation = { id: String /* uuid v4, dep exists */, anchor: Anchor, content: Vec } - -AnnotationTarget.annotations: HashMap // current -AnnotationTarget.annotations: IndexMap // proposed (indexmap is a dep; preserves insertion order for output) -``` - -Degenerate cases: file/content/markdown modes use `side: New` everywhere — one -anchor type covers all three review modes. `start == end` for single-line. - -## Approach - -**Keystone:** the id is the identity; the anchor is a mutable property. -Rejected alternative: keeping position-composite keys (`LineRange`) — that -makes "same annotation, moved anchor" unrepresentable, which kills threads and -re-anchoring after re-diff. - -Sequence: -1. New types (`Side`, `Endpoint`, `Anchor`) in `review.rs` (or new `anchor.rs`), serde-derived. -2. Re-key `AnnotationTarget.annotations`; rewrite `upsert_annotation`/`delete_annotation` (`review.rs:490-508`) to take `(id, anchor, content)` / `(id)`. -3. IPC: `upsert_annotation`/`delete_annotation` commands (`src-tauri/src/commands.rs:58-82`) accept the new shape. Frontend call site (`src/lib/composables/useAnnotations.svelte.ts` pending-sync block) adapts *minimally*: generate uuid client-side at creation, map its existing `coords` (path/start/end source lines) into an Anchor with `side` from `line.origin` (old_line-only lines → Old, else New). Full frontend re-keying is A2, not here. -4. Output builder (`src-tauri/src/output/`) iterates entities instead of range-keyed map. Ordering: sort by (file, anchor position) to keep snapshots byte-identical. -5. Tag-usage walk (`commands.rs:140-160`) — mechanical update. - -**Seams:** -- `id` field → detached-review threads (parked): replies will reference annotation ids. -- `Anchor.side` → S4 split view annotates either column with no model change. - -## Structure -- `src-tauri/src/review.rs` — types, storage, upsert/delete -- `src-tauri/src/commands.rs` — IPC signatures -- `src-tauri/src/output/builder.rs` — consume entities, keep rendering identical -- `src/lib/composables/useAnnotations.svelte.ts` — thin adapter only (id generation + side mapping) - -## Norms -- Declarative style (map/collect) per CLAUDE.md. -- Insta snapshot workflow: `cargo test` → `cargo insta review` — but the bar here is **zero snapshot churn** (O1 is where output changes). - -## Safeguards -- Two annotations on the same range must coexist (new capability — add a test). -- Mixed-side anchors (`start.side != end.side`) must serialize/deserialize round-trip (test now, rendered in O1). -- Frontend behavior unchanged: create/edit/delete annotation in `pnpm demo:diff` works as before. - -## Scope -- In: backend model, IPC shape, minimal frontend adapter, tests. -- Out: frontend store re-keying (A2), output format changes (O1), any UI. diff --git a/.specs.local/diff-redesign/a2-frontend-rekey.md b/.specs.local/diff-redesign/a2-frontend-rekey.md deleted file mode 100644 index ec7806f3..00000000 --- a/.specs.local/diff-redesign/a2-frontend-rekey.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -id: A2 -kind: refactor -wave: 1 -depends_on: [A1] -status: fogged ---- - -# Primer: A2 — Frontend id-keyed annotation store - -> Fogged. Clear before starting: rewrite into full spec shape once A1 has landed -> (its final IPC shape and any surprises feed this). - -**Goal:** Frontend annotations keyed by id with `{path, start:{side,line}, end:{side,line}}` anchors; display index demoted to ephemeral selection state. - -**Why after A1:** the backend contract (id + anchor IPC) must exist to key against; A1 also leaves a thin frontend adapter marking exactly the seams this node replaces. - -**Why before C1:** lands against the *current flat model* so the C1 join doesn't change identity and wire shape simultaneously. `line.origin` already carries `old_line`/`new_line` (`src/lib/types.ts:6-9`) — anchors are computable today. - -**Settled constraints:** -- Anchor computed from selection at *creation*; resolved anchor → row at *render* (needs an anchor→displayIndex map derived from the lines array). -- Context lines anchor new-side; old-side only for deleted files. -- Mixed-side ranges (replacement spans) must be creatable from a unified-view selection. - -**Blast radius (from A1-era code):** `src/lib/range.ts` (display-index Range dies here), `useAnnotations.svelte.ts` (rangeToKey store → id map), `useAnnotationEditor`, `useHistory` (undo/redo references), `AnnotationSlot.svelte` / `LineRow.svelte` (render lookup), selection plumbing in `useInteraction`. - -**Risk to plan around:** undo/redo history currently stores range keys — decide entity-level history semantics before coding. diff --git a/.specs.local/diff-redesign/b1-file-source.md b/.specs.local/diff-redesign/b1-file-source.md deleted file mode 100644 index f21cf471..00000000 --- a/.specs.local/diff-redesign/b1-file-source.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -id: B1 -kind: refactor -wave: 0 -depends_on: [] -status: ready ---- - -# [Spec]: B1 — FileSource trait (content-source seam) - -## Requirements -- **Problem:** Annot only ever holds patch text; nothing can answer "give me the full file at side X", which unfold (S3) and substrate (B4) require. -- **Beneficiary:** B4 (loads both sides), S3 (slices gap lines), parked jj tier (this trait is its front door). -- **Done when:** `GitShellSource` returns full text for old/new sides of files in a real repo (unit-tested against a fixture repo); `RawPatchSource` returns `None`. - -## Entities - -```rust -pub enum Side { Old, New } // shared with A1's anchor types - -pub trait FileSource: Send + Sync { - /// Full text of the file at `path` on `side`. Ok(None) = unavailable - /// (raw patch mode, binary, or side doesn't exist e.g. Old of an added file). - fn full_text(&self, path: &str, side: Side) -> Result>, AnnotError>; -} - -pub struct GitShellSource { - repo_root: PathBuf, - /// (path, side) -> oid, from B2's enumerator. Zero oid => working tree (fs read). - oids: HashMap<(String, Side), Option>, - cache: Mutex>>, -} - -pub struct RawPatchSource; // full_text => Ok(None), always -``` - -## Approach - -**Keystone:** fetch *whole files*, cached, never gap slices — every later -unfold is then a local slice (Zed and hunk both converged here). Rejected -alternative: per-gap `git show file:40-60`-style queries — N round trips, -no reuse, and git can't even express line ranges for blobs. - -- Blob retrieval: single `git cat-file --batch` child process, lazily spawned, - fed `\n` lines, response header ` ` then payload. - One process for the whole session (Zed's pattern), not one `git show` per file. -- Working-tree side (zero oid): `std::fs::read_to_string(repo_root.join(path))`. -- Size cap ~1 MB per file (hunk's guard) → treat oversize as `Ok(None)` - (unfold affordance simply won't render). -- `Ok(None)` is the **capability signal**: UI derives "can unfold?" from it — - no separate mode flag to keep in sync. - -**Seams:** -- Parked JjLib tier = third impl backed by `materialize_tree_value` (returns - full file contents — plugs in directly). -- `Arc` return → B4 holds the same allocation for diffing; no copies. - -## Structure -- New: `src-tauri/src/source.rs` (trait + both impls + tests) -- `src-tauri/src/lib.rs` — module registration -- Consumed later by B4 (pipeline) and S3 (unfold IPC); no call sites yet — this node is deliberately standalone. - -## Operations -1. `Side` + trait + `RawPatchSource` (trivial) + unit test. -2. `GitShellSource::new(repo_root, oids)`; batch process management (spawn on first use, restart on death). -3. Fixture-repo tests: added/deleted/modified/renamed file × old/new side; oversize file → `None`; binary → `None`. - -## Norms -- Errors via `AnnotError` (`src-tauri/src/error.rs`), thiserror. -- No tokio needed — synchronous child-process I/O is fine here (commands calling it are already async or on the blocking pool). - -## Safeguards -- Never panic on missing oid/path — `Ok(None)`. -- Batch process death mid-session must not poison the session: respawn once, then degrade to `None`. - -## Scope -- In: trait, two impls, fixture tests. -- Out: enumeration of oids (B2 provides), any UI, jj impl (parked). diff --git a/.specs.local/diff-redesign/b2-raw-enumerator.md b/.specs.local/diff-redesign/b2-raw-enumerator.md deleted file mode 100644 index 2f0cf977..00000000 --- a/.specs.local/diff-redesign/b2-raw-enumerator.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -id: B2 -kind: refactor -wave: 0 -depends_on: [] -status: ready ---- - -# [Spec]: B2 — `git diff --raw` enumerator - -## Requirements -- **Problem:** Substrate (b) needs, per changed file, the two blob identities — but `git_diff_args` are arbitrary user/agent strings (`review_diff` just appends them: `src-tauri/src/mcp/mod.rs:171-191`). Annot must not parse revision semantics itself. -- **Beneficiary:** B4 (file list + oids feed B1), and the file tree gets rename/status data better than patch parsing gives. -- **Done when:** `enumerate(args)` returns correct entries for modified/added/deleted/renamed/working-tree cases against a fixture repo. - -## Entities - -```rust -pub enum FileStatus { Modified, Added, Deleted, Renamed { similarity: u8 }, Copied, TypeChanged } - -pub struct FileEntry { - pub status: FileStatus, - pub old_path: Option, // None for Added - pub new_path: Option, // None for Deleted - pub old_oid: Option, // None = nonexistent side; Some(ZERO) normalized to WorkingTree below - pub new_oid: Option, -} -pub enum BlobRef { Oid(String), WorkingTree } // zero oid => WorkingTree -``` - -## Approach - -**Keystone:** run `git diff --raw -z ` and let git -resolve every revision/pathspec question; annot only parses the stable `--raw` -record format. Rejected alternative: interpreting `` (revs, ranges, -`--staged`, pathspecs) ourselves — an open-ended reimplementation of git CLI -semantics with permanent drift risk. - -Record format (NUL-separated with `-z`): -`: [score]\0[\0]\0` -— R/C carry two paths; abbreviated oids avoided via `--no-abbrev`. - -Flow: `git diff --raw -z --no-abbrev ` → parse → `Vec` → -(B4) build B1's oid map + drive per-file diffing. - -**Seams:** -- `FileStatus::Renamed` → file tree shows `old → new` (S1 refit at C1). -- Same enumerator later serves "changed since op X" listings in the parked jj tier (different producer, same `FileEntry`). - -## Structure -- New: `src-tauri/src/vcs.rs` (or fold into `source.rs`'s sibling — implementer's call, keep it out of `diff.rs` which is the legacy patch parser) -- Callers: none yet (B4 wires it). Standalone + tests, like B1. - -## Operations -1. Runner: `Command::new("git").args(["diff","--raw","-z","--no-abbrev"]).args(user_args)` with cwd = session cwd; capture stderr for error surfacing (mirror `mcp/mod.rs:180-183`). -2. `-z` record parser (NUL split; R/C two-path handling; score suffix on status letter). -3. Zero-oid normalization → `BlobRef::WorkingTree`. -4. Fixture tests: M/A/D/R cases; `--staged`; rev-range (`HEAD~1..HEAD`); pathspec filter; empty diff. - -## Norms -- Same subprocess discipline as existing `run_diff_session` (error string from stderr). - -## Safeguards -- Unparseable record → error, not silent skip (a missing file in review is worse than a failed session). -- Must handle paths with spaces/unicode (that's what `-z` is for — test it). - -## Scope -- In: runner, parser, fixture tests. -- Out: fetching content (B1), diffing (B3), wiring (B4), submodule/binary special-casing beyond "mark and pass through". diff --git a/.specs.local/diff-redesign/b3-diff-engine.md b/.specs.local/diff-redesign/b3-diff-engine.md deleted file mode 100644 index f56b0eba..00000000 --- a/.specs.local/diff-redesign/b3-diff-engine.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -id: B3 -kind: refactor -wave: 0 -depends_on: [] -status: ready ---- - -# [Spec]: B3 — In-process diff engine + word diffs - -## Requirements -- **Problem:** Hunks are parsed from `git diff` text (`unidiff` in `src-tauri/src/diff.rs:87`), so annot can only know what the patch says — no re-diff, no word-level ranges, no control over context. -- **Beneficiary:** B4 (hunk computation), S5 (word highlights fall out), S4 (old↔new line mapping for split pairing), future re-diff on file change. -- **Done when:** `compute_hunks(old, new, context)` matches `git diff` semantics on a corpus of fixture pairs (insta-snapshotted), word diffs emitted for small hunks. - -## Entities - -```rust -pub struct FileDiff { - pub hunks: Vec, -} -pub struct Hunk { - pub old_range: Range, // 1-indexed lines in old text (incl. context) - pub new_range: Range, - pub rows: Vec, // ordered: context | deleted | added -} -pub enum DiffRow { - Context { old_line: u32, new_line: u32 }, - Deleted { old_line: u32, word_ranges: Vec> }, // byte ranges within the line - Added { new_line: u32, word_ranges: Vec> }, -} -``` - -Pure function, no I/O: -`pub fn compute_hunks(old: &str, new: &str, context: u32) -> FileDiff` - -## Approach - -**Keystone:** hunks become a *derived overlay over two full texts* (Zed's -model), computed by us — the patch is no longer the source of truth for git -mode. Rejected alternative: keep parsing git's patch and bolt word-diffs on -top — leaves T3 false, blocks re-diff, and word alignment against parsed -text is guess-work. - -**Engine choice — UNSETTLED, decide at implementation start:** -- `similar = "3"` is **already a dependency with the `inline` feature** - (Cargo.toml) — `TextDiff` line diffs + built-in inline (word-level) change - ranges. No new dep. Algorithms: Myers/Patience/LCS (no Histogram). -- `imara-diff` — what Zed uses; Histogram algorithm (better hunk quality on - code), faster; new dep, word-diff hand-rolled (token-level second pass). -- Lean: **similar-first** — the engine hides behind `compute_hunks`, so a swap - is contained if hunk quality or perf disappoints. The signature is the - contract; the crate is an implementation detail. - -Word-diff gate (Zed's discipline): only when a hunk's deleted/added line -counts are equal and ≤ 5 lines; token-level, word boundaries. Prevents -noise-highlighting on rewrites. - -Note: `--diff-algorithm` differences mean output may diverge cosmetically from -`git diff`. Accepted at design time (grill session). - -**Seams:** -- `context` param → S3 unfold and a future "more context" setting share the machinery. -- Old↔new line mapping implicit in `rows` → S4 split pairing walks it directly. - -## Structure -- New: `src-tauri/src/engine.rs` (name TBD; NOT in `diff.rs` — that stays the legacy patch parser until C1 shrinks it to raw-mode-only) -- Heavy unit + insta tests: `src-tauri/src/engine.rs` tests module, snapshots beside existing `output/snapshots/` pattern. - -## Operations -1. Line diff → grouped ops → hunk assembly with `context` merging (adjacent hunks whose context overlaps merge — mirrors git). -2. Line-number bookkeeping (the four running counters: old/new × index/line-number). -3. Word-diff pass on gated hunks; byte ranges per line. -4. Corpus tests: empty→content, content→empty, pure add/delete, replacement, adjacent-hunk merge, no-trailing-newline, CRLF, unicode. - -## Norms -- Pure function, zero I/O — the most unit-testable node in the graph; build and trust it first. -- Declarative style per CLAUDE.md. - -## Safeguards -- Property test worth writing: reconstructing `new` from `old` + hunks round-trips exactly (including trailing-newline edge cases). -- Word ranges are byte offsets into the line — must slice at char boundaries (test with multibyte). - -## Scope -- In: engine, word diffs, corpus tests. -- Out: file loading (B1), enumeration (B2), rendering/HTML, syntax highlighting, wiring (B4). diff --git a/.specs.local/diff-redesign/b4-pipeline-swap.md b/.specs.local/diff-redesign/b4-pipeline-swap.md deleted file mode 100644 index 970a376c..00000000 --- a/.specs.local/diff-redesign/b4-pipeline-swap.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -id: B4 -kind: refactor -wave: 1 -depends_on: [B1, B2, B3] -status: fogged ---- - -# Primer: B4 — Git pipeline swap (strangler node) - -> Fogged. Clear before starting: B1/B2/B3's real APIs replace the sketches here. - -**Goal:** Git mode (`git_diff_args`) stops parsing patch text. New pipeline: B2 enumerates files/oids → B1 fetches both sides' full text → B3 computes hunks → **render into the existing flat `Line` stream** (`LineOrigin::Diff` + `DiffSemantics` rows, `DiffMetadata` populated as today). The wire contract does not change; if B4 lands and nobody notices, it worked. - -**Why after B1+B2+B3:** it is pure composition of the three; doing any of their work inline here fattens the riskiest kind of node (a producer swap). - -**Settled constraints:** -- `unidiff`/`parse_diff` (`src-tauri/src/diff.rs:87`) survives, but only reachable from raw `diff_content` mode (and CLI stdin patches). -- Entry point today: `run_diff_session` (`src-tauri/src/mcp/mod.rs:164`) and the CLI diff path in `lib.rs` — both route through `ContentModel::from_diff`; this node forks git-args mode to `ContentModel::from_git(...)`. -- Full texts + `FileSource` stay alive in session state after load — S3 (unfold) and re-diff need them; don't drop after rendering. -- Syntax highlighting: current pipeline highlights via `highlight.rs`/`syntect` — new pipeline must produce equivalent `html` per line. - -**Verification bar:** side-by-side session outputs (old parser vs new pipeline) on real repos agree on file list, hunk boundaries (modulo settled cosmetic divergence), line origins. Existing insta output snapshots stay green. - -**Risk to plan around:** hunk-boundary cosmetic divergence from `git diff` (different algorithm defaults) — accepted at design time, but eyeball a corpus before trusting it. diff --git a/.specs.local/diff-redesign/c1-wire-model-v2.md b/.specs.local/diff-redesign/c1-wire-model-v2.md deleted file mode 100644 index 0ef78507..00000000 --- a/.specs.local/diff-redesign/c1-wire-model-v2.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -id: C1 -kind: refactor -wave: 2 -depends_on: [A2, B4] -status: fogged ---- - -# Primer: C1 — Wire model v2 (per-file documents) — THE JOIN - -> Fogged, deliberately: this node is shaped by what A2 and B4 leave behind. -> Keep it starved — mostly reshaping and deletion. If it's accumulating logic, -> something belonged in A2/B4 and should be pushed back. - -**Goal:** `ContentResponse` for diff mode becomes per-file documents — `{ path, status, hunks, rows }`, rows carrying `(side, old_line, new_line)` — replacing the single flat `Vec` for diffs. Frontend renders per-file sections. Flat contract retired for diff mode (file/markdown modes keep theirs). - -**Why after A2:** annotations no longer key on display index, so restructuring the render array breaks nothing. -**Why after B4:** backend already *has* the per-file model internally; this node exposes it instead of flattening it. - -**Settled constraints:** -- S1/S2 refit here: tree binds to documents (rename `old → new` display arrives), collapse becomes structural instead of render-skip. Their specs name the single derivation points to rebind. -- `review.rs` already tracks per-file `AnnotationTarget`s (`FileKey::diff_file(index)`) — backend identity mostly survives; it's the wire + frontend spine that changes. -- Virtual scrolling consideration: per-file sections change the scroll container geometry — check `adaptiveScrollOverscan`-style logic if any exists frontend-side before assuming free. - -**Exit criteria to write when clearing fog:** demo:diff renders identically (modulo settled cosmetics) through the new model; annotations created pre-C1 sessions aren't a concern (sessions are ephemeral — no migration). diff --git a/.specs.local/diff-redesign/o1-output-mixed-ranges.md b/.specs.local/diff-redesign/o1-output-mixed-ranges.md deleted file mode 100644 index e30ed51f..00000000 --- a/.specs.local/diff-redesign/o1-output-mixed-ranges.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -id: O1 -kind: refactor -wave: 1 -depends_on: [A1] -status: fogged ---- - -# Primer: O1 — Output rendering for mixed-side ranges - -> Fogged. Clear before starting once A1's entity shape is final. - -**Goal:** Structured output (`src-tauri/src/output/`) renders two-endpoint anchors, including mixed-side ranges (annotation spanning deleted+added replacement). The existing `old:new` gutter format (`file.rs (old:2)` style — see `output/snapshots/annot_lib__output__snapshot_tests__diff_annotation_deleted_line.snap`) already speaks sides; this extends it to ranges whose endpoints sit on different sides. - -**Why after A1:** consumes the entity model; A1 deliberately kept snapshots byte-identical, this node is where snapshot churn is *allowed*. - -**Settled constraints:** -- Agents parse this output — the format is a contract. Additive/unambiguous changes only; keep single-side annotations rendering exactly as today. -- Insta workflow: `cargo test` → `cargo insta review` → commit `.snap`. - -**Design question to settle when clearing fog:** how a mixed-side range names itself in the header — e.g. `file.rs (old:2 → new:5)` — pick something an LLM can't misread, add corpus snapshots for: old-only, new-only, mixed, multi-line each. diff --git a/.specs.local/diff-redesign/s1-file-tree.md b/.specs.local/diff-redesign/s1-file-tree.md deleted file mode 100644 index 54f57ca3..00000000 --- a/.specs.local/diff-redesign/s1-file-tree.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -id: S1 -kind: story -wave: 0 -depends_on: [] -status: done ---- - -# [Spec]: S1 — File tree sidebar + palette fuzzy-jump - -## Requirements -- **Problem:** In a big multi-file diff there is no way to see which files changed or jump to one — the anchor pain ("I get lost") in its purest form. -- **Beneficiary:** Anyone reviewing multi-file diffs; ships on the *current* model, no trunk dependency. -- **Done when:** In `pnpm demo:diff` (and a real multi-file `git_diff_args` session): sidebar toggles via shortcut, clicking a file scrolls to its header; `:` palette has a files namespace with fuzzy jump. - -## Entities -N/A — consumes existing `DiffMetadata.files: DiffFileInfo[]` (`src/lib/types.ts:112-119`; `start_line`/`end_line` per file), which is currently computed and unused by any navigation. - -## Approach - -**Keystone:** navigation reads existing metadata; zero backend changes. -Rejected alternative: waiting for C1's per-file documents — weeks of delay for -data that's already on the wire. - -- Sidebar: new `FileTree.svelte`, hidden by default, toggled by shortcut + - palette action. Flat list with directory-prefix grouping (GitHub-style - nested/collapsed-dir tree is a later nicety — flat first). -- Row: filename, dimmed dir prefix, +/− counts (derivable by counting - added/deleted semantics lines within `start_line..end_line`). -- Click → scroll the line list to the file's `start_line` (same scroll - mechanism the search feature uses — see `useSearch.svelte.ts`). -- Palette: new `files` namespace following the existing pattern - (`src/lib/CommandPalette/namespaces/theme.ts` — `Namespace` + `Item[]` with - `EMIT_EVENT` actions, `fuzzySearch` from `$lib/fuzzy`). -- Current-file tracking (highlight in tree while scrolling): IntersectionObserver - or scroll-position → binary search over `start_line`s. - -**Seams:** -- Refit at C1: tree rebinds from `metadata.files` to per-file documents; keep data access behind one derivation function so the refit touches one place. -- Rename display (`old → new`) arrives with B2/C1 data; flat name until then. - -## Structure -- New: `src/lib/components/FileTree.svelte` -- New: `src/lib/CommandPalette/namespaces/files.ts` (+ register in `namespaces/index.ts`) -- `src/routes/+page.svelte` — layout slot for sidebar -- `src/lib/HelpOverlay.svelte` + `docs/features.md` — shortcut + feature docs (CLAUDE.md requires both) - -## Norms -- Composables pattern for any state (`src/lib/composables/`). -- Frontend tests mock Tauri IPC via `vi.mock("@tauri-apps/api/core")`. -- Only render for `metadata.type === 'diff'`. - -## Safeguards -- Sidebar must not steal keyboard focus from the line list (annot is keyboard-driven — verify j/k/selection still work with sidebar open). -- No layout shift of the line list content that would confuse in-flight selection. - -## Scope -- In: sidebar, palette namespace, scroll-jump, current-file highlight, shortcut, help/docs updates. -- Out: collapse (S2), viewed-state (parked), nested dir tree, rename arrows (post-C1 refit). diff --git a/.specs.local/diff-redesign/s2-file-collapse.md b/.specs.local/diff-redesign/s2-file-collapse.md deleted file mode 100644 index 66b3d184..00000000 --- a/.specs.local/diff-redesign/s2-file-collapse.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -id: S2 -kind: story -wave: 0 -depends_on: [] -status: ready ---- - -# [Spec]: S2 — Per-file collapse + "N files changed" header - -## Requirements -- **Problem:** A 15-file diff is one undifferentiated wall; you can't set aside files you're done with or don't care about, and nothing summarizes the changeset. -- **Beneficiary:** Multi-file diff reviewers; ships on the current model. -- **Done when:** File headers show a collapse chevron; collapsing hides the file's lines; a summary header shows "N files changed, +A −D"; existing annotations still resolve correctly after collapse/expand cycles. - -## Entities -N/A — presentation state only: `collapsedFiles: Set` in a small composable. - -## Approach - -**Keystone:** collapse is **render-skip, never array mutation** — the `lines` -array and therefore every display index stays byte-identical, because -annotations are still display-index-keyed until A2 lands. Rejected -alternative: filtering the lines array — silently detaches every annotation -below the first collapsed file. - -- File boundaries from `DiffFileInfo.start_line/end_line` (same data as S1). -- Render loop (`RegularLines.svelte`) skips rows whose index falls inside a - collapsed file's range (keep the `file_header` row visible as the collapsed - bar, GitHub-style: path, +/− counts, chevron). -- Summary header: derive counts once from `metadata.files` + line semantics - (`added`/`deleted`), render above the first file. -- Auto-collapse: files whose changed-line count exceeds a threshold (~500) - start collapsed, like GitHub's "Load diff" barrier. Threshold is a constant, - not config, until someone asks. -- Selection interaction: if the cursor/selection sits inside a file being - collapsed, move selection to the file header row. - -**Seams:** -- At C1 collapse becomes structural (per-file document sections) — keep the - collapsed-set composable; only the render-skip mechanism gets replaced. -- Collapsed bar is where a parked "viewed" checkbox would live later. - -## Structure -- New: `src/lib/composables/useFileCollapse.svelte.ts` -- `src/lib/components/embedded/RegularLines.svelte` — render-skip + collapsed bar -- `src/lib/components/embedded/LineRow.svelte` — chevron on `file_header` rows (inside the `{#if trailing}` block per CLAUDE.md UI patterns) -- `src/lib/HelpOverlay.svelte`, `docs/features.md` — shortcut + docs - -## Norms -- `.line-action` class for the chevron button (CLAUDE.md UI patterns). -- Composables pattern; runes. - -## Safeguards -- **Invariant: `lines` array is never mutated by collapse** — test: annotate line in file 3, collapse file 1, annotation still renders on the same content. -- Keyboard nav (j/k) must skip hidden rows without getting stuck. -- Search hits inside a collapsed file: either auto-expand on jump or skip — pick auto-expand (GitHub behavior), test it. - -## Scope -- In: collapse/expand per file, auto-collapse threshold, summary header, selection/search interaction, docs. -- Out: viewed-state (parked), remembering collapse across sessions, S1's sidebar (independent — no edge between S1 and S2). diff --git a/.specs.local/diff-redesign/s3-unfold.md b/.specs.local/diff-redesign/s3-unfold.md deleted file mode 100644 index 3c340cae..00000000 --- a/.specs.local/diff-redesign/s3-unfold.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: S3 -kind: story -wave: 3 -depends_on: [C1, B1] -status: fogged ---- - -# Primer: S3 — Unfold context between hunks - -> Fogged. Clear after C1: the row/section model it splices into is C1's output. - -**Goal:** GitHub-style gap bars between hunks ("⋯ 20 unchanged lines" with expand up/down/all); clicking slices rows from B1's cached full text and splices them in. - -**Settled constraints:** -- Affordance renders **only when `FileSource::full_text` can return content** — raw `diff_content` mode shows no arrows at all (settled: no unfold there; avoid hunk-the-tool's silent-failure bug). -- Fetch whole file once (B1 caches); every unfold is a local slice. Loading/error/too-large states on the gap bar (hunk's state machine: `loading | loaded | error | too-large`). -- Expansion rows are **tagged** and excluded from hunk bounds / annotation-anchor derivation (hunk's `isExpansionRow` discipline) — anchors must not drift when context is unfolded. -- Annotating an expanded (context) row is allowed and anchors new-side like any context line. -- Gap identity: `(file, position before/after hunkIndex)` — expansion state is per-session, ephemeral. - -**Mechanics reference:** hunk's `expandCollapsedRows.ts` splice (keep the gap bar in place, rewrite label, insert synthesized rows keyed separately) and Zed's merge-adjacent-regions rule (fully unfolded gap disappears; adjacent expansions merge). - -**IPC to design when clearing fog:** frontend asks backend for gap lines (`expand_gap(file, old_range, new_range) → rows` with html-highlighted lines) vs shipping full texts to the frontend at load. Lean backend-slicing — keeps highlighting (`syntect`) and memory in one place. diff --git a/.specs.local/diff-redesign/s4-split-view.md b/.specs.local/diff-redesign/s4-split-view.md deleted file mode 100644 index 94b65d04..00000000 --- a/.specs.local/diff-redesign/s4-split-view.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -id: S4 -kind: story -wave: 3 -depends_on: [C1] -status: fogged ---- - -# Primer: S4 — Split (side-by-side) view + persisted toggle - -> Fogged. Clear after C1; likely the fattest leaf — consider slicing when specing. - -**Goal:** Two-column view: old left, new right; context rows span-aligned; deleted/added rows paired within a change run, shorter side padded with filler cells. Toggle via shortcut + palette; **unified stays default**; choice persisted in config (`src-tauri/src/config.rs` — same persistence path as tags/exit-modes). - -**Settled constraints:** -- Split is a **projection of the same rows** (C1's `(side, old_line, new_line)` model) — no second data pipeline, no new wire format. Pairing walks hunk rows: context → one row both cells; change runs → pair deletions/additions by index up to `max(dels, adds)` (hunk's `buildSplitRows` pattern), padding with empty cells. -- Annotations: side is implicit from the column clicked; anchors are already side-aware (A1/A2), so the model needs zero changes — this story is UI only. -- Selection model in split view: column-scoped ranges; mixed-side range creation stays a unified-view gesture (settled — replacements are selected in unified). -- Word-level highlight spans (S5) must render in both views if S5 lands first. - -**Risks to plan around when clearing fog:** keyboard nav semantics across two columns (j/k walks rows; h/l or focus model for columns?); `LineRow.svelte` reuse vs a `SplitRow` sibling; line-wrap alignment between cells (CSS grid row auto-height keeps pairs aligned — hunk pads with terminal cells, DOM can align naturally). diff --git a/.specs.local/diff-redesign/s5-word-highlights.md b/.specs.local/diff-redesign/s5-word-highlights.md deleted file mode 100644 index c023c26b..00000000 --- a/.specs.local/diff-redesign/s5-word-highlights.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -id: S5 -kind: story -wave: 3 -depends_on: [C1] -status: fogged ---- - -# Primer: S5 — Word-level (intra-line) diff highlights - -> Fogged, but thin: cheapest leaf, ship first of wave 3. - -**Goal:** Within changed line pairs, the changed tokens get a stronger background (GitHub's darker red/green spans). Data already exists: B3 emits `word_ranges: Vec>` (byte offsets per line) on `Deleted`/`Added` rows, gated to hunks ≤ ~5 equal lines. - -**Settled constraints:** -- Rendering only — no computation frontend-side. Backend already merges word ranges into the line HTML (`highlight.rs` produces per-line html; word-diff spans must compose with syntect spans — nested `` around highlighted tokens) **or** ships ranges for frontend wrapping. Decide when clearing fog; lean backend-composited (frontend stays a dumb renderer, consistent with the rest of the pipeline). -- Byte ranges slice at char boundaries (B3 safeguard) — trust but verify with multibyte fixture. -- Must render in unified now and split (S4) later without rework — style via a class on spans, not view-specific markup. - -**Reference:** Zed gates at ≤5-line hunks with equal add/del counts (`MAX_WORD_DIFF_LINE_COUNT`) — the gate lives in B3; if tuning is needed, tune there, not here. diff --git a/docs/features.md b/docs/features.md index 12872bf5..09f97192 100644 --- a/docs/features.md +++ b/docs/features.md @@ -25,7 +25,9 @@ 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; the row for the file currently in view stays highlighted. The `:` palette's **Files** namespace does the same jump by fuzzy search. +**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. diff --git a/src-tauri/src/diff.rs b/src-tauri/src/diff.rs index b24ab68e..e7e9fde2 100644 --- a/src-tauri/src/diff.rs +++ b/src-tauri/src/diff.rs @@ -9,12 +9,17 @@ use crate::error::AnnotError; /// Line type in a diff. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum DiffLineKind { Context, Added, Deleted, - Header, + /// The `diff --git` line — exactly one per file. + FileHeader, + /// An `@@` hunk header line. + HunkHeader, + /// Non-content plumbing: index, ---/+++, mode, rename, Binary files. + Meta, } /// Metadata for a hunk within a file. @@ -158,7 +163,7 @@ pub fn parse_diff(content: &str) -> Result { f.start_line = line_num; } in_hunk = false; - (DiffLineKind::Header, None, None) + (DiffLineKind::FileHeader, None, None) } else if line_content.starts_with("index ") || line_content.starts_with("--- ") || line_content.starts_with("+++ ") @@ -171,7 +176,7 @@ pub fn parse_diff(content: &str) -> Result { || line_content.starts_with("rename to") || line_content.starts_with("Binary files") { - (DiffLineKind::Header, None, None) + (DiffLineKind::Meta, None, None) } else if line_content.starts_with("@@ ") { // Hunk header - parse line numbers and function context in_hunk = true; @@ -183,7 +188,7 @@ pub fn parse_diff(content: &str) -> Result { f.hunks.push(hunk_info); } } - (DiffLineKind::Header, None, None) + (DiffLineKind::HunkHeader, None, None) } else if in_hunk { // Inside a hunk - determine line type if line_content.starts_with('+') { @@ -209,8 +214,8 @@ pub fn parse_diff(content: &str) -> Result { (DiffLineKind::Context, Some(old_num), Some(new_num)) } } else { - // Outside hunk - treat as header - (DiffLineKind::Header, None, None) + // Outside hunk - unrecognized plumbing + (DiffLineKind::Meta, None, None) }; metadata.lines.insert( @@ -471,6 +476,26 @@ index abcdef..0000000 assert_eq!(meta.files[1].language, "rs"); } + #[test] + fn parse_multi_file_diff_one_file_header_per_file() { + let meta = parse_diff(MULTI_FILE_DIFF).unwrap(); + + // Exactly one FileHeader per file, sitting on its start_line + for file in &meta.files { + let headers: Vec<_> = meta + .lines + .iter() + .filter(|(line_num, info)| { + info.kind == DiffLineKind::FileHeader + && **line_num >= file.start_line + && **line_num <= file.end_line + }) + .collect(); + assert_eq!(headers.len(), 1, "one diff --git line per file"); + assert_eq!(*headers[0].0, file.start_line); + } + } + #[test] fn parse_multi_file_diff_tracks_file_index() { let meta = parse_diff(MULTI_FILE_DIFF).unwrap(); @@ -515,13 +540,25 @@ index abcdef..0000000 assert_eq!(meta.files.len(), 1); - // Should have 5 header lines: diff --git, ---, +++, @@ (hunk1), @@ (hunk2) - let headers: Vec<_> = meta + // diff --git → FileHeader; --- and +++ → Meta; the two @@ → HunkHeader + let file_headers = 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)"); + .values() + .filter(|info| info.kind == DiffLineKind::FileHeader) + .count(); + let hunk_headers = meta + .lines + .values() + .filter(|info| info.kind == DiffLineKind::HunkHeader) + .count(); + let meta_lines = meta + .lines + .values() + .filter(|info| info.kind == DiffLineKind::Meta) + .count(); + assert_eq!(file_headers, 1, "Exactly one diff --git line"); + assert_eq!(hunk_headers, 2, "One per @@ hunk"); + assert_eq!(meta_lines, 2, "--- and +++ lines"); // First hunk changes line 2, second hunk changes line 11 let deleted: Vec<_> = meta @@ -583,8 +620,14 @@ index abcdef..0000000 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\""); + let json = serde_json::to_string(&DiffLineKind::FileHeader).unwrap(); + assert_eq!(json, "\"file_header\""); + + let json = serde_json::to_string(&DiffLineKind::HunkHeader).unwrap(); + assert_eq!(json, "\"hunk_header\""); + + let json = serde_json::to_string(&DiffLineKind::Meta).unwrap(); + assert_eq!(json, "\"meta\""); } #[test] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b77f7739..f9764ba9 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -111,6 +111,8 @@ pub enum LineSemantics { pub enum DiffSemantics { FileHeader, HunkHeader { context: Option }, + /// Non-content plumbing lines (index, ---/+++, mode changes) — never rendered. + Meta, Added, Deleted, Context, @@ -763,7 +765,17 @@ impl ContentModel { diff::DiffLineKind::Context => DiffSemantics::Context, diff::DiffLineKind::Added => DiffSemantics::Added, diff::DiffLineKind::Deleted => DiffSemantics::Deleted, - diff::DiffLineKind::Header => DiffSemantics::FileHeader, + diff::DiffLineKind::FileHeader => DiffSemantics::FileHeader, + diff::DiffLineKind::HunkHeader => DiffSemantics::HunkHeader { + context: diff_metadata + .files + .get(info.file_index) + .and_then(|f| { + f.hunks.iter().find(|h| h.display_line == line_num) + }) + .and_then(|h| h.function_context.clone()), + }, + diff::DiffLineKind::Meta => DiffSemantics::Meta, }); (origin, semantics) } diff --git a/src/lib/CommandPalette/namespaces/files.test.ts b/src/lib/CommandPalette/namespaces/files.test.ts index e4afc8d1..78121912 100644 --- a/src/lib/CommandPalette/namespaces/files.test.ts +++ b/src/lib/CommandPalette/namespaces/files.test.ts @@ -13,6 +13,7 @@ function entry(index: number, path: string, startLine: number): FileEntry { added: 1, deleted: 0, startLine, + endLine: startLine + 5, }; } diff --git a/src/lib/components/FileTree.svelte b/src/lib/components/FileTree.svelte index 437ec949..d17376fc 100644 --- a/src/lib/components/FileTree.svelte +++ b/src/lib/components/FileTree.svelte @@ -1,5 +1,5 @@
@@ -113,6 +124,25 @@ {/if}
+ {#if diffMetadata && ctx.fileEntries.length > 0} + + + +{totals.added} + −{totals.deleted} + + + + {/if} {#if zoomLevel !== 1.0} {Math.round(zoomLevel * 100)}% {/if} diff --git a/src/lib/components/embedded/FileHeaderRow.svelte b/src/lib/components/embedded/FileHeaderRow.svelte new file mode 100644 index 00000000..44038709 --- /dev/null +++ b/src/lib/components/embedded/FileHeaderRow.svelte @@ -0,0 +1,40 @@ + + + diff --git a/src/lib/components/embedded/RegularLines.svelte b/src/lib/components/embedded/RegularLines.svelte index 0058669f..5526a8fa 100644 --- a/src/lib/components/embedded/RegularLines.svelte +++ b/src/lib/components/embedded/RegularLines.svelte @@ -5,7 +5,7 @@ * 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 type { SectionInfo } from '$lib/types'; import { getLineNumber, getDiffKind } from '$lib/line-utils'; import { highlightMatches, clearHighlights } from '$lib/search-highlight'; import { injectColorSwatches, clearColorSwatches } from '$lib/color-preview'; @@ -13,12 +13,10 @@ 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 { groupByFile } from '$lib/file-collapse'; + import type { DisplayLine } from '$lib/composables/useLineSegments.svelte'; interface Props { lines: DisplayLine[]; @@ -36,6 +34,10 @@ const markdownMetadata = $derived(ctx.markdownMetadata); const searchMatches = $derived(ctx.search.matches); + // Diff mode: group lines into per-file sections for collapse + sticky headers. + // Null for non-diff content — the flat render path below stays untouched. + const grouped = $derived(groupByFile(lines, ctx.fileEntries)); + // Map of display indices to code element refs for search highlighting let codeRefs: Map = new Map(); @@ -101,7 +103,7 @@ }); -{#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} @@ -159,7 +161,33 @@ {@const rangeKey = ctx.getRangeKeyForLine(displayIndex)} -{/each} +{/snippet} + +{#if grouped} + {#each grouped.leading as dl (dl.displayIndex)} + {@render row(dl)} + {/each} + {#each grouped.sections as section (section.entry.index)} + {@const collapsed = ctx.fileCollapse.isCollapsed(section.entry.index)} +
+ ctx.fileCollapse.toggle(section.entry.index)} + /> + {#if !collapsed} + {#each section.body as dl (dl.displayIndex)} + {@render row(dl)} + {/each} + {/if} +
+ {/each} +{:else} + {#each lines as dl (dl.displayIndex)} + {@render row(dl)} + {/each} +{/if}