Skip to content

Commit 1b7974f

Browse files
authored
Add single-file mode and fix annotation input width (#10)
* add plan: single-file-mode * feat: add singleFile field and detection in handleFilesLoaded * feat: adjust View rendering for single-file mode Skip tree pane entirely when singleFile is set, giving the diff pane full terminal width. handleResize sets treeWidth=0 and diffContentWidth returns width-3 in single-file mode. * feat: disable pane-switching keys in single-file mode feat: verify acceptance criteria for single-file mode * feat: update documentation for single-file mode fix: address code smell findings fix: address code review findings fix: address codex review findings * fix: correct annotation input width to fit within diff pane Use diffContentWidth instead of arbitrary offset to calculate textinput width, preventing cursor from extending past the pane border. * fix: correct test comment for N key (prev search match)
1 parent 4d9fa97 commit 1b7974f

8 files changed

Lines changed: 532 additions & 33 deletions

File tree

.claude-plugin/skills/revdiff/references/usage.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ revdiff --staged # review staged changes
1313
revdiff HEAD~1 # review last commit
1414
```
1515

16+
## Single-File Mode
17+
18+
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.
19+
1620
## Key Bindings
1721

1822
**Navigation:**

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,4 @@ git diff → diff.ParseUnifiedDiff() → []DiffLine
7676
- **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.
7777
- 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.
7878
- 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).
79+
- 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`.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Built for a specific use case: reviewing code changes without leaving a terminal
2121
- Collapsed diff mode: shows final text with change markers, toggle with `v`
2222
- Word wrap mode: wraps long lines at viewport boundary with `` continuation markers, toggle with `w`
2323
- Annotate any line in the diff (added, removed, or context) plus file-level notes
24+
- Single-file auto-detection: when a diff contains exactly one file, hides the tree pane and gives full terminal width to the diff view
2425
- Two-pane TUI: file tree (left) + colorized diff viewport (right)
2526
- Vim-style `/` search within diff with `n`/`N` match navigation
2627
- Hunk navigation to jump between change groups
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Single-File Mode
2+
3+
## Overview
4+
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.
5+
6+
## Context
7+
- **Key files:** `ui/model.go` (Model struct, handleFilesLoaded, View, handleKey, togglePane, handleDiffNav, handleResize), `ui/diffview.go` (diffContentWidth), `ui/model_test.go`
8+
- **Detection point:** `handleFilesLoaded` at line 423 — where the file list arrives
9+
- **Rendering:** `View()` at lines 492-528 — tree pane rendered at lines 499-513, joined with diff at line 528
10+
- **Width calculation:** `diffContentWidth()` in diffview.go:602 uses `m.width - m.treeWidth - 4 - 1`
11+
- **Pane switching:** `togglePane()` at line 280, `h` key in `handleDiffNav` at line 352
12+
13+
## Solution Overview
14+
- Add `singleFile bool` field to Model, set in `handleFilesLoaded` when `len(files) == 1`
15+
- In single-file mode: skip tree pane rendering, diff pane uses full width (`m.width - 2`), focus stays on `paneDiff`
16+
- Key no-ops in single-file mode: `tab`, `h`, `l`, `n`/`p` (file nav), `f` (filter)
17+
- `n`/`N` still work for search navigation when search is active
18+
- `diffContentWidth()` returns `m.width - 3` (diff borders + cursor bar only)
19+
- No CLI flag — purely automatic based on file count
20+
21+
## Technical Details
22+
23+
### Width calculations in single-file mode
24+
- Tree pane: not rendered, `treeWidth = 0`
25+
- Diff pane in `View()`: `Width(m.width - 2)` (only diff pane borders, 1 left + 1 right)
26+
- `diffContentWidth()`: `m.width - 2 - 1 = m.width - 3` (diff borders + cursor bar)
27+
- Viewport in `handleResize`: `diffWidth = m.width - 2`
28+
29+
### Key handling
30+
- `tab` → no-op (guard in `togglePane`)
31+
- `h` in diff pane → no-op (guard in `handleDiffNav`)
32+
- `n`/`p` → file nav no-op, but `n`/`N` search nav still works via `handleFileOrSearchNav`
33+
- `f` (filter) → no-op
34+
- All other keys work normally
35+
36+
## Development Approach
37+
- **Testing approach:** regular (code first, then tests)
38+
- Complete each task fully before moving to the next
39+
- Run tests after each change
40+
- Maintain backward compatibility (multi-file mode unchanged)
41+
42+
## Implementation Steps
43+
44+
### Task 1: Add singleFile field and detection
45+
46+
**Files:**
47+
- Modify: `ui/model.go`
48+
- Modify: `ui/model_test.go`
49+
50+
- [x] add `singleFile bool` field to Model struct
51+
- [x] in `handleFilesLoaded`: set `m.singleFile = len(msg.files) == 1` and `m.focus = paneDiff` when single file
52+
- [x] write test: single file sets `singleFile = true` and `focus = paneDiff`
53+
- [x] write test: multiple files keeps `singleFile = false`
54+
- [x] run `make test` — must pass before task 2
55+
56+
### Task 2: Adjust View rendering for single-file mode
57+
58+
**Files:**
59+
- Modify: `ui/model.go`
60+
- Modify: `ui/diffview.go`
61+
- Modify: `ui/model_test.go`
62+
63+
- [x] in `View()`: when `m.singleFile`, skip tree pane rendering and set diff pane `Width(m.width - 2)`
64+
- [x] in `handleResize`: when `m.singleFile`, set `m.treeWidth = 0` and `diffWidth = m.width - 2`
65+
- [x] in `diffContentWidth()`: when `m.singleFile`, return `max(10, m.width-3)`
66+
- [x] write test: `View()` output in single-file mode does not contain tree pane content
67+
- [x] write test: `diffContentWidth()` returns correct width in single-file mode
68+
- [x] run `make test` — must pass before task 3
69+
70+
### Task 3: Disable pane-switching keys in single-file mode
71+
72+
**Files:**
73+
- Modify: `ui/model.go`
74+
- Modify: `ui/model_test.go`
75+
76+
- [x] in `togglePane()`: early return when `m.singleFile`
77+
- [x] in `handleDiffNav`: skip `h` key (switch to tree) when `m.singleFile`
78+
- [x] in `handleKey`: skip `f` (filter) when `m.singleFile`
79+
- [x] in `handleFileOrSearchNav` or `handleKey`: `n`/`p` file nav no-op when `m.singleFile` (search nav still works)
80+
- [x] write tests: tab, h, f keys are no-ops in single-file mode
81+
- [x] write test: `n` still navigates search matches in single-file mode
82+
- [x] run `make test` — must pass before task 4
83+
84+
### Task 4: Verify acceptance criteria
85+
- [x] verify single-file diff shows no tree pane
86+
- [x] verify diff pane uses full terminal width
87+
- [x] verify focus starts on diff pane
88+
- [x] verify pane-switching keys are no-ops
89+
- [x] verify search, annotations, wrap, collapsed mode all work normally
90+
- [x] verify multi-file mode is unchanged
91+
- [x] run full test suite: `make test`
92+
- [x] run linter: `make lint`
93+
94+
### Task 5: [Final] Update documentation
95+
- [x] update README.md to mention single-file auto-detection
96+
- [x] update CLAUDE.md if new patterns discovered
97+
- [x] move this plan to `docs/plans/completed/`
98+
99+
## Post-Completion
100+
101+
**Manual verification:**
102+
- test with `revdiff HEAD~1` on a commit that changes exactly 1 file
103+
- test with `revdiff HEAD~1` on a commit that changes multiple files
104+
- test resizing terminal in single-file mode
105+
- test all keyboard shortcuts in single-file mode

ui/annotate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func (m *Model) newAnnotationInput(placeholder string) (textinput.Model, tea.Cmd
1717
ti.Placeholder = placeholder
1818
cmd := ti.Focus()
1919
ti.CharLimit = 500
20-
ti.Width = max(10, m.width-m.treeWidth-10)
20+
ti.Width = max(10, m.diffContentWidth()-6) // cursor col + emoji prefix "💬 " + border margin
2121
return ti, cmd
2222
}
2323

ui/diffview.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,10 @@ func (m *Model) handleHorizontalScroll(direction int) {
600600

601601
// diffContentWidth returns the available width for diff line content (excluding cursor bar).
602602
func (m Model) diffContentWidth() int {
603+
if m.singleFile {
604+
// single-file mode: diff pane borders (2) + cursor bar (1)
605+
return max(10, m.width-3)
606+
}
603607
// diff pane width minus borders (4) minus tree width, minus bar (1)
604608
return max(10, m.width-m.treeWidth-4-1)
605609
}

ui/model.go

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ type Model struct {
8989
discarded bool // true when user chose to discard annotations and quit
9090
inConfirmDiscard bool // true when showing discard confirmation prompt
9191
noConfirmDiscard bool // skip confirmation prompt on discard quit
92+
singleFile bool // true when diff contains exactly one file, hides tree pane
9293
}
9394

9495
// 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) {
247248
return m.handleFileOrSearchNav(msg.String())
248249

249250
case msg.String() == "p":
250-
m.tree.prevFile()
251-
return m.loadSelectedIfChanged()
251+
return m.handlePrevFile()
252252

253253
case msg.String() == "enter":
254254
return m.handleEnterKey()
@@ -277,7 +277,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
277277

278278
// togglePane switches focus between tree and diff panes.
279279
// only switches to diff pane when a file is loaded.
280+
// no-op in single-file mode (tree pane is hidden).
280281
func (m *Model) togglePane() {
282+
if m.singleFile {
283+
return
284+
}
281285
if m.focus != paneTree {
282286
m.focus = paneTree
283287
return
@@ -287,6 +291,15 @@ func (m *Model) togglePane() {
287291
}
288292
}
289293

294+
// handleSwitchToTree switches focus to tree pane from diff.
295+
// no-op in single-file mode (tree pane is hidden).
296+
func (m Model) handleSwitchToTree() (tea.Model, tea.Cmd) {
297+
if !m.singleFile {
298+
m.focus = paneTree
299+
}
300+
return m, nil
301+
}
302+
290303
// toggleWrapMode toggles line wrapping on/off.
291304
// resets horizontal scroll when enabling wrap and re-renders the diff.
292305
func (m *Model) toggleWrapMode() {
@@ -350,8 +363,7 @@ func (m Model) paneHeight() int {
350363
func (m Model) handleDiffNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
351364
switch {
352365
case msg.String() == "h":
353-
m.focus = paneTree
354-
return m, nil
366+
return m.handleSwitchToTree()
355367
case msg.String() == "left":
356368
m.handleHorizontalScroll(-1)
357369
return m, nil
@@ -397,11 +409,16 @@ func (m Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
397409
m.width = msg.Width
398410
m.height = msg.Height
399411

400-
// adjust tree width based on ratio (N out of 10 units)
401-
m.treeWidth = max(minTreeWidth, m.width*m.treeWidthRatio/10)
402-
403-
diffWidth := m.width - m.treeWidth - 4 // borders
404-
diffHeight := m.paneHeight() - 1 // pane height minus diff header
412+
var diffWidth int
413+
if m.singleFile {
414+
m.treeWidth = 0
415+
diffWidth = m.width - 2 // diff pane borders only
416+
} else {
417+
// adjust tree width based on ratio (N out of 10 units)
418+
m.treeWidth = max(minTreeWidth, m.width*m.treeWidthRatio/10)
419+
diffWidth = m.width - m.treeWidth - 4 // borders
420+
}
421+
diffHeight := m.paneHeight() - 1 // pane height minus diff header
405422

406423
if !m.ready {
407424
m.viewport = viewport.New(diffWidth, diffHeight)
@@ -426,6 +443,14 @@ func (m Model) handleFilesLoaded(msg filesLoadedMsg) (tea.Model, tea.Cmd) {
426443
return m, nil
427444
}
428445
m.tree = newFileTree(msg.files)
446+
m.singleFile = len(msg.files) == 1
447+
if m.singleFile {
448+
m.focus = paneDiff
449+
m.treeWidth = 0
450+
if m.ready {
451+
m.viewport.Width = m.width - 2
452+
}
453+
}
429454

430455
// auto-select first file
431456
if f := m.tree.selectedFile(); f != "" {
@@ -495,22 +520,6 @@ func (m Model) View() string {
495520
}
496521

497522
ph := m.paneHeight()
498-
annotated := m.annotatedFiles()
499-
treeContent := m.tree.render(m.treeWidth, ph, annotated, m.styles)
500-
501-
// apply pane borders based on focus
502-
treeStyle := m.styles.TreePane
503-
diffStyle := m.styles.DiffPane
504-
if m.focus == paneTree {
505-
treeStyle = m.styles.TreePaneActive
506-
} else {
507-
diffStyle = m.styles.DiffPaneActive
508-
}
509-
510-
treePane := treeStyle.
511-
Width(m.treeWidth).
512-
Height(ph).
513-
Render(treeContent)
514523

515524
// diff pane title
516525
diffTitle := "no file selected"
@@ -520,12 +529,39 @@ func (m Model) View() string {
520529
diffHeader := m.styles.DirEntry.Render(" " + diffTitle)
521530
diffContent := lipgloss.JoinVertical(lipgloss.Left, diffHeader, m.viewport.View())
522531

523-
diffPane := diffStyle.
524-
Width(m.width - m.treeWidth - 4).
525-
Height(ph).
526-
Render(diffContent)
532+
var mainView string
533+
if m.singleFile {
534+
// single-file mode: no tree pane, diff uses full width
535+
diffPane := m.styles.DiffPaneActive.
536+
Width(m.width - 2).
537+
Height(ph).
538+
Render(diffContent)
539+
mainView = diffPane
540+
} else {
541+
annotated := m.annotatedFiles()
542+
treeContent := m.tree.render(m.treeWidth, ph, annotated, m.styles)
543+
544+
// apply pane borders based on focus
545+
treeStyle := m.styles.TreePane
546+
diffStyle := m.styles.DiffPane
547+
if m.focus == paneTree {
548+
treeStyle = m.styles.TreePaneActive
549+
} else {
550+
diffStyle = m.styles.DiffPaneActive
551+
}
527552

528-
mainView := lipgloss.JoinHorizontal(lipgloss.Top, treePane, diffPane)
553+
treePane := treeStyle.
554+
Width(m.treeWidth).
555+
Height(ph).
556+
Render(treeContent)
557+
558+
diffPane := diffStyle.
559+
Width(m.width - m.treeWidth - 4).
560+
Height(ph).
561+
Render(diffContent)
562+
563+
mainView = lipgloss.JoinHorizontal(lipgloss.Top, treePane, diffPane)
564+
}
529565

530566
if m.showHelp {
531567
// overlay help popup on top of current content
@@ -917,7 +953,11 @@ func (m Model) handleConfirmDiscardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
917953
}
918954

919955
// handleFilterToggle toggles the annotated files filter.
956+
// no-op in single-file mode (tree pane is hidden).
920957
func (m Model) handleFilterToggle() (tea.Model, tea.Cmd) {
958+
if m.singleFile {
959+
return m, nil
960+
}
921961
annotated := m.annotatedFiles()
922962
if len(annotated) > 0 {
923963
m.tree.toggleFilter(annotated)
@@ -927,8 +967,19 @@ func (m Model) handleFilterToggle() (tea.Model, tea.Cmd) {
927967
return m, nil
928968
}
929969

970+
// handlePrevFile navigates to previous file.
971+
// no-op in single-file mode (tree pane is hidden).
972+
func (m Model) handlePrevFile() (tea.Model, tea.Cmd) {
973+
if m.singleFile {
974+
return m, nil
975+
}
976+
m.tree.prevFile()
977+
return m.loadSelectedIfChanged()
978+
}
979+
930980
// handleFileOrSearchNav handles n/N keys: navigates search matches when a search is active,
931-
// otherwise n falls through to next-file navigation. N does nothing without search.
981+
// otherwise n falls through to next-file navigation (no-op in single-file mode).
982+
// N does nothing without search.
932983
func (m Model) handleFileOrSearchNav(key string) (tea.Model, tea.Cmd) {
933984
if len(m.searchMatches) > 0 {
934985
if key == "n" {
@@ -939,7 +990,7 @@ func (m Model) handleFileOrSearchNav(key string) (tea.Model, tea.Cmd) {
939990
m.viewport.SetContent(m.renderDiff())
940991
return m, nil
941992
}
942-
if key == "n" {
993+
if key == "n" && !m.singleFile {
943994
m.tree.nextFile()
944995
return m.loadSelectedIfChanged()
945996
}

0 commit comments

Comments
 (0)