diff --git a/.claude-plugin/skills/revdiff/references/config.md b/.claude-plugin/skills/revdiff/references/config.md index fa556fa9..c20868a4 100644 --- a/.claude-plugin/skills/revdiff/references/config.md +++ b/.claude-plugin/skills/revdiff/references/config.md @@ -23,6 +23,7 @@ Then uncomment and edit the values you want to change. | `--tab-width` | `REVDIFF_TAB_WIDTH` | Spaces per tab character | `4` | | `--no-colors` | `REVDIFF_NO_COLORS` | Disable all colors including syntax highlighting | `false` | | `--no-status-bar` | `REVDIFF_NO_STATUS_BAR` | Hide the status bar | `false` | +| `--wrap` | `REVDIFF_WRAP` | Enable line wrapping in diff view | `false` | | `--no-confirm-discard` | `REVDIFF_NO_CONFIRM_DISCARD` | Skip confirmation when discarding annotations with Q | `false` | | `--chroma-style` | `REVDIFF_CHROMA_STYLE` | Chroma color theme for syntax highlighting | `catppuccin-macchiato` | | `-o`, `--output` | `REVDIFF_OUTPUT` | Write annotations to file instead of stdout | | diff --git a/.claude-plugin/skills/revdiff/references/usage.md b/.claude-plugin/skills/revdiff/references/usage.md index 4ed5bf18..c2cfb16e 100644 --- a/.claude-plugin/skills/revdiff/references/usage.md +++ b/.claude-plugin/skills/revdiff/references/usage.md @@ -44,6 +44,7 @@ revdiff HEAD~1 # review last commit | Key | Action | |-----|--------| | `v` | Toggle collapsed diff mode (shows final text with change markers) | +| `w` | Toggle word wrap (long lines wrap with `↪` continuation markers) | | `.` | Expand/collapse individual hunk under cursor (collapsed mode only) | | `f` | Toggle filter: all files / annotated only | | `?` | Toggle help overlay showing all keybindings | diff --git a/CLAUDE.md b/CLAUDE.md index 06b38a81..b10229a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,9 @@ git diff → diff.ParseUnifiedDiff() → []DiffLine collapsed (`v` toggle): renderCollapsedDiff() → skips removed lines, uses buildModifiedSet() to style adds as modify (amber ~) or pure add (green +) expanded hunks (`.` toggle) show all lines inline + when wrap mode is on (`w` toggle, orthogonal to above): + wrapContent() splits long lines via ansi.Wrap, + continuation lines get `↪` gutter marker, cursorViewportY() sums wrapped line counts → viewport.SetContent() → terminal ``` diff --git a/README.md b/README.md index d1fa7805..66335a06 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Built for a specific use case: reviewing code changes without leaving a terminal - Structured annotation output to stdout - pipe into AI agents, scripts, or other tools - Full-file diff view with syntax highlighting - Collapsed diff mode: shows final text with change markers, toggle with `v` +- Word wrap mode: wraps long lines at viewport boundary with `↪` continuation markers, toggle with `w` - Annotate any line in the diff (added, removed, or context) plus file-level notes - Two-pane TUI: file tree (left) + colorized diff viewport (right) - Hunk navigation to jump between change groups @@ -126,6 +127,7 @@ revdiff [OPTIONS] [ref] | `--tab-width` | Number of spaces per tab character, env: `REVDIFF_TAB_WIDTH` | `4` | | `--no-colors` | Disable all colors including syntax highlighting, env: `REVDIFF_NO_COLORS` | `false` | | `--no-status-bar` | Hide the status bar, env: `REVDIFF_NO_STATUS_BAR` | `false` | +| `--wrap` | Enable line wrapping in diff view, env: `REVDIFF_WRAP` | `false` | | `--no-confirm-discard` | Skip confirmation when discarding annotations with Q, env: `REVDIFF_NO_CONFIRM_DISCARD` | `false` | | `--chroma-style` | Chroma color theme for syntax highlighting, env: `REVDIFF_CHROMA_STYLE` | `catppuccin-macchiato` | | `-o`, `--output` | Write annotations to file instead of stdout, env: `REVDIFF_OUTPUT` | | @@ -233,6 +235,7 @@ revdiff HEAD~1 | Key | Action | |-----|--------| | `v` | Toggle collapsed diff mode (shows final text with change markers) | +| `w` | Toggle word wrap (long lines wrap with `↪` continuation markers) | | `.` | Expand/collapse individual hunk under cursor (collapsed mode only) | | `f` | Toggle filter: all files / annotated only (shown when annotations exist) | | `?` | Toggle help overlay showing all keybindings | diff --git a/cmd/revdiff/main.go b/cmd/revdiff/main.go index 457f7aec..68a4fa42 100644 --- a/cmd/revdiff/main.go +++ b/cmd/revdiff/main.go @@ -30,6 +30,7 @@ type options struct { NoColors bool `long:"no-colors" ini-name:"no-colors" env:"REVDIFF_NO_COLORS" description:"disable all colors including syntax highlighting"` NoStatusBar bool `long:"no-status-bar" ini-name:"no-status-bar" env:"REVDIFF_NO_STATUS_BAR" description:"hide the status bar"` NoConfirmDiscard bool `long:"no-confirm-discard" ini-name:"no-confirm-discard" env:"REVDIFF_NO_CONFIRM_DISCARD" description:"skip confirmation prompt when discarding annotations with Q"` + Wrap bool `long:"wrap" ini-name:"wrap" env:"REVDIFF_WRAP" description:"enable line wrapping in diff view"` ChromaStyle string `long:"chroma-style" ini-name:"chroma-style" env:"REVDIFF_CHROMA_STYLE" default:"catppuccin-macchiato" description:"chroma style for syntax highlighting"` Output string `long:"output" short:"o" env:"REVDIFF_OUTPUT" no-ini:"true" description:"write annotations to file instead of stdout"` Config string `long:"config" env:"REVDIFF_CONFIG" no-ini:"true" description:"path to config file"` @@ -177,6 +178,7 @@ func run(opts options) error { NoColors: opts.NoColors, NoStatusBar: opts.NoStatusBar, NoConfirmDiscard: opts.NoConfirmDiscard, + Wrap: opts.Wrap, TabWidth: opts.TabWidth, Ref: opts.Ref.Ref, Staged: opts.Staged, diff --git a/cmd/revdiff/main_test.go b/cmd/revdiff/main_test.go index 5d9e5036..1e76671a 100644 --- a/cmd/revdiff/main_test.go +++ b/cmd/revdiff/main_test.go @@ -26,6 +26,7 @@ func TestParseArgs_Defaults(t *testing.T) { assert.False(t, opts.NoColors) assert.False(t, opts.NoStatusBar) assert.False(t, opts.NoConfirmDiscard) + assert.False(t, opts.Wrap) assert.Empty(t, opts.Output) assert.Empty(t, opts.Ref.Ref) } @@ -55,6 +56,31 @@ func TestParseArgs_NoConfirmDiscard(t *testing.T) { }) } +func TestParseArgs_Wrap(t *testing.T) { + t.Run("flag", func(t *testing.T) { + opts, err := parseArgs(append(noConfigArgs(t), "--wrap")) + require.NoError(t, err) + assert.True(t, opts.Wrap) + }) + + t.Run("env", func(t *testing.T) { + t.Setenv("REVDIFF_WRAP", "true") + opts, err := parseArgs(noConfigArgs(t)) + require.NoError(t, err) + assert.True(t, opts.Wrap) + }) + + t.Run("config file", func(t *testing.T) { + cfgDir := t.TempDir() + cfgPath := filepath.Join(cfgDir, "config") + err := os.WriteFile(cfgPath, []byte("[Application Options]\nwrap = true\n"), 0o600) + require.NoError(t, err) + opts, err := parseArgs([]string{"--config", cfgPath}) + require.NoError(t, err) + assert.True(t, opts.Wrap) + }) +} + func TestParseArgs_OutputFlag(t *testing.T) { opts, err := parseArgs([]string{"-o", "/tmp/out.txt"}) require.NoError(t, err) diff --git a/docs/plans/20260402-word-wrap.md b/docs/plans/completed/20260402-word-wrap.md similarity index 67% rename from docs/plans/20260402-word-wrap.md rename to docs/plans/completed/20260402-word-wrap.md index 3fc1736a..b10438be 100644 --- a/docs/plans/20260402-word-wrap.md +++ b/docs/plans/completed/20260402-word-wrap.md @@ -79,14 +79,14 @@ Add a `w` key toggle to enable line wrapping in the diff view. Currently, long l - Modify: `cmd/revdiff/main.go` - Modify: `ui/model_test.go` -- [ ] add `wrapMode bool` field to `Model` struct -- [ ] add `Wrap bool` CLI flag to opts struct in `main.go` with long name `wrap` -- [ ] wire the flag value into `Model` initialization -- [ ] add config file support (ini-name tag) -- [ ] add `↩` icon to `statusModeIcons()` when `m.wrapMode` is true -- [ ] write test verifying `wrapMode` is set from options -- [ ] write test that `statusModeIcons()` includes `↩` when wrap active -- [ ] run `make test` — must pass before task 2 +- [x] add `wrapMode bool` field to `Model` struct +- [x] add `Wrap bool` CLI flag to opts struct in `main.go` with long name `wrap` +- [x] wire the flag value into `Model` initialization +- [x] add config file support (ini-name tag) +- [x] add `↩` icon to `statusModeIcons()` when `m.wrapMode` is true +- [x] write test verifying `wrapMode` is set from options +- [x] write test that `statusModeIcons()` includes `↩` when wrap active +- [x] run `make test` — must pass before task 2 ### Task 2: Implement line wrapping in renderDiffLine @@ -94,13 +94,13 @@ Add a `w` key toggle to enable line wrapping in the diff view. Currently, long l - Modify: `ui/diffview.go` - Modify: `ui/model_test.go` -- [ ] add `wrapContent(content string, width int) []string` method that uses `ansi.Wrap` and splits on `\n` -- [ ] modify `renderDiffLine()`: when `m.wrapMode` is true, wrap content (excluding gutter), then prepend gutter to first line and `↪` to continuation lines -- [ ] apply same line style (add/remove/context background) to continuation lines -- [ ] skip `ansi.Cut` (horizontal scroll) when `m.wrapMode` is true -- [ ] write tests for `wrapContent()`: short lines, long lines, ANSI content, empty content, multi-byte chars -- [ ] write tests for `renderDiffLine()` output with wrap enabled — verify `↪` markers and line count -- [ ] run `make test` — must pass before task 3 +- [x] add `wrapContent(content string, width int) []string` method that uses `ansi.Wrap` and splits on `\n` +- [x] modify `renderDiffLine()`: when `m.wrapMode` is true, wrap content (excluding gutter), then prepend gutter to first line and `↪` to continuation lines +- [x] apply same line style (add/remove/context background) to continuation lines +- [x] skip `ansi.Cut` (horizontal scroll) when `m.wrapMode` is true +- [x] write tests for `wrapContent()`: short lines, long lines, ANSI content, empty content, multi-byte chars +- [x] write tests for `renderDiffLine()` output with wrap enabled — verify `↪` markers and line count +- [x] run `make test` — must pass before task 3 ### Task 3: Fix cursor and viewport coordinate math @@ -109,12 +109,12 @@ Add a `w` key toggle to enable line wrapping in the diff view. Currently, long l - Modify: `ui/annotate.go` - Modify: `ui/model_test.go` -- [ ] add `wrappedLineCount(idx int) int` method that calls `wrapContent()` and returns `len(result)` — stays in sync with rendering -- [ ] update `cursorViewportY()` to use `wrappedLineCount()` instead of counting 1 per line -- [ ] update `renderAnnotationOrInput()` — annotation row follows after all wrapped lines of its diff line -- [ ] write tests for `wrappedLineCount()` with various line lengths and wrap on/off -- [ ] write tests for `cursorViewportY()` with wrapped lines -- [ ] run `make test` — must pass before task 4 +- [x] add `wrappedLineCount(idx int) int` method that calls `wrapContent()` and returns `len(result)` — stays in sync with rendering +- [x] update `cursorViewportY()` to use `wrappedLineCount()` instead of counting 1 per line +- [x] update `renderAnnotationOrInput()` — annotation row follows after all wrapped lines of its diff line +- [x] write tests for `wrappedLineCount()` with various line lengths and wrap on/off +- [x] write tests for `cursorViewportY()` with wrapped lines +- [x] run `make test` — must pass before task 4 ### Task 4: Apply wrapping to collapsed mode @@ -122,11 +122,11 @@ Add a `w` key toggle to enable line wrapping in the diff view. Currently, long l - Modify: `ui/collapsed.go` - Modify: `ui/collapsed_test.go` -- [ ] apply wrapping in `renderCollapsedAddLine()` — reuse `wrapContent()` from Task 2 -- [ ] apply wrapping in `renderDeletePlaceholder()` for consistency -- [ ] verify `cursorViewportY()` changes from Task 3 work correctly in collapsed mode -- [ ] write tests for collapsed mode rendering with wrap enabled -- [ ] run `make test` — must pass before task 5 +- [x] apply wrapping in `renderCollapsedAddLine()` — reuse `wrapContent()` from Task 2 +- [x] apply wrapping in `renderDeletePlaceholder()` for consistency +- [x] verify `cursorViewportY()` changes from Task 3 work correctly in collapsed mode +- [x] write tests for collapsed mode rendering with wrap enabled +- [x] run `make test` — must pass before task 5 ### Task 5: Wire up `w` key toggle and scroll interaction @@ -134,35 +134,35 @@ Add a `w` key toggle to enable line wrapping in the diff view. Currently, long l - Modify: `ui/model.go` - Modify: `ui/model_test.go` -- [ ] handle `w` key in `handleKey()` to toggle `m.wrapMode` -- [ ] reset `scrollX = 0` when enabling wrap -- [ ] block `left`/`right` scroll keys in `handleDiffNav()` when `m.wrapMode` is true -- [ ] re-render diff content after toggle (`m.viewport.SetContent(m.renderDiff())`) -- [ ] add `w` entry to help overlay under "View" section: `w toggle word wrap` -- [ ] write tests for wrap toggle behavior -- [ ] write tests verifying scroll keys are blocked when wrap is on -- [ ] write test verifying help overlay contains word wrap key listing -- [ ] run `make test` — must pass before task 6 +- [x] handle `w` key in `handleKey()` to toggle `m.wrapMode` +- [x] reset `scrollX = 0` when enabling wrap +- [x] block `left`/`right` scroll keys in `handleDiffNav()` when `m.wrapMode` is true +- [x] re-render diff content after toggle (`m.viewport.SetContent(m.renderDiff())`) +- [x] add `w` entry to help overlay under "View" section: `w toggle word wrap` +- [x] write tests for wrap toggle behavior +- [x] write tests verifying scroll keys are blocked when wrap is on +- [x] write test verifying help overlay contains word wrap key listing +- [x] run `make test` — must pass before task 6 ### Task 6: Verify acceptance criteria -- [ ] verify `w` toggles wrap mode on/off -- [ ] verify long lines wrap with `↪` continuation markers -- [ ] verify continuation lines have correct background styles (add/remove/context) -- [ ] verify horizontal scroll is disabled in wrap mode -- [ ] verify cursor navigation works correctly with wrapped lines -- [ ] verify annotations appear after last continuation line -- [ ] verify wrap works in collapsed mode -- [ ] verify `--wrap` CLI flag works -- [ ] verify `↩` icon appears in status line mode icons when active -- [ ] run full test suite: `make test` -- [ ] run linter: `make lint` +- [x] verify `w` toggles wrap mode on/off +- [x] verify long lines wrap with `↪` continuation markers +- [x] verify continuation lines have correct background styles (add/remove/context) +- [x] verify horizontal scroll is disabled in wrap mode +- [x] verify cursor navigation works correctly with wrapped lines +- [x] verify annotations appear after last continuation line +- [x] verify wrap works in collapsed mode +- [x] verify `--wrap` CLI flag works +- [x] verify `↩` icon appears in status line mode icons when active +- [x] run full test suite: `make test` +- [x] run linter: `make lint` ### Task 7: [Final] Update documentation -- [ ] update README.md with `w` wrap toggle and `--wrap` flag -- [ ] update `.claude-plugin/skills/revdiff/references/usage.md` with wrap keybinding -- [ ] update `.claude-plugin/skills/revdiff/references/config.md` with wrap config option -- [ ] update CLAUDE.md if any new patterns discovered -- [ ] move this plan to `docs/plans/completed/` +- [x] update README.md with `w` wrap toggle and `--wrap` flag +- [x] update `.claude-plugin/skills/revdiff/references/usage.md` with wrap keybinding +- [x] update `.claude-plugin/skills/revdiff/references/config.md` with wrap config option +- [x] update CLAUDE.md if any new patterns discovered +- [x] move this plan to `docs/plans/completed/` ## Post-Completion diff --git a/ui/annotate.go b/ui/annotate.go index 834e94a5..f905d294 100644 --- a/ui/annotate.go +++ b/ui/annotate.go @@ -249,11 +249,19 @@ func (m Model) cursorViewportY() int { if m.isCollapsedHidden(i, hunks) { continue } - y++ // the diff line itself - // delete-only placeholders don't render annotations, skip counting them + // delete-only placeholders render synthetic text ("⋯ N lines deleted"), not original content. + // use placeholder text for wrapping to stay in sync with renderDeletePlaceholder. if m.isDeleteOnlyPlaceholder(i, hunks) { + if m.wrapMode { + text := m.deletePlaceholderText(i) + wrapWidth := m.diffContentWidth() - wrapGutterWidth + y += len(m.wrapContent(text, wrapWidth)) + } else { + y++ // placeholder is always 1 row when not wrapping + } continue } + y += m.wrappedLineCount(i) // the diff line (may occupy multiple visual rows when wrapping) dl := m.diffLines[i] if dl.ChangeType != diff.ChangeDivider { key := m.annotationKey(m.diffLineNum(dl), string(dl.ChangeType)) @@ -262,9 +270,10 @@ func (m Model) cursorViewportY() int { } } } - // if cursor is on the annotation sub-line, add one more row + // if cursor is on the annotation sub-line, offset by wrapped line count + // (annotation renders after all continuation lines of the diff line) if m.cursorOnAnnotation { - y++ + y += m.wrappedLineCount(m.diffCursor) } return y } diff --git a/ui/collapsed.go b/ui/collapsed.go index a5ea45bf..9265875d 100644 --- a/ui/collapsed.go +++ b/ui/collapsed.go @@ -78,21 +78,44 @@ func (m Model) renderCollapsedDiff() string { // renderCollapsedAddLine renders an add line in collapsed mode with modify or add styling. func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffLine, modified bool) { - hasHighlight := idx < len(m.highlightedLines) - hlContent := "" - if hasHighlight { - hlContent = strings.ReplaceAll(m.highlightedLines[idx], "\t", m.tabSpaces) - } - lineContent := strings.ReplaceAll(dl.Content, "\t", m.tabSpaces) + lineContent, textContent, hasHighlight := m.prepareLineContent(idx, dl) style, hlStyle, gutter := m.styles.LineAdd, m.styles.LineAddHighlight, " + " if modified { style, hlStyle, gutter = m.styles.LineModify, m.styles.LineModifyHighlight, " ~ " } + isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation + + // wrap mode: break long lines at word boundaries with continuation markers + if m.wrapMode { + wrapWidth := m.diffContentWidth() - wrapGutterWidth + visualLines := m.wrapContent(textContent, wrapWidth) + for i, vl := range visualLines { + prefix := " ↪ " + if i == 0 { + prefix = gutter + } + + var styled string + if hasHighlight { + styled = hlStyle.Render(prefix + vl) + } else { + styled = style.Render(prefix + vl) + } + + cursor := " " + if i == 0 && isCursor { + cursor = m.styles.DiffCursorLine.Render("▶") + } + b.WriteString(cursor + styled + "\n") + } + return + } + content := style.Render(gutter + lineContent) if hasHighlight { - content = hlStyle.Render(gutter + hlContent) + content = hlStyle.Render(gutter + textContent) } // apply horizontal scroll @@ -100,7 +123,6 @@ func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffL content = ansi.Cut(content, m.scrollX, m.scrollX+m.diffContentWidth()) } - isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation cursor := " " if isCursor { cursor = m.styles.DiffCursorLine.Render("▶") @@ -108,9 +130,9 @@ func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffL b.WriteString(cursor + content + "\n") } -// renderDeletePlaceholder renders a placeholder line for a delete-only hunk in collapsed mode. -// shows "⋯ N lines deleted" with remove styling so users know deletions exist and can expand with '.'. -func (m Model) renderDeletePlaceholder(b *strings.Builder, idx, hunkStart int) { +// deletePlaceholderText returns the text shown for a delete-only hunk placeholder starting at hunkStart. +// used by both renderDeletePlaceholder and cursorViewportY to stay in sync. +func (m Model) deletePlaceholderText(hunkStart int) string { count := 0 for i := hunkStart; i < len(m.diffLines); i++ { ct := m.diffLines[i].ChangeType @@ -121,11 +143,39 @@ func (m Model) renderDeletePlaceholder(b *strings.Builder, idx, hunkStart int) { count++ } } - - text := fmt.Sprintf("⋯ %d lines deleted", count) if count == 1 { - text = "⋯ 1 line deleted" + return "⋯ 1 line deleted" } + return fmt.Sprintf("⋯ %d lines deleted", count) +} + +// renderDeletePlaceholder renders a placeholder line for a delete-only hunk in collapsed mode. +// shows "⋯ N lines deleted" with remove styling so users know deletions exist and can expand with '.'. +func (m Model) renderDeletePlaceholder(b *strings.Builder, idx, hunkStart int) { + text := m.deletePlaceholderText(hunkStart) + + isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation + + // wrap mode: break long placeholder at word boundaries + if m.wrapMode { + wrapWidth := m.diffContentWidth() - wrapGutterWidth + visualLines := m.wrapContent(text, wrapWidth) + for i, vl := range visualLines { + prefix := " ↪ " + if i == 0 { + prefix = " - " + } + styled := m.styles.LineRemove.Render(prefix + vl) + + cursor := " " + if i == 0 && isCursor { + cursor = m.styles.DiffCursorLine.Render("▶") + } + b.WriteString(cursor + styled + "\n") + } + return + } + content := m.styles.LineRemove.Render(" - " + text) // apply horizontal scroll @@ -133,7 +183,6 @@ func (m Model) renderDeletePlaceholder(b *strings.Builder, idx, hunkStart int) { content = ansi.Cut(content, m.scrollX, m.scrollX+m.diffContentWidth()) } - isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation cursor := " " if isCursor { cursor = m.styles.DiffCursorLine.Render("▶") diff --git a/ui/collapsed_test.go b/ui/collapsed_test.go index 1f6220c9..647c55ea 100644 --- a/ui/collapsed_test.go +++ b/ui/collapsed_test.go @@ -1,6 +1,7 @@ package ui import ( + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -1508,3 +1509,189 @@ func TestModel_CollapsedDeleteAnnotationBlockedOnPlaceholder(t *testing.T) { m.deleteAnnotation() assert.True(t, m.store.Has("a.go", 2, "-"), "annotation should not be deleted from placeholder") } + +func TestModel_CollapsedWrapAddLine(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.wrapMode = true + m.width = 50 + m.treeWidth = 0 + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "this is a very long modified line that should be wrapped at word boundaries for readability", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, " ~ ", "modified line should have ~ gutter") + assert.Contains(t, rendered, " ↪ ", "wrapped continuation should have ↪ marker") + assert.NotContains(t, rendered, "old", "removed lines should be hidden") +} + +func TestModel_CollapsedWrapPureAddLine(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.wrapMode = true + m.width = 50 + m.treeWidth = 0 + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "this is a very long pure add line that should be wrapped at word boundaries for readability", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, " + ", "pure add line should have + gutter") + assert.Contains(t, rendered, " ↪ ", "wrapped continuation should have ↪ marker") +} + +func TestModel_CollapsedWrapDeletePlaceholder(t *testing.T) { + t.Run("wrapping", func(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.wrapMode = true + m.width = 15 // narrow width to force placeholder wrapping (wrapWidth=7, text ~17 chars) + m.treeWidth = 0 + m.diffLines = []diff.DiffLine{ + {OldNum: 1, Content: "del1", ChangeType: diff.ChangeRemove}, + {OldNum: 2, Content: "del2", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "del3", ChangeType: diff.ChangeRemove}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, " - ", "placeholder should have - gutter on first line") + assert.Contains(t, rendered, " ↪ ", "long placeholder should have continuation markers") + assert.Contains(t, rendered, "deleted", "placeholder should contain deletion text") + }) + + t.Run("no wrapping needed", func(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.wrapMode = true + m.width = 80 + m.treeWidth = 0 + m.diffLines = []diff.DiffLine{ + {OldNum: 1, Content: "del1", ChangeType: diff.ChangeRemove}, + {OldNum: 2, Content: "del2", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "del3", ChangeType: diff.ChangeRemove}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, "3 lines deleted", "placeholder should show line count") + assert.NotContains(t, rendered, "↪", "short placeholder should not wrap") + }) + + t.Run("singular line deleted", func(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.wrapMode = true + m.width = 80 + m.treeWidth = 0 + m.diffLines = []diff.DiffLine{ + {OldNum: 1, Content: "del1", ChangeType: diff.ChangeRemove}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, "1 line deleted", "singular placeholder text") + assert.NotContains(t, rendered, "lines deleted", "should not use plural form") + }) +} + +func TestModel_CollapsedWrapShortLinesUnchanged(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.wrapMode = true + m.width = 120 + m.treeWidth = 0 + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "short", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "add", ChangeType: diff.ChangeAdd}, + } + + rendered := m.renderDiff() + // short lines should not have continuation markers + assert.NotContains(t, rendered, "↪", "short lines should not have continuation markers") + assert.Contains(t, rendered, " + add", "short add should render normally") +} + +func TestModel_CollapsedWrapNoScrollX(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.wrapMode = true + m.scrollX = 10 // should be ignored in wrap mode + m.width = 50 + m.treeWidth = 0 + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "this is a long added line that needs wrapping at boundary", ChangeType: diff.ChangeAdd}, + } + + rendered := m.renderDiff() + // content should not be cut by scrollX when wrapping is on + assert.Contains(t, rendered, "this is a long", "full content should be visible, scrollX should be ignored") +} + +func TestModel_CollapsedWrapCursorOnFirstLine(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.wrapMode = true + m.width = 50 + m.treeWidth = 0 + m.focus = paneDiff + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "this is a very long line that will wrap into multiple visual rows when rendered", ChangeType: diff.ChangeAdd}, + } + m.diffCursor = 0 + + rendered := m.renderDiff() + lines := strings.Split(rendered, "\n") + + // cursor marker should only be on first visual line + cursorCount := 0 + for _, line := range lines { + if strings.Contains(line, "▶") { + cursorCount++ + } + } + assert.Equal(t, 1, cursorCount, "cursor should appear only on first visual line of wrapped content") +} + +func TestModel_CollapsedWrapExpandedHunkUsesStandardWrap(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.wrapMode = true + m.width = 50 + m.treeWidth = 0 + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "this is a very long removed line that should be wrapped when expanded", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "this is a very long added line that should also be wrapped when expanded", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + // expand the hunk + m.collapsed.expandedHunks = map[int]bool{1: true} + + rendered := m.renderDiff() + // expanded hunk uses renderDiffLine which handles wrapping via renderWrappedDiffLine + assert.Contains(t, rendered, " - ", "expanded remove should use standard - gutter") + assert.Contains(t, rendered, " + ", "expanded add should use standard + gutter") + assert.Contains(t, rendered, " ↪ ", "expanded long lines should have continuation markers") +} diff --git a/ui/diffview.go b/ui/diffview.go index 1aa290ee..1ccbd480 100644 --- a/ui/diffview.go +++ b/ui/diffview.go @@ -65,45 +65,30 @@ func (m Model) renderFileAnnotationHeader(b *strings.Builder, fileComment string } // renderDiffLine writes a single styled diff line (with cursor highlight) to the builder. +// when wrap mode is active, long lines are broken at word boundaries with ↪ continuation markers. func (m Model) renderDiffLine(b *strings.Builder, idx int, dl diff.DiffLine) { - // check for pre-computed syntax-highlighted content - hasHighlight := idx < len(m.highlightedLines) - hlContent := "" - if hasHighlight { - hlContent = strings.ReplaceAll(m.highlightedLines[idx], "\t", m.tabSpaces) + lineContent, textContent, hasHighlight := m.prepareLineContent(idx, dl) + + isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation + + // wrap mode: break long lines at word boundaries (dividers are short, skip them) + if m.wrapMode && dl.ChangeType != diff.ChangeDivider { + m.renderWrappedDiffLine(b, dl, textContent, hasHighlight, isCursor) + return } - lineContent := strings.ReplaceAll(dl.Content, "\t", m.tabSpaces) var content string - switch dl.ChangeType { - case diff.ChangeAdd: - if hasHighlight { - content = m.styles.LineAddHighlight.Render(" + " + hlContent) - } else { - content = m.styles.LineAdd.Render(" + " + lineContent) - } - case diff.ChangeRemove: - if hasHighlight { - content = m.styles.LineRemoveHighlight.Render(" - " + hlContent) - } else { - content = m.styles.LineRemove.Render(" - " + lineContent) - } - case diff.ChangeDivider: + if dl.ChangeType == diff.ChangeDivider { content = m.styles.LineNumber.Render(" " + lineContent) - default: - if hasHighlight { - content = " " + hlContent - } else { - content = m.styles.LineContext.Render(" " + lineContent) - } + } else { + content = m.styleDiffContent(dl.ChangeType, m.linePrefix(dl.ChangeType), textContent, hasHighlight) } - // apply horizontal scroll to content (bar stays fixed) - if m.scrollX > 0 { + // apply horizontal scroll to content (bar stays fixed), disabled in wrap mode + if m.scrollX > 0 && !m.wrapMode { content = ansi.Cut(content, m.scrollX, m.scrollX+m.diffContentWidth()) } - isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation cursor := " " if isCursor { cursor = m.styles.DiffCursorLine.Render("▶") @@ -111,6 +96,99 @@ func (m Model) renderDiffLine(b *strings.Builder, idx int, dl diff.DiffLine) { b.WriteString(cursor + content + "\n") } +// renderWrappedDiffLine renders a diff line with word wrapping, producing continuation lines with ↪ markers. +func (m Model) renderWrappedDiffLine(b *strings.Builder, dl diff.DiffLine, textContent string, hasHighlight, isCursor bool) { + wrapWidth := m.diffContentWidth() - wrapGutterWidth + + visualLines := m.wrapContent(textContent, wrapWidth) + for i, vl := range visualLines { + prefix := " ↪ " + if i == 0 { + prefix = m.linePrefix(dl.ChangeType) + } + + styled := m.styleDiffContent(dl.ChangeType, prefix, vl, hasHighlight) + + cursor := " " + if i == 0 && isCursor { + cursor = m.styles.DiffCursorLine.Render("▶") + } + b.WriteString(cursor + styled + "\n") + } +} + +// wrappedLineCount returns the number of visual rows a diff line occupies. +// returns 1 when wrap mode is off or for divider lines. +// stays in sync with renderWrappedDiffLine by using the same wrapContent method and width calculation. +func (m Model) wrappedLineCount(idx int) int { + if !m.wrapMode || idx < 0 || idx >= len(m.diffLines) { + return 1 + } + dl := m.diffLines[idx] + if dl.ChangeType == diff.ChangeDivider { + return 1 + } + + _, textContent, _ := m.prepareLineContent(idx, dl) + wrapWidth := m.diffContentWidth() - wrapGutterWidth + return len(m.wrapContent(textContent, wrapWidth)) +} + +// wrapContent wraps text content at the given width using word boundaries. +// returns a slice of visual lines (at least one). handles ANSI escape sequences. +func (m Model) wrapContent(content string, width int) []string { + if width <= 0 { + return []string{content} + } + wrapped := ansi.Wrap(content, width, "") + return strings.Split(wrapped, "\n") +} + +// prepareLineContent returns the display-ready content for a diff line with tabs replaced. +// returns the raw line content, the best available content (highlighted if available), and whether highlight was used. +func (m Model) prepareLineContent(idx int, dl diff.DiffLine) (lineContent, textContent string, hasHighlight bool) { + lineContent = strings.ReplaceAll(dl.Content, "\t", m.tabSpaces) + hasHighlight = idx < len(m.highlightedLines) + textContent = lineContent + if hasHighlight { + textContent = strings.ReplaceAll(m.highlightedLines[idx], "\t", m.tabSpaces) + } + return lineContent, textContent, hasHighlight +} + +// linePrefix returns the 3-character gutter prefix for a given change type. +func (m Model) linePrefix(changeType diff.ChangeType) string { + switch changeType { + case diff.ChangeAdd: + return " + " + case diff.ChangeRemove: + return " - " + default: + return " " + } +} + +// styleDiffContent applies the appropriate line style based on change type. +func (m Model) styleDiffContent(changeType diff.ChangeType, prefix, content string, hasHighlight bool) string { + switch changeType { + case diff.ChangeAdd: + if hasHighlight { + return m.styles.LineAddHighlight.Render(prefix + content) + } + return m.styles.LineAdd.Render(prefix + content) + case diff.ChangeRemove: + if hasHighlight { + return m.styles.LineRemoveHighlight.Render(prefix + content) + } + return m.styles.LineRemove.Render(prefix + content) + default: + if hasHighlight { + return prefix + content + } + return m.styles.LineContext.Render(prefix + content) + } +} + // renderAnnotationOrInput writes the annotation input or existing annotation below a diff line. func (m Model) renderAnnotationOrInput(b *strings.Builder, idx int, annotationMap map[string]string) { if m.annotating && !m.fileAnnotating && idx == m.diffCursor { @@ -381,17 +459,21 @@ func (m *Model) centerViewportOnCursor() { m.viewport.SetContent(m.renderDiff()) } -const scrollStep = 4 // horizontal scroll step in characters - -// scrollRight moves the horizontal scroll offset to the right. -func (m *Model) scrollRight() { - m.scrollX += scrollStep - m.viewport.SetContent(m.renderDiff()) -} +const wrapGutterWidth = 3 // wrap gutter prefix width: " + ", " - ", " ", " ↪ " +const scrollStep = 4 // horizontal scroll step in characters -// scrollLeft moves the horizontal scroll offset to the left. -func (m *Model) scrollLeft() { - m.scrollX = max(0, m.scrollX-scrollStep) +// handleHorizontalScroll processes left/right scroll keys. +// direction < 0 scrolls left, direction > 0 scrolls right. +// no-op when wrap mode is active (content is already fully visible). +func (m *Model) handleHorizontalScroll(direction int) { + if m.wrapMode { + return + } + if direction < 0 { + m.scrollX = max(0, m.scrollX-scrollStep) + } else { + m.scrollX += scrollStep + } m.viewport.SetContent(m.renderDiff()) } diff --git a/ui/model.go b/ui/model.go index d2681889..0d5acde4 100644 --- a/ui/model.go +++ b/ui/model.go @@ -76,6 +76,7 @@ type Model struct { fileRemoves int // cached count of removed lines in current file showHelp bool // true when help overlay is visible + wrapMode bool // true when line wrapping is enabled discarded bool // true when user chose to discard annotations and quit inConfirmDiscard bool // true when showing discard confirmation prompt @@ -105,6 +106,7 @@ type ModelConfig struct { NoColors bool // disable all colors including syntax highlighting NoStatusBar bool // hide the status bar NoConfirmDiscard bool // skip confirmation prompt when discarding annotations + Wrap bool // enable line wrapping Colors Colors } @@ -129,6 +131,7 @@ func NewModel(renderer Renderer, store *annotation.Store, highlighter SyntaxHigh staged: cfg.Staged, noStatusBar: cfg.NoStatusBar, noConfirmDiscard: cfg.NoConfirmDiscard, + wrapMode: cfg.Wrap, focus: paneTree, treeWidthRatio: cfg.TreeWidthRatio, tabSpaces: strings.Repeat(" ", cfg.TabWidth), @@ -247,6 +250,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case msg.String() == "v": m.toggleCollapsedMode() return m, nil + + case msg.String() == "w": + m.toggleWrapMode() + return m, nil } // pane-specific navigation @@ -271,6 +278,19 @@ func (m *Model) togglePane() { } } +// toggleWrapMode toggles line wrapping on/off. +// resets horizontal scroll when enabling wrap and re-renders the diff. +func (m *Model) toggleWrapMode() { + if m.focus != paneDiff || m.currFile == "" { + return + } + m.wrapMode = !m.wrapMode + if m.wrapMode { + m.scrollX = 0 + } + m.syncViewportToCursor() +} + // loadSelectedIfChanged ensures the tree is visible and loads the selected file if it changed. func (m Model) loadSelectedIfChanged() (tea.Model, tea.Cmd) { m.tree.ensureVisible(m.treePageSize()) @@ -324,10 +344,10 @@ func (m Model) handleDiffNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focus = paneTree return m, nil case msg.String() == "left": - m.scrollLeft() + m.handleHorizontalScroll(-1) return m, nil case msg.String() == "right": - m.scrollRight() + m.handleHorizontalScroll(1) return m, nil case msg.String() == "j" || msg.String() == "down": m.moveDiffCursorDown() @@ -382,7 +402,7 @@ func (m Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.tree.ensureVisible(m.treePageSize()) if m.currFile != "" { - m.viewport.SetContent(m.renderDiff()) + m.syncViewportToCursor() } return m, nil @@ -620,7 +640,7 @@ func (m Model) hunkSegment() string { return fmt.Sprintf("%d hunks", total) } -// statusModeIcons returns combined mode indicator icons (▼ for collapsed, ◉ for filter). +// statusModeIcons returns combined mode indicator icons (▼ for collapsed, ◉ for filter, ↩ for wrap). func (m Model) statusModeIcons() string { var icons []string if m.collapsed.enabled { @@ -629,10 +649,13 @@ func (m Model) statusModeIcons() string { if m.tree.filter { icons = append(icons, "◉") } + if m.wrapMode { + icons = append(icons, "↩") + } return strings.Join(icons, " ") } -// statusSegmentsNoIcons returns left segments without mode indicators (▼ ◉). +// statusSegmentsNoIcons returns left segments without mode indicators (▼ ◉ ↩). func (m Model) statusSegmentsNoIcons() []string { var segments []string if m.currFile != "" { @@ -675,6 +698,7 @@ func (m Model) helpOverlay() string { "\n" + "View\n" + " v toggle collapsed mode\n" + + " w toggle word wrap\n" + " . expand/collapse hunk\n" + " f filter annotated files\n" + "\n" + diff --git a/ui/model_test.go b/ui/model_test.go index 453b384e..8ea6ff6f 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -313,6 +313,81 @@ func TestModel_StatusBarFilterIndicator(t *testing.T) { }) } +func TestModel_WrapModeFromConfig(t *testing.T) { + renderer := &mocks.RendererMock{ + ChangedFilesFunc: func(string, bool) ([]string, error) { return nil, nil }, + FileDiffFunc: func(string, string, bool) ([]diff.DiffLine, error) { return nil, nil }, + } + store := annotation.NewStore() + + t.Run("wrap enabled via config", func(t *testing.T) { + m := NewModel(renderer, store, noopHighlighter(), ModelConfig{Wrap: true, TreeWidthRatio: 2}) + assert.True(t, m.wrapMode) + }) + + t.Run("wrap disabled by default", func(t *testing.T) { + m := NewModel(renderer, store, noopHighlighter(), ModelConfig{TreeWidthRatio: 2}) + assert.False(t, m.wrapMode) + }) +} + +func TestModel_StatusModeIcons(t *testing.T) { + tests := []struct { + name string + collapsed bool + filter bool + wrap bool + want string + }{ + {name: "no modes active", want: ""}, + {name: "collapsed only", collapsed: true, want: "▼"}, + {name: "filter only", filter: true, want: "◉"}, + {name: "wrap only", wrap: true, want: "↩"}, + {name: "collapsed and filter", collapsed: true, filter: true, want: "▼ ◉"}, + {name: "collapsed and wrap", collapsed: true, wrap: true, want: "▼ ↩"}, + {name: "all modes active", collapsed: true, filter: true, wrap: true, want: "▼ ◉ ↩"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := testModel(nil, nil) + m.collapsed.enabled = tt.collapsed + m.tree.filter = tt.filter + m.wrapMode = tt.wrap + assert.Equal(t, tt.want, m.statusModeIcons()) + }) + } +} + +func TestModel_StatusBarWrapIndicator(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}} + + t.Run("wrap icon shown when active", func(t *testing.T) { + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.ready = true + m.currFile = "a.go" + m.diffLines = lines + m.wrapMode = true + m.width = 200 + + status := m.statusBarText() + assert.Contains(t, status, "↩", "should show wrap icon when wrap active") + }) + + t.Run("wrap icon hidden when inactive", func(t *testing.T) { + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.ready = true + m.currFile = "a.go" + m.diffLines = lines + m.width = 200 + + status := m.statusBarText() + assert.NotContains(t, status, "↩", "should not show wrap icon when wrap inactive") + }) +} + func TestModel_NextPrevFile(t *testing.T) { files := []string{"a.go", "b.go", "c.go"} m := testModel(files, map[string][]diff.DiffLine{ @@ -3117,7 +3192,7 @@ func TestModel_HelpOverlayKeyListings(t *testing.T) { // verify key listings are present keys := []string{ "tab", "n / p", "j / k", "PgDn/PgUp", "Ctrl+d/u", "Home/End", "h / l", "← / →", "[ / ]", - "a / enter", "A", "d", "f", "v", ".", + "a / enter", "A", "d", "f", "v", "w", ".", "q", "Q", "? / esc", } for _, k := range keys { @@ -3207,3 +3282,459 @@ func TestModel_HelpBlocksOtherKeys(t *testing.T) { assert.True(t, model.showHelp, "enter should not close help") assert.Nil(t, cmd, "enter should produce no command") } + +func TestModel_WrapContent(t *testing.T) { + m := testModel(nil, nil) + + t.Run("short line unchanged", func(t *testing.T) { + lines := m.wrapContent("hello world", 40) + assert.Equal(t, []string{"hello world"}, lines) + }) + + t.Run("long line wraps at word boundary", func(t *testing.T) { + lines := m.wrapContent("the quick brown fox jumps over the lazy dog", 20) + assert.Greater(t, len(lines), 1, "should produce multiple lines") + for _, line := range lines { + assert.LessOrEqual(t, len(line), 20, "each line should fit within width") + } + }) + + t.Run("empty content", func(t *testing.T) { + lines := m.wrapContent("", 40) + assert.Equal(t, []string{""}, lines) + }) + + t.Run("zero width returns content as-is", func(t *testing.T) { + lines := m.wrapContent("hello", 0) + assert.Equal(t, []string{"hello"}, lines) + }) + + t.Run("negative width returns content as-is", func(t *testing.T) { + lines := m.wrapContent("hello", -5) + assert.Equal(t, []string{"hello"}, lines) + }) + + t.Run("single long word", func(t *testing.T) { + lines := m.wrapContent("abcdefghijklmnopqrstuvwxyz", 10) + require.NotEmpty(t, lines) + // ansi.Wrap hard-wraps words that exceed the limit + for _, line := range lines { + assert.LessOrEqual(t, len(line), 10+1, "long words should be hard-wrapped") // +1 for potential breakpoint + } + }) + + t.Run("content with ANSI codes", func(t *testing.T) { + ansiContent := "\033[32mgreen text\033[0m and normal" + lines := m.wrapContent(ansiContent, 15) + require.NotEmpty(t, lines) + // the wrapped output should still contain ANSI codes + joined := strings.Join(lines, "") + assert.Contains(t, joined, "\033[32m", "ANSI codes should be preserved") + }) + + t.Run("multi-byte characters", func(t *testing.T) { + lines := m.wrapContent("日本語テスト hello world", 10) + require.NotEmpty(t, lines) + assert.Greater(t, len(lines), 1, "CJK text should wrap") + }) +} + +func TestModel_RenderDiffLineWithWrap(t *testing.T) { + m := testModel(nil, nil) + m.wrapMode = true + m.width = 60 + m.treeWidth = 12 + m.styles = plainStyles() + + t.Run("short line no continuation", func(t *testing.T) { + var b strings.Builder + dl := diff.DiffLine{Content: "short", ChangeType: diff.ChangeAdd, NewNum: 1} + m.renderDiffLine(&b, 0, dl) + output := b.String() + assert.Contains(t, output, " + short") + assert.NotContains(t, output, "↪", "short line should not have continuation") + assert.Equal(t, 1, strings.Count(output, "\n"), "should produce exactly one line") + }) + + t.Run("long add line wraps with continuation markers", func(t *testing.T) { + var b strings.Builder + longContent := "this is a very long line that should definitely be wrapped at word boundaries to fit the viewport" + dl := diff.DiffLine{Content: longContent, ChangeType: diff.ChangeAdd, NewNum: 1} + m.renderDiffLine(&b, 0, dl) + output := b.String() + + lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n") + require.Greater(t, len(lines), 1, "long line should wrap into multiple lines") + + // first line should have " + " prefix + assert.Contains(t, lines[0], " + ", "first line should have add prefix") + + // continuation lines should have " ↪ " prefix + for _, line := range lines[1:] { + assert.Contains(t, line, " ↪ ", "continuation lines should have ↪ marker") + } + }) + + t.Run("long remove line wraps with continuation markers", func(t *testing.T) { + var b strings.Builder + longContent := "this is a removed line that is very long and should be wrapped at word boundaries to fit the viewport width" + dl := diff.DiffLine{Content: longContent, ChangeType: diff.ChangeRemove, OldNum: 5} + m.renderDiffLine(&b, 0, dl) + output := b.String() + + lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n") + require.Greater(t, len(lines), 1, "long line should wrap") + assert.Contains(t, lines[0], " - ", "first line should have remove prefix") + for _, line := range lines[1:] { + assert.Contains(t, line, " ↪ ", "continuation lines should have ↪ marker") + } + }) + + t.Run("long context line wraps with continuation markers", func(t *testing.T) { + var b strings.Builder + longContent := "this is a context line that is very long and should be wrapped at word boundaries for readability" + dl := diff.DiffLine{Content: longContent, ChangeType: diff.ChangeContext, NewNum: 10} + m.renderDiffLine(&b, 0, dl) + output := b.String() + + lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n") + require.Greater(t, len(lines), 1, "long context line should wrap") + for _, line := range lines[1:] { + assert.Contains(t, line, " ↪ ", "continuation lines should have ↪ marker") + } + }) + + t.Run("divider lines are not wrapped", func(t *testing.T) { + var b strings.Builder + dl := diff.DiffLine{Content: "@@ -1,5 +1,7 @@", ChangeType: diff.ChangeDivider} + m.renderDiffLine(&b, 0, dl) + output := b.String() + assert.NotContains(t, output, "↪", "dividers should not be wrapped") + assert.Equal(t, 1, strings.Count(output, "\n"), "divider should be a single line") + }) + + t.Run("cursor only on first visual line", func(t *testing.T) { + m.diffCursor = 0 + m.focus = paneDiff + m.cursorOnAnnotation = false + + var b strings.Builder + longContent := "this is a very long line that should definitely be wrapped at word boundaries to test cursor placement" + dl := diff.DiffLine{Content: longContent, ChangeType: diff.ChangeAdd, NewNum: 1} + m.renderDiffLine(&b, 0, dl) + output := b.String() + + lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n") + require.Greater(t, len(lines), 1, "should have continuation lines") + assert.Contains(t, lines[0], "▶", "first line should have cursor") + for _, line := range lines[1:] { + assert.NotContains(t, line, "▶", "continuation lines should not have cursor") + } + }) + + t.Run("no horizontal scroll in wrap mode", func(t *testing.T) { + m.scrollX = 10 + m.wrapMode = true + + var b strings.Builder + dl := diff.DiffLine{Content: "@@ -1,3 +1,3 @@", ChangeType: diff.ChangeDivider} + m.renderDiffLine(&b, 0, dl) + + // divider falls through to non-wrap path but ansi.Cut should be skipped + output := b.String() + assert.Contains(t, output, "@@", "divider content should not be scrolled in wrap mode") + + m.scrollX = 0 // reset + }) +} + +func TestModel_StyleDiffContent(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + + t.Run("add line", func(t *testing.T) { + result := m.styleDiffContent(diff.ChangeAdd, " + ", "content", false) + assert.Contains(t, result, " + content") + }) + + t.Run("remove line", func(t *testing.T) { + result := m.styleDiffContent(diff.ChangeRemove, " - ", "content", false) + assert.Contains(t, result, " - content") + }) + + t.Run("context line", func(t *testing.T) { + result := m.styleDiffContent(diff.ChangeContext, " ", "content", false) + assert.Contains(t, result, " content") + }) + + t.Run("highlighted add", func(t *testing.T) { + result := m.styleDiffContent(diff.ChangeAdd, " + ", "\033[32mgreen\033[0m", true) + assert.Contains(t, result, " + ") + assert.Contains(t, result, "\033[32m") + }) +} + +func TestModel_WrappedLineCount(t *testing.T) { + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "short", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: strings.Repeat("x", 200), ChangeType: diff.ChangeAdd}, + {Content: "@@ -1,3 +1,3 @@", ChangeType: diff.ChangeDivider}, + {OldNum: 3, Content: strings.Repeat("y", 200), ChangeType: diff.ChangeRemove}, + } + + t.Run("wrap off returns 1 for all lines", func(t *testing.T) { + m.wrapMode = false + assert.Equal(t, 1, m.wrappedLineCount(0)) + assert.Equal(t, 1, m.wrappedLineCount(1)) + assert.Equal(t, 1, m.wrappedLineCount(2)) + assert.Equal(t, 1, m.wrappedLineCount(3)) + }) + + t.Run("wrap on, short line returns 1", func(t *testing.T) { + m.wrapMode = true + assert.Equal(t, 1, m.wrappedLineCount(0)) + }) + + t.Run("wrap on, long line returns more than 1", func(t *testing.T) { + m.wrapMode = true + count := m.wrappedLineCount(1) + assert.Greater(t, count, 1, "long add line should wrap to multiple visual rows") + }) + + t.Run("wrap on, divider always returns 1", func(t *testing.T) { + m.wrapMode = true + assert.Equal(t, 1, m.wrappedLineCount(2)) + }) + + t.Run("wrap on, long remove line wraps", func(t *testing.T) { + m.wrapMode = true + count := m.wrappedLineCount(3) + assert.Greater(t, count, 1, "long remove line should wrap to multiple visual rows") + }) + + t.Run("out of bounds returns 1", func(t *testing.T) { + m.wrapMode = true + assert.Equal(t, 1, m.wrappedLineCount(-1)) + assert.Equal(t, 1, m.wrappedLineCount(100)) + }) +} + +func TestModel_CursorViewportYWithWrap(t *testing.T) { + m := testModel(nil, nil) + m.currFile = "a.go" + m.wrapMode = true + // use a narrow width so wrapping is predictable + m.width = 60 + m.treeWidth = 20 + + // diffContentWidth = 60 - 20 - 4 - 1 = 35 + // wrapWidth = 35 - 3 (gutter) = 32 + + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "short line", ChangeType: diff.ChangeContext}, // idx 0, fits in 1 row + {NewNum: 2, Content: strings.Repeat("a", 60), ChangeType: diff.ChangeAdd}, // idx 1, wraps to ~2 rows + {NewNum: 3, Content: "another short line", ChangeType: diff.ChangeContext}, // idx 2, fits in 1 row + {NewNum: 4, Content: "this is a really long line that " + strings.Repeat("z", 60), ChangeType: diff.ChangeAdd}, // idx 3, wraps to ~3 rows + } + + // verify wrapping counts are consistent + count0 := m.wrappedLineCount(0) + count1 := m.wrappedLineCount(1) + count2 := m.wrappedLineCount(2) + assert.Equal(t, 1, count0, "short context line should be 1 row") + assert.Greater(t, count1, 1, "long add line should wrap") + assert.Equal(t, 1, count2, "short context line should be 1 row") + + t.Run("cursor at 0, no wrapping before it", func(t *testing.T) { + m.diffCursor = 0 + m.cursorOnAnnotation = false + assert.Equal(t, 0, m.cursorViewportY()) + }) + + t.Run("cursor at 1, after short line 0", func(t *testing.T) { + m.diffCursor = 1 + m.cursorOnAnnotation = false + assert.Equal(t, count0, m.cursorViewportY()) + }) + + t.Run("cursor at 2, after wrapped line 1", func(t *testing.T) { + m.diffCursor = 2 + m.cursorOnAnnotation = false + assert.Equal(t, count0+count1, m.cursorViewportY()) + }) + + t.Run("cursor at 3, after lines 0+1+2", func(t *testing.T) { + m.diffCursor = 3 + m.cursorOnAnnotation = false + assert.Equal(t, count0+count1+count2, m.cursorViewportY()) + }) + + t.Run("cursor on annotation after wrapped line", func(t *testing.T) { + // add annotation on line 2 (idx 1, the long wrapped add line) + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "+", Comment: "note"}) + defer func() { m.store.Delete("a.go", 2, "+") }() + + m.diffCursor = 2 + m.cursorOnAnnotation = false + // cursor at line 2: count0 + count1 + 1 (annotation row after line 1) + assert.Equal(t, count0+count1+1, m.cursorViewportY()) + }) + + t.Run("cursor on annotation sub-line of wrapped line", func(t *testing.T) { + m.store.Add(annotation.Annotation{File: "a.go", Line: 3, Type: " ", Comment: "note on ctx"}) + defer func() { m.store.Delete("a.go", 3, " ") }() + + m.diffCursor = 2 + m.cursorOnAnnotation = true + // on annotation sub-line of line 2: offset is line0 + line1 rows + wrappedLineCount(2) + assert.Equal(t, count0+count1+count2, m.cursorViewportY()) + }) +} + +func TestModel_CursorViewportYWithWrapDeletePlaceholder(t *testing.T) { + m := testModel(nil, nil) + m.currFile = "a.go" + m.wrapMode = true + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.width = 60 + m.treeWidth = 20 + + // diffContentWidth = 60 - 20 - 4 - 1 = 35, wrapWidth = 35 - 3 = 32 + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "context line", ChangeType: diff.ChangeContext}, + {OldNum: 1, Content: strings.Repeat("x", 80), ChangeType: diff.ChangeRemove}, // long remove, hunk start + {OldNum: 2, Content: strings.Repeat("y", 80), ChangeType: diff.ChangeRemove}, // long remove + {OldNum: 3, Content: strings.Repeat("z", 80), ChangeType: diff.ChangeRemove}, // long remove + {NewNum: 2, Content: "after context", ChangeType: diff.ChangeContext}, + } + + // placeholder text "⋯ 3 lines deleted" is short (~17 chars), fits in 1 row at wrapWidth=32. + // the original removed lines are 80 chars each and would wrap to ~3 rows. + // cursorViewportY must use placeholder text (1 row), not original content (~3 rows). + + m.diffCursor = 4 // cursor on "after context" line + m.cursorOnAnnotation = false + m.focus = paneDiff + + y := m.cursorViewportY() + // expected: 1 (context) + 1 (placeholder = 1 visual row) = 2 + assert.Equal(t, 2, y, "viewport Y should count placeholder as 1 row, not original line content") +} + +func TestModel_WrapToggle(t *testing.T) { + lines := []diff.DiffLine{{ChangeType: diff.ChangeContext, Content: "x", NewNum: 1}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.currFile = "a.go" + m.diffLines = lines + m.highlightedLines = []string{"x"} + m.focus = paneDiff + m.viewport.Width = 80 + m.viewport.Height = 20 + assert.False(t, m.wrapMode) + + // press w to enable wrap + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + model := result.(Model) + assert.True(t, model.wrapMode) + + // press w again to disable wrap + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + model = result.(Model) + assert.False(t, model.wrapMode) +} + +func TestModel_WrapToggleResetsScrollX(t *testing.T) { + lines := []diff.DiffLine{{ChangeType: diff.ChangeContext, Content: "x", NewNum: 1}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.currFile = "a.go" + m.diffLines = lines + m.highlightedLines = []string{"x"} + m.focus = paneDiff + m.viewport.Width = 80 + m.viewport.Height = 20 + m.scrollX = 10 + + // enable wrap: scrollX should reset to 0 + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + model := result.(Model) + assert.True(t, model.wrapMode) + assert.Equal(t, 0, model.scrollX) +} + +func TestModel_WrapToggleNoOpWithoutFile(t *testing.T) { + m := testModel([]string{"a.go"}, nil) + m.focus = paneDiff + m.currFile = "" + assert.False(t, m.wrapMode) + + // w should be no-op without a loaded file + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + model := result.(Model) + assert.False(t, model.wrapMode) +} + +func TestModel_WrapToggleNoOpInTreePane(t *testing.T) { + lines := []diff.DiffLine{{ChangeType: diff.ChangeContext, Content: "x", NewNum: 1}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.currFile = "a.go" + m.diffLines = lines + m.focus = paneTree + assert.False(t, m.wrapMode) + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + model := result.(Model) + assert.False(t, model.wrapMode) +} + +func TestModel_ScrollBlockedInWrapMode(t *testing.T) { + lines := []diff.DiffLine{{ChangeType: diff.ChangeContext, Content: "x", NewNum: 1}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.currFile = "a.go" + m.diffLines = lines + m.highlightedLines = []string{"x"} + m.focus = paneDiff + m.viewport.Width = 80 + m.viewport.Height = 20 + m.wrapMode = true + m.scrollX = 0 + + // right key should not change scrollX in wrap mode + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + model := result.(Model) + assert.Equal(t, 0, model.scrollX) + + // left key should not change scrollX in wrap mode + model.scrollX = 0 + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model = result.(Model) + assert.Equal(t, 0, model.scrollX) +} + +func TestModel_ScrollWorksWithoutWrapMode(t *testing.T) { + lines := []diff.DiffLine{{ChangeType: diff.ChangeContext, Content: "x", NewNum: 1}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.currFile = "a.go" + m.diffLines = lines + m.highlightedLines = []string{"x"} + m.focus = paneDiff + m.viewport.Width = 80 + m.viewport.Height = 20 + m.wrapMode = false + m.scrollX = 0 + + // right key should scroll in non-wrap mode + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + model := result.(Model) + assert.Positive(t, model.scrollX) +} + +func TestModel_HelpOverlayContainsWordWrap(t *testing.T) { + m := testModel([]string{"a.go"}, nil) + m.styles = plainStyles() + help := m.helpOverlay() + assert.Contains(t, help, "toggle word wrap") + assert.Contains(t, help, "w") +}