Skip to content

Commit 8e79067

Browse files
authored
Merge pull request #3505 from docker/fix/3501-theme-watcher-rewire
fix(tui): rewire theme watcher lost in tab-view rewrite
2 parents 2e17d81 + 729e85b commit 8e79067

5 files changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
version: 2
3+
interactions: []

e2e/tui/theme_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package tui_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/docker/docker-agent/pkg/tui/messages"
13+
"github.com/docker/docker-agent/pkg/tui/styles"
14+
"github.com/docker/docker-agent/pkg/tui/tuitest"
15+
)
16+
17+
// TestTheme_HotReload is the regression net for the theme watcher wiring
18+
// (issue #3501): editing the active custom theme file while the TUI runs
19+
// must hot-reload it without a restart. It drives the full loop — switch to
20+
// a user theme, edit its file on disk, and wait for the watcher to emit
21+
// ThemeFileChangedMsg, which surfaces as the "Theme hot-reloaded"
22+
// notification. No LLM call is made, so the cassette is empty.
23+
func TestTheme_HotReload(t *testing.T) {
24+
// The theme registry is process-global; restore the default so other
25+
// tests never see this test's theme. Registered before newTUI so that
26+
// (LIFO) it runs only after the program stopped rendering.
27+
t.Cleanup(func() { styles.ApplyThemeRef(styles.DefaultThemeRef) })
28+
d := newTUI(t, "testdata/basic.yaml", 120, 40)
29+
30+
// isolateState (inside newTUI) redirected the data dir to a temp dir, so
31+
// this writes the user theme where the running TUI expects it.
32+
themesDir := styles.ThemesDir()
33+
require.NoError(t, os.MkdirAll(themesDir, 0o755))
34+
themePath := filepath.Join(themesDir, "hotreload.yaml")
35+
require.NoError(t, os.WriteFile(themePath, []byte("version: 1\nname: Hot Reload Before\n"), 0o644))
36+
37+
// Switching themes re-targets the watcher onto the new theme's file.
38+
d.Send(messages.ChangeThemeMsg{ThemeRef: "user:hotreload"}).
39+
WaitFor(tuitest.Contains("Theme changed to Hot Reload Before"))
40+
41+
// The watcher arms asynchronously (ThemeChangedMsg is sequenced after the
42+
// notification above) and debounces events for 500ms, so a single write
43+
// could slip in before fsnotify is attached. Re-edit the file on every
44+
// poll — like a user tweaking colors — until the reload lands. The 700ms
45+
// interval exceeds the debounce so rewrites cannot starve the timer.
46+
require.Eventually(t, func() bool {
47+
require.NoError(t, os.WriteFile(themePath, []byte("version: 1\nname: Hot Reload After\n"), 0o644))
48+
return strings.Contains(d.Frame(), "Theme hot-reloaded")
49+
}, 15*time.Second, 700*time.Millisecond, "editing the theme file should hot-reload it")
50+
51+
require.Eventually(t, func() bool {
52+
return styles.CurrentTheme().Name == "Hot Reload After"
53+
}, 3*time.Second, 50*time.Millisecond, "styles.CurrentTheme() should pick up the edited theme")
54+
}

