detached 04d2afb

no-mistakes(review): fix(daemon): propagate themes to floating panes

commit
04d2afbd1226a6bb334166bc1ee82f9892ddaf6c
author
brice · · 2026-07-10 11:40 UTC
parent
789f8b1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +81 -21
M internal/usecase/daemon/config.go +16 -8
@@ -232,13 +232,21 @@ func (d *Daemon) reapplyThemeAllSessions() {
232232 d.mu.Unlock()
233233
234234 for _, sess := range sessions {
235- sess.mu.Lock()
236- ac := sess.client
237- sess.mu.Unlock()
238- var clientTheme theme.Theme
239- if ac != nil {
240- clientTheme = ac.getClientTheme()
241- }
242- d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)
235+ d.reapplyThemeSession(sess)
236+ }
237+}
238+
239+func (d *Daemon) reapplyThemeSession(sess *session) {
240+ sess.mu.Lock()
241+ ac := sess.client
242+ sess.mu.Unlock()
243+ if ac == nil {
244+ d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true)
245+ return
243246 }
247+ ac.sendMu.Lock()
248+ defer ac.sendMu.Unlock()
249+ sess.themeMu.Lock()
250+ defer sess.themeMu.Unlock()
251+ d.applyHostThemeLocked(sess, ac, d.effectiveTheme(ac.getClientTheme()), false)
244252 }
@@ -232,13 +232,21 @@ func (d *Daemon) reapplyThemeAllSessions() {
232 d.mu.Unlock()232 d.mu.Unlock()
233 233
234 for _, sess := range sessions {234 for _, sess := range sessions {
235- sess.mu.Lock()235+ d.reapplyThemeSession(sess)
236- ac := sess.client236+ }
237- sess.mu.Unlock()237+}
238- var clientTheme theme.Theme238+
239- if ac != nil {239+func (d *Daemon) reapplyThemeSession(sess *session) {
240- clientTheme = ac.getClientTheme()240+ sess.mu.Lock()
241- }241+ ac := sess.client
242- d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)242+ sess.mu.Unlock()
243+ if ac == nil {
244+ d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true)
245+ return
243 }246 }
247+ ac.sendMu.Lock()
248+ defer ac.sendMu.Unlock()
249+ sess.themeMu.Lock()
250+ defer sess.themeMu.Unlock()
251+ d.applyHostThemeLocked(sess, ac, d.effectiveTheme(ac.getClientTheme()), false)
244 }252 }
M internal/usecase/daemon/floating.go +1 -0
@@ -339,6 +339,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
339339 closeFloatingPane(p)
340340 return
341341 }
342+ d.reapplyThemeSession(sess)
342343 d.startPaneGoroutines(sess, tb, p)
343344 if visible {
344345 d.resizeActiveFloating(tb)
@@ -339,6 +339,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
339 closeFloatingPane(p)339 closeFloatingPane(p)
340 return340 return
341 }341 }
342+ d.reapplyThemeSession(sess)
342 d.startPaneGoroutines(sess, tb, p)343 d.startPaneGoroutines(sess, tb, p)
343 if visible {344 if visible {
344 d.resizeActiveFloating(tb)345 d.resizeActiveFloating(tb)
M internal/usecase/daemon/floating_test.go +9 -2
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/vev/internal/ports"
1818 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1919 "github.com/bnema/vev/internal/usecase/layout"
20+ themeui "github.com/bnema/vev/internal/usecase/theme"
2021 )
2122
2223 func TestFloatingSlotTransitions(t *testing.T) {
@@ -134,7 +135,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
134135 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
135136 tb.ctx, tb.cancel = context.WithCancel(t.Context())
136137 sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()}
137- d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
138+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
138139 d.ensureFloatingWarm(sess, tb)
139140 // Open has started while this goroutine owns tab.mu: an external factory
140141 // call under that lock would deadlock this channel-controlled test.
@@ -145,13 +146,19 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
145146 t.Fatal("floating Open waited for tab.mu")
146147 }
147148 tb.mu.Unlock()
148- d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
149+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
149150 close(allowOpen)
150151 select {
151152 case <-readerStarted: // install started exactly one reader after the async Open
152153 case <-time.After(time.Second):
153154 t.Fatal("floating pane was not installed")
154155 }
156+ tb.mu.Lock()
157+ floating := tb.floating.pane
158+ tb.mu.Unlock()
159+ require.NotNil(t, floating)
160+ assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
161+ assertPaneColorScheme(t, floating, false)
155162 require.Equal(t, "/bin/custom-shell", gotCommand)
156163 require.Equal(t, []string{"-lc", "btop --utf"}, gotArgs)
157164 require.Equal(t, cwd, gotDir)
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
20+ themeui "github.com/bnema/vev/internal/usecase/theme"
20 )21 )
21 22
22 func TestFloatingSlotTransitions(t *testing.T) {23 func TestFloatingSlotTransitions(t *testing.T) {
@@ -134,7 +135,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
134 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})135 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
135 tb.ctx, tb.cancel = context.WithCancel(t.Context())136 tb.ctx, tb.cancel = context.WithCancel(t.Context())
136 sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()}137 sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()}
137- d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})138+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
138 d.ensureFloatingWarm(sess, tb)139 d.ensureFloatingWarm(sess, tb)
139 // Open has started while this goroutine owns tab.mu: an external factory140 // Open has started while this goroutine owns tab.mu: an external factory
140 // call under that lock would deadlock this channel-controlled test.141 // call under that lock would deadlock this channel-controlled test.
@@ -145,13 +146,19 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
145 t.Fatal("floating Open waited for tab.mu")146 t.Fatal("floating Open waited for tab.mu")
146 }147 }
147 tb.mu.Unlock()148 tb.mu.Unlock()
148- d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})149+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
149 close(allowOpen)150 close(allowOpen)
150 select {151 select {
151 case <-readerStarted: // install started exactly one reader after the async Open152 case <-readerStarted: // install started exactly one reader after the async Open
152 case <-time.After(time.Second):153 case <-time.After(time.Second):
153 t.Fatal("floating pane was not installed")154 t.Fatal("floating pane was not installed")
154 }155 }
156+ tb.mu.Lock()
157+ floating := tb.floating.pane
158+ tb.mu.Unlock()
159+ require.NotNil(t, floating)
160+ assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
161+ assertPaneColorScheme(t, floating, false)
155 require.Equal(t, "/bin/custom-shell", gotCommand)162 require.Equal(t, "/bin/custom-shell", gotCommand)
156 require.Equal(t, []string{"-lc", "btop --utf"}, gotArgs)163 require.Equal(t, []string{"-lc", "btop --utf"}, gotArgs)
157 require.Equal(t, cwd, gotDir)164 require.Equal(t, cwd, gotDir)
M internal/usecase/daemon/session.go +1 -0
@@ -55,6 +55,7 @@ type session struct {
5555 cancel context.CancelFunc
5656
5757 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
58+ themeMu sync.Mutex
5859 tabs []*tab
5960 active int
6061 client *attachedClient
@@ -55,6 +55,7 @@ type session struct {
55 cancel context.CancelFunc55 cancel context.CancelFunc
56 56
57 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state57 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
58+ themeMu sync.Mutex
58 tabs []*tab59 tabs []*tab
59 active int60 active int
60 client *attachedClient61 client *attachedClient
M internal/usecase/daemon/status_test.go +46 -11
@@ -155,6 +155,31 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
155155 assertSessionColorScheme(t, sess, true)
156156 }
157157
158+func TestApplyThemePropagatesToFloatingPane(t *testing.T) {
159+ p, releasePTY := newBlockingPTY(t)
160+ defer releasePTY()
161+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
162+ tb := sess.activeTab()
163+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
164+ installTestFloating(tb, floating, true)
165+ clientTheme := ports.Theme{
166+ HasForeground: true,
167+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168+ HasBackground: true,
169+ Background: renderer.RGB{R: 4, G: 5, B: 6},
170+ SchemeKnown: true,
171+ Light: true,
172+ }
173+
174+ d.applyTheme(sess, ac, clientTheme)
175+ assertPaneDefaultColors(t, floating, clientTheme.Foreground, clientTheme.Background)
176+ assertPaneColorScheme(t, floating, true)
177+
178+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
179+ assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
180+ assertPaneColorScheme(t, floating, false)
181+}
182+
158183 func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
159184 tests := []struct {
160185 name string
@@ -297,26 +322,36 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
297322 func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
298323 t.Helper()
299324 for _, tb := range sess.tabs {
300- var got []byte
301- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
302- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
303- require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
304- require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
325+ assertPaneDefaultColors(t, tb.focusedPane(), fg, bg)
305326 }
306327 }
307328
329+func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {
330+ t.Helper()
331+ var got []byte
332+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
333+ p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
334+ require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
335+ require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
336+}
337+
308338 func assertSessionColorScheme(t *testing.T, sess *session, light bool) {
339+ t.Helper()
340+ for _, tb := range sess.tabs {
341+ assertPaneColorScheme(t, tb.focusedPane(), light)
342+ }
343+}
344+
345+func assertPaneColorScheme(t *testing.T, p *pane, light bool) {
309346 t.Helper()
310347 want := "\x1b[?997;1n"
311348 if light {
312349 want = "\x1b[?997;2n"
313350 }
314- for _, tb := range sess.tabs {
315- var got []byte
316- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
317- tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
318- require.Equal(t, want, string(got))
319- }
351+ var got []byte
352+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
353+ p.screen.Write([]byte("\x1b[?996n"))
354+ require.Equal(t, want, string(got))
320355 }
321356
322357 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
@@ -155,6 +155,31 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
155 assertSessionColorScheme(t, sess, true)155 assertSessionColorScheme(t, sess, true)
156 }156 }
157 157
158+func TestApplyThemePropagatesToFloatingPane(t *testing.T) {
159+ p, releasePTY := newBlockingPTY(t)
160+ defer releasePTY()
161+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
162+ tb := sess.activeTab()
163+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
164+ installTestFloating(tb, floating, true)
165+ clientTheme := ports.Theme{
166+ HasForeground: true,
167+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168+ HasBackground: true,
169+ Background: renderer.RGB{R: 4, G: 5, B: 6},
170+ SchemeKnown: true,
171+ Light: true,
172+ }
173+
174+ d.applyTheme(sess, ac, clientTheme)
175+ assertPaneDefaultColors(t, floating, clientTheme.Foreground, clientTheme.Background)
176+ assertPaneColorScheme(t, floating, true)
177+
178+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
179+ assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
180+ assertPaneColorScheme(t, floating, false)
181+}
182+
158 func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {183 func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
159 tests := []struct {184 tests := []struct {
160 name string185 name string
@@ -297,26 +322,36 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
297 func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {322 func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
298 t.Helper()323 t.Helper()
299 for _, tb := range sess.tabs {324 for _, tb := range sess.tabs {
300- var got []byte325+ assertPaneDefaultColors(t, tb.focusedPane(), fg, bg)
301- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
302- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
303- require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
304- require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
305 }326 }
306 }327 }
307 328
329+func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {
330+ t.Helper()
331+ var got []byte
332+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
333+ p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
334+ require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
335+ require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
336+}
337+
308 func assertSessionColorScheme(t *testing.T, sess *session, light bool) {338 func assertSessionColorScheme(t *testing.T, sess *session, light bool) {
339+ t.Helper()
340+ for _, tb := range sess.tabs {
341+ assertPaneColorScheme(t, tb.focusedPane(), light)
342+ }
343+}
344+
345+func assertPaneColorScheme(t *testing.T, p *pane, light bool) {
309 t.Helper()346 t.Helper()
310 want := "\x1b[?997;1n"347 want := "\x1b[?997;1n"
311 if light {348 if light {
312 want = "\x1b[?997;2n"349 want = "\x1b[?997;2n"
313 }350 }
314- for _, tb := range sess.tabs {351+ var got []byte
315- var got []byte352+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
316- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }353+ p.screen.Write([]byte("\x1b[?996n"))
317- tb.focusedPane().screen.Write([]byte("\x1b[?996n"))354+ require.Equal(t, want, string(got))
318- require.Equal(t, want, string(got))
319- }
320 }355 }
321 356
322 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {357 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
M internal/usecase/daemon/theme_resolve.go +8 -0
@@ -7,7 +7,12 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
77 ac.sendMu.Lock()
88 defer ac.sendMu.Unlock()
99 }
10+ sess.themeMu.Lock()
11+ defer sess.themeMu.Unlock()
12+ return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
13+}
1014
15+func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
1116 sess.mu.Lock()
1217 if ac != nil {
1318 if sess.client != ac {
@@ -28,6 +33,9 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
2833 for _, tb := range tabs {
2934 tb.mu.Lock()
3035 panes := tb.panesSnapshot()
36+ if floating := tb.floating.pane; floating != nil {
37+ panes = append(panes, floating)
38+ }
3139 tb.mu.Unlock()
3240 for _, p := range panes {
3341 p.mu.Lock()
@@ -7,7 +7,12 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
7 ac.sendMu.Lock()7 ac.sendMu.Lock()
8 defer ac.sendMu.Unlock()8 defer ac.sendMu.Unlock()
9 }9 }
10+ sess.themeMu.Lock()
11+ defer sess.themeMu.Unlock()
12+ return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
13+}
10 14
15+func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
11 sess.mu.Lock()16 sess.mu.Lock()
12 if ac != nil {17 if ac != nil {
13 if sess.client != ac {18 if sess.client != ac {
@@ -28,6 +33,9 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
28 for _, tb := range tabs {33 for _, tb := range tabs {
29 tb.mu.Lock()34 tb.mu.Lock()
30 panes := tb.panesSnapshot()35 panes := tb.panesSnapshot()
36+ if floating := tb.floating.pane; floating != nil {
37+ panes = append(panes, floating)
38+ }
31 tb.mu.Unlock()39 tb.mu.Unlock()
32 for _, p := range panes {40 for _, p := range panes {
33 p.mu.Lock()41 p.mu.Lock()

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 2

Tags 13

↑↓ moveenter openesc close