detached 013796f

Centralize CEF viewport sync after layout changes

commit
013796f86100705063c46df78dfc55498eee0e07
author
brice · · 2026-05-11 05:15 UTC
parent
5714070
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +430 -24
M internal/application/port/webview.go +10 -0
@@ -336,6 +336,16 @@ type PopupOpenerCapable interface {
336336 HasActivePopupOpenerBridge() bool
337337 }
338338
339+// ViewportSyncCapable is implemented by WebViews that can explicitly resync
340+// their internal viewport state after UI lifecycle events such as reparenting,
341+// visibility toggles, or layout promotion that may not emit a size change.
342+//
343+// Implementations should make this cheap, safe to call often, and resilient to
344+// duplicate requests.
345+type ViewportSyncCapable interface {
346+ SyncViewport(ctx context.Context, reason string)
347+}
348+
339349 // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.
340350 // It is an optional capability; callers should type-assert port.WebView to this interface
341351 // before use and degrade gracefully when it is absent.
@@ -336,6 +336,16 @@ type PopupOpenerCapable interface {
336 HasActivePopupOpenerBridge() bool336 HasActivePopupOpenerBridge() bool
337 }337 }
338 338
339+// ViewportSyncCapable is implemented by WebViews that can explicitly resync
340+// their internal viewport state after UI lifecycle events such as reparenting,
341+// visibility toggles, or layout promotion that may not emit a size change.
342+//
343+// Implementations should make this cheap, safe to call often, and resilient to
344+// duplicate requests.
345+type ViewportSyncCapable interface {
346+ SyncViewport(ctx context.Context, reason string)
347+}
348+
339 // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.349 // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.
340 // It is an optional capability; callers should type-assert port.WebView to this interface350 // It is an optional capability; callers should type-assert port.WebView to this interface
341 // before use and degrade gracefully when it is absent.351 // before use and degrade gracefully when it is absent.
M internal/infrastructure/cef/factory.go +2 -0
@@ -113,6 +113,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
113113 id: id,
114114 ctx: ctx,
115115 engine: f.engine,
116+ factory: f,
116117 viewBridge: viewBridge,
117118 audioOutputFactory: f.audioOutputFactory,
118119 windowlessFrameRate: f.windowlessFrameRate,
@@ -131,6 +132,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
131132 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
132133 }
133134 }
135+ wv.installViewportSyncHooks()
134136 wv.startRenderStallWatchdog()
135137 return wv, nil
136138 }
@@ -113,6 +113,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
113 id: id,113 id: id,
114 ctx: ctx,114 ctx: ctx,
115 engine: f.engine,115 engine: f.engine,
116+ factory: f,
116 viewBridge: viewBridge,117 viewBridge: viewBridge,
117 audioOutputFactory: f.audioOutputFactory,118 audioOutputFactory: f.audioOutputFactory,
118 windowlessFrameRate: f.windowlessFrameRate,119 windowlessFrameRate: f.windowlessFrameRate,
@@ -131,6 +132,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
131 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")132 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
132 }133 }
133 }134 }
135+ wv.installViewportSyncHooks()
134 wv.startRenderStallWatchdog()136 wv.startRenderStallWatchdog()
135 return wv, nil137 return wv, nil
136 }138 }
M internal/infrastructure/cef/webview.go +13 -7
@@ -29,6 +29,7 @@ var (
2929 _ port.DevToolsOpener = (*WebView)(nil)
3030 _ port.PopupLifecycleCapable = (*WebView)(nil)
3131 _ port.PopupOpenerCapable = (*WebView)(nil)
32+ _ port.ViewportSyncCapable = (*WebView)(nil)
3233 _ port.OAuthCallbackCapable = (*WebView)(nil)
3334 )
3435
@@ -72,6 +73,7 @@ type WebView struct {
7273 id port.WebViewID
7374 ctx context.Context
7475 engine *Engine
76+ factory *WebViewFactory
7577 browser purecef.Browser
7678 host purecef.BrowserHost
7779 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
@@ -88,7 +90,13 @@ type WebView struct {
8890 previousProfileSnapshot cef2gtk.ProfileSnapshot
8991 latestProfileSnapshotAt time.Time
9092
91- removeSizeObserver func()
93+ removeSizeObserver func()
94+ viewportSyncMu sync.Mutex
95+ viewportSyncPending bool
96+ viewportSyncReason string
97+ viewportMapFunc func(gtk.Widget)
98+ viewportShowFunc func(gtk.Widget)
99+ viewportRealizeFunc func(gtk.Widget)
92100
93101 // beginFrameTick drives CEF external BeginFrame requests while the GTK
94102 // widget is visible. Access is guarded by mu.
@@ -541,12 +549,7 @@ func (wv *WebView) Show() {
541549 if wv == nil || wv.destroyed.Load() {
542550 return
543551 }
544- wv.mu.RLock()
545- host := wv.host
546- wv.mu.RUnlock()
547- if host != nil {
548- host.WasHidden(0)
549- }
552+ wv.SyncViewport(wv.ctx, "popup-show")
550553 }
551554
552555 // PrimePopupNavigation implements port.PopupLifecycleCapable.
@@ -1081,6 +1084,9 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
10811084 wv.removeSizeObserver()
10821085 wv.removeSizeObserver = nil
10831086 }
1087+ wv.viewportMapFunc = nil
1088+ wv.viewportShowFunc = nil
1089+ wv.viewportRealizeFunc = nil
10841090 _ = wv.viewBridge.DetachInput()
10851091 _ = wv.viewBridge.Destroy()
10861092 wv.viewBridge = nil
@@ -29,6 +29,7 @@ var (
29 _ port.DevToolsOpener = (*WebView)(nil)29 _ port.DevToolsOpener = (*WebView)(nil)
30 _ port.PopupLifecycleCapable = (*WebView)(nil)30 _ port.PopupLifecycleCapable = (*WebView)(nil)
31 _ port.PopupOpenerCapable = (*WebView)(nil)31 _ port.PopupOpenerCapable = (*WebView)(nil)
32+ _ port.ViewportSyncCapable = (*WebView)(nil)
32 _ port.OAuthCallbackCapable = (*WebView)(nil)33 _ port.OAuthCallbackCapable = (*WebView)(nil)
33 )34 )
34 35
@@ -72,6 +73,7 @@ type WebView struct {
72 id port.WebViewID73 id port.WebViewID
73 ctx context.Context74 ctx context.Context
74 engine *Engine75 engine *Engine
76+ factory *WebViewFactory
75 browser purecef.Browser77 browser purecef.Browser
76 host purecef.BrowserHost78 host purecef.BrowserHost
77 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it79 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
@@ -88,7 +90,13 @@ type WebView struct {
88 previousProfileSnapshot cef2gtk.ProfileSnapshot90 previousProfileSnapshot cef2gtk.ProfileSnapshot
89 latestProfileSnapshotAt time.Time91 latestProfileSnapshotAt time.Time
90 92
91- removeSizeObserver func()93+ removeSizeObserver func()
94+ viewportSyncMu sync.Mutex
95+ viewportSyncPending bool
96+ viewportSyncReason string
97+ viewportMapFunc func(gtk.Widget)
98+ viewportShowFunc func(gtk.Widget)
99+ viewportRealizeFunc func(gtk.Widget)
92 100
93 // beginFrameTick drives CEF external BeginFrame requests while the GTK101 // beginFrameTick drives CEF external BeginFrame requests while the GTK
94 // widget is visible. Access is guarded by mu.102 // widget is visible. Access is guarded by mu.
@@ -541,12 +549,7 @@ func (wv *WebView) Show() {
541 if wv == nil || wv.destroyed.Load() {549 if wv == nil || wv.destroyed.Load() {
542 return550 return
543 }551 }
544- wv.mu.RLock()552+ wv.SyncViewport(wv.ctx, "popup-show")
545- host := wv.host
546- wv.mu.RUnlock()
547- if host != nil {
548- host.WasHidden(0)
549- }
550 }553 }
551 554
552 // PrimePopupNavigation implements port.PopupLifecycleCapable.555 // PrimePopupNavigation implements port.PopupLifecycleCapable.
@@ -1081,6 +1084,9 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
1081 wv.removeSizeObserver()1084 wv.removeSizeObserver()
1082 wv.removeSizeObserver = nil1085 wv.removeSizeObserver = nil
1083 }1086 }
1087+ wv.viewportMapFunc = nil
1088+ wv.viewportShowFunc = nil
1089+ wv.viewportRealizeFunc = nil
1084 _ = wv.viewBridge.DetachInput()1090 _ = wv.viewBridge.DetachInput()
1085 _ = wv.viewBridge.Destroy()1091 _ = wv.viewBridge.Destroy()
1086 wv.viewBridge = nil1092 wv.viewBridge = nil
A internal/infrastructure/cef/webview_viewport.go +196 -0
@@ -0,0 +1,196 @@
1+package cef
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/puregotk/v4/gtk"
9+)
10+
11+type viewportSyncBrowserHost interface {
12+ resizeNotifiableBrowserHost
13+ NotifyScreenInfoChanged()
14+ WasHidden(int32)
15+}
16+
17+// SyncViewport requests an explicit viewport resync for CEF OSR browsers.
18+//
19+// This is a hardening path for UI lifecycle transitions that may not emit a
20+// fresh size change (reparenting, stacked visibility toggles, floating pane
21+// updates, popup show, sibling promotion after close, etc.). Calls are
22+// coalesced and executed on the GTK thread before notifying the CEF host.
23+func (wv *WebView) SyncViewport(ctx context.Context, reason string) {
24+ if wv == nil || wv.destroyed.Load() {
25+ return
26+ }
27+ reason = strings.TrimSpace(reason)
28+ if reason == "" {
29+ reason = "manual"
30+ }
31+
32+ wv.viewportSyncMu.Lock()
33+ wv.viewportSyncReason = reason
34+ if wv.viewportSyncPending {
35+ wv.viewportSyncMu.Unlock()
36+ return
37+ }
38+ wv.viewportSyncPending = true
39+ wv.viewportSyncMu.Unlock()
40+
41+ wv.runOnGTK(func() {
42+ wv.syncViewportOnGTK(wv.viewportSyncContext(ctx))
43+ })
44+}
45+
46+func (wv *WebView) viewportSyncContext(ctx context.Context) context.Context {
47+ if ctx != nil {
48+ return ctx
49+ }
50+ if wv != nil && wv.ctx != nil {
51+ return wv.ctx
52+ }
53+ return context.Background()
54+}
55+
56+func (wv *WebView) takeViewportSyncReason() string {
57+ wv.viewportSyncMu.Lock()
58+ defer wv.viewportSyncMu.Unlock()
59+ reason := wv.viewportSyncReason
60+ wv.viewportSyncReason = ""
61+ wv.viewportSyncPending = false
62+ return reason
63+}
64+
65+func (wv *WebView) syncViewportOnGTK(ctx context.Context) {
66+ if wv == nil {
67+ return
68+ }
69+ reason := wv.takeViewportSyncReason()
70+ if wv.destroyed.Load() || wv.viewBridge == nil {
71+ return
72+ }
73+
74+ if wv.tryStartPendingBrowserCreateOnGTKThread(ctx, reason) {
75+ return
76+ }
77+
78+ wv.mu.RLock()
79+ host := wv.host
80+ wv.mu.RUnlock()
81+ if host == nil {
82+ logging.FromContext(ctx).Debug().
83+ Uint64("webview_id", uint64(wv.id)).
84+ Str("reason", reason).
85+ Msg("cef: viewport sync skipped; browser host not ready")
86+ return
87+ }
88+
89+ widget := wv.viewBridge.Widget()
90+ visible := widget != nil && widget.IsVisible()
91+ notifyBrowserViewportSync(host, visible)
92+ logging.FromContext(ctx).Debug().
93+ Uint64("webview_id", uint64(wv.id)).
94+ Bool("visible", visible).
95+ Str("reason", reason).
96+ Msg("cef: viewport sync requested")
97+}
98+
99+func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool {
100+ if wv == nil || wv.destroyed.Load() || wv.factory == nil || wv.viewBridge == nil {
101+ return false
102+ }
103+
104+ wv.mu.RLock()
105+ hasHost := wv.host != nil
106+ pending := wv.pendingCreate != nil
107+ wv.mu.RUnlock()
108+ if hasHost || !pending {
109+ return false
110+ }
111+
112+ widget := wv.viewBridge.Widget()
113+ if widget == nil {
114+ return false
115+ }
116+ width := int32(widget.GetAllocatedWidth())
117+ height := int32(widget.GetAllocatedHeight())
118+ if width <= 0 {
119+ width = int32(widget.GetWidth())
120+ }
121+ if height <= 0 {
122+ height = int32(widget.GetHeight())
123+ }
124+ if width <= 0 || height <= 0 {
125+ logging.FromContext(ctx).Debug().
126+ Uint64("webview_id", uint64(wv.id)).
127+ Int32("width", width).
128+ Int32("height", height).
129+ Str("reason", reason).
130+ Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")
131+ return false
132+ }
133+ if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
134+ logging.FromContext(ctx).Debug().
135+ Err(err).
136+ Uint64("webview_id", uint64(wv.id)).
137+ Int32("width", width).
138+ Int32("height", height).
139+ Str("reason", reason).
140+ Msg("cef: viewport sync could not prepare bridge for pending browser creation")
141+ return false
142+ }
143+
144+ wv.factory.postPendingBrowserCreate(ctx, wv, width, height)
145+ logging.FromContext(ctx).Debug().
146+ Uint64("webview_id", uint64(wv.id)).
147+ Int32("width", width).
148+ Int32("height", height).
149+ Str("reason", reason).
150+ Msg("cef: viewport sync nudged pending browser creation")
151+ return true
152+}
153+
154+func (wv *WebView) installViewportSyncHooks() {
155+ if wv == nil || wv.viewBridge == nil {
156+ return
157+ }
158+ wv.runOnGTKSync(func() {
159+ wv.installViewportSyncHooksOnGTKThread()
160+ })
161+}
162+
163+func (wv *WebView) installViewportSyncHooksOnGTKThread() {
164+ if wv == nil || wv.viewBridge == nil || wv.viewportMapFunc != nil {
165+ return
166+ }
167+ widget := wv.viewBridge.Widget()
168+ if widget == nil {
169+ return
170+ }
171+
172+ wv.viewportMapFunc = func(gtk.Widget) {
173+ wv.SyncViewport(wv.ctx, "gtk-map")
174+ }
175+ wv.viewportShowFunc = func(gtk.Widget) {
176+ wv.SyncViewport(wv.ctx, "gtk-show")
177+ }
178+ wv.viewportRealizeFunc = func(gtk.Widget) {
179+ wv.SyncViewport(wv.ctx, "gtk-realize")
180+ }
181+
182+ widget.ConnectMap(&wv.viewportMapFunc)
183+ widget.ConnectShow(&wv.viewportShowFunc)
184+ widget.ConnectRealize(&wv.viewportRealizeFunc)
185+}
186+
187+func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
188+ if host == nil {
189+ return
190+ }
191+ if visible {
192+ host.WasHidden(0)
193+ }
194+ host.NotifyScreenInfoChanged()
195+ notifyBrowserResize(host)
196+}
@@ -0,0 +1,196 @@
1+package cef
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/puregotk/v4/gtk"
9+)
10+
11+type viewportSyncBrowserHost interface {
12+ resizeNotifiableBrowserHost
13+ NotifyScreenInfoChanged()
14+ WasHidden(int32)
15+}
16+
17+// SyncViewport requests an explicit viewport resync for CEF OSR browsers.
18+//
19+// This is a hardening path for UI lifecycle transitions that may not emit a
20+// fresh size change (reparenting, stacked visibility toggles, floating pane
21+// updates, popup show, sibling promotion after close, etc.). Calls are
22+// coalesced and executed on the GTK thread before notifying the CEF host.
23+func (wv *WebView) SyncViewport(ctx context.Context, reason string) {
24+ if wv == nil || wv.destroyed.Load() {
25+ return
26+ }
27+ reason = strings.TrimSpace(reason)
28+ if reason == "" {
29+ reason = "manual"
30+ }
31+
32+ wv.viewportSyncMu.Lock()
33+ wv.viewportSyncReason = reason
34+ if wv.viewportSyncPending {
35+ wv.viewportSyncMu.Unlock()
36+ return
37+ }
38+ wv.viewportSyncPending = true
39+ wv.viewportSyncMu.Unlock()
40+
41+ wv.runOnGTK(func() {
42+ wv.syncViewportOnGTK(wv.viewportSyncContext(ctx))
43+ })
44+}
45+
46+func (wv *WebView) viewportSyncContext(ctx context.Context) context.Context {
47+ if ctx != nil {
48+ return ctx
49+ }
50+ if wv != nil && wv.ctx != nil {
51+ return wv.ctx
52+ }
53+ return context.Background()
54+}
55+
56+func (wv *WebView) takeViewportSyncReason() string {
57+ wv.viewportSyncMu.Lock()
58+ defer wv.viewportSyncMu.Unlock()
59+ reason := wv.viewportSyncReason
60+ wv.viewportSyncReason = ""
61+ wv.viewportSyncPending = false
62+ return reason
63+}
64+
65+func (wv *WebView) syncViewportOnGTK(ctx context.Context) {
66+ if wv == nil {
67+ return
68+ }
69+ reason := wv.takeViewportSyncReason()
70+ if wv.destroyed.Load() || wv.viewBridge == nil {
71+ return
72+ }
73+
74+ if wv.tryStartPendingBrowserCreateOnGTKThread(ctx, reason) {
75+ return
76+ }
77+
78+ wv.mu.RLock()
79+ host := wv.host
80+ wv.mu.RUnlock()
81+ if host == nil {
82+ logging.FromContext(ctx).Debug().
83+ Uint64("webview_id", uint64(wv.id)).
84+ Str("reason", reason).
85+ Msg("cef: viewport sync skipped; browser host not ready")
86+ return
87+ }
88+
89+ widget := wv.viewBridge.Widget()
90+ visible := widget != nil && widget.IsVisible()
91+ notifyBrowserViewportSync(host, visible)
92+ logging.FromContext(ctx).Debug().
93+ Uint64("webview_id", uint64(wv.id)).
94+ Bool("visible", visible).
95+ Str("reason", reason).
96+ Msg("cef: viewport sync requested")
97+}
98+
99+func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool {
100+ if wv == nil || wv.destroyed.Load() || wv.factory == nil || wv.viewBridge == nil {
101+ return false
102+ }
103+
104+ wv.mu.RLock()
105+ hasHost := wv.host != nil
106+ pending := wv.pendingCreate != nil
107+ wv.mu.RUnlock()
108+ if hasHost || !pending {
109+ return false
110+ }
111+
112+ widget := wv.viewBridge.Widget()
113+ if widget == nil {
114+ return false
115+ }
116+ width := int32(widget.GetAllocatedWidth())
117+ height := int32(widget.GetAllocatedHeight())
118+ if width <= 0 {
119+ width = int32(widget.GetWidth())
120+ }
121+ if height <= 0 {
122+ height = int32(widget.GetHeight())
123+ }
124+ if width <= 0 || height <= 0 {
125+ logging.FromContext(ctx).Debug().
126+ Uint64("webview_id", uint64(wv.id)).
127+ Int32("width", width).
128+ Int32("height", height).
129+ Str("reason", reason).
130+ Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")
131+ return false
132+ }
133+ if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
134+ logging.FromContext(ctx).Debug().
135+ Err(err).
136+ Uint64("webview_id", uint64(wv.id)).
137+ Int32("width", width).
138+ Int32("height", height).
139+ Str("reason", reason).
140+ Msg("cef: viewport sync could not prepare bridge for pending browser creation")
141+ return false
142+ }
143+
144+ wv.factory.postPendingBrowserCreate(ctx, wv, width, height)
145+ logging.FromContext(ctx).Debug().
146+ Uint64("webview_id", uint64(wv.id)).
147+ Int32("width", width).
148+ Int32("height", height).
149+ Str("reason", reason).
150+ Msg("cef: viewport sync nudged pending browser creation")
151+ return true
152+}
153+
154+func (wv *WebView) installViewportSyncHooks() {
155+ if wv == nil || wv.viewBridge == nil {
156+ return
157+ }
158+ wv.runOnGTKSync(func() {
159+ wv.installViewportSyncHooksOnGTKThread()
160+ })
161+}
162+
163+func (wv *WebView) installViewportSyncHooksOnGTKThread() {
164+ if wv == nil || wv.viewBridge == nil || wv.viewportMapFunc != nil {
165+ return
166+ }
167+ widget := wv.viewBridge.Widget()
168+ if widget == nil {
169+ return
170+ }
171+
172+ wv.viewportMapFunc = func(gtk.Widget) {
173+ wv.SyncViewport(wv.ctx, "gtk-map")
174+ }
175+ wv.viewportShowFunc = func(gtk.Widget) {
176+ wv.SyncViewport(wv.ctx, "gtk-show")
177+ }
178+ wv.viewportRealizeFunc = func(gtk.Widget) {
179+ wv.SyncViewport(wv.ctx, "gtk-realize")
180+ }
181+
182+ widget.ConnectMap(&wv.viewportMapFunc)
183+ widget.ConnectShow(&wv.viewportShowFunc)
184+ widget.ConnectRealize(&wv.viewportRealizeFunc)
185+}
186+
187+func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
188+ if host == nil {
189+ return
190+ }
191+ if visible {
192+ host.WasHidden(0)
193+ }
194+ host.NotifyScreenInfoChanged()
195+ notifyBrowserResize(host)
196+}
A internal/infrastructure/cef/webview_viewport_test.go +47 -0
@@ -0,0 +1,47 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+type viewportSyncOrderHost struct {
11+ calls []string
12+}
13+
14+func (h *viewportSyncOrderHost) WasHidden(state int32) {
15+ h.calls = append(h.calls, "WasHidden")
16+ if state != 0 {
17+ h.calls = append(h.calls, "WasHidden(nonzero)")
18+ }
19+}
20+
21+func (h *viewportSyncOrderHost) NotifyScreenInfoChanged() {
22+ h.calls = append(h.calls, "NotifyScreenInfoChanged")
23+}
24+
25+func (h *viewportSyncOrderHost) WasResized() {
26+ h.calls = append(h.calls, "WasResized")
27+}
28+
29+func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) {
30+ h.calls = append(h.calls, "Invalidate")
31+}
32+
33+func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) {
34+ host := &viewportSyncOrderHost{}
35+
36+ notifyBrowserViewportSync(host, true)
37+
38+ require.Equal(t, []string{"WasHidden", "NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
39+}
40+
41+func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) {
42+ host := &viewportSyncOrderHost{}
43+
44+ notifyBrowserViewportSync(host, false)
45+
46+ require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
47+}
@@ -0,0 +1,47 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+type viewportSyncOrderHost struct {
11+ calls []string
12+}
13+
14+func (h *viewportSyncOrderHost) WasHidden(state int32) {
15+ h.calls = append(h.calls, "WasHidden")
16+ if state != 0 {
17+ h.calls = append(h.calls, "WasHidden(nonzero)")
18+ }
19+}
20+
21+func (h *viewportSyncOrderHost) NotifyScreenInfoChanged() {
22+ h.calls = append(h.calls, "NotifyScreenInfoChanged")
23+}
24+
25+func (h *viewportSyncOrderHost) WasResized() {
26+ h.calls = append(h.calls, "WasResized")
27+}
28+
29+func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) {
30+ h.calls = append(h.calls, "Invalidate")
31+}
32+
33+func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) {
34+ host := &viewportSyncOrderHost{}
35+
36+ notifyBrowserViewportSync(host, true)
37+
38+ require.Equal(t, []string{"WasHidden", "NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
39+}
40+
41+func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) {
42+ host := &viewportSyncOrderHost{}
43+
44+ notifyBrowserViewportSync(host, false)
45+
46+ require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
47+}
M internal/ui/app.go +21 -5
@@ -3686,6 +3686,18 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
36863686 return
36873687 }
36883688 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
3689+ if a.contentCoord != nil {
3690+ syncCtx := ctx
3691+ if a.deps != nil && a.deps.Ctx != nil {
3692+ syncCtx = a.deps.Ctx
3693+ }
3694+ wsView.SetOnWebViewAttached(func(paneID entity.PaneID) {
3695+ a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-widget-attached")
3696+ })
3697+ wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {
3698+ a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
3699+ })
3700+ }
36893701
36903702 // Set the workspace
36913703 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
@@ -3807,11 +3819,8 @@ func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *en
38073819 // Wrap and attach widget
38083820 widget := a.contentCoord.WrapWidget(ctx, wv)
38093821 if widget != nil {
3810- paneView := wsView.GetPaneView(pane.ID)
3811- if paneView != nil {
3812- paneView.SetWebViewWidget(widget)
3813- } else {
3814- log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3822+ if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
3823+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
38153824 }
38163825 }
38173826 }
@@ -4393,6 +4402,13 @@ func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
43934402 session.widget.SetSizeRequest(width, height)
43944403 session.appliedWidth = width
43954404 session.appliedHeight = height
4405+ if a.contentCoord != nil {
4406+ syncCtx := context.Background()
4407+ if a.deps != nil && a.deps.Ctx != nil {
4408+ syncCtx = a.deps.Ctx
4409+ }
4410+ a.contentCoord.SyncWebViewViewport(syncCtx, session.paneID, "floating-resize")
4411+ }
43964412 }
43974413
43984414 func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
@@ -3686,6 +3686,18 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
3686 return3686 return
3687 }3687 }
3688 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())3688 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
3689+ if a.contentCoord != nil {
3690+ syncCtx := ctx
3691+ if a.deps != nil && a.deps.Ctx != nil {
3692+ syncCtx = a.deps.Ctx
3693+ }
3694+ wsView.SetOnWebViewAttached(func(paneID entity.PaneID) {
3695+ a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-widget-attached")
3696+ })
3697+ wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {
3698+ a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
3699+ })
3700+ }
3689 3701
3690 // Set the workspace3702 // Set the workspace
3691 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {3703 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
@@ -3807,11 +3819,8 @@ func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *en
3807 // Wrap and attach widget3819 // Wrap and attach widget
3808 widget := a.contentCoord.WrapWidget(ctx, wv)3820 widget := a.contentCoord.WrapWidget(ctx, wv)
3809 if widget != nil {3821 if widget != nil {
3810- paneView := wsView.GetPaneView(pane.ID)3822+ if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
3811- if paneView != nil {3823+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3812- paneView.SetWebViewWidget(widget)
3813- } else {
3814- log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3815 }3824 }
3816 }3825 }
3817 }3826 }
@@ -4393,6 +4402,13 @@ func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
4393 session.widget.SetSizeRequest(width, height)4402 session.widget.SetSizeRequest(width, height)
4394 session.appliedWidth = width4403 session.appliedWidth = width
4395 session.appliedHeight = height4404 session.appliedHeight = height
4405+ if a.contentCoord != nil {
4406+ syncCtx := context.Background()
4407+ if a.deps != nil && a.deps.Ctx != nil {
4408+ syncCtx = a.deps.Ctx
4409+ }
4410+ a.contentCoord.SyncWebViewViewport(syncCtx, session.paneID, "floating-resize")
4411+ }
4396 }4412 }
4397 4413
4398 func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {4414 func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
M internal/ui/component/workspace_view.go +33 -3
@@ -51,6 +51,8 @@ type WorkspaceView struct {
5151 paneViews map[entity.PaneID]*PaneView
5252
5353 onPaneFocused func(paneID entity.PaneID)
54+ onActivePaneChanged func(paneID entity.PaneID)
55+ onWebViewAttached func(paneID entity.PaneID)
5456 onSplitRatioDragged func(nodeID string, ratio float64)
5557
5658 // Hover suppression for keyboard navigation (Issue #89)
@@ -231,9 +233,17 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace)
231233 // SetActivePaneID updates which pane is visually marked as active.
232234 func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {
233235 wv.mu.Lock()
234- defer wv.mu.Unlock()
235-
236- return wv.setActivePaneIDInternal(paneID)
236+ currentActiveID := wv.getActivePaneIDInternal()
237+ err := wv.setActivePaneIDInternal(paneID)
238+ callback := wv.onActivePaneChanged
239+ wv.mu.Unlock()
240+ if err != nil {
241+ return err
242+ }
243+ if currentActiveID != paneID && callback != nil {
244+ callback(paneID)
245+ }
246+ return nil
237247 }
238248
239249 // setActivePaneIDInternal updates active pane without locking.
@@ -357,6 +367,22 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
357367 wv.onPaneFocused = fn
358368 }
359369
370+// SetOnActivePaneChanged sets the callback invoked after the active pane changes.
371+func (wv *WorkspaceView) SetOnActivePaneChanged(fn func(paneID entity.PaneID)) {
372+ wv.mu.Lock()
373+ defer wv.mu.Unlock()
374+
375+ wv.onActivePaneChanged = fn
376+}
377+
378+// SetOnWebViewAttached sets the callback invoked after a pane receives a WebView widget.
379+func (wv *WorkspaceView) SetOnWebViewAttached(fn func(paneID entity.PaneID)) {
380+ wv.mu.Lock()
381+ defer wv.mu.Unlock()
382+
383+ wv.onWebViewAttached = fn
384+}
385+
360386 func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
361387 wv.mu.Lock()
362388 defer wv.mu.Unlock()
@@ -408,6 +434,7 @@ func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
408434 func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
409435 wv.mu.RLock()
410436 pv, ok := wv.paneViews[paneID]
437+ callback := wv.onWebViewAttached
411438 wv.mu.RUnlock()
412439
413440 if !ok {
@@ -415,6 +442,9 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi
415442 }
416443
417444 pv.SetWebViewWidget(widget)
445+ if widget != nil && callback != nil {
446+ callback(paneID)
447+ }
418448 return nil
419449 }
420450
@@ -51,6 +51,8 @@ type WorkspaceView struct {
51 paneViews map[entity.PaneID]*PaneView51 paneViews map[entity.PaneID]*PaneView
52 52
53 onPaneFocused func(paneID entity.PaneID)53 onPaneFocused func(paneID entity.PaneID)
54+ onActivePaneChanged func(paneID entity.PaneID)
55+ onWebViewAttached func(paneID entity.PaneID)
54 onSplitRatioDragged func(nodeID string, ratio float64)56 onSplitRatioDragged func(nodeID string, ratio float64)
55 57
56 // Hover suppression for keyboard navigation (Issue #89)58 // Hover suppression for keyboard navigation (Issue #89)
@@ -231,9 +233,17 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace)
231 // SetActivePaneID updates which pane is visually marked as active.233 // SetActivePaneID updates which pane is visually marked as active.
232 func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {234 func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {
233 wv.mu.Lock()235 wv.mu.Lock()
234- defer wv.mu.Unlock()236+ currentActiveID := wv.getActivePaneIDInternal()
235-237+ err := wv.setActivePaneIDInternal(paneID)
236- return wv.setActivePaneIDInternal(paneID)238+ callback := wv.onActivePaneChanged
239+ wv.mu.Unlock()
240+ if err != nil {
241+ return err
242+ }
243+ if currentActiveID != paneID && callback != nil {
244+ callback(paneID)
245+ }
246+ return nil
237 }247 }
238 248
239 // setActivePaneIDInternal updates active pane without locking.249 // setActivePaneIDInternal updates active pane without locking.
@@ -357,6 +367,22 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
357 wv.onPaneFocused = fn367 wv.onPaneFocused = fn
358 }368 }
359 369
370+// SetOnActivePaneChanged sets the callback invoked after the active pane changes.
371+func (wv *WorkspaceView) SetOnActivePaneChanged(fn func(paneID entity.PaneID)) {
372+ wv.mu.Lock()
373+ defer wv.mu.Unlock()
374+
375+ wv.onActivePaneChanged = fn
376+}
377+
378+// SetOnWebViewAttached sets the callback invoked after a pane receives a WebView widget.
379+func (wv *WorkspaceView) SetOnWebViewAttached(fn func(paneID entity.PaneID)) {
380+ wv.mu.Lock()
381+ defer wv.mu.Unlock()
382+
383+ wv.onWebViewAttached = fn
384+}
385+
360 func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {386 func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
361 wv.mu.Lock()387 wv.mu.Lock()
362 defer wv.mu.Unlock()388 defer wv.mu.Unlock()
@@ -408,6 +434,7 @@ func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
408 func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {434 func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
409 wv.mu.RLock()435 wv.mu.RLock()
410 pv, ok := wv.paneViews[paneID]436 pv, ok := wv.paneViews[paneID]
437+ callback := wv.onWebViewAttached
411 wv.mu.RUnlock()438 wv.mu.RUnlock()
412 439
413 if !ok {440 if !ok {
@@ -415,6 +442,9 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi
415 }442 }
416 443
417 pv.SetWebViewWidget(widget)444 pv.SetWebViewWidget(widget)
445+ if widget != nil && callback != nil {
446+ callback(paneID)
447+ }
418 return nil448 return nil
419 }449 }
420 450
M internal/ui/component/workspace_view_test.go +36 -1
@@ -128,7 +128,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
128128 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
129129
130130 // GtkWidget is called when attaching hover handler - return nil for tests
131- mockOverlay.EXPECT().GtkWidget().Return(nil).Once()
131+ mockOverlay.EXPECT().GtkWidget().Return(nil).Maybe()
132132
133133 return mockOverlay, mockBorderBox
134134 }
@@ -415,6 +415,10 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
415415 // Now change active pane
416416 mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once()
417417 mockBorderBox2.EXPECT().AddCssClass("pane-active").Once()
418+ activatedPane := entity.PaneID("")
419+ wv.SetOnActivePaneChanged(func(paneID entity.PaneID) {
420+ activatedPane = paneID
421+ })
418422
419423 // Act
420424 err = wv.SetActivePaneID(pane2.ID)
@@ -422,6 +426,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
422426 // Assert
423427 require.NoError(t, err)
424428 assert.Equal(t, pane2.ID, wv.GetActivePaneID())
429+ assert.Equal(t, pane2.ID, activatedPane)
425430 }
426431
427432 func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
@@ -437,3 +442,33 @@ func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
437442 // Assert
438443 assert.ErrorIs(t, err, component.ErrPaneNotFound)
439444 }
445+
446+func TestSetWebViewWidget_InvokesAttachedCallback(t *testing.T) {
447+ mockFactory := mocks.NewMockWidgetFactory(t)
448+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
449+
450+ wv := component.NewWorkspaceView(ctx, mockFactory)
451+ mockOverlay, _ := setupWorkspacePaneViewMocks(t, mockFactory)
452+ pv := component.NewPaneView(ctx, mockFactory, entity.PaneID("pane-1"), nil)
453+ mockBox.EXPECT().AddCssClass("single-pane").Once()
454+ wv.RegisterPaneView(entity.PaneID("pane-1"), pv)
455+
456+ attachedPane := entity.PaneID("")
457+ wv.SetOnWebViewAttached(func(paneID entity.PaneID) {
458+ attachedPane = paneID
459+ })
460+
461+ mockWebViewWidget := mocks.NewMockWidget(t)
462+ mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
463+ mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
464+ mockWebViewWidget.EXPECT().GetParent().Return(nil).Once()
465+ mockWebViewWidget.EXPECT().IsVisible().Return(false).Once()
466+ mockWebViewWidget.EXPECT().GetAllocatedWidth().Return(0).Twice()
467+ mockWebViewWidget.EXPECT().GetAllocatedHeight().Return(0).Twice()
468+ mockOverlay.EXPECT().SetChild(mockWebViewWidget).Once()
469+
470+ err := wv.SetWebViewWidget(entity.PaneID("pane-1"), mockWebViewWidget)
471+
472+ require.NoError(t, err)
473+ assert.Equal(t, entity.PaneID("pane-1"), attachedPane)
474+}
@@ -128,7 +128,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
128 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()128 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
129 129
130 // GtkWidget is called when attaching hover handler - return nil for tests130 // GtkWidget is called when attaching hover handler - return nil for tests
131- mockOverlay.EXPECT().GtkWidget().Return(nil).Once()131+ mockOverlay.EXPECT().GtkWidget().Return(nil).Maybe()
132 132
133 return mockOverlay, mockBorderBox133 return mockOverlay, mockBorderBox
134 }134 }
@@ -415,6 +415,10 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
415 // Now change active pane415 // Now change active pane
416 mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once()416 mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once()
417 mockBorderBox2.EXPECT().AddCssClass("pane-active").Once()417 mockBorderBox2.EXPECT().AddCssClass("pane-active").Once()
418+ activatedPane := entity.PaneID("")
419+ wv.SetOnActivePaneChanged(func(paneID entity.PaneID) {
420+ activatedPane = paneID
421+ })
418 422
419 // Act423 // Act
420 err = wv.SetActivePaneID(pane2.ID)424 err = wv.SetActivePaneID(pane2.ID)
@@ -422,6 +426,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
422 // Assert426 // Assert
423 require.NoError(t, err)427 require.NoError(t, err)
424 assert.Equal(t, pane2.ID, wv.GetActivePaneID())428 assert.Equal(t, pane2.ID, wv.GetActivePaneID())
429+ assert.Equal(t, pane2.ID, activatedPane)
425 }430 }
426 431
427 func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {432 func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
@@ -437,3 +442,33 @@ func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
437 // Assert442 // Assert
438 assert.ErrorIs(t, err, component.ErrPaneNotFound)443 assert.ErrorIs(t, err, component.ErrPaneNotFound)
439 }444 }
445+
446+func TestSetWebViewWidget_InvokesAttachedCallback(t *testing.T) {
447+ mockFactory := mocks.NewMockWidgetFactory(t)
448+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
449+
450+ wv := component.NewWorkspaceView(ctx, mockFactory)
451+ mockOverlay, _ := setupWorkspacePaneViewMocks(t, mockFactory)
452+ pv := component.NewPaneView(ctx, mockFactory, entity.PaneID("pane-1"), nil)
453+ mockBox.EXPECT().AddCssClass("single-pane").Once()
454+ wv.RegisterPaneView(entity.PaneID("pane-1"), pv)
455+
456+ attachedPane := entity.PaneID("")
457+ wv.SetOnWebViewAttached(func(paneID entity.PaneID) {
458+ attachedPane = paneID
459+ })
460+
461+ mockWebViewWidget := mocks.NewMockWidget(t)
462+ mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
463+ mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
464+ mockWebViewWidget.EXPECT().GetParent().Return(nil).Once()
465+ mockWebViewWidget.EXPECT().IsVisible().Return(false).Once()
466+ mockWebViewWidget.EXPECT().GetAllocatedWidth().Return(0).Twice()
467+ mockWebViewWidget.EXPECT().GetAllocatedHeight().Return(0).Twice()
468+ mockOverlay.EXPECT().SetChild(mockWebViewWidget).Once()
469+
470+ err := wv.SetWebViewWidget(entity.PaneID("pane-1"), mockWebViewWidget)
471+
472+ require.NoError(t, err)
473+ assert.Equal(t, entity.PaneID("pane-1"), attachedPane)
474+}
M internal/ui/coordinator/content/lifecycle.go +19 -0
@@ -137,6 +137,7 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac
137137
138138 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
139139 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
140+ continue
140141 }
141142 logging.Trace().Mark("webview_attached")
142143 }
@@ -229,3 +230,21 @@ func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv port.WebView
229230 c.setWebViewLocked(paneID, wv)
230231 }
231232 }
233+
234+// SyncWebViewViewport requests an explicit viewport resync when the engine
235+// supports it. This is useful after widget attachment, reparenting, visibility
236+// changes, or layout promotion paths that may not emit a size change.
237+func (c *Coordinator) SyncWebViewViewport(ctx context.Context, paneID entity.PaneID, reason string) {
238+ if c == nil || paneID == "" {
239+ return
240+ }
241+ wv := c.getWebViewLocked(paneID)
242+ if wv == nil {
243+ return
244+ }
245+ syncer, ok := wv.(port.ViewportSyncCapable)
246+ if !ok {
247+ return
248+ }
249+ syncer.SyncViewport(ctx, reason)
250+}
@@ -137,6 +137,7 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac
137 137
138 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {138 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
139 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")139 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
140+ continue
140 }141 }
141 logging.Trace().Mark("webview_attached")142 logging.Trace().Mark("webview_attached")
142 }143 }
@@ -229,3 +230,21 @@ func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv port.WebView
229 c.setWebViewLocked(paneID, wv)230 c.setWebViewLocked(paneID, wv)
230 }231 }
231 }232 }
233+
234+// SyncWebViewViewport requests an explicit viewport resync when the engine
235+// supports it. This is useful after widget attachment, reparenting, visibility
236+// changes, or layout promotion paths that may not emit a size change.
237+func (c *Coordinator) SyncWebViewViewport(ctx context.Context, paneID entity.PaneID, reason string) {
238+ if c == nil || paneID == "" {
239+ return
240+ }
241+ wv := c.getWebViewLocked(paneID)
242+ if wv == nil {
243+ return
244+ }
245+ syncer, ok := wv.(port.ViewportSyncCapable)
246+ if !ok {
247+ return
248+ }
249+ syncer.SyncViewport(ctx, reason)
250+}
M internal/ui/coordinator/content/lifecycle_test.go +39 -0
@@ -258,3 +258,42 @@ func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) {
258258
259259 assert.Nil(t, c.GetWebView("popup-1"))
260260 }
261+
262+type syncViewportCapableStub struct {
263+ port.WebView
264+ called bool
265+ ctx context.Context
266+ reason string
267+}
268+
269+func (s *syncViewportCapableStub) SyncViewport(ctx context.Context, reason string) {
270+ s.called = true
271+ s.ctx = ctx
272+ s.reason = reason
273+}
274+
275+func TestLifecycle_SyncWebViewViewport_DelegatesWhenCapabilityPresent(t *testing.T) {
276+ t.Parallel()
277+
278+ base := mocks.NewMockWebView(t)
279+ wv := &syncViewportCapableStub{WebView: base}
280+ c := newMinimalCoordinator()
281+ c.webViews[entity.PaneID("pane-1")] = wv
282+
283+ ctx := context.WithValue(context.Background(), struct{}{}, "marker")
284+ c.SyncWebViewViewport(ctx, "pane-1", "unit-test")
285+
286+ require.True(t, wv.called)
287+ require.Equal(t, "unit-test", wv.reason)
288+ require.Same(t, ctx, wv.ctx)
289+}
290+
291+func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) {
292+ t.Parallel()
293+
294+ wv := mocks.NewMockWebView(t)
295+ c := newMinimalCoordinator()
296+ c.webViews[entity.PaneID("pane-1")] = wv
297+
298+ c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")
299+}
@@ -258,3 +258,42 @@ func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) {
258 258
259 assert.Nil(t, c.GetWebView("popup-1"))259 assert.Nil(t, c.GetWebView("popup-1"))
260 }260 }
261+
262+type syncViewportCapableStub struct {
263+ port.WebView
264+ called bool
265+ ctx context.Context
266+ reason string
267+}
268+
269+func (s *syncViewportCapableStub) SyncViewport(ctx context.Context, reason string) {
270+ s.called = true
271+ s.ctx = ctx
272+ s.reason = reason
273+}
274+
275+func TestLifecycle_SyncWebViewViewport_DelegatesWhenCapabilityPresent(t *testing.T) {
276+ t.Parallel()
277+
278+ base := mocks.NewMockWebView(t)
279+ wv := &syncViewportCapableStub{WebView: base}
280+ c := newMinimalCoordinator()
281+ c.webViews[entity.PaneID("pane-1")] = wv
282+
283+ ctx := context.WithValue(context.Background(), struct{}{}, "marker")
284+ c.SyncWebViewViewport(ctx, "pane-1", "unit-test")
285+
286+ require.True(t, wv.called)
287+ require.Equal(t, "unit-test", wv.reason)
288+ require.Same(t, ctx, wv.ctx)
289+}
290+
291+func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) {
292+ t.Parallel()
293+
294+ wv := mocks.NewMockWebView(t)
295+ c := newMinimalCoordinator()
296+ c.webViews[entity.PaneID("pane-1")] = wv
297+
298+ c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")
299+}
M internal/ui/coordinator/workspace.go +14 -8
@@ -605,7 +605,9 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
605605
606606 widget := c.contentCoord.WrapWidget(ctx, wv)
607607 if widget != nil {
608- newPaneView.SetWebViewWidget(widget)
608+ if err := wsView.SetWebViewWidget(output.NewPaneNode.Pane.ID, widget); err != nil {
609+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to attach webview widget for stack split")
610+ }
609611 }
610612
611613 log.Debug().Msg("incremental stack split completed successfully")
@@ -743,7 +745,9 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
743745
744746 widget := c.contentCoord.WrapWidget(ctx, wv)
745747 if widget != nil {
746- newPaneView.SetWebViewWidget(widget)
748+ if err := wsView.SetWebViewWidget(output.NewPaneNode.Pane.ID, widget); err != nil {
749+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to attach webview widget for split")
750+ }
747751 }
748752
749753 log.Debug().Msg("incremental split completed successfully")
@@ -1622,7 +1626,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
16221626 } else {
16231627 widget := c.contentCoord.WrapWidget(ctx, wv)
16241628 if widget != nil {
1625- newPaneView.SetWebViewWidget(widget)
1629+ if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1630+ log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1631+ }
16261632 }
16271633 // Load initial page for the new pane
16281634 if err := wv.LoadURI(ctx, newPane.URI); err != nil {
@@ -2089,11 +2095,9 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
20892095 return
20902096 }
20912097
2092- paneView := wsView.GetPaneView(input.PopupPane.ID)
2093- if paneView == nil {
2094- return
2098+ if err := wsView.SetWebViewWidget(input.PopupPane.ID, widget); err != nil {
2099+ logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to attach popup webview widget")
20952100 }
2096- paneView.SetWebViewWidget(widget)
20972101 }
20982102
20992103 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
@@ -2259,7 +2263,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
22592263 if input.WebView != nil {
22602264 widget := c.contentCoord.WrapWidget(ctx, input.WebView)
22612265 if widget != nil {
2262- newPaneView.SetWebViewWidget(widget)
2266+ if err := wsView.SetWebViewWidget(input.PopupPane.ID, widget); err != nil {
2267+ log.Warn().Err(err).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to attach stacked popup webview widget")
2268+ }
22632269 }
22642270 }
22652271
@@ -605,7 +605,9 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
605 605
606 widget := c.contentCoord.WrapWidget(ctx, wv)606 widget := c.contentCoord.WrapWidget(ctx, wv)
607 if widget != nil {607 if widget != nil {
608- newPaneView.SetWebViewWidget(widget)608+ if err := wsView.SetWebViewWidget(output.NewPaneNode.Pane.ID, widget); err != nil {
609+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to attach webview widget for stack split")
610+ }
609 }611 }
610 612
611 log.Debug().Msg("incremental stack split completed successfully")613 log.Debug().Msg("incremental stack split completed successfully")
@@ -743,7 +745,9 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
743 745
744 widget := c.contentCoord.WrapWidget(ctx, wv)746 widget := c.contentCoord.WrapWidget(ctx, wv)
745 if widget != nil {747 if widget != nil {
746- newPaneView.SetWebViewWidget(widget)748+ if err := wsView.SetWebViewWidget(output.NewPaneNode.Pane.ID, widget); err != nil {
749+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to attach webview widget for split")
750+ }
747 }751 }
748 752
749 log.Debug().Msg("incremental split completed successfully")753 log.Debug().Msg("incremental split completed successfully")
@@ -1622,7 +1626,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1622 } else {1626 } else {
1623 widget := c.contentCoord.WrapWidget(ctx, wv)1627 widget := c.contentCoord.WrapWidget(ctx, wv)
1624 if widget != nil {1628 if widget != nil {
1625- newPaneView.SetWebViewWidget(widget)1629+ if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1630+ log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1631+ }
1626 }1632 }
1627 // Load initial page for the new pane1633 // Load initial page for the new pane
1628 if err := wv.LoadURI(ctx, newPane.URI); err != nil {1634 if err := wv.LoadURI(ctx, newPane.URI); err != nil {
@@ -2089,11 +2095,9 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
2089 return2095 return
2090 }2096 }
2091 2097
2092- paneView := wsView.GetPaneView(input.PopupPane.ID)2098+ if err := wsView.SetWebViewWidget(input.PopupPane.ID, widget); err != nil {
2093- if paneView == nil {2099+ logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to attach popup webview widget")
2094- return
2095 }2100 }
2096- paneView.SetWebViewWidget(widget)
2097 }2101 }
2098 2102
2099 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.2103 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
@@ -2259,7 +2263,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
2259 if input.WebView != nil {2263 if input.WebView != nil {
2260 widget := c.contentCoord.WrapWidget(ctx, input.WebView)2264 widget := c.contentCoord.WrapWidget(ctx, input.WebView)
2261 if widget != nil {2265 if widget != nil {
2262- newPaneView.SetWebViewWidget(widget)2266+ if err := wsView.SetWebViewWidget(input.PopupPane.ID, widget); err != nil {
2267+ log.Warn().Err(err).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to attach stacked popup webview widget")
2268+ }
2263 }2269 }
2264 }2270 }
2265 2271

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