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: 2 additions & 2 deletions .claude-plugin/skills/revdiff/references/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,6 @@ 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`, `next_item`, `prev_item`, `next_hunk`, `prev_hunk`, `toggle_pane`, `focus_tree`, `focus_diff`, `search`, `confirm`, `annotate_file`, `delete_annotation`, `annot_list`, `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `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`, `next_item`, `prev_item`, `next_hunk`, `prev_hunk`, `toggle_pane`, `focus_tree`, `focus_diff`, `search`, `confirm`, `annotate_file`, `delete_annotation`, `annot_list`, `open_editor`, `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `quit`, `discard_quit`, `help`, `dismiss`

Modal keys (annotation input, search input, confirm discard) are not remappable.
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.
4 changes: 2 additions & 2 deletions .claude-plugin/skills/revdiff/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,10 @@ Use `--stdin` to review arbitrary piped or redirected text as one synthetic file
| `@` | Toggle annotation list popup (navigate and jump to any annotation) |
| `}` / `{` | Jump to next/previous annotation (always crosses file boundaries; silent no-op at the first/last annotation) |
| `d` | Delete annotation under cursor |
| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation |
| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation (`open_editor` — rebindable) |
| `Esc` | Cancel annotation input |

While the annotation input is active, press `Ctrl+E` to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output.
While the annotation input is active, press `Ctrl+E` (or whatever key is bound to `open_editor`) to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output.

**View:**

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -659,10 +659,10 @@ In the Claude Code and Codex plugins, you can also tell the agent to use a past
| `@` | Toggle annotation list popup (navigate and jump to any annotation) |
| `}` / `{` | Jump to next/previous annotation (always crosses file boundaries; silent no-op at the first/last annotation) |
| `d` | Delete annotation under cursor |
| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation |
| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation (`open_editor` — rebindable) |
| `Esc` | Cancel annotation input |

While the annotation input is active, press `Ctrl+E` to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output.
While the annotation input is active, press `Ctrl+E` (or whatever key is bound to `open_editor`) to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output.

**View:**

Expand Down Expand Up @@ -753,7 +753,7 @@ mkdir -p ~/.config/revdiff
revdiff --dump-keys > ~/.config/revdiff/keybindings
```

Then edit to taste. Modal keys (annotation input, search input, confirm discard) are not remappable.
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.

**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 @@ -762,7 +762,7 @@ map ctrl+w>x mark_reviewed
map alt+t>n theme_select
```

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.
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) — the leader key is consumed by the text input widget. 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.

Expand All @@ -777,7 +777,7 @@ When the leader is pressed, the status bar shows `Pending: ctrl+w, esc to cancel

**Search:** `search`

**Annotations:** `confirm` (annotate line / select file), `annotate_file`, `delete_annotation`, `annot_list`, `next_annotation`, `prev_annotation`
**Annotations:** `confirm` (annotate line / select file), `annotate_file`, `delete_annotation`, `annot_list`, `open_editor`, `next_annotation`, `prev_annotation`

**View:** `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`

Expand Down
8 changes: 6 additions & 2 deletions app/keymap/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const (
ActionThemeSelect Action = "theme_select"
ActionInfo Action = "info"
ActionReload Action = "reload"
ActionOpenEditor Action = "open_editor"
)

// SectionPane is the help section name for pane-related keybindings.
Expand All @@ -84,8 +85,9 @@ var validActions = map[Action]bool{
ActionToggleLineNums: true, ActionToggleBlame: true, ActionToggleWordDiff: true, ActionToggleHunk: true,
ActionMarkReviewed: true, ActionFilter: true, ActionToggleUntracked: true,
ActionQuit: true, ActionDiscardQuit: true, ActionHelp: true, ActionDismiss: true, ActionThemeSelect: true,
ActionInfo: true,
ActionReload: true,
ActionInfo: true,
ActionReload: true,
ActionOpenEditor: true,
}

// deprecatedActionAliases maps obsolete action names parsed from user
Expand Down Expand Up @@ -207,6 +209,7 @@ func defaultDescriptions() []HelpEntry {
{ActionAnnotateFile, "annotate file", "Annotations"},
{ActionDeleteAnnotation, "delete annotation", "Annotations"},
{ActionAnnotList, "annotation list", "Annotations"},
{ActionOpenEditor, "open annotation in $EDITOR", "Annotations"},
{ActionNextAnnotation, "next annotation (across files)", "Annotations"},
{ActionPrevAnnotation, "previous annotation (across files)", "Annotations"},

Expand Down Expand Up @@ -263,6 +266,7 @@ func defaultBindings() map[string]Action {
"A": ActionAnnotateFile,
"d": ActionDeleteAnnotation,
"@": ActionAnnotList,
"ctrl+e": ActionOpenEditor,
"}": ActionNextAnnotation,
"{": ActionPrevAnnotation,
"v": ActionToggleCollapsed,
Expand Down
25 changes: 24 additions & 1 deletion app/keymap/keymap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestDefault_allExpectedBindings(t *testing.T) {
{"tab", ActionTogglePane}, {"h", ActionFocusTree}, {"l", ActionFocusDiff},
{"/", ActionSearch},
{"a", ActionConfirm}, {"enter", ActionConfirm},
{"A", ActionAnnotateFile}, {"d", ActionDeleteAnnotation}, {"@", ActionAnnotList},
{"A", ActionAnnotateFile}, {"d", ActionDeleteAnnotation}, {"@", ActionAnnotList}, {"ctrl+e", ActionOpenEditor},
{"}", ActionNextAnnotation}, {"{", ActionPrevAnnotation},
{"v", ActionToggleCollapsed}, {"C", ActionToggleCompact}, {"w", ActionToggleWrap}, {"t", ActionToggleTree},
{"L", ActionToggleLineNums}, {"B", ActionToggleBlame}, {"W", ActionToggleWordDiff},
Expand Down Expand Up @@ -245,6 +245,29 @@ func TestActionToggleCompact_HelpEntry(t *testing.T) {
assert.True(t, found, "ActionToggleCompact should have a help entry")
}

func TestActionOpenEditor_IsValid(t *testing.T) {
assert.True(t, IsValidAction(ActionOpenEditor))
}

func TestActionOpenEditor_DefaultBinding(t *testing.T) {
km := Default()
assert.Equal(t, ActionOpenEditor, km.Resolve("ctrl+e"))
}

func TestActionOpenEditor_HelpEntry(t *testing.T) {
entries := defaultDescriptions()
var found bool
for _, e := range entries {
if e.Action == ActionOpenEditor {
assert.Equal(t, "open annotation in $EDITOR", e.Description)
assert.Equal(t, "Annotations", e.Section)
found = true
break
}
}
assert.True(t, found, "ActionOpenEditor should have a help entry")
}

func TestActionScrollConstants_InNavigationActions(t *testing.T) {
// the three scroll-align actions must be recognized as valid so keybindings
// files can map custom keys to them (e.g. "map z scroll_center").
Expand Down
85 changes: 58 additions & 27 deletions app/ui/annotate.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/umputun/revdiff/app/annotation"
"github.com/umputun/revdiff/app/diff"
"github.com/umputun/revdiff/app/keymap"
"github.com/umputun/revdiff/app/ui/style"
)

Expand Down Expand Up @@ -76,25 +77,30 @@ func (m *Model) startAnnotation() tea.Cmd {
return nil
}

placeholder := "annotation... (Ctrl+E for editor)"
editorKey := m.editorKeyDisplay()
placeholder := "annotation..."
if editorKey != "" {
placeholder = fmt.Sprintf("annotation... (%s for editor)", editorKey)
}

// pre-fill with existing annotation if one exists. multi-line comments are
// NOT set via ti.SetValue because textinput's sanitizer collapses \n to
// space; instead, stash the original in existingMultiline and hint at it
// via the placeholder so Ctrl+E can seed the editor from it and Enter with
// empty input preserves it unchanged.
// via the placeholder so the editor key can seed the editor from it and
// Enter with empty input preserves it unchanged.
lineNum := m.diffLineNum(dl)
var preFill, existingMultiline string
for _, a := range m.store.Get(m.file.name) {
if a.Line == lineNum && a.Type == string(dl.ChangeType) {
if strings.Contains(a.Comment, "\n") {
existingMultiline = a.Comment
placeholder = "[existing multi-line — Ctrl+E to edit]"
} else {
preFill = a.Comment
}
break
if a.Line != lineNum || a.Type != string(dl.ChangeType) {
continue
}
if strings.Contains(a.Comment, "\n") {
existingMultiline = a.Comment
placeholder = m.multiLinePlaceholder()
} else {
preFill = a.Comment
}
break
}

ti, cmd := m.newAnnotationInput(placeholder, 3+lipgloss.Width(m.annotPrefix())) // cursor col + annotation prefix + border margin
Expand Down Expand Up @@ -138,23 +144,28 @@ func (m *Model) startFileAnnotation() tea.Cmd {
return nil
}

placeholder := "file-level annotation... (Ctrl+E for editor)"
editorKey := m.editorKeyDisplay()
placeholder := "file-level annotation..."
if editorKey != "" {
placeholder = fmt.Sprintf("file-level annotation... (%s for editor)", editorKey)
}

// pre-fill with existing file-level annotation if one exists. multi-line
// comments bypass ti.SetValue (textinput sanitizer flattens \n to space);
// instead stash in existingMultiline so Ctrl+E can seed and Enter with empty
// input preserves it unchanged.
// instead stash in existingMultiline so the editor key can seed and Enter
// with empty input preserves it unchanged.
var preFill, existingMultiline string
for _, a := range m.store.Get(m.file.name) {
if a.Line == 0 {
if strings.Contains(a.Comment, "\n") {
existingMultiline = a.Comment
placeholder = "[existing multi-line — Ctrl+E to edit]"
} else {
preFill = a.Comment
}
break
if a.Line != 0 {
continue
}
if strings.Contains(a.Comment, "\n") {
existingMultiline = a.Comment
placeholder = m.multiLinePlaceholder()
} else {
preFill = a.Comment
}
break
}

ti, cmd := m.newAnnotationInput(placeholder, 3+lipgloss.Width(m.annotFilePrefix())) // cursor col + file annotation prefix + border margin
Expand Down Expand Up @@ -312,6 +323,27 @@ func (m *Model) deleteAnnotation() tea.Cmd {
return nil
}

// editorKeyDisplay returns the display name for the open_editor binding
// (e.g. "Ctrl+E") for use in placeholder text. Returns empty when unbound.
// Filters out chord bindings since they don't fire during annotation input.
func (m Model) editorKeyDisplay() string {
keys := m.keymap.KeysFor(keymap.ActionOpenEditor)
var single []string
for _, k := range keys {
if strings.Index(k, ">") <= 0 {
single = append(single, m.displayKeyName(k))
}
}
return strings.Join(single, " / ")
}

func (m Model) multiLinePlaceholder() string {
if key := m.editorKeyDisplay(); key != "" {
return fmt.Sprintf("[existing multi-line — %s to edit]", key)
}
return "[existing multi-line]"
}

// handleAnnotateKey handles key messages during annotation input mode.
func (m Model) handleAnnotateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
Expand All @@ -321,12 +353,11 @@ func (m Model) handleAnnotateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyEsc:
m.cancelAnnotation()
return m, nil
case tea.KeyCtrlE:
// hand off to $EDITOR for multi-line annotation input.
// keep annotating=true so editorFinishedMsg routes back through the annotation flow.
cmd := m.openEditor()
return m, cmd
default:
if m.keymap.Resolve(msg.String()) == keymap.ActionOpenEditor {
cmd := m.openEditor()
return m, cmd
}
var cmd tea.Cmd
m.annot.input, cmd = m.annot.input.Update(msg)
m.layout.viewport.SetContent(m.renderDiff()) // re-render so typed characters are visible immediately
Expand Down
80 changes: 80 additions & 0 deletions app/ui/annotate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/umputun/revdiff/app/annotation"
"github.com/umputun/revdiff/app/diff"
"github.com/umputun/revdiff/app/keymap"
"github.com/umputun/revdiff/app/ui/mocks"
"github.com/umputun/revdiff/app/ui/overlay"
"github.com/umputun/revdiff/app/ui/sidepane"
Expand Down Expand Up @@ -2510,6 +2511,85 @@ func TestModel_AnnotationPlaceholderMentionsEditor(t *testing.T) {
assert.Contains(t, m2.annot.input.Placeholder, "Ctrl+E", "file-level placeholder must mention Ctrl+E")
}

func TestModel_AnnotationPlaceholderRemappedEditor(t *testing.T) {
lines := []diff.DiffLine{{NewNum: 1, Content: "x", ChangeType: diff.ChangeAdd}}
m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines})
m.tree = testNewFileTree([]string{"a.go"})
m.layout.focus = paneDiff
m.file.name = "a.go"
m.file.lines = lines
m.nav.diffCursor = 0

m.keymap.Unbind("ctrl+e")
m.keymap.Bind("ctrl+g", keymap.ActionOpenEditor)

m.startAnnotation()
assert.Contains(t, m.annot.input.Placeholder, "Ctrl+G", "placeholder must reflect remapped key")
assert.NotContains(t, m.annot.input.Placeholder, "Ctrl+E", "placeholder must not mention old key")
}

func TestModel_AnnotationPlaceholderUnboundEditor(t *testing.T) {
lines := []diff.DiffLine{{NewNum: 1, Content: "x", ChangeType: diff.ChangeAdd}}
m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines})
m.tree = testNewFileTree([]string{"a.go"})
m.layout.focus = paneDiff
m.file.name = "a.go"
m.file.lines = lines
m.nav.diffCursor = 0

m.keymap.Unbind("ctrl+e")

m.startAnnotation()
assert.NotContains(t, m.annot.input.Placeholder, "Ctrl+E", "placeholder must not mention editor when unbound")
assert.NotContains(t, m.annot.input.Placeholder, "for editor", "placeholder must not mention editor when unbound")
}

func TestModel_RemappedEditorKeyOpensEditor(t *testing.T) {
lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}}
m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines})
m.tree = testNewFileTree([]string{"a.go"})
m.layout.focus = paneDiff
m.file.name = "a.go"
m.file.lines = lines
m.nav.diffCursor = 0

