detached 7913f91

fix(snapshot): retain final snapshots when queue is full

commit
7913f918d28f963f0037518c7d715d0316ba4cdf
author
brice · · 2026-07-13 04:49 UTC
parent
cdb77a1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +142 -4
M internal/usecase/daemon/daemon.go +9 -1
@@ -110,6 +110,11 @@ type Daemon struct {
110110 snapshotWorkerCancel context.CancelFunc
111111 snapshotWorkerDone chan struct{}
112112 snapshotWorkerFlush chan struct{}
113+ snapshotWorkerFinalWake chan struct{}
114+ // snapshotFinalJobs retains immutable terminal captures rejected by the
115+ // bounded regular queue. It is only populated during session teardown and
116+ // drained by the same worker, so persistence remains outside daemon locks.
117+ snapshotFinalJobs []*snapshotCapture
113118 snapshotWorkerClosing bool
114119 snapshotWorkerInFlight *snapshotCapture
115120 restoreDone chan struct{}
@@ -417,8 +422,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
417422 d.waitNotifies()
418423 d.hardCancel()
419424 d.serveCancel()
420- d.stopSnapshotEncodeWorker()
425+ // Wait for handlers before flushing snapshots: a handler that already
426+ // entered killSession may still submit its terminal capture after the
427+ // registry snapshot has been removed.
421428 d.connWg.Wait()
429+ d.stopSnapshotEncodeWorker()
422430 d.shutdownAll(ports.ReasonServerShutdown)
423431 d.sessWg.Wait()
424432 d.waitNotifies()
@@ -110,6 +110,11 @@ type Daemon struct {
110 snapshotWorkerCancel context.CancelFunc110 snapshotWorkerCancel context.CancelFunc
111 snapshotWorkerDone chan struct{}111 snapshotWorkerDone chan struct{}
112 snapshotWorkerFlush chan struct{}112 snapshotWorkerFlush chan struct{}
113+ snapshotWorkerFinalWake chan struct{}
114+ // snapshotFinalJobs retains immutable terminal captures rejected by the
115+ // bounded regular queue. It is only populated during session teardown and
116+ // drained by the same worker, so persistence remains outside daemon locks.
117+ snapshotFinalJobs []*snapshotCapture
113 snapshotWorkerClosing bool118 snapshotWorkerClosing bool
114 snapshotWorkerInFlight *snapshotCapture119 snapshotWorkerInFlight *snapshotCapture
115 restoreDone chan struct{}120 restoreDone chan struct{}
@@ -417,8 +422,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
417 d.waitNotifies()422 d.waitNotifies()
418 d.hardCancel()423 d.hardCancel()
419 d.serveCancel()424 d.serveCancel()
420- d.stopSnapshotEncodeWorker()425+ // Wait for handlers before flushing snapshots: a handler that already
426+ // entered killSession may still submit its terminal capture after the
427+ // registry snapshot has been removed.
421 d.connWg.Wait()428 d.connWg.Wait()
429+ d.stopSnapshotEncodeWorker()
422 d.shutdownAll(ports.ReasonServerShutdown)430 d.shutdownAll(ports.ReasonServerShutdown)
423 d.sessWg.Wait()431 d.sessWg.Wait()
424 d.waitNotifies()432 d.waitNotifies()
M internal/usecase/daemon/session.go +1 -1
@@ -679,7 +679,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
679679 }
680680 } else {
681681 markSnapshotDirty(sess)
682- d.scheduleSnapshot(sess)
682+ d.scheduleFinalSnapshot(sess)
683683 }
684684 }
685685 d.mu.Lock()
@@ -679,7 +679,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
679 }679 }
680 } else {680 } else {
681 markSnapshotDirty(sess)681 markSnapshotDirty(sess)
682- d.scheduleSnapshot(sess)682+ d.scheduleFinalSnapshot(sess)
683 }683 }
684 }684 }
685 d.mu.Lock()685 d.mu.Lock()
M internal/usecase/daemon/snapshot.go +63 -2
@@ -315,6 +315,17 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
315315 // scheduleSnapshot captures a session only once while an immutable capture is
316316 // queued or being written. It never waits for the bounded worker queue.
317317 func (d *Daemon) scheduleSnapshot(sess *session) bool {
318+ return d.scheduleSnapshotWithFinalFallback(sess, false)
319+}
320+
321+// scheduleFinalSnapshot retains a terminal capture rejected by the regular
322+// bounded queue. A removed session cannot be retried by snapshotSaver, so the
323+// worker drains this fallback before it stops.
324+func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
325+ return d.scheduleSnapshotWithFinalFallback(sess, true)
326+}
327+
328+func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
318329 if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {
319330 return true
320331 }
@@ -333,7 +344,7 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
333344 d.finishSnapshotCapture(capture, false)
334345 return false
335346 }
336- if d.enqueueSnapshotCapture(capture) {
347+ if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
337348 return true
338349 }
339350 // Coalesce under saturation or shutdown: the latest state stays dirty and
@@ -359,6 +370,22 @@ func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
359370 }
360371 }
361372
373+// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
374+// without making session teardown wait behind its normal bounded queue.
375+func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
376+ d.snapshotWorkerMu.Lock()
377+ defer d.snapshotWorkerMu.Unlock()
378+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
379+ return false
380+ }
381+ d.snapshotFinalJobs = append(d.snapshotFinalJobs, capture)
382+ select {
383+ case d.snapshotWorkerFinalWake <- struct{}{}:
384+ default:
385+ }
386+ return true
387+}
388+
362389 // captureSession rotates history tails and clones visible frames while holding
363390 // each pane lock. The returned capture contains only immutable state; encoding
364391 // and persistence are deliberately deferred to snapshotEncodeWorker.
@@ -495,10 +522,13 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
495522 d.snapshotWorkerCancel = cancel
496523 d.snapshotWorkerDone = make(chan struct{})
497524 d.snapshotWorkerFlush = make(chan struct{})
525+ d.snapshotWorkerFinalWake = make(chan struct{}, 1)
526+ d.snapshotFinalJobs = nil
498527 d.snapshotWorkerClosing = false
499528 d.snapshotWorkerInFlight = nil
500529 done := d.snapshotWorkerDone
501530 flush := d.snapshotWorkerFlush
531+ finalWake := d.snapshotWorkerFinalWake
502532 d.snapshotWorkerMu.Unlock()
503533 go func() {
504534 defer close(done)
@@ -518,6 +548,17 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
518548 d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
519549 return workerCtx.Err() == nil
520550 }
551+ drainFinal := func() bool {
552+ for {
553+ capture := d.takeFinalSnapshotCapture()
554+ if capture == nil {
555+ return true
556+ }
557+ if !write(capture) {
558+ return false
559+ }
560+ }
561+ }
521562 flushQueued := func() {
522563 for {
523564 select {
@@ -526,6 +567,7 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
526567 return
527568 }
528569 default:
570+ _ = drainFinal()
529571 return
530572 }
531573 }
@@ -537,6 +579,10 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
537579 case <-flush:
538580 flushQueued()
539581 return
582+ case <-finalWake:
583+ if !drainFinal() {
584+ return
585+ }
540586 case capture := <-d.snapshotJobs:
541587 if !write(capture) {
542588 return
@@ -546,6 +592,18 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
546592 }()
547593 }
548594
595+func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
596+ d.snapshotWorkerMu.Lock()
597+ defer d.snapshotWorkerMu.Unlock()
598+ if len(d.snapshotFinalJobs) == 0 {
599+ return nil
600+ }
601+ capture := d.snapshotFinalJobs[0]
602+ d.snapshotFinalJobs[0] = nil
603+ d.snapshotFinalJobs = d.snapshotFinalJobs[1:]
604+ return capture
605+}
606+
549607 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
550608 d.snapshotWorkerMu.Lock()
551609 defer d.snapshotWorkerMu.Unlock()
@@ -601,9 +659,12 @@ func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
601659 d.snapshotWorkerCancel = nil
602660 d.snapshotWorkerDone = nil
603661 d.snapshotWorkerFlush = nil
662+ d.snapshotWorkerFinalWake = nil
604663 d.snapshotWorkerClosing = false
605664 d.snapshotWorkerInFlight = nil
606- queued := make([]*snapshotCapture, 0, len(d.snapshotJobs))
665+ queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))
666+ queued = append(queued, d.snapshotFinalJobs...)
667+ d.snapshotFinalJobs = nil
607668 for {
608669 select {
609670 case capture := <-d.snapshotJobs:
@@ -315,6 +315,17 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
315 // scheduleSnapshot captures a session only once while an immutable capture is315 // scheduleSnapshot captures a session only once while an immutable capture is
316 // queued or being written. It never waits for the bounded worker queue.316 // queued or being written. It never waits for the bounded worker queue.
317 func (d *Daemon) scheduleSnapshot(sess *session) bool {317 func (d *Daemon) scheduleSnapshot(sess *session) bool {
318+ return d.scheduleSnapshotWithFinalFallback(sess, false)
319+}
320+
321+// scheduleFinalSnapshot retains a terminal capture rejected by the regular
322+// bounded queue. A removed session cannot be retried by snapshotSaver, so the
323+// worker drains this fallback before it stops.
324+func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
325+ return d.scheduleSnapshotWithFinalFallback(sess, true)
326+}
327+
328+func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
318 if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {329 if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {
319 return true330 return true
320 }331 }
@@ -333,7 +344,7 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
333 d.finishSnapshotCapture(capture, false)344 d.finishSnapshotCapture(capture, false)
334 return false345 return false
335 }346 }
336- if d.enqueueSnapshotCapture(capture) {347+ if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
337 return true348 return true
338 }349 }
339 // Coalesce under saturation or shutdown: the latest state stays dirty and350 // Coalesce under saturation or shutdown: the latest state stays dirty and
@@ -359,6 +370,22 @@ func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
359 }370 }
360 }371 }
361 372
373+// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
374+// without making session teardown wait behind its normal bounded queue.
375+func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
376+ d.snapshotWorkerMu.Lock()
377+ defer d.snapshotWorkerMu.Unlock()
378+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
379+ return false
380+ }
381+ d.snapshotFinalJobs = append(d.snapshotFinalJobs, capture)
382+ select {
383+ case d.snapshotWorkerFinalWake <- struct{}{}:
384+ default:
385+ }
386+ return true
387+}
388+
362 // captureSession rotates history tails and clones visible frames while holding389 // captureSession rotates history tails and clones visible frames while holding
363 // each pane lock. The returned capture contains only immutable state; encoding390 // each pane lock. The returned capture contains only immutable state; encoding
364 // and persistence are deliberately deferred to snapshotEncodeWorker.391 // and persistence are deliberately deferred to snapshotEncodeWorker.
@@ -495,10 +522,13 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
495 d.snapshotWorkerCancel = cancel522 d.snapshotWorkerCancel = cancel
496 d.snapshotWorkerDone = make(chan struct{})523 d.snapshotWorkerDone = make(chan struct{})
497 d.snapshotWorkerFlush = make(chan struct{})524 d.snapshotWorkerFlush = make(chan struct{})
525+ d.snapshotWorkerFinalWake = make(chan struct{}, 1)
526+ d.snapshotFinalJobs = nil
498 d.snapshotWorkerClosing = false527 d.snapshotWorkerClosing = false
499 d.snapshotWorkerInFlight = nil528 d.snapshotWorkerInFlight = nil
500 done := d.snapshotWorkerDone529 done := d.snapshotWorkerDone
501 flush := d.snapshotWorkerFlush530 flush := d.snapshotWorkerFlush
531+ finalWake := d.snapshotWorkerFinalWake
502 d.snapshotWorkerMu.Unlock()532 d.snapshotWorkerMu.Unlock()
503 go func() {533 go func() {
504 defer close(done)534 defer close(done)
@@ -518,6 +548,17 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
518 d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)548 d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
519 return workerCtx.Err() == nil549 return workerCtx.Err() == nil
520 }550 }
551+ drainFinal := func() bool {
552+ for {
553+ capture := d.takeFinalSnapshotCapture()
554+ if capture == nil {
555+ return true
556+ }
557+ if !write(capture) {
558+ return false
559+ }
560+ }
561+ }
521 flushQueued := func() {562 flushQueued := func() {
522 for {563 for {
523 select {564 select {
@@ -526,6 +567,7 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
526 return567 return
527 }568 }
528 default:569 default:
570+ _ = drainFinal()
529 return571 return
530 }572 }
531 }573 }
@@ -537,6 +579,10 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
537 case <-flush:579 case <-flush:
538 flushQueued()580 flushQueued()
539 return581 return
582+ case <-finalWake:
583+ if !drainFinal() {
584+ return
585+ }
540 case capture := <-d.snapshotJobs:586 case capture := <-d.snapshotJobs:
541 if !write(capture) {587 if !write(capture) {
542 return588 return
@@ -546,6 +592,18 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
546 }()592 }()
547 }593 }
548 594
595+func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
596+ d.snapshotWorkerMu.Lock()
597+ defer d.snapshotWorkerMu.Unlock()
598+ if len(d.snapshotFinalJobs) == 0 {
599+ return nil
600+ }
601+ capture := d.snapshotFinalJobs[0]
602+ d.snapshotFinalJobs[0] = nil
603+ d.snapshotFinalJobs = d.snapshotFinalJobs[1:]
604+ return capture
605+}
606+
549 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {607 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
550 d.snapshotWorkerMu.Lock()608 d.snapshotWorkerMu.Lock()
551 defer d.snapshotWorkerMu.Unlock()609 defer d.snapshotWorkerMu.Unlock()
@@ -601,9 +659,12 @@ func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
601 d.snapshotWorkerCancel = nil659 d.snapshotWorkerCancel = nil
602 d.snapshotWorkerDone = nil660 d.snapshotWorkerDone = nil
603 d.snapshotWorkerFlush = nil661 d.snapshotWorkerFlush = nil
662+ d.snapshotWorkerFinalWake = nil
604 d.snapshotWorkerClosing = false663 d.snapshotWorkerClosing = false
605 d.snapshotWorkerInFlight = nil664 d.snapshotWorkerInFlight = nil
606- queued := make([]*snapshotCapture, 0, len(d.snapshotJobs))665+ queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))
666+ queued = append(queued, d.snapshotFinalJobs...)
667+ d.snapshotFinalJobs = nil
607 for {668 for {
608 select {669 select {
609 case capture := <-d.snapshotJobs:670 case capture := <-d.snapshotJobs:
M internal/usecase/daemon/snapshot_test.go +69 -0
@@ -287,6 +287,75 @@ func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) {
287287 awaitSnapshotClean(t, third)
288288 }
289289
290+func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) {
291+ for _, tc := range []struct {
292+ name string
293+ stop func(t *testing.T, d *Daemon, sess *session)
294+ }{
295+ {
296+ name: "kill session",
297+ stop: func(t *testing.T, d *Daemon, sess *session) {
298+ t.Helper()
299+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
300+ },
301+ },
302+ {
303+ name: "shutdown all",
304+ stop: func(t *testing.T, d *Daemon, _ *session) {
305+ t.Helper()
306+ d.shutdownAll(ports.ReasonServerShutdown)
307+ },
308+ },
309+ } {
310+ t.Run(tc.name, func(t *testing.T) {
311+ store := &channelSnapshotStore{writes: make(chan []byte, 3)}
312+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
313+ WithSnapshotStore(store)(d)
314+ startSnapshotEncodeWorker(t, d)
315+
316+ entered := make(chan struct{})
317+ release := make(chan struct{})
318+ var calls atomic.Int32
319+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
320+ if calls.Add(1) == 1 {
321+ close(entered)
322+ <-release
323+ }
324+ return snapcodec.Marshal(s)
325+ }
326+ first := newSnapshotTestSession(t, "first", false, "/work")
327+ second := newSnapshotTestSession(t, "second", false, "/work")
328+ final := newSnapshotTestSession(t, "final", false, "/work")
329+ final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY).EXPECT().Close().Return(nil).Once()
330+ markSnapshotDirty(first)
331+ markSnapshotDirty(second)
332+ require.True(t, d.scheduleSnapshot(first))
333+ <-entered
334+ require.True(t, d.scheduleSnapshot(second))
335+ d.sessions[final.id] = final
336+
337+ tc.stop(t, d, final)
338+ require.NotContains(t, d.sessions, final.id, "final capture must survive session removal")
339+ close(release)
340+
341+ written := make(map[string]bool, 3)
342+ deadline := time.NewTimer(time.Second)
343+ defer deadline.Stop()
344+ for len(written) < 3 {
345+ select {
346+ case data := <-store.writes:
347+ snap, err := snapcodec.Unmarshal(data)
348+ require.NoError(t, err)
349+ written[snap.Name] = true
350+ case <-deadline.C:
351+ t.Fatalf("final snapshot was dropped: wrote %v", written)
352+ }
353+ }
354+ require.True(t, written[final.name])
355+ })
356+ }
357+}
358+
290359 func TestSnapshotCompletionSignalsWaiters(t *testing.T) {
291360 store := &channelSnapshotStore{writes: make(chan []byte, 1)}
292361 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
@@ -287,6 +287,75 @@ func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) {
287 awaitSnapshotClean(t, third)287 awaitSnapshotClean(t, third)
288 }288 }
289 289
290+func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) {
291+ for _, tc := range []struct {
292+ name string
293+ stop func(t *testing.T, d *Daemon, sess *session)
294+ }{
295+ {
296+ name: "kill session",
297+ stop: func(t *testing.T, d *Daemon, sess *session) {
298+ t.Helper()
299+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
300+ },
301+ },
302+ {
303+ name: "shutdown all",
304+ stop: func(t *testing.T, d *Daemon, _ *session) {
305+ t.Helper()
306+ d.shutdownAll(ports.ReasonServerShutdown)
307+ },
308+ },
309+ } {
310+ t.Run(tc.name, func(t *testing.T) {
311+ store := &channelSnapshotStore{writes: make(chan []byte, 3)}
312+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
313+ WithSnapshotStore(store)(d)
314+ startSnapshotEncodeWorker(t, d)
315+
316+ entered := make(chan struct{})
317+ release := make(chan struct{})
318+ var calls atomic.Int32
319+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
320+ if calls.Add(1) == 1 {
321+ close(entered)
322+ <-release
323+ }
324+ return snapcodec.Marshal(s)
325+ }
326+ first := newSnapshotTestSession(t, "first", false, "/work")
327+ second := newSnapshotTestSession(t, "second", false, "/work")
328+ final := newSnapshotTestSession(t, "final", false, "/work")
329+ final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY).EXPECT().Close().Return(nil).Once()
330+ markSnapshotDirty(first)
331+ markSnapshotDirty(second)
332+ require.True(t, d.scheduleSnapshot(first))
333+ <-entered
334+ require.True(t, d.scheduleSnapshot(second))
335+ d.sessions[final.id] = final
336+
337+ tc.stop(t, d, final)
338+ require.NotContains(t, d.sessions, final.id, "final capture must survive session removal")
339+ close(release)
340+
341+ written := make(map[string]bool, 3)
342+ deadline := time.NewTimer(time.Second)
343+ defer deadline.Stop()
344+ for len(written) < 3 {
345+ select {
346+ case data := <-store.writes:
347+ snap, err := snapcodec.Unmarshal(data)
348+ require.NoError(t, err)
349+ written[snap.Name] = true
350+ case <-deadline.C:
351+ t.Fatalf("final snapshot was dropped: wrote %v", written)
352+ }
353+ }
354+ require.True(t, written[final.name])
355+ })
356+ }
357+}
358+
290 func TestSnapshotCompletionSignalsWaiters(t *testing.T) {359 func TestSnapshotCompletionSignalsWaiters(t *testing.T) {
291 store := &channelSnapshotStore{writes: make(chan []byte, 1)}360 store := &channelSnapshotStore{writes: make(chan []byte, 1)}
292 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})361 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})

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