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: 3 additions & 1 deletion .claude-plugin/skills/revdiff/references/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,9 @@ 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`, `scroll_diff_page_down`, `scroll_diff_page_up`, `scroll_diff_half_page_down`, `scroll_diff_half_page_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`

The `scroll_diff_page_*` and `scroll_diff_half_page_*` actions are the page-sized versions of `J`/`K`: they scroll the diff viewport from either pane. They ship with no default key, so they appear in neither the help overlay nor `--dump-keys` until bound. Binding them to `pgdown`/`pgup`/`ctrl+d`/`ctrl+u` gives lazygit-style paging that always targets the diff. Doing so takes those keys away from tree and markdown TOC paging, replaces cursor paging in the diff pane, and — since `page_down`, `page_up`, `half_page_down` and `half_page_up` have no other default key — leaves them unbound everywhere, including the help and info overlays.

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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,17 @@ revdiff --dump-keys > ~/.config/revdiff/keybindings

Then edit to taste. 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.

**Paging the diff from the file tree:** `J`/`K` scroll the diff viewport from either pane, but only by a few lines, while `PgDown`/`PgUp` and `Ctrl+d`/`Ctrl+u` stay pane-relative and page whichever pane has focus. The `scroll_diff_page_*` and `scroll_diff_half_page_*` actions are the page-sized versions of `J`/`K` — they ship with no default key, so bind them to get lazygit-style paging that always targets the diff:

```
map pgdown scroll_diff_page_down
map pgup scroll_diff_page_up
map ctrl+d scroll_diff_half_page_down
map ctrl+u scroll_diff_half_page_up
```

Three consequences worth knowing. This also replaces cursor paging while the diff pane has focus: the keys scroll the viewport and pin the cursor back into view instead of walking the cursor a page at a time. The file tree and markdown TOC lose their own page-sized traversal on those keys. And because `page_down`, `page_up`, `half_page_down` and `half_page_up` have no other default key, rebinding all four lines leaves them unbound everywhere, including the `?` help and `i` info overlays, which page through the same actions. Omit one pair of mapping lines to keep its default paging. Because they have no default binding, these actions appear in neither the `?` help overlay nor `--dump-keys` until you bind them.

**Chord bindings (ctrl/alt leader):** bind a two-stage chord by joining the leader and second key with `>`. The leader must be a `ctrl+*` or `alt+*` combo; the second stage is any single key. Only two stages are supported.

```
Expand All @@ -908,7 +919,7 @@ When the leader is pressed, the status bar shows `Pending: ctrl+w, esc to cancel
<details>
<summary>Available actions (click to expand)</summary>

**Navigation:** `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`
**Navigation:** `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`, `scroll_diff_page_down`, `scroll_diff_page_up`, `scroll_diff_half_page_down`, `scroll_diff_half_page_up`

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

Expand Down
114 changes: 62 additions & 52 deletions app/keymap/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,58 +19,62 @@ type Action string

// action constants for all mappable actions.
const (
ActionDown Action = "down"
ActionUp Action = "up"
ActionPageDown Action = "page_down"
ActionPageUp Action = "page_up"
ActionHalfPageDown Action = "half_page_down"
ActionHalfPageUp Action = "half_page_up"
ActionHome Action = "home"
ActionEnd Action = "end"
ActionScrollLeft Action = "scroll_left"
ActionScrollRight Action = "scroll_right"
ActionScrollCenter Action = "scroll_center"
ActionScrollTop Action = "scroll_top"
ActionScrollBottom Action = "scroll_bottom"
ActionScrollDiffDown Action = "scroll_diff_down"
ActionScrollDiffUp Action = "scroll_diff_up"
ActionNextItem Action = "next_item"
ActionPrevItem Action = "prev_item"
ActionJumpFile Action = "jump_file"
ActionNextHunk Action = "next_hunk"
ActionPrevHunk Action = "prev_hunk"
ActionTogglePane Action = "toggle_pane"
ActionFocusTree Action = "focus_tree"
ActionFocusDiff Action = "focus_diff"
ActionSearch Action = "search"
ActionConfirm Action = "confirm"
ActionAnnotateFile Action = "annotate_file"
ActionDeleteAnnotation Action = "delete_annotation"
ActionAnnotList Action = "annot_list"
ActionNextAnnotation Action = "next_annotation"
ActionPrevAnnotation Action = "prev_annotation"
ActionToggleCollapsed Action = "toggle_collapsed"
ActionToggleCompact Action = "toggle_compact"
ActionToggleWrap Action = "toggle_wrap"
ActionToggleTree Action = "toggle_tree"
ActionToggleLineNums Action = "toggle_line_numbers"
ActionToggleBlame Action = "toggle_blame"
ActionToggleWordDiff Action = "toggle_word_diff"
ActionToggleHunk Action = "toggle_hunk"
ActionToggleUntracked Action = "toggle_untracked"
ActionMarkReviewed Action = "mark_reviewed"
ActionFilterUnreviewed Action = "filter_unreviewed"
ActionFilter Action = "filter"
ActionQuit Action = "quit"
ActionDiscardQuit Action = "discard_quit"
ActionHelp Action = "help"
ActionDismiss Action = "dismiss"
ActionThemeSelect Action = "theme_select"
ActionInfo Action = "info"
ActionReload Action = "reload"
ActionOpenEditor Action = "open_editor"
ActionOpenFileInEditor Action = "open_file_in_editor"
ActionFlushOutput Action = "flush_output"
ActionDown Action = "down"
ActionUp Action = "up"
ActionPageDown Action = "page_down"
ActionPageUp Action = "page_up"
ActionHalfPageDown Action = "half_page_down"
ActionHalfPageUp Action = "half_page_up"
ActionHome Action = "home"
ActionEnd Action = "end"
ActionScrollLeft Action = "scroll_left"
ActionScrollRight Action = "scroll_right"
ActionScrollCenter Action = "scroll_center"
ActionScrollTop Action = "scroll_top"
ActionScrollBottom Action = "scroll_bottom"
ActionScrollDiffDown Action = "scroll_diff_down"
ActionScrollDiffUp Action = "scroll_diff_up"
ActionScrollDiffPageDown Action = "scroll_diff_page_down"
ActionScrollDiffPageUp Action = "scroll_diff_page_up"
ActionScrollDiffHalfPageDown Action = "scroll_diff_half_page_down"
ActionScrollDiffHalfPageUp Action = "scroll_diff_half_page_up"
ActionNextItem Action = "next_item"
ActionPrevItem Action = "prev_item"
ActionJumpFile Action = "jump_file"
ActionNextHunk Action = "next_hunk"
ActionPrevHunk Action = "prev_hunk"
ActionTogglePane Action = "toggle_pane"
ActionFocusTree Action = "focus_tree"
ActionFocusDiff Action = "focus_diff"
ActionSearch Action = "search"
ActionConfirm Action = "confirm"
ActionAnnotateFile Action = "annotate_file"
ActionDeleteAnnotation Action = "delete_annotation"
ActionAnnotList Action = "annot_list"
ActionNextAnnotation Action = "next_annotation"
ActionPrevAnnotation Action = "prev_annotation"
ActionToggleCollapsed Action = "toggle_collapsed"
ActionToggleCompact Action = "toggle_compact"
ActionToggleWrap Action = "toggle_wrap"
ActionToggleTree Action = "toggle_tree"
ActionToggleLineNums Action = "toggle_line_numbers"
ActionToggleBlame Action = "toggle_blame"
ActionToggleWordDiff Action = "toggle_word_diff"
ActionToggleHunk Action = "toggle_hunk"
ActionToggleUntracked Action = "toggle_untracked"
ActionMarkReviewed Action = "mark_reviewed"
ActionFilterUnreviewed Action = "filter_unreviewed"
ActionFilter Action = "filter"
ActionQuit Action = "quit"
ActionDiscardQuit Action = "discard_quit"
ActionHelp Action = "help"
ActionDismiss Action = "dismiss"
ActionThemeSelect Action = "theme_select"
ActionInfo Action = "info"
ActionReload Action = "reload"
ActionOpenEditor Action = "open_editor"
ActionOpenFileInEditor Action = "open_file_in_editor"
ActionFlushOutput Action = "flush_output"
)

// SectionPane is the help section name for pane-related keybindings.
Expand All @@ -83,6 +87,8 @@ var validActions = map[Action]bool{
ActionScrollLeft: true, ActionScrollRight: true,
ActionScrollCenter: true, ActionScrollTop: true, ActionScrollBottom: true,
ActionScrollDiffDown: true, ActionScrollDiffUp: true,
ActionScrollDiffPageDown: true, ActionScrollDiffPageUp: true,
ActionScrollDiffHalfPageDown: true, ActionScrollDiffHalfPageUp: true,
ActionNextItem: true, ActionPrevItem: true, ActionJumpFile: true,
ActionNextHunk: true, ActionPrevHunk: true,
ActionTogglePane: true, ActionFocusTree: true, ActionFocusDiff: true,
Expand Down Expand Up @@ -201,6 +207,10 @@ func defaultDescriptions() []HelpEntry {
{ActionScrollBottom, "align viewport bottom", "Navigation"},
{ActionScrollDiffDown, "scroll diff down", "Navigation"},
{ActionScrollDiffUp, "scroll diff up", "Navigation"},
{ActionScrollDiffPageDown, "scroll diff one page down", "Navigation"},
{ActionScrollDiffPageUp, "scroll diff one page up", "Navigation"},
{ActionScrollDiffHalfPageDown, "scroll diff half a page down", "Navigation"},
{ActionScrollDiffHalfPageUp, "scroll diff half a page up", "Navigation"},

// file/hunk
{ActionNextItem, "next file / search match", "File/Hunk"},
Expand Down
58 changes: 58 additions & 0 deletions app/keymap/keymap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1237,3 +1237,61 @@ func TestKeysFor_IncludesChordKeys(t *testing.T) {
}
assert.Equal(t, "ctrl+w>x / q", joined)
}

func TestScrollDiffPageActions_AreValid(t *testing.T) {
for _, a := range []Action{
ActionScrollDiffPageDown, ActionScrollDiffPageUp,
ActionScrollDiffHalfPageDown, ActionScrollDiffHalfPageUp,
} {
assert.True(t, IsValidAction(a), "action %q must validate", a)
}
}

func TestScrollDiffPageActions_NoDefaultBindings(t *testing.T) {
km := Default()
for _, a := range []Action{
ActionScrollDiffPageDown, ActionScrollDiffPageUp,
ActionScrollDiffHalfPageDown, ActionScrollDiffHalfPageUp,
} {
assert.Empty(t, km.KeysFor(a), "action %q must have no default bindings", a)
}
assert.Equal(t, ActionPageDown, km.Resolve("pgdown"), "pgdown must keep its pane-relative paging")
assert.Equal(t, ActionPageUp, km.Resolve("pgup"))
assert.Equal(t, ActionHalfPageDown, km.Resolve("ctrl+d"))
assert.Equal(t, ActionHalfPageUp, km.Resolve("ctrl+u"))
}

func TestScrollDiffPageActions_HelpEntries(t *testing.T) {
want := map[Action]string{
ActionScrollDiffPageDown: "scroll diff one page down",
ActionScrollDiffPageUp: "scroll diff one page up",
ActionScrollDiffHalfPageDown: "scroll diff half a page down",
ActionScrollDiffHalfPageUp: "scroll diff half a page up",
}
found := map[Action]bool{}
for _, e := range defaultDescriptions() {
if desc, ok := want[e.Action]; ok {
assert.Equal(t, desc, e.Description)
assert.Equal(t, "Navigation", e.Section)
found[e.Action] = true
}
}
assert.Len(t, found, len(want), "every scroll_diff page action needs a help entry")
}

func TestScrollDiffPageActions_OmittedFromHelpUntilBound(t *testing.T) {
km := Default()
listed := func() bool {
for _, sec := range km.HelpSections() {
for _, entry := range sec.Entries {
if entry.Action == ActionScrollDiffPageDown {
return true
}
}
}
return false
}
assert.False(t, listed(), "unbound action must not appear in help")
km.Bind("pgdown", ActionScrollDiffPageDown)
assert.True(t, listed(), "once bound the action must appear in help")
}
35 changes: 31 additions & 4 deletions app/ui/diffnav.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,22 +139,29 @@ func (m *Model) moveDiffCursorPageUp() {
// directions: the walk stops on cursor positions and one position can span several rendered
// rows (a wrapped line, an annotation block), so a tall line at the page edge carries over
// more than requested when the walk rolls back off it, and less than requested - down to
// rows skipped unseen - when worthRollingBack accepts it whole.
// rows skipped unseen - when worthRollingBack accepts it whole. the scroll_diff_page_*
// actions reuse this distance on a pure viewport scroll, where it is exact.
Comment on lines +142 to +143
// half-page motions do not subtract it - they already retain half a screen.
func (m Model) pageRows() int {
return max(1, m.layout.viewport.Height-m.modes.pageOverlap)
}

// halfPageRows returns how far a half-page motion advances. the page overlap is not
// subtracted: a half page already retains half a screen.
func (m Model) halfPageRows() int {
return max(1, m.layout.viewport.Height/2)
}

// moveDiffCursorHalfPageDown moves the diff cursor down by half a visual page.
// scrolls viewport by half page explicitly, matching vim/less ctrl+d behavior.
func (m *Model) moveDiffCursorHalfPageDown() {
m.moveDiffCursorDownBy(max(1, m.layout.viewport.Height/2))
m.moveDiffCursorDownBy(m.halfPageRows())
}

// moveDiffCursorHalfPageUp moves the diff cursor up by half a visual page.
// scrolls viewport by half page explicitly, matching vim/less ctrl+u behavior.
func (m *Model) moveDiffCursorHalfPageUp() {
m.moveDiffCursorUpBy(max(1, m.layout.viewport.Height/2))
m.moveDiffCursorUpBy(m.halfPageRows())
}

// moveDiffCursorDownBy advances the cursor down by up to rows visual rows
Expand Down Expand Up @@ -806,6 +813,14 @@ func (m *Model) handleDiffMovement(action keymap.Action) bool {
m.scrollDiffViewportLine(wheelStep)
case keymap.ActionScrollDiffUp:
m.scrollDiffViewportLine(-wheelStep)
case keymap.ActionScrollDiffPageDown:
m.scrollDiffViewportLine(m.pageRows())
case keymap.ActionScrollDiffPageUp:
m.scrollDiffViewportLine(-m.pageRows())
case keymap.ActionScrollDiffHalfPageDown:
m.scrollDiffViewportLine(m.halfPageRows())
case keymap.ActionScrollDiffHalfPageUp:
m.scrollDiffViewportLine(-m.halfPageRows())
case keymap.ActionHome:
m.moveDiffCursorToStart()
case keymap.ActionEnd:
Expand All @@ -820,7 +835,7 @@ func (m *Model) handleDiffMovement(action keymap.Action) bool {
// When markdown TOC is active, routes to TOC navigation so chord-resolved and
// keymap-resolved actions reach the TOC without re-resolving from the raw key.
func (m Model) handleTreeAction(action keymap.Action) (tea.Model, tea.Cmd) {
// Shift+J / Shift+K scroll the diff pane while the tree (or TOC) keeps
// the scroll_diff_* actions scroll the diff pane while the tree (or TOC) keeps
// focus. Handled before the mdTOC dispatch and returned early so the
// tree-navigation tail (EnsureVisible, loadSelectedIfChanged) does not run —
// the tree selection is unchanged.
Expand All @@ -831,6 +846,18 @@ func (m Model) handleTreeAction(action keymap.Action) (tea.Model, tea.Cmd) {
case keymap.ActionScrollDiffUp:
m.scrollDiffViewportLine(-wheelStep)
return m, nil
case keymap.ActionScrollDiffPageDown:
m.scrollDiffViewportLine(m.pageRows())
return m, nil
case keymap.ActionScrollDiffPageUp:
m.scrollDiffViewportLine(-m.pageRows())
return m, nil
case keymap.ActionScrollDiffHalfPageDown:
m.scrollDiffViewportLine(m.halfPageRows())
return m, nil
case keymap.ActionScrollDiffHalfPageUp:
m.scrollDiffViewportLine(-m.halfPageRows())
return m, nil
default: // all other actions fall through to tree/TOC navigation below
}

Expand Down
Loading