make keybinds configurable and consolidate

This commit is contained in:
2026-07-28 14:55:38 +02:00
parent bb8e91039f
commit 948f3e1a79
12 changed files with 1469 additions and 158 deletions

View File

@@ -100,8 +100,99 @@ directory = "" # defaults to the OS user cache directory
[editing] [editing]
mode = "vim" # "vim" or "standard"; description field only for now mode = "vim" # "vim" or "standard"; description field only for now
[keybindings.general]
quit = ["q", "ctrl+c"]
help = ["?", "f1"]
refresh = ["r"]
back = ["b", "esc"]
confirm = ["y"]
reject = ["n", "esc"]
[keybindings.navigation]
# Shared by the picker, dashboard, thread panes, help, and Vim Normal/Visual modes.
down = ["j", "down"]
up = ["k", "up"]
left = ["h", "left"]
right = ["l", "right"]
first = ["g"]
last = ["G"]
page_down = ["ctrl+d", "pgdown"]
page_up = ["ctrl+u", "pgup"]
[keybindings.views]
open = ["enter", "l"]
dashboard = ["d"]
edit = ["e"]
toggle_list = ["tab"]
[keybindings.threads]
search = ["/"]
clear_filter = ["F"]
next_unread = ["n"]
previous_unread = ["N"]
reply = ["c"]
resolve = ["R"]
toggle = ["enter"]
fold_prefix = ["z"]
fold_toggle = ["a"]
[keybindings.input]
cancel = ["esc"]
submit = ["ctrl+s"]
newline = ["enter"]
delete_backward = ["backspace"]
delete_forward = ["delete"]
clear = ["ctrl+u"]
next_field = ["tab"]
previous_field = ["shift+tab"]
next_completion = ["ctrl+n"]
previous_completion = ["ctrl+p"]
line_start = ["home", "ctrl+a"]
line_end = ["end", "ctrl+e"]
[keybindings.vim]
insert = ["i"]
append = ["a"]
insert_line_start = ["I"]
append_line_end = ["A"]
open_below = ["o"]
open_above = ["O"]
replace_character = ["s"]
visual = ["v"]
visual_line = ["V"]
selection_other_end = ["o"]
yank = ["y"]
delete = ["d", "x", "delete"]
delete_before = ["X", "backspace"]
paste = ["p"]
line_start = ["0", "home"]
first_non_blank = ["^"]
line_end = ["$", "end"]
word_forward = ["w"]
big_word_forward = ["W"]
word_backward = ["b"]
big_word_backward = ["B"]
word_end = ["e"]
big_word_end = ["E"]
go_prefix = ["g"]
find_forward = ["f"]
find_backward = ["F"]
till_forward = ["t"]
till_backward = ["T"]
repeat_find = [";"]
repeat_find_reverse = [","]
``` ```
Every command binding accepts one or more Bubble Tea key names. Omitted
settings retain their defaults, while an explicitly configured action replaces
its default keys. Printable keys remain text in Insert mode, reply drafts, and
search queries; command bindings apply in the appropriate non-text context.
The contextual `?` popup and compact screen footers use the configured keys.
Configuration loading also checks each active context independently. A key may
be reused on unrelated screens, but assigning it to two different actions that
can be active together reports the context and both conflicting actions.
When cached data exists, the picker and PR details are rendered immediately When cached data exists, the picker and PR details are rendered immediately
from that snapshot while a live GitHub refresh runs in the background. Cached from that snapshot while a live GitHub refresh runs in the background. Cached
screens are labelled with their save time and are replaced automatically when screens are labelled with their save time and are replaced automatically when
@@ -142,7 +233,7 @@ a written summary retain a compact one-line body, while timestamps and commit
SHAs are omitted. Set `compact_reviews = false` to restore the complete review SHAs are omitted. Set `compact_reviews = false` to restore the complete review
history and metadata. history and metadata.
## Keys ## Default keys
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
@@ -156,7 +247,7 @@ history and metadata.
| `n` / `N` | Next / previous thread with a new update | | `n` / `N` | Next / previous thread with a new update |
| `c` | Compose a reply to the selected thread | | `c` | Compose a reply to the selected thread |
| `R` | Resolve or unresolve the selected thread | | `R` | Resolve or unresolve the selected thread |
| `` / `` | Choose a fuzzy-search match | | `ctrl-p` / `ctrl-n` | Choose the previous / next fuzzy-search or branch-completion match |
| `g` / `G` | First / last item | | `g` / `G` | First / last item |
| `enter` / `l` | Open the selected PR dashboard or its review threads | | `enter` / `l` | Open the selected PR dashboard or its review threads |
| `enter` | Toggle the selected review thread | | `enter` | Toggle the selected review thread |

View File

@@ -177,7 +177,15 @@ func (m App) branchCompletionLines(width int) []string {
if len(suggestions) == 0 { if len(suggestions) == 0 {
return []string{dimStyle.Render(" no matching repository branches")} return []string{dimStyle.Render(" no matching repository branches")}
} }
lines := []string{dimStyle.Render(" ctrl-p/n choose • tab/enter complete • tab again advances")} lines := []string{dimStyle.Render(fmt.Sprintf(
" %s choose • %s complete • %s again advances",
primaryCombinedKeyLabel(
m.keybindings.Input.PreviousCompletion,
m.keybindings.Input.NextCompletion,
),
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.NextField),
))}
now := time.Now() now := time.Now()
for index, suggestion := range suggestions { for index, suggestion := range suggestions {
prefix := " " prefix := " "

View File

@@ -95,7 +95,7 @@ func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testi
m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}} m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}}
view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n")) view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n"))
if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "tab/enter complete") { if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "tab / enter complete") {
t.Fatalf("branch suggestions missing:\n%s", view) t.Fatalf("branch suggestions missing:\n%s", view)
} }
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") { if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") {

View File

@@ -36,6 +36,7 @@ type Config struct {
Threads ThreadConfig `toml:"threads"` Threads ThreadConfig `toml:"threads"`
Cache CacheConfig `toml:"cache"` Cache CacheConfig `toml:"cache"`
Editing EditingConfig `toml:"editing"` Editing EditingConfig `toml:"editing"`
KeyBindings KeyBindings `toml:"keybindings"`
} }
type DisplayConfig struct { type DisplayConfig struct {
@@ -85,8 +86,9 @@ func defaultConfig() Config {
StatusOrder: []string{"unresolved", "outdated", "resolved"}, StatusOrder: []string{"unresolved", "outdated", "resolved"},
WithinStatus: "file", WithinStatus: "file",
}, },
Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}}, Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}},
Editing: EditingConfig{Mode: "vim"}, Editing: EditingConfig{Mode: "vim"},
KeyBindings: defaultKeyBindings(),
} }
} }
@@ -181,6 +183,9 @@ func validateConfig(config Config) error {
default: default:
return fmt.Errorf("editing.mode must be standard or vim") return fmt.Errorf("editing.mode must be standard or vim")
} }
if err := validateKeyBindings(config.KeyBindings); err != nil {
return err
}
return nil return nil
} }

View File

