diff --git a/README.md b/README.md
index 554aba6..d1eaac1 100644
--- a/README.md
+++ b/README.md
@@ -12,11 +12,14 @@ A local PR‑style review UI for LLM workflows. Run the `meatcheck` CLI with a s
- File tree + code view UI similar to GitHub PR reviews
- Click to select a line, shift‑click for a range
- Inline comment threads under the referenced line
+- Unified and side‑by‑side diff views (toggle via toolbar button)
+- Comment on both added and deleted lines in diff mode
- Markdown rendering for comments (toggle raw/rendered)
- Syntax highlighting for code (toggle raw/rendered)
- Grouped review mode — organize files into named groups via `--groups`
- Per‑file viewed/commented indicators in the tree sidebar
- "Mark as viewed" advances to the next unviewed file
+- Preferences (diff format, sidebar width) persist across sessions via XDG config
- Outputs TOON format to stdout on Finish
## Install / Build
@@ -66,6 +69,13 @@ The `--groups` flag takes a path to a JSON file that defines an ordered list of
- `Ctrl+Enter` / `Cmd+Enter`: submit the inline comment
+## Preferences
+
+Meatcheck stores user preferences at `$XDG_CONFIG_HOME/meatcheck/preferences.json` (typically `~/.config/meatcheck/preferences.json`). Currently persisted settings:
+
+- **Diff format** — unified or side‑by‑side
+- **Sidebar width** — drag‑resized column width
+
## Output
On “Finish Review”, the app prints TOON to stdout and exits.
diff --git a/docs/plans/diff-view-options.md b/docs/plans/diff-view-options.md
new file mode 100644
index 0000000..0f3b486
--- /dev/null
+++ b/docs/plans/diff-view-options.md
@@ -0,0 +1,264 @@
+# Implementation Plan: Diff View Options (Unified/Side-by-Side, Preferences, Background)
+
+Add switchable unified/side-by-side diff view with localStorage persistence, enable commenting on old (deleted) lines, and change the diff area background to black.
+
+## Context
+
+**Research Document**: `docs/research/2026-03-09-diff-view-options.md`
+
+**Key Files**:
+- `internal/app/model.go` - Data types: `ReviewModel`, `ViewDiffLine`, `ViewDiffHunk`, `ViewDiffFile`, `Comment`
+- `internal/app/view.go` - View builders: `buildViewDiff()`, `updateDiffView()`, `projectLineComments()`, `diffLineExists()`
+- `internal/app/app.go` - Event handlers: `toggle-file-render`, `select-line`, `add-comment`, `cancel-comment`
+- `internal/app/diff.go` - Diff parser: `parseUnifiedDiff()`, `DiffLine`, `DiffHunk`
+- `internal/ui/template.html` - HTML template with JS hooks for line selection and localStorage
+- `internal/ui/styles.css` - CSS with theme variables and diff styling
+
+**Architectural Notes**:
+- Server-driven reactive UI via `jfyne/live` — events go client→server, server mutates model, full re-render diffs to client
+- All UI state lives in `ReviewModel` struct; only sidebar width persists in localStorage
+- Template uses Go `html/template` with `{{if}}` conditionals for mode branching
+- CSS uses custom properties (`:root` variables), embedded in binary via `//go:embed`
+
+**Functional Requirements** (EARS notation):
+- When the user clicks the diff format toggle, the system shall switch between unified and side-by-side diff views
+- While in side-by-side mode, the system shall display old lines on the left and new lines on the right, paired by position within each hunk
+- When the user selects a diff format, the system shall persist the choice to localStorage
+- When the application loads, the system shall restore the diff format from localStorage
+- While viewing a diff in either format, the system shall allow clicking on both old (deleted) and new (added) lines to create comments
+- The diff code area background shall be black (#000000)
+- If the user is not in diff mode, then the diff format toggle shall be hidden
+
+## Execution Stages
+
+### Stage 1: Data Model and Background Color
+
+#### Test Creation Phase (parallel)
+- T-test-1A: Write tests for `DiffFormat` model types and `diffOldLineExists` (hmm-test-writer)
+ - New feature tests (RED): Scenarios 1, 2
+- T-test-1B: Write tests for side-aware `projectLineComments` (hmm-test-writer)
+ - Regression tests: existing `projectLineComments` behavior with empty side
+ - New feature tests (RED): Scenarios 5, 6
+
+#### Implementation Phase (parallel, depends on Test Creation Phase)
+- T-impl-1A: Add `DiffFormat` type, `Comment.Side`, `SelectionSide`, `ViewDiffRow` types to model.go (hmm-implement-worker, TDD mode)
+ - Files: `internal/app/model.go` (modifies)
+ - Make RED tests pass (GREEN)
+- T-impl-1B: Update `projectLineComments` for side-aware matching, add `diffOldLineExists` (hmm-implement-worker, TDD mode)
+ - Files: `internal/app/view.go` (modifies)
+ - Make RED tests pass (GREEN)
+- T-impl-1C: Change `.diff` background to black (hmm-implement-worker, TDD mode)
+ - Files: `internal/ui/styles.css` (modifies)
+
+### Stage 2: Event Handlers and Unified View Old-Line Support (depends on Stage 1)
+
+#### Test Creation Phase (parallel)
+- T-test-2A: Write tests for `buildViewDiff` with old-line selection and commenting (hmm-test-writer)
+ - Regression tests: existing `buildViewDiff` behavior
+ - New feature tests (RED): Scenarios 3, 4
+
+#### Implementation Phase (sequential, depends on Test Creation Phase)
+- T-impl-2A: Update all app.go event handlers — add `toggle-diff-format`, `init-diff-format`; update `select-line`, `add-comment`, `cancel-comment` for old-line support; initialize `DiffFormat` in `Run()` (hmm-implement-worker, TDD mode)
+ - Files: `internal/app/app.go` (modifies)
+- T-impl-2B: Update `buildViewDiff` and `updateDiffView` for old-line selection/commenting and DiffFormat dispatch (hmm-implement-worker, TDD mode)
+ - Files: `internal/app/view.go` (modifies)
+ - Make RED tests pass (GREEN)
+
+### Stage 3: Side-by-Side View Builder (depends on Stage 2)
+
+#### Test Creation Phase (parallel)
+- T-test-3A: Write tests for `buildViewDiffSplit` pairing algorithm (hmm-test-writer)
+ - New feature tests (RED): Scenarios 7, 8, 9
+
+#### Implementation Phase (parallel, depends on Test Creation Phase)
+- T-impl-3A: Implement `buildViewDiffSplit` function (hmm-implement-worker, TDD mode)
+ - Files: `internal/app/view.go` (modifies)
+ - Make RED tests pass (GREEN)
+
+### Stage 4: Template, CSS, and JavaScript (depends on Stage 3)
+
+#### Test Creation Phase
+- T-test-4A: Write render tests for toggle visibility and split template output (hmm-test-writer)
+ - New feature tests (RED): Scenario 10
+
+#### Implementation Phase (sequential, depends on Test Creation Phase)
+- T-impl-4A: Update template.html — add toggle button, `data-old-line` attributes, side-by-side template block, old-line comment form conditional, JS click handler for old-line clicks, localStorage persistence (hmm-implement-worker, TDD mode)
+ - Files: `internal/ui/template.html` (modifies)
+- T-impl-4B: Add side-by-side CSS layout (hmm-implement-worker, TDD mode)
+ - Files: `internal/ui/styles.css` (modifies)
+
+## Task List
+
+### Data Model
+
+- [x] Add `DiffFormat` type and constants [Stage 1]
+ - Files: `internal/app/model.go` (modifies)
+ - Add `type DiffFormat string` with `DiffFormatUnified DiffFormat = "unified"` and `DiffFormatSplit DiffFormat = "split"`.
+ - Add `DiffFormat DiffFormat` field to `ReviewModel` struct.
+
+- [x] Add `Side` field to `Comment` struct [Stage 1]
+ - Files: `internal/app/model.go` (modifies)
+ - Add `Side string \`json:"side,omitempty"\`` after `EndLine`. Values: `""` (new, default), `"old"`.
+ - `omitempty` ensures backward compatibility in TOON output.
+
+- [x] Add `SelectionSide` field to `ReviewModel` [Stage 1]
+ - Files: `internal/app/model.go` (modifies)
+ - Add `SelectionSide string` near `SelectionStart`/`SelectionEnd`.
+
+- [x] Add `ViewDiffRow` and `ViewDiffSide` types for side-by-side view [Stage 1]
+ - Files: `internal/app/model.go` (modifies)
+ - `ViewDiffSide`: `Line int`, `Kind DiffLineKind`, `Text string`, `HTML template.HTML`, `Empty bool`, `Selected bool`, `Commented bool`, `Comments []ViewComment`
+ - `ViewDiffRow`: `Left ViewDiffSide`, `Right ViewDiffSide`
+ - `ViewDiffSplitHunk`: `Header string`, `Rows []ViewDiffRow`
+ - Add `ViewDiffSplit []ViewDiffSplitHunk` field to `ReviewModel`.
+
+### View Building
+
+- [x] Update `projectLineComments` for side-aware matching [Stage 1]
+ - Files: `internal/app/view.go` (modifies)
+ - Add `side string` parameter. Filter `c.Side` against the provided side. Treat `c.Side == ""` as `"new"` for backward compat.
+ - Update all call sites: `buildViewDiff` (2 calls), `buildSingleViewLine` (1 call). File-mode calls pass `""`.
+
+- [x] Add `diffOldLineExists` function [Stage 1]
+ - Files: `internal/app/view.go` (modifies)
+ - Same structure as `diffLineExists` but checks `dl.OldLine == oldLine && dl.Kind != DiffAdd`.
+
+- [x] Update `buildViewDiff` for old-line selection and commenting [Stage 2]
+ - Files: `internal/app/view.go` (modifies)
+ - Accept `selectionSide string` parameter.
+ - Remove the `selectable` guard that blocks deleted lines. All lines with OldLine > 0 or NewLine > 0 are selectable.
+ - When `selectionSide == "old"`, check selection against `dl.OldLine`; otherwise `dl.NewLine`.
+ - Project comments: call `projectLineComments` with side `""` on `dl.NewLine` and side `"old"` on `dl.OldLine`, merge results.
+
+- [x] Update `updateDiffView` to dispatch on DiffFormat [Stage 2]
+ - Files: `internal/app/view.go` (modifies)
+ - When `DiffFormatSplit`, call `buildViewDiffSplit` and populate `model.ViewDiffSplit`.
+ - When `DiffFormatUnified` (or default), use existing `buildViewDiff`.
+ - Clear both `model.ViewDiff` and `model.ViewDiffSplit` at the start.
+ - Pass `model.SelectionSide` to `buildViewDiff` (and later `buildViewDiffSplit`) for correct selection highlighting.
+
+- [x] Implement `buildViewDiffSplit` pairing algorithm [Stage 3]
+ - Files: `internal/app/view.go` (modifies)
+ - For each hunk, pair lines into rows:
+ - Context lines: both Left and Right populated with same text
+ - Del/Add blocks: collect consecutive dels then adds, zip 1:1. Unpaired lines get `Empty: true` opposite side.
+ - Apply selection/comment projection to each side independently.
+ - Render syntax highlighting for old and new lines separately.
+
+### Event Handlers
+
+- [x] Update all app.go handlers and initialization for diff format and old-line support [Stage 2]
+ - Files: `internal/app/app.go` (modifies)
+ - **New handlers**:
+ - `toggle-diff-format`: toggle `model.DiffFormat` between `DiffFormatUnified` and `DiffFormatSplit`. Clear selection state (`SelectionStart`, `SelectionEnd`, `SelectionSide`). Call `updateView(model)`.
+ - `init-diff-format`: read `p.String("format")`, validate (`"unified"` or `"split"`), set `model.DiffFormat`. Call `updateView(model)`.
+ - **Modified handlers**:
+ - `select-line`: read `old_line` param. When `old_line > 0`, validate via `diffOldLineExists`, set `model.SelectionSide = "old"`. When selecting new lines, set `model.SelectionSide = ""`.
+ - `add-comment`: set `Side: model.SelectionSide` on new `Comment`. Clear `model.SelectionSide` after adding.
+ - `cancel-comment`: add `model.SelectionSide = ""` alongside existing selection clearing.
+ - **Initialization**: set `DiffFormat: DiffFormatUnified` in `ReviewModel` struct literal in `Run()`.
+
+### Template and UI
+
+- [x] Update template.html for diff format toggle, side-by-side view, old-line support, and localStorage [Stage 4]
+ - Files: `internal/ui/template.html` (modifies)
+ - **Toggle button**: Add diff format toggle in `.column-header`, only visible when `$root.Mode == "diff"`. Uses `live-click="toggle-diff-format"`. SVG icon of two rectangles side by side. `active` class when `$root.DiffFormat == "split"`.
+ - **Unified view old-line support**: Add `data-old-line="{{.OldLine}}"` to `.diff-line` div (line 123). Show comment threads on deleted lines — update template conditional so comments render when OldLine > 0 and the comment has side `"old"`. Update the inline-comment form conditional (template line 136) to also trigger when `$root.SelectionSide == "old"` and `.OldLine == $root.SelectionEnd`.
+ - **Side-by-side template block**: Add `{{if eq $root.DiffFormat "split"}}` conditional inside the diff mode block. Each row renders as `.diff-row-split` with two `.diff-cell` halves (left=old with `data-old-line`, right=new with `data-line`). Empty sides render placeholder cells. Comment threads render full-width below rows. Inline comment form spans full width after selection end row.
+ - **JS click handler**: If `data-line` (NewLine) is 0, fall back to `data-old-line` (OldLine). Send `old_line: "1"` flag to server when clicking an old line.
+ - **localStorage persistence**: On mount, read `localStorage.getItem("meatcheck-diff-format")` and send `init-diff-format` event (deferred until `window.Live` is available via polling). On toggle, save via `handleEvent("diff-format-changed", ...)`.
+
+### CSS
+
+- [x] Change `.diff` background to black [Stage 1]
+ - Files: `internal/ui/styles.css` (modifies)
+ - Change line 777 from `background: var(--panel)` to `background: #000000`.
+
+- [x] Add side-by-side CSS layout [Stage 4]
+ - Files: `internal/ui/styles.css` (modifies)
+ - `.diff-row-split`: `display: grid; grid-template-columns: 1fr 1fr`
+ - `.diff-cell`: `display: grid; grid-template-columns: 4ch 1ch max-content; gap: 12px`
+ - Kind-based backgrounds on `.diff-cell[data-kind="add"]` and `[data-kind="del"]`
+ - Hover, selected, commented states on `.diff-cell`
+ - `.diff-cell .code-text` rules mirroring existing `.diff-line .code-text`
+ - Border separator between left and right cells
+
+## Acceptance Criteria
+
+~~~gherkin
+Feature: Diff view options
+
+ Scenario: Toggle between unified and side-by-side diff view
+ Given the user is viewing a diff
+ When the user clicks the diff format toggle button
+ Then the view switches from unified to side-by-side (or vice versa)
+ And the toggle button shows an active state when in side-by-side mode
+
+ Scenario: Diff format preference persists across sessions
+ Given the user has selected side-by-side diff format
+ When the user closes and reopens the tool
+ Then the diff view loads in side-by-side format
+
+ Scenario: Side-by-side view pairs old and new lines correctly
+ Given a diff hunk with 2 deleted lines followed by 3 added lines
+ When the view is in side-by-side mode
+ Then the first 2 rows show del on the left and add on the right
+ And the 3rd row shows an empty left cell and add on the right
+
+ Scenario: Context lines appear on both sides
+ Given a diff hunk with context lines
+ When the view is in side-by-side mode
+ Then context lines appear on both the left and right sides with matching line numbers
+
+ Scenario: Comment on a deleted line in unified view
+ Given the user is viewing a diff in unified mode
+ When the user clicks on a deleted line
+ Then the line is selected and a comment form appears
+ And the comment is anchored to the old-side line number
+
+ Scenario: Comment on a deleted line in side-by-side view
+ Given the user is viewing a diff in side-by-side mode
+ When the user clicks on a deleted line in the left column
+ Then the line is selected and a comment form appears
+ And the comment is anchored to the old-side line number
+
+ Scenario: Side-by-side comments on old vs new lines with same number
+ Given a comment exists on old line 5 (deleted) and another on new line 5 (added)
+ When viewing in side-by-side mode
+ Then the old-line comment appears in the left column
+ And the new-line comment appears in the right column
+ And they do not cross-contaminate
+
+ Scenario: Side-by-side view renders hunk headers full-width
+ Given a diff with multiple hunks
+ When the view is in side-by-side mode
+ Then hunk headers span the full width of both columns
+
+ Scenario: Side-by-side view with syntax highlighting
+ Given syntax highlighting is enabled
+ When the view is in side-by-side mode
+ Then both left and right columns render syntax-highlighted code
+
+ Scenario: Diff format toggle hidden in file mode
+ Given the user is viewing a plain file (not a diff)
+ Then the diff format toggle button is not visible
+
+ Scenario: Diff area background is black
+ Given the user is viewing a diff
+ Then the diff code area background is black (#000000)
+ And the sidebar and other panels retain their original background
+~~~
+
+**Source**: Generated from plan context
+
+## Implementation Notes
+
+- **Backward compatibility**: `Comment.Side` uses `omitempty` so existing TOON output is unchanged. Empty `Side` is treated as `"new"`.
+- **Shift-click range selection**: Old-line selection does not support shift-click ranges initially. Shift-clicking resets to single-line selection for simplicity.
+- **Syntax highlighting in split view**: Old-side and new-side lines are rendered separately through `codeRenderer.RenderLines` for accurate highlighting.
+- **Performance**: Side-by-side doubles DOM elements per row. Acceptable for typical diff sizes.
+- **localStorage init timing**: Uses polling interval (50ms) to wait for `window.Live` to be available before sending `init-diff-format`.
+
+## Refs
+
+- `docs/research/2026-03-09-diff-view-options.md`
diff --git a/docs/research/2026-03-09-diff-view-options.md b/docs/research/2026-03-09-diff-view-options.md
new file mode 100644
index 0000000..442d947
--- /dev/null
+++ b/docs/research/2026-03-09-diff-view-options.md
@@ -0,0 +1,159 @@
+---
+date: 2026-03-09T00:00:00+00:00
+researcher: josh
+topic: "Diff view mode switching (unified/side-by-side), preference persistence, and code background color"
+tags: [research, codebase, diff, ui, preferences]
+last_updated: 2026-03-09
+last_updated_by: josh
+---
+
+# Research: Diff View Options
+
+## Research Question
+
+How does the current diff view work, and what would be involved in:
+1. Making the diff view switchable between unified and side-by-side modes
+2. Persisting the user's choice across sessions
+3. Changing the code view background from red to black
+
+## Summary
+
+Meatcheck currently renders diffs in a single **unified diff format** only. The diff pipeline flows through three layers: parsing (`diff.go`), view model building (`view.go`), and HTML rendering (`template.html`). The code view area background uses `var(--panel)` which resolves to `#161014` — a very dark reddish-brown. There is no side-by-side diff view and no mechanism for persisting view preferences beyond sidebar width (stored in `localStorage`).
+
+## Detailed Findings
+
+### Diff Parsing
+
+The `parseUnifiedDiff()` function in `internal/app/diff.go` parses standard Git unified diff output into structured data:
+
+- `DiffLineKind` enum: `DiffContext`, `DiffAdd`, `DiffDel`
+- `DiffLine`: stores kind, old line number, new line number, and text
+- `DiffHunk`: groups lines with old/new start positions and counts
+- `DiffFile`: contains file path and an array of hunks
+
+The parser is a line-by-line state machine that classifies lines by their prefix character (`+`, `-`, ` `). Deleted lines have `NewLine=0`, added lines have `OldLine=0`, context lines have both.
+
+### View Model Building
+
+`buildViewDiff()` in `internal/app/view.go:119` converts parsed `DiffFile` data into `ViewDiffFile` for template rendering:
+
+- Iterates hunks, building hunk header strings (`@@ -oldStart,oldCount +newStart,newCount @@`)
+- For each line: maps kind, line numbers, raw text, optional syntax-highlighted HTML
+- Selection is only allowed on new-file lines (not deletions): `selectable := dl.NewLine > 0 && dl.Kind != DiffDel`
+- Comments can only anchor to new file lines via `projectLineComments()`
+
+When `side-by-side` mode is introduced, this function will need a parallel variant that pairs old/new lines together rather than interleaving them sequentially.
+
+### HTML Template (Unified View)
+
+The diff section in `internal/ui/template.html:118-151` renders a unified view:
+
+```html
+
+ {{range .ViewDiff.Hunks}}
+
{{.Header}}
+ {{range .Lines}}
+
+ {{.OldLine}}
+ {{.NewLine}}
+ +/-/
+
{{.HTML or .Text}}
+
+ {{end}}
+ {{end}}
+
+```
+
+The grid layout uses 4 columns: `4ch 4ch 1ch max-content` (old line#, new line#, sign, content).
+
+For side-by-side mode, the template will need a conditional branch (`{{if eq .DiffFormat "split"}}`) with a different HTML structure — likely two columns, each with its own line number, sign, and code content.
+
+### CSS Styling
+
+**Theme variables** (`internal/ui/styles.css:1-13`):
+
+| Variable | Value | Description |
+|----------|-------|-------------|
+| `--bg` | `#0f0b0c` | Page background |
+| `--panel` | `#161014` | Panel/code area background — **dark reddish-brown** |
+| `--ink` | `#f3e9ea` | Primary text color |
+| `--muted` | `#b7a7aa` | Secondary text color |
+| `--accent` | `#c00000` | Accent color (red) |
+| `--border` | `#2a1b1e` | Border color (dark red-brown) |
+| `--line-hover` | `#241317` | Line hover background |
+| `--line-selected` | `#3a1515` | Selected line background |
+
+The `.diff` container uses `background: var(--panel)` (`styles.css:777`), which is `#161014`. This is the "red" background the user is referring to — it's a dark maroon/reddish-brown rather than pure black.
+
+**Diff line coloring** (`styles.css:828-842`):
+- Added lines: `background: rgba(46, 160, 67, 0.12)` with green sign `#2ea043`
+- Deleted lines: `background: rgba(248, 81, 73, 0.16)` with red sign `#f85149`
+- Context lines: no background color
+
+### Preference Persistence
+
+**Current state**: Only sidebar width is persisted via `localStorage.getItem/setItem("meatcheck-sidebar-width")` in the JS hook (`template.html:223-228`).
+
+**All other state** (render toggles, viewed files, comments) lives in the server-side `ReviewModel` struct and is lost when the session ends.
+
+**Pattern for adding diff format persistence**: Following the existing sidebar-width pattern:
+1. Store in `localStorage` with key like `"meatcheck-diff-format"`
+2. On mount, read from localStorage and send initial value to server via `Live.send()`
+3. Server stores the preference in `ReviewModel.DiffFormat` field
+4. Toggle button sends event to server, server updates model, JS also persists to localStorage
+
+### Event Handling Pattern
+
+Event handlers are registered in `buildLiveHandler()` (`internal/app/app.go:190`). Existing toggle patterns:
+
+- `toggle-file-render`: flips `model.RenderFile` boolean, calls `updateView(model)`
+- `toggle-comment-render`: flips `model.RenderComments` boolean
+- `toggle-sidebar`: flips `model.SidebarCollapsed` boolean
+
+A `toggle-diff-format` event would follow the same pattern: flip a `DiffFormat` field on `ReviewModel` and call `updateView(model)`.
+
+### Toggle UI Pattern
+
+Existing toggle buttons in the column header (`template.html:65-80`) use this pattern:
+
+```html
+
+```
+
+A diff format toggle would follow this same pattern, placed alongside the existing toggles.
+
+## Code References
+
+- `internal/app/diff.go` — Unified diff parser (`parseUnifiedDiff()`, `DiffLine`, `DiffFile`)
+- `internal/app/model.go:66-71` — `ViewMode` enum (`ModeFile`, `ModeDiff`)
+- `internal/app/model.go:73-92` — `ViewDiffLine`, `ViewDiffHunk`, `ViewDiffFile` structs
+- `internal/app/model.go:105-132` — `ReviewModel` struct (all session state)
+- `internal/app/view.go:11-18` — `updateView()` dispatch by mode
+- `internal/app/view.go:119-157` — `buildViewDiff()` — builds view model for unified diff
+- `internal/app/app.go:190-232` — `buildLiveHandler()` — event handler registration, render function
+- `internal/ui/template.html:118-151` — Unified diff HTML template
+- `internal/ui/template.html:216-324` — JavaScript hooks (localStorage, click handlers)
+- `internal/ui/styles.css:1-13` — CSS theme variables (including `--panel: #161014`)
+- `internal/ui/styles.css:776-867` — Diff-specific CSS (`.diff`, `.diff-line`, line kind colors)
+
+## Architecture Documentation
+
+The application follows a server-driven reactive pattern using `jfyne/live`:
+
+1. **Server-side state**: All UI state lives in `ReviewModel` struct, shared across WebSocket connections
+2. **Event-driven updates**: Client sends events via WebSocket → Go handler mutates model → full template re-render → diff patched to client
+3. **Client-side persistence**: Only sidebar width uses `localStorage`; everything else is ephemeral per session
+4. **Template pattern**: Single `template.html` with conditional blocks for diff vs file mode
+5. **CSS pattern**: CSS variables for theming, embedded in binary via `//go:embed`
+
+## Design Decisions
+
+1. **Background color**: Change only the `.diff` container background to black (`#000000`). Leave sidebar, hunk headers, and other panels using `--panel` (`#161014`) unchanged.
+2. **Comment anchoring**: Comments should be attachable to **both old and new lines** — in both unified and side-by-side modes. This is a change from the current behavior where comments can only anchor to new-file lines. The `selectable` guard in `buildViewDiff()` (`view.go:142`) and the template's line-click handler need to be updated to allow selection of deleted/old lines.
+3. **Toggle visibility**: The unified/side-by-side toggle button should only be visible in diff mode. Hide it when viewing plain files.
+
+## Open Questions
+
+None — all resolved.
diff --git a/go.mod b/go.mod
index 2794eca..96200be 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
)
require (
+ github.com/adrg/xdg v0.5.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
diff --git a/go.sum b/go.sum
index ab77a24..e922084 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
+github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
diff --git a/internal/app/app.go b/internal/app/app.go
index d4826bf..ca5a302 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -84,6 +84,8 @@ func Run(ctx context.Context, cfg Config) error {
SelectedPath: "",
SelectedLabel: "",
Mode: mode,
+ DiffFormat: preferredDiffFormat(),
+ SidebarWidth: loadPreferences().SidebarWidth,
RenderFile: true,
RenderComments: true,
Prompt: cfg.Prompt,
@@ -280,18 +282,29 @@ func buildLiveHandler(rs *ReviewServer) *live.Handler {
model := getModel(s, rs.Model)
line := p.Int("line")
lineEnd := p.Int("line_end")
+ oldLine := p.Int("old_line")
shift := p.String("shift") == "1"
- if line <= 0 {
- return model, nil
+ if model.Mode == ModeDiff && oldLine > 0 {
+ if !diffOldLineExists(model.DiffFiles, model.SelectedPath, oldLine) {
+ return model, nil
+ }
+ line = oldLine
+ lineEnd = oldLine
+ model.SelectionSide = "old"
+ } else {
+ if line <= 0 {
+ return model, nil
+ }
+ if model.Mode == ModeDiff {
+ if !diffLineExists(model.DiffFiles, model.SelectedPath, line) {
+ return model, nil
+ }
+ }
+ model.SelectionSide = ""
}
if lineEnd < line {
lineEnd = line
}
- if model.Mode == ModeDiff {
- if !diffLineExists(model.DiffFiles, model.SelectedPath, line) {
- return model, nil
- }
- }
if shift && model.SelectionStart > 0 {
start := model.SelectionStart
end := lineEnd
@@ -326,12 +339,14 @@ func buildLiveHandler(rs *ReviewServer) *live.Handler {
Path: model.SelectedPath,
StartLine: model.SelectionStart,
EndLine: model.SelectionEnd,
+ Side: model.SelectionSide,
Text: text,
})
model.CommentDraft = ""
model.Error = ""
model.SelectionStart = 0
model.SelectionEnd = 0
+ model.SelectionSide = ""
rebuildTree(model)
updateView(model)
return model, nil
@@ -343,6 +358,7 @@ func buildLiveHandler(rs *ReviewServer) *live.Handler {
model.Error = ""
model.SelectionStart = 0
model.SelectionEnd = 0
+ model.SelectionSide = ""
updateView(model)
return model, nil
})
@@ -414,6 +430,31 @@ func buildLiveHandler(rs *ReviewServer) *live.Handler {
return model, nil
})
+ h.HandleEvent("toggle-diff-format", func(ctx context.Context, s *live.Socket, p live.Params) (any, error) {
+ model := getModel(s, rs.Model)
+ if model.DiffFormat == DiffFormatSplit {
+ model.DiffFormat = DiffFormatUnified
+ } else {
+ model.DiffFormat = DiffFormatSplit
+ }
+ model.SelectionStart = 0
+ model.SelectionEnd = 0
+ model.SelectionSide = ""
+ savePreference(func(p *Preferences) { p.DiffFormat = model.DiffFormat })
+ updateView(model)
+ return model, nil
+ })
+
+ h.HandleEvent("save-sidebar-width", func(ctx context.Context, s *live.Socket, p live.Params) (any, error) {
+ model := getModel(s, rs.Model)
+ w := p.String("width")
+ if w != "" {
+ model.SidebarWidth = w
+ savePreference(func(p *Preferences) { p.SidebarWidth = w })
+ }
+ return model, nil
+ })
+
h.HandleEvent("finish", func(ctx context.Context, s *live.Socket, p live.Params) (any, error) {
model := getModel(s, rs.Model)
if s != nil {
diff --git a/internal/app/comment_test.go b/internal/app/comment_test.go
index b42f5e4..d7e8e8c 100644
--- a/internal/app/comment_test.go
+++ b/internal/app/comment_test.go
@@ -61,7 +61,7 @@ func TestProjectLineCommentsEditing(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- _, viewComments := projectLineComments("a.go", 5, comments, tc.editingID)
+ _, viewComments := projectLineComments("a.go", 5, comments, tc.editingID, "")
if len(viewComments) != 2 {
t.Fatalf("expected 2 view comments, got %d", len(viewComments))
}
diff --git a/internal/app/diff_format_test.go b/internal/app/diff_format_test.go
new file mode 100644
index 0000000..5e0ea21
--- /dev/null
+++ b/internal/app/diff_format_test.go
@@ -0,0 +1,106 @@
+package app
+
+import "testing"
+
+// TestDiffFormatConstants verifies DiffFormatUnified and DiffFormatSplit have
+// the correct string values.
+func TestDiffFormatConstants(t *testing.T) {
+ if DiffFormatUnified != DiffFormat("unified") {
+ t.Errorf("expected DiffFormatUnified == \"unified\", got %q", DiffFormatUnified)
+ }
+ if DiffFormatSplit != DiffFormat("split") {
+ t.Errorf("expected DiffFormatSplit == \"split\", got %q", DiffFormatSplit)
+ }
+ // Ensure the two constants are distinct.
+ if DiffFormatUnified == DiffFormatSplit {
+ t.Error("DiffFormatUnified and DiffFormatSplit must be distinct")
+ }
+}
+
+// TestDiffOldLineExists is a table-driven test for diffOldLineExists.
+func TestDiffOldLineExists(t *testing.T) {
+ files := []DiffFile{
+ {
+ Path: "a.go",
+ Hunks: []DiffHunk{
+ {
+ OldStart: 1, OldCount: 3, NewStart: 1, NewCount: 3,
+ Lines: []DiffLine{
+ {Kind: DiffContext, OldLine: 1, NewLine: 1, Text: "ctx"},
+ {Kind: DiffDel, OldLine: 2, NewLine: 0, Text: "removed"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 2, Text: "added"},
+ {Kind: DiffContext, OldLine: 3, NewLine: 3, Text: "ctx2"},
+ },
+ },
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ path string
+ oldLine int
+ expected bool
+ }{
+ {
+ name: "old line on a del line returns true",
+ path: "a.go",
+ oldLine: 2,
+ expected: true,
+ },
+ {
+ name: "old line on a context line returns true",
+ path: "a.go",
+ oldLine: 1,
+ expected: true,
+ },
+ {
+ name: "old line 0 on add line returns false",
+ path: "a.go",
+ oldLine: 0,
+ expected: false,
+ },
+ {
+ name: "line number not in any hunk returns false",
+ path: "a.go",
+ oldLine: 99,
+ expected: false,
+ },
+ {
+ name: "file not found returns false",
+ path: "notexist.go",
+ oldLine: 1,
+ expected: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := diffOldLineExists(files, tc.path, tc.oldLine)
+ if got != tc.expected {
+ t.Errorf("diffOldLineExists(%q, %d) = %v, want %v", tc.path, tc.oldLine, got, tc.expected)
+ }
+ })
+ }
+}
+
+// TestReviewModelHasDiffFormat verifies that ReviewModel carries a DiffFormat
+// field and that it can be set to the known constants.
+func TestReviewModelHasDiffFormat(t *testing.T) {
+ m := ReviewModel{}
+
+ // Zero value — DiffFormat field must exist (compiler enforces this).
+ if m.DiffFormat != DiffFormat("") {
+ t.Errorf("zero value of DiffFormat should be empty string, got %q", m.DiffFormat)
+ }
+
+ m.DiffFormat = DiffFormatUnified
+ if m.DiffFormat != DiffFormatUnified {
+ t.Errorf("expected DiffFormatUnified after assignment, got %q", m.DiffFormat)
+ }
+
+ m.DiffFormat = DiffFormatSplit
+ if m.DiffFormat != DiffFormatSplit {
+ t.Errorf("expected DiffFormatSplit after assignment, got %q", m.DiffFormat)
+ }
+}
diff --git a/internal/app/diff_format_view_test.go b/internal/app/diff_format_view_test.go
new file mode 100644
index 0000000..5e82c1f
--- /dev/null
+++ b/internal/app/diff_format_view_test.go
@@ -0,0 +1,306 @@
+package app
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestUpdateDiffViewUnifiedFormat verifies that updateDiffView populates
+// ViewDiff (not ViewDiffSplit) when DiffFormat is DiffFormatUnified.
+//
+// Scenario: Toggle between unified and side-by-side diff view (unified path)
+func TestUpdateDiffViewUnifiedFormat(t *testing.T) {
+ model := &ReviewModel{
+ Mode: ModeDiff,
+ DiffFormat: DiffFormatUnified,
+ SelectedPath: "a.go",
+ DiffFiles: []DiffFile{{
+ Path: "a.go",
+ Hunks: []DiffHunk{{
+ OldStart: 1,
+ OldCount: 1,
+ NewStart: 1,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffContext, OldLine: 1, NewLine: 1, Text: "package main"},
+ },
+ }},
+ }},
+ Viewed: make(map[string]bool),
+ Ranges: map[string][]LineRange{},
+ MarkdownRenderByPath: map[string]bool{},
+ }
+
+ updateDiffView(model)
+
+ // Unified format: ViewDiff.Hunks must be populated.
+ if len(model.ViewDiff.Hunks) == 0 {
+ t.Errorf("expected ViewDiff.Hunks to be non-empty for DiffFormatUnified, got 0 hunks")
+ }
+ // Specific content check: the hunk must carry the context line.
+ if len(model.ViewDiff.Hunks[0].Lines) != 1 {
+ t.Errorf("expected 1 line in hunk, got %d", len(model.ViewDiff.Hunks[0].Lines))
+ }
+ if model.ViewDiff.Hunks[0].Lines[0].Text != "package main" {
+ t.Errorf("expected line text %q, got %q", "package main", model.ViewDiff.Hunks[0].Lines[0].Text)
+ }
+ // Split view must not be populated.
+ if model.ViewDiffSplit != nil {
+ t.Errorf("expected ViewDiffSplit to be nil for DiffFormatUnified, got %v", model.ViewDiffSplit)
+ }
+}
+
+// TestUpdateDiffViewSplitFormat verifies that updateDiffView populates
+// ViewDiffSplit (not ViewDiff) when DiffFormat is DiffFormatSplit.
+//
+// Scenario: Toggle between unified and side-by-side diff view (split path)
+func TestUpdateDiffViewSplitFormat(t *testing.T) {
+ model := &ReviewModel{
+ Mode: ModeDiff,
+ DiffFormat: DiffFormatSplit,
+ SelectedPath: "b.go",
+ DiffFiles: []DiffFile{{
+ Path: "b.go",
+ Hunks: []DiffHunk{{
+ OldStart: 3,
+ OldCount: 2,
+ NewStart: 3,
+ NewCount: 2,
+ Lines: []DiffLine{
+ {Kind: DiffDel, OldLine: 3, NewLine: 0, Text: "old line"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 3, Text: "new line"},
+ },
+ }},
+ }},
+ Viewed: make(map[string]bool),
+ Ranges: map[string][]LineRange{},
+ MarkdownRenderByPath: map[string]bool{},
+ }
+
+ updateDiffView(model)
+
+ // Split format: ViewDiffSplit must be populated.
+ if len(model.ViewDiffSplit) == 0 {
+ t.Errorf("expected ViewDiffSplit to be non-empty for DiffFormatSplit, got 0 hunks")
+ }
+ // Specific content check: first row left side must carry the del line.
+ rows := model.ViewDiffSplit[0].Rows
+ if len(rows) == 0 {
+ t.Fatalf("expected at least 1 row in ViewDiffSplit hunk, got 0")
+ }
+ if rows[0].Left.Text != "old line" {
+ t.Errorf("expected split row Left.Text %q, got %q", "old line", rows[0].Left.Text)
+ }
+ if rows[0].Right.Text != "new line" {
+ t.Errorf("expected split row Right.Text %q, got %q", "new line", rows[0].Right.Text)
+ }
+ // Unified view must not be populated (hunks must be empty).
+ if len(model.ViewDiff.Hunks) != 0 {
+ t.Errorf("expected ViewDiff.Hunks to be empty for DiffFormatSplit, got %d hunks", len(model.ViewDiff.Hunks))
+ }
+}
+
+// TestToggleDiffFormatClearsSelection verifies that when the diff format is
+// toggled the selection state is cleared, matching what the toggle-diff-format
+// handler does.
+//
+// Scenario: Toggle between unified and side-by-side diff view (selection cleared)
+func TestToggleDiffFormatClearsSelection(t *testing.T) {
+ model := &ReviewModel{
+ Mode: ModeDiff,
+ DiffFormat: DiffFormatUnified,
+ SelectedPath: "c.go",
+ SelectionStart: 5,
+ SelectionEnd: 10,
+ SelectionSide: "old",
+ DiffFiles: []DiffFile{{
+ Path: "c.go",
+ Hunks: []DiffHunk{{
+ OldStart: 1,
+ OldCount: 1,
+ NewStart: 1,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffContext, OldLine: 5, NewLine: 5, Text: "ctx"},
+ },
+ }},
+ }},
+ Viewed: make(map[string]bool),
+ Ranges: map[string][]LineRange{},
+ MarkdownRenderByPath: map[string]bool{},
+ }
+
+ // Simulate what toggle-diff-format handler does: swap format, clear selection.
+ if model.DiffFormat == DiffFormatUnified {
+ model.DiffFormat = DiffFormatSplit
+ } else {
+ model.DiffFormat = DiffFormatUnified
+ }
+ model.SelectionStart = 0
+ model.SelectionEnd = 0
+ model.SelectionSide = ""
+ updateDiffView(model)
+
+ if model.DiffFormat != DiffFormatSplit {
+ t.Errorf("expected DiffFormat to be %q after toggle, got %q", DiffFormatSplit, model.DiffFormat)
+ }
+ if model.SelectionStart != 0 {
+ t.Errorf("expected SelectionStart = 0 after toggle, got %d", model.SelectionStart)
+ }
+ if model.SelectionEnd != 0 {
+ t.Errorf("expected SelectionEnd = 0 after toggle, got %d", model.SelectionEnd)
+ }
+ if model.SelectionSide != "" {
+ t.Errorf("expected SelectionSide = %q after toggle, got %q", "", model.SelectionSide)
+ }
+}
+
+// TestHTTPRenderDiffFormatToggleHiddenInFileMode verifies that the diff format
+// toggle button is NOT rendered when Mode == ModeFile (file mode).
+//
+// Scenario: Diff format toggle hidden in file mode
+func TestHTTPRenderDiffFormatToggleHiddenInFileMode(t *testing.T) {
+ model := &ReviewModel{
+ Files: []File{{
+ Path: "a.go",
+ PathSlash: "a.go",
+ Lines: []string{"package main"},
+ }},
+ SelectedPath: "a.go",
+ Mode: ModeFile,
+ RenderFile: true,
+ RenderComments: true,
+ Viewed: make(map[string]bool),
+ Ranges: map[string][]LineRange{},
+ MarkdownRenderByPath: map[string]bool{},
+ }
+ model.Tree = buildTree(model.Files, model.SelectedPath, nil, nil)
+
+ html := renderReviewHTML(t, model)
+
+ // The toggle button element must not appear in file mode.
+ if strings.Contains(html, `live-click="toggle-diff-format"`) {
+ t.Errorf("expected diff format toggle button to be absent in file mode, but found it in rendered HTML")
+ }
+}
+
+// TestHTTPRenderDiffFormatToggleVisibleInDiffMode verifies that the diff format
+// toggle button IS rendered when Mode == ModeDiff.
+//
+// Scenario: Diff format toggle hidden in file mode (inverse: visible in diff mode)
+func TestHTTPRenderDiffFormatToggleVisibleInDiffMode(t *testing.T) {
+ model := &ReviewModel{
+ DiffFiles: []DiffFile{{
+ Path: "a.go",
+ Hunks: []DiffHunk{{
+ OldStart: 1,
+ OldCount: 1,
+ NewStart: 1,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffContext, OldLine: 1, NewLine: 1, Text: "package main"},
+ },
+ }},
+ }},
+ SelectedPath: "a.go",
+ Mode: ModeDiff,
+ DiffFormat: DiffFormatUnified,
+ RenderFile: true,
+ RenderComments: true,
+ Viewed: make(map[string]bool),
+ Ranges: map[string][]LineRange{},
+ MarkdownRenderByPath: map[string]bool{},
+ }
+ model.Tree = buildTree(diffFilesAsFiles(model.DiffFiles), model.SelectedPath, nil, nil)
+
+ html := renderReviewHTML(t, model)
+
+ // The toggle button must be present in diff mode.
+ if !strings.Contains(html, `toggle-diff-format`) {
+ t.Errorf("expected diff format toggle (toggle-diff-format) to be present in diff mode, but not found in rendered HTML")
+ }
+}
+
+// TestHTTPRenderSplitDiffBlock verifies that when DiffFormat == DiffFormatSplit
+// the rendered HTML contains the side-by-side split block (diff-row-split class).
+//
+// Scenario: Side-by-side view pairs old and new lines correctly (template output)
+func TestHTTPRenderSplitDiffBlock(t *testing.T) {
+ model := &ReviewModel{
+ DiffFiles: []DiffFile{{
+ Path: "a.go",
+ Hunks: []DiffHunk{{
+ OldStart: 1,
+ OldCount: 1,
+ NewStart: 1,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffDel, OldLine: 1, NewLine: 0, Text: "old line"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 1, Text: "new line"},
+ },
+ }},
+ }},
+ SelectedPath: "a.go",
+ Mode: ModeDiff,
+ DiffFormat: DiffFormatSplit,
+ RenderFile: true,
+ RenderComments: true,
+ Viewed: make(map[string]bool),
+ Ranges: map[string][]LineRange{},
+ MarkdownRenderByPath: map[string]bool{},
+ }
+ model.Tree = buildTree(diffFilesAsFiles(model.DiffFiles), model.SelectedPath, nil, nil)
+
+ html := renderReviewHTML(t, model)
+
+ // The split template block must render the diff-row-split element.
+ if !strings.Contains(html, `diff-row-split`) {
+ t.Errorf("expected diff-row-split class in rendered HTML for DiffFormatSplit, got: %q", html)
+ }
+ // Both left (del) and right (add) cells must be rendered.
+ if !strings.Contains(html, `diff-cell`) {
+ t.Errorf("expected diff-cell class in rendered HTML for DiffFormatSplit, got: %q", html)
+ }
+}
+
+// TestHTTPRenderUnifiedDiffNotSplit verifies that when DiffFormat == DiffFormatUnified
+// the rendered HTML uses the unified diff block (diff-line class) and NOT the
+// split block (diff-row-split).
+//
+// Scenario: Toggle between unified and side-by-side diff view (template unified path)
+func TestHTTPRenderUnifiedDiffNotSplit(t *testing.T) {
+ model := &ReviewModel{
+ DiffFiles: []DiffFile{{
+ Path: "a.go",
+ Hunks: []DiffHunk{{
+ OldStart: 1,
+ OldCount: 1,
+ NewStart: 1,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffContext, OldLine: 1, NewLine: 1, Text: "package main"},
+ },
+ }},
+ }},
+ SelectedPath: "a.go",
+ Mode: ModeDiff,
+ DiffFormat: DiffFormatUnified,
+ RenderFile: true,
+ RenderComments: true,
+ Viewed: make(map[string]bool),
+ Ranges: map[string][]LineRange{},
+ MarkdownRenderByPath: map[string]bool{},
+ }
+ model.Tree = buildTree(diffFilesAsFiles(model.DiffFiles), model.SelectedPath, nil, nil)
+
+ html := renderReviewHTML(t, model)
+
+ // Unified format must use diff-line elements.
+ if !strings.Contains(html, `diff-line`) {
+ t.Errorf("expected diff-line class in rendered HTML for DiffFormatUnified, got: %q", html)
+ }
+ // Split view HTML elements must be absent (check for the element class, not CSS selectors).
+ if strings.Contains(html, `class="diff-row-split"`) {
+ t.Errorf("expected diff-row-split elements to be absent in rendered HTML for DiffFormatUnified")
+ }
+}
diff --git a/internal/app/model.go b/internal/app/model.go
index fe785f1..4bf88da 100644
--- a/internal/app/model.go
+++ b/internal/app/model.go
@@ -10,6 +10,7 @@ type Comment struct {
Path string `json:"path"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
+ Side string `json:"side,omitempty"`
Text string `json:"text"`
}
@@ -70,6 +71,13 @@ const (
ModeDiff ViewMode = "diff"
)
+type DiffFormat string
+
+const (
+ DiffFormatUnified DiffFormat = "unified"
+ DiffFormatSplit DiffFormat = "split"
+)
+
type ViewDiffLine struct {
Kind DiffLineKind
OldLine int
@@ -91,6 +99,27 @@ type ViewDiffFile struct {
Hunks []ViewDiffHunk
}
+type ViewDiffSide struct {
+ Line int
+ Kind DiffLineKind
+ Text string
+ HTML template.HTML
+ Empty bool
+ Selected bool
+ Commented bool
+ Comments []ViewComment
+}
+
+type ViewDiffRow struct {
+ Left ViewDiffSide
+ Right ViewDiffSide
+}
+
+type ViewDiffSplitHunk struct {
+ Header string
+ Rows []ViewDiffRow
+}
+
type ViewComment struct {
Comment
Rendered template.HTML
@@ -120,6 +149,7 @@ type ReviewModel struct {
PromptHTML template.HTML
SelectionStart int
SelectionEnd int
+ SelectionSide string
CommentDraft string
Comments []Comment
NextCommentID int
@@ -128,9 +158,30 @@ type ReviewModel struct {
MarkdownRenderByPath map[string]bool
ViewFile ViewFile
ViewDiff ViewDiffFile
+ ViewDiffSplit []ViewDiffSplitHunk
+ DiffFormat DiffFormat
+ SidebarWidth string
Error string
}
+// diffOldLineExists reports whether the given old-side line number exists in
+// the diff hunks for the specified file path. Add lines are excluded because
+// they have no old-side representation.
+func diffOldLineExists(files []DiffFile, path string, oldLine int) bool {
+ file := findDiffFile(files, path)
+ if file == nil {
+ return false
+ }
+ for _, h := range file.Hunks {
+ for _, dl := range h.Lines {
+ if dl.OldLine == oldLine && dl.Kind != DiffAdd {
+ return true
+ }
+ }
+ }
+ return false
+}
+
type ReviewServer struct {
Model *ReviewModel
DoneCh chan struct{}
diff --git a/internal/app/preferences.go b/internal/app/preferences.go
new file mode 100644
index 0000000..9294822
--- /dev/null
+++ b/internal/app/preferences.go
@@ -0,0 +1,48 @@
+package app
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+
+ "github.com/adrg/xdg"
+)
+
+type Preferences struct {
+ DiffFormat DiffFormat `json:"diff_format,omitempty"`
+ SidebarWidth string `json:"sidebar_width,omitempty"`
+}
+
+func preferencesPath() string {
+ return filepath.Join(xdg.ConfigHome, "meatcheck", "preferences.json")
+}
+
+func preferredDiffFormat() DiffFormat {
+ p := loadPreferences()
+ if p.DiffFormat == DiffFormatSplit || p.DiffFormat == DiffFormatUnified {
+ return p.DiffFormat
+ }
+ return DiffFormatUnified
+}
+
+func loadPreferences() Preferences {
+ data, err := os.ReadFile(preferencesPath())
+ if err != nil {
+ return Preferences{}
+ }
+ var p Preferences
+ _ = json.Unmarshal(data, &p)
+ return p
+}
+
+func savePreference(fn func(*Preferences)) {
+ p := loadPreferences()
+ fn(&p)
+ path := preferencesPath()
+ _ = os.MkdirAll(filepath.Dir(path), 0o755)
+ data, err := json.Marshal(p)
+ if err != nil {
+ return
+ }
+ _ = os.WriteFile(path, data, 0o644)
+}
diff --git a/internal/app/side_comment_test.go b/internal/app/side_comment_test.go
new file mode 100644
index 0000000..79a8634
--- /dev/null
+++ b/internal/app/side_comment_test.go
@@ -0,0 +1,216 @@
+package app
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// TestProjectLineCommentsSideAware verifies that projectLineComments, once it
+// gains a side parameter, filters comments by their Side field.
+//
+// Scenario: side="" matches only comments with c.Side=="" (backward compat)
+// Scenario: side="" does NOT match comments with c.Side="old"
+// Scenario: side="old" matches comments with c.Side="old"
+// Scenario: side="old" does NOT match comments with c.Side==""
+func TestProjectLineCommentsSideAware(t *testing.T) {
+ newSideComment := Comment{
+ ID: 1,
+ Path: "file.go",
+ StartLine: 5,
+ EndLine: 5,
+ Text: "new-side comment",
+ Side: "",
+ }
+ oldSideComment := Comment{
+ ID: 2,
+ Path: "file.go",
+ StartLine: 5,
+ EndLine: 5,
+ Text: "old-side comment",
+ Side: "old",
+ }
+ allComments := []Comment{newSideComment, oldSideComment}
+
+ tests := []struct {
+ name string
+ side string
+ wantCommented bool
+ wantCount int
+ wantCommentText string
+ }{
+ {
+ name: "side='' matches new-side comment only",
+ side: "",
+ wantCommented: true,
+ wantCount: 1,
+ wantCommentText: "new-side comment",
+ },
+ {
+ name: "side='' does not match old-side comment",
+ side: "",
+ wantCommented: true,
+ wantCount: 1,
+ // Verified by checking the single result is NOT the old-side one.
+ wantCommentText: "new-side comment",
+ },
+ {
+ name: "side='old' matches old-side comment only",
+ side: "old",
+ wantCommented: true,
+ wantCount: 1,
+ wantCommentText: "old-side comment",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ commented, viewComments := projectLineComments("file.go", 5, allComments, 0, tc.side)
+ if commented != tc.wantCommented {
+ t.Errorf("commented: got %v, want %v", commented, tc.wantCommented)
+ }
+ if len(viewComments) != tc.wantCount {
+ t.Fatalf("len(viewComments): got %d, want %d", len(viewComments), tc.wantCount)
+ }
+ if viewComments[0].Text != tc.wantCommentText {
+ t.Errorf("comment text: got %q, want %q", viewComments[0].Text, tc.wantCommentText)
+ }
+ })
+ }
+
+ // Explicit sub-test: side="old" must NOT return the new-side comment.
+ t.Run("side='old' does not match new-side comment", func(t *testing.T) {
+ _, viewComments := projectLineComments("file.go", 5, allComments, 0, "old")
+ for _, vc := range viewComments {
+ if vc.Side != "old" {
+ t.Errorf("unexpected comment with Side=%q returned for side='old' query", vc.Side)
+ }
+ }
+ })
+
+ // Explicit sub-test: side="" must NOT return the old-side comment.
+ t.Run("side='' does not match old-side comment", func(t *testing.T) {
+ _, viewComments := projectLineComments("file.go", 5, allComments, 0, "")
+ for _, vc := range viewComments {
+ if vc.Side != "" {
+ t.Errorf("unexpected comment with Side=%q returned for side='' query", vc.Side)
+ }
+ }
+ })
+}
+
+// TestProjectLineCommentsSideAwareNotCommented verifies that when side="old" is
+// requested but only a new-side comment exists on the line, commented is false.
+//
+// Scenario: side="old" does NOT set commented when only new-side comment exists
+func TestProjectLineCommentsSideAwareNotCommented(t *testing.T) {
+ comments := []Comment{
+ {ID: 1, Path: "file.go", StartLine: 3, EndLine: 3, Text: "new only", Side: ""},
+ }
+
+ commented, viewComments := projectLineComments("file.go", 3, comments, 0, "old")
+ if commented {
+ t.Error("commented should be false when no old-side comment exists on the line")
+ }
+ if len(viewComments) != 0 {
+ t.Errorf("viewComments: got %d, want 0", len(viewComments))
+ }
+}
+
+// TestProjectLineCommentsSideAwareDifferentInputs verifies different line/path
+// combos to prevent the implementation from returning a hardcoded value.
+func TestProjectLineCommentsSideAwareDifferentInputs(t *testing.T) {
+ comments := []Comment{
+ {ID: 10, Path: "alpha.go", StartLine: 1, EndLine: 1, Text: "alpha line1 new", Side: ""},
+ {ID: 11, Path: "alpha.go", StartLine: 2, EndLine: 2, Text: "alpha line2 old", Side: "old"},
+ {ID: 12, Path: "beta.go", StartLine: 1, EndLine: 1, Text: "beta line1 old", Side: "old"},
+ }
+
+ // alpha.go line 1 new-side: expects 1 comment with ID 10
+ _, got := projectLineComments("alpha.go", 1, comments, 0, "")
+ if len(got) != 1 || got[0].ID != 10 {
+ t.Errorf("alpha.go line 1 side='': got %v, want [{ID:10}]", got)
+ }
+
+ // alpha.go line 2 old-side: expects 1 comment with ID 11
+ _, got = projectLineComments("alpha.go", 2, comments, 0, "old")
+ if len(got) != 1 || got[0].ID != 11 {
+ t.Errorf("alpha.go line 2 side='old': got %v, want [{ID:11}]", got)
+ }
+
+ // beta.go line 1 new-side: expects 0 comments (only old-side comment exists)
+ _, got = projectLineComments("beta.go", 1, comments, 0, "")
+ if len(got) != 0 {
+ t.Errorf("beta.go line 1 side='': got %d comments, want 0", len(got))
+ }
+
+ // beta.go line 1 old-side: expects 1 comment with ID 12
+ _, got = projectLineComments("beta.go", 1, comments, 0, "old")
+ if len(got) != 1 || got[0].ID != 12 {
+ t.Errorf("beta.go line 1 side='old': got %v, want [{ID:12}]", got)
+ }
+}
+
+// TestCommentSideFieldOmitEmpty verifies that a Comment with Side="" marshals
+// to JSON without the "side" key (omitempty behavior).
+//
+// Scenario: Comment with Side="" marshals without "side" key in JSON
+func TestCommentSideFieldOmitEmpty(t *testing.T) {
+ c := Comment{
+ ID: 1,
+ Path: "a.go",
+ StartLine: 1,
+ EndLine: 1,
+ Text: "hello",
+ Side: "",
+ }
+
+ data, err := json.Marshal(c)
+ if err != nil {
+ t.Fatalf("json.Marshal error: %v", err)
+ }
+
+ got := string(data)
+ if strings.Contains(got, `"side"`) {
+ t.Errorf("JSON should not contain 'side' key when Side is empty, got: %s", got)
+ }
+ // Verify other fields are present.
+ if !strings.Contains(got, `"id":1`) {
+ t.Errorf("JSON missing 'id' field, got: %s", got)
+ }
+ if !strings.Contains(got, `"path":"a.go"`) {
+ t.Errorf("JSON missing 'path' field, got: %s", got)
+ }
+}
+
+// TestCommentSideFieldPresent verifies that a Comment with Side="old" marshals
+// to JSON with "side":"old".
+//
+// Scenario: Comment with Side="old" marshals with "side":"old" in JSON
+func TestCommentSideFieldPresent(t *testing.T) {
+ c := Comment{
+ ID: 2,
+ Path: "b.go",
+ StartLine: 5,
+ EndLine: 5,
+ Text: "old side comment",
+ Side: "old",
+ }
+
+ data, err := json.Marshal(c)
+ if err != nil {
+ t.Fatalf("json.Marshal error: %v", err)
+ }
+
+ got := string(data)
+ if !strings.Contains(got, `"side":"old"`) {
+ t.Errorf("JSON should contain '\"side\":\"old\"', got: %s", got)
+ }
+ // Verify other fields are present.
+ if !strings.Contains(got, `"id":2`) {
+ t.Errorf("JSON missing 'id' field, got: %s", got)
+ }
+ if !strings.Contains(got, `"path":"b.go"`) {
+ t.Errorf("JSON missing 'path' field, got: %s", got)
+ }
+}
diff --git a/internal/app/view.go b/internal/app/view.go
index 827919a..098acb0 100644
--- a/internal/app/view.go
+++ b/internal/app/view.go
@@ -59,15 +59,141 @@ func updateFileView(model *ReviewModel) {
}
func updateDiffView(model *ReviewModel) {
+ model.ViewDiff = ViewDiffFile{}
+ model.ViewDiffSplit = nil
+
diffFile := findDiffFile(model.DiffFiles, model.SelectedPath)
- viewDiff := ViewDiffFile{Path: model.SelectedPath}
if diffFile != nil {
- viewDiff = buildViewDiff(diffFile, model.Comments, model.SelectionStart, model.SelectionEnd, model.RenderFile, model.EditingCommentID)
+ switch model.DiffFormat {
+ case DiffFormatSplit:
+ model.ViewDiffSplit = buildViewDiffSplit(diffFile, model.Comments, model.SelectionStart, model.SelectionEnd, model.RenderFile, model.EditingCommentID, model.SelectionSide)
+ default:
+ model.ViewDiff = buildViewDiff(diffFile, model.Comments, model.SelectionStart, model.SelectionEnd, model.RenderFile, model.EditingCommentID, model.SelectionSide)
+ }
}
- model.ViewDiff = viewDiff
model.SelectedLabel = model.SelectedPath
}
+func buildViewDiffSplit(file *DiffFile, comments []Comment, start, end int, render bool, editingID int, selectionSide string) []ViewDiffSplitHunk {
+ hunks := make([]ViewDiffSplitHunk, 0, len(file.Hunks))
+ for _, h := range file.Hunks {
+ hdr := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldCount, h.NewStart, h.NewCount)
+ vh := ViewDiffSplitHunk{Header: hdr}
+
+ // Render syntax highlighting for all lines in the hunk if requested.
+ var rendered []template.HTML
+ if render {
+ texts := make([]string, 0, len(h.Lines))
+ for _, dl := range h.Lines {
+ texts = append(texts, dl.Text)
+ }
+ rendered = codeRenderer.RenderLines(file.Path, texts)
+ }
+
+ // Process lines: walk sequentially, grouping del/add blocks together.
+ lines := h.Lines
+ i := 0
+ for i < len(lines) {
+ dl := lines[i]
+ if dl.Kind == DiffContext {
+ // Context line: both sides populated.
+ row := ViewDiffRow{
+ Left: ViewDiffSide{
+ Line: dl.OldLine,
+ Kind: DiffContext,
+ Text: dl.Text,
+ },
+ Right: ViewDiffSide{
+ Line: dl.NewLine,
+ Kind: DiffContext,
+ Text: dl.Text,
+ },
+ }
+ if len(rendered) > i {
+ row.Left.HTML = rendered[i]
+ row.Right.HTML = rendered[i]
+ }
+ vh.Rows = append(vh.Rows, row)
+ i++
+ } else {
+ // Collect consecutive del lines, then consecutive add lines.
+ var dels []int // indices into lines
+ var adds []int
+ for i < len(lines) && lines[i].Kind == DiffDel {
+ dels = append(dels, i)
+ i++
+ }
+ for i < len(lines) && lines[i].Kind == DiffAdd {
+ adds = append(adds, i)
+ i++
+ }
+
+ // Zip dels and adds 1:1.
+ maxLen := max(len(dels), len(adds))
+ for j := range maxLen {
+ row := ViewDiffRow{}
+ if j < len(dels) {
+ idx := dels[j]
+ row.Left = ViewDiffSide{
+ Line: lines[idx].OldLine,
+ Kind: DiffDel,
+ Text: lines[idx].Text,
+ }
+ if len(rendered) > idx {
+ row.Left.HTML = rendered[idx]
+ }
+ } else {
+ row.Left = ViewDiffSide{Empty: true}
+ }
+ if j < len(adds) {
+ idx := adds[j]
+ row.Right = ViewDiffSide{
+ Line: lines[idx].NewLine,
+ Kind: DiffAdd,
+ Text: lines[idx].Text,
+ }
+ if len(rendered) > idx {
+ row.Right.HTML = rendered[idx]
+ }
+ } else {
+ row.Right = ViewDiffSide{Empty: true}
+ }
+ vh.Rows = append(vh.Rows, row)
+ }
+ }
+ }
+
+ // Apply selection and comment projection to each row.
+ for ri := range vh.Rows {
+ row := &vh.Rows[ri]
+
+ // Selection projection.
+ if start > 0 && end > 0 {
+ if selectionSide == "old" {
+ if !row.Left.Empty && row.Left.Line > 0 && row.Left.Line >= start && row.Left.Line <= end {
+ row.Left.Selected = true
+ }
+ } else {
+ if !row.Right.Empty && row.Right.Line > 0 && row.Right.Line >= start && row.Right.Line <= end {
+ row.Right.Selected = true
+ }
+ }
+ }
+
+ // Comment projection: left side uses "old", right side uses "".
+ if !row.Left.Empty && row.Left.Line > 0 {
+ row.Left.Commented, row.Left.Comments = projectLineComments(file.Path, row.Left.Line, comments, editingID, "old")
+ }
+ if !row.Right.Empty && row.Right.Line > 0 {
+ row.Right.Commented, row.Right.Comments = projectLineComments(file.Path, row.Right.Line, comments, editingID, "")
+ }
+ }
+
+ hunks = append(hunks, vh)
+ }
+ return hunks
+}
+
func buildViewLinesWithRanges(file *File, comments []Comment, start, end int, rendered []template.HTML, ranges []LineRange, editingID int) []ViewLine {
if len(ranges) == 0 {
return buildViewLines(file, comments, start, end, rendered, editingID)
@@ -92,7 +218,7 @@ func buildSingleViewLine(file *File, comments []Comment, start, end int, rendere
lineNum := idx + 1
raw := file.Lines[idx]
selected := start > 0 && end > 0 && lineNum >= start && lineNum <= end
- commented, lineComments := projectLineComments(file.Path, lineNum, comments, editingID)
+ commented, lineComments := projectLineComments(file.Path, lineNum, comments, editingID, "")
lineHTML := template.HTML("")
if len(rendered) > idx {
@@ -116,7 +242,7 @@ func buildViewLines(file *File, comments []Comment, start, end int, rendered []t
return lines
}
-func buildViewDiff(file *DiffFile, comments []Comment, start, end int, render bool, editingID int) ViewDiffFile {
+func buildViewDiff(file *DiffFile, comments []Comment, start, end int, render bool, editingID int, selectionSide string) ViewDiffFile {
view := ViewDiffFile{Path: file.Path}
for _, h := range file.Hunks {
hdr := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldCount, h.NewStart, h.NewCount)
@@ -139,15 +265,28 @@ func buildViewDiff(file *DiffFile, comments []Comment, start, end int, render bo
if len(rendered) > i {
line.HTML = rendered[i]
}
- selectable := dl.NewLine > 0 && dl.Kind != DiffDel
- if selectable && start > 0 && end > 0 && dl.NewLine >= start && dl.NewLine <= end {
- line.Selected = true
+ // Selection: when selectionSide == "old", check OldLine; otherwise check NewLine
+ if start > 0 && end > 0 {
+ if selectionSide == "old" {
+ if dl.OldLine > 0 && dl.OldLine >= start && dl.OldLine <= end {
+ line.Selected = true
+ }
+ } else {
+ if dl.NewLine > 0 && dl.NewLine >= start && dl.NewLine <= end {
+ line.Selected = true
+ }
+ }
}
+ // Project comments from both sides, merge results
if dl.NewLine > 0 {
- line.Commented, line.Comments = projectLineComments(file.Path, dl.NewLine, comments, editingID)
+ line.Commented, line.Comments = projectLineComments(file.Path, dl.NewLine, comments, editingID, "")
}
- if !selectable {
- line.Selected = false
+ if dl.OldLine > 0 {
+ oldCommented, oldComments := projectLineComments(file.Path, dl.OldLine, comments, editingID, "old")
+ if oldCommented {
+ line.Commented = true
+ }
+ line.Comments = append(line.Comments, oldComments...)
}
vh.Lines = append(vh.Lines, line)
}
@@ -156,13 +295,23 @@ func buildViewDiff(file *DiffFile, comments []Comment, start, end int, render bo
return view
}
-func projectLineComments(path string, lineNum int, comments []Comment, editingID int) (bool, []ViewComment) {
+func projectLineComments(path string, lineNum int, comments []Comment, editingID int, side string) (bool, []ViewComment) {
commented := false
lineComments := make([]ViewComment, 0)
for _, c := range comments {
if c.Path != path {
continue
}
+ // Side-aware filtering: "" matches new-side (c.Side == ""), "old" matches old-side (c.Side == "old")
+ if side == "" {
+ if c.Side != "" {
+ continue
+ }
+ } else {
+ if c.Side != side {
+ continue
+ }
+ }
if lineNum >= c.StartLine && lineNum <= c.EndLine {
commented = true
}
diff --git a/internal/app/view_diff_split_test.go b/internal/app/view_diff_split_test.go
new file mode 100644
index 0000000..5575c83
--- /dev/null
+++ b/internal/app/view_diff_split_test.go
@@ -0,0 +1,374 @@
+package app
+
+import (
+ "fmt"
+ "testing"
+)
+
+// TestBuildViewDiffSplitContextLines verifies that context lines appear on
+// both the left and right sides of a split-diff row.
+//
+// Scenario: Context lines appear on both sides
+func TestBuildViewDiffSplitContextLines(t *testing.T) {
+ df := &DiffFile{Path: "ctx.go", Hunks: []DiffHunk{{
+ OldStart: 1,
+ OldCount: 2,
+ NewStart: 1,
+ NewCount: 2,
+ Lines: []DiffLine{
+ {Kind: DiffContext, OldLine: 1, NewLine: 1, Text: "line one"},
+ {Kind: DiffContext, OldLine: 2, NewLine: 2, Text: "line two"},
+ },
+ }}}
+
+ hunks := buildViewDiffSplit(df, nil, 0, 0, false, 0, "")
+
+ if len(hunks) != 1 {
+ t.Fatalf("expected 1 hunk, got %d", len(hunks))
+ }
+ rows := hunks[0].Rows
+ if len(rows) != 2 {
+ t.Fatalf("expected 2 rows, got %d", len(rows))
+ }
+
+ // Row 0: context line 1
+ r0 := rows[0]
+ if r0.Left.Line != 1 {
+ t.Errorf("row 0 Left.Line: got %d, want 1", r0.Left.Line)
+ }
+ if r0.Right.Line != 1 {
+ t.Errorf("row 0 Right.Line: got %d, want 1", r0.Right.Line)
+ }
+ if r0.Left.Text != "line one" {
+ t.Errorf("row 0 Left.Text: got %q, want %q", r0.Left.Text, "line one")
+ }
+ if r0.Right.Text != "line one" {
+ t.Errorf("row 0 Right.Text: got %q, want %q", r0.Right.Text, "line one")
+ }
+ if r0.Left.Kind != DiffContext {
+ t.Errorf("row 0 Left.Kind: got %q, want %q", r0.Left.Kind, DiffContext)
+ }
+ if r0.Right.Kind != DiffContext {
+ t.Errorf("row 0 Right.Kind: got %q, want %q", r0.Right.Kind, DiffContext)
+ }
+ if r0.Left.Empty {
+ t.Errorf("row 0 Left.Empty: got true, want false")
+ }
+ if r0.Right.Empty {
+ t.Errorf("row 0 Right.Empty: got true, want false")
+ }
+
+ // Row 1: context line 2
+ r1 := rows[1]
+ if r1.Left.Line != 2 {
+ t.Errorf("row 1 Left.Line: got %d, want 2", r1.Left.Line)
+ }
+ if r1.Right.Line != 2 {
+ t.Errorf("row 1 Right.Line: got %d, want 2", r1.Right.Line)
+ }
+ if r1.Left.Text != "line two" {
+ t.Errorf("row 1 Left.Text: got %q, want %q", r1.Left.Text, "line two")
+ }
+ if r1.Right.Text != "line two" {
+ t.Errorf("row 1 Right.Text: got %q, want %q", r1.Right.Text, "line two")
+ }
+}
+
+// TestBuildViewDiffSplitDelAddPairing verifies that a del/add block is paired
+// 1:1, with unpaired add lines getting an Empty left side.
+//
+// Scenario: Del/Add blocks: collect consecutive dels then adds, zip 1:1;
+// unpaired lines get Empty: true opposite side (more adds than dels case).
+func TestBuildViewDiffSplitDelAddPairing(t *testing.T) {
+ df := &DiffFile{Path: "pair.go", Hunks: []DiffHunk{{
+ OldStart: 1,
+ OldCount: 2,
+ NewStart: 1,
+ NewCount: 3,
+ Lines: []DiffLine{
+ {Kind: DiffDel, OldLine: 1, NewLine: 0, Text: "old1"},
+ {Kind: DiffDel, OldLine: 2, NewLine: 0, Text: "old2"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 1, Text: "new1"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 2, Text: "new2"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 3, Text: "new3"},
+ },
+ }}}
+
+ hunks := buildViewDiffSplit(df, nil, 0, 0, false, 0, "")
+
+ if len(hunks) != 1 {
+ t.Fatalf("expected 1 hunk, got %d", len(hunks))
+ }
+ rows := hunks[0].Rows
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+
+ // Row 0: del old1 paired with add new1
+ r0 := rows[0]
+ if r0.Left.Line != 1 {
+ t.Errorf("row 0 Left.Line: got %d, want 1", r0.Left.Line)
+ }
+ if r0.Left.Kind != DiffDel {
+ t.Errorf("row 0 Left.Kind: got %q, want %q", r0.Left.Kind, DiffDel)
+ }
+ if r0.Left.Text != "old1" {
+ t.Errorf("row 0 Left.Text: got %q, want %q", r0.Left.Text, "old1")
+ }
+ if r0.Left.Empty {
+ t.Errorf("row 0 Left.Empty: got true, want false")
+ }
+ if r0.Right.Line != 1 {
+ t.Errorf("row 0 Right.Line: got %d, want 1", r0.Right.Line)
+ }
+ if r0.Right.Kind != DiffAdd {
+ t.Errorf("row 0 Right.Kind: got %q, want %q", r0.Right.Kind, DiffAdd)
+ }
+ if r0.Right.Text != "new1" {
+ t.Errorf("row 0 Right.Text: got %q, want %q", r0.Right.Text, "new1")
+ }
+ if r0.Right.Empty {
+ t.Errorf("row 0 Right.Empty: got true, want false")
+ }
+
+ // Row 1: del old2 paired with add new2
+ r1 := rows[1]
+ if r1.Left.Line != 2 {
+ t.Errorf("row 1 Left.Line: got %d, want 2", r1.Left.Line)
+ }
+ if r1.Left.Kind != DiffDel {
+ t.Errorf("row 1 Left.Kind: got %q, want %q", r1.Left.Kind, DiffDel)
+ }
+ if r1.Left.Text != "old2" {
+ t.Errorf("row 1 Left.Text: got %q, want %q", r1.Left.Text, "old2")
+ }
+ if r1.Right.Line != 2 {
+ t.Errorf("row 1 Right.Line: got %d, want 2", r1.Right.Line)
+ }
+ if r1.Right.Kind != DiffAdd {
+ t.Errorf("row 1 Right.Kind: got %q, want %q", r1.Right.Kind, DiffAdd)
+ }
+ if r1.Right.Text != "new2" {
+ t.Errorf("row 1 Right.Text: got %q, want %q", r1.Right.Text, "new2")
+ }
+
+ // Row 2: no del to pair — left side must be empty
+ r2 := rows[2]
+ if !r2.Left.Empty {
+ t.Errorf("row 2 Left.Empty: got false, want true (unpaired add)")
+ }
+ if r2.Right.Line != 3 {
+ t.Errorf("row 2 Right.Line: got %d, want 3", r2.Right.Line)
+ }
+ if r2.Right.Kind != DiffAdd {
+ t.Errorf("row 2 Right.Kind: got %q, want %q", r2.Right.Kind, DiffAdd)
+ }
+ if r2.Right.Text != "new3" {
+ t.Errorf("row 2 Right.Text: got %q, want %q", r2.Right.Text, "new3")
+ }
+ if r2.Right.Empty {
+ t.Errorf("row 2 Right.Empty: got true, want false")
+ }
+}
+
+// TestBuildViewDiffSplitMoreDelsThanAdds verifies that when there are more del
+// lines than add lines, the unpaired del rows get an Empty right side.
+//
+// Scenario: Del/Add blocks: unpaired lines get Empty: true opposite side
+// (more dels than adds case).
+func TestBuildViewDiffSplitMoreDelsThanAdds(t *testing.T) {
+ df := &DiffFile{Path: "moredel.go", Hunks: []DiffHunk{{
+ OldStart: 1,
+ OldCount: 3,
+ NewStart: 1,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffDel, OldLine: 1, NewLine: 0, Text: "del1"},
+ {Kind: DiffDel, OldLine: 2, NewLine: 0, Text: "del2"},
+ {Kind: DiffDel, OldLine: 3, NewLine: 0, Text: "del3"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 1, Text: "add1"},
+ },
+ }}}
+
+ hunks := buildViewDiffSplit(df, nil, 0, 0, false, 0, "")
+
+ if len(hunks) != 1 {
+ t.Fatalf("expected 1 hunk, got %d", len(hunks))
+ }
+ rows := hunks[0].Rows
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+
+ // Row 0: del1 paired with add1
+ r0 := rows[0]
+ if r0.Left.Kind != DiffDel {
+ t.Errorf("row 0 Left.Kind: got %q, want %q", r0.Left.Kind, DiffDel)
+ }
+ if r0.Left.Text != "del1" {
+ t.Errorf("row 0 Left.Text: got %q, want %q", r0.Left.Text, "del1")
+ }
+ if r0.Left.Empty {
+ t.Errorf("row 0 Left.Empty: got true, want false")
+ }
+ if r0.Right.Kind != DiffAdd {
+ t.Errorf("row 0 Right.Kind: got %q, want %q", r0.Right.Kind, DiffAdd)
+ }
+ if r0.Right.Text != "add1" {
+ t.Errorf("row 0 Right.Text: got %q, want %q", r0.Right.Text, "add1")
+ }
+ if r0.Right.Empty {
+ t.Errorf("row 0 Right.Empty: got true, want false")
+ }
+
+ // Row 1: del2 unpaired — right side must be empty
+ r1 := rows[1]
+ if r1.Left.Kind != DiffDel {
+ t.Errorf("row 1 Left.Kind: got %q, want %q", r1.Left.Kind, DiffDel)
+ }
+ if r1.Left.Text != "del2" {
+ t.Errorf("row 1 Left.Text: got %q, want %q", r1.Left.Text, "del2")
+ }
+ if r1.Left.Empty {
+ t.Errorf("row 1 Left.Empty: got true, want false")
+ }
+ if !r1.Right.Empty {
+ t.Errorf("row 1 Right.Empty: got false, want true (unpaired del)")
+ }
+
+ // Row 2: del3 unpaired — right side must be empty
+ r2 := rows[2]
+ if r2.Left.Kind != DiffDel {
+ t.Errorf("row 2 Left.Kind: got %q, want %q", r2.Left.Kind, DiffDel)
+ }
+ if r2.Left.Text != "del3" {
+ t.Errorf("row 2 Left.Text: got %q, want %q", r2.Left.Text, "del3")
+ }
+ if r2.Left.Empty {
+ t.Errorf("row 2 Left.Empty: got true, want false")
+ }
+ if !r2.Right.Empty {
+ t.Errorf("row 2 Right.Empty: got false, want true (unpaired del)")
+ }
+}
+
+// TestBuildViewDiffSplitSelection verifies that selection is applied
+// independently to each side: selectionSide="old" selects only the left (del)
+// side when OldLine is in range, and the right (add) side is not selected.
+//
+// Scenario: Selection works on each side independently
+func TestBuildViewDiffSplitSelection(t *testing.T) {
+ df := &DiffFile{Path: "sel.go", Hunks: []DiffHunk{{
+ OldStart: 5,
+ OldCount: 1,
+ NewStart: 5,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffDel, OldLine: 5, NewLine: 0, Text: "old text"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 5, Text: "new text"},
+ },
+ }}}
+
+ // selectionSide="old", range [5,5]: only the del line (OldLine=5) should be selected.
+ hunks := buildViewDiffSplit(df, nil, 5, 5, false, 0, "old")
+
+ if len(hunks) != 1 {
+ t.Fatalf("expected 1 hunk, got %d", len(hunks))
+ }
+ rows := hunks[0].Rows
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row, got %d", len(rows))
+ }
+
+ r := rows[0]
+ // Left side (del, OldLine=5) must be selected.
+ if !r.Left.Selected {
+ t.Errorf("Left (del, OldLine=5) should be Selected when selectionSide='old' and range=[5,5]")
+ }
+ // Right side (add, OldLine=0) must NOT be selected.
+ if r.Right.Selected {
+ t.Errorf("Right (add) should NOT be selected when selectionSide='old'")
+ }
+}
+
+// TestBuildViewDiffSplitComments verifies that comments are projected to the
+// correct side: old-side comments project to the left, new-side comments to
+// the right.
+//
+// Scenario: Comments project to correct side
+func TestBuildViewDiffSplitComments(t *testing.T) {
+ df := &DiffFile{Path: "cmt.go", Hunks: []DiffHunk{{
+ OldStart: 3,
+ OldCount: 1,
+ NewStart: 4,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffContext, OldLine: 3, NewLine: 4, Text: "context text"},
+ },
+ }}}
+
+ oldComment := Comment{ID: 10, Path: "cmt.go", StartLine: 3, EndLine: 3, Text: "old comment", Side: "old"}
+ newComment := Comment{ID: 11, Path: "cmt.go", StartLine: 4, EndLine: 4, Text: "new comment", Side: ""}
+ comments := []Comment{oldComment, newComment}
+
+ hunks := buildViewDiffSplit(df, comments, 0, 0, false, 0, "")
+
+ if len(hunks) != 1 {
+ t.Fatalf("expected 1 hunk, got %d", len(hunks))
+ }
+ rows := hunks[0].Rows
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row, got %d", len(rows))
+ }
+
+ r := rows[0]
+
+ // Left side uses "old" side for comments.
+ if !r.Left.Commented {
+ t.Errorf("Left side (OldLine=3) should be Commented by the old-side comment")
+ }
+ if len(r.Left.Comments) != 1 {
+ t.Errorf("Left side should have 1 comment, got %d", len(r.Left.Comments))
+ } else if r.Left.Comments[0].ID != 10 {
+ t.Errorf("Left side comment ID: got %d, want 10", r.Left.Comments[0].ID)
+ }
+
+ // Right side uses "" (new) side for comments.
+ if !r.Right.Commented {
+ t.Errorf("Right side (NewLine=4) should be Commented by the new-side comment")
+ }
+ if len(r.Right.Comments) != 1 {
+ t.Errorf("Right side should have 1 comment, got %d", len(r.Right.Comments))
+ } else if r.Right.Comments[0].ID != 11 {
+ t.Errorf("Right side comment ID: got %d, want 11", r.Right.Comments[0].ID)
+ }
+}
+
+// TestBuildViewDiffSplitHunkHeader verifies that the Header field of a
+// returned ViewDiffSplitHunk matches the standard unified-diff format.
+//
+// Scenario: Hunk header is set correctly
+func TestBuildViewDiffSplitHunkHeader(t *testing.T) {
+ df := &DiffFile{Path: "hdr.go", Hunks: []DiffHunk{{
+ OldStart: 10,
+ OldCount: 3,
+ NewStart: 12,
+ NewCount: 4,
+ Lines: []DiffLine{
+ {Kind: DiffContext, OldLine: 10, NewLine: 12, Text: "a"},
+ {Kind: DiffContext, OldLine: 11, NewLine: 13, Text: "b"},
+ {Kind: DiffContext, OldLine: 12, NewLine: 14, Text: "c"},
+ },
+ }}}
+
+ hunks := buildViewDiffSplit(df, nil, 0, 0, false, 0, "")
+
+ if len(hunks) != 1 {
+ t.Fatalf("expected 1 hunk, got %d", len(hunks))
+ }
+
+ want := fmt.Sprintf("@@ -%d,%d +%d,%d @@", 10, 3, 12, 4)
+ if hunks[0].Header != want {
+ t.Errorf("hunk Header: got %q, want %q", hunks[0].Header, want)
+ }
+}
diff --git a/internal/app/view_diff_test.go b/internal/app/view_diff_test.go
index 90cb91a..d5f5db4 100644
--- a/internal/app/view_diff_test.go
+++ b/internal/app/view_diff_test.go
@@ -14,7 +14,7 @@ func TestBuildViewDiffCommentsNewLinesOnly(t *testing.T) {
},
}}}
comments := []Comment{{ID: 1, Path: "x.go", StartLine: 1, EndLine: 1, Text: "hi"}}
- view := buildViewDiff(df, comments, 1, 1, false, 0)
+ view := buildViewDiff(df, comments, 1, 1, false, 0, "")
if len(view.Hunks) != 1 {
t.Fatalf("expected 1 hunk")
}
@@ -32,3 +32,163 @@ func TestBuildViewDiffCommentsNewLinesOnly(t *testing.T) {
t.Fatalf("expected 1 comment on added line")
}
}
+
+// TestBuildViewDiffOldLineSelection verifies that when selectionSide == "old",
+// a deleted line whose OldLine falls in [start, end] is marked Selected.
+//
+// Scenario: selectionSide "old" selects deleted line by OldLine number
+func TestBuildViewDiffOldLineSelection(t *testing.T) {
+ df := &DiffFile{Path: "f.go", Hunks: []DiffHunk{{
+ OldStart: 5,
+ OldCount: 1,
+ NewStart: 5,
+ NewCount: 1,
+ Lines: []DiffLine{
+ {Kind: DiffDel, OldLine: 5, NewLine: 0, Text: "deleted text"},
+ {Kind: DiffAdd, OldLine: 0, NewLine: 5, Text: "added text"},
+ },
+ }}}
+
+ // selectionSide="old", select range [5,5]: only the del line (OldLine==5) should be selected.
+ view := buildViewDiff(df, nil, 5, 5, false, 0, "old")
+
+ if len(view.Hunks) != 1 {
+ t.Fatalf("expected 1 hunk, got %d", len(view.Hunks))
+ }
+ lines := view.Hunks[0].Lines
+ if len(lines) != 2 {
+ t.Fatalf("expected 2 lines, got %d", len(lines))
+ }
+
+ delLine := lines[0]
+ if delLine.Kind != DiffDel {
+ t.Fatalf("lines[0] should be DiffDel, got %q", delLine.Kind)
+ }
+ if !delLine.Selected {
+ t.Errorf("deleted line with OldLine=5 should be Selected when selectionSide='old' and range=[5,5]")
+ }
+
+ addLine := lines[1]
+ if addLine.Kind != DiffAdd {
+ t.Fatalf("lines[1] should be DiffAdd, got %q", addLine.Kind)
+ }
+ // The add line has OldLine=0, so with selectionSide="old" it must NOT be selected.
+ if addLine.Selected {
+ t.Errorf("added line (OldLine=0) should NOT be selected when selectionSide='old'")
+ }
+}
+
+// TestBuildViewDiffOldLineComments verifies that comments with Side "old" appear
+// on deleted lines and comments with Side "" appear on context/new lines.
+//
+// Scenario: old-side comment projects onto deleted line via OldLine
+// Scenario: new-side comment projects onto context line via NewLine
+func TestBuildViewDiffOldLineComments(t *testing.T) {
+ // del line: OldLine=3, NewLine=0
+ // context line: OldLine=3, NewLine=4 (same old number as del, different new number)
+ df := &DiffFile{Path: "g.go", Hunks: []DiffHunk{{
+ OldStart: 3,
+ OldCount: 2,
+ NewStart: 3,
+ NewCount: 2,
+ Lines: []DiffLine{
+ {Kind: DiffDel, OldLine: 3, NewLine: 0, Text: "removed line"},
+ {Kind: DiffContext, OldLine: 4, NewLine: 4, Text: "context line"},
+ },
+ }}}
+
+ oldSideComment := Comment{ID: 10, Path: "g.go", StartLine: 3, EndLine: 3, Text: "old comment", Side: "old"}
+ newSideComment := Comment{ID: 11, Path: "g.go", StartLine: 4, EndLine: 4, Text: "new comment", Side: ""}
+ comments := []Comment{oldSideComment, newSideComment}
+
+ view := buildViewDiff(df, comments, 0, 0, false, 0, "")
+
+ if len(view.Hunks) != 1 {
+ t.Fatalf("expected 1 hunk, got %d", len(view.Hunks))
+ }
+ lines := view.Hunks[0].Lines
+ if len(lines) != 2 {
+ t.Fatalf("expected 2 lines, got %d", len(lines))
+ }
+
+ delLine := lines[0]
+ if delLine.Kind != DiffDel {
+ t.Fatalf("lines[0] should be DiffDel")
+ }
+ // Deleted line should show the old-side comment (Side="old", OldLine=3).
+ if !delLine.Commented {
+ t.Errorf("deleted line (OldLine=3) should be Commented via the old-side comment")
+ }
+ if len(delLine.Comments) != 1 {
+ t.Errorf("deleted line should have 1 comment, got %d", len(delLine.Comments))
+ } else if delLine.Comments[0].ID != 10 {
+ t.Errorf("deleted line comment should have ID=10, got %d", delLine.Comments[0].ID)
+ }
+
+ ctxLine := lines[1]
+ if ctxLine.Kind != DiffContext {
+ t.Fatalf("lines[1] should be DiffContext")
+ }
+ // Context line should show the new-side comment (Side="", NewLine=4).
+ if !ctxLine.Commented {
+ t.Errorf("context line (NewLine=4) should be Commented via the new-side comment")
+ }
+ if len(ctxLine.Comments) != 1 {
+ t.Errorf("context line should have 1 comment, got %d", len(ctxLine.Comments))
+ } else if ctxLine.Comments[0].ID != 11 {
+ t.Errorf("context line comment should have ID=11, got %d", ctxLine.Comments[0].ID)
+ }
+}
+
+// TestBuildViewDiffDeletedLinesSelectable verifies that deleted lines are no
+// longer blocked by the selectable guard and can be selected when
+// selectionSide == "old".
+//
+// Scenario: deleted lines not filtered out — always included in output
+// Scenario: deleted line selected when selectionSide="old" and OldLine in range
+func TestBuildViewDiffDeletedLinesSelectable(t *testing.T) {
+ df := &DiffFile{Path: "h.go", Hunks: []DiffHunk{{
+ OldStart: 10,
+ OldCount: 1,
+ NewStart: 11,
+ NewCount: 0,
+ Lines: []DiffLine{
+ {Kind: DiffDel, OldLine: 10, NewLine: 0, Text: "removed"},
+ },
+ }}}
+
+ // First call: selectionSide="" with no selection range — line must appear in output.
+ view := buildViewDiff(df, nil, 0, 0, false, 0, "")
+
+ if len(view.Hunks) != 1 {
+ t.Fatalf("case 1: expected 1 hunk, got %d", len(view.Hunks))
+ }
+ lines := view.Hunks[0].Lines
+ if len(lines) != 1 {
+ t.Fatalf("case 1: expected 1 line (del line must not be filtered), got %d", len(lines))
+ }
+ if lines[0].Kind != DiffDel {
+ t.Fatalf("case 1: expected DiffDel line, got %q", lines[0].Kind)
+ }
+ if lines[0].OldLine != 10 {
+ t.Errorf("case 1: expected OldLine=10, got %d", lines[0].OldLine)
+ }
+ // With no selection range the line must NOT be selected.
+ if lines[0].Selected {
+ t.Errorf("case 1: del line should not be selected when start=0, end=0")
+ }
+
+ // Second call: selectionSide="old", range covers OldLine=10 — del line must be selected.
+ view2 := buildViewDiff(df, nil, 10, 10, false, 0, "old")
+
+ if len(view2.Hunks) != 1 {
+ t.Fatalf("case 2: expected 1 hunk, got %d", len(view2.Hunks))
+ }
+ lines2 := view2.Hunks[0].Lines
+ if len(lines2) != 1 {
+ t.Fatalf("case 2: expected 1 line, got %d", len(lines2))
+ }
+ if !lines2[0].Selected {
+ t.Errorf("case 2: del line (OldLine=10) should be Selected when selectionSide='old' and range=[10,10]")
+ }
+}
diff --git a/internal/ui/styles.css b/internal/ui/styles.css
index a608c5c..f65f068 100644
--- a/internal/ui/styles.css
+++ b/internal/ui/styles.css
@@ -774,7 +774,7 @@ textarea {
}
.diff {
- background: var(--panel);
+ background: #000000;
border: 1px solid var(--border);
border-radius: 0;
padding: 0;
@@ -795,9 +795,13 @@ textarea {
border-bottom: 1px solid var(--border);
}
+.diff-inner {
+ display: block;
+}
+
.diff-line {
display: grid;
- grid-template-columns: 4ch 4ch 1ch max-content;
+ grid-template-columns: 4ch 4ch 1ch 1fr;
gap: 12px;
padding: 0 20px;
align-items: baseline;
@@ -826,11 +830,11 @@ textarea {
}
.diff-line[data-kind="add"] {
- background: rgba(46, 160, 67, 0.12);
+ background: rgba(46, 160, 67, 0.25);
}
.diff-line[data-kind="del"] {
- background: rgba(248, 81, 73, 0.16);
+ background: rgba(248, 81, 73, 0.25);
}
.diff-sign.add {
@@ -851,7 +855,9 @@ textarea {
}
.diff-line .code-text {
- white-space: pre;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ min-width: 0;
font-family: Menlo, Consolas, Monaco, Adwaita Mono, Liberation Mono, Lucida Console, monospace;
letter-spacing: 0;
word-spacing: 0;
@@ -860,12 +866,17 @@ textarea {
.diff-line .code-text * {
font-family: Menlo, Consolas, Monaco, Adwaita Mono, Liberation Mono, Lucida Console, monospace;
- white-space: pre;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
letter-spacing: 0;
word-spacing: 0;
font-variant-ligatures: none;
}
+.chroma {
+ background-color: transparent !important;
+}
+
.diff-line:hover {
background: var(--line-hover);
}
@@ -906,3 +917,99 @@ textarea {
.edit-comment-form textarea {
min-height: 70px;
}
+
+/* Side-by-side diff layout */
+.diff-split {
+ overflow-x: hidden;
+}
+
+.diff-row-split {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+}
+
+.diff-cell {
+ display: grid;
+ grid-template-columns: 4ch 1ch 1fr;
+ gap: 12px;
+ padding: 0 12px;
+ align-items: baseline;
+ cursor: pointer;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.diff-cell.empty {
+ cursor: default;
+}
+
+.diff-cell[data-kind="add"] {
+ background: rgba(46, 160, 67, 0.25);
+}
+
+.diff-cell[data-kind="del"] {
+ background: rgba(248, 81, 73, 0.25);
+}
+
+.diff-cell:hover:not(.empty) {
+ background: var(--line-hover);
+}
+
+.diff-cell.selected {
+ background: var(--line-selected);
+}
+
+.diff-cell.commented .ln {
+ color: var(--accent);
+ font-weight: 700;
+}
+
+.diff-cell .ln {
+ color: var(--muted);
+ text-align: right;
+ padding-right: 8px;
+ user-select: none;
+ cursor: pointer;
+ font-variant-numeric: tabular-nums;
+ font-feature-settings: "tnum";
+ font-family: Menlo, Consolas, Monaco, Adwaita Mono, Liberation Mono, Lucida Console, monospace;
+ width: 4ch;
+}
+
+.diff-cell .diff-sign {
+ color: var(--muted);
+ text-align: center;
+ width: 1ch;
+}
+
+.diff-cell .diff-sign.add {
+ color: #2ea043;
+}
+
+.diff-cell .diff-sign.del {
+ color: #f85149;
+}
+
+.diff-cell .code-text {
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ min-width: 0;
+ font-family: Menlo, Consolas, Monaco, Adwaita Mono, Liberation Mono, Lucida Console, monospace;
+ letter-spacing: 0;
+ word-spacing: 0;
+ font-variant-ligatures: none;
+}
+
+.diff-cell .code-text * {
+ font-family: Menlo, Consolas, Monaco, Adwaita Mono, Liberation Mono, Lucida Console, monospace;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ letter-spacing: 0;
+ word-spacing: 0;
+ font-variant-ligatures: none;
+}
+
+/* Border separator between left and right cells */
+.diff-cell:first-child {
+ border-right: 1px solid var(--border);
+}
diff --git a/internal/ui/template.html b/internal/ui/template.html
index 4a068bb..49c0d55 100644
--- a/internal/ui/template.html
+++ b/internal/ui/template.html
@@ -62,6 +62,14 @@
{{end}}