appearance.go RawHistoryFolder

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

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