detached 861e555

fix(daemon): answer floating spare shell queries before a tab claims it

The session spare started its PTY reader only on claim, so a shell's startup queries (fish's Primary Device Attribute query) went unanswered and fish disabled features after a 10 s timeout. The spare now reads from launch and writes VT replies while unowned; a claim reuses that reader. A spare is also claimed only by a launch with the same shell, args and environment, not just cwd and floating.command, so a re-attach with a new agent socket or display gets a fresh shell.
commit
861e555dc0a9f01444fe661b411609b31148c034
author
brice · · 2026-10-08 05:57 UTC
parent
03130e9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +129 -16
M internal/usecase/daemon/floating.go +1 -1
@@ -407,7 +407,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
407407 // Claim the session spare when it matches this launch; a missing or
408408 // mismatched spare falls through to a fresh launch. Once a spare was
409409 // used up, the session starts the next one in the background.
410- p, had := sess.takeFloatingSpare(spec.cwd, spec.floatingCommand)
410+ p, had := sess.takeFloatingSpare(spec)
411411 if had {
412412 defer d.ensureFloatingSpare(sess, tb)
413413 }
@@ -407,7 +407,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
407 // Claim the session spare when it matches this launch; a missing or407 // Claim the session spare when it matches this launch; a missing or
408 // mismatched spare falls through to a fresh launch. Once a spare was408 // mismatched spare falls through to a fresh launch. Once a spare was
409 // used up, the session starts the next one in the background.409 // used up, the session starts the next one in the background.
410- p, had := sess.takeFloatingSpare(spec.cwd, spec.floatingCommand)410+ p, had := sess.takeFloatingSpare(spec)
411 if had {411 if had {
412 defer d.ensureFloatingSpare(sess, tb)412 defer d.ensureFloatingSpare(sess, tb)
413 }413 }
M internal/usecase/daemon/floating_spare.go +62 -15
@@ -1,6 +1,8 @@
11 package daemon
22
33 import (
4+ "strings"
5+
46 "github.com/bnema/vev/internal/domain/layout"
57 )
68
@@ -8,20 +10,39 @@ import (
810 // than by a tab. The first tab that opens its floating pane claims it, and the
911 // session then starts a new spare in the background, so a session keeps at
1012 // most one idle floating shell instead of one hidden shell per tab. A spare is
11-// never persisted or restored. Its reader starts only once a tab installs it;
12-// until then the kernel PTY buffer holds its first prompt. Its VEV identity
13-// names the tab that started it; floating panes are not --self targets.
13+// never persisted or restored. Its reader starts with the shell, so startup
14+// terminal queries are answered before any claim; with no owner its screen
15+// changes publish nothing. Its VEV identity names the tab that started it;
16+// floating panes are not --self targets.
1417 //
15-// A claim takes the claiming tab's geometry (resize before the reader starts)
16-// and cwd: a spare started in another directory is discarded and the claim
17-// launches a fresh shell instead.
18+// A claim takes the claiming tab's geometry and requires the same cwd,
19+// floating.command, shell and environment: a mismatched spare is discarded and
20+// the claim launches a fresh shell instead.
1821 //
1922 // The spare is guarded by session.floatingLaunchMu, which is never held across
2023 // PTY operations or architecture locks.
2124 type floatingSpare struct {
22- pane *pane
23- cwd string
24- command string
25+ pane *pane
26+ key floatingSpareKey
27+}
28+
29+// floatingSpareKey is every launch input a claim must share with the spare.
30+// env excludes the per-launch VEV identity.
31+type floatingSpareKey struct {
32+ cwd, floatingCommand, command, args, env string
33+}
34+
35+func spareKey(spec floatingLaunchSpec) floatingSpareKey {
36+ env := make([]string, 0, len(spec.env))
37+ for _, entry := range spec.env {
38+ if key, _, _ := environmentEntry(entry); key != "VEV" {
39+ env = append(env, entry)
40+ }
41+ }
42+ return floatingSpareKey{
43+ cwd: spec.cwd, floatingCommand: spec.floatingCommand, command: spec.command,
44+ args: strings.Join(spec.args, "\x00"), env: strings.Join(env, "\x00"),
45+ }
2546 }
2647
2748 // ensureFloatingSpare starts the session's spare when none exists or is
@@ -61,7 +82,19 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
6182 sess.floatingLaunchMu.Lock()
6283 sess.floatingSpareStarting = false
6384 if p != nil && !sess.floatingLaunchStopping {
64- sess.floatingSpare = &floatingSpare{pane: p, cwd: spec.cwd, command: cfg.Command}
85+ sess.floatingSpare = &floatingSpare{pane: p, key: spareKey(spec)}
86+ p.prestarted = true
87+ spare := p
88+ spare.onExit = func() {
89+ spare.spareExited.Store(true)
90+ if spare.ownerSnapshot() == nil {
91+ sess.dropFloatingSpare(spare)
92+ return
93+ }
94+ d.reapInstalledFloating(spare)
95+ }
96+ d.sessWg.Add(1)
97+ go d.readPanePTY(p)
6598 p = nil
6699 }
67100 sess.floatingLaunchMu.Unlock()
@@ -72,10 +105,10 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
72105 }
73106
74107 // takeFloatingSpare removes the session spare. It returns the pane when the
75-// spare was started for cwd and command; a mismatched spare is closed because
76-// it can never serve this directory or config. had reports whether a spare
108+// spare was started with the same launch inputs as spec; a mismatched spare is
109+// closed because it can never serve this launch. had reports whether a spare
77110 // existed, so the caller knows to start its replacement.
78-func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool) {
111+func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bool) {
79112 sess.floatingLaunchMu.Lock()
80113 spare := sess.floatingSpare
81114 sess.floatingSpare = nil
@@ -83,13 +116,23 @@ func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool)
83116 if spare == nil {
84117 return nil, false
85118 }
86- if spare.cwd != cwd || spare.command != command {
119+ if spare.key != spareKey(spec) {
87120 closeFloatingPane(spare.pane)
88121 return nil, true
89122 }
90123 return spare.pane, true
91124 }
92125
126+// dropFloatingSpare forgets an unclaimed spare whose shell exited.
127+func (sess *session) dropFloatingSpare(p *pane) {
128+ sess.floatingLaunchMu.Lock()
129+ if sess.floatingSpare != nil && sess.floatingSpare.pane == p {
130+ sess.floatingSpare = nil
131+ }
132+ sess.floatingLaunchMu.Unlock()
133+ closeFloatingPane(p)
134+}
135+
93136 // closeFloatingSpare releases the spare on session teardown. The caller has
94137 // already set floatingLaunchStopping, so no launch can publish a new one.
95138 func (sess *session) closeFloatingSpare() {
@@ -147,6 +190,10 @@ func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floati
147190 p.rect = spec.geometry.ptyRect()
148191 p.popupGeometry = spec.geometry
149192 p.mu.Unlock()
150- p.onExit = func() { d.reapInstalledFloating(p) }
151193 d.installFloating(sess, tb, p, generation)
194+ if p.spareExited.Load() {
195+ // The shell exited before the owner was published, so its reader
196+ // could not reap the slot.
197+ d.reapInstalledFloating(p)
198+ }
152199 }
@@ -1,6 +1,8 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "strings"
5+
4 "github.com/bnema/vev/internal/domain/layout"6 "github.com/bnema/vev/internal/domain/layout"
5 )7 )
6 8
@@ -8,20 +10,39 @@ import (
8 // than by a tab. The first tab that opens its floating pane claims it, and the10 // than by a tab. The first tab that opens its floating pane claims it, and the
9 // session then starts a new spare in the background, so a session keeps at11 // session then starts a new spare in the background, so a session keeps at
10 // most one idle floating shell instead of one hidden shell per tab. A spare is12 // most one idle floating shell instead of one hidden shell per tab. A spare is
11-// never persisted or restored. Its reader starts only once a tab installs it;13+// never persisted or restored. Its reader starts with the shell, so startup
12-// until then the kernel PTY buffer holds its first prompt. Its VEV identity14+// terminal queries are answered before any claim; with no owner its screen
13-// names the tab that started it; floating panes are not --self targets.15+// changes publish nothing. Its VEV identity names the tab that started it;
16+// floating panes are not --self targets.
14 //17 //
15-// A claim takes the claiming tab's geometry (resize before the reader starts)18+// A claim takes the claiming tab's geometry and requires the same cwd,
16-// and cwd: a spare started in another directory is discarded and the claim19+// floating.command, shell and environment: a mismatched spare is discarded and
17-// launches a fresh shell instead.20+// the claim launches a fresh shell instead.
18 //21 //
19 // The spare is guarded by session.floatingLaunchMu, which is never held across22 // The spare is guarded by session.floatingLaunchMu, which is never held across
20 // PTY operations or architecture locks.23 // PTY operations or architecture locks.
21 type floatingSpare struct {24 type floatingSpare struct {
22- pane *pane25+ pane *pane
23- cwd string26+ key floatingSpareKey
24- command string27+}
28+
29+// floatingSpareKey is every launch input a claim must share with the spare.
30+// env excludes the per-launch VEV identity.
31+type floatingSpareKey struct {
32+ cwd, floatingCommand, command, args, env string
33+}
34+
35+func spareKey(spec floatingLaunchSpec) floatingSpareKey {
36+ env := make([]string, 0, len(spec.env))
37+ for _, entry := range spec.env {
38+ if key, _, _ := environmentEntry(entry); key != "VEV" {
39+ env = append(env, entry)
40+ }
41+ }
42+ return floatingSpareKey{
43+ cwd: spec.cwd, floatingCommand: spec.floatingCommand, command: spec.command,
44+ args: strings.Join(spec.args, "\x00"), env: strings.Join(env, "\x00"),
45+ }
25 }46 }
26 47
27 // ensureFloatingSpare starts the session's spare when none exists or is48 // ensureFloatingSpare starts the session's spare when none exists or is
@@ -61,7 +82,19 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
61 sess.floatingLaunchMu.Lock()82 sess.floatingLaunchMu.Lock()
62 sess.floatingSpareStarting = false83 sess.floatingSpareStarting = false
63 if p != nil && !sess.floatingLaunchStopping {84 if p != nil && !sess.floatingLaunchStopping {
64- sess.floatingSpare = &floatingSpare{pane: p, cwd: spec.cwd, command: cfg.Command}85+ sess.floatingSpare = &floatingSpare{pane: p, key: spareKey(spec)}
86+ p.prestarted = true
87+ spare := p
88+ spare.onExit = func() {
89+ spare.spareExited.Store(true)
90+ if spare.ownerSnapshot() == nil {
91+ sess.dropFloatingSpare(spare)
92+ return
93+ }
94+ d.reapInstalledFloating(spare)
95+ }
96+ d.sessWg.Add(1)
97+ go d.readPanePTY(p)
65 p = nil98 p = nil
66 }99 }
67 sess.floatingLaunchMu.Unlock()100 sess.floatingLaunchMu.Unlock()
@@ -72,10 +105,10 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
72 }105 }
73 106
74 // takeFloatingSpare removes the session spare. It returns the pane when the107 // takeFloatingSpare removes the session spare. It returns the pane when the
75-// spare was started for cwd and command; a mismatched spare is closed because108+// spare was started with the same launch inputs as spec; a mismatched spare is
76-// it can never serve this directory or config. had reports whether a spare109+// closed because it can never serve this launch. had reports whether a spare
77 // existed, so the caller knows to start its replacement.110 // existed, so the caller knows to start its replacement.
78-func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool) {111+func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bool) {
79 sess.floatingLaunchMu.Lock()112 sess.floatingLaunchMu.Lock()
80 spare := sess.floatingSpare113 spare := sess.floatingSpare
81 sess.floatingSpare = nil114 sess.floatingSpare = nil
@@ -83,13 +116,23 @@ func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool)
83 if spare == nil {116 if spare == nil {
84 return nil, false117 return nil, false
85 }118 }
86- if spare.cwd != cwd || spare.command != command {119+ if spare.key != spareKey(spec) {
87 closeFloatingPane(spare.pane)120 closeFloatingPane(spare.pane)
88 return nil, true121 return nil, true
89 }122 }
90 return spare.pane, true123 return spare.pane, true
91 }124 }
92 125
126+// dropFloatingSpare forgets an unclaimed spare whose shell exited.
127+func (sess *session) dropFloatingSpare(p *pane) {
128+ sess.floatingLaunchMu.Lock()
129+ if sess.floatingSpare != nil && sess.floatingSpare.pane == p {
130+ sess.floatingSpare = nil
131+ }
132+ sess.floatingLaunchMu.Unlock()
133+ closeFloatingPane(p)
134+}
135+
93 // closeFloatingSpare releases the spare on session teardown. The caller has136 // closeFloatingSpare releases the spare on session teardown. The caller has
94 // already set floatingLaunchStopping, so no launch can publish a new one.137 // already set floatingLaunchStopping, so no launch can publish a new one.
95 func (sess *session) closeFloatingSpare() {138 func (sess *session) closeFloatingSpare() {
@@ -147,6 +190,10 @@ func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floati
147 p.rect = spec.geometry.ptyRect()190 p.rect = spec.geometry.ptyRect()
148 p.popupGeometry = spec.geometry191 p.popupGeometry = spec.geometry
149 p.mu.Unlock()192 p.mu.Unlock()
150- p.onExit = func() { d.reapInstalledFloating(p) }
151 d.installFloating(sess, tb, p, generation)193 d.installFloating(sess, tb, p, generation)
194+ if p.spareExited.Load() {
195+ // The shell exited before the owner was published, so its reader
196+ // could not reap the slot.
197+ d.reapInstalledFloating(p)
198+ }
152 }199 }
M internal/usecase/daemon/floating_spare_test.go +50 -0
@@ -183,3 +183,53 @@ type pidPTY struct {
183183 }
184184
185185 func (p *pidPTY) Pid() int { return p.pid }
186+
187+// queryPTY is a floating shell that sends one Primary Device Attribute query
188+// at startup and records what the daemon writes back.
189+type queryPTY struct {
190+ sparePTY
191+ sent chan struct{}
192+ replied chan []byte
193+}
194+
195+func (p *queryPTY) Read(b []byte) (int, error) {
196+ select {
197+ case <-p.sent:
198+ return p.sparePTY.Read(b)
199+ default:
200+ close(p.sent)
201+ return copy(b, "\x1b[c"), nil
202+ }
203+}
204+
205+func (p *queryPTY) Write(b []byte) (int, error) {
206+ p.replied <- append([]byte(nil), b...)
207+ return len(b), nil
208+}
209+
210+type queryFactory struct{ pty *queryPTY }
211+
212+func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
213+ f.pty.ctx = ctx
214+ return f.pty, nil
215+}
216+
217+func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) {
218+ pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, sent: make(chan struct{}), replied: make(chan []byte, 4)}
219+ d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{})
220+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
221+ tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
222+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
223+ sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
224+
225+ d.ensureFloatingWarm(sess, tb)
226+ select {
227+ case reply := <-pty.replied:
228+ require.Contains(t, string(reply), "\x1b[?", "the spare must answer DA1 while unclaimed")
229+ case <-time.After(5 * time.Second):
230+ t.Fatal("the unclaimed spare never answered its shell's startup query")
231+ }
232+
233+ sess.stopInMemoryLifecycle()
234+ d.sessWg.Wait()
235+}
@@ -183,3 +183,53 @@ type pidPTY struct {
183 }183 }
184 184
185 func (p *pidPTY) Pid() int { return p.pid }185 func (p *pidPTY) Pid() int { return p.pid }
186+
187+// queryPTY is a floating shell that sends one Primary Device Attribute query
188+// at startup and records what the daemon writes back.
189+type queryPTY struct {
190+ sparePTY
191+ sent chan struct{}
192+ replied chan []byte
193+}
194+
195+func (p *queryPTY) Read(b []byte) (int, error) {
196+ select {
197+ case <-p.sent:
198+ return p.sparePTY.Read(b)
199+ default:
200+ close(p.sent)
201+ return copy(b, "\x1b[c"), nil
202+ }
203+}
204+
205+func (p *queryPTY) Write(b []byte) (int, error) {
206+ p.replied <- append([]byte(nil), b...)
207+ return len(b), nil
208+}
209+
210+type queryFactory struct{ pty *queryPTY }
211+
212+func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
213+ f.pty.ctx = ctx
214+ return f.pty, nil
215+}
216+
217+func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) {
218+ pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, sent: make(chan struct{}), replied: make(chan []byte, 4)}
219+ d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{})
220+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
221+ tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
222+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
223+ sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
224+
225+ d.ensureFloatingWarm(sess, tb)
226+ select {
227+ case reply := <-pty.replied:
228+ require.Contains(t, string(reply), "\x1b[?", "the spare must answer DA1 while unclaimed")
229+ case <-time.After(5 * time.Second):
230+ t.Fatal("the unclaimed spare never answered its shell's startup query")
231+ }
232+
233+ sess.stopInMemoryLifecycle()
234+ d.sessWg.Wait()
235+}
M internal/usecase/daemon/pane.go +7 -0
@@ -145,6 +145,13 @@ type pane struct {
145145 // onExit is set before the reader starts and never changed. Floating panes
146146 // use it to reap their independent slot without touching the layout tree.
147147 onExit func()
148+ // prestarted marks a floating spare whose reader runs before any owner is
149+ // published, so terminal queries from a starting shell are answered.
150+ // Set before the reader starts and never changed.
151+ prestarted bool
152+ // spareExited records that a prestarted spare's reader ended, so a claim
153+ // racing that exit still reaps the slot it installed.
154+ spareExited atomic.Bool
148155 }
149156
150157 func closePaneProcess(p *pane) {
@@ -145,6 +145,13 @@ type pane struct {
145 // onExit is set before the reader starts and never changed. Floating panes145 // onExit is set before the reader starts and never changed. Floating panes
146 // use it to reap their independent slot without touching the layout tree.146 // use it to reap their independent slot without touching the layout tree.
147 onExit func()147 onExit func()
148+ // prestarted marks a floating spare whose reader runs before any owner is
149+ // published, so terminal queries from a starting shell are answered.
150+ // Set before the reader starts and never changed.
151+ prestarted bool
152+ // spareExited records that a prestarted spare's reader ended, so a claim
153+ // racing that exit still reaps the slot it installed.
154+ spareExited atomic.Bool
148 }155 }
149 156
150 func closePaneProcess(p *pane) {157 func closePaneProcess(p *pane) {
M internal/usecase/daemon/render.go +5 -0
@@ -213,6 +213,11 @@ func filterUnsupportedKittyAnimationResponses(data []byte) []byte {
213213 // bytes that belonged to the old owner.
214214 func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
215215 owner := effects.lease.owner
216+ if owner == nil && p.prestarted && len(effects.responses) > 0 {
217+ // An unclaimed spare still answers its shell's startup queries; a
218+ // closed spare only fails the write.
219+ _, _ = p.pty.Write(effects.responses)
220+ }
216221 if owner == nil || owner.session == nil || owner.tab == nil {
217222 return
218223 }
@@ -213,6 +213,11 @@ func filterUnsupportedKittyAnimationResponses(data []byte) []byte {
213 // bytes that belonged to the old owner.213 // bytes that belonged to the old owner.
214 func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {214 func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
215 owner := effects.lease.owner215 owner := effects.lease.owner
216+ if owner == nil && p.prestarted && len(effects.responses) > 0 {
217+ // An unclaimed spare still answers its shell's startup queries; a
218+ // closed spare only fails the write.
219+ _, _ = p.pty.Write(effects.responses)
220+ }
216 if owner == nil || owner.session == nil || owner.tab == nil {221 if owner == nil || owner.session == nil || owner.tab == nil {
217 return222 return
218 }223 }
M internal/usecase/daemon/session.go +4 -0
@@ -896,6 +896,10 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
896896 name := sess.name
897897 sess.mu.Unlock()
898898 d.log.Info("pane created", "session", name, "pane", p.id)
899+ if p.prestarted {
900+ // A claimed spare keeps the reader it started with.
901+ return
902+ }
899903 // Scheduler ownership was removed; this launch creates exactly one reader.
900904 d.sessWg.Add(1)
901905 go d.readPanePTY(p)
@@ -896,6 +896,10 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
896 name := sess.name896 name := sess.name
897 sess.mu.Unlock()897 sess.mu.Unlock()
898 d.log.Info("pane created", "session", name, "pane", p.id)898 d.log.Info("pane created", "session", name, "pane", p.id)
899+ if p.prestarted {
900+ // A claimed spare keeps the reader it started with.
901+ return
902+ }
899 // Scheduler ownership was removed; this launch creates exactly one reader.903 // Scheduler ownership was removed; this launch creates exactly one reader.
900 d.sessWg.Add(1)904 d.sessWg.Add(1)
901 go d.readPanePTY(p)905 go d.readPanePTY(p)

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