Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude-plugin/skills/revdiff/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions docs/plans/completed/20260403-single-file-mode.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion ui/annotate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions ui/diffview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
115 changes: 83 additions & 32 deletions ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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" {
Expand All @@ -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()
}
Expand Down
Loading
Loading