detached d596066

feat(docmodel): add block kind motions

commit
d59606683551b3b692c22477d1fd71732384afeb
author
brice · · 2026-08-14 04:28 UTC
committer
brice · · 2026-08-14 07:01 UTC
parent
be6b7ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +306 -0
A internal/domain/docmodel/block_motions_test.go +256 -0
@@ -0,0 +1,256 @@
1+package docmodel
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestStrictNextBlockOfKind(t *testing.T) {
9+ doc := blockKindMotionDocument(t)
10+
11+ tests := []struct {
12+ name string
13+ from Cursor
14+ kind BlockKind
15+ count int
16+ want Cursor
17+ ok bool
18+ }{
19+ {
20+ name: "next code block",
21+ from: Cursor{BlockID: "p0"},
22+ kind: BlockKindCode,
23+ want: Cursor{BlockID: "code1"},
24+ ok: true,
25+ },
26+ {
27+ name: "next table block",
28+ from: Cursor{BlockID: "p0"},
29+ kind: BlockKindTable,
30+ want: Cursor{BlockID: "table1"},
31+ ok: true,
32+ },
33+ {
34+ name: "next image block",
35+ from: Cursor{BlockID: "p0"},
36+ kind: BlockKindImage,
37+ want: Cursor{BlockID: "image1"},
38+ ok: true,
39+ },
40+ {
41+ name: "count below one means one",
42+ from: Cursor{BlockID: "p1"},
43+ kind: BlockKindTable,
44+ count: -2,
45+ want: Cursor{BlockID: "table1"},
46+ ok: true,
47+ },
48+ {
49+ name: "strict next does not match current block",
50+ from: Cursor{BlockID: "code1"},
51+ kind: BlockKindCode,
52+ count: 1,
53+ want: Cursor{BlockID: "code2"},
54+ ok: true,
55+ },
56+ {
57+ name: "count selects nth next block of kind",
58+ from: Cursor{BlockID: "p0"},
59+ kind: BlockKindCode,
60+ count: 2,
61+ want: Cursor{BlockID: "code2"},
62+ ok: true,
63+ },
64+ {
65+ name: "no later matching kind returns false",
66+ from: Cursor{BlockID: "code2"},
67+ kind: BlockKindCode,
68+ },
69+ {
70+ name: "count past available matches returns false",
71+ from: Cursor{BlockID: "p0"},
72+ kind: BlockKindImage,
73+ count: 3,
74+ },
75+ {
76+ name: "zero cursor returns false",
77+ from: Cursor{},
78+ kind: BlockKindCode,
79+ },
80+ {
81+ name: "missing cursor returns false",
82+ from: Cursor{BlockID: "missing"},
83+ kind: BlockKindCode,
84+ },
85+ {
86+ name: "empty kind returns false",
87+ from: Cursor{BlockID: "p0"},
88+ kind: "",
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ before := doc.Blocks()
95+ got, ok := doc.NextBlockOfKind(tt.from, tt.kind, tt.count)
96+ if ok != tt.ok || got != tt.want {
97+ t.Fatalf("NextBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok)
98+ }
99+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
100+ t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
101+ }
102+ })
103+ }
104+}
105+
106+func TestStrictPreviousBlockOfKind(t *testing.T) {
107+ doc := blockKindMotionDocument(t)
108+
109+ tests := []struct {
110+ name string
111+ from Cursor
112+ kind BlockKind
113+ count int
114+ want Cursor
115+ ok bool
116+ }{
117+ {
118+ name: "previous code block",
119+ from: Cursor{BlockID: "p2"},
120+ kind: BlockKindCode,
121+ want: Cursor{BlockID: "code2"},
122+ ok: true,
123+ },
124+ {
125+ name: "previous table block",
126+ from: Cursor{BlockID: "p2"},
127+ kind: BlockKindTable,
128+ want: Cursor{BlockID: "table2"},
129+ ok: true,
130+ },
131+ {
132+ name: "previous image block",
133+ from: Cursor{BlockID: "p2"},
134+ kind: BlockKindImage,
135+ want: Cursor{BlockID: "image2"},
136+ ok: true,
137+ },
138+ {
139+ name: "count below one means one",
140+ from: Cursor{BlockID: "p2"},
141+ kind: BlockKindTable,
142+ count: 0,
143+ want: Cursor{BlockID: "table2"},
144+ ok: true,
145+ },
146+ {
147+ name: "strict previous does not match current block",
148+ from: Cursor{BlockID: "image2"},
149+ kind: BlockKindImage,
150+ count: 1,
151+ want: Cursor{BlockID: "image1"},
152+ ok: true,
153+ },
154+ {
155+ name: "count selects nth previous block of kind",
156+ from: Cursor{BlockID: "p2"},
157+ kind: BlockKindTable,
158+ count: 2,
159+ want: Cursor{BlockID: "table1"},
160+ ok: true,
161+ },
162+ {
163+ name: "no earlier matching kind returns false",
164+ from: Cursor{BlockID: "code1"},
165+ kind: BlockKindCode,
166+ },
167+ {
168+ name: "count before available matches returns false",
169+ from: Cursor{BlockID: "p2"},
170+ kind: BlockKindImage,
171+ count: 3,
172+ },
173+ {
174+ name: "zero cursor returns false",
175+ from: Cursor{},
176+ kind: BlockKindCode,
177+ },
178+ {
179+ name: "missing cursor returns false",
180+ from: Cursor{BlockID: "missing"},
181+ kind: BlockKindCode,
182+ },
183+ {
184+ name: "empty kind returns false",
185+ from: Cursor{BlockID: "p2"},
186+ kind: "",
187+ },
188+ }
189+
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ before := doc.Blocks()
193+ got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, tt.count)
194+ if ok != tt.ok || got != tt.want {
195+ t.Fatalf("PreviousBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok)
196+ }
197+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
198+ t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
199+ }
200+ })
201+ }
202+}
203+
204+func TestBlockOfKindMotionsOnEmptyAndUnmatchedDocuments(t *testing.T) {
205+ tests := []struct {
206+ name string
207+ blocks []Block
208+ from Cursor
209+ kind BlockKind
210+ }{
211+ {
212+ name: "empty document",
213+ from: Cursor{BlockID: "missing"},
214+ kind: BlockKindCode,
215+ },
216+ {
217+ name: "document without requested structural kind",
218+ blocks: []Block{
219+ {ID: "p0", Kind: BlockKind("paragraph")},
220+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
221+ {ID: "p1", Kind: BlockKind("list_item")},
222+ },
223+ from: Cursor{BlockID: "p0"},
224+ kind: BlockKindImage,
225+ },
226+ }
227+
228+ for _, tt := range tests {
229+ t.Run(tt.name, func(t *testing.T) {
230+ doc := mustDocument(t, tt.blocks)
231+
232+ if got, ok := doc.NextBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) {
233+ t.Fatalf("NextBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok)
234+ }
235+ if got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) {
236+ t.Fatalf("PreviousBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok)
237+ }
238+ })
239+ }
240+}
241+
242+func blockKindMotionDocument(t *testing.T) Document {
243+ t.Helper()
244+
245+ return mustDocument(t, []Block{
246+ {ID: "p0", Kind: BlockKind("paragraph")},
247+ {ID: "code1", Kind: BlockKindCode},
248+ {ID: "p1", Kind: BlockKind("paragraph")},
249+ {ID: "table1", Kind: BlockKindTable},
250+ {ID: "image1", Kind: BlockKindImage},
251+ {ID: "code2", Kind: BlockKindCode},
252+ {ID: "table2", Kind: BlockKindTable},
253+ {ID: "image2", Kind: BlockKindImage},
254+ {ID: "p2", Kind: BlockKind("paragraph")},
255+ })
256+}
@@ -0,0 +1,256 @@
1+package docmodel
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestStrictNextBlockOfKind(t *testing.T) {
9+ doc := blockKindMotionDocument(t)
10+
11+ tests := []struct {
12+ name string
13+ from Cursor
14+ kind BlockKind
15+ count int
16+ want Cursor
17+ ok bool
18+ }{
19+ {
20+ name: "next code block",
21+ from: Cursor{BlockID: "p0"},
22+ kind: BlockKindCode,
23+ want: Cursor{BlockID: "code1"},
24+ ok: true,
25+ },
26+ {
27+ name: "next table block",
28+ from: Cursor{BlockID: "p0"},
29+ kind: BlockKindTable,
30+ want: Cursor{BlockID: "table1"},
31+ ok: true,
32+ },
33+ {
34+ name: "next image block",
35+ from: Cursor{BlockID: "p0"},
36+ kind: BlockKindImage,
37+ want: Cursor{BlockID: "image1"},
38+ ok: true,
39+ },
40+ {
41+ name: "count below one means one",
42+ from: Cursor{BlockID: "p1"},
43+ kind: BlockKindTable,
44+ count: -2,
45+ want: Cursor{BlockID: "table1"},
46+ ok: true,
47+ },
48+ {
49+ name: "strict next does not match current block",
50+ from: Cursor{BlockID: "code1"},
51+ kind: BlockKindCode,
52+ count: 1,
53+ want: Cursor{BlockID: "code2"},
54+ ok: true,
55+ },
56+ {
57+ name: "count selects nth next block of kind",
58+ from: Cursor{BlockID: "p0"},
59+ kind: BlockKindCode,
60+ count: 2,
61+ want: Cursor{BlockID: "code2"},
62+ ok: true,
63+ },
64+ {
65+ name: "no later matching kind returns false",
66+ from: Cursor{BlockID: "code2"},
67+ kind: BlockKindCode,
68+ },
69+ {
70+ name: "count past available matches returns false",
71+ from: Cursor{BlockID: "p0"},
72+ kind: BlockKindImage,
73+ count: 3,
74+ },
75+ {
76+ name: "zero cursor returns false",
77+ from: Cursor{},
78+ kind: BlockKindCode,
79+ },
80+ {
81+ name: "missing cursor returns false",
82+ from: Cursor{BlockID: "missing"},
83+ kind: BlockKindCode,
84+ },
85+ {
86+ name: "empty kind returns false",
87+ from: Cursor{BlockID: "p0"},
88+ kind: "",
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ before := doc.Blocks()
95+ got, ok := doc.NextBlockOfKind(tt.from, tt.kind, tt.count)
96+ if ok != tt.ok || got != tt.want {
97+ t.Fatalf("NextBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok)
98+ }
99+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
100+ t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
101+ }
102+ })
103+ }
104+}
105+
106+func TestStrictPreviousBlockOfKind(t *testing.T) {
107+ doc := blockKindMotionDocument(t)
108+
109+ tests := []struct {
110+ name string
111+ from Cursor
112+ kind BlockKind
113+ count int
114+ want Cursor
115+ ok bool
116+ }{
117+ {
118+ name: "previous code block",
119+ from: Cursor{BlockID: "p2"},
120+ kind: BlockKindCode,
121+ want: Cursor{BlockID: "code2"},
122+ ok: true,
123+ },
124+ {
125+ name: "previous table block",
126+ from: Cursor{BlockID: "p2"},
127+ kind: BlockKindTable,
128+ want: Cursor{BlockID: "table2"},
129+ ok: true,
130+ },
131+ {
132+ name: "previous image block",
133+ from: Cursor{BlockID: "p2"},
134+ kind: BlockKindImage,
135+ want: Cursor{BlockID: "image2"},
136+ ok: true,
137+ },
138+ {
139+ name: "count below one means one",
140+ from: Cursor{BlockID: "p2"},
141+ kind: BlockKindTable,
142+ count: 0,
143+ want: Cursor{BlockID: "table2"},
144+ ok: true,
145+ },
146+ {
147+ name: "strict previous does not match current block",
148+ from: Cursor{BlockID: "image2"},
149+ kind: BlockKindImage,
150+ count: 1,
151+ want: Cursor{BlockID: "image1"},
152+ ok: true,
153+ },
154+ {
155+ name: "count selects nth previous block of kind",
156+ from: Cursor{BlockID: "p2"},
157+ kind: BlockKindTable,
158+ count: 2,
159+ want: Cursor{BlockID: "table1"},
160+ ok: true,
161+ },
162+ {
163+ name: "no earlier matching kind returns false",
164+ from: Cursor{BlockID: "code1"},
165+ kind: BlockKindCode,
166+ },
167+ {
168+ name: "count before available matches returns false",
169+ from: Cursor{BlockID: "p2"},
170+ kind: BlockKindImage,
171+ count: 3,
172+ },
173+ {
174+ name: "zero cursor returns false",
175+ from: Cursor{},
176+ kind: BlockKindCode,
177+ },
178+ {
179+ name: "missing cursor returns false",
180+ from: Cursor{BlockID: "missing"},
181+ kind: BlockKindCode,
182+ },
183+ {
184+ name: "empty kind returns false",
185+ from: Cursor{BlockID: "p2"},
186+ kind: "",
187+ },
188+ }
189+
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ before := doc.Blocks()
193+ got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, tt.count)
194+ if ok != tt.ok || got != tt.want {
195+ t.Fatalf("PreviousBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok)
196+ }
197+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
198+ t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
199+ }
200+ })
201+ }
202+}
203+
204+func TestBlockOfKindMotionsOnEmptyAndUnmatchedDocuments(t *testing.T) {
205+ tests := []struct {
206+ name string
207+ blocks []Block
208+ from Cursor
209+ kind BlockKind
210+ }{
211+ {
212+ name: "empty document",
213+ from: Cursor{BlockID: "missing"},
214+ kind: BlockKindCode,
215+ },
216+ {
217+ name: "document without requested structural kind",
218+ blocks: []Block{
219+ {ID: "p0", Kind: BlockKind("paragraph")},
220+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
221+ {ID: "p1", Kind: BlockKind("list_item")},
222+ },
223+ from: Cursor{BlockID: "p0"},
224+ kind: BlockKindImage,
225+ },
226+ }
227+
228+ for _, tt := range tests {
229+ t.Run(tt.name, func(t *testing.T) {
230+ doc := mustDocument(t, tt.blocks)
231+
232+ if got, ok := doc.NextBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) {
233+ t.Fatalf("NextBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok)
234+ }
235+ if got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) {
236+ t.Fatalf("PreviousBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok)
237+ }
238+ })
239+ }
240+}
241+
242+func blockKindMotionDocument(t *testing.T) Document {
243+ t.Helper()
244+
245+ return mustDocument(t, []Block{
246+ {ID: "p0", Kind: BlockKind("paragraph")},
247+ {ID: "code1", Kind: BlockKindCode},
248+ {ID: "p1", Kind: BlockKind("paragraph")},
249+ {ID: "table1", Kind: BlockKindTable},
250+ {ID: "image1", Kind: BlockKindImage},
251+ {ID: "code2", Kind: BlockKindCode},
252+ {ID: "table2", Kind: BlockKindTable},
253+ {ID: "image2", Kind: BlockKindImage},
254+ {ID: "p2", Kind: BlockKind("paragraph")},
255+ })
256+}
M internal/domain/docmodel/docmodel.go +6 -0
@@ -15,6 +15,12 @@ type BlockKind string
1515 const (
1616 // BlockKindHeading marks blocks that participate in heading motions.
1717 BlockKindHeading BlockKind = "heading"
18+ // BlockKindCode marks a structural code block for Vim Mode block-kind motions.
19+ BlockKindCode BlockKind = "code"
20+ // BlockKindTable marks a structural table block for Vim Mode block-kind motions.
21+ BlockKindTable BlockKind = "table"
22+ // BlockKindImage marks a structural image block for Vim Mode block-kind motions.
23+ BlockKindImage BlockKind = "image"
1824
1925 // MinHeadingLevel is the lowest valid heading level.
2026 MinHeadingLevel = 1
@@ -15,6 +15,12 @@ type BlockKind string
15 const (15 const (
16 // BlockKindHeading marks blocks that participate in heading motions.16 // BlockKindHeading marks blocks that participate in heading motions.
17 BlockKindHeading BlockKind = "heading"17 BlockKindHeading BlockKind = "heading"
18+ // BlockKindCode marks a structural code block for Vim Mode block-kind motions.
19+ BlockKindCode BlockKind = "code"
20+ // BlockKindTable marks a structural table block for Vim Mode block-kind motions.
21+ BlockKindTable BlockKind = "table"
22+ // BlockKindImage marks a structural image block for Vim Mode block-kind motions.
23+ BlockKindImage BlockKind = "image"
18 24
19 // MinHeadingLevel is the lowest valid heading level.25 // MinHeadingLevel is the lowest valid heading level.
20 MinHeadingLevel = 126 MinHeadingLevel = 1
M internal/domain/docmodel/motions.go +44 -0
@@ -16,6 +16,20 @@ func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor
1616 return d.headingMotion(from, count, -1, levels)
1717 }
1818
19+// NextBlockOfKind returns the cursor for the count-th block of kind after from.
20+// Motions are strict: a block at from is not considered a match. A count less
21+// than or equal to zero is treated as one. Empty kinds never match.
22+func (d Document) NextBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) {
23+ return d.blockKindMotion(from, kind, count, 1)
24+}
25+
26+// PreviousBlockOfKind returns the cursor for the count-th block of kind before
27+// from. Motions are strict: a block at from is not considered a match. A count
28+// less than or equal to zero is treated as one. Empty kinds never match.
29+func (d Document) PreviousBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) {
30+ return d.blockKindMotion(from, kind, count, -1)
31+}
32+
1933 func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) {
2034 start, ok := d.indexByID[from.BlockID]
2135 if !ok {
@@ -42,6 +56,36 @@ func (d Document) headingMotion(from Cursor, count, direction int, levels []int)
4256 return Cursor{}, false
4357 }
4458
59+func (d Document) blockKindMotion(from Cursor, kind BlockKind, count, direction int) (Cursor, bool) {
60+ if kind == "" {
61+ return Cursor{}, false
62+ }
63+
64+ start, ok := d.indexByID[from.BlockID]
65+ if !ok {
66+ return Cursor{}, false
67+ }
68+
69+ if count <= 0 {
70+ count = 1
71+ }
72+
73+ remaining := count
74+ for i := start + direction; i >= 0 && i < len(d.blocks); i += direction {
75+ block := d.blocks[i]
76+ if block.Kind != kind {
77+ continue
78+ }
79+
80+ remaining--
81+ if remaining == 0 {
82+ return Cursor{BlockID: block.ID}, true
83+ }
84+ }
85+
86+ return Cursor{}, false
87+}
88+
4589 func headingLevelMatches(level int, levels []int) bool {
4690 if len(levels) == 0 {
4791 return true
@@ -16,6 +16,20 @@ func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor
16 return d.headingMotion(from, count, -1, levels)16 return d.headingMotion(from, count, -1, levels)
17 }17 }
18 18
19+// NextBlockOfKind returns the cursor for the count-th block of kind after from.
20+// Motions are strict: a block at from is not considered a match. A count less
21+// than or equal to zero is treated as one. Empty kinds never match.
22+func (d Document) NextBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) {
23+ return d.blockKindMotion(from, kind, count, 1)
24+}
25+
26+// PreviousBlockOfKind returns the cursor for the count-th block of kind before
27+// from. Motions are strict: a block at from is not considered a match. A count
28+// less than or equal to zero is treated as one. Empty kinds never match.
29+func (d Document) PreviousBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) {
30+ return d.blockKindMotion(from, kind, count, -1)
31+}
32+
19 func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) {33 func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) {
20 start, ok := d.indexByID[from.BlockID]34 start, ok := d.indexByID[from.BlockID]
21 if !ok {35 if !ok {
@@ -42,6 +56,36 @@ func (d Document) headingMotion(from Cursor, count, direction int, levels []int)
42 return Cursor{}, false56 return Cursor{}, false
43 }57 }
44 58
59+func (d Document) blockKindMotion(from Cursor, kind BlockKind, count, direction int) (Cursor, bool) {
60+ if kind == "" {
61+ return Cursor{}, false
62+ }
63+
64+ start, ok := d.indexByID[from.BlockID]
65+ if !ok {
66+ return Cursor{}, false
67+ }
68+
69+ if count <= 0 {
70+ count = 1
71+ }
72+
73+ remaining := count
74+ for i := start + direction; i >= 0 && i < len(d.blocks); i += direction {
75+ block := d.blocks[i]
76+ if block.Kind != kind {
77+ continue
78+ }
79+
80+ remaining--
81+ if remaining == 0 {
82+ return Cursor{BlockID: block.ID}, true
83+ }
84+ }
85+
86+ return Cursor{}, false
87+}
88+
45 func headingLevelMatches(level int, levels []int) bool {89 func headingLevelMatches(level int, levels []int) bool {
46 if len(levels) == 0 {90 if len(levels) == 0 {
47 return true91 return 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