diff --git a/.claude-plugin/skills/revdiff/references/config.md b/.claude-plugin/skills/revdiff/references/config.md index 2ca07ab7..2e89dc57 100644 --- a/.claude-plugin/skills/revdiff/references/config.md +++ b/.claude-plugin/skills/revdiff/references/config.md @@ -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. diff --git a/README.md b/README.md index f0ae2abc..443420ce 100644 --- a/README.md +++ b/README.md @@ -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. ``` @@ -908,7 +919,7 @@ When the leader is pressed, the status bar shows `Pending: ctrl+w, esc to cancel
Available actions (click to expand) -**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` diff --git a/app/keymap/keymap.go b/app/keymap/keymap.go index d5f226e4..a1ede119 100644 --- a/app/keymap/keymap.go +++ b/app/keymap/keymap.go @@ -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. @@ -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, @@ -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"}, diff --git a/app/keymap/keymap_test.go b/app/keymap/keymap_test.go index 678bce20..4d828f55 100644 --- a/app/keymap/keymap_test.go +++ b/app/keymap/keymap_test.go @@ -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") +} diff --git a/app/ui/diffnav.go b/app/ui/diffnav.go index 8da5bcb9..a4b32778 100644 --- a/app/ui/diffnav.go +++ b/app/ui/diffnav.go @@ -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. // 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 @@ -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: @@ -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. @@ -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 } diff --git a/app/ui/diffnav_test.go b/app/ui/diffnav_test.go index ed5f128b..563d0d91 100644 --- a/app/ui/diffnav_test.go +++ b/app/ui/diffnav_test.go @@ -2,6 +2,8 @@ package ui import ( "fmt" + "os" + "path/filepath" "strings" "testing" "time" @@ -3417,6 +3419,171 @@ func TestModel_JKScrollDiffNoOpWhenContentFits(t *testing.T) { } } +func TestModel_ScrollDiffPageActionsScrollViewport(t *testing.T) { + lines := make([]diff.DiffLine, 400) + for i := range lines { + lines[i] = diff.DiffLine{NewNum: i + 1, Content: "line", ChangeType: diff.ChangeContext} + } + + cases := []struct { + name string + mapping string + key tea.KeyMsg + step func(m Model) int + }{ + {"page down", "map pgdown scroll_diff_page_down", tea.KeyMsg{Type: tea.KeyPgDown}, func(m Model) int { return m.pageRows() }}, + {"half page down", "map ctrl+d scroll_diff_half_page_down", tea.KeyMsg{Type: tea.KeyCtrlD}, func(m Model) int { return m.halfPageRows() }}, + } + focusCases := []struct { + name string + focus pane + }{ + {"tree focused", paneTree}, + {"diff focused", paneDiff}, + } + + for _, tc := range cases { + for _, fc := range focusCases { + t.Run(tc.name+"/"+fc.name, func(t *testing.T) { + model := scrollDiffPageModel(t, lines, fc.focus, tc.mapping) + step := tc.step(model) + require.Positive(t, step) + + result, _ := model.Update(tc.key) + model = result.(Model) + assert.Equal(t, step, model.layout.viewport.YOffset) + assert.Equal(t, step, model.nav.diffCursor) + assert.Equal(t, fc.focus, model.layout.focus) + assert.Equal(t, "a.go", model.tree.SelectedFile()) + }) + } + } +} + +func TestModel_ScrollDiffPageActionsScrollBack(t *testing.T) { + lines := make([]diff.DiffLine, 400) + for i := range lines { + lines[i] = diff.DiffLine{NewNum: i + 1, Content: "line", ChangeType: diff.ChangeContext} + } + + cases := []struct { + name string + mappings string + down tea.KeyMsg + up tea.KeyMsg + step func(m Model) int + }{ + { + "page", "map pgdown scroll_diff_page_down\nmap pgup scroll_diff_page_up", + tea.KeyMsg{Type: tea.KeyPgDown}, tea.KeyMsg{Type: tea.KeyPgUp}, + func(m Model) int { return m.pageRows() }, + }, + { + "half page", "map ctrl+d scroll_diff_half_page_down\nmap ctrl+u scroll_diff_half_page_up", + tea.KeyMsg{Type: tea.KeyCtrlD}, tea.KeyMsg{Type: tea.KeyCtrlU}, + func(m Model) int { return m.halfPageRows() }, + }, + } + focusCases := []struct { + name string + focus pane + }{ + {"tree focused", paneTree}, + {"diff focused", paneDiff}, + } + for _, tc := range cases { + for _, fc := range focusCases { + t.Run(tc.name+"/"+fc.name, func(t *testing.T) { + model := scrollDiffPageModel(t, lines, fc.focus, tc.mappings) + + result, _ := model.Update(tc.down) + model = result.(Model) + require.Equal(t, tc.step(model), model.layout.viewport.YOffset) + + result, _ = model.Update(tc.up) + model = result.(Model) + assert.Zero(t, model.layout.viewport.YOffset) + assert.Equal(t, fc.focus, model.layout.focus) + assert.Equal(t, "a.go", model.tree.SelectedFile()) + }) + } + } +} + +func TestModel_ScrollDiffPageActionsHonorPageOverlap(t *testing.T) { + lines := make([]diff.DiffLine, 400) + for i := range lines { + lines[i] = diff.DiffLine{NewNum: i + 1, Content: "line", ChangeType: diff.ChangeContext} + } + const overlap = 5 + + t.Run("full page subtracts overlap", func(t *testing.T) { + model := scrollDiffPageModel(t, lines, paneTree, "map pgdown scroll_diff_page_down") + model.modes.pageOverlap = overlap + height := model.layout.viewport.Height + require.Greater(t, height, overlap) + + result, _ := model.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + model = result.(Model) + assert.Equal(t, height-overlap, model.layout.viewport.YOffset) + }) + + t.Run("half page ignores overlap", func(t *testing.T) { + model := scrollDiffPageModel(t, lines, paneTree, "map ctrl+d scroll_diff_half_page_down") + model.modes.pageOverlap = overlap + height := model.layout.viewport.Height + + result, _ := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + model = result.(Model) + assert.Equal(t, max(1, height/2), model.layout.viewport.YOffset) + }) +} + +func TestModel_ScrollDiffPageActionsLeaveTOCSelection(t *testing.T) { + lines := make([]diff.DiffLine, 400) + for i := range lines { + lines[i] = diff.DiffLine{NewNum: i + 1, Content: "text", ChangeType: diff.ChangeContext} + } + lines[0] = diff.DiffLine{NewNum: 1, Content: "# one", ChangeType: diff.ChangeContext} + lines[200] = diff.DiffLine{NewNum: 201, Content: "## two", ChangeType: diff.ChangeContext} + + model := scrollDiffPageModel(t, lines, paneTree, "map pgdown scroll_diff_page_down") + model.file.mdTOC = testParseTOCFactory()(lines, "notes.md") + require.NotNil(t, model.file.mdTOC) + require.Greater(t, model.file.mdTOC.NumEntries(), 1) + before, ok := model.file.mdTOC.CurrentLineIdx() + require.True(t, ok) + + result, _ := model.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + model = result.(Model) + assert.Positive(t, model.layout.viewport.YOffset) + after, ok := model.file.mdTOC.CurrentLineIdx() + require.True(t, ok) + assert.Equal(t, before, after) +} + +// scrollDiffPageModel loads a two-file model with the given pane focused and the given +// keybindings file content parsed through keymap.Load. +func scrollDiffPageModel(t *testing.T, lines []diff.DiffLine, focus pane, mappings string) Model { + t.Helper() + path := filepath.Join(t.TempDir(), "keybindings") + require.NoError(t, os.WriteFile(path, []byte(mappings+"\n"), 0o600)) + km, err := keymap.Load(path) + require.NoError(t, err) + + m := testModel([]string{"a.go", "b.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.tree = testNewFileTree([]string{"a.go", "b.go"}) + model.layout.focus = focus + model.keymap = km + require.Zero(t, model.layout.viewport.YOffset) + require.Equal(t, "a.go", model.tree.SelectedFile()) + return model +} + func BenchmarkModel_PageNavigation(b *testing.B) { lines := make([]diff.DiffLine, 10_000) for i := range lines { diff --git a/plugins/codex/skills/revdiff/references/config.md b/plugins/codex/skills/revdiff/references/config.md index 74e11b7b..f02afc7c 100644 --- a/plugins/codex/skills/revdiff/references/config.md +++ b/plugins/codex/skills/revdiff/references/config.md @@ -160,7 +160,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. diff --git a/site/docs.html b/site/docs.html index d1f6348b..fd0ebbc6 100644 --- a/site/docs.html +++ b/site/docs.html @@ -651,6 +651,13 @@

Custom keybindings

unmap q

Generate a template: revdiff --dump-keys > ~/.config/revdiff/keybindings

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.

map ctrl+w>x mark_reviewed @@ -658,7 +665,7 @@

Chord bindings (ctrl/alt leader)

When the leader is pressed, the status bar shows Pending: ctrl+w, esc to cancel; press the second key to dispatch, or esc to cancel silently. Binding a key as both a standalone action and a chord prefix drops the standalone binding (the chord wins, with a warning). Chord bindings work under non-Latin keyboard layouts — the second-stage key is translated via the same layout-resolve fallback as single-key bindings. Note: chord bindings do not fire during text input (annotation and search prompts) — use single-key ctrl+* bindings for actions like open_editor that need to work during annotation input.

macOS note: alt+* leaders require your terminal to send Option as Meta/Alt. Most terminals default to "Option composes special characters" (e.g. Option+T), in which case Alt chords silently won't fire. To enable: iTerm2 → Profiles → Keys → Left/Right Option key → Esc+; Terminal.app → Profiles → Keyboard → Use Option as Meta key; Kitty → macos_option_as_alt yes; Ghostty → macos-option-as-alt = true. If you'd rather not touch terminal settings, use ctrl+* leaders — those work everywhere with no configuration.

Available actions

-

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

Pane: toggle_pane, focus_tree, focus_diff

Search: search