detached 5d3577f

fix(daemon): address stable identity review findings

commit
5d3577fbb5fd031175389e199fbfd393c441b256
author
brice · · 2026-07-06 07:43 UTC
committer
brice · · 2026-07-06 07:44 UTC
parent
884ab90
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +79 -20
M internal/usecase/daemon/identity.go +8 -8
@@ -3,24 +3,24 @@ package daemon
33 import (
44 "crypto/rand"
55 "encoding/base32"
6+ "fmt"
67 "io"
78 "strings"
89 )
910
10-var stableIDRand io.Reader = rand.Reader
11-
12-func newStableID() (string, error) {
11+func newStableID(prefix string) (string, error) {
1312 var b [16]byte
14- if _, err := io.ReadFull(stableIDRand, b[:]); err != nil {
15- return "", err
13+ if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
14+ return "", fmt.Errorf("generate stable id: %w", err)
1615 }
17- return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])), nil
16+ enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
17+ return prefix + "_" + strings.ToLower(enc), nil
1818 }
1919
2020 func mustTestStableID(prefix string) string {
21- id, err := newStableID()
21+ id, err := newStableID(prefix)
2222 if err != nil {
23- return prefix + "-unknown"
23+ return prefix + "_unknown"
2424 }
2525 return id
2626 }
@@ -3,24 +3,24 @@ package daemon
3 import (3 import (
4 "crypto/rand"4 "crypto/rand"
5 "encoding/base32"5 "encoding/base32"
6+ "fmt"
6 "io"7 "io"
7 "strings"8 "strings"
8 )9 )
9 10
10-var stableIDRand io.Reader = rand.Reader11+func newStableID(prefix string) (string, error) {
11-
12-func newStableID() (string, error) {
13 var b [16]byte12 var b [16]byte
14- if _, err := io.ReadFull(stableIDRand, b[:]); err != nil {13+ if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
15- return "", err14+ return "", fmt.Errorf("generate stable id: %w", err)
16 }15 }
17- return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])), nil16+ enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
17+ return prefix + "_" + strings.ToLower(enc), nil
18 }18 }
19 19
20 func mustTestStableID(prefix string) string {20 func mustTestStableID(prefix string) string {
21- id, err := newStableID()21+ id, err := newStableID(prefix)
22 if err != nil {22 if err != nil {
23- return prefix + "-unknown"23+ return prefix + "_unknown"
24 }24 }
25 return id25 return id
26 }26 }
M internal/usecase/daemon/pane.go +1 -1
@@ -35,7 +35,7 @@ type pane struct {
3535 }
3636
3737 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
38- return newPaneWithStableID(id, mustTestStableID("pane"), pty, sz)
38+ return newPaneWithStableID(id, mustTestStableID("p"), pty, sz)
3939 }
4040
4141 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
@@ -35,7 +35,7 @@ type pane struct {
35 }35 }
36 36
37 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {37 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
38- return newPaneWithStableID(id, mustTestStableID("pane"), pty, sz)38+ return newPaneWithStableID(id, mustTestStableID("p"), pty, sz)
39 }39 }
40 40
41 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {41 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
M internal/usecase/daemon/pane_actions.go +1 -1
@@ -60,7 +60,7 @@ func (d *Daemon) spawnPaneOp(
6060 tabStableID := tb.stableID
6161 tb.mu.Unlock()
6262
63- paneStableID, err := newStableID()
63+ paneStableID, err := newStableID("p")
6464 if err != nil {
6565 tb.mu.Lock()
6666 _ = tb.tree.Close(newID)
@@ -60,7 +60,7 @@ func (d *Daemon) spawnPaneOp(
60 tabStableID := tb.stableID60 tabStableID := tb.stableID
61 tb.mu.Unlock()61 tb.mu.Unlock()
62 62
63- paneStableID, err := newStableID()63+ paneStableID, err := newStableID("p")
64 if err != nil {64 if err != nil {
65 tb.mu.Lock()65 tb.mu.Lock()
66 _ = tb.tree.Close(newID)66 _ = tb.tree.Close(newID)
M internal/usecase/daemon/session.go +6 -6
@@ -123,11 +123,11 @@ func (d *Daemon) touchMRU(sess *session) {
123123
124124 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
125125 tbSize := tabSize(sz)
126- tabStableID, err := newStableID()
126+ tabStableID, err := newStableID("t")
127127 if err != nil {
128128 return nil, fmt.Errorf("daemon: generating tab identity: %w", err)
129129 }
130- paneStableID, err := newStableID()
130+ paneStableID, err := newStableID("p")
131131 if err != nil {
132132 return nil, fmt.Errorf("daemon: generating pane identity: %w", err)
133133 }
@@ -141,7 +141,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
141141 d.nextID++
142142 createdAt := time.Now().UnixNano()
143143
144- tb := newTab(pty, tbSize)
144+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
145145 sctx, cancel := context.WithCancel(d.serveCtx)
146146 tb.ctx, tb.cancel = context.WithCancel(sctx)
147147 lastUsedSeq := uint64(0)
@@ -243,11 +243,11 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
243243 cwd := sess.cwd
244244 client := sess.client
245245 sess.mu.Unlock()
246- tabStableID, err := newStableID()
246+ tabStableID, err := newStableID("t")
247247 if err != nil {
248248 return fmt.Errorf("daemon: generating tab identity: %w", err)
249249 }
250- paneStableID, err := newStableID()
250+ paneStableID, err := newStableID("p")
251251 if err != nil {
252252 return fmt.Errorf("daemon: generating pane identity: %w", err)
253253 }
@@ -291,7 +291,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
291291 }
292292
293293 func newTab(pty ports.PTY, sz domain.Size) *tab {
294- return newTabWithStableID(mustTestStableID("tab"), mustTestStableID("pane"), pty, sz)
294+ return newTabWithStableID(mustTestStableID("t"), mustTestStableID("p"), pty, sz)
295295 }
296296
297297 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
@@ -123,11 +123,11 @@ func (d *Daemon) touchMRU(sess *session) {
123 123
124 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {124 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
125 tbSize := tabSize(sz)125 tbSize := tabSize(sz)
126- tabStableID, err := newStableID()126+ tabStableID, err := newStableID("t")
127 if err != nil {127 if err != nil {
128 return nil, fmt.Errorf("daemon: generating tab identity: %w", err)128 return nil, fmt.Errorf("daemon: generating tab identity: %w", err)
129 }129 }
130- paneStableID, err := newStableID()130+ paneStableID, err := newStableID("p")
131 if err != nil {131 if err != nil {
132 return nil, fmt.Errorf("daemon: generating pane identity: %w", err)132 return nil, fmt.Errorf("daemon: generating pane identity: %w", err)
133 }133 }
@@ -141,7 +141,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
141 d.nextID++141 d.nextID++
142 createdAt := time.Now().UnixNano()142 createdAt := time.Now().UnixNano()
143 143
144- tb := newTab(pty, tbSize)144+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
145 sctx, cancel := context.WithCancel(d.serveCtx)145 sctx, cancel := context.WithCancel(d.serveCtx)
146 tb.ctx, tb.cancel = context.WithCancel(sctx)146 tb.ctx, tb.cancel = context.WithCancel(sctx)
147 lastUsedSeq := uint64(0)147 lastUsedSeq := uint64(0)
@@ -243,11 +243,11 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
243 cwd := sess.cwd243 cwd := sess.cwd
244 client := sess.client244 client := sess.client
245 sess.mu.Unlock()245 sess.mu.Unlock()
246- tabStableID, err := newStableID()246+ tabStableID, err := newStableID("t")
247 if err != nil {247 if err != nil {
248 return fmt.Errorf("daemon: generating tab identity: %w", err)248 return fmt.Errorf("daemon: generating tab identity: %w", err)
249 }249 }
250- paneStableID, err := newStableID()250+ paneStableID, err := newStableID("p")
251 if err != nil {251 if err != nil {
252 return fmt.Errorf("daemon: generating pane identity: %w", err)252 return fmt.Errorf("daemon: generating pane identity: %w", err)
253 }253 }
@@ -291,7 +291,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
291 }291 }
292 292
293 func newTab(pty ports.PTY, sz domain.Size) *tab {293 func newTab(pty ports.PTY, sz domain.Size) *tab {
294- return newTabWithStableID(mustTestStableID("tab"), mustTestStableID("pane"), pty, sz)294+ return newTabWithStableID(mustTestStableID("t"), mustTestStableID("p"), pty, sz)
295 }295 }
296 296
297 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {297 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
M internal/usecase/daemon/session_test.go +44 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "io"
66 "log/slog"
7+ "strings"
78 "sync"
89 "sync/atomic"
910 "testing"
@@ -892,6 +893,49 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
892893 require.False(t, ok)
893894 }
894895
896+func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
897+ sz := domain.Size{Cols: 80, Rows: 24}
898+ p, releasePTY := newBlockingPTY(t)
899+ defer releasePTY()
900+
901+ var gotEnv []string
902+ f := portsmocks.NewMockPTYFactory(t)
903+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
904+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
905+ gotEnv = append([]string(nil), env...)
906+ return p, nil
907+ },
908+ ).Once()
909+ d := newTestDaemon(t, f, stubClock{})
910+ d.baseEnv = []string{"KEEP=1", "TERM=old", "VEV=old"}
911+
912+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
913+ require.NoError(t, err)
914+ defer func() {
915+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
916+ releasePTY()
917+ d.sessWg.Wait()
918+ }()
919+
920+ sess.mu.Lock()
921+ require.Len(t, sess.tabs, 1)
922+ tb := sess.tabs[0]
923+ sess.mu.Unlock()
924+ tb.mu.Lock()
925+ paneStableID := tb.panes["pane-1"].stableID
926+ tabStableID := tb.stableID
927+ tb.mu.Unlock()
928+
929+ require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)
930+ require.True(t, strings.HasPrefix(paneStableID, "p_"), paneStableID)
931+ require.NotEqual(t, tabStableID, paneStableID)
932+ require.Contains(t, gotEnv, "KEEP=1")
933+ require.NotContains(t, gotEnv, "TERM=old")
934+ require.NotContains(t, gotEnv, "VEV=old")
935+ require.Contains(t, gotEnv, "TERM=xterm-direct")
936+ require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
937+}
938+
895939 func TestIntentNewStoppedNameRejected(t *testing.T) {
896940 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
897941 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "io"5 "io"
6 "log/slog"6 "log/slog"
7+ "strings"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -892,6 +893,49 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
892 require.False(t, ok)893 require.False(t, ok)
893 }894 }
894 895
896+func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
897+ sz := domain.Size{Cols: 80, Rows: 24}
898+ p, releasePTY := newBlockingPTY(t)
899+ defer releasePTY()
900+
901+ var gotEnv []string
902+ f := portsmocks.NewMockPTYFactory(t)
903+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
904+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
905+ gotEnv = append([]string(nil), env...)
906+ return p, nil
907+ },
908+ ).Once()
909+ d := newTestDaemon(t, f, stubClock{})
910+ d.baseEnv = []string{"KEEP=1", "TERM=old", "VEV=old"}
911+
912+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
913+ require.NoError(t, err)
914+ defer func() {
915+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
916+ releasePTY()
917+ d.sessWg.Wait()
918+ }()
919+
920+ sess.mu.Lock()
921+ require.Len(t, sess.tabs, 1)
922+ tb := sess.tabs[0]
923+ sess.mu.Unlock()
924+ tb.mu.Lock()
925+ paneStableID := tb.panes["pane-1"].stableID
926+ tabStableID := tb.stableID
927+ tb.mu.Unlock()
928+
929+ require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)
930+ require.True(t, strings.HasPrefix(paneStableID, "p_"), paneStableID)
931+ require.NotEqual(t, tabStableID, paneStableID)
932+ require.Contains(t, gotEnv, "KEEP=1")
933+ require.NotContains(t, gotEnv, "TERM=old")
934+ require.NotContains(t, gotEnv, "VEV=old")
935+ require.Contains(t, gotEnv, "TERM=xterm-direct")
936+ require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
937+}
938+
895 func TestIntentNewStoppedNameRejected(t *testing.T) {939 func TestIntentNewStoppedNameRejected(t *testing.T) {
896 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})940 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
897 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}941 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
M internal/usecase/daemon/snapshot.go +2 -2
@@ -92,7 +92,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
9292 for _, pl := range placements {
9393 placementByPane[pl.ID] = pl.Content
9494 }
95- tabStableID, err := newStableID()
95+ tabStableID, err := newStableID("t")
9696 if err != nil {
9797 closeOpened()
9898 return fmt.Errorf("snapshot: generating tab identity: %w", err)
@@ -114,7 +114,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
114114 return fmt.Errorf("snapshot: missing pane placement")
115115 }
116116 contentSize := restorePTYSize(contentRect, tbSize)
117- paneStableID, err := newStableID()
117+ paneStableID, err := newStableID("p")
118118 if err != nil {
119119 closeOpened()
120120 return fmt.Errorf("snapshot: generating pane identity: %w", err)
@@ -92,7 +92,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
92 for _, pl := range placements {92 for _, pl := range placements {
93 placementByPane[pl.ID] = pl.Content93 placementByPane[pl.ID] = pl.Content
94 }94 }
95- tabStableID, err := newStableID()95+ tabStableID, err := newStableID("t")
96 if err != nil {96 if err != nil {
97 closeOpened()97 closeOpened()
98 return fmt.Errorf("snapshot: generating tab identity: %w", err)98 return fmt.Errorf("snapshot: generating tab identity: %w", err)
@@ -114,7 +114,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
114 return fmt.Errorf("snapshot: missing pane placement")114 return fmt.Errorf("snapshot: missing pane placement")
115 }115 }
116 contentSize := restorePTYSize(contentRect, tbSize)116 contentSize := restorePTYSize(contentRect, tbSize)
117- paneStableID, err := newStableID()117+ paneStableID, err := newStableID("p")
118 if err != nil {118 if err != nil {
119 closeOpened()119 closeOpened()
120 return fmt.Errorf("snapshot: generating pane identity: %w", err)120 return fmt.Errorf("snapshot: generating pane identity: %w", err)
M internal/usecase/daemon/snapshot_restore_test.go +17 -2
@@ -4,6 +4,7 @@ import (
44 "context"
55 "io"
66 "math"
7+ "strings"
78 "sync"
89 "testing"
910 "time"
@@ -76,6 +77,19 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
7677 require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
7778 require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
7879 p.mu.Unlock()
80+
81+ tb.mu.Lock()
82+ tabStableID := tb.stableID
83+ pane1StableID := tb.panes["pane-1"].stableID
84+ pane2StableID := tb.panes["pane-2"].stableID
85+ tb.mu.Unlock()
86+ require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)
87+ require.True(t, strings.HasPrefix(pane1StableID, "p_"), pane1StableID)
88+ require.True(t, strings.HasPrefix(pane2StableID, "p_"), pane2StableID)
89+ require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")
90+ require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
91+ require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")
92+ require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
7993 }
8094
8195 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
@@ -263,12 +277,13 @@ type restorePTYFactory struct {
263277 type restorePTYOpen struct {
264278 dir string
265279 size domain.Size
280+ env []string
266281 }
267282
268-func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) {
283+func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
269284 f.mu.Lock()
270285 defer f.mu.Unlock()
271- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz})
286+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...)})
272287 return newRestorePTY(), nil
273288 }
274289
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "io"5 "io"
6 "math"6 "math"
7+ "strings"
7 "sync"8 "sync"
8 "testing"9 "testing"
9 "time"10 "time"
@@ -76,6 +77,19 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
76 require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))77 require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
77 require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))78 require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
78 p.mu.Unlock()79 p.mu.Unlock()
80+
81+ tb.mu.Lock()
82+ tabStableID := tb.stableID
83+ pane1StableID := tb.panes["pane-1"].stableID
84+ pane2StableID := tb.panes["pane-2"].stableID
85+ tb.mu.Unlock()
86+ require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)
87+ require.True(t, strings.HasPrefix(pane1StableID, "p_"), pane1StableID)
88+ require.True(t, strings.HasPrefix(pane2StableID, "p_"), pane2StableID)
89+ require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")
90+ require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
91+ require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")
92+ require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
79 }93 }
80 94
81 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {95 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
@@ -263,12 +277,13 @@ type restorePTYFactory struct {
263 type restorePTYOpen struct {277 type restorePTYOpen struct {
264 dir string278 dir string
265 size domain.Size279 size domain.Size
280+ env []string
266 }281 }
267 282
268-func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) {283+func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
269 f.mu.Lock()284 f.mu.Lock()
270 defer f.mu.Unlock()285 defer f.mu.Unlock()
271- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz})286+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...)})
272 return newRestorePTY(), nil287 return newRestorePTY(), nil
273 }288 }
274 289

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