v0.30.0 eb3068f

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://history/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    var root = document.documentElement;
76
77    // Keep the global flags in sync for both WebKit and CEF-backed pages.
78    window.__dumber_gtk_prefers_dark = prefersDark;
79    window.__dumber_cef_prefers_dark = prefersDark;
80
81    // Update dark/light class
82    if (prefersDark) {
83      root.classList.add('dark');
84      root.classList.remove('light');
85    } else {
86      root.classList.add('light');
87      root.classList.remove('dark');
88    }
89    root.style.colorScheme = prefersDark ? 'dark' : 'light';
90
91    // Update or insert theme style
92    var style = document.getElementById('dumber-theme-vars') || document.querySelector('style[data-dumber-theme-vars]');
93    if (!style) {
94      style = document.createElement('style');
95      style.id = 'dumber-theme-vars';
96      style.setAttribute('data-dumber-theme-vars', '');
97      (document.head || document.documentElement).appendChild(style);
98    }
99    style.textContent = cssText;
100
101    // Notify any running WebUI that theme changed
102    try {
103      window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
104        detail: { prefersDark: prefersDark }
105      }));
106    } catch (e) {
107      // ignore
108    }
109
110    // Keep color-scheme consistent
111    var meta = document.querySelector('meta[name="color-scheme"]');
112    if (!meta) {
113      meta = document.createElement('meta');
114      meta.name = 'color-scheme';
115      (document.head || document.documentElement).appendChild(meta);
116    }
117    meta.content = prefersDark ? 'dark light' : 'light dark';
118  } catch (e) {
119    console.error('[dumber] failed to apply theme', e);
120  }
121})();`, string(cssJSON), prefersDark)
122
123	return script, nil
124}
125
126func (c *Coordinator) applyWebUITheme(
127	ctx context.Context,
128	paneID entity.PaneID,
129	wv port.WebView,
130	script string,
131	prefersDark bool,
132) {
133	if wv == nil || wv.IsDestroyed() {
134		return
135	}
136	uri := wv.URI()
137	if !strings.HasPrefix(uri, "dumb://") {
138		return
139	}
140	wv.RunJavaScript(ctx, script)
141	logging.FromContext(ctx).
142		Debug().
143		Str("pane_id", string(paneID)).
144		Str("uri", uri).
145		Bool("prefers_dark", prefersDark).
146		Msg("applied WebUI theme")
147}
148
149func (c *Coordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
150	c.appearanceMu.Lock()
151	if c.pendingThemePanes == nil {
152		c.pendingThemePanes = make(map[entity.PaneID]bool)
153	}
154	c.pendingThemePanes[paneID] = true
155	c.pendingThemeUpdate = pendingThemeUpdate{
156		prefersDark: prefersDark,
157		cssText:     cssText,
158	}
159	c.hasPendingThemeUpdate = true
160	c.appearanceMu.Unlock()
161}
162
163func (c *Coordinator) setCurrentTheme(prefersDark bool, cssText string) {
164	c.appearanceMu.Lock()
165	c.currentTheme = pendingThemeUpdate{
166		prefersDark: prefersDark,
167		cssText:     cssText,
168	}
169	c.hasCurrentTheme = true
170	c.appearanceMu.Unlock()
171}
172
173func (c *Coordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
174	c.appearanceMu.Lock()
175	defer c.appearanceMu.Unlock()
176
177	if !c.hasCurrentTheme {
178		return pendingThemeUpdate{}, false
179	}
180	return c.currentTheme, true
181}
182
183func (c *Coordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
184	c.appearanceMu.Lock()
185	defer c.appearanceMu.Unlock()
186
187	if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
188		return pendingThemeUpdate{}, false
189	}
190	delete(c.pendingThemePanes, paneID)
191	update := c.pendingThemeUpdate
192	if len(c.pendingThemePanes) == 0 {
193		c.hasPendingThemeUpdate = false
194	}
195	return update, true
196}
197
198func (c *Coordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
199	update, ok := c.takePendingThemeApply(paneID)
200	if !ok {
201		return false
202	}
203
204	script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
205	if err != nil {
206		logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
207		return false
208	}
209	c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
210	return true
211}
212
213func (c *Coordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
214	update, ok := c.getCurrentTheme()
215	if !ok || update.cssText == "" {
216		return false
217	}
218
219	script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
220	if err != nil {
221		logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
222		return false
223	}
224	c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
225	return true
226}
227
228func (c *Coordinator) queueScriptRefresh(paneID entity.PaneID) {
229	c.appearanceMu.Lock()
230	if c.pendingScriptRefresh == nil {
231		c.pendingScriptRefresh = make(map[entity.PaneID]bool)
232	}
233	c.pendingScriptRefresh[paneID] = true
234	c.appearanceMu.Unlock()
235}
236
237func (c *Coordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
238	c.appearanceMu.Lock()
239	defer c.appearanceMu.Unlock()
240
241	if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
242		return false
243	}
244	delete(c.pendingScriptRefresh, paneID)
245	return true
246}
247
248func (c *Coordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
249	if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
250		return
251	}
252	if !c.takePendingScriptRefresh(paneID) {
253		return
254	}
255	if c.injector == nil {
256		return
257	}
258	c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
259}
260
261func (c *Coordinator) shouldDeferAppearance(wv port.WebView) bool {
262	if wv == nil || wv.IsDestroyed() {
263		return false
264	}
265	if wv.IsLoading() {
266		return true
267	}
268	return wv.EstimatedProgress() < 1.0
269}
270
271func (c *Coordinator) refreshInjectedScripts(
272	ctx context.Context,
273	injector port.ContentInjector,
274	paneID entity.PaneID,
275	wv port.WebView,
276) {
277	if injector == nil || wv == nil || wv.IsDestroyed() {
278		return
279	}
280	if err := injector.RefreshScripts(ctx, wv); err != nil {
281		logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to refresh injected scripts")
282		return
283	}
284	logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
285}
286
287func (c *Coordinator) clearPendingAppearance(paneID entity.PaneID) {
288	c.appearanceMu.Lock()
289	if c.pendingScriptRefresh != nil {
290		delete(c.pendingScriptRefresh, paneID)
291	}
292	if c.pendingThemePanes != nil {
293		delete(c.pendingThemePanes, paneID)
294		if len(c.pendingThemePanes) == 0 {
295			c.hasPendingThemeUpdate = false
296		}
297	}
298	c.appearanceMu.Unlock()
299}

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