detached 812d54f

fix(daemon): close pre-pr concurrency gaps

commit
812d54fde7b089dc227d53c4f26c5aeb965b5507
author
brice · · 2026-07-03 11:50 UTC
parent
44ecbad
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +62 -25
M internal/usecase/confirm/confirm.go +1 -5
@@ -27,7 +27,7 @@ func (c Confirmer) Confirm(question string) (bool, error) {
2727 }
2828 }
2929 answer, err := bufio.NewReader(c.in).ReadString('\n')
30- if err != nil && !isEOFWithAnswer(err, answer) {
30+ if err != nil && err != io.EOF {
3131 return false, err
3232 }
3333 switch strings.ToLower(strings.TrimSpace(answer)) {
@@ -37,7 +37,3 @@ func (c Confirmer) Confirm(question string) (bool, error) {
3737 return false, nil
3838 }
3939 }
40-
41-func isEOFWithAnswer(err error, answer string) bool {
42- return err == io.EOF && answer != ""
43-}
@@ -27,7 +27,7 @@ func (c Confirmer) Confirm(question string) (bool, error) {
27 }27 }
28 }28 }
29 answer, err := bufio.NewReader(c.in).ReadString('\n')29 answer, err := bufio.NewReader(c.in).ReadString('\n')
30- if err != nil && !isEOFWithAnswer(err, answer) {30+ if err != nil && err != io.EOF {
31 return false, err31 return false, err
32 }32 }
33 switch strings.ToLower(strings.TrimSpace(answer)) {33 switch strings.ToLower(strings.TrimSpace(answer)) {
@@ -37,7 +37,3 @@ func (c Confirmer) Confirm(question string) (bool, error) {
37 return false, nil37 return false, nil
38 }38 }
39 }39 }
40-
41-func isEOFWithAnswer(err error, answer string) bool {
42- return err == io.EOF && answer != ""
43-}
M internal/usecase/confirm/confirm_test.go +10 -0
@@ -30,6 +30,16 @@ func TestConfirmerDefaultsNo(t *testing.T) {
3030 }
3131 }
3232
33+func TestConfirmerDefaultsNoOnEOF(t *testing.T) {
34+ ok, err := NewConfirmer(strings.NewReader(""), &bytes.Buffer{}).Confirm("Kill daemon?")
35+ if err != nil {
36+ t.Fatalf("Confirm returned error: %v", err)
37+ }
38+ if ok {
39+ t.Fatal("Confirm returned true for EOF, want false")
40+ }
41+}
42+
3343 func TestConfirmerRejectsUnknownAnswer(t *testing.T) {
3444 ok, err := NewConfirmer(strings.NewReader("later\n"), &bytes.Buffer{}).Confirm("Kill daemon?")
3545 if err != nil {
@@ -30,6 +30,16 @@ func TestConfirmerDefaultsNo(t *testing.T) {
30 }30 }
31 }31 }
32 32
33+func TestConfirmerDefaultsNoOnEOF(t *testing.T) {
34+ ok, err := NewConfirmer(strings.NewReader(""), &bytes.Buffer{}).Confirm("Kill daemon?")
35+ if err != nil {
36+ t.Fatalf("Confirm returned error: %v", err)
37+ }
38+ if ok {
39+ t.Fatal("Confirm returned true for EOF, want false")
40+ }
41+}
42+
33 func TestConfirmerRejectsUnknownAnswer(t *testing.T) {43 func TestConfirmerRejectsUnknownAnswer(t *testing.T) {
34 ok, err := NewConfirmer(strings.NewReader("later\n"), &bytes.Buffer{}).Confirm("Kill daemon?")44 ok, err := NewConfirmer(strings.NewReader("later\n"), &bytes.Buffer{}).Confirm("Kill daemon?")
35 if err != nil {45 if err != nil {
M internal/usecase/daemon/client.go +11 -8
@@ -222,7 +222,7 @@ func (d *Daemon) waitNotifies() {
222222 }
223223 }
224224
225-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) *attachedClient {
225+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) {
226226 ac := &attachedClient{
227227 tr: tr,
228228 rend: renderer.New(renderer.Capabilities{}),
@@ -234,15 +234,18 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
234234 old := sess.client
235235 sess.client = ac
236236 sess.mu.Unlock()
237+ return ac, old
238+}
237239
238- if old != nil {
239- d.unregisterPreview(old)
240- old.setSession(nil)
241- // Async + bounded: a dead or wedged old client must not stall the new
242- // client's handshake.
243- d.notifyDetachedAsync(old, ports.ReasonDetach)
240+func (d *Daemon) detachReplacedClient(old *attachedClient) {
241+ if old == nil {
242+ return
244243 }
245- return ac
244+ d.unregisterPreview(old)
245+ old.setSession(nil)
246+ // Async + bounded: a dead or wedged old client must not stall the new
247+ // client's handshake.
248+ d.notifyDetachedAsync(old, ports.ReasonDetach)
246249 }
247250
248251 // firstPaint guarantees the freshly attached client sees the full screen: if
@@ -222,7 +222,7 @@ func (d *Daemon) waitNotifies() {
222 }222 }
223 }223 }
224 224
225-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) *attachedClient {225+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) {
226 ac := &attachedClient{226 ac := &attachedClient{
227 tr: tr,227 tr: tr,
228 rend: renderer.New(renderer.Capabilities{}),228 rend: renderer.New(renderer.Capabilities{}),
@@ -234,15 +234,18 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
234 old := sess.client234 old := sess.client
235 sess.client = ac235 sess.client = ac
236 sess.mu.Unlock()236 sess.mu.Unlock()
237+ return ac, old
238+}
237 239
238- if old != nil {240+func (d *Daemon) detachReplacedClient(old *attachedClient) {
239- d.unregisterPreview(old)241+ if old == nil {
240- old.setSession(nil)242+ return
241- // Async + bounded: a dead or wedged old client must not stall the new
242- // client's handshake.
243- d.notifyDetachedAsync(old, ports.ReasonDetach)
244 }243 }
245- return ac244+ d.unregisterPreview(old)
245+ old.setSession(nil)
246+ // Async + bounded: a dead or wedged old client must not stall the new
247+ // client's handshake.
248+ d.notifyDetachedAsync(old, ports.ReasonDetach)
246 }249 }
247 250
248 // firstPaint guarantees the freshly attached client sees the full screen: if251 // firstPaint guarantees the freshly attached client sees the full screen: if
M internal/usecase/daemon/copymode.go +2 -2
@@ -218,7 +218,7 @@ func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
218218 }
219219
220220 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
221- if len(data) >= 3 && data[1] == '[' {
221+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
222222 switch data[2] {
223223 case 'A':
224224 m.Move(snap, -1)
@@ -242,7 +242,7 @@ func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool
242242 }
243243
244244 func isCopyEscapePrefix(data []byte) bool {
245- return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||
245+ return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') ||
246246 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
247247 }
248248
@@ -218,7 +218,7 @@ func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
218 }218 }
219 219
220 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {220 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
221- if len(data) >= 3 && data[1] == '[' {221+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
222 switch data[2] {222 switch data[2] {
223 case 'A':223 case 'A':
224 m.Move(snap, -1)224 m.Move(snap, -1)
@@ -242,7 +242,7 @@ func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool
242 }242 }
243 243
244 func isCopyEscapePrefix(data []byte) bool {244 func isCopyEscapePrefix(data []byte) bool {
245- return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||245+ return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') ||
246 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')246 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
247 }247 }
248 248
M internal/usecase/daemon/copymode_test.go +1 -0
@@ -211,6 +211,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
211211 {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
212212 {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
213213 {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
214+ {name: "split SS3 up arrow", input: [][]byte{[]byte("\x1bO"), []byte("A")}, wantCursor: 23},
214215 {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
215216 }
216217 for _, tc := range cases {
@@ -211,6 +211,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
211 {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},211 {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
212 {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},212 {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
213 {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},213 {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
214+ {name: "split SS3 up arrow", input: [][]byte{[]byte("\x1bO"), []byte("A")}, wantCursor: 23},
214 {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},215 {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
215 }216 }
216 for _, tc := range cases {217 for _, tc := range cases {
M internal/usecase/daemon/daemon.go +15 -6
@@ -359,11 +359,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
359359 case ports.IntentEphemeral:
360360 name := d.allocEphemeralNameLocked()
361361 sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
362- d.mu.Unlock()
363362 if err != nil {
363+ d.mu.Unlock()
364364 return nil, nil, err
365365 }
366- return sess, d.attachClient(sess, tr, sz), nil
366+ ac, old := d.attachClient(sess, tr, sz)
367+ d.mu.Unlock()
368+ d.detachReplacedClient(old)
369+ return sess, ac, nil
367370
368371 case ports.IntentNew:
369372 if h.Name == "" {
@@ -375,19 +378,25 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
375378 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
376379 }
377380 sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)
378- d.mu.Unlock()
379381 if err != nil {
382+ d.mu.Unlock()
380383 return nil, nil, err
381384 }
382- return sess, d.attachClient(sess, tr, sz), nil
385+ ac, old := d.attachClient(sess, tr, sz)
386+ d.mu.Unlock()
387+ d.detachReplacedClient(old)
388+ return sess, ac, nil
383389
384390 case ports.IntentAttach:
385391 sess := d.findByNameLocked(h.Name)
386- d.mu.Unlock()
387392 if sess == nil {
393+ d.mu.Unlock()
388394 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
389395 }
390- return sess, d.attachClient(sess, tr, sz), nil
396+ ac, old := d.attachClient(sess, tr, sz)
397+ d.mu.Unlock()
398+ d.detachReplacedClient(old)
399+ return sess, ac, nil
391400
392401 default:
393402 d.mu.Unlock()
@@ -359,11 +359,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
359 case ports.IntentEphemeral:359 case ports.IntentEphemeral:
360 name := d.allocEphemeralNameLocked()360 name := d.allocEphemeralNameLocked()
361 sess, err := d.createSessionLocked(name, true, h.Cwd, sz)361 sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
362- d.mu.Unlock()
363 if err != nil {362 if err != nil {
363+ d.mu.Unlock()
364 return nil, nil, err364 return nil, nil, err
365 }365 }
366- return sess, d.attachClient(sess, tr, sz), nil366+ ac, old := d.attachClient(sess, tr, sz)
367+ d.mu.Unlock()
368+ d.detachReplacedClient(old)
369+ return sess, ac, nil
367 370
368 case ports.IntentNew:371 case ports.IntentNew:
369 if h.Name == "" {372 if h.Name == "" {
@@ -375,19 +378,25 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
375 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}378 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
376 }379 }
377 sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)380 sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)
378- d.mu.Unlock()
379 if err != nil {381 if err != nil {
382+ d.mu.Unlock()
380 return nil, nil, err383 return nil, nil, err
381 }384 }
382- return sess, d.attachClient(sess, tr, sz), nil385+ ac, old := d.attachClient(sess, tr, sz)
386+ d.mu.Unlock()
387+ d.detachReplacedClient(old)
388+ return sess, ac, nil
383 389
384 case ports.IntentAttach:390 case ports.IntentAttach:
385 sess := d.findByNameLocked(h.Name)391 sess := d.findByNameLocked(h.Name)
386- d.mu.Unlock()
387 if sess == nil {392 if sess == nil {
393+ d.mu.Unlock()
388 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}394 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
389 }395 }
390- return sess, d.attachClient(sess, tr, sz), nil396+ ac, old := d.attachClient(sess, tr, sz)
397+ d.mu.Unlock()
398+ d.detachReplacedClient(old)
399+ return sess, ac, nil
391 400
392 default:401 default:
393 d.mu.Unlock()402 d.mu.Unlock()
M internal/usecase/daemon/overlay.go +2 -2
@@ -75,7 +75,7 @@ func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
7575 }
7676
7777 func routeOverlayEscape(data []byte, ev overlayEvents) (int, bool) {
78- if len(data) >= 3 && data[1] == '[' {
78+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
7979 switch data[2] {
8080 case 'A':
8181 call(ev.up)
@@ -89,7 +89,7 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (int, bool) {
8989 }
9090
9191 func isOverlayEscapePrefix(data []byte) bool {
92- return len(data) == 2 && data[0] == 0x1b && data[1] == '['
92+ return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O')
9393 }
9494
9595 func call(fn func()) {
@@ -75,7 +75,7 @@ func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
75 }75 }
76 76
77 func routeOverlayEscape(data []byte, ev overlayEvents) (int, bool) {77 func routeOverlayEscape(data []byte, ev overlayEvents) (int, bool) {
78- if len(data) >= 3 && data[1] == '[' {78+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
79 switch data[2] {79 switch data[2] {
80 case 'A':80 case 'A':
81 call(ev.up)81 call(ev.up)
@@ -89,7 +89,7 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (int, bool) {
89 }89 }
90 90
91 func isOverlayEscapePrefix(data []byte) bool {91 func isOverlayEscapePrefix(data []byte) bool {
92- return len(data) == 2 && data[0] == 0x1b && data[1] == '['92+ return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O')
93 }93 }
94 94
95 func call(fn func()) {95 func call(fn func()) {
M internal/usecase/daemon/overlay_test.go +17 -0
@@ -39,6 +39,23 @@ func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) {
3939 require.Equal(t, 2, down)
4040 }
4141
42+func TestRouteOverlayBytesHandlesSS3Arrows(t *testing.T) {
43+ var pending []byte
44+ var up, down int
45+ ev := overlayEvents{
46+ up: func() { up++ },
47+ down: func() { down++ },
48+ }
49+
50+ routeOverlayBytes([]byte("\x1bO"), &pending, ev)
51+ require.Equal(t, []byte("\x1bO"), pending)
52+ routeOverlayBytes([]byte("A\x1bOB"), &pending, ev)
53+
54+ require.Equal(t, 1, up)
55+ require.Equal(t, 1, down)
56+ require.Empty(t, pending)
57+}
58+
4259 func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {
4360 var pending []byte
4461 var backspace, enter, cancel int
@@ -39,6 +39,23 @@ func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) {
39 require.Equal(t, 2, down)39 require.Equal(t, 2, down)
40 }40 }
41 41
42+func TestRouteOverlayBytesHandlesSS3Arrows(t *testing.T) {
43+ var pending []byte
44+ var up, down int
45+ ev := overlayEvents{
46+ up: func() { up++ },
47+ down: func() { down++ },
48+ }
49+
50+ routeOverlayBytes([]byte("\x1bO"), &pending, ev)
51+ require.Equal(t, []byte("\x1bO"), pending)
52+ routeOverlayBytes([]byte("A\x1bOB"), &pending, ev)
53+
54+ require.Equal(t, 1, up)
55+ require.Equal(t, 1, down)
56+ require.Empty(t, pending)
57+}
58+
42 func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {59 func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {
43 var pending []byte60 var pending []byte
44 var backspace, enter, cancel int61 var backspace, enter, cancel int
M internal/usecase/daemon/picker.go +2 -2
@@ -179,7 +179,7 @@ func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
179179 }
180180
181181 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
182- if len(data) >= 3 && data[1] == '[' {
182+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
183183 switch data[2] {
184184 case 'A':
185185 m.Up()
@@ -193,7 +193,7 @@ func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
193193 }
194194
195195 func isPickerEscapePrefix(data []byte) bool {
196- return len(data) == 2 && data[0] == 0x1b && data[1] == '['
196+ return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O')
197197 }
198198
199199 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
@@ -179,7 +179,7 @@ func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
179 }179 }
180 180
181 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {181 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
182- if len(data) >= 3 && data[1] == '[' {182+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
183 switch data[2] {183 switch data[2] {
184 case 'A':184 case 'A':
185 m.Up()185 m.Up()
@@ -193,7 +193,7 @@ func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
193 }193 }
194 194
195 func isPickerEscapePrefix(data []byte) bool {195 func isPickerEscapePrefix(data []byte) bool {
196- return len(data) == 2 && data[0] == 0x1b && data[1] == '['196+ return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O')
197 }197 }
198 198
199 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {199 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
M internal/usecase/daemon/picker_test.go +1 -0
@@ -69,6 +69,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
6969 {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
7070 {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
7171 {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
72+ {name: "split SS3 down arrow", input: [][]byte{[]byte("\x1bO"), []byte("B")}, wantActive: 1},
7273 }
7374 for _, tc := range cases {
7475 t.Run(tc.name, func(t *testing.T) {
@@ -69,6 +69,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
69 {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},69 {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
70 {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},70 {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
71 {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},71 {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
72+ {name: "split SS3 down arrow", input: [][]byte{[]byte("\x1bO"), []byte("B")}, wantActive: 1},
72 }73 }
73 for _, tc := range cases {74 for _, tc := range cases {
74 t.Run(tc.name, func(t *testing.T) {75 t.Run(tc.name, func(t *testing.T) {

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