detached 2affa97

feat(docmodel): add list motion kind

commit
2affa97a3b91e3feeb7931d2d0d1b1851483d141
author
brice · · 2026-08-14 04:51 UTC
committer
brice · · 2026-08-14 07:02 UTC
parent
be1f364
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +178 -0
M internal/domain/docmodel/docmodel.go +2 -0
@@ -23,6 +23,8 @@ const (
2323 BlockKindTable BlockKind = "table"
2424 // BlockKindImage marks a structural image block for Vim Mode block-kind motions.
2525 BlockKindImage BlockKind = "image"
26+ // BlockKindList marks a structural list block for Vim Mode list motions.
27+ BlockKindList BlockKind = "list"
2628
2729 // MinHeadingLevel is the lowest valid heading level.
2830 MinHeadingLevel = 1
@@ -23,6 +23,8 @@ const (
23 BlockKindTable BlockKind = "table"23 BlockKindTable BlockKind = "table"
24 // BlockKindImage marks a structural image block for Vim Mode block-kind motions.24 // BlockKindImage marks a structural image block for Vim Mode block-kind motions.
25 BlockKindImage BlockKind = "image"25 BlockKindImage BlockKind = "image"
26+ // BlockKindList marks a structural list block for Vim Mode list motions.
27+ BlockKindList BlockKind = "list"
26 28
27 // MinHeadingLevel is the lowest valid heading level.29 // MinHeadingLevel is the lowest valid heading level.
28 MinHeadingLevel = 130 MinHeadingLevel = 1
A internal/domain/docmodel/list_motions_test.go +176 -0
@@ -0,0 +1,176 @@
1+package docmodel
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestListNextBlockOfKindSupportsStrictForwardNavigation(t *testing.T) {
9+ doc := listMotionDocument(t)
10+
11+ tests := []struct {
12+ name string
13+ from Cursor
14+ count int
15+ want Cursor
16+ ok bool
17+ }{
18+ {
19+ name: "forward list motion skips non lists",
20+ from: Cursor{BlockID: "h1"},
21+ want: Cursor{BlockID: "list1"},
22+ ok: true,
23+ },
24+ {
25+ name: "count below one means one",
26+ from: Cursor{BlockID: "h1"},
27+ count: -1,
28+ want: Cursor{BlockID: "list1"},
29+ ok: true,
30+ },
31+ {
32+ name: "count zero means one",
33+ from: Cursor{BlockID: "code1"},
34+ count: 0,
35+ want: Cursor{BlockID: "list2"},
36+ ok: true,
37+ },
38+ {
39+ name: "strict forward list motion does not match current list",
40+ from: Cursor{BlockID: "list1"},
41+ count: 1,
42+ want: Cursor{BlockID: "list2"},
43+ ok: true,
44+ },
45+ {
46+ name: "count selects nth next list",
47+ from: Cursor{BlockID: "p0"},
48+ count: 2,
49+ want: Cursor{BlockID: "list2"},
50+ ok: true,
51+ },
52+ {
53+ name: "no later list returns false",
54+ from: Cursor{BlockID: "list3"},
55+ },
56+ {
57+ name: "count past later lists returns false",
58+ from: Cursor{BlockID: "list1"},
59+ count: 3,
60+ },
61+ {
62+ name: "zero cursor returns false",
63+ from: Cursor{},
64+ },
65+ {
66+ name: "missing cursor returns false",
67+ from: Cursor{BlockID: "missing"},
68+ },
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ before := doc.Blocks()
74+ got, ok := doc.NextBlockOfKind(tt.from, BlockKindList, tt.count)
75+ if ok != tt.ok || got != tt.want {
76+ t.Fatalf("NextBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
77+ }
78+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
79+ t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
80+ }
81+ })
82+ }
83+}
84+
85+func TestListPreviousBlockOfKindSupportsStrictBackwardNavigation(t *testing.T) {
86+ doc := listMotionDocument(t)
87+
88+ tests := []struct {
89+ name string
90+ from Cursor
91+ count int
92+ want Cursor
93+ ok bool
94+ }{
95+ {
96+ name: "backward list motion skips non lists",
97+ from: Cursor{BlockID: "table1"},
98+ want: Cursor{BlockID: "list3"},
99+ ok: true,
100+ },
101+ {
102+ name: "count below one means one",
103+ from: Cursor{BlockID: "table1"},
104+ count: -1,
105+ want: Cursor{BlockID: "list3"},
106+ ok: true,
107+ },
108+ {
109+ name: "count zero means one",
110+ from: Cursor{BlockID: "code1"},
111+ count: 0,
112+ want: Cursor{BlockID: "list1"},
113+ ok: true,
114+ },
115+ {
116+ name: "strict backward list motion does not match current list",
117+ from: Cursor{BlockID: "list3"},
118+ count: 1,
119+ want: Cursor{BlockID: "list2"},
120+ ok: true,
121+ },
122+ {
123+ name: "count selects nth previous list",
124+ from: Cursor{BlockID: "list3"},
125+ count: 2,
126+ want: Cursor{BlockID: "list1"},
127+ ok: true,
128+ },
129+ {
130+ name: "no earlier list returns false",
131+ from: Cursor{BlockID: "list0"},
132+ },
133+ {
134+ name: "count past earlier lists returns false",
135+ from: Cursor{BlockID: "list2"},
136+ count: 3,
137+ },
138+ {
139+ name: "zero cursor returns false",
140+ from: Cursor{},
141+ },
142+ {
143+ name: "missing cursor returns false",
144+ from: Cursor{BlockID: "missing"},
145+ },
146+ }
147+
148+ for _, tt := range tests {
149+ t.Run(tt.name, func(t *testing.T) {
150+ before := doc.Blocks()
151+ got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindList, tt.count)
152+ if ok != tt.ok || got != tt.want {
153+ t.Fatalf("PreviousBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
154+ }
155+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
156+ t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
157+ }
158+ })
159+ }
160+}
161+
162+func listMotionDocument(t *testing.T) Document {
163+ t.Helper()
164+
165+ return mustDocument(t, []Block{
166+ {ID: "list0", Kind: BlockKindList},
167+ {ID: "p0", Kind: BlockKindParagraph},
168+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
169+ {ID: "list1", Kind: BlockKindList},
170+ {ID: "code1", Kind: BlockKindCode},
171+ {ID: "list2", Kind: BlockKindList},
172+ {ID: "image1", Kind: BlockKindImage},
173+ {ID: "list3", Kind: BlockKindList},
174+ {ID: "table1", Kind: BlockKindTable},
175+ })
176+}
@@ -0,0 +1,176 @@
1+package docmodel
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestListNextBlockOfKindSupportsStrictForwardNavigation(t *testing.T) {
9+ doc := listMotionDocument(t)
10+
11+ tests := []struct {
12+ name string
13+ from Cursor
14+ count int
15+ want Cursor
16+ ok bool
17+ }{
18+ {
19+ name: "forward list motion skips non lists",
20+ from: Cursor{BlockID: "h1"},
21+ want: Cursor{BlockID: "list1"},
22+ ok: true,
23+ },
24+ {
25+ name: "count below one means one",
26+ from: Cursor{BlockID: "h1"},
27+ count: -1,
28+ want: Cursor{BlockID: "list1"},
29+ ok: true,
30+ },
31+ {
32+ name: "count zero means one",
33+ from: Cursor{BlockID: "code1"},
34+ count: 0,
35+ want: Cursor{BlockID: "list2"},
36+ ok: true,
37+ },
38+ {
39+ name: "strict forward list motion does not match current list",
40+ from: Cursor{BlockID: "list1"},
41+ count: 1,
42+ want: Cursor{BlockID: "list2"},
43+ ok: true,
44+ },
45+ {
46+ name: "count selects nth next list",
47+ from: Cursor{BlockID: "p0"},
48+ count: 2,
49+ want: Cursor{BlockID: "list2"},
50+ ok: true,
51+ },
52+ {
53+ name: "no later list returns false",
54+ from: Cursor{BlockID: "list3"},
55+ },
56+ {
57+ name: "count past later lists returns false",
58+ from: Cursor{BlockID: "list1"},
59+ count: 3,
60+ },
61+ {
62+ name: "zero cursor returns false",
63+ from: Cursor{},
64+ },
65+ {
66+ name: "missing cursor returns false",
67+ from: Cursor{BlockID: "missing"},
68+ },
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ before := doc.Blocks()
74+ got, ok := doc.NextBlockOfKind(tt.from, BlockKindList, tt.count)
75+ if ok != tt.ok || got != tt.want {
76+ t.Fatalf("NextBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
77+ }
78+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
79+ t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
80+ }
81+ })
82+ }
83+}
84+
85+func TestListPreviousBlockOfKindSupportsStrictBackwardNavigation(t *testing.T) {
86+ doc := listMotionDocument(t)
87+
88+ tests := []struct {
89+ name string
90+ from Cursor
91+ count int
92+ want Cursor
93+ ok bool
94+ }{
95+ {
96+ name: "backward list motion skips non lists",
97+ from: Cursor{BlockID: "table1"},
98+ want: Cursor{BlockID: "list3"},
99+ ok: true,
100+ },
101+ {
102+ name: "count below one means one",
103+ from: Cursor{BlockID: "table1"},
104+ count: -1,
105+ want: Cursor{BlockID: "list3"},
106+ ok: true,
107+ },
108+ {
109+ name: "count zero means one",
110+ from: Cursor{BlockID: "code1"},
111+ count: 0,
112+ want: Cursor{BlockID: "list1"},
113+ ok: true,
114+ },
115+ {
116+ name: "strict backward list motion does not match current list",
117+ from: Cursor{BlockID: "list3"},
118+ count: 1,
119+ want: Cursor{BlockID: "list2"},
120+ ok: true,
121+ },
122+ {
123+ name: "count selects nth previous list",
124+ from: Cursor{BlockID: "list3"},
125+ count: 2,
126+ want: Cursor{BlockID: "list1"},
127+ ok: true,
128+ },
129+ {
130+ name: "no earlier list returns false",
131+ from: Cursor{BlockID: "list0"},
132+ },
133+ {
134+ name: "count past earlier lists returns false",
135+ from: Cursor{BlockID: "list2"},
136+ count: 3,
137+ },
138+ {
139+ name: "zero cursor returns false",
140+ from: Cursor{},
141+ },
142+ {
143+ name: "missing cursor returns false",
144+ from: Cursor{BlockID: "missing"},
145+ },
146+ }
147+
148+ for _, tt := range tests {
149+ t.Run(tt.name, func(t *testing.T) {
150+ before := doc.Blocks()
151+ got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindList, tt.count)
152+ if ok != tt.ok || got != tt.want {
153+ t.Fatalf("PreviousBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
154+ }
155+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
156+ t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
157+ }
158+ })
159+ }
160+}
161+
162+func listMotionDocument(t *testing.T) Document {
163+ t.Helper()
164+
165+ return mustDocument(t, []Block{
166+ {ID: "list0", Kind: BlockKindList},
167+ {ID: "p0", Kind: BlockKindParagraph},
168+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
169+ {ID: "list1", Kind: BlockKindList},
170+ {ID: "code1", Kind: BlockKindCode},
171+ {ID: "list2", Kind: BlockKindList},
172+ {ID: "image1", Kind: BlockKindImage},
173+ {ID: "list3", Kind: BlockKindList},
174+ {ID: "table1", Kind: BlockKindTable},
175+ })
176+}

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