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]
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
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
@@ -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
history and metadata.
## Keys
## Default keys
| Key | Action |
| --- | --- |
@@ -156,7 +247,7 @@ history and metadata.
| `n` / `N` | Next / previous thread with a new update |
| `c` | Compose a reply to 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 |
| `enter` / `l` | Open the selected PR dashboard or its review threads |
| `enter` | Toggle the selected review thread |

View File

@@ -177,7 +177,15 @@ func (m App) branchCompletionLines(width int) []string {
if len(suggestions) == 0 {
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()
for index, suggestion := range suggestions {
prefix := " "

View File

@@ -95,7 +95,7 @@ func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testi
m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}}
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)
}
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"`
Cache CacheConfig `toml:"cache"`
Editing EditingConfig `toml:"editing"`
KeyBindings KeyBindings `toml:"keybindings"`
}
type DisplayConfig struct {
@@ -85,8 +86,9 @@ func defaultConfig() Config {
StatusOrder: []string{"unresolved", "outdated", "resolved"},
WithinStatus: "file",
},
Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}},
Editing: EditingConfig{Mode: "vim"},
Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}},
Editing: EditingConfig{Mode: "vim"},
KeyBindings: defaultKeyBindings(),
}
}
@@ -181,6 +183,9 @@ func validateConfig(config Config) error {
default:
return fmt.Errorf("editing.mode must be standard or vim")
}
if err := validateKeyBindings(config.KeyBindings); err != nil {
return err
}
return nil
}

View File

@@ -56,6 +56,10 @@ directory = "/tmp/gh-threads-cache"
[editing]
mode = "standard"
[keybindings.navigation]
down = ["ctrl+j"]
up = ["ctrl+k"]
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
@@ -76,11 +80,63 @@ mode = "standard"
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
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)
}
}
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) {
path := filepath.Join(t.TempDir(), "config.toml")
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,
ThreadWithinStatus: config.Threads.WithinStatus,
EditorMode: config.Editing.Mode,
KeyBindings: config.KeyBindings,
},
)
cursorOutput := newTerminalCursorOutput(os.Stdout)

View File

