detached d2f974d

fix(daemon): theme the floating spare and resize it under the apply gate

With its reader running from launch, an unclaimed spare answered color and scheme queries (OSC 10/11) without a theme, so they got no reply. The spare now takes the session theme before its reader starts and follows later theme changes. A claim resizes the running spare behind resizeApplying so its redraw is parsed at the new size. An exiting spare is dropped only while still unclaimed, under floatingLaunchMu, so an exit racing a claim no longer closes the claimed pane and leaves the slot without an owner.
commit
d2f974da47ef1eca2e7702740cf4b719de67cb6b
author
brice · · 2026-10-08 06:21 UTC
parent
8dda6f5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +113 -19
M internal/usecase/daemon/floating_spare.go +59 -8
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "strings"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/domain/layout"
78 )
89
@@ -79,6 +80,11 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
7980 d.sessWg.Go(func() {
8081 defer sess.finishFloatingLaunch(launch)
8182 p := d.openFloatingPane(sess, spec)
83+ if p != nil {
84+ // The reader answers color and scheme queries, so the spare
85+ // starts with the session theme, as a new tab does.
86+ d.applySessionThemeToPane(sess, p)
87+ }
8288 sess.floatingLaunchMu.Lock()
8389 sess.floatingSpareStarting = false
8490 if p != nil && !sess.floatingLaunchStopping {
@@ -87,8 +93,9 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
8793 spare := p
8894 spare.onExit = func() {
8995 spare.spareExited.Store(true)
90- if spare.ownerSnapshot() == nil {
91- sess.dropFloatingSpare(spare)
96+ // Still unclaimed: drop it. A claim takes the spare under the
97+ // same lock, then reaps after install when spareExited is set.
98+ if sess.dropFloatingSpare(spare) {
9299 return
93100 }
94101 d.reapInstalledFloating(spare)
@@ -123,14 +130,43 @@ func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bo
123130 return spare.pane, true
124131 }
125132
126-// dropFloatingSpare forgets an unclaimed spare whose shell exited.
127-func (sess *session) dropFloatingSpare(p *pane) {
133+// dropFloatingSpare closes p when it is still the session's unclaimed spare
134+// and reports whether it did. A claimed spare is left to its tab.
135+func (sess *session) dropFloatingSpare(p *pane) bool {
128136 sess.floatingLaunchMu.Lock()
129- if sess.floatingSpare != nil && sess.floatingSpare.pane == p {
137+ unclaimed := sess.floatingSpare != nil && sess.floatingSpare.pane == p
138+ if unclaimed {
130139 sess.floatingSpare = nil
131140 }
132141 sess.floatingLaunchMu.Unlock()
133- closeFloatingPane(p)
142+ if unclaimed {
143+ closeFloatingPane(p)
144+ }
145+ return unclaimed
146+}
147+
148+// applySessionThemeToPane gives a pane the theme of the session's first
149+// attachment, as createTab does for a new tab. A headless session leaves the
150+// pane unthemed until the next theme application.
151+func (d *Daemon) applySessionThemeToPane(sess *session, p *pane) {
152+ attachments := sess.snapshotAttachments()
153+ if len(attachments) == 0 {
154+ return
155+ }
156+ t := d.effectiveTheme(attachments[0].getClientTheme())
157+ p.mu.Lock()
158+ applyPaneThemeLocked(p, t, false)
159+ p.mu.Unlock()
160+}
161+
162+// floatingSparePane returns the session's unclaimed spare pane, if any.
163+func (sess *session) floatingSparePane() *pane {
164+ sess.floatingLaunchMu.Lock()
165+ defer sess.floatingLaunchMu.Unlock()
166+ if sess.floatingSpare == nil {
167+ return nil
168+ }
169+ return sess.floatingSpare.pane
134170 }
135171
136172 // closeFloatingSpare releases the spare on session teardown. The caller has
@@ -177,8 +213,19 @@ func (d *Daemon) openFloatingPane(sess *session, spec floatingLaunchSpec) *pane
177213 // and installs it in the current slot generation. It runs in the launch worker
178214 // without any architecture lock, because PTY.Resize is external.
179215 func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) {
180- if p.geometry != spec.ptyGeometry {
181- if err := p.pty.Resize(spec.ptyGeometry); err != nil {
216+ p.resizeMu.Lock()
217+ p.mu.Lock()
218+ resize := p.geometry != spec.ptyGeometry
219+ if resize {
220+ // The spare's reader is running: buffer its output across the
221+ // resize so the redraw is parsed at the new size.
222+ p.resizeApplying = true
223+ }
224+ p.mu.Unlock()
225+ if resize {
226+ if err := resizePTYGeometry(p.pty, spec.ptyGeometry); err != nil {
227+ d.replayResizePending(sess, tb, p, false, domain.Rect{})
228+ p.resizeMu.Unlock()
182229 closeFloatingPane(p)
183230 d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err)
184231 return
@@ -190,6 +237,10 @@ func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floati
190237 p.rect = spec.geometry.ptyRect()
191238 p.popupGeometry = spec.geometry
192239 p.mu.Unlock()
240+ if resize {
241+ d.replayResizePending(sess, tb, p, false, domain.Rect{})
242+ }
243+ p.resizeMu.Unlock()
193244 d.installFloating(sess, tb, p, generation)
194245 if p.spareExited.Load() {
195246 // The shell exited before the owner was published, so its reader
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "strings"4 "strings"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/domain/layout"7 "github.com/bnema/vev/internal/domain/layout"
7 )8 )
8 9
@@ -79,6 +80,11 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
79 d.sessWg.Go(func() {80 d.sessWg.Go(func() {
80 defer sess.finishFloatingLaunch(launch)81 defer sess.finishFloatingLaunch(launch)
81 p := d.openFloatingPane(sess, spec)82 p := d.openFloatingPane(sess, spec)
83+ if p != nil {
84+ // The reader answers color and scheme queries, so the spare
85+ // starts with the session theme, as a new tab does.
86+ d.applySessionThemeToPane(sess, p)
87+ }
82 sess.floatingLaunchMu.Lock()88 sess.floatingLaunchMu.Lock()
83 sess.floatingSpareStarting = false89 sess.floatingSpareStarting = false
84 if p != nil && !sess.floatingLaunchStopping {90 if p != nil && !sess.floatingLaunchStopping {
@@ -87,8 +93,9 @@ func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
87 spare := p93 spare := p
88 spare.onExit = func() {94 spare.onExit = func() {
89 spare.spareExited.Store(true)95 spare.spareExited.Store(true)
90- if spare.ownerSnapshot() == nil {96+ // Still unclaimed: drop it. A claim takes the spare under the
91- sess.dropFloatingSpare(spare)97+ // same lock, then reaps after install when spareExited is set.
98+ if sess.dropFloatingSpare(spare) {
92 return99 return
93 }100 }
94 d.reapInstalledFloating(spare)101 d.reapInstalledFloating(spare)
@@ -123,14 +130,43 @@ func (sess *session) takeFloatingSpare(spec floatingLaunchSpec) (p *pane, had bo
123 return spare.pane, true130 return spare.pane, true
124 }131 }
125 132
126-// dropFloatingSpare forgets an unclaimed spare whose shell exited.133+// dropFloatingSpare closes p when it is still the session's unclaimed spare
127-func (sess *session) dropFloatingSpare(p *pane) {134+// and reports whether it did. A claimed spare is left to its tab.
135+func (sess *session) dropFloatingSpare(p *pane) bool {
128 sess.floatingLaunchMu.Lock()136 sess.floatingLaunchMu.Lock()
129- if sess.floatingSpare != nil && sess.floatingSpare.pane == p {137+ unclaimed := sess.floatingSpare != nil && sess.floatingSpare.pane == p
138+ if unclaimed {
130 sess.floatingSpare = nil139 sess.floatingSpare = nil
131 }140 }
132 sess.floatingLaunchMu.Unlock()141 sess.floatingLaunchMu.Unlock()
133- closeFloatingPane(p)142+ if unclaimed {
143+ closeFloatingPane(p)
144+ }
145+ return unclaimed
146+}
147+
148+// applySessionThemeToPane gives a pane the theme of the session's first
149+// attachment, as createTab does for a new tab. A headless session leaves the
150+// pane unthemed until the next theme application.
151+func (d *Daemon) applySessionThemeToPane(sess *session, p *pane) {
152+ attachments := sess.snapshotAttachments()
153+ if len(attachments) == 0 {
154+ return
155+ }
156+ t := d.effectiveTheme(attachments[0].getClientTheme())
157+ p.mu.Lock()
158+ applyPaneThemeLocked(p, t, false)
159+ p.mu.Unlock()
160+}
161+
162+// floatingSparePane returns the session's unclaimed spare pane, if any.
163+func (sess *session) floatingSparePane() *pane {
164+ sess.floatingLaunchMu.Lock()
165+ defer sess.floatingLaunchMu.Unlock()
166+ if sess.floatingSpare == nil {
167+ return nil
168+ }
169+ return sess.floatingSpare.pane
134 }170 }
135 171
136 // closeFloatingSpare releases the spare on session teardown. The caller has172 // closeFloatingSpare releases the spare on session teardown. The caller has
@@ -177,8 +213,19 @@ func (d *Daemon) openFloatingPane(sess *session, spec floatingLaunchSpec) *pane
177 // and installs it in the current slot generation. It runs in the launch worker213 // and installs it in the current slot generation. It runs in the launch worker
178 // without any architecture lock, because PTY.Resize is external.214 // without any architecture lock, because PTY.Resize is external.
179 func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) {215 func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) {
180- if p.geometry != spec.ptyGeometry {216+ p.resizeMu.Lock()
181- if err := p.pty.Resize(spec.ptyGeometry); err != nil {217+ p.mu.Lock()
218+ resize := p.geometry != spec.ptyGeometry
219+ if resize {
220+ // The spare's reader is running: buffer its output across the
221+ // resize so the redraw is parsed at the new size.
222+ p.resizeApplying = true
223+ }
224+ p.mu.Unlock()
225+ if resize {
226+ if err := resizePTYGeometry(p.pty, spec.ptyGeometry); err != nil {
227+ d.replayResizePending(sess, tb, p, false, domain.Rect{})
228+ p.resizeMu.Unlock()
182 closeFloatingPane(p)229 closeFloatingPane(p)
183 d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err)230 d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err)
184 return231 return
@@ -190,6 +237,10 @@ func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floati
190 p.rect = spec.geometry.ptyRect()237 p.rect = spec.geometry.ptyRect()
191 p.popupGeometry = spec.geometry238 p.popupGeometry = spec.geometry
192 p.mu.Unlock()239 p.mu.Unlock()
240+ if resize {
241+ d.replayResizePending(sess, tb, p, false, domain.Rect{})
242+ }
243+ p.resizeMu.Unlock()
193 d.installFloating(sess, tb, p, generation)244 d.installFloating(sess, tb, p, generation)
194 if p.spareExited.Load() {245 if p.spareExited.Load() {
195 // The shell exited before the owner was published, so its reader246 // The shell exited before the owner was published, so its reader
M internal/usecase/daemon/floating_spare_test.go +48 -11
@@ -7,10 +7,12 @@ import (
77 "testing"
88 "time"
99
10+ renderer "github.com/bnema/vev-vt"
1011 "github.com/stretchr/testify/require"
1112
1213 "github.com/bnema/vev/internal/domain"
1314 "github.com/bnema/vev/internal/ports"
15+ themeui "github.com/bnema/vev/internal/usecase/theme"
1416 )
1517
1618 // spareOpen records one PTY Open issued for a floating shell.
@@ -184,10 +186,11 @@ type pidPTY struct {
184186
185187 func (p *pidPTY) Pid() int { return p.pid }
186188
187-// queryPTY is a floating shell that sends one Primary Device Attribute query
188-// at startup and records what the daemon writes back.
189+// queryPTY is a floating shell that sends one terminal query at startup and
190+// records what the daemon writes back.
189191 type queryPTY struct {
190192 sparePTY
193+ query string
191194 sent chan struct{}
192195 replied chan []byte
193196 }
@@ -198,7 +201,7 @@ func (p *queryPTY) Read(b []byte) (int, error) {
198201 return p.sparePTY.Read(b)
199202 default:
200203 close(p.sent)
201- return copy(b, "\x1b[c"), nil
204+ return copy(b, p.query), nil
202205 }
203206 }
204207
@@ -215,20 +218,54 @@ func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []strin
215218 }
216219
217220 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{})
221+ for _, tt := range []struct {
222+ name string
223+ query string
224+ want string
225+ }{
226+ {name: "primary device attributes", query: "\x1b[c", want: "\x1b[?"},
227+ {name: "background color from the session theme", query: "\x1b]11;?\x07", want: "\x1b]11;rgb:"},
228+ } {
229+ t.Run(tt.name, func(t *testing.T) {
230+ pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, query: tt.query, sent: make(chan struct{}), replied: make(chan []byte, 4)}
231+ d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{})
232+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
233+ tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
234+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
235+ ac := &attachedClient{}
236+ ac.setClientTheme(themeui.Theme{Known: true, HasFG: true, HasBG: true,
237+ Foreground: renderer.RGB{R: 0xee, G: 0xee, B: 0xee}, Background: renderer.RGB{R: 0x11, G: 0x11, B: 0x11}})
238+ sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
239+
240+ d.ensureFloatingWarm(sess, tb)
241+ select {
242+ case reply := <-pty.replied:
243+ require.Contains(t, string(reply), tt.want, "the unclaimed spare must answer its shell's startup query")
244+ case <-time.After(5 * time.Second):
245+ t.Fatal("the unclaimed spare never answered its shell's startup query")
246+ }
247+
248+ sess.stopInMemoryLifecycle()
249+ d.sessWg.Wait()
250+ })
251+ }
252+}
253+
254+func TestFloatingSpareExitBeforeClaimDropsIt(t *testing.T) {
255+ factory := &spareFactory{}
256+ d := newTestDaemon(t, factory, stubClock{})
220257 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
221258 tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
222259 tb.ctx, tb.cancel = context.WithCancel(t.Context())
223260 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
224261
225262 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- }
263+ waitSpareReady(t, sess)
264+ spare := sess.floatingSparePane()
265+ require.NotNil(t, spare)
266+ require.NoError(t, factory.snapshot()[0].pty.Close(), "the spare's shell exits")
267+ require.Eventually(t, func() bool { return sess.floatingSparePane() == nil }, time.Second, time.Millisecond,
268+ "an exited unclaimed spare must be dropped so the next claim launches a fresh shell")
232269
233270 sess.stopInMemoryLifecycle()
234271 d.sessWg.Wait()
@@ -7,10 +7,12 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10+ renderer "github.com/bnema/vev-vt"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 12
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ themeui "github.com/bnema/vev/internal/usecase/theme"
14 )16 )
15 17
16 // spareOpen records one PTY Open issued for a floating shell.18 // spareOpen records one PTY Open issued for a floating shell.
@@ -184,10 +186,11 @@ type pidPTY struct {
184 186
185 func (p *pidPTY) Pid() int { return p.pid }187 func (p *pidPTY) Pid() int { return p.pid }
186 188
187-// queryPTY is a floating shell that sends one Primary Device Attribute query189+// queryPTY is a floating shell that sends one terminal query at startup and
188-// at startup and records what the daemon writes back.190+// records what the daemon writes back.
189 type queryPTY struct {191 type queryPTY struct {
190 sparePTY192 sparePTY
193+ query string
191 sent chan struct{}194 sent chan struct{}
192 replied chan []byte195 replied chan []byte
193 }196 }
@@ -198,7 +201,7 @@ func (p *queryPTY) Read(b []byte) (int, error) {
198 return p.sparePTY.Read(b)201 return p.sparePTY.Read(b)
199 default:202 default:
200 close(p.sent)203 close(p.sent)
201- return copy(b, "\x1b[c"), nil204+ return copy(b, p.query), nil
202 }205 }
203 }206 }
204 207
@@ -215,20 +218,54 @@ func (f *queryFactory) Open(ctx context.Context, _ string, _ []string, _ []strin
215 }218 }
216 219
217 func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) {220 func TestFloatingSpareAnswersStartupQueriesBeforeClaim(t *testing.T) {
218- pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, sent: make(chan struct{}), replied: make(chan []byte, 4)}221+ for _, tt := range []struct {
219- d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{})222+ name string
223+ query string
224+ want string
225+ }{
226+ {name: "primary device attributes", query: "\x1b[c", want: "\x1b[?"},
227+ {name: "background color from the session theme", query: "\x1b]11;?\x07", want: "\x1b]11;rgb:"},
228+ } {
229+ t.Run(tt.name, func(t *testing.T) {
230+ pty := &queryPTY{sparePTY: sparePTY{closed: make(chan struct{})}, query: tt.query, sent: make(chan struct{}), replied: make(chan []byte, 4)}
231+ d := newTestDaemon(t, &queryFactory{pty: pty}, stubClock{})
232+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
233+ tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
234+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
235+ ac := &attachedClient{}
236+ ac.setClientTheme(themeui.Theme{Known: true, HasFG: true, HasBG: true,
237+ Foreground: renderer.RGB{R: 0xee, G: 0xee, B: 0xee}, Background: renderer.RGB{R: 0x11, G: 0x11, B: 0x11}})
238+ sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
239+
240+ d.ensureFloatingWarm(sess, tb)
241+ select {
242+ case reply := <-pty.replied:
243+ require.Contains(t, string(reply), tt.want, "the unclaimed spare must answer its shell's startup query")
244+ case <-time.After(5 * time.Second):
245+ t.Fatal("the unclaimed spare never answered its shell's startup query")
246+ }
247+
248+ sess.stopInMemoryLifecycle()
249+ d.sessWg.Wait()
250+ })
251+ }
252+}
253+
254+func TestFloatingSpareExitBeforeClaimDropsIt(t *testing.T) {
255+ factory := &spareFactory{}
256+ d := newTestDaemon(t, factory, stubClock{})
220 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})257 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 80, Height: 80}})
221 tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})258 tb := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 80, Rows: 24})
222 tb.ctx, tb.cancel = context.WithCancel(t.Context())259 tb.ctx, tb.cancel = context.WithCancel(t.Context())
223 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}260 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
224 261
225 d.ensureFloatingWarm(sess, tb)262 d.ensureFloatingWarm(sess, tb)
226- select {263+ waitSpareReady(t, sess)
227- case reply := <-pty.replied:264+ spare := sess.floatingSparePane()
228- require.Contains(t, string(reply), "\x1b[?", "the spare must answer DA1 while unclaimed")265+ require.NotNil(t, spare)
229- case <-time.After(5 * time.Second):266+ require.NoError(t, factory.snapshot()[0].pty.Close(), "the spare's shell exits")
230- t.Fatal("the unclaimed spare never answered its shell's startup query")267+ require.Eventually(t, func() bool { return sess.floatingSparePane() == nil }, time.Second, time.Millisecond,
231- }268+ "an exited unclaimed spare must be dropped so the next claim launches a fresh shell")
232 269
233 sess.stopInMemoryLifecycle()270 sess.stopInMemoryLifecycle()
234 d.sessWg.Wait()271 d.sessWg.Wait()
M internal/usecase/daemon/theme_resolve.go +6 -0
@@ -51,6 +51,12 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
5151 p.mu.Unlock()
5252 }
5353 }
54+ // An unclaimed spare answers color queries too, so it follows the theme.
55+ if spare := sess.floatingSparePane(); spare != nil {
56+ spare.mu.Lock()
57+ applyPaneThemeLocked(spare, t, clearUnknownScheme)
58+ spare.mu.Unlock()
59+ }
5460 return true
5561 }
5662
@@ -51,6 +51,12 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
51 p.mu.Unlock()51 p.mu.Unlock()
52 }52 }
53 }53 }
54+ // An unclaimed spare answers color queries too, so it follows the theme.
55+ if spare := sess.floatingSparePane(); spare != nil {
56+ spare.mu.Lock()
57+ applyPaneThemeLocked(spare, t, clearUnknownScheme)
58+ spare.mu.Unlock()
59+ }
54 return true60 return true
55 }61 }
56 62

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