Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .claude-plugin/skills/revdiff/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ When you are launching revdiff for the user (e.g., right after a refactor or ana

**When the recent change likely created new untracked files** (new packages, new test files, new docs, new scripts that haven't been `git add`-ed yet), pass `--untracked` so those files appear in the tree. Use this in working-tree mode (no ref, no `--staged`); skip it for ref-to-ref reviews where untracked files are not part of the historical diff.

**When the user explicitly requests the file tree on a side**, pass `--tree-position=left` or `--tree-position=right`. Otherwise omit it and respect the user's config.

Run the launcher through the override-chain resolver:

```bash
Expand Down
3 changes: 2 additions & 1 deletion .claude-plugin/skills/revdiff/references/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Then uncomment and edit the values you want to change.
| `--staged` | `REVDIFF_STAGED` | Show staged changes | `false` |
| `--untracked` | `REVDIFF_UNTRACKED` | Show untracked files in the tree | `false` |
| `--tree-width` | `REVDIFF_TREE_WIDTH` | File tree panel width in units (1-10) | `2` |
| `--tree-position` | `REVDIFF_TREE_POSITION` | File tree and markdown TOC position (`left` or `right`) | `left` |
| `--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` |
Expand Down Expand Up @@ -152,7 +153,7 @@ unmap q
map ctrl+d half_page_down
```

Available actions: `down`, `up`, `page_down`, `page_up`, `half_page_down`, `half_page_up`, `home`, `end`, `scroll_left`, `scroll_right`, `scroll_center`, `scroll_top`, `scroll_bottom`, `scroll_diff_down`, `scroll_diff_up`, `next_item`, `prev_item`, `jump_file`, `next_hunk`, `prev_hunk`, `open_file_in_editor`, `toggle_pane`, `focus_tree`, `focus_diff`, `search`, `confirm`, `annotate_file`, `delete_annotation`, `annot_list`, `open_editor`, `next_annotation`, `prev_annotation`, `flush_output`, `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_word_diff`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `reload`, `quit`, `discard_quit`, `help`, `dismiss`
Available actions: `down`, `up`, `page_down`, `page_up`, `half_page_down`, `half_page_up`, `home`, `end`, `scroll_left`, `scroll_right`, `scroll_center`, `scroll_top`, `scroll_bottom`, `scroll_diff_down`, `scroll_diff_up`, `next_item`, `prev_item`, `jump_file`, `next_hunk`, `prev_hunk`, `open_file_in_editor`, `toggle_pane`, `focus_left`, `focus_right`, `focus_tree`, `focus_diff`, `search`, `confirm`, `annotate_file`, `delete_annotation`, `annot_list`, `open_editor`, `next_annotation`, `prev_annotation`, `flush_output`, `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_word_diff`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `reload`, `quit`, `discard_quit`, `help`, `dismiss`

Fixed modal keys (Enter, Esc in annotation/search input, confirm discard) are not remappable. Keymap-resolved actions like `open_editor` work during annotation input and can be rebound. Chord bindings do not fire during text input — use single-key `ctrl+*` bindings for actions that need to work during annotation input.

Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/skills/revdiff/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ When a diff contains exactly one file, revdiff automatically hides the file tree

## Markdown TOC Navigation

When reviewing a single markdown file in context-only mode (e.g., `revdiff --only=README.md`), a table-of-contents pane appears on the left listing all markdown headers with indentation by level. Use `Tab` to switch between TOC and diff, `j`/`k` to navigate headers, `n`/`p` to jump to next/prev header from either pane, `Enter` to jump to a header. The TOC highlights the current section as you scroll. Headers inside fenced code blocks are excluded.
When reviewing a single markdown file in context-only mode (e.g., `revdiff --only=README.md`), a table-of-contents pane appears on the configured tree side (left by default) listing all markdown headers with indentation by level. Use `Tab` to switch between TOC and diff, `j`/`k` to navigate headers, `n`/`p` to jump to next/prev header from either pane, `Enter` to jump to a header. The TOC highlights the current section as you scroll. Headers inside fenced code blocks are excluded.

## All-Files Mode

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Built for a specific use case: reviewing code changes, plans, and documents with
- Blame gutter: shows author name and commit age per line, toggle with `B`
- 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)
- Two-pane TUI: file tree + colorized diff viewport
- Vim-style `/` search within diff with `n`/`N` match navigation
- Hunk navigation to jump between change groups
- Annotation list popup (`@`): browse all annotations across files, jump to any annotation
Expand Down Expand Up @@ -354,6 +354,7 @@ Positional arguments support several forms:
| `--staged` | Show staged changes, env: `REVDIFF_STAGED` | `false` |
| `--untracked` | Show untracked files in the tree, env: `REVDIFF_UNTRACKED` | `false` |
| `--tree-width` | File tree panel width in units (1-10), env: `REVDIFF_TREE_WIDTH` | `2` |
| `--tree-position` | File tree and markdown TOC position (`left` or `right`), env: `REVDIFF_TREE_POSITION` | `left` |
| `--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` |
Expand Down Expand Up @@ -665,7 +666,7 @@ revdiff HEAD~3 --description-file=.review-description.md

### Markdown TOC Navigation

When reviewing a single markdown file in context-only mode (e.g., `revdiff --only=README.md` or `printf '# title\n' | revdiff --stdin --stdin-name plan.md`), revdiff shows a table-of-contents pane on the left listing all markdown headers. Use `Tab` to switch focus between the TOC and diff panes, `j`/`k` to navigate headers, and `Enter` to jump to a header in the diff. The TOC automatically highlights the current section as you scroll through the file.
When reviewing a single markdown file in context-only mode (e.g., `revdiff --only=README.md` or `printf '# title\n' | revdiff --stdin --stdin-name plan.md`), revdiff shows a table-of-contents pane on the configured side listing all markdown headers. Use `Tab` to switch focus between the TOC and diff panes, `j`/`k` to navigate headers, and `Enter` to jump to a header in the diff. The TOC automatically highlights the current section as you scroll through the file.

This mode activates when all three conditions are met: single file, markdown extension (`.md`/`.markdown`), and all lines are context (no diff changes). Headers inside fenced code blocks are excluded from the TOC.

Expand Down Expand Up @@ -885,7 +886,7 @@ When the leader is pressed, the status bar shows `Pending: ctrl+w, esc to cancel

**File/Hunk:** `next_item`, `prev_item`, `jump_file`, `next_hunk`, `prev_hunk`, `open_file_in_editor`

**Pane:** `toggle_pane`, `focus_tree`, `focus_diff`
**Pane:** `toggle_pane`, `focus_left`, `focus_right`, `focus_tree`, `focus_diff`

**Search:** `search`

Expand Down
11 changes: 11 additions & 0 deletions app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"

"github.com/jessevdk/go-flags"

"github.com/umputun/revdiff/app/ui"
)

type options struct {
Expand All @@ -20,6 +22,7 @@ type options struct {
Staged bool `long:"staged" ini-name:"staged" env:"REVDIFF_STAGED" description:"show staged changes"`
Untracked bool `long:"untracked" ini-name:"untracked" env:"REVDIFF_UNTRACKED" description:"show untracked files in the tree"`
TreeWidth int `long:"tree-width" ini-name:"tree-width" env:"REVDIFF_TREE_WIDTH" default:"2" description:"file tree panel width in units (1-10, default 2 of 10)"`
TreePosition string `long:"tree-position" ini-name:"tree-position" env:"REVDIFF_TREE_POSITION" choice:"left" choice:"right" default:"left" description:"file tree and markdown TOC position"`
TabWidth int `long:"tab-width" ini-name:"tab-width" env:"REVDIFF_TAB_WIDTH" default:"4" description:"number of spaces per tab character"`
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"`
Expand Down Expand Up @@ -119,6 +122,14 @@ func (o options) startupUntracked() bool {
return true
}

// treePosition resolves the --tree-position flag value to its typed position.
func (o options) treePosition() ui.TreePosition {
if o.TreePosition == "right" {
return ui.TreePositionRight
}
return ui.TreePositionLeft
}

// parseArgs parses CLI arguments with config file support.
// config file is loaded first, then CLI args override.
// precedence: CLI flags > env vars > config file > built-in defaults.
Expand Down
32 changes: 32 additions & 0 deletions app/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func TestParseArgs_Defaults(t *testing.T) {
opts, err := parseArgs(noConfigArgs(t))
require.NoError(t, err)
assert.Equal(t, 2, opts.TreeWidth)
assert.Equal(t, "left", opts.TreePosition)
assert.Equal(t, 4, opts.TabWidth)
assert.Equal(t, "catppuccin-macchiato", opts.ChromaStyle)
assert.Equal(t, "💬", opts.AnnotationMarker)
Expand Down Expand Up @@ -48,6 +49,36 @@ func TestParseArgs_Defaults(t *testing.T) {
assert.Equal(t, "catppuccin-latte", opts.AutoThemeLight)
}

func TestParseArgs_TreePosition(t *testing.T) {
t.Run("flag", func(t *testing.T) {
opts, err := parseArgs(append(noConfigArgs(t), "--tree-position=right"))
require.NoError(t, err)
assert.Equal(t, "right", opts.TreePosition)
})

t.Run("env", func(t *testing.T) {
t.Setenv("REVDIFF_TREE_POSITION", "right")
opts, err := parseArgs(noConfigArgs(t))
require.NoError(t, err)
assert.Equal(t, "right", opts.TreePosition)
})

t.Run("config file", func(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config")
err := os.WriteFile(cfgPath, []byte("[Application Options]\ntree-position = right\n"), 0o600)
require.NoError(t, err)

opts, err := parseArgs([]string{"--config", cfgPath})
require.NoError(t, err)
assert.Equal(t, "right", opts.TreePosition)
})

t.Run("invalid", func(t *testing.T) {
_, err := parseArgs(append(noConfigArgs(t), "--tree-position=center"))
require.Error(t, err)
})
}

func TestParseArgs_NoConfirmDiscard(t *testing.T) {
t.Run("flag", func(t *testing.T) {
opts, err := parseArgs(append(noConfigArgs(t), "--no-confirm-discard"))
Expand Down Expand Up @@ -855,6 +886,7 @@ func TestDumpConfig(t *testing.T) {

assert.Contains(t, output, "[Application Options]")
assert.Contains(t, output, "chroma-style = catppuccin-macchiato")
assert.Contains(t, output, "tree-position = left")
assert.Contains(t, output, "cross-file-hunks = false")
assert.Contains(t, output, "exit-code-on-annotations = false")
assert.Contains(t, output, "no-mouse = false")
Expand Down
11 changes: 8 additions & 3 deletions app/keymap/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const (
ActionNextHunk Action = "next_hunk"
ActionPrevHunk Action = "prev_hunk"
ActionTogglePane Action = "toggle_pane"
ActionFocusLeft Action = "focus_left"
ActionFocusRight Action = "focus_right"
ActionFocusTree Action = "focus_tree"
ActionFocusDiff Action = "focus_diff"
ActionSearch Action = "search"
Expand Down Expand Up @@ -85,7 +87,8 @@ var validActions = map[Action]bool{
ActionScrollDiffDown: true, ActionScrollDiffUp: true,
ActionNextItem: true, ActionPrevItem: true, ActionJumpFile: true,
ActionNextHunk: true, ActionPrevHunk: true,
ActionTogglePane: true, ActionFocusTree: true, ActionFocusDiff: true,
ActionTogglePane: true, ActionFocusLeft: true, ActionFocusRight: true,
ActionFocusTree: true, ActionFocusDiff: true,
ActionSearch: true,
ActionConfirm: true, ActionAnnotateFile: true, ActionDeleteAnnotation: true, ActionAnnotList: true,
ActionNextAnnotation: true, ActionPrevAnnotation: true,
Expand Down Expand Up @@ -212,6 +215,8 @@ func defaultDescriptions() []HelpEntry {

// pane
{ActionTogglePane, "toggle pane focus", SectionPane},
{ActionFocusLeft, "focus left pane", SectionPane},
{ActionFocusRight, "focus right pane", SectionPane},
{ActionFocusTree, "focus tree pane", SectionPane},
{ActionFocusDiff, "focus diff pane", SectionPane},

Expand Down Expand Up @@ -278,8 +283,8 @@ func defaultBindings() map[string]Action {
"[": ActionPrevHunk,
"e": ActionOpenFileInEditor,
"tab": ActionTogglePane,
"h": ActionFocusTree,
"l": ActionFocusDiff,
"h": ActionFocusLeft,
"l": ActionFocusRight,
"/": ActionSearch,
"a": ActionConfirm,
"enter": ActionConfirm,
Expand Down
4 changes: 3 additions & 1 deletion app/keymap/keymap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestDefault_allExpectedBindings(t *testing.T) {
{"n", ActionNextItem}, {"N", ActionPrevItem}, {"p", ActionPrevItem},
{"ctrl+p", ActionJumpFile},
{"]", ActionNextHunk}, {"[", ActionPrevHunk}, {"e", ActionOpenFileInEditor},
{"tab", ActionTogglePane}, {"h", ActionFocusTree}, {"l", ActionFocusDiff},
{"tab", ActionTogglePane}, {"h", ActionFocusLeft}, {"l", ActionFocusRight},
{"/", ActionSearch},
{"a", ActionConfirm}, {"enter", ActionConfirm},
{"A", ActionAnnotateFile}, {"d", ActionDeleteAnnotation}, {"@", ActionAnnotList}, {"ctrl+e", ActionOpenEditor},
Expand Down Expand Up @@ -404,6 +404,8 @@ func TestActionScrollConstants_NoDefaultBindings(t *testing.T) {
func TestIsValidAction(t *testing.T) {
assert.True(t, IsValidAction(ActionQuit))
assert.True(t, IsValidAction(ActionDown))
assert.True(t, IsValidAction(ActionFocusLeft))
assert.True(t, IsValidAction(ActionFocusRight))
assert.True(t, IsValidAction(ActionInfo))
assert.True(t, IsValidAction(Action("commit_info")), "deprecated alias must validate")
assert.False(t, IsValidAction(Action("nonexistent")))
Expand Down
1 change: 1 addition & 0 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ func run(opts options) (int, error) {
Ref: opts.ref(),
Staged: opts.Staged,
TreeWidthRatio: opts.TreeWidth,
TreePosition: opts.treePosition(),
Only: opts.Only,
WorkDir: workDir,
SourceEditor: sourceEditorPolicy(opts, workDir),
Expand Down
2 changes: 1 addition & 1 deletion app/ui/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
// (styleResolver, styleRenderer, sgrProcessor) defined in model.go; concrete
// implementations live in the style sub-package.
//
// Left-pane navigation components live in the [sidepane] sub-package (app/ui/sidepane/).
// Navigation-pane components live in the [sidepane] sub-package (app/ui/sidepane/).
// It owns the file tree (FileTree) and markdown table-of-contents (TOC) types,
// including cursor/offset management, entry parsing, and rendering logic.
// Model holds sidepane types through consumer-side interfaces (FileTreeComponent,
Expand Down
31 changes: 31 additions & 0 deletions app/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ type TOCComponent interface {
Render(r sidepane.TOCRender) string
}

// TreePosition is the side of the screen where the file tree or markdown TOC renders.
type TreePosition int

const (
TreePositionLeft TreePosition = iota
TreePositionRight
)

// pane identifies which pane has focus.
type pane int

Expand Down Expand Up @@ -332,6 +340,8 @@ type modelConfigState struct {
annotPrefix string // cached: marker + " "
annotFilePrefix string // cached: marker + " file: "
outputPath string // --output destination for the O in-session flush; empty disables it

treePosition TreePosition // side the file tree or markdown TOC renders on
}

// layoutState holds viewport and layout concerns that change on resize and pane toggles.
Expand Down Expand Up @@ -788,6 +798,8 @@ type ModelConfig struct {
// disables the flush (there is no file to write to); a non-empty path enables
// it. Copied into modelConfigState.outputPath as a plain value.
OutputPath string

TreePosition TreePosition // side the file tree or markdown TOC renders on
}

// NewModel creates a new Model from the given configuration. All dependencies
Expand Down Expand Up @@ -887,6 +899,7 @@ func NewModel(cfg ModelConfig) (Model, error) {
noConfirmReload: cfg.NoConfirmReload,
crossFileHunks: cfg.CrossFileHunks,
treeWidthRatio: cfg.TreeWidthRatio,
treePosition: cfg.TreePosition,
tabSpaces: strings.Repeat(" ", cfg.TabWidth),
wrapIndent: max(0, cfg.WrapIndent),
annotPrefix: cfg.AnnotationMarker + " ",
Expand Down Expand Up @@ -1057,11 +1070,29 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.dispatchAction(action)
}

// resolveDirectionalFocus maps spatial focus actions to semantic pane actions.
// Explicit user bindings to focus_tree or focus_diff bypass this mapping.
func (m Model) resolveDirectionalFocus(action keymap.Action) keymap.Action {
left, right := keymap.ActionFocusTree, keymap.ActionFocusDiff
if m.cfg.treePosition == TreePositionRight {
left, right = right, left
}
switch action {
case keymap.ActionFocusLeft:
return left
case keymap.ActionFocusRight:
return right
default:
return action
}
}

// dispatchAction routes a resolved keymap action through overlay-open, the
// global action switch, and the pane-specific nav fallback. It is the unified
// dispatch path shared by keymap-resolved single keys (handleKey) and by
// chord-resolved actions (handleChordSecond).
func (m Model) dispatchAction(action keymap.Action) (tea.Model, tea.Cmd) {
action = m.resolveDirectionalFocus(action)
if model, cmd, ok := m.handleOverlayOpen(action); ok {
return model, cmd
}
Expand Down
42 changes: 33 additions & 9 deletions app/ui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,19 +501,43 @@ func TestModel_TreeNavigation(t *testing.T) {
}

func TestModel_FocusSwitching(t *testing.T) {
for _, tc := range []struct {
name string
treePos TreePosition
toDiff rune // key that moves focus tree→diff
toTree rune // key that moves focus diff→tree
}{
{name: "left tree", toDiff: 'l', toTree: 'h'},
{name: "right tree", treePos: TreePositionRight, toDiff: 'h', toTree: 'l'},
} {
t.Run(tc.name, func(t *testing.T) {
m := testModel([]string{"a.go"}, nil)
m.tree = testNewFileTree([]string{"a.go"})
m.cfg.treePosition = tc.treePos
m.file.name = "a.go" // pretend a file is loaded
m.layout.focus = paneTree

result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{tc.toDiff}})
model := result.(Model)
assert.Equal(t, paneDiff, model.layout.focus)

result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{tc.toTree}})
model = result.(Model)
assert.Equal(t, paneTree, model.layout.focus)
})
}
}

func TestModel_FocusSwitching_UserSemanticBinding(t *testing.T) {
m := testModel([]string{"a.go"}, nil)
m.tree = testNewFileTree([]string{"a.go"})
m.file.name = "a.go" // pretend a file is loaded
m.layout.focus = paneTree
m.cfg.treePosition = TreePositionRight
m.keymap.Bind("h", keymap.ActionFocusTree)
m.file.name = "a.go"
m.layout.focus = paneDiff

// l switches to diff pane
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
model := result.(Model)
assert.Equal(t, paneDiff, model.layout.focus)

// h switches back to tree
result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
model = result.(Model)
assert.Equal(t, paneTree, model.layout.focus)
}

Expand Down
Loading