@@ -34,6 +34,7 @@ func (m *App) startPREdit() tea.Cmd {
m.prEditEditors[prEditBodyField].highlightMarkdown = true
for index := range m.prEditEditors {
m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil
m.prEditEditors[index].keys = m.keybindings
}
m.prEditOriginal = m.currentPRMetadata()
m.prEditBranches = nil
@@ -83,7 +84,18 @@ func (m App) pullRequestUpdateUnavailable() string {
}
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()
if m.writeMode == writePREditConfirm {
switch k {
@@ -375,17 +387,6 @@ func (m App) dashboardEditLayout() ([]string, int) {
appendField("title", prEditTitleField)
appendField("target branch", prEditBaseField)
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
}
@@ -467,6 +468,10 @@ func (m App) prEditConfirmationLines(width int) []string {
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
}

View File

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

View File

@@ -16,6 +16,21 @@ type memoryTextClipboard struct {
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) {
return c.text, c.readErr
}

358
tui.go
View File

@@ -135,6 +135,7 @@ type App struct {
dashboardReturn screen
compactReviews bool
editorMode string
keybindings KeyBindings
readState *readStateStore
knownThreads map[string]bool
knownComments map[string]bool
@@ -153,6 +154,7 @@ type AppSettings struct {
DashboardMode string
CompactReviews bool
EditorMode string
KeyBindings KeyBindings
ReadState *readStateStore
}
@@ -166,6 +168,7 @@ func defaultAppSettings() AppSettings {
DashboardMode: "hotkey",
CompactReviews: true,
EditorMode: "vim",
KeyBindings: defaultKeyBindings(),
}
}
@@ -195,6 +198,7 @@ func NewAppWithSettings(
dashboardMode: settings.DashboardMode, dashboardReturn: prScreen,
compactReviews: settings.CompactReviews,
editorMode: settings.EditorMode,
keybindings: settings.KeyBindings,
readState: state,
knownThreads: make(map[string]bool), knownComments: 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) {
k := key.String()
if k == "ctrl+c" {
if key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
keyMatches(key.String(), m.keybindings.General.Quit) {
return m, tea.Quit
}
k := m.keybindings.canonicalWriteKey(key.String())
switch m.writeMode {
case writeReply:
switch k {
@@ -611,10 +616,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
k := key.String()
if m.writeMode != writeNone {
return m.updateWriteInput(key)
}
if m.helpVisible {
k = m.keybindings.canonicalHelpKey(k)
switch k {
case "ctrl+c":
return m, tea.Quit
@@ -635,7 +638,25 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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 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 {
case "ctrl+c":
return m, tea.Quit
@@ -667,6 +688,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
rawKey := k
k = m.keybindings.canonicalMainKey(k, m.screen)
if k == "ctrl+c" || k == "q" {
return m, tea.Quit
}
@@ -676,7 +699,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.pendingZ {
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]
m.folded[thread.ID] = !m.folded[thread.ID]
m.scroll = 0
@@ -1227,15 +1251,15 @@ func (m App) View() string {
if m.width == 0 {
return "Loading…"
}
if m.helpVisible {
return m.viewHelp()
}
if m.writeMode != writeNone && m.writeMode != writeReply {
if m.writeMode == writePREdit {
return m.viewDashboard()
}
return m.viewWritePopup()
}
if m.helpVisible {
return m.viewHelp()
}
if m.screen == prScreen {
return m.viewPRs()
}
@@ -1267,11 +1291,20 @@ func (m App) viewWritePopup() string {
if m.err != nil {
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:
lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""}
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:
lines = []string{titleStyle.Render("Submitting reply…"), "", dimStyle.Render(location)}
case writeResolveConfirm:
@@ -1282,7 +1315,11 @@ func (m App) viewWritePopup() string {
lines = []string{
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:
action := "Resolving"
@@ -1322,21 +1359,94 @@ type helpBinding struct {
}
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 {
openAction := "Open pull request dashboard"
if m.dashboardMode == "hotkey" {
openAction = "Open review threads"
}
return []helpBinding{
{"j / ↓", "Next pull request"},
{"k / ↑", "Previous pull request"},
{"g / G", "First / last pull request"},
{"ctrl-d / ctrl-u", "Page down / up"},
{"enter / l", openAction},
{"d", "Open pull request dashboard"},
{"r", "Refresh now"},
{"?", "Close this help"},
{"q / ctrl-c", "Quit"},
{keyLabel(m.keybindings.Navigation.Down), "Next pull request"},
{keyLabel(m.keybindings.Navigation.Up), "Previous pull request"},
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last pull request"},
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
{keyLabel(m.keybindings.Views.Open), openAction},
{keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"},
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
{keyLabel(m.keybindings.General.Help), "Close this help"},
{keyLabel(m.keybindings.General.Quit), "Quit"},
}
}
if m.screen == dashboardScreen {
@@ -1345,16 +1455,16 @@ func (m App) helpBindings() []helpBinding {
backAction = "Return to review threads"
}
return []helpBinding{
{"j / ↓", "Scroll description down"},
{"k / ↑", "Scroll description up"},
{"g / G", "Top / bottom"},
{"ctrl-d / ctrl-u", "Page down / up"},
{"e", "Edit title, target branch, and description"},
{"enter / l", "Open review threads"},
{"b / esc", backAction},
{"r", "Refresh now"},
{"?", "Close this help"},
{"q / ctrl-c", "Quit"},
{keyLabel(m.keybindings.Navigation.Down), "Scroll description down"},
{keyLabel(m.keybindings.Navigation.Up), "Scroll description up"},
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"},
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
{keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"},
{keyLabel(m.keybindings.Views.Open), "Open review threads"},
{keyLabel(m.keybindings.General.Back), backAction},
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
{keyLabel(m.keybindings.General.Help), "Close this help"},
{keyLabel(m.keybindings.General.Quit), "Quit"},
}
}
backAction := "Return to pull requests"
@@ -1362,31 +1472,34 @@ func (m App) helpBindings() []helpBinding {
backAction = "Return to PR dashboard"
}
bindings := []helpBinding{
{"h / l", "Focus thread list / detail"},
{"j / k", "Move or scroll focused pane"},
{"↓ / ↑", "Move or scroll focused pane"},
{"g / G", "First / last item"},
{"ctrl-d / ctrl-u", "Page down / up"},
{"tab", "Hide / reveal thread list"},
{"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"},
{"F", "Clear the active thread filter and show every thread"},
{"n / N", "Next / previous new update"},
{"c", "Compose a reply to the selected thread"},
{"R", "Resolve or unresolve the selected thread"},
{"d", "Open pull request dashboard"},
{"enter / za", "Fold / expand thread"},
{"b / esc", backAction},
{"r", "Refresh now"},
{combinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right), "Focus thread list / detail"},
{combinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), "Move or scroll focused pane"},
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last item"},
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
{keyLabel(m.keybindings.Views.ToggleList), "Hide / reveal thread list"},
{keyLabel(m.keybindings.Threads.Search), "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"},
{combinedKeyLabel(m.keybindings.Threads.NextUnread, m.keybindings.Threads.PreviousUnread), "Next / previous new update"},
{keyLabel(m.keybindings.Threads.Reply), "Compose a reply to the selected thread"},
{keyLabel(m.keybindings.Threads.Resolve), "Resolve or unresolve the selected thread"},
{keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"},
{combinedKeyLabel(
m.keybindings.Threads.Toggle,
[]string{sequenceKeyLabel(m.keybindings.Threads.FoldPrefix, m.keybindings.Threads.FoldToggle)},
), "Fold / expand thread"},
{keyLabel(m.keybindings.General.Back), backAction},
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
}
bindings = append(bindings,
helpBinding{"?", "Close this help"},
helpBinding{"q / ctrl-c", "Quit"},
helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"},
helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"},
)
return bindings
}
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 {
@@ -1401,25 +1514,45 @@ func (m App) viewHelp() string {
end := min(len(rows), start+visibleRows)
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"
} else if m.screen == threadScreen {
title = "Review thread keys"
}
lines := []string{titleStyle.Render(title)}
lines = append(lines, rows[start:end]...)
contentLines := append([]string(nil), rows[start:end]...)
if len(rows) > visibleRows {
lines = append(lines, dimStyle.Render(fmt.Sprintf(
"%d%d of %d • j/k scroll • ?/esc/q close",
contentLines = append(contentLines, dimStyle.Render(fmt.Sprintf(
"%d%d of %d • %s/%s scroll • %s close",
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 {
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 {
lines[i] = ansi.Truncate(lines[i], contentWidth, "")
for i := range contentLines {
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)
}
@@ -1431,16 +1564,34 @@ func (m App) helpRows(contentWidth int) []string {
keyWidth := min(17, max(8, contentWidth/3))
actionWidth := max(1, contentWidth-keyWidth-1)
var rows []string
for _, binding := range m.helpBindings() {
wrapped := ansi.Hardwrap(ansi.Wordwrap(binding.action, actionWidth, ""), actionWidth, false)
actionLines := strings.Split(wrapped, "\n")
for index, action := range actionLines {
key := ""
if index == 0 {
key = binding.key
bindings := m.helpBindings()
for bindingIndex, binding := range bindings {
wrappedKeys := ansi.Hardwrap(
ansi.Wordwrap(binding.key, keyWidth, ""),
keyWidth,
false,
)
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)
}
if bindingIndex < len(bindings)-1 {
rows = append(rows, dimStyle.Render(strings.Repeat("─", contentWidth)))
}
}
return rows
}
@@ -1512,9 +1663,22 @@ func (m App) viewPRs() string {
}
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" {
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)
}
@@ -1552,9 +1716,22 @@ func (m App) viewDashboard() string {
maxScroll := max(0, len(lines)-viewportHeight)
scroll := min(m.scroll, maxScroll)
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 {
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)
}
view := m.frame(visible, footer)
@@ -2010,11 +2187,30 @@ func (m App) viewThreads() string {
right := m.threadDetail(rightWidth, contentHeight)
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 {
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 {
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)
}
@@ -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)},
}
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 {
if len(thread.Comments) > 0 {
lines = append(lines, detailLine{})
@@ -2294,7 +2499,12 @@ func (m App) inlineReplyLines(width int) []detailLine {
}
lines = append(lines, detailLine{
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
}

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) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
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")})
m = updated.(App)
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) {
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) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
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) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen