make keybinds configurable and consolidate
This commit is contained in:
95
README.md
95
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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 := " "
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -87,6 +88,7 @@ func defaultConfig() Config {
|
|||||||
},
|
},
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
676
keybindings.go
Normal 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...)
|
||||||
|
}
|
||||||
1
main.go
1
main.go
@@ -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)
|
||||||
|
|||||||
31
pr_editor.go
31
pr_editor.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
148
text_editor.go
148
text_editor.go
@@ -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) {
|
||||||
|
|||||||
@@ -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
358
tui.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
224
tui_test.go
224
tui_test.go
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user