detached 21f7418

Fix CEF clipboard flow and dev runtime isolation

commit
21f7418d8817efe2b5b96f3f1ca7dbc402486fd6
author
brice · · 2026-04-18 07:37 UTC
parent
5214440
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +688 -44
M internal/application/usecase/clipboard_text_orchestrator.go +11 -0
@@ -9,6 +9,7 @@ import (
99 "unicode/utf8"
1010
1111 "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/logging"
1213 )
1314
1415 const explicitClipboardDedupWindow = 250 * time.Millisecond
@@ -125,6 +126,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
125126 if uc.toast != nil {
126127 uc.toast(textLen)
127128 }
129+ logging.FromContext(ctx).Debug().
130+ Int("text_len", textLen).
131+ Str("source_engine", string(input.SourceEngine)).
132+ Msg("clipboard selection copied")
128133 return nil
129134 }
130135
@@ -172,6 +177,12 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
172177 if uc.toast != nil {
173178 uc.toast(textLen)
174179 }
180+ logging.FromContext(ctx).Debug().
181+ Int("text_len", textLen).
182+ Str("source_engine", string(input.SourceEngine)).
183+ Str("action", input.Action).
184+ Bool("native_handled", input.NativeHandled).
185+ Msg("clipboard explicit copied")
175186 return nil
176187 }
177188
@@ -9,6 +9,7 @@ import (
9 "unicode/utf8"9 "unicode/utf8"
10 10
11 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/logging"
12 )13 )
13 14
14 const explicitClipboardDedupWindow = 250 * time.Millisecond15 const explicitClipboardDedupWindow = 250 * time.Millisecond
@@ -125,6 +126,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
125 if uc.toast != nil {126 if uc.toast != nil {
126 uc.toast(textLen)127 uc.toast(textLen)
127 }128 }
129+ logging.FromContext(ctx).Debug().
130+ Int("text_len", textLen).
131+ Str("source_engine", string(input.SourceEngine)).
132+ Msg("clipboard selection copied")
128 return nil133 return nil
129 }134 }
130 135
@@ -172,6 +177,12 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
172 if uc.toast != nil {177 if uc.toast != nil {
173 uc.toast(textLen)178 uc.toast(textLen)
174 }179 }
180+ logging.FromContext(ctx).Debug().
181+ Int("text_len", textLen).
182+ Str("source_engine", string(input.SourceEngine)).
183+ Str("action", input.Action).
184+ Bool("native_handled", input.NativeHandled).
185+ Msg("clipboard explicit copied")
175 return nil186 return nil
176 }187 }
177 188
M internal/infrastructure/cef/context_menu.go +5 -2
@@ -176,7 +176,7 @@ func dispatchContextMenuSelection(
176176 return
177177 }
178178 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
179- if item.Action == port.MenuActionCopySelection {
179+ if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" {
180180 if cmdID, ok := commandIDByAction[item.Action]; ok {
181181 log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
182182 callback.Cont(cmdID, 0)
@@ -212,6 +212,8 @@ func dispatchContextMenuSelection(
212212
213213 func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {
214214 switch action {
215+ case port.MenuActionCopySelection:
216+ return menuContext.SelectionText
215217 case port.MenuActionCopyLink:
216218 return menuContext.LinkURI
217219 default:
@@ -227,7 +229,8 @@ func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
227229 port.MenuActionOpenLinkNewTab,
228230 port.MenuActionCopyLink,
229231 port.MenuActionCopyImage,
230- port.MenuActionInspectElement:
232+ port.MenuActionInspectElement,
233+ port.MenuActionCopySelection:
231234 return true
232235 default:
233236 return false
@@ -176,7 +176,7 @@ func dispatchContextMenuSelection(
176 return176 return
177 }177 }
178 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")178 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
179- if item.Action == port.MenuActionCopySelection {179+ if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" {
180 if cmdID, ok := commandIDByAction[item.Action]; ok {180 if cmdID, ok := commandIDByAction[item.Action]; ok {
181 log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")181 log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
182 callback.Cont(cmdID, 0)182 callback.Cont(cmdID, 0)
@@ -212,6 +212,8 @@ func dispatchContextMenuSelection(
212 212
213 func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {213 func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {
214 switch action {214 switch action {
215+ case port.MenuActionCopySelection:
216+ return menuContext.SelectionText
215 case port.MenuActionCopyLink:217 case port.MenuActionCopyLink:
216 return menuContext.LinkURI218 return menuContext.LinkURI
217 default:219 default:
@@ -227,7 +229,8 @@ func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
227 port.MenuActionOpenLinkNewTab,229 port.MenuActionOpenLinkNewTab,
228 port.MenuActionCopyLink,230 port.MenuActionCopyLink,
229 port.MenuActionCopyImage,231 port.MenuActionCopyImage,
230- port.MenuActionInspectElement:232+ port.MenuActionInspectElement,
233+ port.MenuActionCopySelection:
231 return true234 return true
232 default:235 default:
233 return false236 return false
M internal/infrastructure/cef/context_menu_adapter_test.go +26 -3
@@ -171,10 +171,11 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
171171 require.Equal(t, 1, callback.cancelCalls)
172172 }
173173
174-func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) {
174+func TestContextMenuSelectionExecutesCopySelectionDirectlyWhenSelectionTextAvailable(t *testing.T) {
175175 callback := &stubRunContextMenuCallback{}
176176 executor := &stubContextMenuExecutor{}
177177 var copiedLens []int
178+ menuContext := port.MenuContext{SelectionText: "selected text", HasSelection: true}
178179
179180 dispatchContextMenuSelection(
180181 context.Background(),
@@ -183,14 +184,36 @@ func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) {
183184 func(text string) { copiedLens = append(copiedLens, len(text)) },
184185 map[port.MenuAction]int32{port.MenuActionCopySelection: 333},
185186 port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},
186- port.MenuContext{SelectionText: "selected text", HasSelection: true},
187+ menuContext,
188+ )
189+
190+ require.Equal(t, 1, executor.executeCalls)
191+ require.Equal(t, port.MenuActionCopySelection, executor.action)
192+ require.Equal(t, menuContext, executor.menuContext)
193+ require.Zero(t, callback.contCalls)
194+ require.Zero(t, callback.commandID)
195+ require.Equal(t, 1, callback.cancelCalls)
196+ require.Equal(t, []int{len("selected text")}, copiedLens)
197+}
198+
199+func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t *testing.T) {
200+ callback := &stubRunContextMenuCallback{}
201+ executor := &stubContextMenuExecutor{}
202+
203+ dispatchContextMenuSelection(
204+ context.Background(),
205+ executor,
206+ callback,
207+ nil,
208+ map[port.MenuAction]int32{port.MenuActionCopySelection: 333},
209+ port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},
210+ port.MenuContext{HasSelection: true},
187211 )
188212
189213 require.Equal(t, 1, callback.contCalls)
190214 require.Zero(t, callback.cancelCalls)
191215 require.Equal(t, int32(333), callback.commandID)
192216 require.Zero(t, executor.executeCalls)
193- require.Empty(t, copiedLens)
194217 }
195218
196219 func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
@@ -171,10 +171,11 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
171 require.Equal(t, 1, callback.cancelCalls)171 require.Equal(t, 1, callback.cancelCalls)
172 }172 }
173 173
174-func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) {174+func TestContextMenuSelectionExecutesCopySelectionDirectlyWhenSelectionTextAvailable(t *testing.T) {
175 callback := &stubRunContextMenuCallback{}175 callback := &stubRunContextMenuCallback{}
176 executor := &stubContextMenuExecutor{}176 executor := &stubContextMenuExecutor{}
177 var copiedLens []int177 var copiedLens []int
178+ menuContext := port.MenuContext{SelectionText: "selected text", HasSelection: true}
178 179
179 dispatchContextMenuSelection(180 dispatchContextMenuSelection(
180 context.Background(),181 context.Background(),
@@ -183,14 +184,36 @@ func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) {
183 func(text string) { copiedLens = append(copiedLens, len(text)) },184 func(text string) { copiedLens = append(copiedLens, len(text)) },
184 map[port.MenuAction]int32{port.MenuActionCopySelection: 333},185 map[port.MenuAction]int32{port.MenuActionCopySelection: 333},
185 port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},186 port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},
186- port.MenuContext{SelectionText: "selected text", HasSelection: true},187+ menuContext,
188+ )
189+
190+ require.Equal(t, 1, executor.executeCalls)
191+ require.Equal(t, port.MenuActionCopySelection, executor.action)
192+ require.Equal(t, menuContext, executor.menuContext)
193+ require.Zero(t, callback.contCalls)
194+ require.Zero(t, callback.commandID)
195+ require.Equal(t, 1, callback.cancelCalls)
196+ require.Equal(t, []int{len("selected text")}, copiedLens)
197+}
198+
199+func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t *testing.T) {
200+ callback := &stubRunContextMenuCallback{}
201+ executor := &stubContextMenuExecutor{}
202+
203+ dispatchContextMenuSelection(
204+ context.Background(),
205+ executor,
206+ callback,
207+ nil,
208+ map[port.MenuAction]int32{port.MenuActionCopySelection: 333},
209+ port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},
210+ port.MenuContext{HasSelection: true},
187 )211 )
188 212
189 require.Equal(t, 1, callback.contCalls)213 require.Equal(t, 1, callback.contCalls)
190 require.Zero(t, callback.cancelCalls)214 require.Zero(t, callback.cancelCalls)
191 require.Equal(t, int32(333), callback.commandID)215 require.Equal(t, int32(333), callback.commandID)
192 require.Zero(t, executor.executeCalls)216 require.Zero(t, executor.executeCalls)
193- require.Empty(t, copiedLens)
194 }217 }
195 218
196 func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {219 func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
M internal/infrastructure/cef/engine.go +4 -5
@@ -298,11 +298,10 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
298298 }
299299 textLen := utf8.RuneCountInString(text)
300300 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{
301- Text: text,
302- Action: action,
303- SourceEngine: port.ClipboardSourceCEF,
304- ViewID: viewID,
305- NativeHandled: true,
301+ Text: text,
302+ Action: action,
303+ SourceEngine: port.ClipboardSourceCEF,
304+ ViewID: viewID,
306305 }); err != nil {
307306 logging.FromContext(e.currentContext()).Debug().
308307 Err(err).
@@ -298,11 +298,10 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
298 }298 }
299 textLen := utf8.RuneCountInString(text)299 textLen := utf8.RuneCountInString(text)
300 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{300 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{
301- Text: text,301+ Text: text,
302- Action: action,302+ Action: action,
303- SourceEngine: port.ClipboardSourceCEF,303+ SourceEngine: port.ClipboardSourceCEF,
304- ViewID: viewID,304+ ViewID: viewID,
305- NativeHandled: true,
306 }); err != nil {305 }); err != nil {
307 logging.FromContext(e.currentContext()).Debug().306 logging.FromContext(e.currentContext()).Debug().
308 Err(err).307 Err(err).
M internal/infrastructure/cef/engine_runtime_test.go +1 -1
@@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
8585
8686 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
8787
88- require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: true}, orchestrator.explicit)
88+ require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit)
8989 }
9090
9191 type stubClipboardTextOrchestrator struct{}
@@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
85 85
86 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")86 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
87 87
88- require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: true}, orchestrator.explicit)88+ require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit)
89 }89 }
90 90
91 type stubClipboardTextOrchestrator struct{}91 type stubClipboardTextOrchestrator struct{}
M internal/infrastructure/cef/factory.go +12 -1
@@ -103,6 +103,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
103103 wv.findCtrl = newFindController()
104104
105105 input := newInputBridge(ctx, f.scale)
106+ input.selectionText = wv.selectedTextSnapshot
107+ input.explicitCopyText = func(action, text string) {
108+ if f.engine != nil {
109+ f.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
110+ }
111+ }
106112 input.attachTo(pipeline.glArea)
107113 wv.input = input
108114
@@ -172,14 +178,19 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
172178 log.Debug().Msg("cef: skipping browser creation for destroyed webview")
173179 return
174180 }
181+ initialURL := wv.pendingNavigationURI()
182+ if initialURL == "" {
183+ initialURL = "about:blank"
184+ }
175185 result := purecef.BrowserHostCreateBrowser(
176186 pc.windowInfo,
177187 pc.client,
178- "about:blank",
188+ initialURL,
179189 pc.settings,
180190 nil, // extraInfo
181191 nil, // requestContext
182192 )
193+ log.Debug().Str("initial_url", initialURL).Msg("cef: BrowserHostCreateBrowser initial URL")
183194 if f.engine != nil {
184195 f.engine.recordBrowserCreateRequest(w, h, result)
185196 }
@@ -103,6 +103,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
103 wv.findCtrl = newFindController()103 wv.findCtrl = newFindController()
104 104
105 input := newInputBridge(ctx, f.scale)105 input := newInputBridge(ctx, f.scale)
106+ input.selectionText = wv.selectedTextSnapshot
107+ input.explicitCopyText = func(action, text string) {
108+ if f.engine != nil {
109+ f.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
110+ }
111+ }
106 input.attachTo(pipeline.glArea)112 input.attachTo(pipeline.glArea)
107 wv.input = input113 wv.input = input
108 114
@@ -172,14 +178,19 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
172 log.Debug().Msg("cef: skipping browser creation for destroyed webview")178 log.Debug().Msg("cef: skipping browser creation for destroyed webview")
173 return179 return
174 }180 }
181+ initialURL := wv.pendingNavigationURI()
182+ if initialURL == "" {
183+ initialURL = "about:blank"
184+ }
175 result := purecef.BrowserHostCreateBrowser(185 result := purecef.BrowserHostCreateBrowser(
176 pc.windowInfo,186 pc.windowInfo,
177 pc.client,187 pc.client,
178- "about:blank",188+ initialURL,
179 pc.settings,189 pc.settings,
180 nil, // extraInfo190 nil, // extraInfo
181 nil, // requestContext191 nil, // requestContext
182 )192 )
193+ log.Debug().Str("initial_url", initialURL).Msg("cef: BrowserHostCreateBrowser initial URL")
183 if f.engine != nil {194 if f.engine != nil {
184 f.engine.recordBrowserCreateRequest(w, h, result)195 f.engine.recordBrowserCreateRequest(w, h, result)
185 }196 }
M internal/infrastructure/cef/input_bridge.go +48 -1
@@ -57,6 +57,14 @@ type inputBridge struct {
5757 // ImeCommitText. Set once during attachTo, read-only afterwards.
5858 clipboard *gdk.Clipboard
5959
60+ // selectionText returns the browser's latest known text selection so Ctrl+C
61+ // / Ctrl+X can route the selection through the shared explicit-copy path.
62+ selectionText func() string
63+
64+ // explicitCopyText routes explicit copy/cut text through the shared
65+ // clipboard orchestration path.
66+ explicitCopyText func(action, text string)
67+
6068 // IMContext for dead key / compose sequence support. Held as a field
6169 // to prevent garbage collection while the controller is alive.
6270 imContext *gtk.IMContextSimple
@@ -223,6 +231,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
223231 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
224232 mods := uint(state)
225233
234+ ib.maybeMirrorClipboardShortcut(keyval, mods)
235+
226236 // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
227237 // Wayland/X11 clipboard, so we read from GDK and inject via
228238 // ImeCommitText instead of letting CEF handle the paste.
@@ -371,6 +381,39 @@ func (ib *inputBridge) hasGTKFocus() bool {
371381 return ib.gtkFocused.Load()
372382 }
373383
384+func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) {
385+ action, ok := clipboardShortcutAction(keyval, mods)
386+ if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil {
387+ return
388+ }
389+ text := ib.selectionText()
390+ if text == "" {
391+ return
392+ }
393+ logging.FromContext(ib.ctx).Debug().
394+ Str("action", action).
395+ Int("text_len", len(text)).
396+ Msg("cef: clipboard shortcut routed through explicit copy handler")
397+ ib.explicitCopyText(action, text)
398+}
399+
400+func clipboardShortcutAction(keyval, mods uint) (string, bool) {
401+ if mods&uint(gdk.ControlMaskValue) == 0 {
402+ return "", false
403+ }
404+ if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 {
405+ return "", false
406+ }
407+ switch keyval {
408+ case gdkKeyLowercaseC, gdkKeyUppercaseC:
409+ return "copy", true
410+ case gdkKeyLowercaseX, gdkKeyUppercaseX:
411+ return "cut", true
412+ default:
413+ return "", false
414+ }
415+}
416+
374417 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
375418 ib.mu.Lock()
376419 host := ib.host
@@ -591,9 +634,13 @@ const (
591634 gdkKeyPageUp = 0xff55
592635 gdkKeyPageDown = 0xff56
593636
594- // Paste shortcut keyvals.
637+ // Clipboard shortcut keyvals.
638+ gdkKeyLowercaseC = 0x063
639+ gdkKeyUppercaseC = 0x043
595640 gdkKeyLowercaseV = 0x076
596641 gdkKeyUppercaseV = 0x056
642+ gdkKeyLowercaseX = 0x078
643+ gdkKeyUppercaseX = 0x058
597644 )
598645
599646 // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.
@@ -57,6 +57,14 @@ type inputBridge struct {
57 // ImeCommitText. Set once during attachTo, read-only afterwards.57 // ImeCommitText. Set once during attachTo, read-only afterwards.
58 clipboard *gdk.Clipboard58 clipboard *gdk.Clipboard
59 59
60+ // selectionText returns the browser's latest known text selection so Ctrl+C
61+ // / Ctrl+X can route the selection through the shared explicit-copy path.
62+ selectionText func() string
63+
64+ // explicitCopyText routes explicit copy/cut text through the shared
65+ // clipboard orchestration path.
66+ explicitCopyText func(action, text string)
67+
60 // IMContext for dead key / compose sequence support. Held as a field68 // IMContext for dead key / compose sequence support. Held as a field
61 // to prevent garbage collection while the controller is alive.69 // to prevent garbage collection while the controller is alive.
62 imContext *gtk.IMContextSimple70 imContext *gtk.IMContextSimple
@@ -223,6 +231,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
223 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {231 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
224 mods := uint(state)232 mods := uint(state)
225 233
234+ ib.maybeMirrorClipboardShortcut(keyval, mods)
235+
226 // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the236 // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
227 // Wayland/X11 clipboard, so we read from GDK and inject via237 // Wayland/X11 clipboard, so we read from GDK and inject via
228 // ImeCommitText instead of letting CEF handle the paste.238 // ImeCommitText instead of letting CEF handle the paste.
@@ -371,6 +381,39 @@ func (ib *inputBridge) hasGTKFocus() bool {
371 return ib.gtkFocused.Load()381 return ib.gtkFocused.Load()
372 }382 }
373 383
384+func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) {
385+ action, ok := clipboardShortcutAction(keyval, mods)
386+ if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil {
387+ return
388+ }
389+ text := ib.selectionText()
390+ if text == "" {
391+ return
392+ }
393+ logging.FromContext(ib.ctx).Debug().
394+ Str("action", action).
395+ Int("text_len", len(text)).
396+ Msg("cef: clipboard shortcut routed through explicit copy handler")
397+ ib.explicitCopyText(action, text)
398+}
399+
400+func clipboardShortcutAction(keyval, mods uint) (string, bool) {
401+ if mods&uint(gdk.ControlMaskValue) == 0 {
402+ return "", false
403+ }
404+ if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 {
405+ return "", false
406+ }
407+ switch keyval {
408+ case gdkKeyLowercaseC, gdkKeyUppercaseC:
409+ return "copy", true
410+ case gdkKeyLowercaseX, gdkKeyUppercaseX:
411+ return "cut", true
412+ default:
413+ return "", false
414+ }
415+}
416+
374 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {417 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
375 ib.mu.Lock()418 ib.mu.Lock()
376 host := ib.host419 host := ib.host
@@ -591,9 +634,13 @@ const (
591 gdkKeyPageUp = 0xff55634 gdkKeyPageUp = 0xff55
592 gdkKeyPageDown = 0xff56635 gdkKeyPageDown = 0xff56
593 636
594- // Paste shortcut keyvals.637+ // Clipboard shortcut keyvals.
638+ gdkKeyLowercaseC = 0x063
639+ gdkKeyUppercaseC = 0x043
595 gdkKeyLowercaseV = 0x076640 gdkKeyLowercaseV = 0x076
596 gdkKeyUppercaseV = 0x056641 gdkKeyUppercaseV = 0x056
642+ gdkKeyLowercaseX = 0x078
643+ gdkKeyUppercaseX = 0x058
597 )644 )
598 645
599 // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.646 // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.
M internal/infrastructure/cef/input_bridge_test.go +44 -0
@@ -1,9 +1,11 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
56
67 purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/bnema/puregotk/v4/gdk"
79 "github.com/stretchr/testify/require"
810 )
911
@@ -39,3 +41,45 @@ func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *tes
3941 require.Equal(t, []int32{1}, host.focusValues)
4042 require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations)
4143 }
44+
45+func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) {
46+ action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue))
47+ require.True(t, ok)
48+ require.Equal(t, "copy", action)
49+
50+ action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue))
51+ require.True(t, ok)
52+ require.Equal(t, "cut", action)
53+
54+ _, ok = clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue))
55+ require.False(t, ok)
56+}
57+
58+func TestInputBridgeMaybeMirrorClipboardShortcut_RoutesSelectionThroughExplicitHandler(t *testing.T) {
59+ ib := newInputBridge(context.Background(), 1)
60+ ib.selectionText = func() string { return "selected text" }
61+ routed := struct {
62+ action string
63+ text string
64+ }{}
65+ ib.explicitCopyText = func(action, text string) {
66+ routed.action = action
67+ routed.text = text
68+ }
69+
70+ ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue))
71+
72+ require.Equal(t, "copy", routed.action)
73+ require.Equal(t, "selected text", routed.text)
74+}
75+
76+func TestInputBridgeMaybeMirrorClipboardShortcut_IgnoresEmptySelection(t *testing.T) {
77+ ib := newInputBridge(context.Background(), 1)
78+ ib.selectionText = func() string { return "" }
79+ called := false
80+ ib.explicitCopyText = func(string, string) { called = true }
81+
82+ ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseX, uint(gdk.ControlMaskValue))
83+
84+ require.False(t, called)
85+}
@@ -1,9 +1,11 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 purecef "github.com/bnema/purego-cef/cef"7 purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/bnema/puregotk/v4/gdk"
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
8 )10 )
9 11
@@ -39,3 +41,45 @@ func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *tes
39 require.Equal(t, []int32{1}, host.focusValues)41 require.Equal(t, []int32{1}, host.focusValues)
40 require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations)42 require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations)
41 }43 }
44+
45+func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) {
46+ action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue))
47+ require.True(t, ok)
48+ require.Equal(t, "copy", action)
49+
50+ action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue))
51+ require.True(t, ok)
52+ require.Equal(t, "cut", action)
53+
54+ _, ok = clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue))
55+ require.False(t, ok)
56+}
57+
58+func TestInputBridgeMaybeMirrorClipboardShortcut_RoutesSelectionThroughExplicitHandler(t *testing.T) {
59+ ib := newInputBridge(context.Background(), 1)
60+ ib.selectionText = func() string { return "selected text" }
61+ routed := struct {
62+ action string
63+ text string
64+ }{}
65+ ib.explicitCopyText = func(action, text string) {
66+ routed.action = action
67+ routed.text = text
68+ }
69+
70+ ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue))
71+
72+ require.Equal(t, "copy", routed.action)
73+ require.Equal(t, "selected text", routed.text)
74+}
75+
76+func TestInputBridgeMaybeMirrorClipboardShortcut_IgnoresEmptySelection(t *testing.T) {
77+ ib := newInputBridge(context.Background(), 1)
78+ ib.selectionText = func() string { return "" }
79+ called := false
80+ ib.explicitCopyText = func(string, string) { called = true }
81+
82+ ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseX, uint(gdk.ControlMaskValue))
83+
84+ require.False(t, called)
85+}
M internal/infrastructure/cef/render_pipeline.go +55 -0
@@ -114,6 +114,27 @@ type renderPipeline struct {
114114 onResizeCB func(width, height int32)
115115 }
116116
117+type renderPipelineSnapshot struct {
118+ Width int32
119+ Height int32
120+ Scale int32
121+ GLReady bool
122+ NeedsUpload bool
123+ SizeChanged bool
124+ ForceFullUpload bool
125+ ViewRectSeq uint64
126+ ScreenInfoSeq uint64
127+ PaintSeq uint64
128+ ResizeSeq uint64
129+ LastQueuedPaintSeq uint64
130+ GLRenderSeq uint64
131+ PaintCount uint64
132+ QueueRenderCount uint64
133+ RenderCount uint64
134+ UploadCount uint64
135+ FullUploadCount uint64
136+}
137+
117138 // Shader sources for the fullscreen textured quad.
118139 const vertexShaderSource = "" +
119140 "#version 330 core\n" +
@@ -806,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() {
806827 }
807828
808829 // buildShaderProgram compiles and links the vertex+fragment shaders.
830+func (rp *renderPipeline) diagnosticSnapshot() renderPipelineSnapshot {
831+ if rp == nil {
832+ return renderPipelineSnapshot{}
833+ }
834+
835+ rp.diagMu.Lock()
836+ snap := renderPipelineSnapshot{
837+ Width: rp.widthAtomic.Load(),
838+ Height: rp.heightAtomic.Load(),
839+ ViewRectSeq: rp.viewRectSeq.Load(),
840+ ScreenInfoSeq: rp.screenInfoSeq.Load(),
841+ PaintSeq: rp.paintSeq.Load(),
842+ ResizeSeq: rp.resizeSeq.Load(),
843+ LastQueuedPaintSeq: rp.lastQueuedPaintSeq.Load(),
844+ GLRenderSeq: rp.glRenderSeq.Load(),
845+ PaintCount: rp.paintCount,
846+ QueueRenderCount: rp.queueRenderCount,
847+ RenderCount: rp.glRenderCount,
848+ UploadCount: rp.uploadCount,
849+ FullUploadCount: rp.fullUploadCount,
850+ }
851+ rp.diagMu.Unlock()
852+
853+ rp.mu.Lock()
854+ snap.Scale = rp.scale
855+ snap.GLReady = rp.glReady
856+ snap.NeedsUpload = rp.needsUpload
857+ snap.SizeChanged = rp.sizeChanged
858+ snap.ForceFullUpload = rp.forceFullUpload
859+ rp.mu.Unlock()
860+
861+ return snap
862+}
863+
809864 func (rp *renderPipeline) buildShaderProgram() uint32 {
810865 gl := rp.gl
811866
@@ -114,6 +114,27 @@ type renderPipeline struct {
114 onResizeCB func(width, height int32)114 onResizeCB func(width, height int32)
115 }115 }
116 116
117+type renderPipelineSnapshot struct {
118+ Width int32
119+ Height int32
120+ Scale int32
121+ GLReady bool
122+ NeedsUpload bool
123+ SizeChanged bool
124+ ForceFullUpload bool
125+ ViewRectSeq uint64
126+ ScreenInfoSeq uint64
127+ PaintSeq uint64
128+ ResizeSeq uint64
129+ LastQueuedPaintSeq uint64
130+ GLRenderSeq uint64
131+ PaintCount uint64
132+ QueueRenderCount uint64
133+ RenderCount uint64
134+ UploadCount uint64
135+ FullUploadCount uint64
136+}
137+
117 // Shader sources for the fullscreen textured quad.138 // Shader sources for the fullscreen textured quad.
118 const vertexShaderSource = "" +139 const vertexShaderSource = "" +
119 "#version 330 core\n" +140 "#version 330 core\n" +
@@ -806,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() {
806 }827 }
807 828
808 // buildShaderProgram compiles and links the vertex+fragment shaders.829 // buildShaderProgram compiles and links the vertex+fragment shaders.
830+func (rp *renderPipeline) diagnosticSnapshot() renderPipelineSnapshot {
831+ if rp == nil {
832+ return renderPipelineSnapshot{}
833+ }
834+
835+ rp.diagMu.Lock()
836+ snap := renderPipelineSnapshot{
837+ Width: rp.widthAtomic.Load(),
838+ Height: rp.heightAtomic.Load(),
839+ ViewRectSeq: rp.viewRectSeq.Load(),
840+ ScreenInfoSeq: rp.screenInfoSeq.Load(),
841+ PaintSeq: rp.paintSeq.Load(),
842+ ResizeSeq: rp.resizeSeq.Load(),
843+ LastQueuedPaintSeq: rp.lastQueuedPaintSeq.Load(),
844+ GLRenderSeq: rp.glRenderSeq.Load(),
845+ PaintCount: rp.paintCount,
846+ QueueRenderCount: rp.queueRenderCount,
847+ RenderCount: rp.glRenderCount,
848+ UploadCount: rp.uploadCount,
849+ FullUploadCount: rp.fullUploadCount,
850+ }
851+ rp.diagMu.Unlock()
852+
853+ rp.mu.Lock()
854+ snap.Scale = rp.scale
855+ snap.GLReady = rp.glReady
856+ snap.NeedsUpload = rp.needsUpload
857+ snap.SizeChanged = rp.sizeChanged
858+ snap.ForceFullUpload = rp.forceFullUpload
859+ rp.mu.Unlock()
860+
861+ return snap
862+}
863+
809 func (rp *renderPipeline) buildShaderProgram() uint32 {864 func (rp *renderPipeline) buildShaderProgram() uint32 {
810 gl := rp.gl865 gl := rp.gl
811 866
M internal/infrastructure/cef/webview.go +257 -9
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "math"
9+ "strings"
910 "sync"
1011 "sync/atomic"
1112 "time"
@@ -31,7 +32,26 @@ var errDestroyed = errors.New("cef: webview is destroyed")
3132 // errNoBrowser is returned when the browser has not been created yet.
3233 var errNoBrowser = errors.New("cef: browser not yet created")
3334
34-const clipboardSelectionDebounceDelay = 300 * time.Millisecond
35+const (
36+ clipboardSelectionDebounceDelay = 300 * time.Millisecond
37+ pendingNavigationRetryDelay = 25 * time.Millisecond
38+ pendingNavigationMaxRetries = 20
39+)
40+
41+var cefLoadWatchdogDelays = []time.Duration{
42+ 250 * time.Millisecond,
43+ 1 * time.Second,
44+ 2 * time.Second,
45+ 5 * time.Second,
46+ 15 * time.Second,
47+ 30 * time.Second,
48+}
49+
50+var (
51+ cefNewTask = purecef.NewTask
52+ cefPostTask = purecef.PostTask
53+ cefPostDelayedTask = purecef.PostDelayedTask
54+)
3555
3656 // WebView implements port.WebView using a CEF off-screen browser rendered
3757 // through a renderPipeline and driven by an inputBridge.
@@ -82,6 +102,13 @@ type WebView struct {
82102 // Last known hover URI for middle-click → new tab.
83103 lastHoverURI string
84104
105+ // Load diagnostics state (mutex-protected).
106+ loadDiagSeq uint64
107+ loadDiagStartedAt time.Time
108+ loadDiagLastProgressAt time.Time
109+ loadDiagLastAddressAt time.Time
110+ loadDiagLastLoadStateAt time.Time
111+
85112 // Atomic state.
86113 destroyed atomic.Bool
87114 fullscreen atomic.Bool
@@ -134,16 +161,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
134161 actualURI := toActualInternalURL(uri)
135162 wv.mu.Lock()
136163 browser := wv.browser
164+ // Always remember the latest requested URI so a browser/main-frame race
165+ // cannot strand startup on about:blank.
166+ wv.pendingURI = actualURI
167+ wv.mu.Unlock()
137168 if browser == nil {
138- // Browser not yet created — queue the URI for OnAfterCreated.
139- wv.pendingURI = actualURI
140- wv.mu.Unlock()
141169 return nil
142170 }
143- wv.mu.Unlock()
144- if frame := browser.GetMainFrame(); frame != nil {
145- frame.LoadURL(actualURI)
146- }
171+ wv.schedulePendingNavigationReplay(browser, 0)
147172 return nil
148173 }
149174
@@ -515,11 +540,23 @@ func (wv *WebView) NativeWidget() uintptr {
515540
516541 func (wv *WebView) updateURI(uri string) {
517542 uri = toConceptualInternalURL(uri)
543+ now := time.Now()
518544 wv.mu.Lock()
519545 wv.uri = uri
546+ wv.loadDiagLastAddressAt = now
520547 cb := wv.callbacks
548+ pendingMatched := pendingURIEquivalent(wv.pendingURI, uri)
549+ if pendingMatched {
550+ wv.pendingURI = ""
551+ }
521552 wv.mu.Unlock()
522553
554+ if pendingMatched && wv.ctx != nil {
555+ logging.FromContext(wv.ctx).Debug().
556+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
557+ Msg("cef: pending navigation acknowledged by address change")
558+ }
559+
523560 if cb != nil && cb.OnURIChanged != nil {
524561 wv.runOnGTK(func() {
525562 cb.OnURIChanged(uri)
@@ -541,8 +578,10 @@ func (wv *WebView) updateTitle(title string) {
541578 }
542579
543580 func (wv *WebView) updateProgress(progress float64) {
581+ now := time.Now()
544582 wv.mu.Lock()
545583 wv.progress = progress
584+ wv.loadDiagLastProgressAt = now
546585 cb := wv.callbacks
547586 wv.mu.Unlock()
548587
@@ -554,11 +593,26 @@ func (wv *WebView) updateProgress(progress float64) {
554593 }
555594
556595 func (wv *WebView) updateLoadState(loading, back, fwd bool) {
596+ now := time.Now()
557597 wv.mu.Lock()
598+ wasLoading := wv.isLoading
558599 wv.isLoading = loading
559600 wv.canGoBack = back
560601 wv.canGoFwd = fwd
602+ wv.loadDiagLastLoadStateAt = now
603+ var loadDiagSeq uint64
604+ if loading && !wasLoading {
605+ wv.loadDiagSeq++
606+ wv.loadDiagStartedAt = now
607+ wv.loadDiagLastProgressAt = time.Time{}
608+ wv.loadDiagLastAddressAt = time.Time{}
609+ loadDiagSeq = wv.loadDiagSeq
610+ }
561611 wv.mu.Unlock()
612+
613+ if loadDiagSeq != 0 {
614+ wv.scheduleLoadWatchdogs(loadDiagSeq)
615+ }
562616 }
563617
564618 func (wv *WebView) setAudioPlaying(playing bool) {
@@ -587,6 +641,7 @@ func (wv *WebView) setEditableFocus(editable bool) {
587641 return
588642 }
589643 wv.mu.Lock()
644+ previous := wv.focusedEditable
590645 wv.focusedEditable = editable
591646 if editable {
592647 wv.selectionDebounceSeq++
@@ -596,9 +651,15 @@ func (wv *WebView) setEditableFocus(editable bool) {
596651 if timer != nil {
597652 timer.Stop()
598653 }
654+ if previous != editable && wv.ctx != nil {
655+ logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
656+ }
599657 return
600658 }
601659 wv.mu.Unlock()
660+ if previous != editable && wv.ctx != nil {
661+ logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
662+ }
602663 }
603664
604665 func (wv *WebView) cancelSelectionDebounce() {
@@ -620,10 +681,19 @@ func (wv *WebView) scheduleSelectionUpdate(text string) {
620681 return
621682 }
622683 wv.mu.Lock()
623- if wv.destroyed.Load() || wv.focusedEditable || wv.engine == nil {
684+ if wv.destroyed.Load() || wv.engine == nil {
624685 wv.mu.Unlock()
625686 return
626687 }
688+ if wv.focusedEditable {
689+ wv.mu.Unlock()
690+ if wv.ctx != nil {
691+ logging.FromContext(wv.ctx).Debug().
692+ Int("text_len", len(text)).
693+ Msg("cef: selection auto-copy skipped while editable focused")
694+ }
695+ return
696+ }
627697 wv.selectionDebounceSeq++
628698 seq := wv.selectionDebounceSeq
629699 if timer := wv.selectionDebounceTimer; timer != nil {
@@ -648,6 +718,11 @@ func (wv *WebView) flushSelectionUpdate(seq uint64, text string) {
648718 viewID := wv.id
649719 wv.mu.Unlock()
650720 if engine != nil {
721+ if wv.ctx != nil {
722+ logging.FromContext(wv.ctx).Debug().
723+ Int("text_len", len(text)).
724+ Msg("cef: debounced selection update flushed")
725+ }
651726 engine.handleClipboardSelectionUpdate(viewID, text)
652727 }
653728 }
@@ -658,6 +733,179 @@ func (wv *WebView) selectedTextSnapshot() string {
658733 return wv.selectedText
659734 }
660735
736+func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) {
737+ for _, delay := range cefLoadWatchdogDelays {
738+ d := delay
739+ time.AfterFunc(d, func() {
740+ wv.logLoadWatchdog(loadSeq, d)
741+ })
742+ }
743+}
744+
745+func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) {
746+ if wv == nil || wv.destroyed.Load() || wv.ctx == nil {
747+ return
748+ }
749+
750+ wv.mu.RLock()
751+ currentSeq := wv.loadDiagSeq
752+ loading := wv.isLoading
753+ uri := wv.uri
754+ title := wv.title
755+ progress := wv.progress
756+ pendingURI := wv.pendingURI
757+ startedAt := wv.loadDiagStartedAt
758+ lastProgressAt := wv.loadDiagLastProgressAt
759+ lastAddressAt := wv.loadDiagLastAddressAt
760+ lastLoadStateAt := wv.loadDiagLastLoadStateAt
761+ wv.mu.RUnlock()
762+
763+ if loadSeq != currentSeq || !loading {
764+ return
765+ }
766+
767+ snap := renderPipelineSnapshot{}
768+ if wv.pipeline != nil {
769+ snap = wv.pipeline.diagnosticSnapshot()
770+ }
771+
772+ now := time.Now()
773+ logging.FromContext(wv.ctx).Debug().
774+ Uint64("load_seq", loadSeq).
775+ Int64("watch_delay_ms", delay.Milliseconds()).
776+ Int64("since_start_ms", sinceTimestampMs(startedAt, now)).
777+ Int64("since_progress_ms", sinceTimestampMs(lastProgressAt, now)).
778+ Int64("since_address_ms", sinceTimestampMs(lastAddressAt, now)).
779+ Int64("since_load_state_ms", sinceTimestampMs(lastLoadStateAt, now)).
780+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
781+ Str("title", title).
782+ Float64("progress", progress).
783+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
784+ Int32("surface_width", snap.Width).
785+ Int32("surface_height", snap.Height).
786+ Int32("surface_scale", snap.Scale).
787+ Bool("gl_ready", snap.GLReady).
788+ Bool("needs_upload", snap.NeedsUpload).
789+ Bool("size_changed", snap.SizeChanged).
790+ Bool("force_full_upload", snap.ForceFullUpload).
791+ Uint64("view_rect_seq", snap.ViewRectSeq).
792+ Uint64("screen_info_seq", snap.ScreenInfoSeq).
793+ Uint64("paint_seq", snap.PaintSeq).
794+ Uint64("resize_seq", snap.ResizeSeq).
795+ Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq).
796+ Uint64("gl_render_seq", snap.GLRenderSeq).
797+ Uint64("paint_total", snap.PaintCount).
798+ Uint64("queue_total", snap.QueueRenderCount).
799+ Uint64("render_total", snap.RenderCount).
800+ Uint64("upload_total", snap.UploadCount).
801+ Uint64("full_upload_total", snap.FullUploadCount).
802+ Msg("cef: loading watchdog")
803+}
804+
805+func (wv *WebView) pendingNavigationURI() string {
806+ if wv == nil {
807+ return ""
808+ }
809+ wv.mu.RLock()
810+ defer wv.mu.RUnlock()
811+ return wv.pendingURI
812+}
813+
814+func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, attempt int) {
815+ if wv == nil || browser == nil || wv.destroyed.Load() {
816+ return
817+ }
818+ task := cefNewTask(cefTaskFunc(func() {
819+ wv.replayPendingNavigation(browser, attempt)
820+ }))
821+ if task == nil {
822+ return
823+ }
824+ var result int32
825+ if attempt <= 0 {
826+ result = cefPostTask(purecef.ThreadIDTidUi, task)
827+ } else {
828+ result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond))
829+ }
830+ if result != 1 && wv.ctx != nil {
831+ logging.FromContext(wv.ctx).Warn().
832+ Int("attempt", attempt).
833+ Int32("result", result).
834+ Msg("cef: failed to schedule pending navigation replay")
835+ }
836+}
837+
838+func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) {
839+ if wv == nil || browser == nil || wv.destroyed.Load() {
840+ return
841+ }
842+ wv.mu.RLock()
843+ uri := wv.pendingURI
844+ wv.mu.RUnlock()
845+ if uri == "" {
846+ return
847+ }
848+ frame := browser.GetMainFrame()
849+ if frame == nil {
850+ if attempt >= pendingNavigationMaxRetries {
851+ if wv.ctx != nil {
852+ logging.FromContext(wv.ctx).Warn().
853+ Int("attempt", attempt).
854+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
855+ Msg("cef: pending navigation replay exhausted without main frame")
856+ }
857+ return
858+ }
859+ if wv.ctx != nil {
860+ logging.FromContext(wv.ctx).Debug().
861+ Int("attempt", attempt).
862+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
863+ Msg("cef: pending navigation replay waiting for main frame")
864+ }
865+ wv.schedulePendingNavigationReplay(browser, attempt+1)
866+ return
867+ }
868+ currentURL := ""
869+ if frame != nil {
870+ currentURL = frame.GetURL()
871+ }
872+ if pendingURIEquivalent(currentURL, uri) {
873+ wv.mu.Lock()
874+ if pendingURIEquivalent(wv.pendingURI, uri) {
875+ wv.pendingURI = ""
876+ }
877+ wv.mu.Unlock()
878+ if wv.ctx != nil {
879+ logging.FromContext(wv.ctx).Debug().
880+ Int("attempt", attempt).
881+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
882+ Msg("cef: pending navigation already active")
883+ }
884+ return
885+ }
886+ frame.LoadURL(uri)
887+ if wv.ctx != nil {
888+ logging.FromContext(wv.ctx).Debug().
889+ Int("attempt", attempt).
890+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
891+ Msg("cef: replayed pending navigation")
892+ }
893+}
894+
895+func pendingURIEquivalent(a, b string) bool {
896+ if a == "" || b == "" {
897+ return false
898+ }
899+ return toConceptualInternalURL(strings.TrimSpace(a)) == toConceptualInternalURL(strings.TrimSpace(b))
900+}
901+
902+func sinceTimestampMs(ts, now time.Time) int64 {
903+ if ts.IsZero() {
904+ return -1
905+ }
906+ return now.Sub(ts).Milliseconds()
907+}
908+
661909 func (wv *WebView) updateHoverURI(uri string) {
662910 wv.mu.Lock()
663911 wv.lastHoverURI = uri
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "math"8 "math"
9+ "strings"
9 "sync"10 "sync"
10 "sync/atomic"11 "sync/atomic"
11 "time"12 "time"
@@ -31,7 +32,26 @@ var errDestroyed = errors.New("cef: webview is destroyed")
31 // errNoBrowser is returned when the browser has not been created yet.32 // errNoBrowser is returned when the browser has not been created yet.
32 var errNoBrowser = errors.New("cef: browser not yet created")33 var errNoBrowser = errors.New("cef: browser not yet created")
33 34
34-const clipboardSelectionDebounceDelay = 300 * time.Millisecond35+const (
36+ clipboardSelectionDebounceDelay = 300 * time.Millisecond
37+ pendingNavigationRetryDelay = 25 * time.Millisecond
38+ pendingNavigationMaxRetries = 20
39+)
40+
41+var cefLoadWatchdogDelays = []time.Duration{
42+ 250 * time.Millisecond,
43+ 1 * time.Second,
44+ 2 * time.Second,
45+ 5 * time.Second,
46+ 15 * time.Second,
47+ 30 * time.Second,
48+}
49+
50+var (
51+ cefNewTask = purecef.NewTask
52+ cefPostTask = purecef.PostTask
53+ cefPostDelayedTask = purecef.PostDelayedTask
54+)
35 55
36 // WebView implements port.WebView using a CEF off-screen browser rendered56 // WebView implements port.WebView using a CEF off-screen browser rendered
37 // through a renderPipeline and driven by an inputBridge.57 // through a renderPipeline and driven by an inputBridge.
@@ -82,6 +102,13 @@ type WebView struct {
82 // Last known hover URI for middle-click → new tab.102 // Last known hover URI for middle-click → new tab.
83 lastHoverURI string103 lastHoverURI string
84 104
105+ // Load diagnostics state (mutex-protected).
106+ loadDiagSeq uint64
107+ loadDiagStartedAt time.Time
108+ loadDiagLastProgressAt time.Time
109+ loadDiagLastAddressAt time.Time
110+ loadDiagLastLoadStateAt time.Time
111+
85 // Atomic state.112 // Atomic state.
86 destroyed atomic.Bool113 destroyed atomic.Bool
87 fullscreen atomic.Bool114 fullscreen atomic.Bool
@@ -134,16 +161,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
134 actualURI := toActualInternalURL(uri)161 actualURI := toActualInternalURL(uri)
135 wv.mu.Lock()162 wv.mu.Lock()
136 browser := wv.browser163 browser := wv.browser
164+ // Always remember the latest requested URI so a browser/main-frame race
165+ // cannot strand startup on about:blank.
166+ wv.pendingURI = actualURI
167+ wv.mu.Unlock()
137 if browser == nil {168 if browser == nil {
138- // Browser not yet created — queue the URI for OnAfterCreated.
139- wv.pendingURI = actualURI
140- wv.mu.Unlock()
141 return nil169 return nil
142 }170 }
143- wv.mu.Unlock()171+ wv.schedulePendingNavigationReplay(browser, 0)
144- if frame := browser.GetMainFrame(); frame != nil {
145- frame.LoadURL(actualURI)
146- }
147 return nil172 return nil
148 }173 }
149 174
@@ -515,11 +540,23 @@ func (wv *WebView) NativeWidget() uintptr {
515 540
516 func (wv *WebView) updateURI(uri string) {541 func (wv *WebView) updateURI(uri string) {
517 uri = toConceptualInternalURL(uri)542 uri = toConceptualInternalURL(uri)
543+ now := time.Now()
518 wv.mu.Lock()544 wv.mu.Lock()
519 wv.uri = uri545 wv.uri = uri
546+ wv.loadDiagLastAddressAt = now
520 cb := wv.callbacks547 cb := wv.callbacks
548+ pendingMatched := pendingURIEquivalent(wv.pendingURI, uri)
549+ if pendingMatched {
550+ wv.pendingURI = ""
551+ }
521 wv.mu.Unlock()552 wv.mu.Unlock()
522 553
554+ if pendingMatched && wv.ctx != nil {
555+ logging.FromContext(wv.ctx).Debug().
556+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
557+ Msg("cef: pending navigation acknowledged by address change")
558+ }
559+
523 if cb != nil && cb.OnURIChanged != nil {560 if cb != nil && cb.OnURIChanged != nil {
524 wv.runOnGTK(func() {561 wv.runOnGTK(func() {
525 cb.OnURIChanged(uri)562 cb.OnURIChanged(uri)
@@ -541,8 +578,10 @@ func (wv *WebView) updateTitle(title string) {
541 }578 }
542 579
543 func (wv *WebView) updateProgress(progress float64) {580 func (wv *WebView) updateProgress(progress float64) {
581+ now := time.Now()
544 wv.mu.Lock()582 wv.mu.Lock()
545 wv.progress = progress583 wv.progress = progress
584+ wv.loadDiagLastProgressAt = now
546 cb := wv.callbacks585 cb := wv.callbacks
547 wv.mu.Unlock()586 wv.mu.Unlock()
548 587
@@ -554,11 +593,26 @@ func (wv *WebView) updateProgress(progress float64) {
554 }593 }
555 594
556 func (wv *WebView) updateLoadState(loading, back, fwd bool) {595 func (wv *WebView) updateLoadState(loading, back, fwd bool) {
596+ now := time.Now()
557 wv.mu.Lock()597 wv.mu.Lock()
598+ wasLoading := wv.isLoading
558 wv.isLoading = loading599 wv.isLoading = loading
559 wv.canGoBack = back600 wv.canGoBack = back
560 wv.canGoFwd = fwd601 wv.canGoFwd = fwd
602+ wv.loadDiagLastLoadStateAt = now
603+ var loadDiagSeq uint64
604+ if loading && !wasLoading {
605+ wv.loadDiagSeq++
606+ wv.loadDiagStartedAt = now
607+ wv.loadDiagLastProgressAt = time.Time{}
608+ wv.loadDiagLastAddressAt = time.Time{}
609+ loadDiagSeq = wv.loadDiagSeq
610+ }
561 wv.mu.Unlock()611 wv.mu.Unlock()
612+
613+ if loadDiagSeq != 0 {
614+ wv.scheduleLoadWatchdogs(loadDiagSeq)
615+ }
562 }616 }
563 617
564 func (wv *WebView) setAudioPlaying(playing bool) {618 func (wv *WebView) setAudioPlaying(playing bool) {
@@ -587,6 +641,7 @@ func (wv *WebView) setEditableFocus(editable bool) {
587 return641 return
588 }642 }
589 wv.mu.Lock()643 wv.mu.Lock()
644+ previous := wv.focusedEditable
590 wv.focusedEditable = editable645 wv.focusedEditable = editable
591 if editable {646 if editable {
592 wv.selectionDebounceSeq++647 wv.selectionDebounceSeq++
@@ -596,9 +651,15 @@ func (wv *WebView) setEditableFocus(editable bool) {
596 if timer != nil {651 if timer != nil {
597 timer.Stop()652 timer.Stop()
598 }653 }
654+ if previous != editable && wv.ctx != nil {
655+ logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
656+ }
599 return657 return
600 }658 }
601 wv.mu.Unlock()659 wv.mu.Unlock()
660+ if previous != editable && wv.ctx != nil {
661+ logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
662+ }
602 }663 }
603 664
604 func (wv *WebView) cancelSelectionDebounce() {665 func (wv *WebView) cancelSelectionDebounce() {
@@ -620,10 +681,19 @@ func (wv *WebView) scheduleSelectionUpdate(text string) {
620 return681 return
621 }682 }
622 wv.mu.Lock()683 wv.mu.Lock()
623- if wv.destroyed.Load() || wv.focusedEditable || wv.engine == nil {684+ if wv.destroyed.Load() || wv.engine == nil {
624 wv.mu.Unlock()685 wv.mu.Unlock()
625 return686 return
626 }687 }
688+ if wv.focusedEditable {
689+ wv.mu.Unlock()
690+ if wv.ctx != nil {
691+ logging.FromContext(wv.ctx).Debug().
692+ Int("text_len", len(text)).
693+ Msg("cef: selection auto-copy skipped while editable focused")
694+ }
695+ return
696+ }
627 wv.selectionDebounceSeq++697 wv.selectionDebounceSeq++
628 seq := wv.selectionDebounceSeq698 seq := wv.selectionDebounceSeq
629 if timer := wv.selectionDebounceTimer; timer != nil {699 if timer := wv.selectionDebounceTimer; timer != nil {
@@ -648,6 +718,11 @@ func (wv *WebView) flushSelectionUpdate(seq uint64, text string) {
648 viewID := wv.id718 viewID := wv.id
649 wv.mu.Unlock()719 wv.mu.Unlock()
650 if engine != nil {720 if engine != nil {
721+ if wv.ctx != nil {
722+ logging.FromContext(wv.ctx).Debug().
723+ Int("text_len", len(text)).
724+ Msg("cef: debounced selection update flushed")
725+ }
651 engine.handleClipboardSelectionUpdate(viewID, text)726 engine.handleClipboardSelectionUpdate(viewID, text)
652 }727 }
653 }728 }
@@ -658,6 +733,179 @@ func (wv *WebView) selectedTextSnapshot() string {
658 return wv.selectedText733 return wv.selectedText
659 }734 }
660 735
736+func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) {
737+ for _, delay := range cefLoadWatchdogDelays {
738+ d := delay
739+ time.AfterFunc(d, func() {
740+ wv.logLoadWatchdog(loadSeq, d)
741+ })
742+ }
743+}
744+
745+func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) {
746+ if wv == nil || wv.destroyed.Load() || wv.ctx == nil {
747+ return
748+ }
749+
750+ wv.mu.RLock()
751+ currentSeq := wv.loadDiagSeq
752+ loading := wv.isLoading
753+ uri := wv.uri
754+ title := wv.title
755+ progress := wv.progress
756+ pendingURI := wv.pendingURI
757+ startedAt := wv.loadDiagStartedAt
758+ lastProgressAt := wv.loadDiagLastProgressAt
759+ lastAddressAt := wv.loadDiagLastAddressAt
760+ lastLoadStateAt := wv.loadDiagLastLoadStateAt
761+ wv.mu.RUnlock()
762+
763+ if loadSeq != currentSeq || !loading {
764+ return
765+ }
766+
767+ snap := renderPipelineSnapshot{}
768+ if wv.pipeline != nil {
769+ snap = wv.pipeline.diagnosticSnapshot()
770+ }
771+
772+ now := time.Now()
773+ logging.FromContext(wv.ctx).Debug().
774+ Uint64("load_seq", loadSeq).
775+ Int64("watch_delay_ms", delay.Milliseconds()).
776+ Int64("since_start_ms", sinceTimestampMs(startedAt, now)).
777+ Int64("since_progress_ms", sinceTimestampMs(lastProgressAt, now)).
778+ Int64("since_address_ms", sinceTimestampMs(lastAddressAt, now)).
779+ Int64("since_load_state_ms", sinceTimestampMs(lastLoadStateAt, now)).
780+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
781+ Str("title", title).
782+ Float64("progress", progress).
783+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
784+ Int32("surface_width", snap.Width).
785+ Int32("surface_height", snap.Height).
786+ Int32("surface_scale", snap.Scale).
787+ Bool("gl_ready", snap.GLReady).
788+ Bool("needs_upload", snap.NeedsUpload).
789+ Bool("size_changed", snap.SizeChanged).
790+ Bool("force_full_upload", snap.ForceFullUpload).
791+ Uint64("view_rect_seq", snap.ViewRectSeq).
792+ Uint64("screen_info_seq", snap.ScreenInfoSeq).
793+ Uint64("paint_seq", snap.PaintSeq).
794+ Uint64("resize_seq", snap.ResizeSeq).
795+ Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq).
796+ Uint64("gl_render_seq", snap.GLRenderSeq).
797+ Uint64("paint_total", snap.PaintCount).
798+ Uint64("queue_total", snap.QueueRenderCount).
799+ Uint64("render_total", snap.RenderCount).
800+ Uint64("upload_total", snap.UploadCount).
801+ Uint64("full_upload_total", snap.FullUploadCount).
802+ Msg("cef: loading watchdog")
803+}
804+
805+func (wv *WebView) pendingNavigationURI() string {
806+ if wv == nil {
807+ return ""
808+ }
809+ wv.mu.RLock()
810+ defer wv.mu.RUnlock()
811+ return wv.pendingURI
812+}
813+
814+func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, attempt int) {
815+ if wv == nil || browser == nil || wv.destroyed.Load() {
816+ return
817+ }
818+ task := cefNewTask(cefTaskFunc(func() {
819+ wv.replayPendingNavigation(browser, attempt)
820+ }))
821+ if task == nil {
822+ return
823+ }
824+ var result int32
825+ if attempt <= 0 {
826+ result = cefPostTask(purecef.ThreadIDTidUi, task)
827+ } else {
828+ result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond))
829+ }
830+ if result != 1 && wv.ctx != nil {
831+ logging.FromContext(wv.ctx).Warn().
832+ Int("attempt", attempt).
833+ Int32("result", result).
834+ Msg("cef: failed to schedule pending navigation replay")
835+ }
836+}
837+
838+func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) {
839+ if wv == nil || browser == nil || wv.destroyed.Load() {
840+ return
841+ }
842+ wv.mu.RLock()
843+ uri := wv.pendingURI
844+ wv.mu.RUnlock()
845+ if uri == "" {
846+ return
847+ }
848+ frame := browser.GetMainFrame()
849+ if frame == nil {
850+ if attempt >= pendingNavigationMaxRetries {
851+ if wv.ctx != nil {
852+ logging.FromContext(wv.ctx).Warn().
853+ Int("attempt", attempt).
854+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
855+ Msg("cef: pending navigation replay exhausted without main frame")
856+ }
857+ return
858+ }
859+ if wv.ctx != nil {
860+ logging.FromContext(wv.ctx).Debug().
861+ Int("attempt", attempt).
862+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
863+ Msg("cef: pending navigation replay waiting for main frame")
864+ }
865+ wv.schedulePendingNavigationReplay(browser, attempt+1)
866+ return
867+ }
868+ currentURL := ""
869+ if frame != nil {
870+ currentURL = frame.GetURL()
871+ }
872+ if pendingURIEquivalent(currentURL, uri) {
873+ wv.mu.Lock()
874+ if pendingURIEquivalent(wv.pendingURI, uri) {
875+ wv.pendingURI = ""
876+ }
877+ wv.mu.Unlock()
878+ if wv.ctx != nil {
879+ logging.FromContext(wv.ctx).Debug().
880+ Int("attempt", attempt).
881+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
882+ Msg("cef: pending navigation already active")
883+ }
884+ return
885+ }
886+ frame.LoadURL(uri)
887+ if wv.ctx != nil {
888+ logging.FromContext(wv.ctx).Debug().
889+ Int("attempt", attempt).
890+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
891+ Msg("cef: replayed pending navigation")
892+ }
893+}
894+
895+func pendingURIEquivalent(a, b string) bool {
896+ if a == "" || b == "" {
897+ return false
898+ }
899+ return toConceptualInternalURL(strings.TrimSpace(a)) == toConceptualInternalURL(strings.TrimSpace(b))
900+}
901+
902+func sinceTimestampMs(ts, now time.Time) int64 {
903+ if ts.IsZero() {
904+ return -1
905+ }
906+ return now.Sub(ts).Milliseconds()
907+}
908+
661 func (wv *WebView) updateHoverURI(uri string) {909 func (wv *WebView) updateHoverURI(uri string) {
662 wv.mu.Lock()910 wv.mu.Lock()
663 wv.lastHoverURI = uri911 wv.lastHoverURI = uri
M internal/infrastructure/cef/webview_handlers.go +43 -8
@@ -307,6 +307,7 @@ func (h *handlerSet) shouldAcceptMainViewPaint(
307307 if sizeMatchesCurrentView {
308308 _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize()
309309 if shouldLog {
310+ logging.Trace().Mark("cef_first_content_paint")
310311 logging.FromContext(h.wv.ctx).Debug().
311312 Uint64("webview_id", uint64(h.wv.id)).
312313 Uint64("paint_seq", paintSeq).
@@ -403,6 +404,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex
403404 // OnAddressChange updates the cached URI when the main frame navigates.
404405 func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
405406 if frame != nil && frame.IsMain() {
407+ if h.wv != nil && h.wv.ctx != nil {
408+ logging.FromContext(h.wv.ctx).Debug().
409+ Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)).
410+ Msg("cef: OnAddressChange")
411+ }
406412 h.wv.updateURI(url)
407413 }
408414 }
@@ -486,6 +492,11 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re
486492
487493 // OnLoadingProgressChange updates the cached progress value.
488494 func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {
495+ if h.wv != nil && h.wv.ctx != nil {
496+ logging.FromContext(h.wv.ctx).Trace().
497+ Float64("progress", progress).
498+ Msg("cef: OnLoadingProgressChange")
499+ }
489500 h.wv.updateProgress(progress)
490501 }
491502
@@ -516,6 +527,21 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect)
516527 func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {
517528 loading := isloading != 0
518529 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
530+ if h.wv != nil && h.wv.ctx != nil {
531+ h.wv.mu.RLock()
532+ uri := h.wv.uri
533+ progress := h.wv.progress
534+ pendingURI := h.wv.pendingURI
535+ h.wv.mu.RUnlock()
536+ logging.FromContext(h.wv.ctx).Debug().
537+ Bool("loading", loading).
538+ Bool("can_go_back", cangoback != 0).
539+ Bool("can_go_forward", cangoforward != 0).
540+ Float64("progress", progress).
541+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
542+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
543+ Msg("cef: OnLoadingStateChange")
544+ }
519545
520546 if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() {
521547 h.wv.mu.RLock()
@@ -547,6 +573,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
547573 if frame == nil || !frame.IsMain() {
548574 return
549575 }
576+ if h.wv != nil && h.wv.ctx != nil {
577+ logging.FromContext(h.wv.ctx).Debug().
578+ Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
579+ Msg("cef: OnLoadStart")
580+ }
550581 h.wv.updateURI(frame.GetURL())
551582 h.wv.mu.RLock()
552583 cb := h.wv.callbacks
@@ -572,6 +603,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
572603 // Successful load — reset the consecutive crash counter.
573604 h.wv.crashCount.Store(0)
574605
606+ // If a queued startup navigation is still pending after about:blank finished,
607+ // replay it now that the initial main-frame load completed.
608+ frameURL := frame.GetURL()
609+ if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) {
610+ if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
611+ log.Debug().
612+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
613+ Msg("cef: replaying pending navigation after about:blank load end")
614+ h.wv.schedulePendingNavigationReplay(h.wv.browser, 0)
615+ }
616+ }
617+
575618 // Inject scripts and styles after page load.
576619 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
577620 // and JavaScript injection requires the main thread.
@@ -660,8 +703,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
660703 h.wv.mu.Lock()
661704 h.wv.browser = browser
662705 h.wv.host = host
663- uri := h.wv.pendingURI
664- h.wv.pendingURI = ""
665706 h.wv.input.setHost(host)
666707 if h.wv.findCtrl != nil {
667708 h.wv.findCtrl.setHost(host)
@@ -677,12 +718,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
677718 syncWindowlessBrowserFocus(host)
678719 }
679720
680- // Replay any navigation that was requested before the browser existed.
681- if uri != "" {
682- if frame := browser.GetMainFrame(); frame != nil {
683- frame.LoadURL(uri)
684- }
685- }
686721 h.wv.mu.Unlock()
687722
688723 h.wv.scheduleStartBeginFrameLoop()
@@ -307,6 +307,7 @@ func (h *handlerSet) shouldAcceptMainViewPaint(
307 if sizeMatchesCurrentView {307 if sizeMatchesCurrentView {
308 _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize()308 _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize()
309 if shouldLog {309 if shouldLog {
310+ logging.Trace().Mark("cef_first_content_paint")
310 logging.FromContext(h.wv.ctx).Debug().311 logging.FromContext(h.wv.ctx).Debug().
311 Uint64("webview_id", uint64(h.wv.id)).312 Uint64("webview_id", uint64(h.wv.id)).
312 Uint64("paint_seq", paintSeq).313 Uint64("paint_seq", paintSeq).
@@ -403,6 +404,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex
403 // OnAddressChange updates the cached URI when the main frame navigates.404 // OnAddressChange updates the cached URI when the main frame navigates.
404 func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {405 func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
405 if frame != nil && frame.IsMain() {406 if frame != nil && frame.IsMain() {
407+ if h.wv != nil && h.wv.ctx != nil {
408+ logging.FromContext(h.wv.ctx).Debug().
409+ Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)).
410+ Msg("cef: OnAddressChange")
411+ }
406 h.wv.updateURI(url)412 h.wv.updateURI(url)
407 }413 }
408 }414 }
@@ -486,6 +492,11 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re
486 492
487 // OnLoadingProgressChange updates the cached progress value.493 // OnLoadingProgressChange updates the cached progress value.
488 func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {494 func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {
495+ if h.wv != nil && h.wv.ctx != nil {
496+ logging.FromContext(h.wv.ctx).Trace().
497+ Float64("progress", progress).
498+ Msg("cef: OnLoadingProgressChange")
499+ }
489 h.wv.updateProgress(progress)500 h.wv.updateProgress(progress)
490 }501 }
491 502
@@ -516,6 +527,21 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect)
516 func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {527 func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {
517 loading := isloading != 0528 loading := isloading != 0
518 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)529 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
530+ if h.wv != nil && h.wv.ctx != nil {
531+ h.wv.mu.RLock()
532+ uri := h.wv.uri
533+ progress := h.wv.progress
534+ pendingURI := h.wv.pendingURI
535+ h.wv.mu.RUnlock()
536+ logging.FromContext(h.wv.ctx).Debug().
537+ Bool("loading", loading).
538+ Bool("can_go_back", cangoback != 0).
539+ Bool("can_go_forward", cangoforward != 0).
540+ Float64("progress", progress).
541+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
542+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
543+ Msg("cef: OnLoadingStateChange")
544+ }
519 545
520 if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() {546 if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() {
521 h.wv.mu.RLock()547 h.wv.mu.RLock()
@@ -547,6 +573,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
547 if frame == nil || !frame.IsMain() {573 if frame == nil || !frame.IsMain() {
548 return574 return
549 }575 }
576+ if h.wv != nil && h.wv.ctx != nil {
577+ logging.FromContext(h.wv.ctx).Debug().
578+ Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
579+ Msg("cef: OnLoadStart")
580+ }
550 h.wv.updateURI(frame.GetURL())581 h.wv.updateURI(frame.GetURL())
551 h.wv.mu.RLock()582 h.wv.mu.RLock()
552 cb := h.wv.callbacks583 cb := h.wv.callbacks
@@ -572,6 +603,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
572 // Successful load — reset the consecutive crash counter.603 // Successful load — reset the consecutive crash counter.
573 h.wv.crashCount.Store(0)604 h.wv.crashCount.Store(0)
574 605
606+ // If a queued startup navigation is still pending after about:blank finished,
607+ // replay it now that the initial main-frame load completed.
608+ frameURL := frame.GetURL()
609+ if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) {
610+ if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
611+ log.Debug().
612+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
613+ Msg("cef: replaying pending navigation after about:blank load end")
614+ h.wv.schedulePendingNavigationReplay(h.wv.browser, 0)
615+ }
616+ }
617+
575 // Inject scripts and styles after page load.618 // Inject scripts and styles after page load.
576 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,619 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
577 // and JavaScript injection requires the main thread.620 // and JavaScript injection requires the main thread.
@@ -660,8 +703,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
660 h.wv.mu.Lock()703 h.wv.mu.Lock()
661 h.wv.browser = browser704 h.wv.browser = browser
662 h.wv.host = host705 h.wv.host = host
663- uri := h.wv.pendingURI
664- h.wv.pendingURI = ""
665 h.wv.input.setHost(host)706 h.wv.input.setHost(host)
666 if h.wv.findCtrl != nil {707 if h.wv.findCtrl != nil {
667 h.wv.findCtrl.setHost(host)708 h.wv.findCtrl.setHost(host)
@@ -677,12 +718,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
677 syncWindowlessBrowserFocus(host)718 syncWindowlessBrowserFocus(host)
678 }719 }
679 720
680- // Replay any navigation that was requested before the browser existed.
681- if uri != "" {
682- if frame := browser.GetMainFrame(); frame != nil {
683- frame.LoadURL(uri)
684- }
685- }
686 h.wv.mu.Unlock()721 h.wv.mu.Unlock()
687 722
688 h.wv.scheduleStartBeginFrameLoop()723 h.wv.scheduleStartBeginFrameLoop()
A internal/infrastructure/cef/webview_navigation_test.go +93 -0
@@ -0,0 +1,93 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *testing.T) {
14+ browser := cefmocks.NewMockBrowser(t)
15+ frame := cefmocks.NewMockFrame(t)
16+ frame.EXPECT().GetURL().Return("").Once()
17+ frame.EXPECT().LoadURL("https://github.com/bnema").Once()
18+ browser.EXPECT().GetMainFrame().Return(frame).Once()
19+
20+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
21+ wv.replayPendingNavigation(browser, 0)
22+
23+ wv.mu.RLock()
24+ defer wv.mu.RUnlock()
25+ require.Equal(t, "https://github.com/bnema", wv.pendingURI)
26+}
27+
28+func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {
29+ browser := cefmocks.NewMockBrowser(t)
30+ browser.EXPECT().GetMainFrame().Return((purecef.Frame)(nil)).Once()
31+
32+ oldTask := cefNewTask
33+ oldDelayed := cefPostDelayedTask
34+ defer func() {
35+ cefNewTask = oldTask
36+ cefPostDelayedTask = oldDelayed
37+ }()
38+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
39+
40+ scheduled := false
41+ cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
42+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
43+ require.NotNil(t, task)
44+ require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs)
45+ scheduled = true
46+ return 1
47+ }
48+
49+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
50+ wv.replayPendingNavigation(browser, 0)
51+
52+ require.True(t, scheduled)
53+ wv.mu.RLock()
54+ defer wv.mu.RUnlock()
55+ require.Equal(t, "https://github.com/bnema", wv.pendingURI)
56+}
57+
58+func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {
59+ browser := cefmocks.NewMockBrowser(t)
60+ frame := cefmocks.NewMockFrame(t)
61+ browser.EXPECT().GetMainFrame().Return(frame).Once()
62+ frame.EXPECT().GetURL().Return("").Once()
63+ frame.EXPECT().LoadURL("github.com/bnema").Once()
64+
65+ oldTask := cefNewTask
66+ oldPost := cefPostTask
67+ defer func() {
68+ cefNewTask = oldTask
69+ cefPostTask = oldPost
70+ }()
71+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
72+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
73+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
74+ require.NotNil(t, task)
75+ task.Execute()
76+ return 1
77+ }
78+
79+ wv := &WebView{ctx: context.Background(), browser: browser}
80+ require.NoError(t, wv.LoadURI(context.Background(), "github.com/bnema"))
81+
82+ wv.mu.RLock()
83+ defer wv.mu.RUnlock()
84+ require.Equal(t, "github.com/bnema", wv.pendingURI)
85+}
86+
87+func TestWebViewUpdateURI_ClearsMatchingPendingNavigation(t *testing.T) {
88+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
89+
90+ wv.updateURI("https://github.com/bnema")
91+
92+ require.Empty(t, wv.pendingNavigationURI())
93+}
@@ -0,0 +1,93 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *testing.T) {
14+ browser := cefmocks.NewMockBrowser(t)
15+ frame := cefmocks.NewMockFrame(t)
16+ frame.EXPECT().GetURL().Return("").Once()
17+ frame.EXPECT().LoadURL("https://github.com/bnema").Once()
18+ browser.EXPECT().GetMainFrame().Return(frame).Once()
19+
20+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
21+ wv.replayPendingNavigation(browser, 0)
22+
23+ wv.mu.RLock()
24+ defer wv.mu.RUnlock()
25+ require.Equal(t, "https://github.com/bnema", wv.pendingURI)
26+}
27+
28+func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {
29+ browser := cefmocks.NewMockBrowser(t)
30+ browser.EXPECT().GetMainFrame().Return((purecef.Frame)(nil)).Once()
31+
32+ oldTask := cefNewTask
33+ oldDelayed := cefPostDelayedTask
34+ defer func() {
35+ cefNewTask = oldTask
36+ cefPostDelayedTask = oldDelayed
37+ }()
38+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
39+
40+ scheduled := false
41+ cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
42+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
43+ require.NotNil(t, task)
44+ require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs)
45+ scheduled = true
46+ return 1
47+ }
48+
49+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
50+ wv.replayPendingNavigation(browser, 0)
51+
52+ require.True(t, scheduled)
53+ wv.mu.RLock()
54+ defer wv.mu.RUnlock()
55+ require.Equal(t, "https://github.com/bnema", wv.pendingURI)
56+}
57+
58+func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {
59+ browser := cefmocks.NewMockBrowser(t)
60+ frame := cefmocks.NewMockFrame(t)
61+ browser.EXPECT().GetMainFrame().Return(frame).Once()
62+ frame.EXPECT().GetURL().Return("").Once()
63+ frame.EXPECT().LoadURL("github.com/bnema").Once()
64+
65+ oldTask := cefNewTask
66+ oldPost := cefPostTask
67+ defer func() {
68+ cefNewTask = oldTask
69+ cefPostTask = oldPost
70+ }()
71+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
72+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
73+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
74+ require.NotNil(t, task)
75+ task.Execute()
76+ return 1
77+ }
78+
79+ wv := &WebView{ctx: context.Background(), browser: browser}
80+ require.NoError(t, wv.LoadURI(context.Background(), "github.com/bnema"))
81+
82+ wv.mu.RLock()
83+ defer wv.mu.RUnlock()
84+ require.Equal(t, "github.com/bnema", wv.pendingURI)
85+}
86+
87+func TestWebViewUpdateURI_ClearsMatchingPendingNavigation(t *testing.T) {
88+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
89+
90+ wv.updateURI("https://github.com/bnema")
91+
92+ require.Empty(t, wv.pendingNavigationURI())
93+}
M internal/infrastructure/xdg/adapter.go +7 -2
@@ -29,8 +29,13 @@ func (a *Adapter) StateDir() (string, error) {
2929 }
3030
3131 func (a *Adapter) RuntimeDir() (string, error) {
32- if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
33- return dir, nil
32+ // In dev mode, keep runtime IPC isolated inside the .dev sandbox even when
33+ // XDG_RUNTIME_DIR is set. This prevents browser-launch relay collisions
34+ // between the installed app and a repo-local ENV=dev instance.
35+ if os.Getenv("ENV") != "dev" {
36+ if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
37+ return dir, nil
38+ }
3439 }
3540
3641 stateDir, err := a.StateDir()
@@ -29,8 +29,13 @@ func (a *Adapter) StateDir() (string, error) {
29 }29 }
30 30
31 func (a *Adapter) RuntimeDir() (string, error) {31 func (a *Adapter) RuntimeDir() (string, error) {
32- if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {32+ // In dev mode, keep runtime IPC isolated inside the .dev sandbox even when
33- return dir, nil33+ // XDG_RUNTIME_DIR is set. This prevents browser-launch relay collisions
34+ // between the installed app and a repo-local ENV=dev instance.
35+ if os.Getenv("ENV") != "dev" {
36+ if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
37+ return dir, nil
38+ }
34 }39 }
35 40
36 stateDir, err := a.StateDir()41 stateDir, err := a.StateDir()
M internal/infrastructure/xdg/adapter_test.go +21 -1
@@ -39,7 +39,8 @@ func TestAdapter_DownloadDir(t *testing.T) {
3939 func TestAdapter_RuntimeDir(t *testing.T) {
4040 adapter := New()
4141
42- t.Run("returns XDG_RUNTIME_DIR when set", func(t *testing.T) {
42+ t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) {
43+ t.Setenv("ENV", "")
4344 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")
4445
4546 dir, err := adapter.RuntimeDir()
@@ -49,6 +50,7 @@ func TestAdapter_RuntimeDir(t *testing.T) {
4950 })
5051
5152 t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {
53+ t.Setenv("ENV", "")
5254 t.Setenv("XDG_RUNTIME_DIR", "")
5355 t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state")
5456
@@ -57,6 +59,24 @@ func TestAdapter_RuntimeDir(t *testing.T) {
5759 require.NoError(t, err)
5860 assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)
5961 })
62+
63+ t.Run("uses sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) {
64+ wd := t.TempDir()
65+ oldWD, err := os.Getwd()
66+ require.NoError(t, err)
67+ require.NoError(t, os.Chdir(wd))
68+ defer func() {
69+ require.NoError(t, os.Chdir(oldWD))
70+ }()
71+
72+ t.Setenv("ENV", "dev")
73+ t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime")
74+
75+ dir, err := adapter.RuntimeDir()
76+
77+ require.NoError(t, err)
78+ assert.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime"), dir)
79+ })
6080 }
6181
6282 func TestNew(t *testing.T) {
@@ -39,7 +39,8 @@ func TestAdapter_DownloadDir(t *testing.T) {
39 func TestAdapter_RuntimeDir(t *testing.T) {39 func TestAdapter_RuntimeDir(t *testing.T) {
40 adapter := New()40 adapter := New()
41 41
42- t.Run("returns XDG_RUNTIME_DIR when set", func(t *testing.T) {42+ t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) {
43+ t.Setenv("ENV", "")
43 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")44 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")
44 45
45 dir, err := adapter.RuntimeDir()46 dir, err := adapter.RuntimeDir()
@@ -49,6 +50,7 @@ func TestAdapter_RuntimeDir(t *testing.T) {
49 })50 })
50 51
51 t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {52 t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {
53+ t.Setenv("ENV", "")
52 t.Setenv("XDG_RUNTIME_DIR", "")54 t.Setenv("XDG_RUNTIME_DIR", "")
53 t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state")55 t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state")
54 56
@@ -57,6 +59,24 @@ func TestAdapter_RuntimeDir(t *testing.T) {
57 require.NoError(t, err)59 require.NoError(t, err)
58 assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)60 assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)
59 })61 })
62+
63+ t.Run("uses sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) {
64+ wd := t.TempDir()
65+ oldWD, err := os.Getwd()
66+ require.NoError(t, err)
67+ require.NoError(t, os.Chdir(wd))
68+ defer func() {
69+ require.NoError(t, os.Chdir(oldWD))
70+ }()
71+
72+ t.Setenv("ENV", "dev")
73+ t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime")
74+
75+ dir, err := adapter.RuntimeDir()
76+
77+ require.NoError(t, err)
78+ assert.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime"), dir)
79+ })
60 }80 }
61 81
62 func TestNew(t *testing.T) {82 func TestNew(t *testing.T) {
M internal/ui/component/pane_view.go +2 -2
@@ -390,7 +390,7 @@ func (pv *PaneView) SetLoadProgress(progress float64) {
390390 ctx = context.Background()
391391 }
392392 logging.FromContext(ctx).
393- Debug().
393+ Trace().
394394 Str("pane_id", string(pv.paneID)).
395395 Float64("progress", progress).
396396 Msg("setting load progress")
@@ -419,7 +419,7 @@ func (pv *PaneView) SetLoading(loading bool) {
419419 if loading {
420420 pb.Show()
421421 } else {
422- pb.Hide()
422+ pb.HideWithReason("loading_false")
423423 }
424424 }
425425
@@ -390,7 +390,7 @@ func (pv *PaneView) SetLoadProgress(progress float64) {
390 ctx = context.Background()390 ctx = context.Background()
391 }391 }
392 logging.FromContext(ctx).392 logging.FromContext(ctx).
393- Debug().393+ Trace().
394 Str("pane_id", string(pv.paneID)).394 Str("pane_id", string(pv.paneID)).
395 Float64("progress", progress).395 Float64("progress", progress).
396 Msg("setting load progress")396 Msg("setting load progress")
@@ -419,7 +419,7 @@ func (pv *PaneView) SetLoading(loading bool) {
419 if loading {419 if loading {
420 pb.Show()420 pb.Show()
421 } else {421 } else {
422- pb.Hide()422+ pb.HideWithReason("loading_false")
423 }423 }
424 }424 }
425 425
M internal/ui/component/progress_bar.go +59 -9
@@ -4,6 +4,7 @@ package component
44 import (
55 "context"
66 "sync"
7+ "time"
78
89 "github.com/bnema/dumber/internal/logging"
910 "github.com/bnema/puregotk/v4/glib"
@@ -29,11 +30,15 @@ type ProgressBar struct {
2930 ctx context.Context
3031 progressBar layout.ProgressBarWidget
3132
32- visible bool
33- currentValue float64 // Current displayed value
34- targetValue float64 // Target value to animate towards
35- animationTimer uint // Timer source ID for animation
36- timeoutTimer uint // Timer source ID for auto-hide timeout
33+ visible bool
34+ currentValue float64 // Current displayed value
35+ targetValue float64 // Target value to animate towards
36+ animationTimer uint // Timer source ID for animation
37+ timeoutTimer uint // Timer source ID for auto-hide timeout
38+ lastShowAt time.Time
39+ lastProgressAt time.Time
40+ lastHideReason string
41+ progressEventSeq uint64
3742
3843 mu sync.Mutex
3944 }
@@ -85,12 +90,17 @@ func (pb *ProgressBar) SetProgress(progress float64) {
8590 if ctx == nil {
8691 ctx = context.Background()
8792 }
93+ now := time.Now()
8894 logging.FromContext(ctx).
89- Debug().
95+ Trace().
9096 Float64("incoming_progress", incomingProgress).
9197 Float64("current_value", pb.currentValue).
9298 Float64("target_value", pb.targetValue).
9399 Bool("visible", pb.visible).
100+ Bool("animation_running", pb.animationTimer != 0).
101+ Bool("timeout_running", pb.timeoutTimer != 0).
102+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
103+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
94104 Msg("setting progress")
95105
96106 // Clamp progress to valid range
@@ -101,6 +111,8 @@ func (pb *ProgressBar) SetProgress(progress float64) {
101111 }
102112
103113 pb.targetValue = progress
114+ pb.lastProgressAt = now
115+ pb.progressEventSeq++
104116
105117 // For large jumps (>0.3) or completion, update immediately
106118 diff := progress - pb.currentValue
@@ -146,6 +158,12 @@ func (pb *ProgressBar) startAnimation() {
146158 })
147159
148160 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
161+ logging.FromContext(pb.ctx).
162+ Trace().
163+ Uint64("progress_event_seq", pb.progressEventSeq).
164+ Float64("current_value", pb.currentValue).
165+ Float64("target_value", pb.targetValue).
166+ Msg("progress bar animation started")
149167 }
150168
151169 // initialProgressFraction is the fraction set when the progress bar first
@@ -172,6 +190,8 @@ func (pb *ProgressBar) Show() {
172190
173191 if !pb.visible {
174192 pb.visible = true
193+ pb.lastShowAt = time.Now()
194+ pb.lastHideReason = ""
175195 // Set an initial non-zero fraction so the bar is visually noticeable
176196 // immediately. Without this, the bar is technically visible but
177197 // renders as empty (0% fill) until the first progress callback,
@@ -207,11 +227,20 @@ func (pb *ProgressBar) resetTimeout() {
207227 pb.mu.Lock()
208228 defer pb.mu.Unlock()
209229
230+ now := time.Now()
231+ logging.FromContext(pb.ctx).
232+ Debug().
233+ Float64("current_value", pb.currentValue).
234+ Float64("target_value", pb.targetValue).
235+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
236+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
237+ Msg("progress bar timeout fired")
238+
210239 // Clear timer ID (timer is being removed)
211240 pb.timeoutTimer = 0
212241
213242 // Hide the progress bar inline to avoid race condition
214- pb.hideInternal()
243+ pb.hideInternal("timeout")
215244 return false // Don't repeat
216245 })
217246
@@ -220,26 +249,40 @@ func (pb *ProgressBar) resetTimeout() {
220249
221250 // Hide makes the progress bar invisible and resets state.
222251 func (pb *ProgressBar) Hide() {
252+ pb.HideWithReason("manual")
253+}
254+
255+// HideWithReason makes the progress bar invisible and records why it was hidden.
256+func (pb *ProgressBar) HideWithReason(reason string) {
223257 pb.mu.Lock()
224258 defer pb.mu.Unlock()
225259
226- pb.hideInternal()
260+ pb.hideInternal(reason)
227261 }
228262
229263 // hideInternal performs the actual hide operation.
230264 // Must be called with lock held.
231-func (pb *ProgressBar) hideInternal() {
265+func (pb *ProgressBar) hideInternal(reason string) {
232266 ctx := pb.ctx
233267 if ctx == nil {
234268 ctx = context.Background()
235269 }
270+ now := time.Now()
236271 logging.FromContext(ctx).
237272 Debug().
238273 Bool("visible", pb.visible).
274+ Str("reason", reason).
275+ Float64("current_value", pb.currentValue).
276+ Float64("target_value", pb.targetValue).
277+ Bool("animation_running", pb.animationTimer != 0).
278+ Bool("timeout_running", pb.timeoutTimer != 0).
279+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
280+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
239281 Msg("progress bar hide before")
240282
241283 if pb.visible {
242284 pb.visible = false
285+ pb.lastHideReason = reason
243286 pb.progressBar.SetVisible(false)
244287
245288 // Stop any running animation
@@ -261,6 +304,13 @@ func (pb *ProgressBar) hideInternal() {
261304 }
262305 }
263306
307+func sinceTimeMs(start, now time.Time) int64 {
308+ if start.IsZero() {
309+ return -1
310+ }
311+ return now.Sub(start).Milliseconds()
312+}
313+
264314 // IsVisible returns whether the progress bar is currently visible.
265315 func (pb *ProgressBar) IsVisible() bool {
266316 pb.mu.Lock()
@@ -4,6 +4,7 @@ package component
4 import (4 import (
5 "context"5 "context"
6 "sync"6 "sync"
7+ "time"
7 8
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/puregotk/v4/glib"10 "github.com/bnema/puregotk/v4/glib"
@@ -29,11 +30,15 @@ type ProgressBar struct {
29 ctx context.Context30 ctx context.Context
30 progressBar layout.ProgressBarWidget31 progressBar layout.ProgressBarWidget
31 32
32- visible bool33+ visible bool
33- currentValue float64 // Current displayed value34+ currentValue float64 // Current displayed value
34- targetValue float64 // Target value to animate towards35+ targetValue float64 // Target value to animate towards
35- animationTimer uint // Timer source ID for animation36+ animationTimer uint // Timer source ID for animation
36- timeoutTimer uint // Timer source ID for auto-hide timeout37+ timeoutTimer uint // Timer source ID for auto-hide timeout
38+ lastShowAt time.Time
39+ lastProgressAt time.Time
40+ lastHideReason string
41+ progressEventSeq uint64
37 42
38 mu sync.Mutex43 mu sync.Mutex
39 }44 }
@@ -85,12 +90,17 @@ func (pb *ProgressBar) SetProgress(progress float64) {
85 if ctx == nil {90 if ctx == nil {
86 ctx = context.Background()91 ctx = context.Background()
87 }92 }
93+ now := time.Now()
88 logging.FromContext(ctx).94 logging.FromContext(ctx).
89- Debug().95+ Trace().
90 Float64("incoming_progress", incomingProgress).96 Float64("incoming_progress", incomingProgress).
91 Float64("current_value", pb.currentValue).97 Float64("current_value", pb.currentValue).
92 Float64("target_value", pb.targetValue).98 Float64("target_value", pb.targetValue).
93 Bool("visible", pb.visible).99 Bool("visible", pb.visible).
100+ Bool("animation_running", pb.animationTimer != 0).
101+ Bool("timeout_running", pb.timeoutTimer != 0).
102+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
103+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
94 Msg("setting progress")104 Msg("setting progress")
95 105
96 // Clamp progress to valid range106 // Clamp progress to valid range
@@ -101,6 +111,8 @@ func (pb *ProgressBar) SetProgress(progress float64) {
101 }111 }
102 112
103 pb.targetValue = progress113 pb.targetValue = progress
114+ pb.lastProgressAt = now
115+ pb.progressEventSeq++
104 116
105 // For large jumps (>0.3) or completion, update immediately117 // For large jumps (>0.3) or completion, update immediately
106 diff := progress - pb.currentValue118 diff := progress - pb.currentValue
@@ -146,6 +158,12 @@ func (pb *ProgressBar) startAnimation() {
146 })158 })
147 159
148 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)160 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
161+ logging.FromContext(pb.ctx).
162+ Trace().
163+ Uint64("progress_event_seq", pb.progressEventSeq).
164+ Float64("current_value", pb.currentValue).
165+ Float64("target_value", pb.targetValue).
166+ Msg("progress bar animation started")
149 }167 }
150 168
151 // initialProgressFraction is the fraction set when the progress bar first169 // initialProgressFraction is the fraction set when the progress bar first
@@ -172,6 +190,8 @@ func (pb *ProgressBar) Show() {
172 190
173 if !pb.visible {191 if !pb.visible {
174 pb.visible = true192 pb.visible = true
193+ pb.lastShowAt = time.Now()
194+ pb.lastHideReason = ""
175 // Set an initial non-zero fraction so the bar is visually noticeable195 // Set an initial non-zero fraction so the bar is visually noticeable
176 // immediately. Without this, the bar is technically visible but196 // immediately. Without this, the bar is technically visible but
177 // renders as empty (0% fill) until the first progress callback,197 // renders as empty (0% fill) until the first progress callback,
@@ -207,11 +227,20 @@ func (pb *ProgressBar) resetTimeout() {
207 pb.mu.Lock()227 pb.mu.Lock()
208 defer pb.mu.Unlock()228 defer pb.mu.Unlock()
209 229
230+ now := time.Now()
231+ logging.FromContext(pb.ctx).
232+ Debug().
233+ Float64("current_value", pb.currentValue).
234+ Float64("target_value", pb.targetValue).
235+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
236+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
237+ Msg("progress bar timeout fired")
238+
210 // Clear timer ID (timer is being removed)239 // Clear timer ID (timer is being removed)
211 pb.timeoutTimer = 0240 pb.timeoutTimer = 0
212 241
213 // Hide the progress bar inline to avoid race condition242 // Hide the progress bar inline to avoid race condition
214- pb.hideInternal()243+ pb.hideInternal("timeout")
215 return false // Don't repeat244 return false // Don't repeat
216 })245 })
217 246
@@ -220,26 +249,40 @@ func (pb *ProgressBar) resetTimeout() {
220 249
221 // Hide makes the progress bar invisible and resets state.250 // Hide makes the progress bar invisible and resets state.
222 func (pb *ProgressBar) Hide() {251 func (pb *ProgressBar) Hide() {
252+ pb.HideWithReason("manual")
253+}
254+
255+// HideWithReason makes the progress bar invisible and records why it was hidden.
256+func (pb *ProgressBar) HideWithReason(reason string) {
223 pb.mu.Lock()257 pb.mu.Lock()
224 defer pb.mu.Unlock()258 defer pb.mu.Unlock()
225 259
226- pb.hideInternal()260+ pb.hideInternal(reason)
227 }261 }
228 262
229 // hideInternal performs the actual hide operation.263 // hideInternal performs the actual hide operation.
230 // Must be called with lock held.264 // Must be called with lock held.
231-func (pb *ProgressBar) hideInternal() {265+func (pb *ProgressBar) hideInternal(reason string) {
232 ctx := pb.ctx266 ctx := pb.ctx
233 if ctx == nil {267 if ctx == nil {
234 ctx = context.Background()268 ctx = context.Background()
235 }269 }
270+ now := time.Now()
236 logging.FromContext(ctx).271 logging.FromContext(ctx).
237 Debug().272 Debug().
238 Bool("visible", pb.visible).273 Bool("visible", pb.visible).
274+ Str("reason", reason).
275+ Float64("current_value", pb.currentValue).
276+ Float64("target_value", pb.targetValue).
277+ Bool("animation_running", pb.animationTimer != 0).
278+ Bool("timeout_running", pb.timeoutTimer != 0).
279+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
280+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
239 Msg("progress bar hide before")281 Msg("progress bar hide before")
240 282
241 if pb.visible {283 if pb.visible {
242 pb.visible = false284 pb.visible = false
285+ pb.lastHideReason = reason
243 pb.progressBar.SetVisible(false)286 pb.progressBar.SetVisible(false)
244 287
245 // Stop any running animation288 // Stop any running animation
@@ -261,6 +304,13 @@ func (pb *ProgressBar) hideInternal() {
261 }304 }
262 }305 }
263 306
307+func sinceTimeMs(start, now time.Time) int64 {
308+ if start.IsZero() {
309+ return -1
310+ }
311+ return now.Sub(start).Milliseconds()
312+}
313+
264 // IsVisible returns whether the progress bar is currently visible.314 // IsVisible returns whether the progress bar is currently visible.
265 func (pb *ProgressBar) IsVisible() bool {315 func (pb *ProgressBar) IsVisible() bool {
266 pb.mu.Lock()316 pb.mu.Lock()

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