detached 77255a2

Fix webview theme and ID sync

commit
77255a235a4d5b2592f144fb4ab3ea8402959544
author
bnema · · 2025-12-30 03:52 UTC
parent
df74c4c
signature
GPG Unverified2B82207D370127A0 The key is unknown
11 files · +468 -47
M internal/infrastructure/webkit/injector.go +12 -19
@@ -26,7 +26,7 @@ const themeCSSScript = `(function() {
2626 (document.head || document.documentElement).appendChild(style);
2727 })();`
2828
29-// internalDarkModeScript is injected ONLY on internal pages (dumb://*).
29+// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
3030 // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
3131 // for JS-based dark mode detection in WebUI components.
3232 //
@@ -38,8 +38,9 @@ const themeCSSScript = `(function() {
3838 // - (prefers-color-scheme: dark)
3939 // - (prefers-color-scheme:dark) -- no space
4040 // - screen and (prefers-color-scheme: dark)
41-const internalDarkModeScript = `(function() {
42- var prefersDark = window.__dumber_gtk_prefers_dark || false;
41+const internalDarkModeScriptTemplate = `(function() {
42+ var prefersDark = %t;
43+ window.__dumber_gtk_prefers_dark = prefersDark;
4344 var originalMatchMedia = window.matchMedia.bind(window);
4445
4546 // Apply dark/light class to document element for Tailwind CSS theming
@@ -180,21 +181,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
180181 log.Debug().Str("script", label).Msg("injected user script")
181182 }
182183
183- // 1. Inject GTK dark mode preference for internal pages only
184184 prefersDark := ci.PrefersDark()
185- darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", prefersDark)
186- addScript(
187- webkit.NewUserScript(
188- darkModePrefScript,
189- webkit.UserContentInjectTopFrameValue,
190- webkit.UserScriptInjectAtDocumentStartValue,
191- internalPageAllowList,
192- nil,
193- ),
194- "gtk-dark-mode-pref",
195- )
196185
197- // 2. Inject WebView ID for debugging (internal pages only)
186+ // 1. Inject WebView ID for debugging (internal pages only)
198187 if webviewID != 0 {
199188 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
200189 addScript(
@@ -207,10 +196,14 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
207196 ),
208197 "webview-id",
209198 )
199+ log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
200+ } else {
201+ log.Warn().Msg("webview ID is 0, skipping ID injection")
210202 }
211203
212- // 3. Inject dark mode handler for internal pages only
204+ // 2. Inject dark mode handler for internal pages only
213205 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
206+ internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
214207 addScript(
215208 webkit.NewUserScript(
216209 internalDarkModeScript,
@@ -222,7 +215,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
222215 "internal-dark-mode-handler",
223216 )
224217
225- // 4. Inject theme CSS for internal pages (dumb://* only)
218+ // 3. Inject theme CSS for internal pages (dumb://* only)
226219 if ci.themeCSSVars != "" {
227220 // Escape for JS string literal
228221 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -242,7 +235,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
242235 log.Debug().Msg("theme CSS vars injection configured for internal pages")
243236 }
244237
245- // 5. Inject find highlight CSS for all pages
238+ // 4. Inject find highlight CSS for all pages
246239 if ci.findCSS != "" {
247240 stylesheet := webkit.NewUserStyleSheet(
248241 ci.findCSS,
@@ -26,7 +26,7 @@ const themeCSSScript = `(function() {
26 (document.head || document.documentElement).appendChild(style);26 (document.head || document.documentElement).appendChild(style);
27 })();`27 })();`
28 28
29-// internalDarkModeScript is injected ONLY on internal pages (dumb://*).29+// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
30 // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia30 // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
31 // for JS-based dark mode detection in WebUI components.31 // for JS-based dark mode detection in WebUI components.
32 //32 //
@@ -38,8 +38,9 @@ const themeCSSScript = `(function() {
38 // - (prefers-color-scheme: dark)38 // - (prefers-color-scheme: dark)
39 // - (prefers-color-scheme:dark) -- no space39 // - (prefers-color-scheme:dark) -- no space
40 // - screen and (prefers-color-scheme: dark)40 // - screen and (prefers-color-scheme: dark)
41-const internalDarkModeScript = `(function() {41+const internalDarkModeScriptTemplate = `(function() {
42- var prefersDark = window.__dumber_gtk_prefers_dark || false;42+ var prefersDark = %t;
43+ window.__dumber_gtk_prefers_dark = prefersDark;
43 var originalMatchMedia = window.matchMedia.bind(window);44 var originalMatchMedia = window.matchMedia.bind(window);
44 45
45 // Apply dark/light class to document element for Tailwind CSS theming46 // Apply dark/light class to document element for Tailwind CSS theming
@@ -180,21 +181,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
180 log.Debug().Str("script", label).Msg("injected user script")181 log.Debug().Str("script", label).Msg("injected user script")
181 }182 }
182 183
183- // 1. Inject GTK dark mode preference for internal pages only
184 prefersDark := ci.PrefersDark()184 prefersDark := ci.PrefersDark()
185- darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", prefersDark)
186- addScript(
187- webkit.NewUserScript(
188- darkModePrefScript,
189- webkit.UserContentInjectTopFrameValue,
190- webkit.UserScriptInjectAtDocumentStartValue,
191- internalPageAllowList,
192- nil,
193- ),
194- "gtk-dark-mode-pref",
195- )
196 185
197- // 2. Inject WebView ID for debugging (internal pages only)186+ // 1. Inject WebView ID for debugging (internal pages only)
198 if webviewID != 0 {187 if webviewID != 0 {
199 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))188 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
200 addScript(189 addScript(
@@ -207,10 +196,14 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
207 ),196 ),
208 "webview-id",197 "webview-id",
209 )198 )
199+ log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
200+ } else {
201+ log.Warn().Msg("webview ID is 0, skipping ID injection")
210 }202 }
211 203
212- // 3. Inject dark mode handler for internal pages only204+ // 2. Inject dark mode handler for internal pages only
213 // This sets .dark/.light class on <html> and patches matchMedia for WebUI205 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
206+ internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
214 addScript(207 addScript(
215 webkit.NewUserScript(208 webkit.NewUserScript(
216 internalDarkModeScript,209 internalDarkModeScript,
@@ -222,7 +215,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
222 "internal-dark-mode-handler",215 "internal-dark-mode-handler",
223 )216 )
224 217
225- // 4. Inject theme CSS for internal pages (dumb://* only)218+ // 3. Inject theme CSS for internal pages (dumb://* only)
226 if ci.themeCSSVars != "" {219 if ci.themeCSSVars != "" {
227 // Escape for JS string literal220 // Escape for JS string literal
228 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")221 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -242,7 +235,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
242 log.Debug().Msg("theme CSS vars injection configured for internal pages")235 log.Debug().Msg("theme CSS vars injection configured for internal pages")
243 }236 }
244 237
245- // 5. Inject find highlight CSS for all pages238+ // 4. Inject find highlight CSS for all pages
246 if ci.findCSS != "" {239 if ci.findCSS != "" {
247 stylesheet := webkit.NewUserStyleSheet(240 stylesheet := webkit.NewUserStyleSheet(
248 ci.findCSS,241 ci.findCSS,
M internal/infrastructure/webkit/messaging.go +42 -3
@@ -48,6 +48,9 @@ type MessageRouter struct {
4848 mu sync.RWMutex
4949 callbacks []interface{}
5050 signals []uint32
51+
52+ idMu sync.Mutex
53+ syncedIDs map[WebViewID]bool
5154 }
5255
5356 // NewMessageRouter creates a new message router.
@@ -59,6 +62,7 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
5962 return &MessageRouter{
6063 handlers: make(map[string]handlerEntry),
6164 baseCtx: ctx,
65+ syncedIDs: make(map[WebViewID]bool),
6266 }
6367 }
6468
@@ -131,9 +135,9 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
131135
132136 // Connect to signal BEFORE registering handler to avoid race conditions
133137 // (as recommended by WebKit documentation)
134- cb := func(_ webkit.UserContentManager, valuePtr uintptr) {
138+ cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
135139 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
136- r.handleScriptMessage(valuePtr)
140+ r.handleScriptMessage(ucm, valuePtr)
137141 }
138142
139143 r.mu.Lock()
@@ -167,7 +171,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
167171 }
168172
169173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.
170-func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
174+func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
171175 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
172176
173177 if valuePtr == 0 {
@@ -195,6 +199,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
195199 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
196200 msg.WebViewID = msg.WebViewIDAlt
197201 }
202+ if msg.WebViewID == 0 {
203+ if senderWV := lookupSenderWebView(senderUCM); senderWV != nil {
204+ msg.WebViewID = uint64(senderWV.ID())
205+ r.syncWebViewID(senderWV)
206+ }
207+ }
198208
199209 if msg.Type == "" {
200210 log.Warn().Msg("script message missing type")
@@ -234,6 +244,35 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
234244 }
235245 }
236246
247+func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
248+ ptr := senderUCM.GoPointer()
249+ if ptr == 0 {
250+ return nil
251+ }
252+ return LookupWebViewByUCMPointer(ptr)
253+}
254+
255+func (r *MessageRouter) syncWebViewID(wv *WebView) {
256+ if wv == nil {
257+ return
258+ }
259+ if !r.markWebViewIDSynced(wv.ID()) {
260+ return
261+ }
262+ script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
263+ wv.RunJavaScript(r.baseCtx, script, "")
264+}
265+
266+func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
267+ r.idMu.Lock()
268+ defer r.idMu.Unlock()
269+ if r.syncedIDs[id] {
270+ return false
271+ }
272+ r.syncedIDs[id] = true
273+ return true
274+}
275+
237276 // getHandler retrieves a handler by message type.
238277 func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
239278 r.mu.RLock()
@@ -48,6 +48,9 @@ type MessageRouter struct {
48 mu sync.RWMutex48 mu sync.RWMutex
49 callbacks []interface{}49 callbacks []interface{}
50 signals []uint3250 signals []uint32
51+
52+ idMu sync.Mutex
53+ syncedIDs map[WebViewID]bool
51 }54 }
52 55
53 // NewMessageRouter creates a new message router.56 // NewMessageRouter creates a new message router.
@@ -59,6 +62,7 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
59 return &MessageRouter{62 return &MessageRouter{
60 handlers: make(map[string]handlerEntry),63 handlers: make(map[string]handlerEntry),
61 baseCtx: ctx,64 baseCtx: ctx,
65+ syncedIDs: make(map[WebViewID]bool),
62 }66 }
63 }67 }
64 68
@@ -131,9 +135,9 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
131 135
132 // Connect to signal BEFORE registering handler to avoid race conditions136 // Connect to signal BEFORE registering handler to avoid race conditions
133 // (as recommended by WebKit documentation)137 // (as recommended by WebKit documentation)
134- cb := func(_ webkit.UserContentManager, valuePtr uintptr) {138+ cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
135 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")139 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
136- r.handleScriptMessage(valuePtr)140+ r.handleScriptMessage(ucm, valuePtr)
137 }141 }
138 142
139 r.mu.Lock()143 r.mu.Lock()
@@ -167,7 +171,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
167 }171 }
168 172
169 // handleScriptMessage decodes the JSC value and routes it to the correct handler.173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.
170-func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {174+func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
171 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()175 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
172 176
173 if valuePtr == 0 {177 if valuePtr == 0 {
@@ -195,6 +199,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
195 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {199 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
196 msg.WebViewID = msg.WebViewIDAlt200 msg.WebViewID = msg.WebViewIDAlt
197 }201 }
202+ if msg.WebViewID == 0 {
203+ if senderWV := lookupSenderWebView(senderUCM); senderWV != nil {
204+ msg.WebViewID = uint64(senderWV.ID())
205+ r.syncWebViewID(senderWV)
206+ }
207+ }
198 208
199 if msg.Type == "" {209 if msg.Type == "" {
200 log.Warn().Msg("script message missing type")210 log.Warn().Msg("script message missing type")
@@ -234,6 +244,35 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
234 }244 }
235 }245 }
236 246
247+func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
248+ ptr := senderUCM.GoPointer()
249+ if ptr == 0 {
250+ return nil
251+ }
252+ return LookupWebViewByUCMPointer(ptr)
253+}
254+
255+func (r *MessageRouter) syncWebViewID(wv *WebView) {
256+ if wv == nil {
257+ return
258+ }
259+ if !r.markWebViewIDSynced(wv.ID()) {
260+ return
261+ }
262+ script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
263+ wv.RunJavaScript(r.baseCtx, script, "")
264+}
265+
266+func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
267+ r.idMu.Lock()
268+ defer r.idMu.Unlock()
269+ if r.syncedIDs[id] {
270+ return false
271+ }
272+ r.syncedIDs[id] = true
273+ return true
274+}
275+
237 // getHandler retrieves a handler by message type.276 // getHandler retrieves a handler by message type.
238 func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {277 func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
239 r.mu.RLock()278 r.mu.RLock()
M internal/infrastructure/webkit/pool.go +51 -0
@@ -353,6 +353,57 @@ func (p *WebViewPool) Size() int {
353353 return len(p.pool)
354354 }
355355
356+// RefreshScripts clears and re-injects scripts to all pooled WebViews.
357+// This is needed after adw.Init() to update dark mode preference that was
358+// injected with the wrong value during early prewarming.
359+func (p *WebViewPool) RefreshScripts(ctx context.Context) {
360+ if p.closed.Load() || p.injector == nil {
361+ return
362+ }
363+
364+ log := logging.FromContext(ctx)
365+
366+ // Drain and refresh each WebView, then put it back
367+ count := len(p.pool)
368+ if count == 0 {
369+ return
370+ }
371+
372+ refreshed := 0
373+refreshLoop:
374+ for i := 0; i < count; i++ {
375+ select {
376+ case wv := <-p.pool:
377+ if wv == nil || wv.IsDestroyed() {
378+ continue
379+ }
380+
381+ // Clear existing scripts and re-inject with updated values
382+ if wv.ucm != nil {
383+ wv.ucm.RemoveAllScripts()
384+ wv.ucm.RemoveAllStyleSheets()
385+ p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
386+ }
387+
388+ // Put back in pool
389+ select {
390+ case p.pool <- wv:
391+ refreshed++
392+ default:
393+ wv.Destroy()
394+ }
395+ default:
396+ // Pool was modified during iteration, stop
397+ break refreshLoop
398+ }
399+ }
400+
401+ log.Debug().
402+ Int("refreshed", refreshed).
403+ Bool("prefers_dark", p.injector.PrefersDark()).
404+ Msg("refreshed scripts in pooled webviews")
405+}
406+
356407 // Close shuts down the pool and destroys all pooled WebViews.
357408 func (p *WebViewPool) Close(ctx context.Context) {
358409 if p.closed.Swap(true) {
@@ -353,6 +353,57 @@ func (p *WebViewPool) Size() int {
353 return len(p.pool)353 return len(p.pool)
354 }354 }
355 355
356+// RefreshScripts clears and re-injects scripts to all pooled WebViews.
357+// This is needed after adw.Init() to update dark mode preference that was
358+// injected with the wrong value during early prewarming.
359+func (p *WebViewPool) RefreshScripts(ctx context.Context) {
360+ if p.closed.Load() || p.injector == nil {
361+ return
362+ }
363+
364+ log := logging.FromContext(ctx)
365+
366+ // Drain and refresh each WebView, then put it back
367+ count := len(p.pool)
368+ if count == 0 {
369+ return
370+ }
371+
372+ refreshed := 0
373+refreshLoop:
374+ for i := 0; i < count; i++ {
375+ select {
376+ case wv := <-p.pool:
377+ if wv == nil || wv.IsDestroyed() {
378+ continue
379+ }
380+
381+ // Clear existing scripts and re-inject with updated values
382+ if wv.ucm != nil {
383+ wv.ucm.RemoveAllScripts()
384+ wv.ucm.RemoveAllStyleSheets()
385+ p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
386+ }
387+
388+ // Put back in pool
389+ select {
390+ case p.pool <- wv:
391+ refreshed++
392+ default:
393+ wv.Destroy()
394+ }
395+ default:
396+ // Pool was modified during iteration, stop
397+ break refreshLoop
398+ }
399+ }
400+
401+ log.Debug().
402+ Int("refreshed", refreshed).
403+ Bool("prefers_dark", p.injector.PrefersDark()).
404+ Msg("refreshed scripts in pooled webviews")
405+}
406+
356 // Close shuts down the pool and destroys all pooled WebViews.407 // Close shuts down the pool and destroys all pooled WebViews.
357 func (p *WebViewPool) Close(ctx context.Context) {408 func (p *WebViewPool) Close(ctx context.Context) {
358 if p.closed.Swap(true) {409 if p.closed.Swap(true) {
M internal/infrastructure/webkit/webview.go +27 -0
@@ -46,25 +46,34 @@ type PopupRequest struct {
4646 // webViewRegistry tracks all active WebViews.
4747 type webViewRegistry struct {
4848 views map[WebViewID]*WebView
49+ byUCM map[uintptr]WebViewID
4950 counter atomic.Uint64
5051 mu sync.RWMutex
5152 }
5253
5354 var globalRegistry = &webViewRegistry{
5455 views: make(map[WebViewID]*WebView),
56+ byUCM: make(map[uintptr]WebViewID),
5557 }
5658
5759 func (r *webViewRegistry) register(wv *WebView) WebViewID {
5860 id := WebViewID(r.counter.Add(1))
5961 r.mu.Lock()
6062 r.views[id] = wv
63+ if wv != nil && wv.ucm != nil {
64+ r.byUCM[wv.ucm.GoPointer()] = id
65+ }
6166 r.mu.Unlock()
6267 return id
6368 }
6469
6570 func (r *webViewRegistry) unregister(id WebViewID) {
6671 r.mu.Lock()
72+ wv := r.views[id]
6773 delete(r.views, id)
74+ if wv != nil && wv.ucm != nil {
75+ delete(r.byUCM, wv.ucm.GoPointer())
76+ }
6877 r.mu.Unlock()
6978 }
7079
@@ -75,11 +84,29 @@ func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
7584 return r.views[id]
7685 }
7786
87+func (r *webViewRegistry) LookupByUCMPointer(ptr uintptr) *WebView {
88+ if ptr == 0 {
89+ return nil
90+ }
91+ r.mu.RLock()
92+ defer r.mu.RUnlock()
93+ id, ok := r.byUCM[ptr]
94+ if !ok {
95+ return nil
96+ }
97+ return r.views[id]
98+}
99+
78100 // LookupWebView returns a WebView by ID from the global registry.
79101 func LookupWebView(id WebViewID) *WebView {
80102 return globalRegistry.Lookup(id)
81103 }
82104
105+// LookupWebViewByUCMPointer returns a WebView by its UserContentManager pointer.
106+func LookupWebViewByUCMPointer(ptr uintptr) *WebView {
107+ return globalRegistry.LookupByUCMPointer(ptr)
108+}
109+
83110 // WebView wraps webkit.WebView with Go-level state tracking and callbacks.
84111 type WebView struct {
85112 id WebViewID
@@ -46,25 +46,34 @@ type PopupRequest struct {
46 // webViewRegistry tracks all active WebViews.46 // webViewRegistry tracks all active WebViews.
47 type webViewRegistry struct {47 type webViewRegistry struct {
48 views map[WebViewID]*WebView48 views map[WebViewID]*WebView
49+ byUCM map[uintptr]WebViewID
49 counter atomic.Uint6450 counter atomic.Uint64
50 mu sync.RWMutex51 mu sync.RWMutex
51 }52 }
52 53
53 var globalRegistry = &webViewRegistry{54 var globalRegistry = &webViewRegistry{
54 views: make(map[WebViewID]*WebView),55 views: make(map[WebViewID]*WebView),
56+ byUCM: make(map[uintptr]WebViewID),
55 }57 }
56 58
57 func (r *webViewRegistry) register(wv *WebView) WebViewID {59 func (r *webViewRegistry) register(wv *WebView) WebViewID {
58 id := WebViewID(r.counter.Add(1))60 id := WebViewID(r.counter.Add(1))
59 r.mu.Lock()61 r.mu.Lock()
60 r.views[id] = wv62 r.views[id] = wv
63+ if wv != nil && wv.ucm != nil {
64+ r.byUCM[wv.ucm.GoPointer()] = id
65+ }
61 r.mu.Unlock()66 r.mu.Unlock()
62 return id67 return id
63 }68 }
64 69
65 func (r *webViewRegistry) unregister(id WebViewID) {70 func (r *webViewRegistry) unregister(id WebViewID) {
66 r.mu.Lock()71 r.mu.Lock()
72+ wv := r.views[id]
67 delete(r.views, id)73 delete(r.views, id)
74+ if wv != nil && wv.ucm != nil {
75+ delete(r.byUCM, wv.ucm.GoPointer())
76+ }
68 r.mu.Unlock()77 r.mu.Unlock()
69 }78 }
70 79
@@ -75,11 +84,29 @@ func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
75 return r.views[id]84 return r.views[id]
76 }85 }
77 86
87+func (r *webViewRegistry) LookupByUCMPointer(ptr uintptr) *WebView {
88+ if ptr == 0 {
89+ return nil
90+ }
91+ r.mu.RLock()
92+ defer r.mu.RUnlock()
93+ id, ok := r.byUCM[ptr]
94+ if !ok {
95+ return nil
96+ }
97+ return r.views[id]
98+}
99+
78 // LookupWebView returns a WebView by ID from the global registry.100 // LookupWebView returns a WebView by ID from the global registry.
79 func LookupWebView(id WebViewID) *WebView {101 func LookupWebView(id WebViewID) *WebView {
80 return globalRegistry.Lookup(id)102 return globalRegistry.Lookup(id)
81 }103 }
82 104
105+// LookupWebViewByUCMPointer returns a WebView by its UserContentManager pointer.
106+func LookupWebViewByUCMPointer(ptr uintptr) *WebView {
107+ return globalRegistry.LookupByUCMPointer(ptr)
108+}
109+
83 // WebView wraps webkit.WebView with Go-level state tracking and callbacks.110 // WebView wraps webkit.WebView with Go-level state tracking and callbacks.
84 type WebView struct {111 type WebView struct {
85 id WebViewID112 id WebViewID
M internal/ui/app.go +6 -0
@@ -165,6 +165,12 @@ func (a *App) Run(ctx context.Context, args []string) int {
165165 if a.deps != nil && a.deps.AdwaitaDetector != nil {
166166 a.deps.AdwaitaDetector.MarkAvailable()
167167 log.Debug().Msg("adwaita detector marked available")
168+
169+ // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
170+ // They have the wrong dark mode preference injected; re-inject with correct value.
171+ if a.pool != nil {
172+ a.pool.RefreshScripts(ctx)
173+ }
168174 }
169175
170176 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
@@ -165,6 +165,12 @@ func (a *App) Run(ctx context.Context, args []string) int {
165 if a.deps != nil && a.deps.AdwaitaDetector != nil {165 if a.deps != nil && a.deps.AdwaitaDetector != nil {
166 a.deps.AdwaitaDetector.MarkAvailable()166 a.deps.AdwaitaDetector.MarkAvailable()
167 log.Debug().Msg("adwaita detector marked available")167 log.Debug().Msg("adwaita detector marked available")
168+
169+ // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
170+ // They have the wrong dark mode preference injected; re-inject with correct value.
171+ if a.pool != nil {
172+ a.pool.RefreshScripts(ctx)
173+ }
168 }174 }
169 175
170 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)176 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
M internal/ui/coordinator/content.go +259 -22
@@ -21,12 +21,15 @@ import (
2121 "github.com/jwijenbergh/puregotk/v4/gdk"
2222 )
2323
24+const aboutBlankURI = "about:blank"
25+
2426 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
2527 type ContentCoordinator struct {
2628 pool *webkit.WebViewPool
2729 widgetFactory layout.WidgetFactory
2830 faviconAdapter *adapter.FaviconAdapter
2931 zoomUC *usecase.ManageZoomUseCase
32+ injector *webkit.ContentInjector
3033
3134 webViews map[entity.PaneID]*webkit.WebView
3235 paneTitles map[entity.PaneID]string
@@ -58,6 +61,14 @@ type ContentCoordinator struct {
5861 revealMu sync.Mutex
5962 pendingReveal map[entity.PaneID]bool
6063
64+ appearanceMu sync.Mutex
65+ pendingScriptRefresh map[entity.PaneID]bool
66+ pendingThemePanes map[entity.PaneID]bool
67+ pendingThemeUpdate pendingThemeUpdate
68+ hasPendingThemeUpdate bool
69+ currentTheme pendingThemeUpdate
70+ hasCurrentTheme bool
71+
6172 // Gesture action handler for mouse button navigation
6273 gestureActionHandler input.ActionHandler
6374
@@ -83,6 +94,11 @@ type ContentCoordinator struct {
8394 onFullscreenChanged func(entering bool)
8495 }
8596
97+type pendingThemeUpdate struct {
98+ prefersDark bool
99+ cssText string
100+}
101+
86102 // NewContentCoordinator creates a new ContentCoordinator.
87103 func NewContentCoordinator(
88104 ctx context.Context,
@@ -186,7 +202,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
186202 case webkit.LoadCommitted:
187203 c.onLoadCommitted(ctx, paneID, wv)
188204 case webkit.LoadFinished:
189- c.onLoadFinished(paneID)
205+ c.onLoadFinished(ctx, paneID, wv)
190206 }
191207 }
192208
@@ -234,6 +250,7 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
234250 return
235251 }
236252 delete(c.webViews, paneID)
253+ c.clearPendingAppearance(paneID)
237254
238255 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
239256 // we must release the inhibition before destroying the webview.
@@ -403,24 +420,26 @@ func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.
403420 // WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
404421 // appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
405422 // the scripts so future navigations pick up the latest values.
423+// Script refresh is deferred for any WebView that is currently loading to avoid
424+// removing scripts mid-navigation.
406425 func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
407426 log := logging.FromContext(ctx)
408427 if injector == nil {
409428 return
410429 }
411430
431+ c.injector = injector
412432 for paneID, wv := range c.webViews {
413433 if wv == nil || wv.IsDestroyed() {
414434 continue
415435 }
416- ucm := wv.UserContentManager()
417- if ucm == nil {
436+ if c.shouldDeferAppearance(wv) {
437+ c.queueScriptRefresh(paneID)
438+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
418439 continue
419440 }
420441
421- ucm.RemoveAllScripts()
422- injector.InjectScripts(ctx, ucm, wv.ID())
423- log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
442+ c.refreshInjectedScripts(ctx, injector, paneID, wv)
424443 }
425444 }
426445
@@ -432,13 +451,33 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
432451 return
433452 }
434453
435- // Safely embed CSS in JS
436- cssJSON, err := json.Marshal(cssText)
454+ c.setCurrentTheme(prefersDark, cssText)
455+
456+ script, err := buildWebUIThemeScript(prefersDark, cssText)
437457 if err != nil {
438- log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")
458+ log.Warn().Err(err).Msg("failed to build WebUI theme script")
439459 return
440460 }
441461
462+ for paneID, wv := range c.webViews {
463+ if wv == nil || wv.IsDestroyed() {
464+ continue
465+ }
466+ if c.shouldDeferAppearance(wv) {
467+ c.queueThemeApply(paneID, prefersDark, cssText)
468+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
469+ continue
470+ }
471+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
472+ }
473+}
474+
475+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
476+ cssJSON, err := json.Marshal(cssText)
477+ if err != nil {
478+ return "", err
479+ }
480+
442481 script := fmt.Sprintf(`(function(){
443482 try {
444483 var cssText = %s;
@@ -487,17 +526,185 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
487526 }
488527 })();`, string(cssJSON), prefersDark)
489528
490- for paneID, wv := range c.webViews {
491- if wv == nil || wv.IsDestroyed() {
492- continue
493- }
494- uri := wv.URI()
495- if !strings.HasPrefix(uri, "dumb://") {
496- continue
529+ return script, nil
530+}
531+
532+func (c *ContentCoordinator) applyWebUITheme(
533+ ctx context.Context,
534+ paneID entity.PaneID,
535+ wv *webkit.WebView,
536+ script string,
537+ prefersDark bool,
538+) {
539+ if wv == nil || wv.IsDestroyed() {
540+ return
541+ }
542+ uri := wv.URI()
543+ if !strings.HasPrefix(uri, "dumb://") {
544+ return
545+ }
546+ wv.RunJavaScript(ctx, script, "")
547+ logging.FromContext(ctx).
548+ Debug().
549+ Str("pane_id", string(paneID)).
550+ Str("uri", uri).
551+ Bool("prefers_dark", prefersDark).
552+ Msg("applied WebUI theme")
553+}
554+
555+func (c *ContentCoordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
556+ c.appearanceMu.Lock()
557+ if c.pendingThemePanes == nil {
558+ c.pendingThemePanes = make(map[entity.PaneID]bool)
559+ }
560+ c.pendingThemePanes[paneID] = true
561+ c.pendingThemeUpdate = pendingThemeUpdate{
562+ prefersDark: prefersDark,
563+ cssText: cssText,
564+ }
565+ c.hasPendingThemeUpdate = true
566+ c.appearanceMu.Unlock()
567+}
568+
569+func (c *ContentCoordinator) setCurrentTheme(prefersDark bool, cssText string) {
570+ c.appearanceMu.Lock()
571+ c.currentTheme = pendingThemeUpdate{
572+ prefersDark: prefersDark,
573+ cssText: cssText,
574+ }
575+ c.hasCurrentTheme = true
576+ c.appearanceMu.Unlock()
577+}
578+
579+func (c *ContentCoordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
580+ c.appearanceMu.Lock()
581+ defer c.appearanceMu.Unlock()
582+
583+ if !c.hasCurrentTheme {
584+ return pendingThemeUpdate{}, false
585+ }
586+ return c.currentTheme, true
587+}
588+
589+func (c *ContentCoordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
590+ c.appearanceMu.Lock()
591+ defer c.appearanceMu.Unlock()
592+
593+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
594+ return pendingThemeUpdate{}, false
595+ }
596+ delete(c.pendingThemePanes, paneID)
597+ update := c.pendingThemeUpdate
598+ if len(c.pendingThemePanes) == 0 {
599+ c.hasPendingThemeUpdate = false
600+ }
601+ return update, true
602+}
603+
604+func (c *ContentCoordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
605+ update, ok := c.takePendingThemeApply(paneID)
606+ if !ok {
607+ return false
608+ }
609+
610+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
611+ if err != nil {
612+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
613+ return false
614+ }
615+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
616+ return true
617+}
618+
619+func (c *ContentCoordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
620+ update, ok := c.getCurrentTheme()
621+ if !ok || update.cssText == "" {
622+ return false
623+ }
624+
625+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
626+ if err != nil {
627+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
628+ return false
629+ }
630+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
631+ return true
632+}
633+
634+func (c *ContentCoordinator) queueScriptRefresh(paneID entity.PaneID) {
635+ c.appearanceMu.Lock()
636+ if c.pendingScriptRefresh == nil {
637+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
638+ }
639+ c.pendingScriptRefresh[paneID] = true
640+ c.appearanceMu.Unlock()
641+}
642+
643+func (c *ContentCoordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
644+ c.appearanceMu.Lock()
645+ defer c.appearanceMu.Unlock()
646+
647+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
648+ return false
649+ }
650+ delete(c.pendingScriptRefresh, paneID)
651+ return true
652+}
653+
654+func (c *ContentCoordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
655+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
656+ return
657+ }
658+ if !c.takePendingScriptRefresh(paneID) {
659+ return
660+ }
661+ if c.injector == nil {
662+ return
663+ }
664+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
665+}
666+
667+func (c *ContentCoordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
668+ if wv == nil || wv.IsDestroyed() {
669+ return false
670+ }
671+ if wv.IsLoading() {
672+ return true
673+ }
674+ return wv.EstimatedProgress() < 1.0
675+}
676+
677+func (c *ContentCoordinator) refreshInjectedScripts(
678+ ctx context.Context,
679+ injector *webkit.ContentInjector,
680+ paneID entity.PaneID,
681+ wv *webkit.WebView,
682+) {
683+ if injector == nil || wv == nil || wv.IsDestroyed() {
684+ return
685+ }
686+ ucm := wv.UserContentManager()
687+ if ucm == nil {
688+ return
689+ }
690+ ucm.RemoveAllScripts()
691+ ucm.RemoveAllStyleSheets()
692+ injector.InjectScripts(ctx, ucm, wv.ID())
693+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
694+}
695+
696+func (c *ContentCoordinator) clearPendingAppearance(paneID entity.PaneID) {
697+ c.appearanceMu.Lock()
698+ if c.pendingScriptRefresh != nil {
699+ delete(c.pendingScriptRefresh, paneID)
700+ }
701+ if c.pendingThemePanes != nil {
702+ delete(c.pendingThemePanes, paneID)
703+ if len(c.pendingThemePanes) == 0 {
704+ c.hasPendingThemeUpdate = false
497705 }
498- wv.RunJavaScript(ctx, script, "")
499- log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
500706 }
707+ c.appearanceMu.Unlock()
501708 }
502709
503710 // GetTitle returns the current title for a pane.
@@ -704,13 +911,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
704911 // Skip showing if this is about:blank but the pane is loading a different URL
705912 // This prevents the brief flash of about:blank during initial navigation
706913 shouldShow := true
707- if url == "about:blank" {
914+ if url == aboutBlankURI {
708915 // Get the pane's intended URI from the workspace
709916 ws, _ := c.getActiveWS()
710917 if ws != nil {
711918 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
712919 // Don't show about:blank if the pane is supposed to load a different URL
713- if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" {
920+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
714921 shouldShow = false
715922 log.Debug().
716923 Str("pane_id", string(paneID)).
@@ -729,6 +936,10 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
729936 return
730937 }
731938
939+ if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
940+ c.applyCurrentTheme(ctx, paneID, wv)
941+ }
942+
732943 c.markPendingReveal(paneID)
733944 if wv.EstimatedProgress() > 0 {
734945 c.revealIfPending(ctx, paneID, url, "progress-after-commit")
@@ -755,6 +966,27 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
755966 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
756967 }
757968
969+func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
970+ if wv == nil || wv.IsDestroyed() {
971+ return false
972+ }
973+ if wv.URI() != aboutBlankURI {
974+ return false
975+ }
976+ ws, _ := c.getActiveWS()
977+ if ws == nil {
978+ return false
979+ }
980+ paneNode := ws.FindPane(paneID)
981+ if paneNode == nil || paneNode.Pane == nil {
982+ return false
983+ }
984+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
985+ return true
986+ }
987+ return false
988+}
989+
758990 // onSPANavigation records history when URL changes via JavaScript (History API).
759991 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
760992 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
@@ -792,7 +1024,7 @@ func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
7921024 }
7931025
7941026 // onLoadFinished hides the progress bar when page loading completes.
795-func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
1027+func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
7961028 _, wsView := c.getActiveWS()
7971029 if wsView == nil {
7981030 return
@@ -804,6 +1036,11 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
8041036 }
8051037
8061038 c.revealIfPending(context.Background(), paneID, "", "load-finished")
1039+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
1040+ return
1041+ }
1042+ c.applyPendingThemeUpdate(ctx, paneID, wv)
1043+ c.refreshPendingScripts(ctx, paneID, wv)
8071044 }
8081045
8091046 // onProgressChanged updates the progress bar with current load progress.
@@ -1150,7 +1387,7 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
11501387 case webkit.LoadCommitted:
11511388 c.onLoadCommitted(ctx, paneID, wv)
11521389 case webkit.LoadFinished:
1153- c.onLoadFinished(paneID)
1390+ c.onLoadFinished(ctx, paneID, wv)
11541391 }
11551392 }
11561393
@@ -21,12 +21,15 @@ import (
21 "github.com/jwijenbergh/puregotk/v4/gdk"21 "github.com/jwijenbergh/puregotk/v4/gdk"
22 )22 )
23 23
24+const aboutBlankURI = "about:blank"
25+
24 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.26 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
25 type ContentCoordinator struct {27 type ContentCoordinator struct {
26 pool *webkit.WebViewPool28 pool *webkit.WebViewPool
27 widgetFactory layout.WidgetFactory29 widgetFactory layout.WidgetFactory
28 faviconAdapter *adapter.FaviconAdapter30 faviconAdapter *adapter.FaviconAdapter
29 zoomUC *usecase.ManageZoomUseCase31 zoomUC *usecase.ManageZoomUseCase
32+ injector *webkit.ContentInjector
30 33
31 webViews map[entity.PaneID]*webkit.WebView34 webViews map[entity.PaneID]*webkit.WebView
32 paneTitles map[entity.PaneID]string35 paneTitles map[entity.PaneID]string
@@ -58,6 +61,14 @@ type ContentCoordinator struct {
58 revealMu sync.Mutex61 revealMu sync.Mutex
59 pendingReveal map[entity.PaneID]bool62 pendingReveal map[entity.PaneID]bool
60 63
64+ appearanceMu sync.Mutex
65+ pendingScriptRefresh map[entity.PaneID]bool
66+ pendingThemePanes map[entity.PaneID]bool
67+ pendingThemeUpdate pendingThemeUpdate
68+ hasPendingThemeUpdate bool
69+ currentTheme pendingThemeUpdate
70+ hasCurrentTheme bool
71+
61 // Gesture action handler for mouse button navigation72 // Gesture action handler for mouse button navigation
62 gestureActionHandler input.ActionHandler73 gestureActionHandler input.ActionHandler
63 74
@@ -83,6 +94,11 @@ type ContentCoordinator struct {
83 onFullscreenChanged func(entering bool)94 onFullscreenChanged func(entering bool)
84 }95 }
85 96
97+type pendingThemeUpdate struct {
98+ prefersDark bool
99+ cssText string
100+}
101+
86 // NewContentCoordinator creates a new ContentCoordinator.102 // NewContentCoordinator creates a new ContentCoordinator.
87 func NewContentCoordinator(103 func NewContentCoordinator(
88 ctx context.Context,104 ctx context.Context,
@@ -186,7 +202,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
186 case webkit.LoadCommitted:202 case webkit.LoadCommitted:
187 c.onLoadCommitted(ctx, paneID, wv)203 c.onLoadCommitted(ctx, paneID, wv)
188 case webkit.LoadFinished:204 case webkit.LoadFinished:
189- c.onLoadFinished(paneID)205+ c.onLoadFinished(ctx, paneID, wv)
190 }206 }
191 }207 }
192 208
@@ -234,6 +250,7 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
234 return250 return
235 }251 }
236 delete(c.webViews, paneID)252 delete(c.webViews, paneID)
253+ c.clearPendingAppearance(paneID)
237 254
238 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),255 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
239 // we must release the inhibition before destroying the webview.256 // we must release the inhibition before destroying the webview.
@@ -403,24 +420,26 @@ func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.
403 // WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when420 // WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
404 // appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh421 // appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
405 // the scripts so future navigations pick up the latest values.422 // the scripts so future navigations pick up the latest values.
423+// Script refresh is deferred for any WebView that is currently loading to avoid
424+// removing scripts mid-navigation.
406 func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {425 func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
407 log := logging.FromContext(ctx)426 log := logging.FromContext(ctx)
408 if injector == nil {427 if injector == nil {
409 return428 return
410 }429 }
411 430
431+ c.injector = injector
412 for paneID, wv := range c.webViews {432 for paneID, wv := range c.webViews {
413 if wv == nil || wv.IsDestroyed() {433 if wv == nil || wv.IsDestroyed() {
414 continue434 continue
415 }435 }
416- ucm := wv.UserContentManager()436+ if c.shouldDeferAppearance(wv) {
417- if ucm == nil {437+ c.queueScriptRefresh(paneID)
438+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
418 continue439 continue
419 }440 }
420 441
421- ucm.RemoveAllScripts()442+ c.refreshInjectedScripts(ctx, injector, paneID, wv)
422- injector.InjectScripts(ctx, ucm, wv.ID())
423- log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
424 }443 }
425 }444 }
426 445
@@ -432,13 +451,33 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
432 return451 return
433 }452 }
434 453
435- // Safely embed CSS in JS454+ c.setCurrentTheme(prefersDark, cssText)
436- cssJSON, err := json.Marshal(cssText)455+
456+ script, err := buildWebUIThemeScript(prefersDark, cssText)
437 if err != nil {457 if err != nil {
438- log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")458+ log.Warn().Err(err).Msg("failed to build WebUI theme script")
439 return459 return
440 }460 }
441 461
462+ for paneID, wv := range c.webViews {
463+ if wv == nil || wv.IsDestroyed() {
464+ continue
465+ }
466+ if c.shouldDeferAppearance(wv) {
467+ c.queueThemeApply(paneID, prefersDark, cssText)
468+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
469+ continue
470+ }
471+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
472+ }
473+}
474+
475+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
476+ cssJSON, err := json.Marshal(cssText)
477+ if err != nil {
478+ return "", err
479+ }
480+
442 script := fmt.Sprintf(`(function(){481 script := fmt.Sprintf(`(function(){
443 try {482 try {
444 var cssText = %s;483 var cssText = %s;
@@ -487,17 +526,185 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
487 }526 }
488 })();`, string(cssJSON), prefersDark)527 })();`, string(cssJSON), prefersDark)
489 528
490- for paneID, wv := range c.webViews {529+ return script, nil
491- if wv == nil || wv.IsDestroyed() {530+}
492- continue531+
493- }532+func (c *ContentCoordinator) applyWebUITheme(
494- uri := wv.URI()533+ ctx context.Context,
495- if !strings.HasPrefix(uri, "dumb://") {534+ paneID entity.PaneID,
496- continue535+ wv *webkit.WebView,
536+ script string,
537+ prefersDark bool,
538+) {
539+ if wv == nil || wv.IsDestroyed() {
540+ return
541+ }
542+ uri := wv.URI()
543+ if !strings.HasPrefix(uri, "dumb://") {
544+ return
545+ }
546+ wv.RunJavaScript(ctx, script, "")
547+ logging.FromContext(ctx).
548+ Debug().
549+ Str("pane_id", string(paneID)).
550+ Str("uri", uri).
551+ Bool("prefers_dark", prefersDark).
552+ Msg("applied WebUI theme")
553+}
554+
555+func (c *ContentCoordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
556+ c.appearanceMu.Lock()
557+ if c.pendingThemePanes == nil {
558+ c.pendingThemePanes = make(map[entity.PaneID]bool)
559+ }
560+ c.pendingThemePanes[paneID] = true
561+ c.pendingThemeUpdate = pendingThemeUpdate{
562+ prefersDark: prefersDark,
563+ cssText: cssText,
564+ }
565+ c.hasPendingThemeUpdate = true
566+ c.appearanceMu.Unlock()
567+}
568+
569+func (c *ContentCoordinator) setCurrentTheme(prefersDark bool, cssText string) {
570+ c.appearanceMu.Lock()
571+ c.currentTheme = pendingThemeUpdate{
572+ prefersDark: prefersDark,
573+ cssText: cssText,
574+ }
575+ c.hasCurrentTheme = true
576+ c.appearanceMu.Unlock()
577+}
578+
579+func (c *ContentCoordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
580+ c.appearanceMu.Lock()
581+ defer c.appearanceMu.Unlock()
582+
583+ if !c.hasCurrentTheme {
584+ return pendingThemeUpdate{}, false
585+ }
586+ return c.currentTheme, true
587+}
588+
589+func (c *ContentCoordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
590+ c.appearanceMu.Lock()
591+ defer c.appearanceMu.Unlock()
592+
593+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
594+ return pendingThemeUpdate{}, false
595+ }
596+ delete(c.pendingThemePanes, paneID)
597+ update := c.pendingThemeUpdate
598+ if len(c.pendingThemePanes) == 0 {
599+ c.hasPendingThemeUpdate = false
600+ }
601+ return update, true
602+}
603+
604+func (c *ContentCoordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
605+ update, ok := c.takePendingThemeApply(paneID)
606+ if !ok {
607+ return false
608+ }
609+
610+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
611+ if err != nil {
612+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
613+ return false
614+ }
615+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
616+ return true
617+}
618+
619+func (c *ContentCoordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
620+ update, ok := c.getCurrentTheme()
621+ if !ok || update.cssText == "" {
622+ return false
623+ }
624+
625+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
626+ if err != nil {
627+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
628+ return false
629+ }
630+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
631+ return true
632+}
633+
634+func (c *ContentCoordinator) queueScriptRefresh(paneID entity.PaneID) {
635+ c.appearanceMu.Lock()
636+ if c.pendingScriptRefresh == nil {
637+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
638+ }
639+ c.pendingScriptRefresh[paneID] = true
640+ c.appearanceMu.Unlock()
641+}
642+
643+func (c *ContentCoordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
644+ c.appearanceMu.Lock()
645+ defer c.appearanceMu.Unlock()
646+
647+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
648+ return false
649+ }
650+ delete(c.pendingScriptRefresh, paneID)
651+ return true
652+}
653+
654+func (c *ContentCoordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
655+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
656+ return
657+ }
658+ if !c.takePendingScriptRefresh(paneID) {
659+ return
660+ }
661+ if c.injector == nil {
662+ return
663+ }
664+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
665+}
666+
667+func (c *ContentCoordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
668+ if wv == nil || wv.IsDestroyed() {
669+ return false
670+ }
671+ if wv.IsLoading() {
672+ return true
673+ }
674+ return wv.EstimatedProgress() < 1.0
675+}
676+
677+func (c *ContentCoordinator) refreshInjectedScripts(
678+ ctx context.Context,
679+ injector *webkit.ContentInjector,
680+ paneID entity.PaneID,
681+ wv *webkit.WebView,
682+) {
683+ if injector == nil || wv == nil || wv.IsDestroyed() {
684+ return
685+ }
686+ ucm := wv.UserContentManager()
687+ if ucm == nil {
688+ return
689+ }
690+ ucm.RemoveAllScripts()
691+ ucm.RemoveAllStyleSheets()
692+ injector.InjectScripts(ctx, ucm, wv.ID())
693+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
694+}
695+
696+func (c *ContentCoordinator) clearPendingAppearance(paneID entity.PaneID) {
697+ c.appearanceMu.Lock()
698+ if c.pendingScriptRefresh != nil {
699+ delete(c.pendingScriptRefresh, paneID)
700+ }
701+ if c.pendingThemePanes != nil {
702+ delete(c.pendingThemePanes, paneID)
703+ if len(c.pendingThemePanes) == 0 {
704+ c.hasPendingThemeUpdate = false
497 }705 }
498- wv.RunJavaScript(ctx, script, "")
499- log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
500 }706 }
707+ c.appearanceMu.Unlock()
501 }708 }
502 709
503 // GetTitle returns the current title for a pane.710 // GetTitle returns the current title for a pane.
@@ -704,13 +911,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
704 // Skip showing if this is about:blank but the pane is loading a different URL911 // Skip showing if this is about:blank but the pane is loading a different URL
705 // This prevents the brief flash of about:blank during initial navigation912 // This prevents the brief flash of about:blank during initial navigation
706 shouldShow := true913 shouldShow := true
707- if url == "about:blank" {914+ if url == aboutBlankURI {
708 // Get the pane's intended URI from the workspace915 // Get the pane's intended URI from the workspace
709 ws, _ := c.getActiveWS()916 ws, _ := c.getActiveWS()
710 if ws != nil {917 if ws != nil {
711 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {918 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
712 // Don't show about:blank if the pane is supposed to load a different URL919 // Don't show about:blank if the pane is supposed to load a different URL
713- if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" {920+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
714 shouldShow = false921 shouldShow = false
715 log.Debug().922 log.Debug().
716 Str("pane_id", string(paneID)).923 Str("pane_id", string(paneID)).
@@ -729,6 +936,10 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
729 return936 return
730 }937 }
731 938
939+ if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
940+ c.applyCurrentTheme(ctx, paneID, wv)
941+ }
942+
732 c.markPendingReveal(paneID)943 c.markPendingReveal(paneID)
733 if wv.EstimatedProgress() > 0 {944 if wv.EstimatedProgress() > 0 {
734 c.revealIfPending(ctx, paneID, url, "progress-after-commit")945 c.revealIfPending(ctx, paneID, url, "progress-after-commit")
@@ -755,6 +966,27 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
755 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)966 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
756 }967 }
757 968
969+func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
970+ if wv == nil || wv.IsDestroyed() {
971+ return false
972+ }
973+ if wv.URI() != aboutBlankURI {
974+ return false
975+ }
976+ ws, _ := c.getActiveWS()
977+ if ws == nil {
978+ return false
979+ }
980+ paneNode := ws.FindPane(paneID)
981+ if paneNode == nil || paneNode.Pane == nil {
982+ return false
983+ }
984+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
985+ return true
986+ }
987+ return false
988+}
989+
758 // onSPANavigation records history when URL changes via JavaScript (History API).990 // onSPANavigation records history when URL changes via JavaScript (History API).
759 // This handles SPA navigation like YouTube search, where the URL changes without a page load.991 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
760 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {992 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
@@ -792,7 +1024,7 @@ func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
792 }1024 }
793 1025
794 // onLoadFinished hides the progress bar when page loading completes.1026 // onLoadFinished hides the progress bar when page loading completes.
795-func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {1027+func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
796 _, wsView := c.getActiveWS()1028 _, wsView := c.getActiveWS()
797 if wsView == nil {1029 if wsView == nil {
798 return1030 return
@@ -804,6 +1036,11 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
804 }1036 }
805 1037
806 c.revealIfPending(context.Background(), paneID, "", "load-finished")1038 c.revealIfPending(context.Background(), paneID, "", "load-finished")
1039+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
1040+ return
1041+ }
1042+ c.applyPendingThemeUpdate(ctx, paneID, wv)
1043+ c.refreshPendingScripts(ctx, paneID, wv)
807 }1044 }
808 1045
809 // onProgressChanged updates the progress bar with current load progress.1046 // onProgressChanged updates the progress bar with current load progress.
@@ -1150,7 +1387,7 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1150 case webkit.LoadCommitted:1387 case webkit.LoadCommitted:
1151 c.onLoadCommitted(ctx, paneID, wv)1388 c.onLoadCommitted(ctx, paneID, wv)
1152 case webkit.LoadFinished:1389 case webkit.LoadFinished:
1153- c.onLoadFinished(paneID)1390+ c.onLoadFinished(ctx, paneID, wv)
1154 }1391 }
1155 }1392 }
1156 1393
M webui/package-lock.json +64 -0
@@ -11,6 +11,7 @@
1111 "bits-ui": "^2.14.4",
1212 "clsx": "^2.1.1",
1313 "frappe-charts": "^1.6.2",
14+ "mode-watcher": "^1.1.0",
1415 "tailwind-merge": "^3.4.0"
1516 },
1617 "devDependencies": {
@@ -3522,6 +3523,69 @@
35223523 "url": "https://github.com/sponsors/isaacs"
35233524 }
35243525 },
3526+ "node_modules/mode-watcher": {
3527+ "version": "1.1.0",
3528+ "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
3529+ "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
3530+ "license": "MIT",
3531+ "dependencies": {
3532+ "runed": "^0.25.0",
3533+ "svelte-toolbelt": "^0.7.1"
3534+ },
3535+ "peerDependencies": {
3536+ "svelte": "^5.27.0"
3537+ }
3538+ },
3539+ "node_modules/mode-watcher/node_modules/runed": {
3540+ "version": "0.25.0",
3541+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
3542+ "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
3543+ "funding": [
3544+ "https://github.com/sponsors/huntabyte",
3545+ "https://github.com/sponsors/tglide"
3546+ ],
3547+ "dependencies": {
3548+ "esm-env": "^1.0.0"
3549+ },
3550+ "peerDependencies": {
3551+ "svelte": "^5.7.0"
3552+ }
3553+ },
3554+ "node_modules/mode-watcher/node_modules/svelte-toolbelt": {
3555+ "version": "0.7.1",
3556+ "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
3557+ "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
3558+ "funding": [
3559+ "https://github.com/sponsors/huntabyte"
3560+ ],
3561+ "dependencies": {
3562+ "clsx": "^2.1.1",
3563+ "runed": "^0.23.2",
3564+ "style-to-object": "^1.0.8"
3565+ },
3566+ "engines": {
3567+ "node": ">=18",
3568+ "pnpm": ">=8.7.0"
3569+ },
3570+ "peerDependencies": {
3571+ "svelte": "^5.0.0"
3572+ }
3573+ },
3574+ "node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
3575+ "version": "0.23.4",
3576+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
3577+ "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
3578+ "funding": [
3579+ "https://github.com/sponsors/huntabyte",
3580+ "https://github.com/sponsors/tglide"
3581+ ],
3582+ "dependencies": {
3583+ "esm-env": "^1.0.0"
3584+ },
3585+ "peerDependencies": {
3586+ "svelte": "^5.7.0"
3587+ }
3588+ },
35253589 "node_modules/mri": {
35263590 "version": "1.2.0",
35273591 "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -11,6 +11,7 @@
11 "bits-ui": "^2.14.4",11 "bits-ui": "^2.14.4",
12 "clsx": "^2.1.1",12 "clsx": "^2.1.1",
13 "frappe-charts": "^1.6.2",13 "frappe-charts": "^1.6.2",
14+ "mode-watcher": "^1.1.0",
14 "tailwind-merge": "^3.4.0"15 "tailwind-merge": "^3.4.0"
15 },16 },
16 "devDependencies": {17 "devDependencies": {
@@ -3522,6 +3523,69 @@
3522 "url": "https://github.com/sponsors/isaacs"3523 "url": "https://github.com/sponsors/isaacs"
3523 }3524 }
3524 },3525 },
3526+ "node_modules/mode-watcher": {
3527+ "version": "1.1.0",
3528+ "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
3529+ "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
3530+ "license": "MIT",
3531+ "dependencies": {
3532+ "runed": "^0.25.0",
3533+ "svelte-toolbelt": "^0.7.1"
3534+ },
3535+ "peerDependencies": {
3536+ "svelte": "^5.27.0"
3537+ }
3538+ },
3539+ "node_modules/mode-watcher/node_modules/runed": {
3540+ "version": "0.25.0",
3541+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
3542+ "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
3543+ "funding": [
3544+ "https://github.com/sponsors/huntabyte",
3545+ "https://github.com/sponsors/tglide"
3546+ ],
3547+ "dependencies": {
3548+ "esm-env": "^1.0.0"
3549+ },
3550+ "peerDependencies": {
3551+ "svelte": "^5.7.0"
3552+ }
3553+ },
3554+ "node_modules/mode-watcher/node_modules/svelte-toolbelt": {
3555+ "version": "0.7.1",
3556+ "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
3557+ "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
3558+ "funding": [
3559+ "https://github.com/sponsors/huntabyte"
3560+ ],
3561+ "dependencies": {
3562+ "clsx": "^2.1.1",
3563+ "runed": "^0.23.2",
3564+ "style-to-object": "^1.0.8"
3565+ },
3566+ "engines": {
3567+ "node": ">=18",
3568+ "pnpm": ">=8.7.0"
3569+ },
3570+ "peerDependencies": {
3571+ "svelte": "^5.0.0"
3572+ }
3573+ },
3574+ "node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
3575+ "version": "0.23.4",
3576+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
3577+ "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
3578+ "funding": [
3579+ "https://github.com/sponsors/huntabyte",
3580+ "https://github.com/sponsors/tglide"
3581+ ],
3582+ "dependencies": {
3583+ "esm-env": "^1.0.0"
3584+ },
3585+ "peerDependencies": {
3586+ "svelte": "^5.7.0"
3587+ }
3588+ },
3525 "node_modules/mri": {3589 "node_modules/mri": {
3526 "version": "1.2.0",3590 "version": "1.2.0",
3527 "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",3591 "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
M webui/package.json +1 -0
@@ -42,6 +42,7 @@
4242 "bits-ui": "^2.14.4",
4343 "clsx": "^2.1.1",
4444 "frappe-charts": "^1.6.2",
45+ "mode-watcher": "^1.1.0",
4546 "tailwind-merge": "^3.4.0"
4647 }
4748 }
@@ -42,6 +42,7 @@
42 "bits-ui": "^2.14.4",42 "bits-ui": "^2.14.4",
43 "clsx": "^2.1.1",43 "clsx": "^2.1.1",
44 "frappe-charts": "^1.6.2",44 "frappe-charts": "^1.6.2",
45+ "mode-watcher": "^1.1.0",
45 "tailwind-merge": "^3.4.0"46 "tailwind-merge": "^3.4.0"
46 }47 }
47 }48 }
M webui/src/pages/ConfigPage.svelte +2 -3
@@ -1,5 +1,6 @@
11 <script lang="ts">
22 import { onMount } from "svelte";
3+ import { ModeWatcher } from "mode-watcher";
34 import ConfigShell from "./config/ConfigShell.svelte";
45 import ShortcutsTable from "./config/ShortcutsTable.svelte";
56 import * as AlertDialog from "$lib/components/ui/alert-dialog";
@@ -175,9 +176,6 @@
175176 if (!bridge) {
176177 throw new Error("WebKit bridge not available (not running inside Dumber)");
177178 }
178- if (!webviewId) {
179- throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
180- }
181179 if (!config) {
182180 throw new Error("Config not loaded");
183181 }
@@ -268,6 +266,7 @@
268266 });
269267 </script>
270268
269+<ModeWatcher />
271270 <ConfigShell>
272271 <div class="flex w-full flex-col gap-6 px-6 py-8">
273272 {#if loading}
@@ -1,5 +1,6 @@
1 <script lang="ts">1 <script lang="ts">
2 import { onMount } from "svelte";2 import { onMount } from "svelte";
3+ import { ModeWatcher } from "mode-watcher";
3 import ConfigShell from "./config/ConfigShell.svelte";4 import ConfigShell from "./config/ConfigShell.svelte";
4 import ShortcutsTable from "./config/ShortcutsTable.svelte";5 import ShortcutsTable from "./config/ShortcutsTable.svelte";
5 import * as AlertDialog from "$lib/components/ui/alert-dialog";6 import * as AlertDialog from "$lib/components/ui/alert-dialog";
@@ -175,9 +176,6 @@
175 if (!bridge) {176 if (!bridge) {
176 throw new Error("WebKit bridge not available (not running inside Dumber)");177 throw new Error("WebKit bridge not available (not running inside Dumber)");
177 }178 }
178- if (!webviewId) {
179- throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
180- }
181 if (!config) {179 if (!config) {
182 throw new Error("Config not loaded");180 throw new Error("Config not loaded");
183 }181 }
@@ -268,6 +266,7 @@
268 });266 });
269 </script>267 </script>
270 268
269+<ModeWatcher />
271 <ConfigShell>270 <ConfigShell>
272 <div class="flex w-full flex-col gap-6 px-6 py-8">271 <div class="flex w-full flex-col gap-6 px-6 py-8">
273 {#if loading}272 {#if loading}
M webui/src/pages/ErrorPage.svelte +2 -0
@@ -1,5 +1,6 @@
11 <script lang="ts">
22 import { onMount } from 'svelte';
3+ import { ModeWatcher } from 'mode-watcher';
34 import { Button } from '$lib/components/ui/button';
45 import * as Card from '$lib/components/ui/card';
56
@@ -137,6 +138,7 @@
137138 <title>{config.title}</title>
138139 </svelte:head>
139140
141+<ModeWatcher />
140142 <div class="min-h-screen flex items-center justify-center p-8 bg-background">
141143 <Card.Root class="w-full max-w-md">
142144 <Card.Header class="text-center">
@@ -1,5 +1,6 @@
1 <script lang="ts">1 <script lang="ts">
2 import { onMount } from 'svelte';2 import { onMount } from 'svelte';
3+ import { ModeWatcher } from 'mode-watcher';
3 import { Button } from '$lib/components/ui/button';4 import { Button } from '$lib/components/ui/button';
4 import * as Card from '$lib/components/ui/card';5 import * as Card from '$lib/components/ui/card';
5 6
@@ -137,6 +138,7 @@
137 <title>{config.title}</title>138 <title>{config.title}</title>
138 </svelte:head>139 </svelte:head>
139 140
141+<ModeWatcher />
140 <div class="min-h-screen flex items-center justify-center p-8 bg-background">142 <div class="min-h-screen flex items-center justify-center p-8 bg-background">
141 <Card.Root class="w-full max-w-md">143 <Card.Root class="w-full max-w-md">
142 <Card.Header class="text-center">144 <Card.Header class="text-center">
M webui/src/pages/Homepage.svelte +2 -0
@@ -1,9 +1,11 @@
11 <script lang="ts">
2+ import { ModeWatcher } from 'mode-watcher';
23 import { HomepageShell } from './homepage/shell';
34 import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
45 import { homepageState } from './homepage/state.svelte';
56 </script>
67
8+<ModeWatcher />
79 <HomepageShell>
810 {#if homepageState.activePanel === 'history'}
911 <HistoryPanel />
@@ -1,9 +1,11 @@
1 <script lang="ts">1 <script lang="ts">
2+ import { ModeWatcher } from 'mode-watcher';
2 import { HomepageShell } from './homepage/shell';3 import { HomepageShell } from './homepage/shell';
3 import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';4 import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
4 import { homepageState } from './homepage/state.svelte';5 import { homepageState } from './homepage/state.svelte';
5 </script>6 </script>
6 7
8+<ModeWatcher />
7 <HomepageShell>9 <HomepageShell>
8 {#if homepageState.activePanel === 'history'}10 {#if homepageState.activePanel === 'history'}
9 <HistoryPanel />11 <HistoryPanel />

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