detached 2f744a4

fix(daemon): stabilize transactional resize completion

commit
2f744a4caeeadccda766d52aee2267aa1d70a9e5
author
brice · · 2026-07-12 09:36 UTC
parent
bf46590
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +63 -10
M internal/usecase/daemon/floating.go +14 -0
@@ -306,6 +306,20 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
306306 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
307307 // occurs under tb.mu; the pane is fully initialized before publication.
308308 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
309+ // Launch workers are allowed to outlive the action that queued them, but
310+ // never session teardown. Check both cancellation and daemon ownership
311+ // immediately before the irreversible external Open.
312+ select {
313+ case <-spec.parentCtx.Done():
314+ return
315+ default:
316+ }
317+ d.mu.Lock()
318+ live := !d.closing && d.sessions[sess.id] == sess
319+ d.mu.Unlock()
320+ if !live {
321+ return
322+ }
309323 pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
310324 if err != nil {
311325 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
@@ -306,6 +306,20 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
306 // openAndInstallFloating runs entirely in the launch worker. No PTY operation306 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
307 // occurs under tb.mu; the pane is fully initialized before publication.307 // occurs under tb.mu; the pane is fully initialized before publication.
308 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {308 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
309+ // Launch workers are allowed to outlive the action that queued them, but
310+ // never session teardown. Check both cancellation and daemon ownership
311+ // immediately before the irreversible external Open.
312+ select {
313+ case <-spec.parentCtx.Done():
314+ return
315+ default:
316+ }
317+ d.mu.Lock()
318+ live := !d.closing && d.sessions[sess.id] == sess
319+ d.mu.Unlock()
320+ if !live {
321+ return
322+ }
309 pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)323 pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
310 if err != nil {324 if err != nil {
311 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)325 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
M internal/usecase/daemon/picker_test.go +1 -1
@@ -258,7 +258,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
258258 awaitFrame(t, sends1, ports.MsgOutput)
259259 }
260260
261-func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
261+func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
262262 p1, releasePTY1 := newBlockingPTY(t)
263263 p2, releasePTY2 := newBlockingPTY(t)
264264 defer releasePTY1()
@@ -258,7 +258,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
258 awaitFrame(t, sends1, ports.MsgOutput)258 awaitFrame(t, sends1, ports.MsgOutput)
259 }259 }
260 260
261-func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {261+func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
262 p1, releasePTY1 := newBlockingPTY(t)262 p1, releasePTY1 := newBlockingPTY(t)
263 p2, releasePTY2 := newBlockingPTY(t)263 p2, releasePTY2 := newBlockingPTY(t)
264 defer releasePTY1()264 defer releasePTY1()
M internal/usecase/daemon/render_coordinator.go +19 -1
@@ -164,7 +164,11 @@ type renderCoordinator struct {
164164 // resize burst coalescing and is cancelled by the same lifecycle changes.
165165 resizeTimer ports.Timer
166166 resizeCancel chan struct{}
167- resizeGen uint64
167+ // resizeDone closes after the latest timer callback has either been
168+ // rejected or completed its transaction. It provides a completion edge
169+ // without polling scheduler state.
170+ resizeDone chan struct{}
171+ resizeGen uint64
168172 // retry is a separate, latest-committed-epoch retry lane. It is cancelled
169173 // with resize requests and attachment lifecycle changes; callbacks also
170174 // validate their epoch before touching a pane.
@@ -783,13 +787,16 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
783787 c.resizeGen++
784788 gen := c.resizeGen
785789 cancel := make(chan struct{})
790+ done := make(chan struct{})
786791 c.resizeCancel = cancel
792+ c.resizeDone = done
787793 clock := c.opts.clock
788794 c.resizeTimer = nil
789795 c.mu.Unlock()
790796 stopTimer(old)
791797 if clock == nil {
792798 run(epoch)
799+ close(done)
793800 return epoch
794801 }
795802 timer := clock.NewTimer(minOutputRenderDeadline)
@@ -797,17 +804,20 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
797804 if timerC == nil {
798805 stopTimer(timer)
799806 run(epoch)
807+ close(done)
800808 return epoch
801809 }
802810 c.mu.Lock()
803811 if c.resizeGen != gen || c.torndown {
804812 c.mu.Unlock()
805813 stopTimer(timer)
814+ close(done)
806815 return epoch
807816 }
808817 c.resizeTimer = timer
809818 c.mu.Unlock()
810819 go func() {
820+ defer close(done)
811821 select {
812822 case <-timerC:
813823 case <-cancel:
@@ -912,6 +922,14 @@ func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) b
912922 return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
913923 }
914924
925+// resizeCallbackDone returns the completion edge for the latest scheduled
926+// resize callback. A nil result means no resize deadline is pending.
927+func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} {
928+ c.mu.Lock()
929+ defer c.mu.Unlock()
930+ return c.resizeDone
931+}
932+
915933 // resizeSnapshot returns a locked copy of the latest resize metadata.
916934 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
917935 c.mu.Lock()
@@ -164,7 +164,11 @@ type renderCoordinator struct {
164 // resize burst coalescing and is cancelled by the same lifecycle changes.164 // resize burst coalescing and is cancelled by the same lifecycle changes.
165 resizeTimer ports.Timer165 resizeTimer ports.Timer
166 resizeCancel chan struct{}166 resizeCancel chan struct{}
167- resizeGen uint64167+ // resizeDone closes after the latest timer callback has either been
168+ // rejected or completed its transaction. It provides a completion edge
169+ // without polling scheduler state.
170+ resizeDone chan struct{}
171+ resizeGen uint64
168 // retry is a separate, latest-committed-epoch retry lane. It is cancelled172 // retry is a separate, latest-committed-epoch retry lane. It is cancelled
169 // with resize requests and attachment lifecycle changes; callbacks also173 // with resize requests and attachment lifecycle changes; callbacks also
170 // validate their epoch before touching a pane.174 // validate their epoch before touching a pane.
@@ -783,13 +787,16 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
783 c.resizeGen++787 c.resizeGen++
784 gen := c.resizeGen788 gen := c.resizeGen
785 cancel := make(chan struct{})789 cancel := make(chan struct{})
790+ done := make(chan struct{})
786 c.resizeCancel = cancel791 c.resizeCancel = cancel
792+ c.resizeDone = done
787 clock := c.opts.clock793 clock := c.opts.clock
788 c.resizeTimer = nil794 c.resizeTimer = nil
789 c.mu.Unlock()795 c.mu.Unlock()
790 stopTimer(old)796 stopTimer(old)
791 if clock == nil {797 if clock == nil {
792 run(epoch)798 run(epoch)
799+ close(done)
793 return epoch800 return epoch
794 }801 }
795 timer := clock.NewTimer(minOutputRenderDeadline)802 timer := clock.NewTimer(minOutputRenderDeadline)
@@ -797,17 +804,20 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
797 if timerC == nil {804 if timerC == nil {
798 stopTimer(timer)805 stopTimer(timer)
799 run(epoch)806 run(epoch)
807+ close(done)
800 return epoch808 return epoch
801 }809 }
802 c.mu.Lock()810 c.mu.Lock()
803 if c.resizeGen != gen || c.torndown {811 if c.resizeGen != gen || c.torndown {
804 c.mu.Unlock()812 c.mu.Unlock()
805 stopTimer(timer)813 stopTimer(timer)
814+ close(done)
806 return epoch815 return epoch
807 }816 }
808 c.resizeTimer = timer817 c.resizeTimer = timer
809 c.mu.Unlock()818 c.mu.Unlock()
810 go func() {819 go func() {
820+ defer close(done)
811 select {821 select {
812 case <-timerC:822 case <-timerC:
813 case <-cancel:823 case <-cancel:
@@ -912,6 +922,14 @@ func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) b
912 return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch922 return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
913 }923 }
914 924
925+// resizeCallbackDone returns the completion edge for the latest scheduled
926+// resize callback. A nil result means no resize deadline is pending.
927+func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} {
928+ c.mu.Lock()
929+ defer c.mu.Unlock()
930+ return c.resizeDone
931+}
932+
915 // resizeSnapshot returns a locked copy of the latest resize metadata.933 // resizeSnapshot returns a locked copy of the latest resize metadata.
916 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {934 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
917 c.mu.Lock()935 c.mu.Lock()
M internal/usecase/daemon/render_coordinator_publication_test.go +1 -1
@@ -16,7 +16,7 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin
1616 rc.attach(old)
1717 rc.noteReplace(old, replacement)
1818
19- // This models a PR #71 resize callback that passed its sendMu generation
19+ // This models a stale resize callback that passed its sendMu generation
2020 // check immediately before a replacement was published.
2121 rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"})
2222 require.Empty(t, invalidations)
@@ -16,7 +16,7 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin
16 rc.attach(old)16 rc.attach(old)
17 rc.noteReplace(old, replacement)17 rc.noteReplace(old, replacement)
18 18
19- // This models a PR #71 resize callback that passed its sendMu generation19+ // This models a stale resize callback that passed its sendMu generation
20 // check immediately before a replacement was published.20 // check immediately before a replacement was published.
21 rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"})21 rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"})
22 require.Empty(t, invalidations)22 require.Empty(t, invalidations)
M internal/usecase/daemon/render_coordinator_test.go +18 -3
@@ -198,6 +198,15 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
198198 }
199199 }
200200
201+func awaitResizeCallback(t *testing.T, rc *renderCoordinator) {
202+ t.Helper()
203+ done := rc.resizeCallbackDone()
204+ if done == nil {
205+ t.Fatal("coordinator did not publish a resize callback completion")
206+ }
207+ <-done
208+}
209+
201210 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
202211 t.Helper()
203212 select {
@@ -1023,7 +1032,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
10231032 h.rc.attach(staleOwner)
10241033 require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
10251034
1026- // This models a retained PR #71 callback which captured the old
1035+ // This models a stale callback which captured the old
10271036 // attachment before lifecycle ownership changed.
10281037 staleCallback := func() uint64 {
10291038 return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
@@ -1164,9 +1173,13 @@ func TestProducerInvalidations(t *testing.T) {
11641173 name: "retained resize dispatch",
11651174 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
11661175 clk := newCoordinatorMockClock(t, 2)
1167- d.clock = clk.clock
1176+ // The coordinator owns deadline time; install the deterministic
1177+ // clock on the already-attached coordinator rather than changing
1178+ // Daemon's construction-time clock afterwards.
1179+ sess.renderCoordinator().opts.clock = clk.clock
11681180 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
11691181 awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}
1182+ awaitResizeCallback(t, sess.renderCoordinator())
11701183 },
11711184 },
11721185 {
@@ -1232,7 +1245,7 @@ func TestProducerInvalidationInventory(t *testing.T) {
12321245 }
12331246 }
12341247
1235-// --- retained PR #71 resize dispatch ----------------------------------------------
1248+// --- coordinator resize dispatch ----------------------------------------------
12361249
12371250 func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
12381251 p, release := newBlockingPTY(t)
@@ -1481,6 +1494,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
14811494 requireNoCoordinatorOutputFrame(t, sends)
14821495
14831496 timer.ch <- time.Time{}
1497+ awaitResizeCallback(t, sess.renderCoordinator())
14841498 inv := awaitInvalidation(t, invs)
14851499 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
14861500 requireNoInvalidation(t, invs)
@@ -1502,6 +1516,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
15021516 requireNoCoordinatorOutputFrame(t, sends)
15031517
15041518 latest.ch <- time.Time{}
1519+ awaitResizeCallback(t, sess.renderCoordinator())
15051520 inv := awaitInvalidation(t, invs)
15061521 require.True(t, inv.reset)
15071522 requireNoInvalidation(t, invs)
@@ -198,6 +198,15 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
198 }198 }
199 }199 }
200 200
201+func awaitResizeCallback(t *testing.T, rc *renderCoordinator) {
202+ t.Helper()
203+ done := rc.resizeCallbackDone()
204+ if done == nil {
205+ t.Fatal("coordinator did not publish a resize callback completion")
206+ }
207+ <-done
208+}
209+
201 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {210 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
202 t.Helper()211 t.Helper()
203 select {212 select {
@@ -1023,7 +1032,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1023 h.rc.attach(staleOwner)1032 h.rc.attach(staleOwner)
1024 require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))1033 require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
1025 1034
1026- // This models a retained PR #71 callback which captured the old1035+ // This models a stale callback which captured the old
1027 // attachment before lifecycle ownership changed.1036 // attachment before lifecycle ownership changed.
1028 staleCallback := func() uint64 {1037 staleCallback := func() uint64 {
1029 return h.rc.recordResizeRequest(sz(80, 20), staleOwner)1038 return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
@@ -1164,9 +1173,13 @@ func TestProducerInvalidations(t *testing.T) {
1164 name: "retained resize dispatch",1173 name: "retained resize dispatch",
1165 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {1174 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1166 clk := newCoordinatorMockClock(t, 2)1175 clk := newCoordinatorMockClock(t, 2)
1167- d.clock = clk.clock1176+ // The coordinator owns deadline time; install the deterministic
1177+ // clock on the already-attached coordinator rather than changing
1178+ // Daemon's construction-time clock afterwards.
1179+ sess.renderCoordinator().opts.clock = clk.clock
1168 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})1180 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
1169 awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}1181 awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}
1182+ awaitResizeCallback(t, sess.renderCoordinator())
1170 },1183 },
1171 },1184 },
1172 {1185 {
@@ -1232,7 +1245,7 @@ func TestProducerInvalidationInventory(t *testing.T) {
1232 }1245 }
1233 }1246 }
1234 1247
1235-// --- retained PR #71 resize dispatch ----------------------------------------------1248+// --- coordinator resize dispatch ----------------------------------------------
1236 1249
1237 func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {1250 func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
1238 p, release := newBlockingPTY(t)1251 p, release := newBlockingPTY(t)
@@ -1481,6 +1494,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1481 requireNoCoordinatorOutputFrame(t, sends)1494 requireNoCoordinatorOutputFrame(t, sends)
1482 1495
1483 timer.ch <- time.Time{}1496 timer.ch <- time.Time{}
1497+ awaitResizeCallback(t, sess.renderCoordinator())
1484 inv := awaitInvalidation(t, invs)1498 inv := awaitInvalidation(t, invs)
1485 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")1499 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
1486 requireNoInvalidation(t, invs)1500 requireNoInvalidation(t, invs)
@@ -1502,6 +1516,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1502 requireNoCoordinatorOutputFrame(t, sends)1516 requireNoCoordinatorOutputFrame(t, sends)
1503 1517
1504 latest.ch <- time.Time{}1518 latest.ch <- time.Time{}
1519+ awaitResizeCallback(t, sess.renderCoordinator())
1505 inv := awaitInvalidation(t, invs)1520 inv := awaitInvalidation(t, invs)
1506 require.True(t, inv.reset)1521 require.True(t, inv.reset)
1507 requireNoInvalidation(t, invs)1522 requireNoInvalidation(t, invs)
M internal/usecase/daemon/status_test.go +1 -1
@@ -1332,7 +1332,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
13321332 }, 2*time.Second, time.Millisecond)
13331333
13341334 // The queued create, switch, and resize transitions may collapse into one
1335- // latest-state wake. Advance every retained PR #71/coordinator deadline
1335+ // latest-state wake. Advance every retained coordinator deadline
13361336 // until that coalesced output is observable.
13371337 var resized ports.Frame
13381338 deadline := time.NewTimer(2 * time.Second)
@@ -1332,7 +1332,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1332 }, 2*time.Second, time.Millisecond)1332 }, 2*time.Second, time.Millisecond)
1333 1333
1334 // The queued create, switch, and resize transitions may collapse into one1334 // The queued create, switch, and resize transitions may collapse into one
1335- // latest-state wake. Advance every retained PR #71/coordinator deadline1335+ // latest-state wake. Advance every retained coordinator deadline
1336 // until that coalesced output is observable.1336 // until that coalesced output is observable.
1337 var resized ports.Frame1337 var resized ports.Frame
1338 deadline := time.NewTimer(2 * time.Second)1338 deadline := time.NewTimer(2 * time.Second)
M internal/usecase/daemon/transactional_resize.go +8 -0
@@ -43,6 +43,14 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
4343 }
4444 }
4545 }
46+ } else if tb.tree.Root.Kind == layout.Leaf {
47+ // A single pane remains a valid PTY target even when a tiny client
48+ // is below the interactive layout minimum. Keeping it live avoids
49+ // stale screen dimensions (and lost scrollback) until the client
50+ // grows back into a solvable layout.
51+ if p := tb.panes[tb.tree.Root.Leaf]; p != nil {
52+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: area})
53+ }
4654 }
4755 }
4856 // Hidden retained popups enter this same primitive when shown; only a
@@ -43,6 +43,14 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
43 }43 }
44 }44 }
45 }45 }
46+ } else if tb.tree.Root.Kind == layout.Leaf {
47+ // A single pane remains a valid PTY target even when a tiny client
48+ // is below the interactive layout minimum. Keeping it live avoids
49+ // stale screen dimensions (and lost scrollback) until the client
50+ // grows back into a solvable layout.
51+ if p := tb.panes[tb.tree.Root.Leaf]; p != nil {
52+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: area})
53+ }
46 }54 }
47 }55 }
48 // Hidden retained popups enter this same primitive when shown; only a56 // Hidden retained popups enter this same primitive when shown; only a
M internal/usecase/daemon/transactional_resize_test.go +1 -3
@@ -185,9 +185,7 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
185185 for _, timer := range timers {
186186 timer.ch <- time.Time{}
187187 }
188- require.Eventually(t, func() bool {
189- return len(pty.requested()) == 1
190- }, time.Second, time.Millisecond, "the newest timer callback must finish")
188+ awaitResizeCallback(t, sess.renderCoordinator())
191189 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
192190 snapshot := sess.renderCoordinator().resizeSnapshot()
193191 require.Equal(t, uint64(3), snapshot.epoch)
@@ -185,9 +185,7 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
185 for _, timer := range timers {185 for _, timer := range timers {
186 timer.ch <- time.Time{}186 timer.ch <- time.Time{}
187 }187 }
188- require.Eventually(t, func() bool {188+ awaitResizeCallback(t, sess.renderCoordinator())
189- return len(pty.requested()) == 1
190- }, time.Second, time.Millisecond, "the newest timer callback must finish")
191 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())189 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
192 snapshot := sess.renderCoordinator().resizeSnapshot()190 snapshot := sess.renderCoordinator().resizeSnapshot()
193 require.Equal(t, uint64(3), snapshot.epoch)191 require.Equal(t, uint64(3), snapshot.epoch)

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