v0.28.0 3cb3770

appearance.go RawHistoryFolder

1package content
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"strings"
8	"time"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/logging"
13)
14
15const (
16	aboutBlankURI              = "about:blank"
17	crashPageURI               = "dumb://home/crash"
18	logURLMaxLen               = 80
19	oauthParentRefreshDebounce = 200 * time.Millisecond
20
21	// Dark theme background color (#0a0a0b) as float32 RGBA values
22	darkBgR = 0.039
23	darkBgG = 0.039
24	darkBgB = 0.043
25	darkBgA = 1.0
26)
27
28// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
29// This is necessary because user scripts only run on navigation.
30func (c *Coordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
31	log := logging.FromContext(ctx)
32	if cssText == "" {
33		return
34	}
35
36	c.setCurrentTheme(prefersDark, cssText)
37
38	script, err := buildWebUIThemeScript(prefersDark, cssText)
39	if err != nil {
40		log.Warn().Err(err).Msg("failed to build WebUI theme script")
41		return
42	}
43
44	// Snapshot webViews under lock to avoid data race with concurrent popup create/close.
45	c.webViewsMu.RLock()
46	snapshot := make(map[entity.PaneID]port.WebView, len(c.webViews))
47	for k, v := range c.webViews {
48		snapshot[k] = v
49	}
50	c.webViewsMu.RUnlock()
51
52	for paneID, wv := range snapshot {
53		if wv == nil || wv.IsDestroyed() {
54			continue
55		}
56		if c.shouldDeferAppearance(wv) {
57			c.queueThemeApply(paneID, prefersDark, cssText)
58			log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
59			continue
60		}
61		c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
62	}
63}
64
65func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
66	cssJSON, err := json.Marshal(cssText)
67	if err != nil {
68		return "", err
69	}
70
71	script := fmt.Sprintf(`(function(){
72  try {
73    var cssText = %s;
74    var prefersDark = %t;
75
76    // Keep the global flag in sync
77    window.__dumber_gtk_prefers_dark = prefersDark;
78
79    // Update dark/light class
80    if (prefersDark) {
81      document.documentElement.classList.add('dark');
82      document.documentElement.classList.remove('light');
83    } else {
84      document.documentElement.classList.add('light');
85      document.documentElement.classList.remove('dark');
86    }
87
88    // Update or insert theme style
89    var style = document.querySelector('style[data-dumber-theme-vars]');
90    if (!style) {
91      style = document.createElement('style');
92      style.setAttribute('data-dumber-theme-vars', '');
93      (document.head || document.documentElement).appendChild(style);
94    }
95    style.textContent = cssText;
96
97    // Notify any running WebUI that theme changed
98    try {
99      window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
100        detail: { prefersDark: prefersDark }
101      }));
102    } catch (e) {
103      // ignore
104    }
105
106    // Keep color-scheme consistent
107    var meta = document.querySelector('meta[name="color-scheme"]');
108    if (!meta) {
109      meta = document.createElement('meta');
110      meta.name = 'color-scheme';
111      (document.head || document.documentElement).appendChild(meta);
112    }
113    meta.content = prefersDark ? 'dark light' : 'light dark';
114  } catch (e) {
115    console.error('[dumber] failed to apply theme', e);
116  }
117})();`, string(cssJSON), prefersDark)
118
119	return script, nil
120}
121
122func (c *Coordinator) applyWebUITheme(
123	ctx context.Context,
124	paneID entity.PaneID,
125	wv port.WebView,
126	script string,
127	prefersDark bool,
128) {
129	if wv == nil || wv.IsDestroyed() {
130		return
131	}
132	uri := wv.URI()
133	if !strings.HasPrefix(uri, "dumb://") {
134		return
135	}
136	wv.RunJavaScript(ctx, script)
137	logging.FromContext(ctx).
138		Debug().
139		Str("pane_id", string(paneID)).
140		Str("uri", uri).
141		Bool("prefers_dark", prefersDark).
142		Msg("applied WebUI theme")
143}
144
145func (c *Coordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
146	c.appearanceMu.Lock()
147	if c.pendingThemePanes == nil {
148		c.pendingThemePanes = make(map[entity.PaneID]bool)
149	}
150	c.pendingThemePanes[paneID] = true
151	c.pendingThemeUpdate = pendingThemeUpdate{
152		prefersDark: prefersDark,
153		cssText:     cssText,
154	}
155	c.hasPendingThemeUpdate = true
156	c.appearanceMu.Unlock()
157}
158
159func (c *Coordinator) setCurrentTheme(prefersDark bool, cssText string) {
160	c.appearanceMu.Lock()
161	c.currentTheme = pendingThemeUpdate{
162		prefersDark: prefersDark,
163		cssText:     cssText,
164	}
165	c.hasCurrentTheme = true
166	c.appearanceMu.Unlock()
167}
168
169func (c *Coordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
170	c.appearanceMu.Lock()
171	defer c.appearanceMu.Unlock()
172
173	if !c.hasCurrentTheme {
174		return pendingThemeUpdate{}, false
175	}
176	return c.currentTheme, true
177}
178
179func (c *Coordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
180	c.appearanceMu.Lock()
181	defer c.appearanceMu.Unlock()
182
183	if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
184		return pendingThemeUpdate{}, false
185	}
186	delete(c.pendingThemePanes, paneID)
187	update := c.pendingThemeUpdate
188	if len(c.pendingThemePanes) == 0 {
189		c.hasPendingThemeUpdate = false
190	}
191	return update, true
192}
193
194func (c *Coordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
195	update, ok := c.takePendingThemeApply(paneID)
196	if !ok {
197		return false
198	}
199
200	script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
201	if err != nil {
202		logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
203		return false
204	}
205	c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
206	return true
207}
208
209func (c *Coordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
210	update, ok := c.getCurrentTheme()
211	if !ok || update.cssText == "" {
212		return false
213	}
214
215	script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
216	if err != nil {
217		logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
218		return false
219	}
220	c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
221	return true
222}
223
224func (c *Coordinator) queueScriptRefresh(paneID entity.PaneID) {
225	c.appearanceMu.Lock()
226	if c.pendingScriptRefresh == nil {
227		c.pendingScriptRefresh = make(map[entity.PaneID]bool)
228	}
229	c.pendingScriptRefresh[paneID] = true
230	c.appearanceMu.Unlock()
231}
232
233func (c *Coordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
234	c.appearanceMu.Lock()
235	defer c.appearanceMu.Unlock()
236
237	if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
238		return false
239	}
240	delete(c.pendingScriptRefresh, paneID)
241	return true
242}
243
244func (c *Coordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
245	if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
246		return
247	}
248	if !c.takePendingScriptRefresh(paneID) {
249		return
250	}
251	if c.injector == nil {
252		return
253	}
254	c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
255}
256
257func (c *Coordinator) shouldDeferAppearance(wv port.WebView) bool {
258	if wv == nil || wv.IsDestroyed() {
259		return false
260	}
261	if wv.IsLoading() {
262		return true
263	}
264	return wv.EstimatedProgress() < 1.0
265}
266
267func (c *Coordinator) refreshInjectedScripts(
268	ctx context.Context,
269	injector port.ContentInjector,
270	paneID entity.PaneID,
271	wv port.WebView,
272) {
273	if injector == nil || wv == nil || wv.IsDestroyed() {
274		return
275	}
276	if err := injector.RefreshScripts(ctx, wv); err != nil {
277		logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to refresh injected scripts")
278		return
279	}
280	logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
281}
282
283func (c *Coordinator) clearPendingAppearance(paneID entity.PaneID) {
284	c.appearanceMu.Lock()
285	if c.pendingScriptRefresh != nil {
286		delete(c.pendingScriptRefresh, paneID)
287	}
288	if c.pendingThemePanes != nil {
289		delete(c.pendingThemePanes, paneID)
290		if len(c.pendingThemePanes) == 0 {
291			c.hasPendingThemeUpdate = false
292		}
293	}
294	c.appearanceMu.Unlock()
295}

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