detached fc65432

fix(vim): handle nil sequence trie

commit
fc654324b6172ad9307120ed5d585670ee20c010
author
brice · · 2026-08-14 03:30 UTC
parent
2bdff8a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +92 -5
M internal/domain/vimkeys/matcher.go +27 -2
@@ -36,6 +36,9 @@ func NewMatcher(trie *Trie) *Matcher {
3636
3737 // Reset clears matcher state.
3838 func (m *Matcher) Reset() {
39+ if m == nil {
40+ return
41+ }
3942 m.node = nil
4043 m.pending = nil
4144 m.count = 0
@@ -45,16 +48,24 @@ func (m *Matcher) Reset() {
4548
4649 // Pending returns the count prefix and keys matched so far.
4750 func (m *Matcher) Pending() string {
51+ if !m.hasTrie() {
52+ return ""
53+ }
4854 return formatPending(m.count, m.pending)
4955 }
5056
5157 // Ambiguous reports whether the current prefix is both exact and extendable.
5258 func (m *Matcher) Ambiguous() bool {
53- return m.ambiguous
59+ return m.hasTrie() && m.ambiguous
5460 }
5561
5662 // Feed consumes one key and returns the current match state.
5763 func (m *Matcher) Feed(key Key) Result {
64+ if !m.hasTrie() {
65+ m.Reset()
66+ return Result{Kind: ResultInvalid}
67+ }
68+
5869 if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount {
5970 m.beginSequence()
6071 }
@@ -71,7 +82,15 @@ func (m *Matcher) Feed(key Key) Result {
7182 return m.feedSequenceKey(key)
7283 }
7384
85+func (m *Matcher) hasTrie() bool {
86+ return m != nil && m.trie != nil && m.trie.root != nil
87+}
88+
7489 func (m *Matcher) beginSequence() {
90+ if !m.hasTrie() {
91+ m.Reset()
92+ return
93+ }
7594 m.node = m.trie.root
7695 m.pending = nil
7796 m.count = 0
@@ -96,6 +115,12 @@ func (m *Matcher) feedCountDigit(key Key) Result {
96115 }
97116
98117 func (m *Matcher) feedSequenceKey(key Key) Result {
118+ if m.node == nil {
119+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
120+ m.Reset()
121+ return result
122+ }
123+
99124 child, ok := m.node.children[key]
100125 if !ok {
101126 result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
@@ -132,7 +157,7 @@ func (m *Matcher) feedSequenceKey(key Key) Result {
132157
133158 // ResolveAmbiguity completes the current short exact binding.
134159 func (m *Matcher) ResolveAmbiguity() (Result, bool) {
135- if !m.ambiguous || m.node == nil || !m.node.hasAction {
160+ if !m.hasTrie() || !m.ambiguous || m.node == nil || !m.node.hasAction {
136161 return Result{}, false
137162 }
138163
@@ -36,6 +36,9 @@ func NewMatcher(trie *Trie) *Matcher {
36 36
37 // Reset clears matcher state.37 // Reset clears matcher state.
38 func (m *Matcher) Reset() {38 func (m *Matcher) Reset() {
39+ if m == nil {
40+ return
41+ }
39 m.node = nil42 m.node = nil
40 m.pending = nil43 m.pending = nil
41 m.count = 044 m.count = 0
@@ -45,16 +48,24 @@ func (m *Matcher) Reset() {
45 48
46 // Pending returns the count prefix and keys matched so far.49 // Pending returns the count prefix and keys matched so far.
47 func (m *Matcher) Pending() string {50 func (m *Matcher) Pending() string {
51+ if !m.hasTrie() {
52+ return ""
53+ }
48 return formatPending(m.count, m.pending)54 return formatPending(m.count, m.pending)
49 }55 }
50 56
51 // Ambiguous reports whether the current prefix is both exact and extendable.57 // Ambiguous reports whether the current prefix is both exact and extendable.
52 func (m *Matcher) Ambiguous() bool {58 func (m *Matcher) Ambiguous() bool {
53- return m.ambiguous59+ return m.hasTrie() && m.ambiguous
54 }60 }
55 61
56 // Feed consumes one key and returns the current match state.62 // Feed consumes one key and returns the current match state.
57 func (m *Matcher) Feed(key Key) Result {63 func (m *Matcher) Feed(key Key) Result {
64+ if !m.hasTrie() {
65+ m.Reset()
66+ return Result{Kind: ResultInvalid}
67+ }
68+
58 if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount {69 if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount {
59 m.beginSequence()70 m.beginSequence()
60 }71 }
@@ -71,7 +82,15 @@ func (m *Matcher) Feed(key Key) Result {
71 return m.feedSequenceKey(key)82 return m.feedSequenceKey(key)
72 }83 }
73 84
85+func (m *Matcher) hasTrie() bool {
86+ return m != nil && m.trie != nil && m.trie.root != nil
87+}
88+
74 func (m *Matcher) beginSequence() {89 func (m *Matcher) beginSequence() {
90+ if !m.hasTrie() {
91+ m.Reset()
92+ return
93+ }
75 m.node = m.trie.root94 m.node = m.trie.root
76 m.pending = nil95 m.pending = nil
77 m.count = 096 m.count = 0
@@ -96,6 +115,12 @@ func (m *Matcher) feedCountDigit(key Key) Result {
96 }115 }
97 116
98 func (m *Matcher) feedSequenceKey(key Key) Result {117 func (m *Matcher) feedSequenceKey(key Key) Result {
118+ if m.node == nil {
119+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
120+ m.Reset()
121+ return result
122+ }
123+
99 child, ok := m.node.children[key]124 child, ok := m.node.children[key]
100 if !ok {125 if !ok {
101 result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}126 result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
@@ -132,7 +157,7 @@ func (m *Matcher) feedSequenceKey(key Key) Result {
132 157
133 // ResolveAmbiguity completes the current short exact binding.158 // ResolveAmbiguity completes the current short exact binding.
134 func (m *Matcher) ResolveAmbiguity() (Result, bool) {159 func (m *Matcher) ResolveAmbiguity() (Result, bool) {
135- if !m.ambiguous || m.node == nil || !m.node.hasAction {160+ if !m.hasTrie() || !m.ambiguous || m.node == nil || !m.node.hasAction {
136 return Result{}, false161 return Result{}, false
137 }162 }
138 163
M internal/domain/vimkeys/matcher_test.go +34 -0
@@ -335,6 +335,40 @@ func TestMatcher_ResolveAmbiguityInvalid(t *testing.T) {
335335 }
336336 }
337337
338+func TestMatcher_NilTriePendingEmpty(t *testing.T) {
339+ m := NewMatcher(nil)
340+ if got := m.Pending(); got != "" {
341+ t.Fatalf("Pending() = %q, want empty", got)
342+ }
343+}
344+
345+func TestMatcher_NilTrieAmbiguousFalse(t *testing.T) {
346+ m := NewMatcher(nil)
347+ if m.Ambiguous() {
348+ t.Fatal("Ambiguous() = true, want false")
349+ }
350+}
351+
352+func TestMatcher_NilTrieFeedInvalid(t *testing.T) {
353+ m := NewMatcher(nil)
354+
355+ for _, key := range []Key{{Sym: "x"}, {Sym: "2"}} {
356+ result := m.Feed(key)
357+ if result.Kind != ResultInvalid {
358+ t.Fatalf("Feed(%v) kind = %v, want %v", key, result.Kind, ResultInvalid)
359+ }
360+ if result.Pending != "" {
361+ t.Fatalf("Feed(%v) pending = %q, want empty", key, result.Pending)
362+ }
363+ if got := m.Pending(); got != "" {
364+ t.Fatalf("after Feed(%v) Pending() = %q, want empty", key, got)
365+ }
366+ if m.Ambiguous() {
367+ t.Fatalf("after Feed(%v) Ambiguous() = true, want false", key)
368+ }
369+ }
370+}
371+
338372 func TestMatcher_InvalidDeadEnd(t *testing.T) {
339373 trie := NewTrie()
340374 trie.root.children[Key{Sym: "x"}] = newTrieNode()
@@ -335,6 +335,40 @@ func TestMatcher_ResolveAmbiguityInvalid(t *testing.T) {
335 }335 }
336 }336 }
337 337
338+func TestMatcher_NilTriePendingEmpty(t *testing.T) {
339+ m := NewMatcher(nil)
340+ if got := m.Pending(); got != "" {
341+ t.Fatalf("Pending() = %q, want empty", got)
342+ }
343+}
344+
345+func TestMatcher_NilTrieAmbiguousFalse(t *testing.T) {
346+ m := NewMatcher(nil)
347+ if m.Ambiguous() {
348+ t.Fatal("Ambiguous() = true, want false")
349+ }
350+}
351+
352+func TestMatcher_NilTrieFeedInvalid(t *testing.T) {
353+ m := NewMatcher(nil)
354+
355+ for _, key := range []Key{{Sym: "x"}, {Sym: "2"}} {
356+ result := m.Feed(key)
357+ if result.Kind != ResultInvalid {
358+ t.Fatalf("Feed(%v) kind = %v, want %v", key, result.Kind, ResultInvalid)
359+ }
360+ if result.Pending != "" {
361+ t.Fatalf("Feed(%v) pending = %q, want empty", key, result.Pending)
362+ }
363+ if got := m.Pending(); got != "" {
364+ t.Fatalf("after Feed(%v) Pending() = %q, want empty", key, got)
365+ }
366+ if m.Ambiguous() {
367+ t.Fatalf("after Feed(%v) Ambiguous() = true, want false", key)
368+ }
369+ }
370+}
371+
338 func TestMatcher_InvalidDeadEnd(t *testing.T) {372 func TestMatcher_InvalidDeadEnd(t *testing.T) {
339 trie := NewTrie()373 trie := NewTrie()
340 trie.root.children[Key{Sym: "x"}] = newTrieNode()374 trie.root.children[Key{Sym: "x"}] = newTrieNode()
M internal/infrastructure/config/validation.go +7 -1
@@ -313,7 +313,13 @@ func validateModalModeActionsWithKeyCanon(
313313 for _, key := range keys {
314314 canonical, err := keyCanon(key)
315315 if err != nil {
316- validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err))
316+ validationErrors = append(validationErrors, fmt.Sprintf(
317+ "workspace.%s.actions.%s has invalid key binding '%s': %v",
318+ modePath,
319+ action,
320+ key,
321+ err,
322+ ))
317323 continue
318324 }
319325 if existingAction, exists := seenSequence[canonical]; exists {
@@ -313,7 +313,13 @@ func validateModalModeActionsWithKeyCanon(
313 for _, key := range keys {313 for _, key := range keys {
314 canonical, err := keyCanon(key)314 canonical, err := keyCanon(key)
315 if err != nil {315 if err != nil {
316- validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err))316+ validationErrors = append(validationErrors, fmt.Sprintf(
317+ "workspace.%s.actions.%s has invalid key binding '%s': %v",
318+ modePath,
319+ action,
320+ key,
321+ err,
322+ ))
317 continue323 continue
318 }324 }
319 if existingAction, exists := seenSequence[canonical]; exists {325 if existingAction, exists := seenSequence[canonical]; exists {
M internal/ui/app_browser_launch_test.go +7 -2
@@ -1013,11 +1013,16 @@ func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
10131013 toaster, box, label := newTestShellToaster(t)
10141014 box.EXPECT().SetVisible(true).Once()
10151015 label.EXPECT().SetText("will not outlive this test").Once()
1016- toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo)
1016+ toaster.Show(
1017+ context.Background(),
1018+ "will not outlive this test",
1019+ component.ToastInfo,
1020+ component.WithDuration(component.ToastBriefDurationMs),
1021+ )
10171022 toaster.CancelAutoDismiss()
10181023
10191024 mainContext := glib.MainContextDefault()
1020- deadline := time.Now().Add(component.ToastBriefDurationMs + 600*time.Millisecond)
1025+ deadline := time.Now().Add(component.ToastBriefDurationMs*time.Millisecond + 600*time.Millisecond)
10211026 for time.Now().Before(deadline) {
10221027 for mainContext.Pending() {
10231028 mainContext.Iteration(false)
@@ -1013,11 +1013,16 @@ func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
1013 toaster, box, label := newTestShellToaster(t)1013 toaster, box, label := newTestShellToaster(t)
1014 box.EXPECT().SetVisible(true).Once()1014 box.EXPECT().SetVisible(true).Once()
1015 label.EXPECT().SetText("will not outlive this test").Once()1015 label.EXPECT().SetText("will not outlive this test").Once()
1016- toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo)1016+ toaster.Show(
1017+ context.Background(),
1018+ "will not outlive this test",
1019+ component.ToastInfo,
1020+ component.WithDuration(component.ToastBriefDurationMs),
1021+ )
1017 toaster.CancelAutoDismiss()1022 toaster.CancelAutoDismiss()
1018 1023
1019 mainContext := glib.MainContextDefault()1024 mainContext := glib.MainContextDefault()
1020- deadline := time.Now().Add(component.ToastBriefDurationMs + 600*time.Millisecond)1025+ deadline := time.Now().Add(component.ToastBriefDurationMs*time.Millisecond + 600*time.Millisecond)
1021 for time.Now().Before(deadline) {1026 for time.Now().Before(deadline) {
1022 for mainContext.Pending() {1027 for mainContext.Pending() {
1023 mainContext.Iteration(false)1028 mainContext.Iteration(false)
M internal/ui/input/vimkeys_dispatch_test.go +17 -0
@@ -67,6 +67,23 @@ func captureSequenceActions(h *KeyboardHandler) *[]struct {
6767 return actions
6868 }
6969
70+func TestNewVimModeSequenceState_NilTrie(t *testing.T) {
71+ state := newVimModeSequenceState(nil, 0)
72+ if state.matcher == nil {
73+ t.Fatal("matcher is nil")
74+ }
75+ if got := state.matcher.Pending(); got != "" {
76+ t.Fatalf("Pending() = %q, want empty", got)
77+ }
78+ if state.matcher.Ambiguous() {
79+ t.Fatal("Ambiguous() = true, want false")
80+ }
81+ result := state.matcher.Feed(vimkeys.Key{Sym: "x"})
82+ if result.Kind != vimkeys.ResultInvalid {
83+ t.Fatalf("Feed kind = %v, want %v", result.Kind, vimkeys.ResultInvalid)
84+ }
85+}
86+
7087 func TestTrieOwnsBinding(t *testing.T) {
7188 cases := map[string]bool{
7289 "]]": true,
@@ -67,6 +67,23 @@ func captureSequenceActions(h *KeyboardHandler) *[]struct {
67 return actions67 return actions
68 }68 }
69 69
70+func TestNewVimModeSequenceState_NilTrie(t *testing.T) {
71+ state := newVimModeSequenceState(nil, 0)
72+ if state.matcher == nil {
73+ t.Fatal("matcher is nil")
74+ }
75+ if got := state.matcher.Pending(); got != "" {
76+ t.Fatalf("Pending() = %q, want empty", got)
77+ }
78+ if state.matcher.Ambiguous() {
79+ t.Fatal("Ambiguous() = true, want false")
80+ }
81+ result := state.matcher.Feed(vimkeys.Key{Sym: "x"})
82+ if result.Kind != vimkeys.ResultInvalid {
83+ t.Fatalf("Feed kind = %v, want %v", result.Kind, vimkeys.ResultInvalid)
84+ }
85+}
86+
70 func TestTrieOwnsBinding(t *testing.T) {87 func TestTrieOwnsBinding(t *testing.T) {
71 cases := map[string]bool{88 cases := map[string]bool{
72 "]]": true,89 "]]": true,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close