@@ -56,6 +56,10 @@ directory = "/tmp/gh-threads-cache"
[editing] [editing]
mode = "standard" mode = "standard"
[keybindings.navigation]
down = ["ctrl+j"]
up = ["ctrl+k"]
` `
if err := os.WriteFile(path, []byte(content), 0o600); err != nil { if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -76,11 +80,63 @@ mode = "standard"
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled || got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" || got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" ||
got.Editing.Mode != "standard" { got.Editing.Mode != "standard" ||
strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" ||
strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" {
t.Fatalf("config = %#v", got) t.Fatalf("config = %#v", got)
} }
} }
func TestValidateConfigRejectsEmptyKeyBinding(t *testing.T) {
config := defaultConfig()
config.KeyBindings.Navigation.Down = nil
err := validateConfig(config)
if err == nil || !strings.Contains(err.Error(), "keybindings.navigation.down") {
t.Fatalf("empty binding error = %v", err)
}
}
func TestValidateConfigRejectsKeyConflictsInTheSameContext(t *testing.T) {
tests := []struct {
name string
change func(*Config)
context string
actions []string
}{
{
name: "thread navigation and reply",
change: func(config *Config) {
config.KeyBindings.Threads.Reply = []string{"j"}
},
context: "review threads",
actions: []string{"down", "reply"},
},
{
name: "vim motion and cancel",
change: func(config *Config) {
config.KeyBindings.Input.Cancel = []string{"b"}
},
context: "Vim Normal mode",
actions: []string{"cancel", "word_backward"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
config := defaultConfig()
test.change(&config)
err := validateConfig(config)
if err == nil || !strings.Contains(err.Error(), test.context) {
t.Fatalf("conflict error = %v", err)
}
for _, action := range test.actions {
if !strings.Contains(err.Error(), action) {
t.Fatalf("conflict error does not name %q: %v", action, err)
}
}
})
}
}
func TestLoadConfigRejectsUnknownSettings(t *testing.T) { func TestLoadConfigRejectsUnknownSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml") path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil { if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil {

676
keybindings.go Normal file
View File

@@ -0,0 +1,676 @@
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
type KeyBindings struct {
General GeneralKeyBindings `toml:"general"`
Navigation NavigationKeyBindings `toml:"navigation"`
Views ViewKeyBindings `toml:"views"`
Threads ThreadKeyBindings `toml:"threads"`
Input InputKeyBindings `toml:"input"`
Vim VimKeyBindings `toml:"vim"`
}
type GeneralKeyBindings struct {
Quit []string `toml:"quit"`
Help []string `toml:"help"`
Refresh []string `toml:"refresh"`
Back []string `toml:"back"`
Confirm []string `toml:"confirm"`
Reject []string `toml:"reject"`
}
type NavigationKeyBindings struct {
Down []string `toml:"down"`
Up []string `toml:"up"`
Left []string `toml:"left"`
Right []string `toml:"right"`
First []string `toml:"first"`
Last []string `toml:"last"`
PageDown []string `toml:"page_down"`
PageUp []string `toml:"page_up"`
}
type ViewKeyBindings struct {
Open []string `toml:"open"`
Dashboard []string `toml:"dashboard"`
Edit []string `toml:"edit"`
ToggleList []string `toml:"toggle_list"`
}
type ThreadKeyBindings struct {
Search []string `toml:"search"`
ClearFilter []string `toml:"clear_filter"`
NextUnread []string `toml:"next_unread"`
PreviousUnread []string `toml:"previous_unread"`
Reply []string `toml:"reply"`
Resolve []string `toml:"resolve"`
Toggle []string `toml:"toggle"`
FoldPrefix []string `toml:"fold_prefix"`
FoldToggle []string `toml:"fold_toggle"`
}
type InputKeyBindings struct {
Cancel []string `toml:"cancel"`
Submit []string `toml:"submit"`
Newline []string `toml:"newline"`
DeleteBackward []string `toml:"delete_backward"`
DeleteForward []string `toml:"delete_forward"`
Clear []string `toml:"clear"`
NextField []string `toml:"next_field"`
PreviousField []string `toml:"previous_field"`
NextCompletion []string `toml:"next_completion"`
PreviousCompletion []string `toml:"previous_completion"`
LineStart []string `toml:"line_start"`
LineEnd []string `toml:"line_end"`
}
type VimKeyBindings struct {
Insert []string `toml:"insert"`
Append []string `toml:"append"`
InsertLineStart []string `toml:"insert_line_start"`
AppendLineEnd []string `toml:"append_line_end"`
OpenBelow []string `toml:"open_below"`
OpenAbove []string `toml:"open_above"`
ReplaceCharacter []string `toml:"replace_character"`
Visual []string `toml:"visual"`
VisualLine []string `toml:"visual_line"`
SelectionOtherEnd []string `toml:"selection_other_end"`
Yank []string `toml:"yank"`
Delete []string `toml:"delete"`
DeleteBefore []string `toml:"delete_before"`
Paste []string `toml:"paste"`
LineStart []string `toml:"line_start"`
FirstNonBlank []string `toml:"first_non_blank"`
LineEnd []string `toml:"line_end"`
WordForward []string `toml:"word_forward"`
WORDForward []string `toml:"big_word_forward"`
WordBackward []string `toml:"word_backward"`
WORDBackward []string `toml:"big_word_backward"`
WordEnd []string `toml:"word_end"`
WORDEnd []string `toml:"big_word_end"`
GoPrefix []string `toml:"go_prefix"`
FindForward []string `toml:"find_forward"`
FindBackward []string `toml:"find_backward"`
TillForward []string `toml:"till_forward"`
TillBackward []string `toml:"till_backward"`
RepeatFind []string `toml:"repeat_find"`
RepeatFindReverse []string `toml:"repeat_find_reverse"`
}
func defaultKeyBindings() KeyBindings {
return KeyBindings{
General: GeneralKeyBindings{
Quit: []string{"q", "ctrl+c"}, Help: []string{"?", "f1"},
Refresh: []string{"r"}, Back: []string{"b", "esc"},
Confirm: []string{"y"}, Reject: []string{"n", "esc"},
},
Navigation: NavigationKeyBindings{
Down: []string{"j", "down"}, Up: []string{"k", "up"},
Left: []string{"h", "left"}, Right: []string{"l", "right"},
First: []string{"g"}, Last: []string{"G"},
PageDown: []string{"ctrl+d", "pgdown"}, PageUp: []string{"ctrl+u", "pgup"},
},
Views: ViewKeyBindings{
Open: []string{"enter", "l"}, Dashboard: []string{"d"},
Edit: []string{"e"}, ToggleList: []string{"tab"},
},
Threads: ThreadKeyBindings{
Search: []string{"/"}, ClearFilter: []string{"F"},
NextUnread: []string{"n"}, PreviousUnread: []string{"N"},
Reply: []string{"c"}, Resolve: []string{"R"}, Toggle: []string{"enter"},
FoldPrefix: []string{"z"}, FoldToggle: []string{"a"},
},
Input: InputKeyBindings{
Cancel: []string{"esc"}, Submit: []string{"ctrl+s"}, Newline: []string{"enter"},
DeleteBackward: []string{"backspace"}, DeleteForward: []string{"delete"},
Clear: []string{"ctrl+u"}, NextField: []string{"tab"},
PreviousField: []string{"shift+tab"}, NextCompletion: []string{"ctrl+n"},
PreviousCompletion: []string{"ctrl+p"},
LineStart: []string{"home", "ctrl+a"}, LineEnd: []string{"end", "ctrl+e"},
},
Vim: VimKeyBindings{
Insert: []string{"i"}, Append: []string{"a"},
InsertLineStart: []string{"I"}, AppendLineEnd: []string{"A"},
OpenBelow: []string{"o"}, OpenAbove: []string{"O"},
ReplaceCharacter: []string{"s"}, Visual: []string{"v"}, VisualLine: []string{"V"},
SelectionOtherEnd: []string{"o"}, Yank: []string{"y"},
Delete: []string{"d", "x", "delete"}, DeleteBefore: []string{"X", "backspace"},
Paste: []string{"p"}, LineStart: []string{"0", "home"},
FirstNonBlank: []string{"^"}, LineEnd: []string{"$", "end"},
WordForward: []string{"w"}, WORDForward: []string{"W"},
WordBackward: []string{"b"}, WORDBackward: []string{"B"},
WordEnd: []string{"e"}, WORDEnd: []string{"E"}, GoPrefix: []string{"g"},
FindForward: []string{"f"}, FindBackward: []string{"F"},
TillForward: []string{"t"}, TillBackward: []string{"T"},
RepeatFind: []string{";"}, RepeatFindReverse: []string{","},
},
}
}
func keyMatches(key string, bindings []string) bool {
for _, binding := range bindings {
if key == binding {
return true
}
}
return false
}
func keyLabel(bindings []string) string {
return strings.Join(bindings, " / ")
}
func primaryKeyLabel(bindings []string) string {
if len(bindings) == 0 {
return ""
}
return bindings[0]
}
func primaryCombinedKeyLabel(groups ...[]string) string {
keys := make([]string, 0, len(groups))
for _, group := range groups {
if key := primaryKeyLabel(group); key != "" {
keys = append(keys, key)
}
}
return strings.Join(keys, " / ")
}
func primarySequenceKeyLabel(prefixes, suffixes []string) string {
return primaryKeyLabel(prefixes) + primaryKeyLabel(suffixes)
}
func combinedKeyLabel(groups ...[]string) string {
var keys []string
for _, group := range groups {
keys = append(keys, group...)
}
return keyLabel(keys)
}
func sequenceKeyLabel(prefixes, suffixes []string) string {
var sequences []string
for _, prefix := range prefixes {
for _, suffix := range suffixes {
sequences = append(sequences, prefix+suffix)
}
}
return keyLabel(sequences)
}
func (k KeyBindings) canonicalHelpKey(key string) string {
switch {
case keyMatches(key, k.General.Quit):
return "ctrl+c"
case keyMatches(key, k.General.Help), keyMatches(key, k.General.Back):
return "esc"
case keyMatches(key, k.Navigation.Down):
return "j"
case keyMatches(key, k.Navigation.Up):
return "k"
case keyMatches(key, k.Navigation.First):
return "g"
case keyMatches(key, k.Navigation.Last):
return "G"
case keyMatches(key, k.Navigation.PageDown):
return "ctrl+d"
case keyMatches(key, k.Navigation.PageUp):
return "ctrl+u"
default:
return ""
}
}
func (k KeyBindings) canonicalSearchKey(key string) string {
switch {
case keyMatches(key, k.Input.Cancel):
return "esc"
case keyMatches(key, k.Input.Newline):
return "enter"
case keyMatches(key, k.Input.PreviousCompletion):
return "up"
case keyMatches(key, k.Input.NextCompletion):
return "down"
case keyMatches(key, k.Input.DeleteBackward):
return "backspace"
case keyMatches(key, k.Input.Clear):
return "ctrl+u"
default:
return ""
}
}
func (k KeyBindings) canonicalWriteKey(key string) string {
switch {
case keyMatches(key, k.Input.Cancel):
return "esc"
case keyMatches(key, k.General.Confirm):
return "y"
case keyMatches(key, k.General.Reject):
return "n"
case keyMatches(key, k.Input.Submit):
return "ctrl+s"
case keyMatches(key, k.Input.Newline):
return "enter"
case keyMatches(key, k.Input.DeleteBackward):
return "backspace"
default:
return ""
}
}
func (k KeyBindings) canonicalMainKey(key string, current screen) string {
switch {
case keyMatches(key, k.General.Quit):
return "q"
case keyMatches(key, k.General.Help):
return "?"
case keyMatches(key, k.General.Refresh):
return "r"
case keyMatches(key, k.General.Back):
return "esc"
case keyMatches(key, k.Navigation.Down):
return "j"
case keyMatches(key, k.Navigation.Up):
return "k"
case keyMatches(key, k.Navigation.First):
return "g"
case keyMatches(key, k.Navigation.Last):
return "G"
case keyMatches(key, k.Navigation.PageDown):
return "ctrl+d"
case keyMatches(key, k.Navigation.PageUp):
return "ctrl+u"
}
if current == prScreen || current == dashboardScreen {
if keyMatches(key, k.Views.Open) {
return "enter"
}
}
if current == threadScreen {
switch {
case keyMatches(key, k.Navigation.Left):
return "h"
case keyMatches(key, k.Navigation.Right):
return "l"
case keyMatches(key, k.Views.ToggleList):
return "tab"
case keyMatches(key, k.Threads.Search):
return "/"
case keyMatches(key, k.Threads.ClearFilter):
return "F"
case keyMatches(key, k.Threads.NextUnread):
return "n"
case keyMatches(key, k.Threads.PreviousUnread):
return "N"
case keyMatches(key, k.Threads.Reply):
return "c"
case keyMatches(key, k.Threads.Resolve):
return "R"
case keyMatches(key, k.Threads.Toggle):
return "enter"
case keyMatches(key, k.Threads.FoldPrefix):
return "z"
}
}
switch {
case keyMatches(key, k.Views.Dashboard):
return "d"
case keyMatches(key, k.Views.Edit):
return "e"
default:
return ""
}
}
func (k KeyBindings) canonicalPREditKey(key string, field int, confirming bool) string {
if confirming {
switch {
case keyMatches(key, k.General.Confirm):
return "y"
case keyMatches(key, k.General.Reject), keyMatches(key, k.Input.Cancel):
return "esc"
}
}
switch {
case keyMatches(key, k.Input.Cancel):
return "esc"
case keyMatches(key, k.Input.Submit):
return "ctrl+s"
case keyMatches(key, k.Input.NextField):
return "tab"
case keyMatches(key, k.Input.PreviousField):
return "shift+tab"
case keyMatches(key, k.Input.NextCompletion):
return "ctrl+n"
case keyMatches(key, k.Input.PreviousCompletion):
return "ctrl+p"
case keyMatches(key, k.Navigation.PageDown):
return "ctrl+d"
case keyMatches(key, k.Navigation.PageUp):
return "ctrl+u"
}
if field != prEditBodyField {
switch {
case keyMatches(key, k.Input.Newline):
return "enter"
}
}
return ""
}
func validateKeyBindings(bindings KeyBindings) error {
groups := []struct {
name string
values map[string][]string
}{
{"keybindings.general", map[string][]string{
"quit": bindings.General.Quit, "help": bindings.General.Help,
"refresh": bindings.General.Refresh, "back": bindings.General.Back,
"confirm": bindings.General.Confirm, "reject": bindings.General.Reject,
}},
{"keybindings.navigation", map[string][]string{
"down": bindings.Navigation.Down, "up": bindings.Navigation.Up,
"left": bindings.Navigation.Left, "right": bindings.Navigation.Right,
"first": bindings.Navigation.First, "last": bindings.Navigation.Last,
"page_down": bindings.Navigation.PageDown, "page_up": bindings.Navigation.PageUp,
}},
{"keybindings.views", map[string][]string{
"open": bindings.Views.Open, "dashboard": bindings.Views.Dashboard,
"edit": bindings.Views.Edit, "toggle_list": bindings.Views.ToggleList,
}},
{"keybindings.threads", map[string][]string{
"search": bindings.Threads.Search, "clear_filter": bindings.Threads.ClearFilter,
"next_unread": bindings.Threads.NextUnread,
"previous_unread": bindings.Threads.PreviousUnread,
"reply": bindings.Threads.Reply, "resolve": bindings.Threads.Resolve,
"toggle": bindings.Threads.Toggle, "fold_prefix": bindings.Threads.FoldPrefix,
"fold_toggle": bindings.Threads.FoldToggle,
}},
{"keybindings.input", map[string][]string{
"cancel": bindings.Input.Cancel,
"submit": bindings.Input.Submit, "newline": bindings.Input.Newline,
"delete_backward": bindings.Input.DeleteBackward,
"delete_forward": bindings.Input.DeleteForward, "clear": bindings.Input.Clear,
"next_field": bindings.Input.NextField, "previous_field": bindings.Input.PreviousField,
"next_completion": bindings.Input.NextCompletion,
"previous_completion": bindings.Input.PreviousCompletion,
"line_start": bindings.Input.LineStart, "line_end": bindings.Input.LineEnd,
}},
{"keybindings.vim", map[string][]string{
"insert": bindings.Vim.Insert, "append": bindings.Vim.Append,
"insert_line_start": bindings.Vim.InsertLineStart,
"append_line_end": bindings.Vim.AppendLineEnd,
"open_below": bindings.Vim.OpenBelow, "open_above": bindings.Vim.OpenAbove,
"replace_character": bindings.Vim.ReplaceCharacter,
"visual": bindings.Vim.Visual, "visual_line": bindings.Vim.VisualLine,
"selection_other_end": bindings.Vim.SelectionOtherEnd,
"yank": bindings.Vim.Yank, "delete": bindings.Vim.Delete,
"delete_before": bindings.Vim.DeleteBefore, "paste": bindings.Vim.Paste,
"line_start": bindings.Vim.LineStart,
"first_non_blank": bindings.Vim.FirstNonBlank, "line_end": bindings.Vim.LineEnd,
"word_forward": bindings.Vim.WordForward, "big_word_forward": bindings.Vim.WORDForward,
"word_backward": bindings.Vim.WordBackward, "big_word_backward": bindings.Vim.WORDBackward,
"word_end": bindings.Vim.WordEnd, "big_word_end": bindings.Vim.WORDEnd,
"go_prefix": bindings.Vim.GoPrefix,
"find_forward": bindings.Vim.FindForward, "find_backward": bindings.Vim.FindBackward,
"till_forward": bindings.Vim.TillForward, "till_backward": bindings.Vim.TillBackward,
"repeat_find": bindings.Vim.RepeatFind,
"repeat_find_reverse": bindings.Vim.RepeatFindReverse,
}},
}
for _, group := range groups {
for action, keys := range group.values {
if len(keys) == 0 {
return fmt.Errorf("%s.%s must contain at least one key", group.name, action)
}
seen := make(map[string]bool)
for _, key := range keys {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("%s.%s contains an empty key", group.name, action)
}
if seen[key] {
return fmt.Errorf("%s.%s contains duplicate key %q", group.name, action, key)
}
seen[key] = true
}
}
}
return validateKeyBindingContexts(bindings)
}
type contextBinding struct {
action string
keys []string
}
func validateKeyBindingContexts(bindings KeyBindings) error {
navigation := bindings.Navigation
general := bindings.General
views := bindings.Views
threads := bindings.Threads
input := bindings.Input
vim := bindings.Vim
screenCommon := []contextBinding{
{"quit", general.Quit}, {"help", general.Help}, {"refresh", general.Refresh},
{"back", general.Back}, {"down", navigation.Down}, {"up", navigation.Up},
{"first", navigation.First}, {"last", navigation.Last},
{"page_down", navigation.PageDown}, {"page_up", navigation.PageUp},
}
if err := validateKeyContext("pull request list", append(screenCommon,
contextBinding{"open", views.Open},
contextBinding{"dashboard", views.Dashboard},
)...); err != nil {
return err
}
if err := validateKeyContext("dashboard", append(screenCommon,
contextBinding{"open", views.Open},
contextBinding{"edit", views.Edit},
)...); err != nil {
return err
}
if err := validateKeyContext("review threads", append(screenCommon,
contextBinding{"left", navigation.Left},
contextBinding{"right", navigation.Right},
contextBinding{"toggle_list", views.ToggleList},
contextBinding{"dashboard", views.Dashboard},
contextBinding{"search", threads.Search},
contextBinding{"clear_filter", threads.ClearFilter},
contextBinding{"next_unread", threads.NextUnread},
contextBinding{"previous_unread", threads.PreviousUnread},
contextBinding{"reply", threads.Reply},
contextBinding{"resolve", threads.Resolve},
contextBinding{"toggle", threads.Toggle},
contextBinding{"fold_prefix", threads.FoldPrefix},
)...); err != nil {
return err
}
if err := validateKeyContext("help popup",
contextBinding{"quit", general.Quit},
contextBinding{"close_help", appendCopy(general.Help, general.Back...)},
contextBinding{"down", navigation.Down},
contextBinding{"up", navigation.Up},
contextBinding{"first", navigation.First},
contextBinding{"last", navigation.Last},
contextBinding{"page_down", navigation.PageDown},
contextBinding{"page_up", navigation.PageUp},
); err != nil {
return err
}
if err := validateKeyContext("search input",
contextBinding{"quit", nonTextBindings(general.Quit)},
contextBinding{"cancel", input.Cancel},
contextBinding{"apply", input.Newline},
contextBinding{"previous_completion", input.PreviousCompletion},
contextBinding{"next_completion", input.NextCompletion},
contextBinding{"delete_backward", input.DeleteBackward},
contextBinding{"clear", input.Clear},
); err != nil {
return err
}
if err := validateKeyContext("reply input",
contextBinding{"quit", nonTextBindings(general.Quit)},
contextBinding{"cancel", input.Cancel},
contextBinding{"submit", input.Submit},
contextBinding{"newline", input.Newline},
contextBinding{"delete_backward", input.DeleteBackward},
); err != nil {
return err
}
if err := validateKeyContext("confirmation",
contextBinding{"quit", nonTextBindings(general.Quit)},
contextBinding{"confirm", general.Confirm},
contextBinding{"cancel", appendCopy(general.Reject, input.Cancel...)},
); err != nil {
return err
}
editorOuter := []contextBinding{
{"help", general.Help},
{"cancel", input.Cancel},
{"submit", input.Submit},
{"next_field", input.NextField},
{"previous_field", input.PreviousField},
{"page_down", navigation.PageDown},
{"page_up", navigation.PageUp},
}
normalEditor := append(append([]contextBinding(nil), editorOuter...),
contextBinding{"left", navigation.Left},
contextBinding{"down", navigation.Down},
contextBinding{"up", navigation.Up},
contextBinding{"right", navigation.Right},
contextBinding{"last", navigation.Last},
contextBinding{"insert", vim.Insert},
contextBinding{"append", vim.Append},
contextBinding{"insert_line_start", vim.InsertLineStart},
contextBinding{"append_line_end", vim.AppendLineEnd},
contextBinding{"open_below", vim.OpenBelow},
contextBinding{"open_above", vim.OpenAbove},
contextBinding{"replace_character", vim.ReplaceCharacter},
contextBinding{"visual", vim.Visual},
contextBinding{"visual_line", vim.VisualLine},
contextBinding{"paste", vim.Paste},
contextBinding{"line_start", vim.LineStart},
contextBinding{"first_non_blank", vim.FirstNonBlank},
contextBinding{"line_end", vim.LineEnd},
contextBinding{"word_forward", vim.WordForward},
contextBinding{"big_word_forward", vim.WORDForward},
contextBinding{"word_backward", vim.WordBackward},
contextBinding{"big_word_backward", vim.WORDBackward},
contextBinding{"word_end", vim.WordEnd},
contextBinding{"big_word_end", vim.WORDEnd},
contextBinding{"go_prefix", vim.GoPrefix},
contextBinding{"find_forward", vim.FindForward},
contextBinding{"find_backward", vim.FindBackward},
contextBinding{"till_forward", vim.TillForward},
contextBinding{"till_backward", vim.TillBackward},
contextBinding{"repeat_find", vim.RepeatFind},
contextBinding{"repeat_find_reverse", vim.RepeatFindReverse},
contextBinding{"delete", vim.Delete},
contextBinding{"delete_before", vim.DeleteBefore},
)
if err := validateKeyContext("Vim Normal mode", normalEditor...); err != nil {
return err
}
visualEditor := append(append([]contextBinding(nil), editorOuter...),
contextBinding{"left", navigation.Left},
contextBinding{"down", navigation.Down},
contextBinding{"up", navigation.Up},
contextBinding{"right", navigation.Right},
contextBinding{"last", navigation.Last},
contextBinding{"visual", vim.Visual},
contextBinding{"visual_line", vim.VisualLine},
contextBinding{"selection_other_end", vim.SelectionOtherEnd},
contextBinding{"yank", vim.Yank},
contextBinding{"delete", vim.Delete},
contextBinding{"paste", vim.Paste},
contextBinding{"line_start", vim.LineStart},
contextBinding{"first_non_blank", vim.FirstNonBlank},
contextBinding{"line_end", vim.LineEnd},
contextBinding{"word_forward", vim.WordForward},
contextBinding{"big_word_forward", vim.WORDForward},
contextBinding{"word_backward", vim.WordBackward},
contextBinding{"big_word_backward", vim.WORDBackward},
contextBinding{"word_end", vim.WordEnd},
contextBinding{"big_word_end", vim.WORDEnd},
contextBinding{"go_prefix", vim.GoPrefix},
contextBinding{"find_forward", vim.FindForward},
contextBinding{"find_backward", vim.FindBackward},
contextBinding{"till_forward", vim.TillForward},
contextBinding{"till_backward", vim.TillBackward},
contextBinding{"repeat_find", vim.RepeatFind},
contextBinding{"repeat_find_reverse", vim.RepeatFindReverse},
)
if err := validateKeyContext("Vim Visual mode", visualEditor...); err != nil {
return err
}
insertEditor := append(append([]contextBinding(nil), editorOuter...),
contextBinding{"left", nonTextBindings(navigation.Left)},
contextBinding{"down", nonTextBindings(navigation.Down)},
contextBinding{"up", nonTextBindings(navigation.Up)},
contextBinding{"right", nonTextBindings(navigation.Right)},
contextBinding{"newline", input.Newline},
contextBinding{"delete_backward", input.DeleteBackward},
contextBinding{"delete_forward", input.DeleteForward},
contextBinding{"line_start", input.LineStart},
contextBinding{"line_end", input.LineEnd},
)
if err := validateKeyContext("editor Insert mode", insertEditor...); err != nil {
return err
}
return validateKeyContext("single-line editor input",
contextBinding{"help", nonTextBindings(general.Help)},
contextBinding{"cancel", input.Cancel},
contextBinding{"submit", input.Submit},
contextBinding{"next_field", input.NextField},
contextBinding{"previous_field", input.PreviousField},
contextBinding{"previous_completion", input.PreviousCompletion},
contextBinding{"next_completion", input.NextCompletion},
contextBinding{"newline", input.Newline},
contextBinding{"delete_backward", input.DeleteBackward},
contextBinding{"delete_forward", input.DeleteForward},
contextBinding{"line_start", input.LineStart},
contextBinding{"line_end", input.LineEnd},
contextBinding{"up", nonTextBindings(navigation.Up)},
contextBinding{"down", nonTextBindings(navigation.Down)},
)
}
func validateKeyContext(context string, bindings ...contextBinding) error {
assigned := make(map[string]string)
for _, binding := range bindings {
for _, key := range binding.keys {
if previous, exists := assigned[key]; exists && previous != binding.action {
return fmt.Errorf(
"keybinding conflict in %s: key %q is assigned to both %s and %s",
context, key, previous, binding.action,
)
}
assigned[key] = binding.action
}
}
return nil
}
func nonTextBindings(bindings []string) []string {
var filtered []string
for _, binding := range bindings {
if utf8.RuneCountInString(binding) != 1 {
filtered = append(filtered, binding)
}
}
return filtered
}
func appendCopy(bindings []string, more ...string) []string {
result := append([]string(nil), bindings...)
return append(result, more...)
}

