detached de9b334

fix(daemon): retry accepted failed layout applies

commit
de9b3348fd76d58fbc8fcfa2272556512d69b53f
author
brice · · 2026-07-25 03:54 UTC
committer
brice · · 2026-07-25 04:08 UTC
parent
6400ab4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +143 -9
M internal/usecase/daemon/pane.go +2 -1
@@ -18,7 +18,7 @@ type pane struct {
1818 id layout.PaneID
1919 stableID string
2020 pty ports.PTY
21- mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, and title
2222 resizeMu sync.Mutex // serializes PTY resizes without holding mu
2323 screen *vt.Screen
2424 history *vt.History
@@ -28,6 +28,7 @@ type pane struct {
2828 // draining into resizePending so output is replayed against the target (or
2929 // retained old) screen only after apply resolves.
3030 resizeApplying bool
31+ resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan.
3132 resizePending []byte
3233 ptyResponses []byte
3334 ptyClipboards []string
@@ -18,7 +18,7 @@ type pane struct {
18 id layout.PaneID18 id layout.PaneID
19 stableID string19 stableID string
20 pty ports.PTY20 pty ports.PTY
21- mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, and title
22 resizeMu sync.Mutex // serializes PTY resizes without holding mu22 resizeMu sync.Mutex // serializes PTY resizes without holding mu
23 screen *vt.Screen23 screen *vt.Screen
24 history *vt.History24 history *vt.History
@@ -28,6 +28,7 @@ type pane struct {
28 // draining into resizePending so output is replayed against the target (or28 // draining into resizePending so output is replayed against the target (or
29 // retained old) screen only after apply resolves.29 // retained old) screen only after apply resolves.
30 resizeApplying bool30 resizeApplying bool
31+ resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan.
31 resizePending []byte32 resizePending []byte
32 ptyResponses []byte33 ptyResponses []byte
33 ptyClipboards []string34 ptyClipboards []string
M internal/usecase/daemon/pane_actions_test.go +87 -3
@@ -21,13 +21,94 @@ import (
2121 )
2222
2323 func TestLayoutGenerationMutationBookkeeping(t *testing.T) {
24- tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
24+ t.Run("focus and expanded member share one logical bump", func(t *testing.T) {
25+ tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
26+ second := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 10})
27+ tb.mu.Lock()
28+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}, Focus: "pane-1"}
29+ tb.panes[second.id] = second
30+ require.Zero(t, tb.layoutGeneration)
31+ require.True(t, focusPlacementLocked(tb, second.id))
32+ require.Equal(t, uint64(1), tb.layoutGeneration)
33+ require.False(t, focusPlacementLocked(tb, second.id))
34+ require.Equal(t, uint64(1), tb.layoutGeneration)
35+ tb.mu.Unlock()
36+ })
37+
38+ t.Run("size changes bump only when the value changes", func(t *testing.T) {
39+ tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
40+ tb.mu.Lock()
41+ if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
42+ tb.size = domain.Size{Cols: 80, Rows: 23}
43+ tb.bumpLayoutGenerationLocked()
44+ }
45+ require.Equal(t, uint64(1), tb.layoutGeneration)
46+ if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
47+ tb.size = domain.Size{Cols: 80, Rows: 23}
48+ tb.bumpLayoutGenerationLocked()
49+ }
50+ require.Equal(t, uint64(1), tb.layoutGeneration)
51+ tb.mu.Unlock()
52+ })
53+}
2554
55+type layoutRetryClock struct{ timers chan *layoutRetryTimer }
56+
57+type layoutRetryTimer struct {
58+ ch chan time.Time
59+ delay time.Duration
60+}
61+
62+func (c *layoutRetryClock) Now() time.Time { return time.Unix(0, 0) }
63+
64+func (c *layoutRetryClock) NewTimer(delay time.Duration) ports.Timer {
65+ timer := &layoutRetryTimer{ch: make(chan time.Time, 1), delay: delay}
66+ c.timers <- timer
67+ return timer
68+}
69+
70+func (t *layoutRetryTimer) C() <-chan time.Time { return t.ch }
71+func (*layoutRetryTimer) Reset(time.Duration) bool { return false }
72+func (*layoutRetryTimer) Stop() bool { return true }
73+func (t *layoutRetryTimer) fire() { t.ch <- time.Unix(0, 0) }
74+
75+func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
76+ pty := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
77+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
78+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
79+ d.clock = clock
80+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
2681 tb.mu.Lock()
27- require.Zero(t, tb.layoutGeneration)
82+ tb.size = domain.Size{Cols: 100, Rows: 20}
2883 tb.bumpLayoutGenerationLocked()
29- require.Equal(t, uint64(1), tb.layoutGeneration)
3084 tb.mu.Unlock()
85+
86+ secondResize := make(chan struct{})
87+ var resized sync.Once
88+ pty.onResize = func() {
89+ if len(pty.requested()) == 2 {
90+ resized.Do(func() { close(secondResize) })
91+ }
92+ }
93+ d.applyTabLayout(sess, tb)
94+
95+ var timer *layoutRetryTimer
96+ for timer == nil {
97+ candidate := <-clock.timers
98+ if candidate.delay == minOutputRenderDeadline {
99+ timer = candidate
100+ }
101+ }
102+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested())
103+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize")
104+ timer.fire()
105+ awaitSignal(t, secondResize, "accepted failure was not retried")
106+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested())
107+ require.Eventually(t, func() bool {
108+ p.mu.Lock()
109+ defer p.mu.Unlock()
110+ return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20
111+ }, time.Second, time.Millisecond, "successful retry did not publish its VT size")
31112 }
32113
33114 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
@@ -147,6 +228,8 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
147228 require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")
148229 p2.mu.Unlock()
149230 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect)
231+ require.Equal(t, []domain.Size{{Cols: 40, Rows: 23}, {Cols: 80, Rows: 23}}, first.requested(), "the rejected plan must be followed by a fresh surviving-pane apply")
232+ require.Equal(t, []domain.Size{{Cols: 39, Rows: 23}}, second.requested(), "the removed pane may receive an obsolete external resize but must never be published")
150233 })
151234
152235 t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) {
@@ -180,6 +263,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
180263 tb.mu.Unlock()
181264 require.True(t, ok)
182265 require.Equal(t, placementContent(placements, p2.id), p2.rect)
266+ require.Equal(t, []domain.Size{{Cols: 80, Rows: 21}}, first.requested(), "the old expanded pane is applied only by the rejected attempt")
183267 require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested())
184268 })
185269
@@ -21,13 +21,94 @@ import (
21 )21 )
22 22
23 func TestLayoutGenerationMutationBookkeeping(t *testing.T) {23 func TestLayoutGenerationMutationBookkeeping(t *testing.T) {
24- tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})24+ t.Run("focus and expanded member share one logical bump", func(t *testing.T) {
25+ tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
26+ second := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 10})
27+ tb.mu.Lock()
28+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}, Focus: "pane-1"}
29+ tb.panes[second.id] = second
30+ require.Zero(t, tb.layoutGeneration)
31+ require.True(t, focusPlacementLocked(tb, second.id))
32+ require.Equal(t, uint64(1), tb.layoutGeneration)
33+ require.False(t, focusPlacementLocked(tb, second.id))
34+ require.Equal(t, uint64(1), tb.layoutGeneration)
35+ tb.mu.Unlock()
36+ })
37+
38+ t.Run("size changes bump only when the value changes", func(t *testing.T) {
39+ tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
40+ tb.mu.Lock()
41+ if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
42+ tb.size = domain.Size{Cols: 80, Rows: 23}
43+ tb.bumpLayoutGenerationLocked()
44+ }
45+ require.Equal(t, uint64(1), tb.layoutGeneration)
46+ if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
47+ tb.size = domain.Size{Cols: 80, Rows: 23}
48+ tb.bumpLayoutGenerationLocked()
49+ }
50+ require.Equal(t, uint64(1), tb.layoutGeneration)
51+ tb.mu.Unlock()
52+ })
53+}
25 54
55+type layoutRetryClock struct{ timers chan *layoutRetryTimer }
56+
57+type layoutRetryTimer struct {
58+ ch chan time.Time
59+ delay time.Duration
60+}
61+
62+func (c *layoutRetryClock) Now() time.Time { return time.Unix(0, 0) }
63+
64+func (c *layoutRetryClock) NewTimer(delay time.Duration) ports.Timer {
65+ timer := &layoutRetryTimer{ch: make(chan time.Time, 1), delay: delay}
66+ c.timers <- timer
67+ return timer
68+}
69+
70+func (t *layoutRetryTimer) C() <-chan time.Time { return t.ch }
71+func (*layoutRetryTimer) Reset(time.Duration) bool { return false }
72+func (*layoutRetryTimer) Stop() bool { return true }
73+func (t *layoutRetryTimer) fire() { t.ch <- time.Unix(0, 0) }
74+
75+func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
76+ pty := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
77+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
78+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
79+ d.clock = clock
80+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
26 tb.mu.Lock()81 tb.mu.Lock()
27- require.Zero(t, tb.layoutGeneration)82+ tb.size = domain.Size{Cols: 100, Rows: 20}
28 tb.bumpLayoutGenerationLocked()83 tb.bumpLayoutGenerationLocked()
29- require.Equal(t, uint64(1), tb.layoutGeneration)
30 tb.mu.Unlock()84 tb.mu.Unlock()
85+
86+ secondResize := make(chan struct{})
87+ var resized sync.Once
88+ pty.onResize = func() {
89+ if len(pty.requested()) == 2 {
90+ resized.Do(func() { close(secondResize) })
91+ }
92+ }
93+ d.applyTabLayout(sess, tb)
94+
95+ var timer *layoutRetryTimer
96+ for timer == nil {
97+ candidate := <-clock.timers
98+ if candidate.delay == minOutputRenderDeadline {
99+ timer = candidate
100+ }
101+ }
102+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested())
103+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize")
104+ timer.fire()
105+ awaitSignal(t, secondResize, "accepted failure was not retried")
106+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested())
107+ require.Eventually(t, func() bool {
108+ p.mu.Lock()
109+ defer p.mu.Unlock()
110+ return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20
111+ }, time.Second, time.Millisecond, "successful retry did not publish its VT size")
31 }112 }
32 113
33 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {114 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
@@ -147,6 +228,8 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
147 require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")228 require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")
148 p2.mu.Unlock()229 p2.mu.Unlock()
149 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect)230 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect)
231+ require.Equal(t, []domain.Size{{Cols: 40, Rows: 23}, {Cols: 80, Rows: 23}}, first.requested(), "the rejected plan must be followed by a fresh surviving-pane apply")
232+ require.Equal(t, []domain.Size{{Cols: 39, Rows: 23}}, second.requested(), "the removed pane may receive an obsolete external resize but must never be published")
150 })233 })
151 234
152 t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) {235 t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) {
@@ -180,6 +263,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
180 tb.mu.Unlock()263 tb.mu.Unlock()
181 require.True(t, ok)264 require.True(t, ok)
182 require.Equal(t, placementContent(placements, p2.id), p2.rect)265 require.Equal(t, placementContent(placements, p2.id), p2.rect)
266+ require.Equal(t, []domain.Size{{Cols: 80, Rows: 21}}, first.requested(), "the old expanded pane is applied only by the rejected attempt")
183 require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested())267 require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested())
184 })268 })
185 269
M internal/usecase/daemon/transactional_resize.go +54 -5
@@ -70,8 +70,9 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
7070 pty := p.pty
7171 // A rejected earlier attempt deliberately leaves its parser gate open.
7272 // Reapply even if the committed rectangle already matches: the PTY may
73- // still be at that rejected attempt's external size.
74- needsApply := p.resizeApplying || old.Width != member.rect.Width || old.Height != member.rect.Height
73+ // still be at that rejected attempt's external size. An accepted failed
74+ // attempt records resizeRetry for the same reason after its gate closes.
75+ needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height
7576 if needsApply && pty != nil {
7677 p.resizeApplying = true
7778 }
@@ -80,6 +81,9 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
8081 member.ok = true
8182 } else if err := pty.Resize(rectSize(member.rect)); err != nil {
8283 d.log.Warn("pty resize failed", "err", err)
84+ p.mu.Lock()
85+ p.resizeRetry = true
86+ p.mu.Unlock()
8387 d.replayResizePending(member.session, member.tab, p, false, member.rect)
8488 member.retry = true
8589 member.err = err
@@ -110,6 +114,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) {
110114 member.pane.mu.Lock()
111115 member.pane.rect = member.rect
112116 if member.ok {
117+ member.pane.resizeRetry = false
113118 member.pane.screen.Resize(member.rect.Width, member.rect.Height)
114119 }
115120 member.pane.mu.Unlock()
@@ -222,10 +227,54 @@ func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...fu
222227 }
223228 }
224229
225-func (d *Daemon) applyTabLayout(sess *session, tb *tab) {
226- if _, ok := d.applyTabLayoutTransaction(sess, tb); ok {
227- markSnapshotDirty(sess)
230+func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
231+ failed, ok := d.applyTabLayoutTransaction(sess, tb)
232+ if !ok {
233+ return false
234+ }
235+ markSnapshotDirty(sess)
236+ if len(failed) != 0 {
237+ d.scheduleAcceptedTabLayoutRetry(sess, tb)
228238 }
239+ return true
240+}
241+
242+// scheduleAcceptedTabLayoutRetry retries a failed PTY resize only after its
243+// geometry has passed the tab generation and pane-membership validation. The
244+// retry prepares a new canonical plan, so a later layout mutation supersedes
245+// this target rather than replaying stale rectangles.
246+func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
247+ if d.clock == nil || tb == nil || tb.ctx == nil {
248+ return
249+ }
250+ timer := d.clock.NewTimer(minOutputRenderDeadline)
251+ if timer == nil {
252+ return
253+ }
254+ timerC := timer.C()
255+ if timerC == nil {
256+ timer.Stop()
257+ return
258+ }
259+ go func() {
260+ select {
261+ case <-tb.ctx.Done():
262+ timer.Stop()
263+ return
264+ case <-timerC:
265+ }
266+ if tb.ctx.Err() != nil {
267+ return
268+ }
269+ if d.applyTabLayout(sess, tb) {
270+ sess.mu.Lock()
271+ ac := sess.client
272+ sess.mu.Unlock()
273+ if ac != nil {
274+ d.invalidateRender(sess, ac, true, "transactional_resize.go")
275+ }
276+ }
277+ }()
229278 }
230279
231280 func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
@@ -70,8 +70,9 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
70 pty := p.pty70 pty := p.pty
71 // A rejected earlier attempt deliberately leaves its parser gate open.71 // A rejected earlier attempt deliberately leaves its parser gate open.
72 // Reapply even if the committed rectangle already matches: the PTY may72 // Reapply even if the committed rectangle already matches: the PTY may
73- // still be at that rejected attempt's external size.73+ // still be at that rejected attempt's external size. An accepted failed
74- needsApply := p.resizeApplying || old.Width != member.rect.Width || old.Height != member.rect.Height74+ // attempt records resizeRetry for the same reason after its gate closes.
75+ needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height
75 if needsApply && pty != nil {76 if needsApply && pty != nil {
76 p.resizeApplying = true77 p.resizeApplying = true
77 }78 }
@@ -80,6 +81,9 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
80 member.ok = true81 member.ok = true
81 } else if err := pty.Resize(rectSize(member.rect)); err != nil {82 } else if err := pty.Resize(rectSize(member.rect)); err != nil {
82 d.log.Warn("pty resize failed", "err", err)83 d.log.Warn("pty resize failed", "err", err)
84+ p.mu.Lock()
85+ p.resizeRetry = true
86+ p.mu.Unlock()
83 d.replayResizePending(member.session, member.tab, p, false, member.rect)87 d.replayResizePending(member.session, member.tab, p, false, member.rect)
84 member.retry = true88 member.retry = true
85 member.err = err89 member.err = err
@@ -110,6 +114,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) {
110 member.pane.mu.Lock()114 member.pane.mu.Lock()
111 member.pane.rect = member.rect115 member.pane.rect = member.rect
112 if member.ok {116 if member.ok {
117+ member.pane.resizeRetry = false
113 member.pane.screen.Resize(member.rect.Width, member.rect.Height)118 member.pane.screen.Resize(member.rect.Width, member.rect.Height)
114 }119 }
115 member.pane.mu.Unlock()120 member.pane.mu.Unlock()
@@ -222,10 +227,54 @@ func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...fu
222 }227 }
223 }228 }
224 229
225-func (d *Daemon) applyTabLayout(sess *session, tb *tab) {230+func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
226- if _, ok := d.applyTabLayoutTransaction(sess, tb); ok {231+ failed, ok := d.applyTabLayoutTransaction(sess, tb)
227- markSnapshotDirty(sess)232+ if !ok {
233+ return false
234+ }
235+ markSnapshotDirty(sess)
236+ if len(failed) != 0 {
237+ d.scheduleAcceptedTabLayoutRetry(sess, tb)
228 }238 }
239+ return true
240+}
241+
242+// scheduleAcceptedTabLayoutRetry retries a failed PTY resize only after its
243+// geometry has passed the tab generation and pane-membership validation. The
244+// retry prepares a new canonical plan, so a later layout mutation supersedes
245+// this target rather than replaying stale rectangles.
246+func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
247+ if d.clock == nil || tb == nil || tb.ctx == nil {
248+ return
249+ }
250+ timer := d.clock.NewTimer(minOutputRenderDeadline)
251+ if timer == nil {
252+ return
253+ }
254+ timerC := timer.C()
255+ if timerC == nil {
256+ timer.Stop()
257+ return
258+ }
259+ go func() {
260+ select {
261+ case <-tb.ctx.Done():
262+ timer.Stop()
263+ return
264+ case <-timerC:
265+ }
266+ if tb.ctx.Err() != nil {
267+ return
268+ }
269+ if d.applyTabLayout(sess, tb) {
270+ sess.mu.Lock()
271+ ac := sess.client
272+ sess.mu.Unlock()
273+ if ac != nil {
274+ d.invalidateRender(sess, ac, true, "transactional_resize.go")
275+ }
276+ }
277+ }()
229 }278 }
230 279
231 func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {280 func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {

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