v0.31.0 bf0756b

external_theme_reload_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"errors"
6	"testing"
7
8	"github.com/stretchr/testify/mock"
9	"github.com/stretchr/testify/require"
10
11	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12	"github.com/bnema/dumber/internal/application/usecase"
13	"github.com/bnema/dumber/internal/bootstrap"
14	"github.com/bnema/dumber/internal/domain/entity"
15	"github.com/bnema/dumber/internal/infrastructure/config"
16	"github.com/bnema/dumber/internal/shared/syncdispatch"
17	uitheme "github.com/bnema/dumber/internal/ui/theme"
18)
19
20type recordingExternalThemeWatcher struct {
21	starts  int
22	stops   int
23	cfg     entity.ExternalThemeConfig
24	trigger func()
25}
26
27func (w *recordingExternalThemeWatcher) Start(_ context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {
28	w.starts++
29	w.cfg = cfg
30	w.trigger = onChange
31	return nil
32}
33
34func (w *recordingExternalThemeWatcher) Stop() error {
35	w.stops++
36	return nil
37}
38
39type mutableExternalThemeSource struct {
40	cfg   entity.ExternalThemeConfig
41	theme *entity.ExternalTheme
42	err   error
43}
44
45func (s *mutableExternalThemeSource) Configure(cfg entity.ExternalThemeConfig) {
46	s.cfg = cfg
47}
48
49func (s *mutableExternalThemeSource) ExternalThemeIdentity() string {
50	if !s.cfg.Enabled {
51		return ""
52	}
53	return s.cfg.Provider + "|" + s.cfg.Format + "|" + s.cfg.Path
54}
55
56func (s *mutableExternalThemeSource) IsEnabled() bool {
57	return s.cfg.Enabled
58}
59
60func (s *mutableExternalThemeSource) Get(context.Context) (*entity.ExternalTheme, error) {
61	if !s.IsEnabled() {
62		return nil, nil
63	}
64	return s.theme, s.err
65}
66
67func immediateDispatchForExternalThemeTest(label string, fn func()) syncdispatch.SyncDispatchResult {
68	fn()
69	return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
70}
71
72func TestApplyAppearanceConfigSendsCompleteEngineSettingsPayload(t *testing.T) {
73	ctx := context.Background()
74	cfg := config.DefaultConfig()
75	cfg.DefaultUIScale = 1.25
76	cfg.Appearance.SansFont = "Inter"
77	cfg.Appearance.SerifFont = "Literata"
78	cfg.Appearance.MonospaceFont = "Fira Code"
79	cfg.Appearance.DefaultFontSize = 17
80	cfg.Debug.EnableDevTools = true
81	cfg.Logging.CaptureConsole = true
82	cfg.Engine.WebKit.DrawCompositingIndicators = true
83	cfg.Media.HardwareDecodingMode = config.HardwareDecodingForce
84	cfg.Clipboard.AutoCopyOnSelection = true
85	expected := entity.EngineSettingsPayload{
86		DefaultUIScale: cfg.DefaultUIScale,
87		WebContent: entity.EngineWebContentSettingsPayload{
88			SansFont:                  cfg.Appearance.SansFont,
89			SerifFont:                 cfg.Appearance.SerifFont,
90			MonospaceFont:             cfg.Appearance.MonospaceFont,
91			DefaultFontSize:           cfg.Appearance.DefaultFontSize,
92			EnableDevTools:            cfg.Debug.EnableDevTools,
93			CaptureConsole:            cfg.Logging.CaptureConsole,
94			DrawCompositingIndicators: cfg.Engine.WebKit.DrawCompositingIndicators,
95			HardwareDecoding:          entity.EngineHardwareDecodingForce,
96			AutoCopyOnSelection:       cfg.Clipboard.AutoCopyOnSelection,
97		},
98	}
99	engine := portmocks.NewMockEngine(t)
100	var captured entity.EngineSettingsUpdate
101	engine.EXPECT().UpdateSettings(mock.Anything, mock.Anything).
102		Run(func(_ context.Context, update entity.EngineSettingsUpdate) {
103			captured = update
104		}).
105		Return(nil).
106		Once()
107	app := &App{
108		deps:          &Dependencies{},
109		runtimeConfig: runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
110		engine:        engine,
111	}
112
113	app.applyAppearanceConfig(ctx)
114
115	require.Equal(t, expected, captured.Settings)
116}
117
118func TestExternalThemeWatcherCallbackAppliesThemeThroughSharedPath(t *testing.T) {
119	ctx := context.Background()
120	cfg := config.DefaultConfig()
121	cfg.Appearance.ColorScheme = "prefer-dark"
122	cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
123		Enabled:  true,
124		Provider: "noctalia",
125		Format:   "dumber-json",
126		Path:     "/tmp/theme.json",
127	}
128	source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
129	watcher := &recordingExternalThemeWatcher{}
130	manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
131	engine := portmocks.NewMockEngine(t)
132	var backgroundUpdated bool
133	engine.EXPECT().UpdateSettings(mock.Anything, mock.Anything).Return(nil).Once()
134	engine.EXPECT().UpdateAppearance(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
135		Run(func(_ context.Context, r, g, b, alpha float64) {
136			backgroundUpdated = true
137			require.InDelta(t, 0.0, r, 0.000001)
138			require.InDelta(t, 0.0, g, 0.000001)
139			require.InDelta(t, 0.0, b, 0.000001)
140			require.InDelta(t, 1.0, alpha, 0.000001)
141		}).
142		Return(nil).
143		Once()
144	engine.EXPECT().ContentInjector().Return(nil).Once()
145	app := &App{
146		deps: &Dependencies{
147			Theme:                manager,
148			ResolveThemeUC:       usecase.NewResolveThemeUseCase(source),
149			ExternalThemeSource:  source,
150			ExternalThemeWatcher: watcher,
151		},
152		runtimeConfig:        runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
153		dispatchOnMainThread: immediateDispatchForExternalThemeTest,
154		engine:               engine,
155	}
156
157	app.syncExternalThemeWatcher(ctx)
158	require.Equal(t, 1, watcher.starts)
159	require.Equal(t, cfg.Appearance.ExternalTheme, watcher.cfg)
160	require.NotNil(t, watcher.trigger)
161
162	watcher.trigger()
163
164	require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
165	require.True(t, backgroundUpdated, "theme reload should refresh engine background color")
166}
167
168func TestExternalThemeReloadKeepsLastGoodAndDisablingClearsIt(t *testing.T) {
169	ctx := context.Background()
170	cfg := config.DefaultConfig()
171	cfg.Appearance.ColorScheme = "prefer-dark"
172	cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
173		Enabled:  true,
174		Provider: "noctalia",
175		Format:   "dumber-json",
176		Path:     "/tmp/theme.json",
177	}
178	source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
179	manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
180	app := &App{
181		deps: &Dependencies{
182			Theme:               manager,
183			ResolveThemeUC:      usecase.NewResolveThemeUseCase(source),
184			ExternalThemeSource: source,
185		},
186		runtimeConfig:        runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
187		dispatchOnMainThread: immediateDispatchForExternalThemeTest,
188	}
189
190	app.applyAppearanceConfig(ctx)
191	require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
192
193	source.theme = &entity.ExternalTheme{
194		Name:         "broken",
195		Provider:     "noctalia",
196		LightPalette: &entity.ColorPalette{Accent: "not-a-hex"},
197		DarkPalette:  &entity.ColorPalette{Background: "also-bad"},
198	}
199	app.applyAppearanceConfig(ctx)
200	require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
201
202	cfg.Appearance.ExternalTheme.Enabled = false
203	setRuntimeConfigSnapshotForTest(app, bootstrap.RuntimeConfigSnapshotFromConfig(cfg))
204	app.applyAppearanceConfig(ctx)
205	require.Equal(t, cfg.Appearance.DarkPalette.Background, app.deps.Theme.GetCurrentPalette().Background)
206}
207
208func TestExternalThemeReloadKeepsLastGoodOnReadError(t *testing.T) {
209	ctx := context.Background()
210	cfg := config.DefaultConfig()
211	cfg.Appearance.ColorScheme = "prefer-dark"
212	cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
213		Enabled:  true,
214		Provider: "noctalia",
215		Format:   "dumber-json",
216		Path:     "/tmp/theme.json",
217	}
218	source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
219	manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
220	app := &App{
221		deps: &Dependencies{
222			Theme:               manager,
223			ResolveThemeUC:      usecase.NewResolveThemeUseCase(source),
224			ExternalThemeSource: source,
225		},
226		runtimeConfig:        runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
227		dispatchOnMainThread: immediateDispatchForExternalThemeTest,
228	}
229
230	app.applyAppearanceConfig(ctx)
231	require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
232
233	source.theme = nil
234	source.err = errors.New("temporary read failure")
235	app.applyAppearanceConfig(ctx)
236	require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
237}
238
239func TestExternalThemeReloadRecoversAfterMalformedUpdate(t *testing.T) {
240	ctx := context.Background()
241	cfg := config.DefaultConfig()
242	cfg.Appearance.ColorScheme = "prefer-dark"
243	cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
244		Enabled:  true,
245		Provider: "noctalia",
246		Format:   "dumber-json",
247		Path:     "/tmp/theme.json",
248	}
249	source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
250	manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
251	app := &App{
252		deps: &Dependencies{
253			Theme:               manager,
254			ResolveThemeUC:      usecase.NewResolveThemeUseCase(source),
255			ExternalThemeSource: source,
256		},
257		runtimeConfig:        runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
258		dispatchOnMainThread: immediateDispatchForExternalThemeTest,
259	}
260
261	app.applyAppearanceConfig(ctx)
262	require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
263
264	source.theme = &entity.ExternalTheme{
265		Name:         "broken",
266		Provider:     "noctalia",
267		LightPalette: &entity.ColorPalette{Accent: "not-a-hex"},
268		DarkPalette:  &entity.ColorPalette{Background: "also-bad"},
269	}
270	app.applyAppearanceConfig(ctx)
271	require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
272
273	source.theme = externalThemeWithDarkBackground("#123456")
274	app.applyAppearanceConfig(ctx)
275	require.Equal(t, "#123456", app.deps.Theme.GetCurrentPalette().Background)
276}
277
278func TestOnShutdownStopsExternalThemeWatcher(t *testing.T) {
279	ctx, cancel := context.WithCancelCause(context.Background())
280	watcher := &recordingExternalThemeWatcher{}
281	app := &App{
282		deps:   &Dependencies{ExternalThemeWatcher: watcher},
283		cancel: cancel,
284	}
285
286	app.onShutdown(ctx)
287
288	require.Equal(t, 1, watcher.stops)
289}
290
291func resolvedThemeForUITest(light, dark entity.ColorPalette) entity.ResolvedTheme {
292	return entity.ResolvedTheme{
293		LightPalette:      light,
294		DarkPalette:       dark,
295		ActivePalette:     dark,
296		PrefersDark:       true,
297		ColorSchemeSource: "test",
298		ThemeSource:       entity.ThemeSourceMetadata{Kind: entity.ThemeSourceConfig},
299		Fonts:             entity.DefaultThemeFonts(),
300		UIScale:           1,
301		ModeColors:        entity.DefaultThemeModeColors(),
302	}
303}
304
305func externalThemeWithDarkBackground(background string) *entity.ExternalTheme {
306	return &entity.ExternalTheme{
307		Name:     "Noctalia",
308		Provider: "noctalia",
309		LightPalette: &entity.ColorPalette{
310			Background: "#eeeeee",
311			Accent:     "#112233",
312		},
313		DarkPalette: &entity.ColorPalette{
314			Background: background,
315			Accent:     "#445566",
316		},
317	}
318}

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 8

Tags 42

↑↓ moveenter openesc close