m.keymap.Unbind("ctrl+e")
m.keymap.Bind("ctrl+g", keymap.ActionOpenEditor)

fake := mockEditor("edited", nil)
m.editor = fake

m.startAnnotation()
m.annot.input.SetValue("seed")

result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
model := result.(Model)
require.NotNil(t, cmd, "remapped ctrl+g should trigger editor")
require.Len(t, fake.CommandCalls(), 1, "editor.Command called once")
assert.Equal(t, "seed", fake.CommandCalls()[0].Content)
assert.True(t, model.annot.annotating)
}

func TestModel_UnboundEditorKeyFallsThrough(t *testing.T) {
lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}}
m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines})
m.tree = testNewFileTree([]string{"a.go"})
m.layout.focus = paneDiff
m.file.name = "a.go"
m.file.lines = lines
m.nav.diffCursor = 0

m.keymap.Unbind("ctrl+e")

fake := mockEditor("edited", nil)
m.editor = fake

m.startAnnotation()

_, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlE})
assert.Empty(t, fake.CommandCalls(), "unbound ctrl+e must not open editor")
}

func TestModel_VisualRowToDiffLine_EmptyFile(t *testing.T) {
m := testModel(nil, nil)
m.file.name = "a.go"
Expand Down
6 changes: 5 additions & 1 deletion app/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ func (m Model) displayKeyName(key string) string {
return d
}
if strings.HasPrefix(key, "ctrl+") {
return "Ctrl+" + key[5:]
suffix := key[5:]
if suffix != "" {
return "Ctrl+" + strings.ToUpper(suffix[:1]) + suffix[1:]
}
return "Ctrl+"
}
return key
}
Expand Down
Loading