detached e2f71ea

feat(copy): add vim-like cell navigation

commit
e2f71ea1790e11523c78b95356f2aae5e8e9b887
author
brice · · 2026-07-15 06:49 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
6b1fde2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +296 -0
A internal/usecase/copy/navigator.go +158 -0
@@ -0,0 +1,158 @@
1+package copy
2+
3+// Navigator owns cursor movement policy over a Document. Its positions are
4+// always normalized glyph heads after a successful movement.
5+type Navigator struct {
6+ Pos Pos
7+ PreferredCol int
8+}
9+
10+// NewNavigator creates a navigator at at. Set can normalize a position when a
11+// document is available.
12+func NewNavigator(at Pos) Navigator {
13+ return Navigator{Pos: at, PreferredCol: at.Col}
14+}
15+
16+// Left moves to the previous glyph on the current physical row.
17+func (n *Navigator) Left(doc *Document) bool {
18+ if doc == nil {
19+ return false
20+ }
21+ pos, ok := doc.PrevGlyph(n.Pos)
22+ if !ok {
23+ return false
24+ }
25+ return n.set(pos, true)
26+}
27+
28+// Right moves to the next glyph on the current physical row.
29+func (n *Navigator) Right(doc *Document) bool {
30+ if doc == nil {
31+ return false
32+ }
33+ pos, ok := doc.NextGlyph(n.Pos)
34+ if !ok {
35+ return false
36+ }
37+ return n.set(pos, true)
38+}
39+
40+// Up moves to the preceding physical row while retaining PreferredCol.
41+func (n *Navigator) Up(doc *Document) bool {
42+ if doc == nil {
43+ return false
44+ }
45+ return n.moveVertical(doc, n.Pos.Row-1)
46+}
47+
48+// Down moves to the following physical row while retaining PreferredCol.
49+func (n *Navigator) Down(doc *Document) bool {
50+ if doc == nil {
51+ return false
52+ }
53+ return n.moveVertical(doc, n.Pos.Row+1)
54+}
55+
56+// WordNext moves to the start of the next word, possibly on another row.
57+func (n *Navigator) WordNext(doc *Document) bool {
58+ if doc == nil {
59+ return false
60+ }
61+ pos, ok := doc.NextWordStart(n.Pos)
62+ if !ok {
63+ return false
64+ }
65+ return n.set(pos, true)
66+}
67+
68+// WordBackward moves to the start of the current or preceding word.
69+func (n *Navigator) WordBackward(doc *Document) bool {
70+ if doc == nil {
71+ return false
72+ }
73+ pos, ok := doc.PreviousWordStart(n.Pos)
74+ if !ok {
75+ return false
76+ }
77+ return n.set(pos, true)
78+}
79+
80+// WordEnd moves to the end of the current or next word.
81+func (n *Navigator) WordEnd(doc *Document) bool {
82+ if doc == nil {
83+ return false
84+ }
85+ pos, ok := doc.NextWordEnd(n.Pos)
86+ if !ok {
87+ return false
88+ }
89+ return n.set(pos, true)
90+}
91+
92+// Top moves to the first physical row at the current column and resets the
93+// preferred column to the normalized destination.
94+func (n *Navigator) Top(doc *Document) bool {
95+ if doc == nil || doc.Len() == 0 {
96+ return false
97+ }
98+ return n.Set(doc, Pos{Row: 0, Col: n.Pos.Col})
99+}
100+
101+// Bottom moves to the last physical row at the current column and resets the
102+// preferred column to the normalized destination.
103+func (n *Navigator) Bottom(doc *Document) bool {
104+ if doc == nil || doc.Len() == 0 {
105+ return false
106+ }
107+ return n.Set(doc, Pos{Row: doc.Len() - 1, Col: n.Pos.Col})
108+}
109+
110+// Page moves rows physical rows while retaining PreferredCol. Its destination
111+// clamps to the first or last physical row.
112+func (n *Navigator) Page(doc *Document, rows int) bool {
113+ if doc == nil || doc.Len() == 0 {
114+ return false
115+ }
116+ return n.moveVertical(doc, min(max(n.Pos.Row+rows, 0), doc.Len()-1))
117+}
118+
119+// Set moves to pos after normalizing it through doc and updates PreferredCol.
120+func (n *Navigator) Set(doc *Document, pos Pos) bool {
121+ if doc == nil {
122+ return false
123+ }
124+ pos, ok := doc.Normalize(pos)
125+ if !ok {
126+ return false
127+ }
128+ return n.set(pos, true)
129+}
130+
131+func (n *Navigator) moveVertical(doc *Document, row int) bool {
132+ if row < 0 || row >= doc.Len() {
133+ return false
134+ }
135+ pos, ok := navigatorPosOnRow(doc, row, n.PreferredCol)
136+ if !ok {
137+ return false
138+ }
139+ return n.set(pos, false)
140+}
141+
142+func navigatorPosOnRow(doc *Document, row, col int) (Pos, bool) {
143+ cells := doc.Row(row)
144+ if len(cells) == 0 {
145+ return doc.Normalize(Pos{Row: row, Col: 0})
146+ }
147+ col = min(max(col, 0), len(cells)-1)
148+ return doc.Normalize(Pos{Row: row, Col: col})
149+}
150+
151+func (n *Navigator) set(pos Pos, setPreferred bool) bool {
152+ moved := n.Pos != pos
153+ n.Pos = pos
154+ if setPreferred {
155+ n.PreferredCol = pos.Col
156+ }
157+ return moved
158+}
@@ -0,0 +1,158 @@
1+package copy
2+
3+// Navigator owns cursor movement policy over a Document. Its positions are
4+// always normalized glyph heads after a successful movement.
5+type Navigator struct {
6+ Pos Pos
7+ PreferredCol int
8+}
9+
10+// NewNavigator creates a navigator at at. Set can normalize a position when a
11+// document is available.
12+func NewNavigator(at Pos) Navigator {
13+ return Navigator{Pos: at, PreferredCol: at.Col}
14+}
15+
16+// Left moves to the previous glyph on the current physical row.
17+func (n *Navigator) Left(doc *Document) bool {
18+ if doc == nil {
19+ return false
20+ }
21+ pos, ok := doc.PrevGlyph(n.Pos)
22+ if !ok {
23+ return false
24+ }
25+ return n.set(pos, true)
26+}
27+
28+// Right moves to the next glyph on the current physical row.
29+func (n *Navigator) Right(doc *Document) bool {
30+ if doc == nil {
31+ return false
32+ }
33+ pos, ok := doc.NextGlyph(n.Pos)
34+ if !ok {
35+ return false
36+ }
37+ return n.set(pos, true)
38+}
39+
40+// Up moves to the preceding physical row while retaining PreferredCol.
41+func (n *Navigator) Up(doc *Document) bool {
42+ if doc == nil {
43+ return false
44+ }
45+ return n.moveVertical(doc, n.Pos.Row-1)
46+}
47+
48+// Down moves to the following physical row while retaining PreferredCol.
49+func (n *Navigator) Down(doc *Document) bool {
50+ if doc == nil {
51+ return false
52+ }
53+ return n.moveVertical(doc, n.Pos.Row+1)
54+}
55+
56+// WordNext moves to the start of the next word, possibly on another row.
57+func (n *Navigator) WordNext(doc *Document) bool {
58+ if doc == nil {
59+ return false
60+ }
61+ pos, ok := doc.NextWordStart(n.Pos)
62+ if !ok {
63+ return false
64+ }
65+ return n.set(pos, true)
66+}
67+
68+// WordBackward moves to the start of the current or preceding word.
69+func (n *Navigator) WordBackward(doc *Document) bool {
70+ if doc == nil {
71+ return false
72+ }
73+ pos, ok := doc.PreviousWordStart(n.Pos)
74+ if !ok {
75+ return false
76+ }
77+ return n.set(pos, true)
78+}
79+
80+// WordEnd moves to the end of the current or next word.
81+func (n *Navigator) WordEnd(doc *Document) bool {
82+ if doc == nil {
83+ return false
84+ }
85+ pos, ok := doc.NextWordEnd(n.Pos)
86+ if !ok {
87+ return false
88+ }
89+ return n.set(pos, true)
90+}
91+
92+// Top moves to the first physical row at the current column and resets the
93+// preferred column to the normalized destination.
94+func (n *Navigator) Top(doc *Document) bool {
95+ if doc == nil || doc.Len() == 0 {
96+ return false
97+ }
98+ return n.Set(doc, Pos{Row: 0, Col: n.Pos.Col})
99+}
100+
101+// Bottom moves to the last physical row at the current column and resets the
102+// preferred column to the normalized destination.
103+func (n *Navigator) Bottom(doc *Document) bool {
104+ if doc == nil || doc.Len() == 0 {
105+ return false
106+ }
107+ return n.Set(doc, Pos{Row: doc.Len() - 1, Col: n.Pos.Col})
108+}
109+
110+// Page moves rows physical rows while retaining PreferredCol. Its destination
111+// clamps to the first or last physical row.
112+func (n *Navigator) Page(doc *Document, rows int) bool {
113+ if doc == nil || doc.Len() == 0 {
114+ return false
115+ }
116+ return n.moveVertical(doc, min(max(n.Pos.Row+rows, 0), doc.Len()-1))
117+}
118+
119+// Set moves to pos after normalizing it through doc and updates PreferredCol.
120+func (n *Navigator) Set(doc *Document, pos Pos) bool {
121+ if doc == nil {
122+ return false
123+ }
124+ pos, ok := doc.Normalize(pos)
125+ if !ok {
126+ return false
127+ }
128+ return n.set(pos, true)
129+}
130+
131+func (n *Navigator) moveVertical(doc *Document, row int) bool {
132+ if row < 0 || row >= doc.Len() {
133+ return false
134+ }
135+ pos, ok := navigatorPosOnRow(doc, row, n.PreferredCol)
136+ if !ok {
137+ return false
138+ }
139+ return n.set(pos, false)
140+}
141+
142+func navigatorPosOnRow(doc *Document, row, col int) (Pos, bool) {
143+ cells := doc.Row(row)
144+ if len(cells) == 0 {
145+ return doc.Normalize(Pos{Row: row, Col: 0})
146+ }
147+ col = min(max(col, 0), len(cells)-1)
148+ return doc.Normalize(Pos{Row: row, Col: col})
149+}
150+
151+func (n *Navigator) set(pos Pos, setPreferred bool) bool {
152+ moved := n.Pos != pos
153+ n.Pos = pos
154+ if setPreferred {
155+ n.PreferredCol = pos.Col
156+ }
157+ return moved
158+}
A internal/usecase/copy/navigator_test.go +138 -0
@@ -0,0 +1,138 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func navigatorDocument() *Document {
11+ return NewDocument(NewSnapshotFromRows([][]renderer.Cell{
12+ documentCells("abcdef"),
13+ documentCells("xy"),
14+ {{Rune: '界'}, {Continuation: true}, {Rune: 'z'}},
15+ {},
16+ documentCells("omega"),
17+ }, 6, 2), " -_@")
18+}
19+
20+func TestNavigatorHorizontalSkipsContinuations(t *testing.T) {
21+ doc := navigatorDocument()
22+
23+ tests := []struct {
24+ name string
25+ move func(*Navigator, *Document) bool
26+ from Pos
27+ want Pos
28+ }{
29+ {"right skips wide continuation", (*Navigator).Right, Pos{Row: 2, Col: 0}, Pos{Row: 2, Col: 2}},
30+ {"left skips wide continuation", (*Navigator).Left, Pos{Row: 2, Col: 2}, Pos{Row: 2, Col: 0}},
31+ }
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ navigator := NewNavigator(tt.from)
35+
36+ require.True(t, tt.move(&navigator, doc))
37+ require.Equal(t, tt.want, navigator.Pos)
38+ require.Equal(t, tt.want.Col, navigator.PreferredCol)
39+ })
40+ }
41+}
42+
43+func TestNavigatorVerticalRetainsPreferredColumn(t *testing.T) {
44+ doc := navigatorDocument()
45+ navigator := NewNavigator(Pos{Row: 0, Col: 5})
46+
47+ require.True(t, navigator.Down(doc))
48+ require.Equal(t, Pos{Row: 1, Col: 1}, navigator.Pos)
49+ require.Equal(t, 5, navigator.PreferredCol)
50+
51+ require.True(t, navigator.Down(doc))
52+ require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos)
53+ require.Equal(t, 5, navigator.PreferredCol)
54+
55+ require.True(t, navigator.Down(doc))
56+ require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos)
57+ require.Equal(t, 5, navigator.PreferredCol)
58+
59+ require.True(t, navigator.Down(doc))
60+ require.Equal(t, Pos{Row: 4, Col: 4}, navigator.Pos)
61+ require.Equal(t, 5, navigator.PreferredCol)
62+
63+ require.True(t, navigator.Up(doc))
64+ require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos)
65+ require.Equal(t, 5, navigator.PreferredCol)
66+
67+ require.True(t, navigator.Up(doc))
68+ require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos)
69+ require.Equal(t, 5, navigator.PreferredCol)
70+}
71+
72+func TestNavigatorWordMotionsCrossRows(t *testing.T) {
73+ doc := navigatorDocument()
74+
75+ tests := []struct {
76+ name string
77+ move func(*Navigator, *Document) bool
78+ from Pos
79+ want Pos
80+ }{
81+ {"word next", (*Navigator).WordNext, Pos{Row: 0, Col: 5}, Pos{Row: 1, Col: 0}},
82+ {"word backward", (*Navigator).WordBackward, Pos{Row: 1, Col: 0}, Pos{Row: 0, Col: 0}},
83+ {"word end", (*Navigator).WordEnd, Pos{Row: 1, Col: 1}, Pos{Row: 2, Col: 2}},
84+ }
85+ for _, tt := range tests {
86+ t.Run(tt.name, func(t *testing.T) {
87+ navigator := NewNavigator(tt.from)
88+
89+ require.True(t, tt.move(&navigator, doc))
90+ require.Equal(t, tt.want, navigator.Pos)
91+ require.Equal(t, tt.want.Col, navigator.PreferredCol)
92+ })
93+ }
94+}
95+
96+func TestNavigatorEmptyTopBottomPageAndSet(t *testing.T) {
97+ doc := navigatorDocument()
98+
99+ t.Run("empty row is stable", func(t *testing.T) {
100+ navigator := NewNavigator(Pos{Row: 2, Col: 2})
101+ require.True(t, navigator.Down(doc))
102+ require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos)
103+ require.False(t, navigator.Right(doc))
104+ require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos)
105+ })
106+
107+ t.Run("top and bottom reset the preferred column", func(t *testing.T) {
108+ navigator := NewNavigator(Pos{Row: 0, Col: 5})
109+ require.True(t, navigator.Down(doc))
110+ require.Equal(t, Pos{Row: 1, Col: 1}, navigator.Pos)
111+ require.Equal(t, 5, navigator.PreferredCol)
112+ require.True(t, navigator.Top(doc))
113+ require.Equal(t, Pos{Row: 0, Col: 1}, navigator.Pos)
114+ require.Equal(t, 1, navigator.PreferredCol)
115+ require.True(t, navigator.Bottom(doc))
116+ require.Equal(t, Pos{Row: 4, Col: 1}, navigator.Pos)
117+ require.Equal(t, 1, navigator.PreferredCol)
118+ })
119+
120+ t.Run("page keeps preferred column", func(t *testing.T) {
121+ navigator := NewNavigator(Pos{Row: 0, Col: 5})
122+ require.True(t, navigator.Page(doc, 2))
123+ require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos)
124+ require.Equal(t, 5, navigator.PreferredCol)
125+ require.True(t, navigator.Page(doc, 2))
126+ require.Equal(t, Pos{Row: 4, Col: 4}, navigator.Pos)
127+ require.Equal(t, 5, navigator.PreferredCol)
128+ })
129+
130+ t.Run("set normalizes continuation and updates preferred column", func(t *testing.T) {
131+ navigator := NewNavigator(Pos{Row: 0, Col: 0})
132+ require.True(t, navigator.Set(doc, Pos{Row: 2, Col: 1}))
133+ require.Equal(t, Pos{Row: 2, Col: 0}, navigator.Pos)
134+ require.Equal(t, 0, navigator.PreferredCol)
135+ require.False(t, navigator.Set(doc, Pos{Row: 9, Col: 0}))
136+ require.Equal(t, Pos{Row: 2, Col: 0}, navigator.Pos)
137+ })
138+}
@@ -0,0 +1,138 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func navigatorDocument() *Document {
11+ return NewDocument(NewSnapshotFromRows([][]renderer.Cell{
12+ documentCells("abcdef"),
13+ documentCells("xy"),
14+ {{Rune: '界'}, {Continuation: true}, {Rune: 'z'}},
15+ {},
16+ documentCells("omega"),
17+ }, 6, 2), " -_@")
18+}
19+
20+func TestNavigatorHorizontalSkipsContinuations(t *testing.T) {
21+ doc := navigatorDocument()
22+
23+ tests := []struct {
24+ name string
25+ move func(*Navigator, *Document) bool
26+ from Pos
27+ want Pos
28+ }{
29+ {"right skips wide continuation", (*Navigator).Right, Pos{Row: 2, Col: 0}, Pos{Row: 2, Col: 2}},
30+ {"left skips wide continuation", (*Navigator).Left, Pos{Row: 2, Col: 2}, Pos{Row: 2, Col: 0}},
31+ }
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ navigator := NewNavigator(tt.from)
35+
36+ require.True(t, tt.move(&navigator, doc))
37+ require.Equal(t, tt.want, navigator.Pos)
38+ require.Equal(t, tt.want.Col, navigator.PreferredCol)
39+ })
40+ }
41+}
42+
43+func TestNavigatorVerticalRetainsPreferredColumn(t *testing.T) {
44+ doc := navigatorDocument()
45+ navigator := NewNavigator(Pos{Row: 0, Col: 5})
46+
47+ require.True(t, navigator.Down(doc))
48+ require.Equal(t, Pos{Row: 1, Col: 1}, navigator.Pos)
49+ require.Equal(t, 5, navigator.PreferredCol)
50+
51+ require.True(t, navigator.Down(doc))
52+ require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos)
53+ require.Equal(t, 5, navigator.PreferredCol)
54+
55+ require.True(t, navigator.Down(doc))
56+ require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos)
57+ require.Equal(t, 5, navigator.PreferredCol)
58+
59+ require.True(t, navigator.Down(doc))
60+ require.Equal(t, Pos{Row: 4, Col: 4}, navigator.Pos)
61+ require.Equal(t, 5, navigator.PreferredCol)
62+
63+ require.True(t, navigator.Up(doc))
64+ require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos)
65+ require.Equal(t, 5, navigator.PreferredCol)
66+
67+ require.True(t, navigator.Up(doc))
68+ require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos)
69+ require.Equal(t, 5, navigator.PreferredCol)
70+}
71+
72+func TestNavigatorWordMotionsCrossRows(t *testing.T) {
73+ doc := navigatorDocument()
74+
75+ tests := []struct {
76+ name string
77+ move func(*Navigator, *Document) bool
78+ from Pos
79+ want Pos
80+ }{
81+ {"word next", (*Navigator).WordNext, Pos{Row: 0, Col: 5}, Pos{Row: 1, Col: 0}},
82+ {"word backward", (*Navigator).WordBackward, Pos{Row: 1, Col: 0}, Pos{Row: 0, Col: 0}},
83+ {"word end", (*Navigator).WordEnd, Pos{Row: 1, Col: 1}, Pos{Row: 2, Col: 2}},
84+ }
85+ for _, tt := range tests {
86+ t.Run(tt.name, func(t *testing.T) {
87+ navigator := NewNavigator(tt.from)
88+
89+ require.True(t, tt.move(&navigator, doc))
90+ require.Equal(t, tt.want, navigator.Pos)
91+ require.Equal(t, tt.want.Col, navigator.PreferredCol)
92+ })
93+ }
94+}
95+
96+func TestNavigatorEmptyTopBottomPageAndSet(t *testing.T) {
97+ doc := navigatorDocument()
98+
99+ t.Run("empty row is stable", func(t *testing.T) {
100+ navigator := NewNavigator(Pos{Row: 2, Col: 2})
101+ require.True(t, navigator.Down(doc))
102+ require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos)
103+ require.False(t, navigator.Right(doc))
104+ require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos)
105+ })
106+
107+ t.Run("top and bottom reset the preferred column", func(t *testing.T) {
108+ navigator := NewNavigator(Pos{Row: 0, Col: 5})
109+ require.True(t, navigator.Down(doc))
110+ require.Equal(t, Pos{Row: 1, Col: 1}, navigator.Pos)
111+ require.Equal(t, 5, navigator.PreferredCol)
112+ require.True(t, navigator.Top(doc))
113+ require.Equal(t, Pos{Row: 0, Col: 1}, navigator.Pos)
114+ require.Equal(t, 1, navigator.PreferredCol)
115+ require.True(t, navigator.Bottom(doc))
116+ require.Equal(t, Pos{Row: 4, Col: 1}, navigator.Pos)
117+ require.Equal(t, 1, navigator.PreferredCol)
118+ })
119+
120+ t.Run("page keeps preferred column", func(t *testing.T) {
121+ navigator := NewNavigator(Pos{Row: 0, Col: 5})
122+ require.True(t, navigator.Page(doc, 2))
123+ require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos)
124+ require.Equal(t, 5, navigator.PreferredCol)
125+ require.True(t, navigator.Page(doc, 2))
126+ require.Equal(t, Pos{Row: 4, Col: 4}, navigator.Pos)
127+ require.Equal(t, 5, navigator.PreferredCol)
128+ })
129+
130+ t.Run("set normalizes continuation and updates preferred column", func(t *testing.T) {
131+ navigator := NewNavigator(Pos{Row: 0, Col: 0})
132+ require.True(t, navigator.Set(doc, Pos{Row: 2, Col: 1}))
133+ require.Equal(t, Pos{Row: 2, Col: 0}, navigator.Pos)
134+ require.Equal(t, 0, navigator.PreferredCol)
135+ require.False(t, navigator.Set(doc, Pos{Row: 9, Col: 0}))
136+ require.Equal(t, Pos{Row: 2, Col: 0}, navigator.Pos)
137+ })
138+}

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 12

Tags 11

↑↓ moveenter openesc close