diff --git a/.claude-plugin/skills/revdiff/references/usage.md b/.claude-plugin/skills/revdiff/references/usage.md index 8ca95721..2300d9a6 100644 --- a/.claude-plugin/skills/revdiff/references/usage.md +++ b/.claude-plugin/skills/revdiff/references/usage.md @@ -13,6 +13,10 @@ revdiff --staged # review staged changes revdiff HEAD~1 # review last commit ``` +## Single-File Mode + +When a diff contains exactly one file, revdiff automatically hides the file tree pane and gives full terminal width to the diff view. Pane-switching keys (`Tab`, `h/l`, `n/p`, `f`) become no-ops. Search navigation (`n`/`N`) still works normally. + ## Key Bindings **Navigation:** diff --git a/CLAUDE.md b/CLAUDE.md index 49402046..1862b53d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,3 +76,4 @@ git diff → diff.ParseUnifiedDiff() → []DiffLine - **ANSI nesting with lipgloss**: `lipgloss.Render()` emits `\033[0m` (full reset) which breaks outer style backgrounds. For styled substrings inside a lipgloss container (status bar separators, search highlights), use raw ANSI sequences via `ansiColor(hex, code)` — code 38 for fg, 48 for bg. Never use `lipgloss.NewStyle().Render()` for inline elements within a lipgloss-rendered parent. - Status bar mode icons (`▼ ◉ ↩ ≋`) are always rendered on the right side via `statusModeIcons()`. Active modes use `StatusFg`, inactive use `Muted` — both via raw ANSI fg sequences. Graceful degradation on narrow terminals drops left segments: search position first (`statusSegmentsNoSearch`), then hunk info (`statusSegmentsMinimal`), then truncates filename. - Search and hunk navigation both use `centerViewportOnCursor()` to center the target in the middle of the viewport. Use `syncViewportToCursor()` only for cursor movements that should keep the cursor barely visible (j/k scrolling). +- Single-file mode (`m.singleFile`): when diff has exactly one file, tree pane is hidden, `treeWidth = 0`, diff gets full width (`m.width - 2` for borders, content width `m.width - 3`). Pane-switching keys (tab, h, l) and file navigation (n/p, f) become no-ops. Search nav (n/N) still works. Detection happens in `handleFilesLoaded`. diff --git a/README.md b/README.md index 11fb4f54..05bfd7cf 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Built for a specific use case: reviewing code changes without leaving a terminal - 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 +- Single-file auto-detection: when a diff contains exactly one file, hides the tree pane and gives full terminal width to the diff view - Two-pane TUI: file tree (left) + colorized diff viewport (right) - Vim-style `/` search within diff with `n`/`N` match navigation - Hunk navigation to jump between change groups diff --git a/docs/plans/completed/20260403-single-file-mode.md b/docs/plans/completed/20260403-single-file-mode.md new file mode 100644 index 00000000..e01e656d --- /dev/null +++ b/docs/plans/completed/20260403-single-file-mode.md @@ -0,0 +1,105 @@ +# Single-File Mode + +## Overview +When a diff contains exactly one file, automatically hide the file tree pane and give the full terminal width to the diff view. This eliminates the unnecessary tree panel and pane-switching overhead for single-file reviews. + +## Context +- **Key files:** `ui/model.go` (Model struct, handleFilesLoaded, View, handleKey, togglePane, handleDiffNav, handleResize), `ui/diffview.go` (diffContentWidth), `ui/model_test.go` +- **Detection point:** `handleFilesLoaded` at line 423 — where the file list arrives +- **Rendering:** `View()` at lines 492-528 — tree pane rendered at lines 499-513, joined with diff at line 528 +- **Width calculation:** `diffContentWidth()` in diffview.go:602 uses `m.width - m.treeWidth - 4 - 1` +- **Pane switching:** `togglePane()` at line 280, `h` key in `handleDiffNav` at line 352 + +## Solution Overview +- Add `singleFile bool` field to Model, set in `handleFilesLoaded` when `len(files) == 1` +- In single-file mode: skip tree pane rendering, diff pane uses full width (`m.width - 2`), focus stays on `paneDiff` +- Key no-ops in single-file mode: `tab`, `h`, `l`, `n`/`p` (file nav), `f` (filter) +- `n`/`N` still work for search navigation when search is active +- `diffContentWidth()` returns `m.width - 3` (diff borders + cursor bar only) +- No CLI flag — purely automatic based on file count + +## Technical Details + +### Width calculations in single-file mode +- Tree pane: not rendered, `treeWidth = 0` +- Diff pane in `View()`: `Width(m.width - 2)` (only diff pane borders, 1 left + 1 right) +- `diffContentWidth()`: `m.width - 2 - 1 = m.width - 3` (diff borders + cursor bar) +- Viewport in `handleResize`: `diffWidth = m.width - 2` + +### Key handling +- `tab` → no-op (guard in `togglePane`) +- `h` in diff pane → no-op (guard in `handleDiffNav`) +- `n`/`p` → file nav no-op, but `n`/`N` search nav still works via `handleFileOrSearchNav` +- `f` (filter) → no-op +- All other keys work normally + +## Development Approach +- **Testing approach:** regular (code first, then tests) +- Complete each task fully before moving to the next +- Run tests after each change +- Maintain backward compatibility (multi-file mode unchanged) + +## Implementation Steps + +### Task 1: Add singleFile field and detection + +**Files:** +- Modify: `ui/model.go` +- Modify: `ui/model_test.go` + +- [x] add `singleFile bool` field to Model struct +- [x] in `handleFilesLoaded`: set `m.singleFile = len(msg.files) == 1` and `m.focus = paneDiff` when single file +- [x] write test: single file sets `singleFile = true` and `focus = paneDiff` +- [x] write test: multiple files keeps `singleFile = false` +- [x] run `make test` — must pass before task 2 + +### Task 2: Adjust View rendering for single-file mode + +**Files:** +- Modify: `ui/model.go` +- Modify: `ui/diffview.go` +- Modify: `ui/model_test.go` + +- [x] in `View()`: when `m.singleFile`, skip tree pane rendering and set diff pane `Width(m.width - 2)` +- [x] in `handleResize`: when `m.singleFile`, set `m.treeWidth = 0` and `diffWidth = m.width - 2` +- [x] in `diffContentWidth()`: when `m.singleFile`, return `max(10, m.width-3)` +- [x] write test: `View()` output in single-file mode does not contain tree pane content +- [x] write test: `diffContentWidth()` returns correct width in single-file mode +- [x] run `make test` — must pass before task 3 + +### Task 3: Disable pane-switching keys in single-file mode + +**Files:** +- Modify: `ui/model.go` +- Modify: `ui/model_test.go` + +- [x] in `togglePane()`: early return when `m.singleFile` +- [x] in `handleDiffNav`: skip `h` key (switch to tree) when `m.singleFile` +- [x] in `handleKey`: skip `f` (filter) when `m.singleFile` +- [x] in `handleFileOrSearchNav` or `handleKey`: `n`/`p` file nav no-op when `m.singleFile` (search nav still works) +- [x] write tests: tab, h, f keys are no-ops in single-file mode +- [x] write test: `n` still navigates search matches in single-file mode +- [x] run `make test` — must pass before task 4 + +### Task 4: Verify acceptance criteria +- [x] verify single-file diff shows no tree pane +- [x] verify diff pane uses full terminal width +- [x] verify focus starts on diff pane +- [x] verify pane-switching keys are no-ops +- [x] verify search, annotations, wrap, collapsed mode all work normally +- [x] verify multi-file mode is unchanged +- [x] run full test suite: `make test` +- [x] run linter: `make lint` + +### Task 5: [Final] Update documentation +- [x] update README.md to mention single-file auto-detection +- [x] update CLAUDE.md if new patterns discovered +- [x] move this plan to `docs/plans/completed/` + +## Post-Completion + +**Manual verification:** +- test with `revdiff HEAD~1` on a commit that changes exactly 1 file +- test with `revdiff HEAD~1` on a commit that changes multiple files +- test resizing terminal in single-file mode +- test all keyboard shortcuts in single-file mode diff --git a/ui/annotate.go b/ui/annotate.go index 256fca0b..be5ee395 100644 --- a/ui/annotate.go +++ b/ui/annotate.go @@ -17,7 +17,7 @@ func (m *Model) newAnnotationInput(placeholder string) (textinput.Model, tea.Cmd ti.Placeholder = placeholder cmd := ti.Focus() ti.CharLimit = 500 - ti.Width = max(10, m.width-m.treeWidth-10) + ti.Width = max(10, m.diffContentWidth()-6) // cursor col + emoji prefix "💬 " + border margin return ti, cmd } diff --git a/ui/diffview.go b/ui/diffview.go index a06e0c7b..d8cb38e5 100644 --- a/ui/diffview.go +++ b/ui/diffview.go @@ -600,6 +600,10 @@ func (m *Model) handleHorizontalScroll(direction int) { // diffContentWidth returns the available width for diff line content (excluding cursor bar). func (m Model) diffContentWidth() int { + if m.singleFile { + // single-file mode: diff pane borders (2) + cursor bar (1) + return max(10, m.width-3) + } // diff pane width minus borders (4) minus tree width, minus bar (1) return max(10, m.width-m.treeWidth-4-1) } diff --git a/ui/model.go b/ui/model.go index 3f79ec75..ca6b7e1b 100644 --- a/ui/model.go +++ b/ui/model.go @@ -89,6 +89,7 @@ type Model struct { discarded bool // true when user chose to discard annotations and quit inConfirmDiscard bool // true when showing discard confirmation prompt noConfirmDiscard bool // skip confirmation prompt on discard quit + singleFile bool // true when diff contains exactly one file, hides tree pane } // fileLoadedMsg is sent when a file's diff has been loaded. @@ -247,8 +248,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleFileOrSearchNav(msg.String()) case msg.String() == "p": - m.tree.prevFile() - return m.loadSelectedIfChanged() + return m.handlePrevFile() case msg.String() == "enter": return m.handleEnterKey() @@ -277,7 +277,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // togglePane switches focus between tree and diff panes. // only switches to diff pane when a file is loaded. +// no-op in single-file mode (tree pane is hidden). func (m *Model) togglePane() { + if m.singleFile { + return + } if m.focus != paneTree { m.focus = paneTree return @@ -287,6 +291,15 @@ func (m *Model) togglePane() { } } +// handleSwitchToTree switches focus to tree pane from diff. +// no-op in single-file mode (tree pane is hidden). +func (m Model) handleSwitchToTree() (tea.Model, tea.Cmd) { + if !m.singleFile { + m.focus = paneTree + } + return m, nil +} + // toggleWrapMode toggles line wrapping on/off. // resets horizontal scroll when enabling wrap and re-renders the diff. func (m *Model) toggleWrapMode() { @@ -350,8 +363,7 @@ func (m Model) paneHeight() int { func (m Model) handleDiffNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case msg.String() == "h": - m.focus = paneTree - return m, nil + return m.handleSwitchToTree() case msg.String() == "left": m.handleHorizontalScroll(-1) return m, nil @@ -397,11 +409,16 @@ func (m Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height - // adjust tree width based on ratio (N out of 10 units) - m.treeWidth = max(minTreeWidth, m.width*m.treeWidthRatio/10) - - diffWidth := m.width - m.treeWidth - 4 // borders - diffHeight := m.paneHeight() - 1 // pane height minus diff header + var diffWidth int + if m.singleFile { + m.treeWidth = 0 + diffWidth = m.width - 2 // diff pane borders only + } else { + // adjust tree width based on ratio (N out of 10 units) + m.treeWidth = max(minTreeWidth, m.width*m.treeWidthRatio/10) + diffWidth = m.width - m.treeWidth - 4 // borders + } + diffHeight := m.paneHeight() - 1 // pane height minus diff header if !m.ready { m.viewport = viewport.New(diffWidth, diffHeight) @@ -426,6 +443,14 @@ func (m Model) handleFilesLoaded(msg filesLoadedMsg) (tea.Model, tea.Cmd) { return m, nil } m.tree = newFileTree(msg.files) + m.singleFile = len(msg.files) == 1 + if m.singleFile { + m.focus = paneDiff + m.treeWidth = 0 + if m.ready { + m.viewport.Width = m.width - 2 + } + } // auto-select first file if f := m.tree.selectedFile(); f != "" { @@ -495,22 +520,6 @@ func (m Model) View() string { } ph := m.paneHeight() - annotated := m.annotatedFiles() - treeContent := m.tree.render(m.treeWidth, ph, annotated, m.styles) - - // apply pane borders based on focus - treeStyle := m.styles.TreePane - diffStyle := m.styles.DiffPane - if m.focus == paneTree { - treeStyle = m.styles.TreePaneActive - } else { - diffStyle = m.styles.DiffPaneActive - } - - treePane := treeStyle. - Width(m.treeWidth). - Height(ph). - Render(treeContent) // diff pane title diffTitle := "no file selected" @@ -520,12 +529,39 @@ func (m Model) View() string { diffHeader := m.styles.DirEntry.Render(" " + diffTitle) diffContent := lipgloss.JoinVertical(lipgloss.Left, diffHeader, m.viewport.View()) - diffPane := diffStyle. - Width(m.width - m.treeWidth - 4). - Height(ph). - Render(diffContent) + var mainView string + if m.singleFile { + // single-file mode: no tree pane, diff uses full width + diffPane := m.styles.DiffPaneActive. + Width(m.width - 2). + Height(ph). + Render(diffContent) + mainView = diffPane + } else { + annotated := m.annotatedFiles() + treeContent := m.tree.render(m.treeWidth, ph, annotated, m.styles) + + // apply pane borders based on focus + treeStyle := m.styles.TreePane + diffStyle := m.styles.DiffPane + if m.focus == paneTree { + treeStyle = m.styles.TreePaneActive + } else { + diffStyle = m.styles.DiffPaneActive + } - mainView := lipgloss.JoinHorizontal(lipgloss.Top, treePane, diffPane) + treePane := treeStyle. + Width(m.treeWidth). + Height(ph). + Render(treeContent) + + diffPane := diffStyle. + Width(m.width - m.treeWidth - 4). + Height(ph). + Render(diffContent) + + mainView = lipgloss.JoinHorizontal(lipgloss.Top, treePane, diffPane) + } if m.showHelp { // overlay help popup on top of current content @@ -917,7 +953,11 @@ func (m Model) handleConfirmDiscardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleFilterToggle toggles the annotated files filter. +// no-op in single-file mode (tree pane is hidden). func (m Model) handleFilterToggle() (tea.Model, tea.Cmd) { + if m.singleFile { + return m, nil + } annotated := m.annotatedFiles() if len(annotated) > 0 { m.tree.toggleFilter(annotated) @@ -927,8 +967,19 @@ func (m Model) handleFilterToggle() (tea.Model, tea.Cmd) { return m, nil } +// handlePrevFile navigates to previous file. +// no-op in single-file mode (tree pane is hidden). +func (m Model) handlePrevFile() (tea.Model, tea.Cmd) { + if m.singleFile { + return m, nil + } + m.tree.prevFile() + return m.loadSelectedIfChanged() +} + // handleFileOrSearchNav handles n/N keys: navigates search matches when a search is active, -// otherwise n falls through to next-file navigation. N does nothing without search. +// otherwise n falls through to next-file navigation (no-op in single-file mode). +// N does nothing without search. func (m Model) handleFileOrSearchNav(key string) (tea.Model, tea.Cmd) { if len(m.searchMatches) > 0 { if key == "n" { @@ -939,7 +990,7 @@ func (m Model) handleFileOrSearchNav(key string) (tea.Model, tea.Cmd) { m.viewport.SetContent(m.renderDiff()) return m, nil } - if key == "n" { + if key == "n" && !m.singleFile { m.tree.nextFile() return m.loadSelectedIfChanged() } diff --git a/ui/model_test.go b/ui/model_test.go index ba456f25..5afbff2c 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -78,6 +78,56 @@ func TestModel_FilesLoadedError(t *testing.T) { assert.Empty(t, model.tree.entries) } +func TestModel_FilesLoadedSingleFile(t *testing.T) { + m := testModel(nil, nil) + result, cmd := m.Update(filesLoadedMsg{files: []string{"main.go"}}) + model := result.(Model) + + assert.True(t, model.singleFile, "singleFile should be true for one file") + assert.Equal(t, paneDiff, model.focus, "focus should be on diff pane in single-file mode") + assert.NotNil(t, cmd) // should auto-select first file +} + +func TestModel_FilesLoadedSingleFileViewportWidth(t *testing.T) { + m := testModel(nil, nil) + // simulate initial resize (viewport created with multi-file width) + resized, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = resized.(Model) + assert.True(t, m.ready, "model should be ready after resize") + + // now load single file — viewport width should be recalculated + result, _ := m.Update(filesLoadedMsg{files: []string{"main.go"}}) + model := result.(Model) + assert.True(t, model.singleFile) + assert.Equal(t, 0, model.treeWidth, "treeWidth should be 0 in single-file mode") + assert.Equal(t, 98, model.viewport.Width, "viewport width should be width - 2 (borders only)") +} + +func TestModel_ResizeInSingleFileMode(t *testing.T) { + m := testModel(nil, nil) + // set up single-file mode via filesLoadedMsg + resized, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = resized.(Model) + loaded, _ := m.Update(filesLoadedMsg{files: []string{"main.go"}}) + m = loaded.(Model) + require.True(t, m.singleFile) + + // resize while in single-file mode + result, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + model := result.(Model) + + assert.Equal(t, 0, model.treeWidth, "treeWidth stays 0 after resize in single-file mode") + assert.Equal(t, 78, model.viewport.Width, "viewport width should be new width - 2") +} + +func TestModel_FilesLoadedMultipleFiles(t *testing.T) { + m := testModel(nil, nil) + result, _ := m.Update(filesLoadedMsg{files: []string{"a.go", "b.go", "c.go"}}) + model := result.(Model) + + assert.False(t, model.singleFile, "singleFile should be false for multiple files") +} + func TestModel_FileLoaded(t *testing.T) { m := testModel([]string{"a.go"}, nil) m.tree = newFileTree([]string{"a.go"}) @@ -5038,3 +5088,286 @@ func TestModel_DeletePlaceholderSearchHighlight(t *testing.T) { model.wrapMode = false }) } + +func TestModel_ViewSingleFileMode(t *testing.T) { + t.Run("single-file mode renders full-width diff without tree pane", func(t *testing.T) { + m := testModel([]string{"main.go"}, nil) + m.tree = newFileTree([]string{"main.go"}) + m.singleFile = true + m.treeWidth = 0 + m.focus = paneDiff + m.currFile = "main.go" + m.noStatusBar = true + m.ready = true + + view := m.View() + assert.Contains(t, view, "main.go") + + // every rendered line must be full terminal width (diff pane uses m.width - 2 + 2 border = m.width) + lines := strings.Split(view, "\n") + for i, line := range lines { + w := lipgloss.Width(line) + if w == 0 { + continue // skip empty trailing lines + } + assert.Equal(t, m.width, w, "line %d should be full width (%d), got %d", i, m.width, w) + } + + // single-file mode must not contain adjacent pane borders (││) from JoinHorizontal + stripped := ansi.Strip(view) + assert.NotContains(t, stripped, "││", "single-file mode should not have two adjacent pane borders") + }) + + t.Run("multi-file mode renders tree and diff panes side by side", func(t *testing.T) { + m := testModel([]string{"internal/a.go", "internal/b.go"}, nil) + m.tree = newFileTree([]string{"internal/a.go", "internal/b.go"}) + m.singleFile = false + m.focus = paneTree + m.noStatusBar = true + m.ready = true + + view := m.View() + stripped := ansi.Strip(view) + assert.Contains(t, stripped, "a.go") + assert.Contains(t, stripped, "b.go") + + // multi-file mode should have adjacent pane borders from JoinHorizontal + assert.Contains(t, stripped, "││", "multi-file mode should have two pane borders from tree+diff join") + }) +} + +func TestModel_DiffContentWidthSingleFile(t *testing.T) { + t.Run("single-file mode", func(t *testing.T) { + m := testModel([]string{"main.go"}, nil) + m.singleFile = true + m.width = 100 + m.treeWidth = 0 + assert.Equal(t, 97, m.diffContentWidth()) // width - 3 (borders + cursor bar) + }) + + t.Run("multi-file mode", func(t *testing.T) { + m := testModel([]string{"a.go", "b.go"}, nil) + m.singleFile = false + m.width = 120 + m.treeWidth = 36 + assert.Equal(t, 79, m.diffContentWidth()) // 120 - 36 - 4 - 1 + }) + + t.Run("single-file mode minimum width", func(t *testing.T) { + m := testModel([]string{"main.go"}, nil) + m.singleFile = true + m.width = 5 + m.treeWidth = 0 + assert.Equal(t, 10, m.diffContentWidth()) // min 10 + }) +} + +func TestModel_SingleFileKeysNoOp(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "line one", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "line two", ChangeType: diff.ChangeAdd}, + } + setup := func() Model { + m := testModel([]string{"main.go"}, map[string][]diff.DiffLine{"main.go": lines}) + m.tree = newFileTree([]string{"main.go"}) + m.singleFile = true + m.focus = paneDiff + m.currFile = "main.go" + m.diffLines = lines + m.highlightedLines = noopHighlighter().HighlightLines("main.go", lines) + m.styles = plainStyles() + return m + } + + t.Run("tab is no-op in single-file mode", func(t *testing.T) { + m := setup() + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + model := result.(Model) + assert.Equal(t, paneDiff, model.focus, "tab should not switch pane in single-file mode") + }) + + t.Run("h is no-op in single-file mode", func(t *testing.T) { + m := setup() + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + model := result.(Model) + assert.Equal(t, paneDiff, model.focus, "h should not switch to tree in single-file mode") + }) + + t.Run("f is no-op in single-file mode", func(t *testing.T) { + m := setup() + m.store.Add(annotation.Annotation{File: "main.go", Line: 1, Type: "+", Comment: "test"}) + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + model := result.(Model) + assert.False(t, model.tree.filter, "f should not toggle filter in single-file mode") + }) + + t.Run("p is no-op in single-file mode", func(t *testing.T) { + m := setup() + selected := m.tree.selectedFile() + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + model := result.(Model) + assert.Equal(t, selected, model.tree.selectedFile(), "p should not change file in single-file mode") + }) + + t.Run("n is no-op for file nav in single-file mode", func(t *testing.T) { + m := setup() + selected := m.tree.selectedFile() + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model := result.(Model) + assert.Equal(t, selected, model.tree.selectedFile(), "n should not advance file in single-file mode") + }) +} + +func TestModel_SingleFileSearchNavStillWorks(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match one", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "no hit", ChangeType: diff.ChangeContext}, + {NewNum: 3, Content: "match two", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"main.go"}, map[string][]diff.DiffLine{"main.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "main.go", lines: lines}) + model = result.(Model) + model.singleFile = true + model.focus = paneDiff + model.searchMatches = []int{0, 2} + model.searchCursor = 0 + model.diffCursor = 0 + + // n should navigate to next search match + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = result.(Model) + assert.Equal(t, 1, model.searchCursor, "n should advance search cursor in single-file mode") + assert.Equal(t, 2, model.diffCursor, "cursor should move to second match") + + // N should navigate to previous search match + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) + model = result.(Model) + assert.Equal(t, 0, model.searchCursor, "N should go back in single-file mode") + assert.Equal(t, 0, model.diffCursor, "cursor should return to first match") +} + +func TestModel_SingleFileWrapModeWorks(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "short line", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: strings.Repeat("long ", 50), ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"main.go"}, map[string][]diff.DiffLine{"main.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "main.go", lines: lines}) + model = result.(Model) + model.singleFile = true + model.focus = paneDiff + + assert.False(t, model.wrapMode, "wrap should be off initially") + + // toggle wrap on + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + model = result.(Model) + assert.True(t, model.wrapMode, "w should toggle wrap on in single-file mode") + assert.Equal(t, 0, model.scrollX, "wrap should reset horizontal scroll") + + // toggle wrap off + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + model = result.(Model) + assert.False(t, model.wrapMode, "w should toggle wrap off in single-file mode") +} + +func TestModel_SingleFileCollapsedModeWorks(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "added", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"main.go"}, map[string][]diff.DiffLine{"main.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "main.go", lines: lines}) + model = result.(Model) + model.singleFile = true + model.focus = paneDiff + + assert.False(t, model.collapsed.enabled, "collapsed should be off initially") + + // toggle collapsed on + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + model = result.(Model) + assert.True(t, model.collapsed.enabled, "v should toggle collapsed on in single-file mode") + + // toggle collapsed off + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + model = result.(Model) + assert.False(t, model.collapsed.enabled, "v should toggle collapsed off in single-file mode") +} + +func TestModel_SingleFileAnnotationWorks(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "line one", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "added line", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"main.go"}, map[string][]diff.DiffLine{"main.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "main.go", lines: lines}) + model = result.(Model) + model.singleFile = true + model.focus = paneDiff + model.diffCursor = 1 // on the add line + model.styles = plainStyles() + + // press enter to start annotation + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = result.(Model) + assert.True(t, model.annotating, "enter should start annotation in single-file mode") + + // type annotation text + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}}) + model = result.(Model) + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + model = result.(Model) + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + model = result.(Model) + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}}) + model = result.(Model) + + // press enter to save + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = result.(Model) + assert.False(t, model.annotating, "annotation should be saved") + assert.Equal(t, 1, model.store.Count(), "annotation should be stored") +} + +func TestModel_SingleFileMultiFileModeUnchanged(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "line one", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go", "b.go"}, map[string][]diff.DiffLine{"a.go": lines, "b.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(filesLoadedMsg{files: []string{"a.go", "b.go"}}) + model = result.(Model) + + assert.False(t, model.singleFile, "multi-file should not be in single-file mode") + assert.Equal(t, paneTree, model.focus, "multi-file should start on tree pane") + + // tab should switch panes + model.focus = paneTree + model.currFile = "a.go" + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) + model = result.(Model) + assert.Equal(t, paneDiff, model.focus, "tab should switch to diff pane in multi-file mode") + + // tab back to tree + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) + model = result.(Model) + assert.Equal(t, paneTree, model.focus, "tab should switch back to tree in multi-file mode") + + // f should toggle filter (with annotations present) + model.store.Add(annotation.Annotation{File: "a.go", Line: 1, Type: "+", Comment: "note"}) + model.focus = paneDiff + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + model = result.(Model) + assert.True(t, model.tree.filter, "f should toggle filter in multi-file mode") +}