View File

@@ -139,6 +139,7 @@ func main() {
ThreadStatusOrder: config.Threads.StatusOrder, ThreadStatusOrder: config.Threads.StatusOrder,
ThreadWithinStatus: config.Threads.WithinStatus, ThreadWithinStatus: config.Threads.WithinStatus,
EditorMode: config.Editing.Mode, EditorMode: config.Editing.Mode,
KeyBindings: config.KeyBindings,
}, },
) )
cursorOutput := newTerminalCursorOutput(os.Stdout) cursorOutput := newTerminalCursorOutput(os.Stdout)

View File

@@ -34,6 +34,7 @@ func (m *App) startPREdit() tea.Cmd {
m.prEditEditors[prEditBodyField].highlightMarkdown = true m.prEditEditors[prEditBodyField].highlightMarkdown = true
for index := range m.prEditEditors { for index := range m.prEditEditors {
m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil
m.prEditEditors[index].keys = m.keybindings
} }
m.prEditOriginal = m.currentPRMetadata() m.prEditOriginal = m.currentPRMetadata()
m.prEditBranches = nil m.prEditBranches = nil
@@ -83,7 +84,18 @@ func (m App) pullRequestUpdateUnavailable() string {
} }
func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
k := key.String() k := m.keybindings.canonicalPREditKey(
key.String(), m.prEditField, m.writeMode == writePREditConfirm,
)
if m.prEditField != prEditBodyField &&
key.Type != tea.KeyRunes && key.Type != tea.KeySpace {
switch {
case keyMatches(key.String(), m.keybindings.Navigation.Up):
k = "up"
case keyMatches(key.String(), m.keybindings.Navigation.Down):
k = "down"
}
}
editorWidth := m.prEditEditorWidth() editorWidth := m.prEditEditorWidth()
if m.writeMode == writePREditConfirm { if m.writeMode == writePREditConfirm {
switch k { switch k {
@@ -375,17 +387,6 @@ func (m App) dashboardEditLayout() ([]string, int) {
appendField("title", prEditTitleField) appendField("title", prEditTitleField)
appendField("target branch", prEditBaseField) appendField("target branch", prEditBaseField)
appendField("description", prEditBodyField) appendField("description", prEditBodyField)
help := "tab/shift-tab field • ctrl-s review • esc normal/cancel"
if m.prEditEditors[prEditBodyField].Modal {
help += " • i/a/s insert • v/V select • y copy • p paste • hjkl/wWbBeE move • ctrl-d/u page • fFtT + ; find"
} else {
help += " • arrows/home/end move • ctrl-d/u page • enter newline"
}
wrapped := ansi.Hardwrap(ansi.Wordwrap(help, width, ""), width, false)
lines = append(lines, "")
for _, line := range strings.Split(wrapped, "\n") {
lines = append(lines, dimStyle.Render(line))
}
return lines, cursorLine return lines, cursorLine
} }
@@ -467,6 +468,10 @@ func (m App) prEditConfirmationLines(width int) []string {
len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)), len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)),
), "") ), "")
} }
lines = append(lines, warnStyle.Render("y submit • n/esc continue editing")) lines = append(lines, warnStyle.Render(fmt.Sprintf(
"%s submit • %s continue editing",
primaryKeyLabel(m.keybindings.General.Confirm),
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
)))
return lines return lines
} }