pkg/tui/handlers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,9 @@ func (m *appModel) invalidateCachesForThemeChange() {
771771

772772
func (m *appModel) applyThemeChanged() (tea.Model, tea.Cmd) {
773773
m.invalidateCachesForThemeChange()
774+
// Re-target the file watcher: theme changes (picker selection, preview,
775+
// hot reload) can move the active theme to a different backing file.
776+
m.watchCurrentTheme()
774777
return m, tea.Batch(
775778
m.updateDialogCmd(messages.ThemeChangedMsg{}),
776779
m.updateChatCmd(messages.ThemeChangedMsg{}),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/docker/docker-agent/pkg/tui/components/statusbar"
9+
"github.com/docker/docker-agent/pkg/tui/styles"
10+
)
11+
12+
// fakeThemeWatcher records Watch/Stop calls so the wiring between the model
13+
// lifecycle and the theme watcher can be asserted without touching the
14+
// filesystem.
15+
type fakeThemeWatcher struct {
16+
watched []string
17+
stopped bool
18+
}
19+
20+
func (f *fakeThemeWatcher) Watch(themeRef string) error {
21+
f.watched = append(f.watched, themeRef)
22+
return nil
23+
}
24+
25+
func (f *fakeThemeWatcher) Stop() { f.stopped = true }
26+
27+
func TestWatchCurrentTheme_TargetsAppliedTheme(t *testing.T) {
28+
m, _ := newTestModel(t)
29+
fw := &fakeThemeWatcher{}
30+
m.themeWatcher = fw
31+
32+
m.watchCurrentTheme()
33+
34+
assert.Equal(t, []string{styles.CurrentTheme().Ref}, fw.watched)
35+
}
36+
37+
func TestWatchCurrentTheme_NoWatcherIsNoop(t *testing.T) {
38+
m, _ := newTestModel(t)
39+
40+
assert.NotPanics(t, func() { m.watchCurrentTheme() })
41+
}
42+
43+
func TestApplyThemeChanged_RetargetsWatcher(t *testing.T) {
44+
m, _ := newTestModel(t)
45+
m.statusBar = statusbar.New(m)
46+
fw := &fakeThemeWatcher{}
47+
m.themeWatcher = fw
48+
49+
_, _ = m.applyThemeChanged()
50+
51+
assert.Equal(t, []string{styles.CurrentTheme().Ref}, fw.watched,
52+
"theme changes must re-target the watcher onto the active theme")
53+
}
54+
55+
func TestCleanupManagedResources_StopsThemeWatcher(t *testing.T) {
56+
m, _ := newTestModel(t)
57+
fw := &fakeThemeWatcher{}
58+
m.themeWatcher = fw
59+
60+
m.cleanupManagedResources()
61+
62+
assert.True(t, fw.stopped, "TUI shutdown must stop the theme watcher")
63+
}

pkg/tui/tui.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ type appModel struct {
154154
// perform a full terminal release/restore cycle on focus events.
155155
program *tea.Program
156156

157+
// themeWatcher hot-reloads the active custom theme: it watches the
158+
// current theme's backing file and reports edits as ThemeFileChangedMsg.
159+
// Created in SetProgram (the callback needs the program to inject
160+
// messages into the event loop) and re-targeted after every theme
161+
// change via watchCurrentTheme.
162+
themeWatcher themeFileWatcher
163+
157164
// dockerDesktop is true when running inside Docker Desktop's terminal
158165
// (TERM_PROGRAM=docker_desktop). Focus reporting and the terminal
159166
// release/restore cycle on tab switch are only enabled in this
@@ -243,6 +250,13 @@ type appModel struct {
243250
disabledCommands map[string]bool
244251
}
245252

253+
// themeFileWatcher is the subset of *styles.ThemeWatcher the model drives.
254+
// It is an interface so tests can record Watch/Stop calls.
255+
type themeFileWatcher interface {
256+
Watch(themeRef string) error
257+
Stop()
258+
}
259+
246260
// Transcriber is the speech-to-text interface used by the TUI. It is an
247261
// interface (rather than the concrete *transcribe.Transcriber) so that tests
248262
// can inject a fake implementation via WithTranscriber and so that the TUI
@@ -500,9 +514,35 @@ func (m *appModel) Resolve(v any) any {
500514
}
501515

502516
// SetProgram sets the tea.Program for the supervisor to send routed messages.
517+
// It also starts the theme file watcher, whose callback needs the program to
518+
// inject hot-reload messages into the event loop.
503519
func (m *appModel) SetProgram(p *tea.Program) {
504520
m.program = p
505521
m.supervisor.SetProgram(p)
522+
523+
if m.themeWatcher != nil {
524+
m.themeWatcher.Stop()
525+
}
526+
m.themeWatcher = styles.NewThemeWatcher(func(themeRef string) {
527+
p.Send(messages.ThemeFileChangedMsg{ThemeRef: themeRef})
528+
})
529+
m.watchCurrentTheme()
530+
}
531+
532+
// watchCurrentTheme points the theme watcher at the currently applied theme
533+
// so edits to its backing file hot-reload it. The watcher no-ops for themes
534+
// without a user theme file (e.g. built-ins).
535+
func (m *appModel) watchCurrentTheme() {
536+
if m.themeWatcher == nil {
537+
return
538+
}
539+
theme := styles.CurrentTheme()
540+
if theme.Ref == "" {
541+
return
542+
}
543+
if err := m.themeWatcher.Watch(theme.Ref); err != nil {
544+
slog.Warn("Failed to watch theme file", "theme", theme.Ref, "error", err)
545+
}
506546
}
507547

508548
// reapplyKeyboardEnhancements forwards the cached keyboard enhancements message
@@ -2766,6 +2806,9 @@ func (m *appModel) shutdownTimeoutOrDefault() time.Duration {
27662806
// exit) and the context watcher (external cancellation).
27672807
func (m *appModel) cleanupManagedResources() {
27682808
m.cleanupOnce.Do(func() {
2809+
if m.themeWatcher != nil {
2810+
m.themeWatcher.Stop()
2811+
}
27692812
if m.tuiStore != nil {
27702813
_ = m.tuiStore.Close()
27712814
}

0 commit comments

Comments
 (0)