View File

@@ -39,12 +39,13 @@ type textEditor struct {
err error err error
hardwareCursor bool hardwareCursor bool
highlightMarkdown bool highlightMarkdown bool
keys KeyBindings
} }
func newTextEditor(text string, modal bool) textEditor { func newTextEditor(text string, modal bool) textEditor {
editor := textEditor{ editor := textEditor{
Text: text, Modal: modal, Mode: textEditorInsert, Text: text, Modal: modal, Mode: textEditorInsert,
clipboard: systemTextClipboard{}, clipboard: systemTextClipboard{}, keys: defaultKeyBindings(),
} }
editor.Cursor = len([]rune(text)) editor.Cursor = len([]rune(text))
if modal { if modal {
@@ -63,7 +64,7 @@ func (e *textEditor) handleKeyAtWidth(key tea.KeyMsg, multiline bool, wrapWidth
return e.handleStandardKey(key, multiline, wrapWidth) return e.handleStandardKey(key, multiline, wrapWidth)
} }
if e.Mode == textEditorInsert { if e.Mode == textEditorInsert {
if key.String() == "esc" { if keyMatches(key.String(), e.keys.Input.Cancel) {
e.Mode = textEditorNormal e.Mode = textEditorNormal
e.clearPending() e.clearPending()
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
@@ -87,30 +88,35 @@ func (e *textEditor) movePage(delta, wrapWidth int) {
} }
func (e *textEditor) handleStandardKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool { func (e *textEditor) handleStandardKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
switch key.String() { k := key.String()
case "left": switch {
case key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
keyMatches(k, e.keys.Navigation.Left):
e.Cursor = max(0, e.Cursor-1) e.Cursor = max(0, e.Cursor-1)
case "right": case key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
keyMatches(k, e.keys.Navigation.Right):
e.Cursor = min(len([]rune(e.Text)), e.Cursor+1) e.Cursor = min(len([]rune(e.Text)), e.Cursor+1)
case "up": case key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
keyMatches(k, e.keys.Navigation.Up):
if !multiline { if !multiline {
return false return false
} }
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, false) e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, false)
case "down": case key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
keyMatches(k, e.keys.Navigation.Down):
if !multiline { if !multiline {
return false return false
} }
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, false) e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, false)
case "home", "ctrl+a": case keyMatches(k, e.keys.Input.LineStart):
e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth)
case "end", "ctrl+e": case keyMatches(k, e.keys.Input.LineEnd):
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
case "backspace": case keyMatches(k, e.keys.Input.DeleteBackward):
e.deleteBefore() e.deleteBefore()
case "delete": case keyMatches(k, e.keys.Input.DeleteForward):
e.deleteAt() e.deleteAt()
case "enter": case keyMatches(k, e.keys.Input.Newline):
if !multiline { if !multiline {
return false return false
} }
@@ -145,36 +151,37 @@ func (e *textEditor) handleNormalKey(key tea.KeyMsg, multiline bool, wrapWidth i
} }
if e.pendingG { if e.pendingG {
e.pendingG = false e.pendingG = false
if key.String() == "g" { if keyMatches(key.String(), e.keys.Vim.GoPrefix) {
e.Cursor = firstNonBlank(e.Text, 0) e.Cursor = firstNonBlank(e.Text, 0)
return true return true
} }
} }
switch key.String() { k := key.String()
case "esc": switch {
case keyMatches(k, e.keys.Input.Cancel):
e.clearPending() e.clearPending()
return false return false
case "i": case keyMatches(k, e.keys.Vim.Insert):
e.Mode = textEditorInsert e.Mode = textEditorInsert
case "a": case keyMatches(k, e.keys.Vim.Append):
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.Cursor = min(end, e.Cursor+1) e.Cursor = min(end, e.Cursor+1)
e.Mode = textEditorInsert e.Mode = textEditorInsert
case "I": case keyMatches(k, e.keys.Vim.InsertLineStart):
e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth) e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth)
e.Mode = textEditorInsert e.Mode = textEditorInsert
case "A": case keyMatches(k, e.keys.Vim.AppendLineEnd):
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.Mode = textEditorInsert e.Mode = textEditorInsert
case "o": case keyMatches(k, e.keys.Vim.OpenBelow):
if !multiline { if !multiline {
return false return false
} }
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.insert("\n") e.insert("\n")
e.Mode = textEditorInsert e.Mode = textEditorInsert
case "O": case keyMatches(k, e.keys.Vim.OpenAbove):
if !multiline { if !multiline {
return false return false
} }
@@ -183,69 +190,75 @@ func (e *textEditor) handleNormalKey(key tea.KeyMsg, multiline bool, wrapWidth i
e.insert("\n") e.insert("\n")
e.Cursor = start e.Cursor = start
e.Mode = textEditorInsert e.Mode = textEditorInsert
case "h", "left": case keyMatches(k, e.keys.Navigation.Left):
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.Cursor = max(start, e.Cursor-1) e.Cursor = max(start, e.Cursor-1)
case "l", "right": case keyMatches(k, e.keys.Navigation.Right):
e.Cursor = min(normalEditorLineLast(e.Text, e.Cursor, wrapWidth), e.Cursor+1) e.Cursor = min(normalEditorLineLast(e.Text, e.Cursor, wrapWidth), e.Cursor+1)
case "j", "down": case keyMatches(k, e.keys.Navigation.Down):
if multiline { if multiline {
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, true) e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, true)
} }
case "k", "up": case keyMatches(k, e.keys.Navigation.Up):
if multiline { if multiline {
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, true) e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, true)
} }
case "0", "home": case keyMatches(k, e.keys.Vim.LineStart):
e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth)
case "^": case keyMatches(k, e.keys.Vim.FirstNonBlank):
e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth) e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth)
case "$", "end": case keyMatches(k, e.keys.Vim.LineEnd):
e.Cursor = normalEditorLineLast(e.Text, e.Cursor, wrapWidth) e.Cursor = normalEditorLineLast(e.Text, e.Cursor, wrapWidth)
case "w": case keyMatches(k, e.keys.Vim.WordForward):
e.Cursor = nextWordStart(e.Text, e.Cursor, false) e.Cursor = nextWordStart(e.Text, e.Cursor, false)
case "W": case keyMatches(k, e.keys.Vim.WORDForward):
e.Cursor = nextWordStart(e.Text, e.Cursor, true) e.Cursor = nextWordStart(e.Text, e.Cursor, true)
case "b": case keyMatches(k, e.keys.Vim.WordBackward):
e.Cursor = previousWordStart(e.Text, e.Cursor, false) e.Cursor = previousWordStart(e.Text, e.Cursor, false)
case "B": case keyMatches(k, e.keys.Vim.WORDBackward):
e.Cursor = previousWordStart(e.Text, e.Cursor, true) e.Cursor = previousWordStart(e.Text, e.Cursor, true)
case "e": case keyMatches(k, e.keys.Vim.WordEnd):
e.Cursor = wordEndAtWidth(e.Text, e.Cursor, false, wrapWidth) e.Cursor = wordEndAtWidth(e.Text, e.Cursor, false, wrapWidth)
case "E": case keyMatches(k, e.keys.Vim.WORDEnd):
e.Cursor = wordEndAtWidth(e.Text, e.Cursor, true, wrapWidth) e.Cursor = wordEndAtWidth(e.Text, e.Cursor, true, wrapWidth)
case "g": case keyMatches(k, e.keys.Vim.GoPrefix):
e.pendingG = true e.pendingG = true
case "G": case keyMatches(k, e.keys.Navigation.Last):
e.Cursor = firstNonBlank(e.Text, len([]rune(e.Text))) e.Cursor = firstNonBlank(e.Text, len([]rune(e.Text)))
case "v": case keyMatches(k, e.keys.Vim.Visual):
e.startVisual(false) e.startVisual(false)
case "V": case keyMatches(k, e.keys.Vim.VisualLine):
e.startVisual(true) e.startVisual(true)
case "p": case keyMatches(k, e.keys.Vim.Paste):
e.pasteClipboard(false, wrapWidth) e.pasteClipboard(false, wrapWidth)
case "s": case keyMatches(k, e.keys.Vim.ReplaceCharacter):
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
if e.Cursor < end { if e.Cursor < end {
e.deleteAt() e.deleteAt()
} }
e.Mode = textEditorInsert e.Mode = textEditorInsert
case "f", "F", "t", "T": case keyMatches(k, e.keys.Vim.FindForward):
e.pendingFind = []rune(key.String())[0] e.pendingFind = 'f'
case ";": case keyMatches(k, e.keys.Vim.FindBackward):
e.pendingFind = 'F'
case keyMatches(k, e.keys.Vim.TillForward):
e.pendingFind = 't'
case keyMatches(k, e.keys.Vim.TillBackward):
e.pendingFind = 'T'
case keyMatches(k, e.keys.Vim.RepeatFind):
if e.lastFind.valid { if e.lastFind.valid {
e.performFind(e.lastFind.command, e.lastFind.target, false, wrapWidth) e.performFind(e.lastFind.command, e.lastFind.target, false, wrapWidth)
} }
case ",": case keyMatches(k, e.keys.Vim.RepeatFindReverse):
if e.lastFind.valid { if e.lastFind.valid {
e.performFind(reverseFind(e.lastFind.command), e.lastFind.target, false, wrapWidth) e.performFind(reverseFind(e.lastFind.command), e.lastFind.target, false, wrapWidth)
} }
case "x", "delete": case keyMatches(k, e.keys.Vim.Delete):
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
if e.Cursor < end { if e.Cursor < end {
e.deleteAt() e.deleteAt()
} }
case "X", "backspace": case keyMatches(k, e.keys.Vim.DeleteBefore):
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
if e.Cursor > start { if e.Cursor > start {
e.deleteBefore() e.deleteBefore()
@@ -263,25 +276,26 @@ func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth i
e.Mode = textEditorVisual e.Mode = textEditorVisual
return handled return handled
} }
switch key.String() { k := key.String()
case "esc", "v": switch {
case keyMatches(k, e.keys.Input.Cancel), keyMatches(k, e.keys.Vim.Visual):
e.stopVisual() e.stopVisual()
case "V": case keyMatches(k, e.keys.Vim.VisualLine):
if e.visualLine { if e.visualLine {
e.stopVisual() e.stopVisual()
} else { } else {
e.visualLine = true e.visualLine = true
} }
case "o": case keyMatches(k, e.keys.Vim.SelectionOtherEnd):
e.Cursor, e.visualAnchor = e.visualAnchor, e.Cursor e.Cursor, e.visualAnchor = e.visualAnchor, e.Cursor
case "y": case keyMatches(k, e.keys.Vim.Yank):
e.yankSelection(wrapWidth) e.yankSelection(wrapWidth)
case "d", "x", "delete": case keyMatches(k, e.keys.Vim.Delete):
e.deleteSelection(wrapWidth) e.deleteSelection(wrapWidth)
case "p": case keyMatches(k, e.keys.Vim.Paste):
e.pasteClipboard(true, wrapWidth) e.pasteClipboard(true, wrapWidth)
default: default:
if !isVisualMotion(key.String()) { if !e.isVisualMotion(k) {
return false return false
} }
e.Mode = textEditorNormal e.Mode = textEditorNormal
@@ -292,16 +306,24 @@ func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth i
return true return true
} }
func isVisualMotion(key string) bool { func (e textEditor) isVisualMotion(key string) bool {
switch key { groups := [][]string{
case "h", "j", "k", "l", "left", "down", "up", "right", e.keys.Navigation.Left, e.keys.Navigation.Down, e.keys.Navigation.Up,
"0", "^", "$", "home", "end", e.keys.Navigation.Right, e.keys.Navigation.Last,
"w", "W", "b", "B", "e", "E", "g", "G", e.keys.Vim.LineStart, e.keys.Vim.FirstNonBlank, e.keys.Vim.LineEnd,
"f", "F", "t", "T", ";", ",": e.keys.Vim.WordForward, e.keys.Vim.WORDForward,
return true e.keys.Vim.WordBackward, e.keys.Vim.WORDBackward,
default: e.keys.Vim.WordEnd, e.keys.Vim.WORDEnd, e.keys.Vim.GoPrefix,
return false e.keys.Vim.FindForward, e.keys.Vim.FindBackward,
e.keys.Vim.TillForward, e.keys.Vim.TillBackward,
e.keys.Vim.RepeatFind, e.keys.Vim.RepeatFindReverse,
} }
for _, group := range groups {
if keyMatches(key, group) {
return true
}
}
return false
} }
func (e *textEditor) startVisual(linewise bool) { func (e *textEditor) startVisual(linewise bool) {

View File

@@ -16,6 +16,21 @@ type memoryTextClipboard struct {
writeErr error writeErr error
} }
func TestVimEditorUsesSharedConfiguredNavigation(t *testing.T) {
editor := newTextEditor("first\nsecond", true)
editor.keys.Navigation.Down = []string{"ctrl+j"}
editor.keys.Navigation.Up = []string{"ctrl+k"}
editor.handleKey(runeKey("j"), true)
if editor.Cursor != 0 {
t.Fatalf("removed default j moved cursor to %d", editor.Cursor)
}
editor.handleKey(tea.KeyMsg{Type: tea.KeyCtrlJ}, true)
if editor.Cursor != len([]rune("first\n")) {
t.Fatalf("configured ctrl+j moved cursor to %d", editor.Cursor)
}
}
func (c *memoryTextClipboard) ReadText() (string, error) { func (c *memoryTextClipboard) ReadText() (string, error) {
return c.text, c.readErr return c.text, c.readErr
} }

358
tui.go
View File

@@ -135,6 +135,7 @@ type App struct {
dashboardReturn screen dashboardReturn screen
compactReviews bool compactReviews bool
editorMode string editorMode string
keybindings KeyBindings
readState *readStateStore readState *readStateStore
knownThreads map[string]bool knownThreads map[string]bool
knownComments map[string]bool knownComments map[string]bool
@@ -153,6 +154,7 @@ type AppSettings struct {
DashboardMode string DashboardMode string
CompactReviews bool CompactReviews bool
EditorMode string EditorMode string
KeyBindings KeyBindings
ReadState *readStateStore ReadState *readStateStore
} }
@@ -166,6 +168,7 @@ func defaultAppSettings() AppSettings {
DashboardMode: "hotkey", DashboardMode: "hotkey",
CompactReviews: true, CompactReviews: true,
EditorMode: "vim", EditorMode: "vim",
KeyBindings: defaultKeyBindings(),
} }
} }
@@ -195,6 +198,7 @@ func NewAppWithSettings(
dashboardMode: settings.DashboardMode, dashboardReturn: prScreen, dashboardMode: settings.DashboardMode, dashboardReturn: prScreen,
compactReviews: settings.CompactReviews, compactReviews: settings.CompactReviews,
editorMode: settings.EditorMode, editorMode: settings.EditorMode,
keybindings: settings.KeyBindings,
readState: state, readState: state,
knownThreads: make(map[string]bool), knownComments: make(map[string]bool), knownThreads: make(map[string]bool), knownComments: make(map[string]bool),
initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool), initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool),
@@ -328,10 +332,11 @@ func (m App) writeActionUnavailable(action string, thread *ReviewThread) string
} }
func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
k := key.String() if key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
if k == "ctrl+c" { keyMatches(key.String(), m.keybindings.General.Quit) {
return m, tea.Quit return m, tea.Quit
} }
k := m.keybindings.canonicalWriteKey(key.String())
switch m.writeMode { switch m.writeMode {
case writeReply: case writeReply:
switch k { switch k {
@@ -611,10 +616,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
k := key.String() k := key.String()
if m.writeMode != writeNone {
return m.updateWriteInput(key)
}
if m.helpVisible { if m.helpVisible {
k = m.keybindings.canonicalHelpKey(k)
switch k { switch k {
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
@@ -635,7 +638,25 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if m.writeMode == writePREdit || m.writeMode == writePREditConfirm {
editor := m.prEditEditors[m.prEditField]
helpOutsideTextInput := key.Type != tea.KeyRunes && key.Type != tea.KeySpace
helpInVimNormalMode := editor.Modal && editor.Mode == textEditorNormal
if keyMatches(k, m.keybindings.General.Help) &&
(helpOutsideTextInput || helpInVimNormalMode) {
m.helpVisible, m.helpScroll = true, 0
return m, nil
}
}
if m.writeMode != writeNone {
return m.updateWriteInput(key)
}
if m.searching { if m.searching {
if key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
keyMatches(k, m.keybindings.General.Quit) {
return m, tea.Quit
}
k = m.keybindings.canonicalSearchKey(k)
switch k { switch k {
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
@@ -667,6 +688,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
rawKey := k
k = m.keybindings.canonicalMainKey(k, m.screen)
if k == "ctrl+c" || k == "q" { if k == "ctrl+c" || k == "q" {
return m, tea.Quit return m, tea.Quit
} }
@@ -676,7 +699,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if m.pendingZ { if m.pendingZ {
m.pendingZ = false m.pendingZ = false
if k == "a" && m.screen == threadScreen && len(m.details.Threads) > 0 { if keyMatches(rawKey, m.keybindings.Threads.FoldToggle) &&
m.screen == threadScreen && len(m.details.Threads) > 0 {
thread := m.details.Threads[m.threadIndex] thread := m.details.Threads[m.threadIndex]
m.folded[thread.ID] = !m.folded[thread.ID] m.folded[thread.ID] = !m.folded[thread.ID]
m.scroll = 0 m.scroll = 0
@@ -1227,15 +1251,15 @@ func (m App) View() string {
if m.width == 0 { if m.width == 0 {
return "Loading…" return "Loading…"
} }
if m.helpVisible {
return m.viewHelp()
}
if m.writeMode != writeNone && m.writeMode != writeReply { if m.writeMode != writeNone && m.writeMode != writeReply {
if m.writeMode == writePREdit { if m.writeMode == writePREdit {
return m.viewDashboard() return m.viewDashboard()
} }
return m.viewWritePopup() return m.viewWritePopup()
} }
if m.helpVisible {
return m.viewHelp()
}
if m.screen == prScreen { if m.screen == prScreen {
return m.viewPRs() return m.viewPRs()
} }
@@ -1267,11 +1291,20 @@ func (m App) viewWritePopup() string {
if m.err != nil { if m.err != nil {
lines = append(lines, "", badStyle.Render(m.err.Error())) lines = append(lines, "", badStyle.Render(m.err.Error()))
} }
lines = append(lines, "", dimStyle.Render("enter newline • ctrl-s review • esc cancel")) lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
"%s newline • %s review • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)))
case writeReplyConfirm: case writeReplyConfirm:
lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""} lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""}
lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...) lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...)
lines = append(lines, "", warnStyle.Render("y submit • n/esc continue editing")) lines = append(lines, "", warnStyle.Render(fmt.Sprintf(
"%s submit • %s continue editing",
primaryKeyLabel(m.keybindings.General.Confirm),
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
)))
case writeReplyBusy: case writeReplyBusy:
lines = []string{titleStyle.Render("Submitting reply…"), "", dimStyle.Render(location)} lines = []string{titleStyle.Render("Submitting reply…"), "", dimStyle.Render(location)}
case writeResolveConfirm: case writeResolveConfirm:
@@ -1282,7 +1315,11 @@ func (m App) viewWritePopup() string {
lines = []string{ lines = []string{
titleStyle.Render(strings.ToUpper(action[:1]) + action[1:] + " " + location + "?"), titleStyle.Render(strings.ToUpper(action[:1]) + action[1:] + " " + location + "?"),
"", "",
warnStyle.Render("y confirm • n/esc cancel"), warnStyle.Render(fmt.Sprintf(
"%s confirm • %s cancel",
primaryKeyLabel(m.keybindings.General.Confirm),
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
)),
} }
case writeResolveBusy: case writeResolveBusy:
action := "Resolving" action := "Resolving"
@@ -1322,21 +1359,94 @@ type helpBinding struct {
} }
func (m App) helpBindings() []helpBinding { func (m App) helpBindings() []helpBinding {
if m.writeMode == writePREdit || m.writeMode == writePREditConfirm {
bindings := []helpBinding{
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.PreviousField), "Move to the next / previous field"},
{keyLabel(m.keybindings.Input.Submit), "Review pull request metadata changes"},
{keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"},
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"},
{combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next target-branch completion"},
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected target branch"},
}
if m.prEditEditors[prEditBodyField].Modal {
bindings = append(bindings,
helpBinding{combinedKeyLabel(
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
m.keybindings.Navigation.Up, m.keybindings.Navigation.Right,
), "Move left / down / up / right in Normal or Visual mode"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.WordForward, m.keybindings.Vim.WORDForward,
m.keybindings.Vim.WordBackward, m.keybindings.Vim.WORDBackward,
m.keybindings.Vim.WordEnd, m.keybindings.Vim.WORDEnd,
), "Move by words or WORDs"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.LineStart, m.keybindings.Vim.FirstNonBlank,
m.keybindings.Vim.LineEnd,
), "Move to the line start, first non-blank, or line end"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.GoPrefix, m.keybindings.Navigation.Last,
), "Move to the start / end of the description"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.Insert, m.keybindings.Vim.Append,
m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd,
m.keybindings.Vim.ReplaceCharacter,
), "Enter Insert mode"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove,
), "Open a line below / above"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine,
), "Start character-wise / line-wise Visual mode"},
helpBinding{keyLabel(m.keybindings.Vim.SelectionOtherEnd), "Move to the other end of a Visual selection"},
helpBinding{keyLabel(m.keybindings.Vim.Yank), "Copy the Visual selection to the system clipboard"},
helpBinding{keyLabel(m.keybindings.Vim.Paste), "Paste from the system clipboard"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.Delete, m.keybindings.Vim.DeleteBefore,
), "Delete the selection or text at / before the cursor"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.FindForward, m.keybindings.Vim.FindBackward,
m.keybindings.Vim.TillForward, m.keybindings.Vim.TillBackward,
), "Find or move until a character on the current visual line"},
helpBinding{combinedKeyLabel(
m.keybindings.Vim.RepeatFind, m.keybindings.Vim.RepeatFindReverse,
), "Repeat the last character find forward / backward"},
)
} else {
bindings = append(bindings,
helpBinding{combinedKeyLabel(
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
m.keybindings.Navigation.Up, m.keybindings.Navigation.Right,
), "Move the description cursor"},
helpBinding{combinedKeyLabel(
m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd,
), "Move to the start / end of the current visual line"},
helpBinding{keyLabel(m.keybindings.Input.Newline), "Insert a newline"},
helpBinding{combinedKeyLabel(
m.keybindings.Input.DeleteBackward, m.keybindings.Input.DeleteForward,
), "Delete text before / at the cursor"},
)
}
bindings = append(bindings,
helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"},
helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"},
)
return bindings
}
if m.screen == prScreen { if m.screen == prScreen {
openAction := "Open pull request dashboard" openAction := "Open pull request dashboard"
if m.dashboardMode == "hotkey" { if m.dashboardMode == "hotkey" {
openAction = "Open review threads" openAction = "Open review threads"
} }
return []helpBinding{ return []helpBinding{
{"j / ↓", "Next pull request"}, {keyLabel(m.keybindings.Navigation.Down), "Next pull request"},
{"k / ↑", "Previous pull request"}, {keyLabel(m.keybindings.Navigation.Up), "Previous pull request"},
{"g / G", "First / last pull request"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last pull request"},
{"ctrl-d / ctrl-u", "Page down / up"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
{"enter / l", openAction}, {keyLabel(m.keybindings.Views.Open), openAction},
{"d", "Open pull request dashboard"}, {keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"},
{"r", "Refresh now"}, {keyLabel(m.keybindings.General.Refresh), "Refresh now"},
{"?", "Close this help"}, {keyLabel(m.keybindings.General.Help), "Close this help"},
{"q / ctrl-c", "Quit"}, {keyLabel(m.keybindings.General.Quit), "Quit"},
} }
} }
if m.screen == dashboardScreen { if m.screen == dashboardScreen {
@@ -1345,16 +1455,16 @@ func (m App) helpBindings() []helpBinding {
backAction = "Return to review threads" backAction = "Return to review threads"
} }
return []helpBinding{ return []helpBinding{
{"j / ↓", "Scroll description down"}, {keyLabel(m.keybindings.Navigation.Down), "Scroll description down"},
{"k / ↑", "Scroll description up"}, {keyLabel(m.keybindings.Navigation.Up), "Scroll description up"},
{"g / G", "Top / bottom"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"},
{"ctrl-d / ctrl-u", "Page down / up"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
{"e", "Edit title, target branch, and description"}, {keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"},
{"enter / l", "Open review threads"}, {keyLabel(m.keybindings.Views.Open), "Open review threads"},
{"b / esc", backAction}, {keyLabel(m.keybindings.General.Back), backAction},
{"r", "Refresh now"}, {keyLabel(m.keybindings.General.Refresh), "Refresh now"},
{"?", "Close this help"}, {keyLabel(m.keybindings.General.Help), "Close this help"},
{"q / ctrl-c", "Quit"}, {keyLabel(m.keybindings.General.Quit), "Quit"},
} }
} }
backAction := "Return to pull requests" backAction := "Return to pull requests"
@@ -1362,31 +1472,34 @@ func (m App) helpBindings() []helpBinding {
backAction = "Return to PR dashboard" backAction = "Return to PR dashboard"
} }
bindings := []helpBinding{ bindings := []helpBinding{
{"h / l", "Focus thread list / detail"}, {combinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right), "Focus thread list / detail"},
{"j / k", "Move or scroll focused pane"}, {combinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), "Move or scroll focused pane"},
{"↓ / ↑", "Move or scroll focused pane"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last item"},
{"g / G", "First / last item"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
{"ctrl-d / ctrl-u", "Page down / up"}, {keyLabel(m.keybindings.Views.ToggleList), "Hide / reveal thread list"},
{"tab", "Hide / reveal thread list"}, {keyLabel(m.keybindings.Threads.Search), "Fuzzy-search file paths and combine status:, author:, and updated:true filters"},
{"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"}, {keyLabel(m.keybindings.Threads.ClearFilter), "Clear the active thread filter and show every thread"},
{"F", "Clear the active thread filter and show every thread"}, {combinedKeyLabel(m.keybindings.Threads.NextUnread, m.keybindings.Threads.PreviousUnread), "Next / previous new update"},
{"n / N", "Next / previous new update"}, {keyLabel(m.keybindings.Threads.Reply), "Compose a reply to the selected thread"},
{"c", "Compose a reply to the selected thread"}, {keyLabel(m.keybindings.Threads.Resolve), "Resolve or unresolve the selected thread"},
{"R", "Resolve or unresolve the selected thread"}, {keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"},
{"d", "Open pull request dashboard"}, {combinedKeyLabel(
{"enter / za", "Fold / expand thread"}, m.keybindings.Threads.Toggle,
{"b / esc", backAction}, []string{sequenceKeyLabel(m.keybindings.Threads.FoldPrefix, m.keybindings.Threads.FoldToggle)},
{"r", "Refresh now"}, ), "Fold / expand thread"},
{keyLabel(m.keybindings.General.Back), backAction},
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
} }
bindings = append(bindings, bindings = append(bindings,
helpBinding{"?", "Close this help"}, helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"},
helpBinding{"q / ctrl-c", "Quit"}, helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"},
) )
return bindings return bindings
} }
func (m App) helpVisibleRows() int { func (m App) helpVisibleRows() int {
return max(1, m.height-5) // Reserve rows for the popup border, title, title divider, and footer.
return max(1, m.height-6)
} }
func (m App) helpMaxScroll() int { func (m App) helpMaxScroll() int {
@@ -1401,25 +1514,45 @@ func (m App) viewHelp() string {
end := min(len(rows), start+visibleRows) end := min(len(rows), start+visibleRows)
title := "Pull request picker keys" title := "Pull request picker keys"
if m.screen == dashboardScreen { if m.writeMode == writePREdit || m.writeMode == writePREditConfirm {
title = "Pull request editor keys"
} else if m.screen == dashboardScreen {
title = "Pull request dashboard keys" title = "Pull request dashboard keys"
} else if m.screen == threadScreen { } else if m.screen == threadScreen {
title = "Review thread keys" title = "Review thread keys"
} }
lines := []string{titleStyle.Render(title)} contentLines := append([]string(nil), rows[start:end]...)
lines = append(lines, rows[start:end]...)
if len(rows) > visibleRows { if len(rows) > visibleRows {
lines = append(lines, dimStyle.Render(fmt.Sprintf( contentLines = append(contentLines, dimStyle.Render(fmt.Sprintf(
"%d%d of %d • j/k scroll • ?/esc/q close", "%d%d of %d • %s/%s scroll • %s close",
start+1, end, len(rows), start+1, end, len(rows),
primaryKeyLabel(m.keybindings.Navigation.Down),
primaryKeyLabel(m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.General.Help)+"/"+
primaryKeyLabel(m.keybindings.General.Back),
))) )))
} else { } else {
lines = append(lines, dimStyle.Render("?/esc/q close")) contentLines = append(contentLines, dimStyle.Render(
primaryKeyLabel(m.keybindings.General.Help)+"/"+
primaryKeyLabel(m.keybindings.General.Back)+" close",
))
} }
for i := range lines { for i := range contentLines {
lines[i] = ansi.Truncate(lines[i], contentWidth, "") contentLines[i] = ansi.Truncate(contentLines[i], contentWidth, "")
} }
popup := paneStyle(true).Width(contentWidth).Render(strings.Join(lines, "\n")) border := lipgloss.NewStyle().Foreground(paneActiveColor)
boxLines := []string{
border.Render("╭" + strings.Repeat("─", contentWidth) + "╮"),
border.Render("│") + pad(titleStyle.Render(title), contentWidth) + border.Render("│"),
border.Render("├" + strings.Repeat("─", contentWidth) + "┤"),
}
for _, line := range contentLines {
boxLines = append(boxLines,
border.Render("│")+pad(line, contentWidth)+border.Render("│"),
)
}
boxLines = append(boxLines, border.Render("╰"+strings.Repeat("─", contentWidth)+"╯"))
popup := strings.Join(boxLines, "\n")
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup)
} }
@@ -1431,16 +1564,34 @@ func (m App) helpRows(contentWidth int) []string {
keyWidth := min(17, max(8, contentWidth/3)) keyWidth := min(17, max(8, contentWidth/3))
actionWidth := max(1, contentWidth-keyWidth-1) actionWidth := max(1, contentWidth-keyWidth-1)
var rows []string var rows []string
for _, binding := range m.helpBindings() { bindings := m.helpBindings()
wrapped := ansi.Hardwrap(ansi.Wordwrap(binding.action, actionWidth, ""), actionWidth, false) for bindingIndex, binding := range bindings {
actionLines := strings.Split(wrapped, "\n") wrappedKeys := ansi.Hardwrap(
for index, action := range actionLines { ansi.Wordwrap(binding.key, keyWidth, ""),
key := "" keyWidth,
if index == 0 { false,
key = binding.key )
keyLines := strings.Split(wrappedKeys, "\n")
wrappedAction := ansi.Hardwrap(
ansi.Wordwrap(binding.action, actionWidth, ""),
actionWidth,
false,
)
actionLines := strings.Split(wrappedAction, "\n")
lineCount := max(len(keyLines), len(actionLines))
for lineIndex := range lineCount {
key, action := "", ""
if lineIndex < len(keyLines) {
key = keyLines[lineIndex]
}
if lineIndex < len(actionLines) {
action = actionLines[lineIndex]
} }
rows = append(rows, titleStyle.Render(pad(key, keyWidth))+" "+action) rows = append(rows, titleStyle.Render(pad(key, keyWidth))+" "+action)
} }
if bindingIndex < len(bindings)-1 {
rows = append(rows, dimStyle.Render(strings.Repeat("─", contentWidth)))
}
} }
return rows return rows
} }
@@ -1512,9 +1663,22 @@ func (m App) viewPRs() string {
} }
lines = append(lines, line) lines = append(lines, line)
} }
footer := "? keys • j/k move • enter dashboard • q quit" footer := fmt.Sprintf(
"%s keys • %s move • %s dashboard • %s quit",
primaryKeyLabel(m.keybindings.General.Help),
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.Views.Open),
primaryKeyLabel(m.keybindings.General.Quit),
)
if m.dashboardMode == "hotkey" { if m.dashboardMode == "hotkey" {
footer = "? keys • j/k move • enter threads • d dashboard • q quit" footer = fmt.Sprintf(
"%s keys • %s move • %s threads • %s dashboard • %s quit",
primaryKeyLabel(m.keybindings.General.Help),
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.Views.Open),
primaryKeyLabel(m.keybindings.Views.Dashboard),
primaryKeyLabel(m.keybindings.General.Quit),
)
} }
return m.frame(lines, footer) return m.frame(lines, footer)
} }
@@ -1552,9 +1716,22 @@ func (m App) viewDashboard() string {
maxScroll := max(0, len(lines)-viewportHeight) maxScroll := max(0, len(lines)-viewportHeight)
scroll := min(m.scroll, maxScroll) scroll := min(m.scroll, maxScroll)
visible := lines[scroll:min(len(lines), scroll+viewportHeight)] visible := lines[scroll:min(len(lines), scroll+viewportHeight)]
footer := "? keys • j/k scroll • enter threads • b back • q quit" footer := fmt.Sprintf(
"%s keys • %s scroll • %s threads • %s back • %s quit",
primaryKeyLabel(m.keybindings.General.Help),
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.Views.Open),
primaryKeyLabel(m.keybindings.General.Back),
primaryKeyLabel(m.keybindings.General.Quit),
)
if m.writeMode == writePREdit { if m.writeMode == writePREdit {
footer = "tab fields • ctrl-d/u page • v/V select • y copy • p paste • ctrl-s review • esc normal/cancel" footer = fmt.Sprintf(
"%s keys • %s fields • %s review • %s normal/cancel",
primaryKeyLabel(m.keybindings.General.Help),
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.PreviousField),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)
m.positionPREditHardwareCursor(scroll, viewportHeight) m.positionPREditHardwareCursor(scroll, viewportHeight)
} }
view := m.frame(visible, footer) view := m.frame(visible, footer)
@@ -2010,11 +2187,30 @@ func (m App) viewThreads() string {
right := m.threadDetail(rightWidth, contentHeight) right := m.threadDetail(rightWidth, contentHeight)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
} }
help := "? keys • h/l focus • j/k move/scroll • c reply • R resolve • d dashboard • b back • q quit" help := fmt.Sprintf(
"%s keys • %s focus • %s move/scroll • %s reply • %s resolve • %s dashboard • %s back • %s quit",
primaryKeyLabel(m.keybindings.General.Help),
primaryCombinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right),
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.Threads.Reply),
primaryKeyLabel(m.keybindings.Threads.Resolve),
primaryKeyLabel(m.keybindings.Views.Dashboard),
primaryKeyLabel(m.keybindings.General.Back),
primaryKeyLabel(m.keybindings.General.Quit),
)
if m.searching { if m.searching {
help = "path words • status:open • author:name • updated:true • enter apply • esc cancel" help = fmt.Sprintf(
"path words • status:open • author:name • updated:true • %s apply • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Cancel),
)
} else if m.writeMode == writeReply { } else if m.writeMode == writeReply {
help = "reply inline • enter newline • ctrl-s review • esc cancel" help = fmt.Sprintf(
"reply inline • %s newline • %s review • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)
} }
return m.frame(append(top, body), help) return m.frame(append(top, body), help)
} }
@@ -2138,7 +2334,16 @@ func (m App) detailLines(width int) []detailLine {
{anchor: "header", text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)}, {anchor: "header", text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)},
} }
if m.folded[thread.ID] { if m.folded[thread.ID] {
lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render("Thread folded. Press za or enter to expand.")}) lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render(fmt.Sprintf(
"Thread folded. Press %s to expand.",
primaryCombinedKeyLabel(
m.keybindings.Threads.Toggle,
[]string{primarySequenceKeyLabel(
m.keybindings.Threads.FoldPrefix,
m.keybindings.Threads.FoldToggle,
)},
),
))})
} else { } else {
if len(thread.Comments) > 0 { if len(thread.Comments) > 0 {
lines = append(lines, detailLine{}) lines = append(lines, detailLine{})
@@ -2294,7 +2499,12 @@ func (m App) inlineReplyLines(width int) []detailLine {
} }
lines = append(lines, detailLine{ lines = append(lines, detailLine{
rail: rail, rail: rail,
text: dimStyle.Render("enter newline • ctrl-s review • esc cancel"), text: dimStyle.Render(fmt.Sprintf(
"%s newline • %s review • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)),
}) })
return lines return lines
} }

View File

@@ -687,6 +687,82 @@ func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) {
} }
} }
func TestDashboardEditorHelpIsPopupOnlyAndAvailableFromNormalMode(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 70, 24
m.details = PRDetails{
PullRequest: PullRequest{
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
Number: 1, Title: "Title",
},
Body: "description", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
editorContent := ansi.Strip(strings.Join(m.dashboardEditLines(), "\n"))
if strings.Contains(editorContent, "normal/cancel") ||
strings.Contains(editorContent, "copy") ||
strings.Contains(editorContent, "paste") {
t.Fatalf("key guidance leaked into scrollable editor content:\n%s", editorContent)
}
updated, _ := m.Update(runeKey("?"))
m = updated.(App)
if !m.helpVisible {
t.Fatal("? did not open help from Vim Normal mode")
}
help := ansi.Strip(m.View())
allHelpRows := ansi.Strip(strings.Join(m.helpRows(m.helpContentWidth()), "\n"))
if !strings.Contains(help, "Pull request editor keys") ||
!strings.Contains(allHelpRows, "system clipboard") {
t.Fatalf("editor help is missing contextual bindings:\n%s", help)
}
editorBindings := m.helpBindings()
if len(editorBindings) < 3 || editorBindings[2].key != "esc" {
t.Fatalf("editor cancel binding = %#v, want esc", editorBindings)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.helpVisible || m.writeMode != writePREdit {
t.Fatalf("closing help left visible=%v writeMode=%d", m.helpVisible, m.writeMode)
}
}
func TestDashboardEditorQuestionMarkRemainsTextInInsertModeAndF1OpensHelp(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 70, 24
m.details = PRDetails{
PullRequest: PullRequest{
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
Number: 1, Title: "Title",
},
Body: "description", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
updated, _ := m.updatePREditInput(runeKey("i"))
m = updated.(App)
before := m.prEditEditors[prEditBodyField].Text
updated, _ = m.Update(runeKey("?"))
m = updated.(App)
if m.helpVisible || m.prEditEditors[prEditBodyField].Text == before {
t.Fatalf(
"insert-mode ? help=%v text=%q",
m.helpVisible,
m.prEditEditors[prEditBodyField].Text,
)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyF1})
m = updated.(App)
if !m.helpVisible {
t.Fatal("f1 did not open editor help from Insert mode")
}
}
func TestDashboardVimEscapeReturnsToNormalBeforeClosingEditor(t *testing.T) { func TestDashboardVimEscapeReturnsToNormalBeforeClosingEditor(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30 m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
@@ -930,7 +1006,7 @@ func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
m = updated.(App) m = updated.(App)
plain := ansi.Strip(m.View()) plain := ansi.Strip(m.View())
for _, wanted := range []string{"Existing review context", "Reply draft", "ctrl-s review"} { for _, wanted := range []string{"Existing review context", "Reply draft", "ctrl+s review"} {
if !strings.Contains(plain, wanted) { if !strings.Contains(plain, wanted) {
t.Fatalf("inline reply view is missing %q:\n%s", wanted, plain) t.Fatalf("inline reply view is missing %q:\n%s", wanted, plain)
} }
@@ -1044,6 +1120,32 @@ func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) {
} }
} }
func TestConfiguredNavigationReplacesDefaultsAcrossScreensAndHelp(t *testing.T) {
settings := defaultAppSettings()
settings.KeyBindings.Navigation.Down = []string{"ctrl+j"}
settings.KeyBindings.Navigation.Up = []string{"ctrl+k"}
m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings)
m.screen, m.loading, m.width, m.height = prScreen, false, 80, 24
m.prs = []PullRequest{{ID: "1"}, {ID: "2"}}
updated, _ := m.Update(runeKey("j"))
m = updated.(App)
if m.prIndex != 0 {
t.Fatalf("removed default j still moved selection: %d", m.prIndex)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ})
m = updated.(App)
if m.prIndex != 1 {
t.Fatalf("configured ctrl+j did not move selection: %d", m.prIndex)
}
m.helpVisible = true
plain := ansi.Strip(m.viewHelp())
if !strings.Contains(plain, "ctrl+j") || strings.Contains(plain, "j / down") {
t.Fatalf("help did not use configured navigation:\n%s", plain)
}
}
func TestHelpCanScrollInShortTerminal(t *testing.T) { func TestHelpCanScrollInShortTerminal(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.helpVisible = threadScreen, true m.screen, m.helpVisible = threadScreen, true
@@ -1085,6 +1187,126 @@ func TestHelpWrapsLongActionsWithoutCapabilityStatus(t *testing.T) {
} }
} }
func TestHelpWrapsLongKeyGroupsAndSeparatesBindings(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.width, m.height = threadScreen, 46, 30
rows := m.helpRows(m.helpContentWidth())
plain := ansi.Strip(strings.Join(rows, "\n"))
for _, key := range []string{"h", "left", "l", "right"} {
if !strings.Contains(plain, key) {
t.Fatalf("wrapped focus binding lost %q:\n%s", key, plain)
}
}
if !strings.Contains(plain, strings.Repeat("─", m.helpContentWidth())) {
t.Fatalf("help bindings are not visually separated:\n%s", plain)
}
for index, row := range rows {
if width := ansi.StringWidth(row); width > m.helpContentWidth() {
t.Fatalf(
"wrapped help row %d width = %d, content width = %d",
index, width, m.helpContentWidth(),
)
}
}
}
func TestHelpTitleIsSeparatedFromFirstBinding(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.helpVisible = threadScreen, true
m.width, m.height = 70, 24
lines := strings.Split(ansi.Strip(m.viewHelp()), "\n")
titleLine := -1
for index, line := range lines {
if strings.Contains(line, "Review thread keys") {
titleLine = index
break
}
}
if titleLine < 0 || titleLine+2 >= len(lines) {
t.Fatalf("help title was not rendered:\n%s", strings.Join(lines, "\n"))
}
if !strings.Contains(lines[titleLine+1], "├") ||
!strings.Contains(lines[titleLine+1], "┤") {
t.Fatalf("help title divider is not joined to the box:\n%s", strings.Join(lines, "\n"))
}
if strings.Contains(lines[titleLine+1], "Focus thread") {
t.Fatalf("first binding shares the title divider row:\n%s", strings.Join(lines, "\n"))
}
}
func TestHelpFooterUsesOnlyPrimaryConfiguredKeys(t *testing.T) {
settings := defaultAppSettings()
settings.KeyBindings.Navigation.Down = []string{"j", "down", "ctrl+j"}
settings.KeyBindings.Navigation.Up = []string{"k", "up", "ctrl+k"}
settings.KeyBindings.General.Help = []string{"?", "f1"}
settings.KeyBindings.General.Back = []string{"b", "esc"}
m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings)
m.screen, m.helpVisible = threadScreen, true
m.width, m.height = 70, 9
lines := strings.Split(ansi.Strip(m.viewHelp()), "\n")
footer := ""
for _, line := range lines {
if strings.Contains(line, "scroll") && strings.Contains(line, "close") {
footer = line
break
}
}
if footer == "" {
t.Fatalf("help footer was not found:\n%s", strings.Join(lines, "\n"))
}
if !strings.Contains(footer, "j/k scroll") ||
!strings.Contains(footer, "?/b close") {
t.Fatalf("help footer does not show primary keys:\n%s", footer)
}
for _, secondary := range []string{"down", "up", "ctrl+j", "ctrl+k", "f1", "esc"} {
if strings.Contains(footer, secondary) {
t.Fatalf("help footer contains secondary key %q:\n%s", secondary, footer)
}
}
}
func TestEveryCompactScreenFooterUsesOnlyPrimaryKeys(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, time.Second)
m.width, m.height, m.loading = 100, 24, false
m.screen = prScreen
prFooter := compactFooterLine(ansi.Strip(m.viewPRs()))
if !strings.Contains(prFooter, "j / k move") ||
strings.Contains(prFooter, "down") || strings.Contains(prFooter, "up") {
t.Fatalf("picker footer is not compact:\n%s", prFooter)
}
m.screen = dashboardScreen
m.details = PRDetails{PullRequest: PullRequest{
Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "PR",
}}
dashboardFooter := compactFooterLine(ansi.Strip(m.viewDashboard()))
if !strings.Contains(dashboardFooter, "j / k scroll") ||
strings.Contains(dashboardFooter, "down") || strings.Contains(dashboardFooter, "up") {
t.Fatalf("dashboard footer is not compact:\n%s", dashboardFooter)
}
m.screen = threadScreen
threadFooter := compactFooterLine(ansi.Strip(m.viewThreads()))
if !strings.Contains(threadFooter, "h / l focus") ||
!strings.Contains(threadFooter, "j / k move/scroll") ||
strings.Contains(threadFooter, "left") || strings.Contains(threadFooter, "right") {
t.Fatalf("thread footer is not compact:\n%s", threadFooter)
}
}
func compactFooterLine(view string) string {
for _, line := range strings.Split(view, "\n") {
if strings.Contains(line, " keys ") {
return strings.TrimSpace(line)
}
}
return ""
}
func TestUppercaseFClearsAppliedThreadFilter(t *testing.T) { func TestUppercaseFClearsAppliedThreadFilter(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen m.screen = threadScreen