detached a814296

content: move popup lifecycle into manager

commit
a8142966c0518789497b4372b318c88468b093e8
author
brice · · 2026-04-22 15:05 UTC
parent
1202f7c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +724 -595
M internal/ui/coordinator/content/coordinator.go +32 -0
@@ -325,3 +325,35 @@ func (c *Coordinator) paneIDByWebViewID(webViewID port.WebViewID) (entity.PaneID
325325 paneID, ok := c.webViewPaneIDs[webViewID]
326326 return paneID, ok
327327 }
328+
329+func (c *Coordinator) findPaneByWebViewID(webViewID port.WebViewID) (entity.PaneID, bool) {
330+ if paneID, ok := c.paneIDByWebViewID(webViewID); ok && paneID != "" {
331+ return paneID, true
332+ }
333+ if webViewID == 0 {
334+ return "", false
335+ }
336+
337+ c.webViewsMu.RLock()
338+ defer c.webViewsMu.RUnlock()
339+ for paneID, wv := range c.webViews {
340+ if wv != nil && wv.ID() == webViewID {
341+ return paneID, true
342+ }
343+ }
344+ return "", false
345+}
346+
347+func (c *Coordinator) popupHooks() popupCoordinatorHooks {
348+ return popupCoordinatorHooks{
349+ setupWebViewCallbacks: c.setupWebViewCallbacks,
350+ registerPopupWebView: c.RegisterPopupWebView,
351+ setWebView: c.setWebViewLocked,
352+ getWebView: c.getWebViewLocked,
353+ deleteWebView: c.deleteWebViewLocked,
354+ releaseWebView: c.ReleaseWebView,
355+ findPaneByWebViewID: c.findPaneByWebViewID,
356+ setupOAuthAutoClose: c.setupOAuthAutoClose,
357+ handlePopupOAuthClose: c.handlePopupOAuthClose,
358+ }
359+}
@@ -325,3 +325,35 @@ func (c *Coordinator) paneIDByWebViewID(webViewID port.WebViewID) (entity.PaneID
325 paneID, ok := c.webViewPaneIDs[webViewID]325 paneID, ok := c.webViewPaneIDs[webViewID]
326 return paneID, ok326 return paneID, ok
327 }327 }
328+
329+func (c *Coordinator) findPaneByWebViewID(webViewID port.WebViewID) (entity.PaneID, bool) {
330+ if paneID, ok := c.paneIDByWebViewID(webViewID); ok && paneID != "" {
331+ return paneID, true
332+ }
333+ if webViewID == 0 {
334+ return "", false
335+ }
336+
337+ c.webViewsMu.RLock()
338+ defer c.webViewsMu.RUnlock()
339+ for paneID, wv := range c.webViews {
340+ if wv != nil && wv.ID() == webViewID {
341+ return paneID, true
342+ }
343+ }
344+ return "", false
345+}
346+
347+func (c *Coordinator) popupHooks() popupCoordinatorHooks {
348+ return popupCoordinatorHooks{
349+ setupWebViewCallbacks: c.setupWebViewCallbacks,
350+ registerPopupWebView: c.RegisterPopupWebView,
351+ setWebView: c.setWebViewLocked,
352+ getWebView: c.getWebViewLocked,
353+ deleteWebView: c.deleteWebViewLocked,
354+ releaseWebView: c.ReleaseWebView,
355+ findPaneByWebViewID: c.findPaneByWebViewID,
356+ setupOAuthAutoClose: c.setupOAuthAutoClose,
357+ handlePopupOAuthClose: c.handlePopupOAuthClose,
358+ }
359+}
M internal/ui/coordinator/content/oauth.go +5 -54
@@ -348,59 +348,21 @@ func (c *Coordinator) setupOAuthAutoClose(
348348 }
349349
350350 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
351- c.popups.mu.Lock()
352- defer c.popups.mu.Unlock()
353- if c.popups.popupOAuth == nil {
354- c.popups.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
355- }
356- c.popups.popupOAuth[popupID] = &popupOAuthState{
357- ParentPaneID: parentPaneID,
358- ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
359- }
351+ c.ensurePopupManager().trackOAuthPopup(popupID, parentPaneID, parentURIAtOpen)
360352 }
361353
362354 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
363- c.popups.mu.Lock()
364- defer c.popups.mu.Unlock()
365-
366- state, ok := c.popups.popupOAuth[popupID]
367- if !ok {
368- return
369- }
370-
371- state.Seen = true
372- state.CallbackURI = uri
373- state.Success = IsOAuthSuccess(uri)
374- state.Error = IsOAuthError(uri)
355+ c.ensurePopupManager().capturePopupOAuthState(popupID, uri)
375356 }
376357
377358 func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
378- c.popups.mu.Lock()
379- defer c.popups.mu.Unlock()
380-
381- state, ok := c.popups.popupOAuth[popupID]
382- if !ok {
383- return
384- }
385-
386- state.Seen = true
387- if state.CallbackURI == "" {
388- state.CallbackURI = "postmessage://oauth-complete"
389- }
390- if !state.Error {
391- state.Success = true
392- }
359+ c.ensurePopupManager().capturePopupOAuthMessage(popupID)
393360 }
394361
395362 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
396363 log := logging.FromContext(ctx)
397364
398- c.popups.mu.Lock()
399- state, ok := c.popups.popupOAuth[popupID]
400- if ok {
401- delete(c.popups.popupOAuth, popupID)
402- }
403- c.popups.mu.Unlock()
365+ state, ok := c.ensurePopupManager().takePopupOAuthState(popupID)
404366
405367 if !ok || state == nil || !state.Seen {
406368 return
@@ -431,24 +393,13 @@ func (c *Coordinator) scheduleParentPaneOAuthResume(
431393 parentURIAtOpen string,
432394 callbackURI string,
433395 ) {
434- c.popups.mu.Lock()
435- if c.popups.popupRefresh == nil {
436- c.popups.popupRefresh = make(map[entity.PaneID]*time.Timer)
437- }
438- if existing := c.popups.popupRefresh[parentPaneID]; existing != nil {
439- existing.Stop()
440- }
441- c.popups.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
442- c.popups.mu.Lock()
443- delete(c.popups.popupRefresh, parentPaneID)
444- c.popups.mu.Unlock()
396+ c.ensurePopupManager().schedulePopupRefresh(parentPaneID, oauthParentRefreshDebounce, func() {
445397 cb := glib.SourceFunc(func(_ uintptr) bool {
446398 c.resumeParentPaneAfterOAuth(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI)
447399 return false
448400 })
449401 glib.IdleAdd(&cb, 0)
450402 })
451- c.popups.mu.Unlock()
452403 }
453404
454405 func (c *Coordinator) resumeParentPaneAfterOAuth(
@@ -348,59 +348,21 @@ func (c *Coordinator) setupOAuthAutoClose(
348 }348 }
349 349
350 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {350 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
351- c.popups.mu.Lock()351+ c.ensurePopupManager().trackOAuthPopup(popupID, parentPaneID, parentURIAtOpen)
352- defer c.popups.mu.Unlock()
353- if c.popups.popupOAuth == nil {
354- c.popups.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
355- }
356- c.popups.popupOAuth[popupID] = &popupOAuthState{
357- ParentPaneID: parentPaneID,
358- ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
359- }
360 }352 }
361 353
362 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {354 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
363- c.popups.mu.Lock()355+ c.ensurePopupManager().capturePopupOAuthState(popupID, uri)
364- defer c.popups.mu.Unlock()
365-
366- state, ok := c.popups.popupOAuth[popupID]
367- if !ok {
368- return
369- }
370-
371- state.Seen = true
372- state.CallbackURI = uri
373- state.Success = IsOAuthSuccess(uri)
374- state.Error = IsOAuthError(uri)
375 }356 }
376 357
377 func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {358 func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
378- c.popups.mu.Lock()359+ c.ensurePopupManager().capturePopupOAuthMessage(popupID)
379- defer c.popups.mu.Unlock()
380-
381- state, ok := c.popups.popupOAuth[popupID]
382- if !ok {
383- return
384- }
385-
386- state.Seen = true
387- if state.CallbackURI == "" {
388- state.CallbackURI = "postmessage://oauth-complete"
389- }
390- if !state.Error {
391- state.Success = true
392- }
393 }360 }
394 361
395 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {362 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
396 log := logging.FromContext(ctx)363 log := logging.FromContext(ctx)
397 364
398- c.popups.mu.Lock()365+ state, ok := c.ensurePopupManager().takePopupOAuthState(popupID)
399- state, ok := c.popups.popupOAuth[popupID]
400- if ok {
401- delete(c.popups.popupOAuth, popupID)
402- }
403- c.popups.mu.Unlock()
404 366
405 if !ok || state == nil || !state.Seen {367 if !ok || state == nil || !state.Seen {
406 return368 return
@@ -431,24 +393,13 @@ func (c *Coordinator) scheduleParentPaneOAuthResume(
431 parentURIAtOpen string,393 parentURIAtOpen string,
432 callbackURI string,394 callbackURI string,
433 ) {395 ) {
434- c.popups.mu.Lock()396+ c.ensurePopupManager().schedulePopupRefresh(parentPaneID, oauthParentRefreshDebounce, func() {
435- if c.popups.popupRefresh == nil {
436- c.popups.popupRefresh = make(map[entity.PaneID]*time.Timer)
437- }
438- if existing := c.popups.popupRefresh[parentPaneID]; existing != nil {
439- existing.Stop()
440- }
441- c.popups.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
442- c.popups.mu.Lock()
443- delete(c.popups.popupRefresh, parentPaneID)
444- c.popups.mu.Unlock()
445 cb := glib.SourceFunc(func(_ uintptr) bool {397 cb := glib.SourceFunc(func(_ uintptr) bool {
446 c.resumeParentPaneAfterOAuth(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI)398 c.resumeParentPaneAfterOAuth(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI)
447 return false399 return false
448 })400 })
449 glib.IdleAdd(&cb, 0)401 glib.IdleAdd(&cb, 0)
450 })402 })
451- c.popups.mu.Unlock()
452 }403 }
453 404
454 func (c *Coordinator) resumeParentPaneAfterOAuth(405 func (c *Coordinator) resumeParentPaneAfterOAuth(
M internal/ui/coordinator/content/popup.go +14 -541
@@ -2,13 +2,10 @@ package content
22
33 import (
44 "context"
5- "errors"
6- "fmt"
75 "time"
86
97 "github.com/bnema/dumber/internal/application/port"
108 "github.com/bnema/dumber/internal/domain/entity"
11- domainerrors "github.com/bnema/dumber/internal/domain/errors"
129 "github.com/bnema/dumber/internal/logging"
1310 )
1411
@@ -95,29 +92,7 @@ func isReusableNamedPopupFrame(frameName string) bool {
9592 }
9693
9794 func (c *Coordinator) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {
98- if !isReusableNamedPopupFrame(frameName) {
99- return nil, false
100- }
101-
102- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
103-
104- c.popups.mu.RLock()
105- state, ok := c.popups.namedPopups[key]
106- c.popups.mu.RUnlock()
107- if !ok || state == nil || state.WebView == nil {
108- return nil, false
109- }
110-
111- if state.WebView.IsDestroyed() {
112- c.popups.mu.Lock()
113- if current, ok := c.popups.namedPopups[key]; ok && current == state {
114- delete(c.popups.namedPopups, key)
115- }
116- c.popups.mu.Unlock()
117- return nil, false
118- }
119-
120- return state, true
95+ return c.ensurePopupManager().lookupReusableNamedPopup(parentPaneID, frameName)
12196 }
12297
12398 func (c *Coordinator) storeReusableNamedPopup(
@@ -125,36 +100,15 @@ func (c *Coordinator) storeReusableNamedPopup(
125100 frameName string,
126101 wv port.WebView,
127102 ) {
128- if !isReusableNamedPopupFrame(frameName) || wv == nil {
129- return
130- }
131-
132- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
133-
134- c.popups.mu.Lock()
135- if c.popups.namedPopups == nil {
136- c.popups.namedPopups = make(map[namedPopupKey]*namedPopupState)
137- }
138- c.popups.namedPopups[key] = &namedPopupState{WebView: wv}
139- c.popups.mu.Unlock()
103+ c.ensurePopupManager().storeReusableNamedPopup(parentPaneID, frameName, wv)
140104 }
141105
142106 func (c *Coordinator) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
143- c.popups.mu.Lock()
144- if pending, ok := c.popups.pendingPopups[popupID]; ok && pending != nil {
145- pending.TargetURI = targetURI
146- }
147- c.popups.mu.Unlock()
107+ c.ensurePopupManager().updatePendingPopupTarget(popupID, targetURI)
148108 }
149109
150110 func (c *Coordinator) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
151- c.popups.mu.Lock()
152- for key, state := range c.popups.namedPopups {
153- if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
154- delete(c.popups.namedPopups, key)
155- }
156- }
157- c.popups.mu.Unlock()
111+ c.ensurePopupManager().clearReusableNamedPopupByWebViewID(popupID)
158112 }
159113
160114 // InsertPopupInput contains the data needed to insert a popup into the workspace.
@@ -255,26 +209,15 @@ func (c *Coordinator) createPopupPane(
255209 }
256210
257211 func (c *Coordinator) relatedPopupSupportDisabled() bool {
258- popups := c.ensurePopupManager()
259- popups.mu.RLock()
260- defer popups.mu.RUnlock()
261- return popups.relatedPopupSupportDetected && popups.relatedPopupUnsupported
212+ return c.ensurePopupManager().relatedPopupSupportDisabled()
262213 }
263214
264215 func (c *Coordinator) markRelatedPopupUnsupported() {
265- popups := c.ensurePopupManager()
266- popups.mu.Lock()
267- defer popups.mu.Unlock()
268- popups.relatedPopupSupportDetected = true
269- popups.relatedPopupUnsupported = true
216+ c.ensurePopupManager().markRelatedPopupUnsupported()
270217 }
271218
272219 func (c *Coordinator) markRelatedPopupSupported() {
273- popups := c.ensurePopupManager()
274- popups.mu.Lock()
275- defer popups.mu.Unlock()
276- popups.relatedPopupSupportDetected = true
277- popups.relatedPopupUnsupported = false
220+ c.ensurePopupManager().markRelatedPopupSupported()
278221 }
279222
280223 // createPopupWebView prefers a related WebView so popups can share the
@@ -287,65 +230,7 @@ func (c *Coordinator) createPopupWebView(
287230 targetURI string,
288231 noJavaScriptAccess bool,
289232 ) (port.WebView, string, bool, error) {
290- if c.popups.factory == nil {
291- return nil, targetURI, false, fmt.Errorf("no webview factory configured")
292- }
293-
294- log := logging.FromContext(ctx)
295- relatedErr := error(nil)
296-
297- if !noJavaScriptAccess && !c.relatedPopupSupportDisabled() {
298- popupWV, err := c.popups.factory.CreateRelated(ctx, parentID)
299- if err == nil && popupWV != nil {
300- c.markRelatedPopupSupported()
301- return popupWV, targetURI, false, nil
302- }
303- if err == nil {
304- relatedErr = fmt.Errorf("related popup webview factory returned nil without error")
305- log.Warn().
306- Uint64("parent_webview_id", uint64(parentID)).
307- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
308- Msg("related popup webview factory returned nil popup, falling back to regular webview")
309- } else if errors.Is(err, domainerrors.ErrRelatedWebViewUnsupported) {
310- relatedErr = err
311- c.markRelatedPopupUnsupported()
312- log.Debug().
313- Err(err).
314- Uint64("parent_webview_id", uint64(parentID)).
315- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
316- Msg("related popup webview unavailable, falling back to regular webview")
317- } else {
318- relatedErr = err
319- log.Warn().
320- Err(err).
321- Uint64("parent_webview_id", uint64(parentID)).
322- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
323- Msg("related popup webview creation failed, falling back to regular webview")
324- }
325- } else if noJavaScriptAccess {
326- log.Debug().
327- Uint64("parent_webview_id", uint64(parentID)).
328- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
329- Msg("popup requested no JavaScript opener access, creating regular webview")
330- } else {
331- log.Debug().
332- Uint64("parent_webview_id", uint64(parentID)).
333- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
334- Msg("related popup webviews known unsupported, creating regular webview")
335- }
336-
337- popupWV, fallbackErr := c.popups.factory.Create(ctx)
338- if fallbackErr != nil {
339- if relatedErr != nil {
340- return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
341- }
342- return nil, targetURI, false, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
343- }
344- if popupWV == nil {
345- return nil, targetURI, false, fmt.Errorf("popup webview factory returned nil without error")
346- }
347-
348- return popupWV, targetURI, true, nil
233+ return c.ensurePopupManager().createPopupWebView(ctx, parentID, targetURI, noJavaScriptAccess)
349234 }
350235
351236 // handlePopupCreate handles a popup request from the current WebView.
@@ -360,91 +245,7 @@ func (c *Coordinator) handlePopupCreate(
360245 parentWV port.WebView,
361246 req port.PopupRequest,
362247 ) port.WebView {
363- log := logging.FromContext(ctx)
364-
365- log.Debug().
366- Str("parent_pane", string(parentPaneID)).
367- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
368- Str("frame_name", req.FrameName).
369- Bool("user_gesture", req.IsUserGesture).
370- Bool("no_javascript_access", req.NoJavaScriptAccess).
371- Msg("popup create request")
372-
373- // Check if popups are enabled
374- if c.popups.popupConfig != nil && !c.popups.popupConfig.OpenInNewPane {
375- log.Debug().Msg("popups disabled by config, blocking")
376- return nil
377- }
378-
379- // Check if factory is available
380- if c.popups.factory == nil {
381- log.Warn().Msg("no webview factory, cannot create popup")
382- return nil
383- }
384-
385- parentID := parentWV.ID()
386- parentURIAtOpen := ""
387- if c.popups.popupConfig != nil && c.popups.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
388- if parentWV != nil {
389- parentURIAtOpen = parentWV.URI()
390- }
391- if parentURIAtOpen == "" {
392- if parent := c.getWebViewLocked(parentPaneID); parent != nil {
393- parentURIAtOpen = parent.URI()
394- }
395- }
396- }
397- if !req.NoJavaScriptAccess {
398- if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
399- return reused
400- }
401- }
402-
403- // Prefer a related WebView for session sharing, but fall back to a regular
404- // WebView when the active engine cannot provide a related popup instance for
405- // this request.
406- popupWV, effectiveTargetURI, usedRegularFallback, err := c.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
407- if err != nil {
408- log.Error().Err(err).Msg("failed to create webview for popup")
409- return nil
410- }
411-
412- if effectiveTargetURI != req.TargetURI {
413- req.TargetURI = effectiveTargetURI
414- }
415- if usedRegularFallback {
416- if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {
417- openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
418- }
419- }
420-
421- // Detect popup type
422- popupType := DetectPopupType(req.FrameName)
423- popupID := popupWV.ID()
424-
425- // Determine behavior from config
426- behavior := GetBehavior(popupType, c.popups.popupConfig)
427- placement := "right"
428- if c.popups.popupConfig != nil {
429- placement = c.popups.popupConfig.Placement
430- }
431-
432- // Create popup pane entity
433- paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
434-
435- return c.finishPopupCreate(ctx, popupCreateContext{
436- ParentPaneID: parentPaneID,
437- ParentWebViewID: parentID,
438- ParentURIAtOpen: parentURIAtOpen,
439- PopupID: popupID,
440- PopupWebView: popupWV,
441- PopupPane: popupPane,
442- PopupPaneID: paneID,
443- PopupType: popupType,
444- Behavior: behavior,
445- Placement: placement,
446- Request: req,
447- })
248+ return c.ensurePopupManager().handlePopupCreate(ctx, c.popupHooks(), parentPaneID, parentWV, req)
448249 }
449250
450251 func (c *Coordinator) reuseNamedPopup(
@@ -453,355 +254,27 @@ func (c *Coordinator) reuseNamedPopup(
453254 frameName string,
454255 targetURI string,
455256 ) (port.WebView, bool) {
456- log := logging.FromContext(ctx)
457-
458- if existing, ok := c.lookupReusableNamedPopup(parentPaneID, frameName); ok {
459- c.updatePendingPopupTarget(existing.WebView.ID(), targetURI)
460- if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {
461- log.Warn().Err(err).
462- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
463- Msg("failed to load target URI in reused popup")
464- }
465- log.Info().
466- Str("parent_pane", string(parentPaneID)).
467- Str("frame_name", frameName).
468- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
469- Msg("reused named popup")
470- return existing.WebView, true
471- }
472-
473- return nil, false
257+ return c.ensurePopupManager().reuseNamedPopup(ctx, parentPaneID, frameName, targetURI)
474258 }
475259
476260 func (c *Coordinator) finishPopupCreate(ctx context.Context, create popupCreateContext) port.WebView {
477- log := logging.FromContext(ctx)
478- hasConfig := c.popups.popupConfig != nil
479- oauthEnabled := hasConfig && c.popups.popupConfig.OAuthAutoClose
480- isOAuth := IsOAuthURL(create.Request.TargetURI)
481-
482- log.Debug().
483- Bool("has_config", hasConfig).
484- Bool("oauth_enabled", oauthEnabled).
485- Bool("is_oauth", isOAuth).
486- Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
487- Msg("popup OAuth check")
488-
489- // Initialize callbacks before publishing the WebView so any reader that sees
490- // the popup during workspace insertion gets a fully configured instance.
491- c.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
492-
493- // Register the popup WebView before workspace insertion so split/stack UI
494- // updates can reuse the real popup instead of acquiring a placeholder pane
495- // WebView that races the actual popup flow.
496- c.RegisterPopupWebView(create.PopupPaneID, create.PopupWebView)
497- inserted := false
498- defer func() {
499- if inserted {
500- return
501- }
502- if current := c.getWebViewLocked(create.PopupPaneID); current == create.PopupWebView {
503- c.deleteWebViewLocked(create.PopupPaneID)
504- }
505- }()
506-
507- // Insert into workspace IMMEDIATELY (WebView stays hidden)
508- // This is required for WebKit to establish window.opener relationship.
509- if c.popups.onInsertPopup != nil {
510- popupInput := InsertPopupInput{
511- ParentPaneID: create.ParentPaneID,
512- PopupPane: create.PopupPane,
513- WebView: create.PopupWebView,
514- Behavior: create.Behavior,
515- Placement: create.Placement,
516- PopupType: create.PopupType,
517- TargetURI: create.Request.TargetURI,
518- }
519-
520- if err := c.popups.onInsertPopup(ctx, popupInput); err != nil {
521- log.Error().Err(err).Msg("failed to insert popup into workspace")
522- create.PopupWebView.Destroy()
523- return nil
524- }
525- }
526- inserted = true
527-
528- if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
529- lifecycle.PrimePopupNavigation(create.Request.TargetURI)
530- }
531-
532- if !create.Request.NoJavaScriptAccess {
533- c.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupWebView)
534- }
535-
536- // Engines without native popup lifecycle hooks can still support
537- // programmatic popup closure (proxy.close(), OAuth auto-close) by exposing
538- // the optional OAuthCallbackCapable interface.
539- if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
540- if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
541- closeCapable.AddCloseCallback(func() {
542- c.handlePopupClose(ctx, create.PopupID)
543- })
544- }
545- }
546-
547- // Setup OAuth auto-close if configured.
548- if hasConfig && oauthEnabled && isOAuth {
549- create.PopupPane.AutoClose = true
550- c.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
551- c.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
552- log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
553- }
554-
555- // Store pending popup for ready-to-show handling (just visibility now).
556- pending := &PendingPopup{
557- PaneID: create.PopupPaneID,
558- WebView: create.PopupWebView,
559- ParentPaneID: create.ParentPaneID,
560- ParentWebViewID: create.ParentWebViewID,
561- TargetURI: create.Request.TargetURI,
562- FrameName: create.Request.FrameName,
563- IsUserGesture: create.Request.IsUserGesture,
564- PopupType: create.PopupType,
565- CreatedAt: time.Now(),
566- }
567-
568- c.popups.mu.Lock()
569- c.popups.pendingPopups[create.PopupID] = pending
570- c.popups.mu.Unlock()
571-
572- // Wire ready-to-show and close signals via the popup lifecycle interface.
573- if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
574- lifecycle.SetOnReadyToShow(func() {
575- c.handlePopupReadyToShow(ctx, create.PopupID)
576- })
577- lifecycle.SetOnClose(func() {
578- c.handlePopupClose(ctx, create.PopupID)
579- })
580- log.Info().
581- Uint64("popup_id", uint64(create.PopupID)).
582- Str("pane_id", string(create.PopupPaneID)).
583- Str("popup_type", create.PopupType.String()).
584- Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
585- Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
586- } else {
587- // Engine does not expose popup lifecycle callbacks. Treat the inserted
588- // WebView as ready immediately and continue with the same coordinator flow.
589- log.Info().
590- Uint64("popup_id", uint64(create.PopupID)).
591- Str("pane_id", string(create.PopupPaneID)).
592- Str("popup_type", create.PopupType.String()).
593- Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
594- Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
595- c.handlePopupReadyToShow(ctx, create.PopupID)
596- }
597-
598- return create.PopupWebView
261+ return c.ensurePopupManager().finishPopupCreate(ctx, c.popupHooks(), create)
599262 }
600263
601264 // handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
602265 // The popup WebView was already inserted into the workspace (hidden) during
603266 // the create signal. This handler just makes it visible.
604267 func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
605- log := logging.FromContext(ctx)
606-
607- // Get pending popup
608- c.popups.mu.Lock()
609- pending, ok := c.popups.pendingPopups[popupID]
610- if ok {
611- delete(c.popups.pendingPopups, popupID)
612- }
613- c.popups.mu.Unlock()
614-
615- if !ok || pending == nil {
616- log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
617- return
618- }
619-
620- log.Debug().
621- Uint64("popup_id", uint64(popupID)).
622- Str("popup_type", pending.PopupType.String()).
623- Msg("popup ready to show - making visible")
624-
625- // Make the WebView visible now that it's ready.
626- if pending.WebView != nil {
627- if lifecycle, ok := pending.WebView.(port.PopupLifecycleCapable); ok {
628- lifecycle.Show()
629- }
630- _, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
631- // Engines that do not preload popup navigation can request the target URI
632- // here once the popup becomes visible.
633- if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
634- if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
635- log.Warn().Err(err).
636- Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
637- Msg("failed to load target URI in popup")
638- }
639- }
640- }
641-
642- log.Info().
643- Uint64("popup_id", uint64(popupID)).
644- Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
645- Msg("popup now visible")
268+ c.ensurePopupManager().handlePopupReadyToShow(ctx, popupID)
646269 }
647270
648271 // handlePopupClose handles the WebKit "close" signal for popup windows.
649272 func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
650- log := logging.FromContext(ctx)
651- log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
652-
653- // Check if still pending (never shown)
654- c.popups.mu.Lock()
655- pending, wasPending := c.popups.pendingPopups[popupID]
656- if wasPending {
657- delete(c.popups.pendingPopups, popupID)
658- }
659- c.popups.mu.Unlock()
660-
661- if wasPending && pending != nil {
662- c.handlePopupOAuthClose(ctx, popupID)
663- if c.popups.onClosePane != nil {
664- if err := c.popups.onClosePane(ctx, pending.PaneID); err != nil {
665- log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
666- }
667- }
668- c.clearReusableNamedPopupByWebViewID(popupID)
669- if c.getWebViewLocked(pending.PaneID) != nil {
670- c.ReleaseWebView(ctx, pending.PaneID)
671- }
672- log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
673- return
674- }
675-
676- // Find pane by WebView ID.
677- paneID, ok := c.paneIDByWebViewID(popupID)
678- if (!ok || paneID == "") && popupID != 0 {
679- c.webViewsMu.RLock()
680- for pid, wv := range c.webViews {
681- if wv != nil && wv.ID() == popupID {
682- paneID = pid
683- ok = true
684- break
685- }
686- }
687- c.webViewsMu.RUnlock()
688- }
689- if !ok || paneID == "" {
690- c.handlePopupOAuthClose(ctx, popupID)
691- c.clearReusableNamedPopupByWebViewID(popupID)
692- log.Warn().Msg("popup close: could not find pane for webview")
693- return
694- }
695-
696- c.handlePopupOAuthClose(ctx, popupID)
697-
698- // Close the pane in workspace (this removes the UI element)
699- if c.popups.onClosePane != nil {
700- if err := c.popups.onClosePane(ctx, paneID); err != nil {
701- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
702- }
703- }
704-
705- c.clearReusableNamedPopupByWebViewID(popupID)
706-
707- // ClosePaneByID usually releases the WebView already; only release here if it
708- // is still registered.
709- if c.getWebViewLocked(paneID) != nil {
710- c.ReleaseWebView(ctx, paneID)
711- }
712-
713- log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
273+ c.ensurePopupManager().handlePopupClose(ctx, c.popupHooks(), popupID)
714274 }
715275
716276 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.
717277 // Opens the link in a new pane using blank_target_behavior config.
718278 func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
719- log := logging.FromContext(ctx)
720-
721- log.Info().
722- Str("parent_pane", string(parentPaneID)).
723- Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
724- Msg("middle-click/ctrl+click on link")
725-
726- // Check if popups are enabled
727- if c.popups.popupConfig != nil && !c.popups.popupConfig.OpenInNewPane {
728- log.Debug().Msg("popups disabled by config, ignoring middle-click")
729- return false
730- }
731-
732- // Check if factory is available
733- if c.popups.factory == nil {
734- log.Warn().Msg("no webview factory, cannot handle middle-click")
735- return false
736- }
737-
738- // Create a new WebView (regular, not related - just opening a link)
739- newWV, err := c.popups.factory.Create(ctx)
740- if err != nil {
741- log.Error().Err(err).Msg("failed to create webview for middle-click")
742- return false
743- }
744-
745- // Generate pane ID
746- var paneID entity.PaneID
747- if c.popups.generatePaneID != nil {
748- paneID = entity.PaneID(c.popups.generatePaneID())
749- } else {
750- paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
751- }
752-
753- // Create pane entity - uses blank_target_behavior
754- newPane := entity.NewPane(paneID)
755- newPane.WindowType = entity.WindowPopup
756- newPane.URI = uri
757-
758- // Register WebView
759- c.setWebViewLocked(paneID, newWV)
760-
761- // Setup callbacks
762- c.setupWebViewCallbacks(ctx, paneID, newWV)
763-
764- // Get behavior from config (same as _blank links)
765- behavior := entity.PopupBehaviorStacked // default
766- if c.popups.popupConfig != nil && c.popups.popupConfig.BlankTargetBehavior != "" {
767- behavior = entity.PopupBehavior(c.popups.popupConfig.BlankTargetBehavior)
768- }
769- placement := "right"
770- if c.popups.popupConfig != nil {
771- placement = c.popups.popupConfig.Placement
772- }
773-
774- // Request insertion into workspace
775- if c.popups.onInsertPopup != nil {
776- popupInput := InsertPopupInput{
777- ParentPaneID: parentPaneID,
778- PopupPane: newPane,
779- WebView: newWV,
780- Behavior: behavior,
781- Placement: placement,
782- PopupType: PopupTypeTab, // Treat like _blank
783- TargetURI: uri,
784- }
785-
786- if err := c.popups.onInsertPopup(ctx, popupInput); err != nil {
787- log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
788- // Clean up on failure — use the locking helper to avoid a data race.
789- c.deleteWebViewLocked(paneID)
790- newWV.Destroy()
791- return false
792- }
793- }
794-
795- // Load the URI after insertion
796- if err := newWV.LoadURI(ctx, uri); err != nil {
797- log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
798- }
799-
800- log.Info().
801- Str("pane_id", string(paneID)).
802- Str("behavior", string(behavior)).
803- Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
804- Msg("middle-click link opened in new pane")
805-
806- return true
279+ return c.ensurePopupManager().handleLinkMiddleClick(ctx, c.popupHooks(), parentPaneID, uri)
807280 }
@@ -2,13 +2,10 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6- "fmt"
7 "time"5 "time"
8 6
9 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
11- domainerrors "github.com/bnema/dumber/internal/domain/errors"
12 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
13 )10 )
14 11
@@ -95,29 +92,7 @@ func isReusableNamedPopupFrame(frameName string) bool {
95 }92 }
96 93
97 func (c *Coordinator) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {94 func (c *Coordinator) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {
98- if !isReusableNamedPopupFrame(frameName) {95+ return c.ensurePopupManager().lookupReusableNamedPopup(parentPaneID, frameName)
99- return nil, false
100- }
101-
102- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
103-
104- c.popups.mu.RLock()
105- state, ok := c.popups.namedPopups[key]
106- c.popups.mu.RUnlock()
107- if !ok || state == nil || state.WebView == nil {
108- return nil, false
109- }
110-
111- if state.WebView.IsDestroyed() {
112- c.popups.mu.Lock()
113- if current, ok := c.popups.namedPopups[key]; ok && current == state {
114- delete(c.popups.namedPopups, key)
115- }
116- c.popups.mu.Unlock()
117- return nil, false
118- }
119-
120- return state, true
121 }96 }
122 97
123 func (c *Coordinator) storeReusableNamedPopup(98 func (c *Coordinator) storeReusableNamedPopup(
@@ -125,36 +100,15 @@ func (c *Coordinator) storeReusableNamedPopup(
125 frameName string,100 frameName string,
126 wv port.WebView,101 wv port.WebView,
127 ) {102 ) {
128- if !isReusableNamedPopupFrame(frameName) || wv == nil {103+ c.ensurePopupManager().storeReusableNamedPopup(parentPaneID, frameName, wv)
129- return
130- }
131-
132- key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
133-
134- c.popups.mu.Lock()
135- if c.popups.namedPopups == nil {
136- c.popups.namedPopups = make(map[namedPopupKey]*namedPopupState)
137- }
138- c.popups.namedPopups[key] = &namedPopupState{WebView: wv}
139- c.popups.mu.Unlock()
140 }104 }
141 105
142 func (c *Coordinator) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {106 func (c *Coordinator) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
143- c.popups.mu.Lock()107+ c.ensurePopupManager().updatePendingPopupTarget(popupID, targetURI)
144- if pending, ok := c.popups.pendingPopups[popupID]; ok && pending != nil {
145- pending.TargetURI = targetURI
146- }
147- c.popups.mu.Unlock()
148 }108 }
149 109
150 func (c *Coordinator) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {110 func (c *Coordinator) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
151- c.popups.mu.Lock()111+ c.ensurePopupManager().clearReusableNamedPopupByWebViewID(popupID)
152- for key, state := range c.popups.namedPopups {
153- if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
154- delete(c.popups.namedPopups, key)
155- }
156- }
157- c.popups.mu.Unlock()
158 }112 }
159 113
160 // InsertPopupInput contains the data needed to insert a popup into the workspace.114 // InsertPopupInput contains the data needed to insert a popup into the workspace.
@@ -255,26 +209,15 @@ func (c *Coordinator) createPopupPane(
255 }209 }
256 210
257 func (c *Coordinator) relatedPopupSupportDisabled() bool {211 func (c *Coordinator) relatedPopupSupportDisabled() bool {
258- popups := c.ensurePopupManager()212+ return c.ensurePopupManager().relatedPopupSupportDisabled()
259- popups.mu.RLock()
260- defer popups.mu.RUnlock()
261- return popups.relatedPopupSupportDetected && popups.relatedPopupUnsupported
262 }213 }
263 214
264 func (c *Coordinator) markRelatedPopupUnsupported() {215 func (c *Coordinator) markRelatedPopupUnsupported() {
265- popups := c.ensurePopupManager()216+ c.ensurePopupManager().markRelatedPopupUnsupported()
266- popups.mu.Lock()
267- defer popups.mu.Unlock()
268- popups.relatedPopupSupportDetected = true
269- popups.relatedPopupUnsupported = true
270 }217 }
271 218
272 func (c *Coordinator) markRelatedPopupSupported() {219 func (c *Coordinator) markRelatedPopupSupported() {
273- popups := c.ensurePopupManager()220+ c.ensurePopupManager().markRelatedPopupSupported()
274- popups.mu.Lock()
275- defer popups.mu.Unlock()
276- popups.relatedPopupSupportDetected = true
277- popups.relatedPopupUnsupported = false
278 }221 }
279 222
280 // createPopupWebView prefers a related WebView so popups can share the223 // createPopupWebView prefers a related WebView so popups can share the
@@ -287,65 +230,7 @@ func (c *Coordinator) createPopupWebView(
287 targetURI string,230 targetURI string,
288 noJavaScriptAccess bool,231 noJavaScriptAccess bool,
289 ) (port.WebView, string, bool, error) {232 ) (port.WebView, string, bool, error) {
290- if c.popups.factory == nil {233+ return c.ensurePopupManager().createPopupWebView(ctx, parentID, targetURI, noJavaScriptAccess)
291- return nil, targetURI, false, fmt.Errorf("no webview factory configured")
292- }
293-
294- log := logging.FromContext(ctx)
295- relatedErr := error(nil)
296-
297- if !noJavaScriptAccess && !c.relatedPopupSupportDisabled() {
298- popupWV, err := c.popups.factory.CreateRelated(ctx, parentID)
299- if err == nil && popupWV != nil {
300- c.markRelatedPopupSupported()
301- return popupWV, targetURI, false, nil
302- }
303- if err == nil {
304- relatedErr = fmt.Errorf("related popup webview factory returned nil without error")
305- log.Warn().
306- Uint64("parent_webview_id", uint64(parentID)).
307- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
308- Msg("related popup webview factory returned nil popup, falling back to regular webview")
309- } else if errors.Is(err, domainerrors.ErrRelatedWebViewUnsupported) {
310- relatedErr = err
311- c.markRelatedPopupUnsupported()
312- log.Debug().
313- Err(err).
314- Uint64("parent_webview_id", uint64(parentID)).
315- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
316- Msg("related popup webview unavailable, falling back to regular webview")
317- } else {
318- relatedErr = err
319- log.Warn().
320- Err(err).
321- Uint64("parent_webview_id", uint64(parentID)).
322- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
323- Msg("related popup webview creation failed, falling back to regular webview")
324- }
325- } else if noJavaScriptAccess {
326- log.Debug().
327- Uint64("parent_webview_id", uint64(parentID)).
328- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
329- Msg("popup requested no JavaScript opener access, creating regular webview")
330- } else {
331- log.Debug().
332- Uint64("parent_webview_id", uint64(parentID)).
333- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
334- Msg("related popup webviews known unsupported, creating regular webview")
335- }
336-
337- popupWV, fallbackErr := c.popups.factory.Create(ctx)
338- if fallbackErr != nil {
339- if relatedErr != nil {
340- return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
341- }
342- return nil, targetURI, false, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
343- }
344- if popupWV == nil {
345- return nil, targetURI, false, fmt.Errorf("popup webview factory returned nil without error")
346- }
347-
348- return popupWV, targetURI, true, nil
349 }234 }
350 235
351 // handlePopupCreate handles a popup request from the current WebView.236 // handlePopupCreate handles a popup request from the current WebView.
@@ -360,91 +245,7 @@ func (c *Coordinator) handlePopupCreate(
360 parentWV port.WebView,245 parentWV port.WebView,
361 req port.PopupRequest,246 req port.PopupRequest,
362 ) port.WebView {247 ) port.WebView {
363- log := logging.FromContext(ctx)248+ return c.ensurePopupManager().handlePopupCreate(ctx, c.popupHooks(), parentPaneID, parentWV, req)
364-
365- log.Debug().
366- Str("parent_pane", string(parentPaneID)).
367- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
368- Str("frame_name", req.FrameName).
369- Bool("user_gesture", req.IsUserGesture).
370- Bool("no_javascript_access", req.NoJavaScriptAccess).
371- Msg("popup create request")
372-
373- // Check if popups are enabled
374- if c.popups.popupConfig != nil && !c.popups.popupConfig.OpenInNewPane {
375- log.Debug().Msg("popups disabled by config, blocking")
376- return nil
377- }
378-
379- // Check if factory is available
380- if c.popups.factory == nil {
381- log.Warn().Msg("no webview factory, cannot create popup")
382- return nil
383- }
384-
385- parentID := parentWV.ID()
386- parentURIAtOpen := ""
387- if c.popups.popupConfig != nil && c.popups.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
388- if parentWV != nil {
389- parentURIAtOpen = parentWV.URI()
390- }
391- if parentURIAtOpen == "" {
392- if parent := c.getWebViewLocked(parentPaneID); parent != nil {
393- parentURIAtOpen = parent.URI()
394- }
395- }
396- }
397- if !req.NoJavaScriptAccess {
398- if reused, ok := c.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
399- return reused
400- }
401- }
402-
403- // Prefer a related WebView for session sharing, but fall back to a regular
404- // WebView when the active engine cannot provide a related popup instance for
405- // this request.
406- popupWV, effectiveTargetURI, usedRegularFallback, err := c.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
407- if err != nil {
408- log.Error().Err(err).Msg("failed to create webview for popup")
409- return nil
410- }
411-
412- if effectiveTargetURI != req.TargetURI {
413- req.TargetURI = effectiveTargetURI
414- }
415- if usedRegularFallback {
416- if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {
417- openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
418- }
419- }
420-
421- // Detect popup type
422- popupType := DetectPopupType(req.FrameName)
423- popupID := popupWV.ID()
424-
425- // Determine behavior from config
426- behavior := GetBehavior(popupType, c.popups.popupConfig)
427- placement := "right"
428- if c.popups.popupConfig != nil {
429- placement = c.popups.popupConfig.Placement
430- }
431-
432- // Create popup pane entity
433- paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
434-
435- return c.finishPopupCreate(ctx, popupCreateContext{
436- ParentPaneID: parentPaneID,
437- ParentWebViewID: parentID,
438- ParentURIAtOpen: parentURIAtOpen,
439- PopupID: popupID,
440- PopupWebView: popupWV,
441- PopupPane: popupPane,
442- PopupPaneID: paneID,
443- PopupType: popupType,
444- Behavior: behavior,
445- Placement: placement,
446- Request: req,
447- })
448 }249 }
449 250
450 func (c *Coordinator) reuseNamedPopup(251 func (c *Coordinator) reuseNamedPopup(
@@ -453,355 +254,27 @@ func (c *Coordinator) reuseNamedPopup(
453 frameName string,254 frameName string,
454 targetURI string,255 targetURI string,
455 ) (port.WebView, bool) {256 ) (port.WebView, bool) {
456- log := logging.FromContext(ctx)257+ return c.ensurePopupManager().reuseNamedPopup(ctx, parentPaneID, frameName, targetURI)
457-
458- if existing, ok := c.lookupReusableNamedPopup(parentPaneID, frameName); ok {
459- c.updatePendingPopupTarget(existing.WebView.ID(), targetURI)
460- if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {
461- log.Warn().Err(err).
462- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
463- Msg("failed to load target URI in reused popup")
464- }
465- log.Info().
466- Str("parent_pane", string(parentPaneID)).
467- Str("frame_name", frameName).
468- Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
469- Msg("reused named popup")
470- return existing.WebView, true
471- }
472-
473- return nil, false
474 }258 }
475 259
476 func (c *Coordinator) finishPopupCreate(ctx context.Context, create popupCreateContext) port.WebView {260 func (c *Coordinator) finishPopupCreate(ctx context.Context, create popupCreateContext) port.WebView {
477- log := logging.FromContext(ctx)261+ return c.ensurePopupManager().finishPopupCreate(ctx, c.popupHooks(), create)
478- hasConfig := c.popups.popupConfig != nil
479- oauthEnabled := hasConfig && c.popups.popupConfig.OAuthAutoClose
480- isOAuth := IsOAuthURL(create.Request.TargetURI)
481-
482- log.Debug().
483- Bool("has_config", hasConfig).
484- Bool("oauth_enabled", oauthEnabled).
485- Bool("is_oauth", isOAuth).
486- Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
487- Msg("popup OAuth check")
488-
489- // Initialize callbacks before publishing the WebView so any reader that sees
490- // the popup during workspace insertion gets a fully configured instance.
491- c.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
492-
493- // Register the popup WebView before workspace insertion so split/stack UI
494- // updates can reuse the real popup instead of acquiring a placeholder pane
495- // WebView that races the actual popup flow.
496- c.RegisterPopupWebView(create.PopupPaneID, create.PopupWebView)
497- inserted := false
498- defer func() {
499- if inserted {
500- return
501- }
502- if current := c.getWebViewLocked(create.PopupPaneID); current == create.PopupWebView {
503- c.deleteWebViewLocked(create.PopupPaneID)
504- }
505- }()
506-
507- // Insert into workspace IMMEDIATELY (WebView stays hidden)
508- // This is required for WebKit to establish window.opener relationship.
509- if c.popups.onInsertPopup != nil {
510- popupInput := InsertPopupInput{
511- ParentPaneID: create.ParentPaneID,
512- PopupPane: create.PopupPane,
513- WebView: create.PopupWebView,
514- Behavior: create.Behavior,
515- Placement: create.Placement,
516- PopupType: create.PopupType,
517- TargetURI: create.Request.TargetURI,
518- }
519-
520- if err := c.popups.onInsertPopup(ctx, popupInput); err != nil {
521- log.Error().Err(err).Msg("failed to insert popup into workspace")
522- create.PopupWebView.Destroy()
523- return nil
524- }
525- }
526- inserted = true
527-
528- if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
529- lifecycle.PrimePopupNavigation(create.Request.TargetURI)
530- }
531-
532- if !create.Request.NoJavaScriptAccess {
533- c.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupWebView)
534- }
535-
536- // Engines without native popup lifecycle hooks can still support
537- // programmatic popup closure (proxy.close(), OAuth auto-close) by exposing
538- // the optional OAuthCallbackCapable interface.
539- if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
540- if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
541- closeCapable.AddCloseCallback(func() {
542- c.handlePopupClose(ctx, create.PopupID)
543- })
544- }
545- }
546-
547- // Setup OAuth auto-close if configured.
548- if hasConfig && oauthEnabled && isOAuth {
549- create.PopupPane.AutoClose = true
550- c.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
551- c.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
552- log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
553- }
554-
555- // Store pending popup for ready-to-show handling (just visibility now).
556- pending := &PendingPopup{
557- PaneID: create.PopupPaneID,
558- WebView: create.PopupWebView,
559- ParentPaneID: create.ParentPaneID,
560- ParentWebViewID: create.ParentWebViewID,
561- TargetURI: create.Request.TargetURI,
562- FrameName: create.Request.FrameName,
563- IsUserGesture: create.Request.IsUserGesture,
564- PopupType: create.PopupType,
565- CreatedAt: time.Now(),
566- }
567-
568- c.popups.mu.Lock()
569- c.popups.pendingPopups[create.PopupID] = pending
570- c.popups.mu.Unlock()
571-
572- // Wire ready-to-show and close signals via the popup lifecycle interface.
573- if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
574- lifecycle.SetOnReadyToShow(func() {
575- c.handlePopupReadyToShow(ctx, create.PopupID)
576- })
577- lifecycle.SetOnClose(func() {
578- c.handlePopupClose(ctx, create.PopupID)
579- })
580- log.Info().
581- Uint64("popup_id", uint64(create.PopupID)).
582- Str("pane_id", string(create.PopupPaneID)).
583- Str("popup_type", create.PopupType.String()).
584- Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
585- Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
586- } else {
587- // Engine does not expose popup lifecycle callbacks. Treat the inserted
588- // WebView as ready immediately and continue with the same coordinator flow.
589- log.Info().
590- Uint64("popup_id", uint64(create.PopupID)).
591- Str("pane_id", string(create.PopupPaneID)).
592- Str("popup_type", create.PopupType.String()).
593- Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
594- Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
595- c.handlePopupReadyToShow(ctx, create.PopupID)
596- }
597-
598- return create.PopupWebView
599 }262 }
600 263
601 // handlePopupReadyToShow handles the WebKit "ready-to-show" signal.264 // handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
602 // The popup WebView was already inserted into the workspace (hidden) during265 // The popup WebView was already inserted into the workspace (hidden) during
603 // the create signal. This handler just makes it visible.266 // the create signal. This handler just makes it visible.
604 func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {267 func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
605- log := logging.FromContext(ctx)268+ c.ensurePopupManager().handlePopupReadyToShow(ctx, popupID)
606-
607- // Get pending popup
608- c.popups.mu.Lock()
609- pending, ok := c.popups.pendingPopups[popupID]
610- if ok {
611- delete(c.popups.pendingPopups, popupID)
612- }
613- c.popups.mu.Unlock()
614-
615- if !ok || pending == nil {
616- log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
617- return
618- }
619-
620- log.Debug().
621- Uint64("popup_id", uint64(popupID)).
622- Str("popup_type", pending.PopupType.String()).
623- Msg("popup ready to show - making visible")
624-
625- // Make the WebView visible now that it's ready.
626- if pending.WebView != nil {
627- if lifecycle, ok := pending.WebView.(port.PopupLifecycleCapable); ok {
628- lifecycle.Show()
629- }
630- _, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
631- // Engines that do not preload popup navigation can request the target URI
632- // here once the popup becomes visible.
633- if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
634- if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
635- log.Warn().Err(err).
636- Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
637- Msg("failed to load target URI in popup")
638- }
639- }
640- }
641-
642- log.Info().
643- Uint64("popup_id", uint64(popupID)).
644- Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
645- Msg("popup now visible")
646 }269 }
647 270
648 // handlePopupClose handles the WebKit "close" signal for popup windows.271 // handlePopupClose handles the WebKit "close" signal for popup windows.
649 func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {272 func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
650- log := logging.FromContext(ctx)273+ c.ensurePopupManager().handlePopupClose(ctx, c.popupHooks(), popupID)
651- log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
652-
653- // Check if still pending (never shown)
654- c.popups.mu.Lock()
655- pending, wasPending := c.popups.pendingPopups[popupID]
656- if wasPending {
657- delete(c.popups.pendingPopups, popupID)
658- }
659- c.popups.mu.Unlock()
660-
661- if wasPending && pending != nil {
662- c.handlePopupOAuthClose(ctx, popupID)
663- if c.popups.onClosePane != nil {
664- if err := c.popups.onClosePane(ctx, pending.PaneID); err != nil {
665- log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
666- }
667- }
668- c.clearReusableNamedPopupByWebViewID(popupID)
669- if c.getWebViewLocked(pending.PaneID) != nil {
670- c.ReleaseWebView(ctx, pending.PaneID)
671- }
672- log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
673- return
674- }
675-
676- // Find pane by WebView ID.
677- paneID, ok := c.paneIDByWebViewID(popupID)
678- if (!ok || paneID == "") && popupID != 0 {
679- c.webViewsMu.RLock()
680- for pid, wv := range c.webViews {
681- if wv != nil && wv.ID() == popupID {
682- paneID = pid
683- ok = true
684- break
685- }
686- }
687- c.webViewsMu.RUnlock()
688- }
689- if !ok || paneID == "" {
690- c.handlePopupOAuthClose(ctx, popupID)
691- c.clearReusableNamedPopupByWebViewID(popupID)
692- log.Warn().Msg("popup close: could not find pane for webview")
693- return
694- }
695-
696- c.handlePopupOAuthClose(ctx, popupID)
697-
698- // Close the pane in workspace (this removes the UI element)
699- if c.popups.onClosePane != nil {
700- if err := c.popups.onClosePane(ctx, paneID); err != nil {
701- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
702- }
703- }
704-
705- c.clearReusableNamedPopupByWebViewID(popupID)
706-
707- // ClosePaneByID usually releases the WebView already; only release here if it
708- // is still registered.
709- if c.getWebViewLocked(paneID) != nil {
710- c.ReleaseWebView(ctx, paneID)
711- }
712-
713- log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
714 }274 }
715 275
716 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.276 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.
717 // Opens the link in a new pane using blank_target_behavior config.277 // Opens the link in a new pane using blank_target_behavior config.
718 func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {278 func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
719- log := logging.FromContext(ctx)279+ return c.ensurePopupManager().handleLinkMiddleClick(ctx, c.popupHooks(), parentPaneID, uri)
720-
721- log.Info().
722- Str("parent_pane", string(parentPaneID)).
723- Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
724- Msg("middle-click/ctrl+click on link")
725-
726- // Check if popups are enabled
727- if c.popups.popupConfig != nil && !c.popups.popupConfig.OpenInNewPane {
728- log.Debug().Msg("popups disabled by config, ignoring middle-click")
729- return false
730- }
731-
732- // Check if factory is available
733- if c.popups.factory == nil {
734- log.Warn().Msg("no webview factory, cannot handle middle-click")
735- return false
736- }
737-
738- // Create a new WebView (regular, not related - just opening a link)
739- newWV, err := c.popups.factory.Create(ctx)
740- if err != nil {
741- log.Error().Err(err).Msg("failed to create webview for middle-click")
742- return false
743- }
744-
745- // Generate pane ID
746- var paneID entity.PaneID
747- if c.popups.generatePaneID != nil {
748- paneID = entity.PaneID(c.popups.generatePaneID())
749- } else {
750- paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
751- }
752-
753- // Create pane entity - uses blank_target_behavior
754- newPane := entity.NewPane(paneID)
755- newPane.WindowType = entity.WindowPopup
756- newPane.URI = uri
757-
758- // Register WebView
759- c.setWebViewLocked(paneID, newWV)
760-
761- // Setup callbacks
762- c.setupWebViewCallbacks(ctx, paneID, newWV)
763-
764- // Get behavior from config (same as _blank links)
765- behavior := entity.PopupBehaviorStacked // default
766- if c.popups.popupConfig != nil && c.popups.popupConfig.BlankTargetBehavior != "" {
767- behavior = entity.PopupBehavior(c.popups.popupConfig.BlankTargetBehavior)
768- }
769- placement := "right"
770- if c.popups.popupConfig != nil {
771- placement = c.popups.popupConfig.Placement
772- }
773-
774- // Request insertion into workspace
775- if c.popups.onInsertPopup != nil {
776- popupInput := InsertPopupInput{
777- ParentPaneID: parentPaneID,
778- PopupPane: newPane,
779- WebView: newWV,
780- Behavior: behavior,
781- Placement: placement,
782- PopupType: PopupTypeTab, // Treat like _blank
783- TargetURI: uri,
784- }
785-
786- if err := c.popups.onInsertPopup(ctx, popupInput); err != nil {
787- log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
788- // Clean up on failure — use the locking helper to avoid a data race.
789- c.deleteWebViewLocked(paneID)
790- newWV.Destroy()
791- return false
792- }
793- }
794-
795- // Load the URI after insertion
796- if err := newWV.LoadURI(ctx, uri); err != nil {
797- log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
798- }
799-
800- log.Info().
801- Str("pane_id", string(paneID)).
802- Str("behavior", string(behavior)).
803- Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
804- Msg("middle-click link opened in new pane")
805-
806- return true
807 }280 }
M internal/ui/coordinator/content/popup_manager.go +673 -0
@@ -2,12 +2,16 @@ package content
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
7+ "strings"
68 "sync"
79 "time"
810
911 "github.com/bnema/dumber/internal/application/port"
1012 "github.com/bnema/dumber/internal/domain/entity"
13+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
14+ "github.com/bnema/dumber/internal/logging"
1115 )
1216
1317 // popupManager owns popup-specific coordinator state. It stays in the UI
@@ -51,6 +55,18 @@ type popupCreateContext struct {
5155 Request port.PopupRequest
5256 }
5357
58+type popupCoordinatorHooks struct {
59+ setupWebViewCallbacks func(context.Context, entity.PaneID, port.WebView)
60+ registerPopupWebView func(entity.PaneID, port.WebView)
61+ setWebView func(entity.PaneID, port.WebView)
62+ getWebView func(entity.PaneID) port.WebView
63+ deleteWebView func(entity.PaneID) port.WebView
64+ releaseWebView func(context.Context, entity.PaneID)
65+ findPaneByWebViewID func(port.WebViewID) (entity.PaneID, bool)
66+ setupOAuthAutoClose func(context.Context, entity.PaneID, port.WebViewID, port.WebView)
67+ handlePopupOAuthClose func(context.Context, port.WebViewID)
68+}
69+
5470 func newPopupManager() *popupManager {
5571 pm := &popupManager{}
5672 pm.ensureInitialized()
@@ -128,3 +144,660 @@ func (pm *popupManager) createPopupPane(
128144
129145 return paneID, popupPane
130146 }
147+
148+func (pm *popupManager) relatedPopupSupportDisabled() bool {
149+ if pm == nil {
150+ return false
151+ }
152+ pm.mu.RLock()
153+ defer pm.mu.RUnlock()
154+ return pm.relatedPopupSupportDetected && pm.relatedPopupUnsupported
155+}
156+
157+func (pm *popupManager) markRelatedPopupUnsupported() {
158+ if pm == nil {
159+ return
160+ }
161+ pm.mu.Lock()
162+ defer pm.mu.Unlock()
163+ pm.relatedPopupSupportDetected = true
164+ pm.relatedPopupUnsupported = true
165+}
166+
167+func (pm *popupManager) markRelatedPopupSupported() {
168+ if pm == nil {
169+ return
170+ }
171+ pm.mu.Lock()
172+ defer pm.mu.Unlock()
173+ pm.relatedPopupSupportDetected = true
174+ pm.relatedPopupUnsupported = false
175+}
176+
177+func (pm *popupManager) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {
178+ if pm == nil || !isReusableNamedPopupFrame(frameName) {
179+ return nil, false
180+ }
181+
182+ key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
183+
184+ pm.mu.RLock()
185+ state, ok := pm.namedPopups[key]
186+ pm.mu.RUnlock()
187+ if !ok || state == nil || state.WebView == nil {
188+ return nil, false
189+ }
190+
191+ if state.WebView.IsDestroyed() {
192+ pm.mu.Lock()
193+ if current, ok := pm.namedPopups[key]; ok && current == state {
194+ delete(pm.namedPopups, key)
195+ }
196+ pm.mu.Unlock()
197+ return nil, false
198+ }
199+
200+ return state, true
201+}
202+
203+func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, wv port.WebView) {
204+ if pm == nil || !isReusableNamedPopupFrame(frameName) || wv == nil {
205+ return
206+ }
207+
208+ key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
209+ pm.mu.Lock()
210+ pm.namedPopups[key] = &namedPopupState{WebView: wv}
211+ pm.mu.Unlock()
212+}
213+
214+func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
215+ if pm == nil {
216+ return
217+ }
218+ pm.mu.Lock()
219+ if pending, ok := pm.pendingPopups[popupID]; ok && pending != nil {
220+ pending.TargetURI = targetURI
221+ }
222+ pm.mu.Unlock()
223+}
224+
225+func (pm *popupManager) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
226+ if pm == nil {
227+ return
228+ }
229+ pm.mu.Lock()
230+ for key, state := range pm.namedPopups {
231+ if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
232+ delete(pm.namedPopups, key)
233+ }
234+ }
235+ pm.mu.Unlock()
236+}
237+
238+func (pm *popupManager) storePendingPopup(popupID port.WebViewID, pending *PendingPopup) {
239+ if pm == nil || pending == nil {
240+ return
241+ }
242+ pm.mu.Lock()
243+ pm.pendingPopups[popupID] = pending
244+ pm.mu.Unlock()
245+}
246+
247+func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, bool) {
248+ if pm == nil {
249+ return nil, false
250+ }
251+ pm.mu.Lock()
252+ defer pm.mu.Unlock()
253+ pending, ok := pm.pendingPopups[popupID]
254+ if ok {
255+ delete(pm.pendingPopups, popupID)
256+ }
257+ return pending, ok
258+}
259+
260+func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
261+ if pm == nil {
262+ return
263+ }
264+ pm.mu.Lock()
265+ pm.popupOAuth[popupID] = &popupOAuthState{
266+ ParentPaneID: parentPaneID,
267+ ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
268+ }
269+ pm.mu.Unlock()
270+}
271+
272+func (pm *popupManager) capturePopupOAuthState(popupID port.WebViewID, uri string) {
273+ if pm == nil {
274+ return
275+ }
276+ pm.mu.Lock()
277+ defer pm.mu.Unlock()
278+ state, ok := pm.popupOAuth[popupID]
279+ if !ok {
280+ return
281+ }
282+ state.Seen = true
283+ state.CallbackURI = uri
284+ state.Success = IsOAuthSuccess(uri)
285+ state.Error = IsOAuthError(uri)
286+}
287+
288+func (pm *popupManager) capturePopupOAuthMessage(popupID port.WebViewID) {
289+ if pm == nil {
290+ return
291+ }
292+ pm.mu.Lock()
293+ defer pm.mu.Unlock()
294+ state, ok := pm.popupOAuth[popupID]
295+ if !ok {
296+ return
297+ }
298+ state.Seen = true
299+ if state.CallbackURI == "" {
300+ state.CallbackURI = "postmessage://oauth-complete"
301+ }
302+ if !state.Error {
303+ state.Success = true
304+ }
305+}
306+
307+func (pm *popupManager) takePopupOAuthState(popupID port.WebViewID) (*popupOAuthState, bool) {
308+ if pm == nil {
309+ return nil, false
310+ }
311+ pm.mu.Lock()
312+ defer pm.mu.Unlock()
313+ state, ok := pm.popupOAuth[popupID]
314+ if ok {
315+ delete(pm.popupOAuth, popupID)
316+ }
317+ return state, ok
318+}
319+
320+func (pm *popupManager) schedulePopupRefresh(parentPaneID entity.PaneID, debounce time.Duration, fn func()) {
321+ if pm == nil || fn == nil {
322+ return
323+ }
324+ pm.mu.Lock()
325+ if existing := pm.popupRefresh[parentPaneID]; existing != nil {
326+ existing.Stop()
327+ }
328+ pm.popupRefresh[parentPaneID] = time.AfterFunc(debounce, func() {
329+ pm.mu.Lock()
330+ delete(pm.popupRefresh, parentPaneID)
331+ pm.mu.Unlock()
332+ fn()
333+ })
334+ pm.mu.Unlock()
335+}
336+
337+func (pm *popupManager) createPopupWebView(
338+ ctx context.Context,
339+ parentID port.WebViewID,
340+ targetURI string,
341+ noJavaScriptAccess bool,
342+) (port.WebView, string, bool, error) {
343+ if pm == nil || pm.factory == nil {
344+ return nil, targetURI, false, fmt.Errorf("no webview factory configured")
345+ }
346+
347+ log := logging.FromContext(ctx)
348+ relatedErr := error(nil)
349+
350+ if !noJavaScriptAccess && !pm.relatedPopupSupportDisabled() {
351+ popupWV, err := pm.factory.CreateRelated(ctx, parentID)
352+ if err == nil && popupWV != nil {
353+ pm.markRelatedPopupSupported()
354+ return popupWV, targetURI, false, nil
355+ }
356+ if err == nil {
357+ relatedErr = fmt.Errorf("related popup webview factory returned nil without error")
358+ log.Warn().
359+ Uint64("parent_webview_id", uint64(parentID)).
360+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
361+ Msg("related popup webview factory returned nil popup, falling back to regular webview")
362+ } else if errors.Is(err, domainerrors.ErrRelatedWebViewUnsupported) {
363+ relatedErr = err
364+ pm.markRelatedPopupUnsupported()
365+ log.Debug().
366+ Err(err).
367+ Uint64("parent_webview_id", uint64(parentID)).
368+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
369+ Msg("related popup webview unavailable, falling back to regular webview")
370+ } else {
371+ relatedErr = err
372+ log.Warn().
373+ Err(err).
374+ Uint64("parent_webview_id", uint64(parentID)).
375+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
376+ Msg("related popup webview creation failed, falling back to regular webview")
377+ }
378+ } else if noJavaScriptAccess {
379+ log.Debug().
380+ Uint64("parent_webview_id", uint64(parentID)).
381+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
382+ Msg("popup requested no JavaScript opener access, creating regular webview")
383+ } else {
384+ log.Debug().
385+ Uint64("parent_webview_id", uint64(parentID)).
386+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
387+ Msg("related popup webviews known unsupported, creating regular webview")
388+ }
389+
390+ popupWV, fallbackErr := pm.factory.Create(ctx)
391+ if fallbackErr != nil {
392+ if relatedErr != nil {
393+ return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
394+ }
395+ return nil, targetURI, false, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
396+ }
397+ if popupWV == nil {
398+ if relatedErr != nil {
399+ return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback returned nil", relatedErr)
400+ }
401+ return nil, targetURI, false, fmt.Errorf("create popup webview: fallback returned nil")
402+ }
403+
404+ return popupWV, targetURI, true, nil
405+}
406+
407+func (pm *popupManager) reuseNamedPopup(
408+ ctx context.Context,
409+ parentPaneID entity.PaneID,
410+ frameName string,
411+ targetURI string,
412+) (port.WebView, bool) {
413+ log := logging.FromContext(ctx)
414+
415+ if existing, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName); ok {
416+ pm.updatePendingPopupTarget(existing.WebView.ID(), targetURI)
417+ if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {
418+ log.Warn().Err(err).
419+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
420+ Msg("failed to load target URI in reused popup")
421+ }
422+ log.Info().
423+ Str("parent_pane", string(parentPaneID)).
424+ Str("frame_name", frameName).
425+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
426+ Msg("reused named popup")
427+ return existing.WebView, true
428+ }
429+
430+ return nil, false
431+}
432+
433+func (pm *popupManager) handlePopupCreate(
434+ ctx context.Context,
435+ hooks popupCoordinatorHooks,
436+ parentPaneID entity.PaneID,
437+ parentWV port.WebView,
438+ req port.PopupRequest,
439+) port.WebView {
440+ log := logging.FromContext(ctx)
441+
442+ log.Debug().
443+ Str("parent_pane", string(parentPaneID)).
444+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
445+ Str("frame_name", req.FrameName).
446+ Bool("user_gesture", req.IsUserGesture).
447+ Bool("no_javascript_access", req.NoJavaScriptAccess).
448+ Msg("popup create request")
449+
450+ if pm.popupConfig != nil && !pm.popupConfig.OpenInNewPane {
451+ log.Debug().Msg("popups disabled by config, blocking")
452+ return nil
453+ }
454+ if pm.factory == nil {
455+ log.Warn().Msg("no webview factory, cannot create popup")
456+ return nil
457+ }
458+
459+ parentID := parentWV.ID()
460+ parentURIAtOpen := ""
461+ if pm.popupConfig != nil && pm.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
462+ parentURIAtOpen = parentWV.URI()
463+ if parentURIAtOpen == "" && hooks.getWebView != nil {
464+ if parent := hooks.getWebView(parentPaneID); parent != nil {
465+ parentURIAtOpen = parent.URI()
466+ }
467+ }
468+ }
469+ if !req.NoJavaScriptAccess {
470+ if reused, ok := pm.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
471+ return reused
472+ }
473+ }
474+
475+ popupWV, effectiveTargetURI, usedRegularFallback, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
476+ if err != nil {
477+ log.Error().Err(err).Msg("failed to create webview for popup")
478+ return nil
479+ }
480+
481+ if effectiveTargetURI != req.TargetURI {
482+ req.TargetURI = effectiveTargetURI
483+ }
484+ if usedRegularFallback {
485+ if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {
486+ openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
487+ }
488+ }
489+
490+ popupType := DetectPopupType(req.FrameName)
491+ popupID := popupWV.ID()
492+ behavior := GetBehavior(popupType, pm.popupConfig)
493+ placement := "right"
494+ if pm.popupConfig != nil {
495+ placement = pm.popupConfig.Placement
496+ }
497+ paneID, popupPane := pm.createPopupPane(popupID, parentPaneID, req.TargetURI)
498+
499+ return pm.finishPopupCreate(ctx, hooks, popupCreateContext{
500+ ParentPaneID: parentPaneID,
501+ ParentWebViewID: parentID,
502+ ParentURIAtOpen: parentURIAtOpen,
503+ PopupID: popupID,
504+ PopupWebView: popupWV,
505+ PopupPane: popupPane,
506+ PopupPaneID: paneID,
507+ PopupType: popupType,
508+ Behavior: behavior,
509+ Placement: placement,
510+ Request: req,
511+ })
512+}
513+
514+func (pm *popupManager) finishPopupCreate(
515+ ctx context.Context,
516+ hooks popupCoordinatorHooks,
517+ create popupCreateContext,
518+) port.WebView {
519+ log := logging.FromContext(ctx)
520+ hasConfig := pm.popupConfig != nil
521+ oauthEnabled := hasConfig && pm.popupConfig.OAuthAutoClose
522+ isOAuth := IsOAuthURL(create.Request.TargetURI)
523+
524+ log.Debug().
525+ Bool("has_config", hasConfig).
526+ Bool("oauth_enabled", oauthEnabled).
527+ Bool("is_oauth", isOAuth).
528+ Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
529+ Msg("popup OAuth check")
530+
531+ if hooks.setupWebViewCallbacks != nil {
532+ hooks.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
533+ }
534+ if hooks.registerPopupWebView != nil {
535+ hooks.registerPopupWebView(create.PopupPaneID, create.PopupWebView)
536+ }
537+
538+ inserted := false
539+ defer func() {
540+ if inserted {
541+ return
542+ }
543+ if hooks.getWebView != nil && hooks.deleteWebView != nil {
544+ if current := hooks.getWebView(create.PopupPaneID); current == create.PopupWebView {
545+ hooks.deleteWebView(create.PopupPaneID)
546+ }
547+ }
548+ }()
549+
550+ if pm.onInsertPopup != nil {
551+ popupInput := InsertPopupInput{
552+ ParentPaneID: create.ParentPaneID,
553+ PopupPane: create.PopupPane,
554+ WebView: create.PopupWebView,
555+ Behavior: create.Behavior,
556+ Placement: create.Placement,
557+ PopupType: create.PopupType,
558+ TargetURI: create.Request.TargetURI,
559+ }
560+ if err := pm.onInsertPopup(ctx, popupInput); err != nil {
561+ log.Error().Err(err).Msg("failed to insert popup into workspace")
562+ create.PopupWebView.Destroy()
563+ return nil
564+ }
565+ }
566+ inserted = true
567+
568+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
569+ lifecycle.PrimePopupNavigation(create.Request.TargetURI)
570+ }
571+ if !create.Request.NoJavaScriptAccess {
572+ pm.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupWebView)
573+ }
574+ if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
575+ if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
576+ closeCapable.AddCloseCallback(func() {
577+ pm.handlePopupClose(ctx, hooks, create.PopupID)
578+ })
579+ }
580+ }
581+
582+ if hasConfig && oauthEnabled && isOAuth {
583+ create.PopupPane.AutoClose = true
584+ pm.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
585+ if hooks.setupOAuthAutoClose != nil {
586+ hooks.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
587+ }
588+ log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
589+ }
590+
591+ pending := &PendingPopup{
592+ PaneID: create.PopupPaneID,
593+ WebView: create.PopupWebView,
594+ ParentPaneID: create.ParentPaneID,
595+ ParentWebViewID: create.ParentWebViewID,
596+ TargetURI: create.Request.TargetURI,
597+ FrameName: create.Request.FrameName,
598+ IsUserGesture: create.Request.IsUserGesture,
599+ PopupType: create.PopupType,
600+ CreatedAt: time.Now(),
601+ }
602+ pm.storePendingPopup(create.PopupID, pending)
603+
604+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
605+ lifecycle.SetOnReadyToShow(func() {
606+ pm.handlePopupReadyToShow(ctx, create.PopupID)
607+ })
608+ lifecycle.SetOnClose(func() {
609+ pm.handlePopupClose(ctx, hooks, create.PopupID)
610+ })
611+ log.Info().
612+ Uint64("popup_id", uint64(create.PopupID)).
613+ Str("pane_id", string(create.PopupPaneID)).
614+ Str("popup_type", create.PopupType.String()).
615+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
616+ Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
617+ } else {
618+ log.Info().
619+ Uint64("popup_id", uint64(create.PopupID)).
620+ Str("pane_id", string(create.PopupPaneID)).
621+ Str("popup_type", create.PopupType.String()).
622+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
623+ Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
624+ pm.handlePopupReadyToShow(ctx, create.PopupID)
625+ }
626+
627+ return create.PopupWebView
628+}
629+
630+func (pm *popupManager) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
631+ log := logging.FromContext(ctx)
632+ pending, ok := pm.takePendingPopup(popupID)
633+ if !ok || pending == nil {
634+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
635+ return
636+ }
637+
638+ log.Debug().
639+ Uint64("popup_id", uint64(popupID)).
640+ Str("popup_type", pending.PopupType.String()).
641+ Msg("popup ready to show - making visible")
642+
643+ if pending.WebView != nil {
644+ if lifecycle, ok := pending.WebView.(port.PopupLifecycleCapable); ok {
645+ lifecycle.Show()
646+ }
647+ _, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
648+ if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
649+ if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
650+ log.Warn().Err(err).
651+ Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
652+ Msg("failed to load target URI in popup")
653+ }
654+ }
655+ }
656+
657+ log.Info().
658+ Uint64("popup_id", uint64(popupID)).
659+ Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
660+ Msg("popup now visible")
661+}
662+
663+func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordinatorHooks, popupID port.WebViewID) {
664+ log := logging.FromContext(ctx)
665+ log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
666+
667+ pending, wasPending := pm.takePendingPopup(popupID)
668+ if wasPending && pending != nil {
669+ if hooks.handlePopupOAuthClose != nil {
670+ hooks.handlePopupOAuthClose(ctx, popupID)
671+ }
672+ if pm.onClosePane != nil {
673+ if err := pm.onClosePane(ctx, pending.PaneID); err != nil {
674+ log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
675+ }
676+ }
677+ pm.clearReusableNamedPopupByWebViewID(popupID)
678+ if hooks.getWebView != nil && hooks.releaseWebView != nil && hooks.getWebView(pending.PaneID) != nil {
679+ hooks.releaseWebView(ctx, pending.PaneID)
680+ }
681+ log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
682+ return
683+ }
684+
685+ var (
686+ paneID entity.PaneID
687+ ok bool
688+ )
689+ if hooks.findPaneByWebViewID != nil {
690+ paneID, ok = hooks.findPaneByWebViewID(popupID)
691+ }
692+ if !ok || paneID == "" {
693+ if hooks.handlePopupOAuthClose != nil {
694+ hooks.handlePopupOAuthClose(ctx, popupID)
695+ }
696+ pm.clearReusableNamedPopupByWebViewID(popupID)
697+ log.Warn().Msg("popup close: could not find pane for webview")
698+ return
699+ }
700+
701+ if hooks.handlePopupOAuthClose != nil {
702+ hooks.handlePopupOAuthClose(ctx, popupID)
703+ }
704+ if pm.onClosePane != nil {
705+ if err := pm.onClosePane(ctx, paneID); err != nil {
706+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
707+ }
708+ }
709+ pm.clearReusableNamedPopupByWebViewID(popupID)
710+ if hooks.getWebView != nil && hooks.releaseWebView != nil && hooks.getWebView(paneID) != nil {
711+ hooks.releaseWebView(ctx, paneID)
712+ }
713+
714+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
715+}
716+
717+func (pm *popupManager) handleLinkMiddleClick(
718+ ctx context.Context,
719+ hooks popupCoordinatorHooks,
720+ parentPaneID entity.PaneID,
721+ uri string,
722+) bool {
723+ log := logging.FromContext(ctx)
724+
725+ log.Info().
726+ Str("parent_pane", string(parentPaneID)).
727+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
728+ Msg("middle-click/ctrl+click on link")
729+
730+ if pm.popupConfig != nil && !pm.popupConfig.OpenInNewPane {
731+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
732+ return false
733+ }
734+ if pm.factory == nil {
735+ log.Warn().Msg("no webview factory, cannot handle middle-click")
736+ return false
737+ }
738+
739+ newWV, err := pm.factory.Create(ctx)
740+ if err != nil {
741+ log.Error().Err(err).Msg("failed to create webview for middle-click")
742+ return false
743+ }
744+
745+ var paneID entity.PaneID
746+ if pm.generatePaneID != nil {
747+ paneID = entity.PaneID(pm.generatePaneID())
748+ } else {
749+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
750+ }
751+
752+ newPane := entity.NewPane(paneID)
753+ newPane.WindowType = entity.WindowPopup
754+ newPane.URI = uri
755+
756+ if hooks.setWebView != nil {
757+ hooks.setWebView(paneID, newWV)
758+ }
759+ if hooks.setupWebViewCallbacks != nil {
760+ hooks.setupWebViewCallbacks(ctx, paneID, newWV)
761+ }
762+
763+ behavior := entity.PopupBehaviorStacked
764+ if pm.popupConfig != nil && pm.popupConfig.BlankTargetBehavior != "" {
765+ behavior = entity.PopupBehavior(pm.popupConfig.BlankTargetBehavior)
766+ }
767+ placement := "right"
768+ if pm.popupConfig != nil {
769+ placement = pm.popupConfig.Placement
770+ }
771+
772+ if pm.onInsertPopup != nil {
773+ popupInput := InsertPopupInput{
774+ ParentPaneID: parentPaneID,
775+ PopupPane: newPane,
776+ WebView: newWV,
777+ Behavior: behavior,
778+ Placement: placement,
779+ PopupType: PopupTypeTab,
780+ TargetURI: uri,
781+ }
782+ if err := pm.onInsertPopup(ctx, popupInput); err != nil {
783+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
784+ if hooks.deleteWebView != nil {
785+ hooks.deleteWebView(paneID)
786+ }
787+ newWV.Destroy()
788+ return false
789+ }
790+ }
791+
792+ if err := newWV.LoadURI(ctx, uri); err != nil {
793+ log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
794+ }
795+
796+ log.Info().
797+ Str("pane_id", string(paneID)).
798+ Str("behavior", string(behavior)).
799+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
800+ Msg("middle-click link opened in new pane")
801+
802+ return true
803+}
@@ -2,12 +2,16 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
7+ "strings"
6 "sync"8 "sync"
7 "time"9 "time"
8 10
9 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
14+ "github.com/bnema/dumber/internal/logging"
11 )15 )
12 16
13 // popupManager owns popup-specific coordinator state. It stays in the UI17 // popupManager owns popup-specific coordinator state. It stays in the UI
@@ -51,6 +55,18 @@ type popupCreateContext struct {
51 Request port.PopupRequest55 Request port.PopupRequest
52 }56 }
53 57
58+type popupCoordinatorHooks struct {
59+ setupWebViewCallbacks func(context.Context, entity.PaneID, port.WebView)
60+ registerPopupWebView func(entity.PaneID, port.WebView)
61+ setWebView func(entity.PaneID, port.WebView)
62+ getWebView func(entity.PaneID) port.WebView
63+ deleteWebView func(entity.PaneID) port.WebView
64+ releaseWebView func(context.Context, entity.PaneID)
65+ findPaneByWebViewID func(port.WebViewID) (entity.PaneID, bool)
66+ setupOAuthAutoClose func(context.Context, entity.PaneID, port.WebViewID, port.WebView)
67+ handlePopupOAuthClose func(context.Context, port.WebViewID)
68+}
69+
54 func newPopupManager() *popupManager {70 func newPopupManager() *popupManager {
55 pm := &popupManager{}71 pm := &popupManager{}
56 pm.ensureInitialized()72 pm.ensureInitialized()
@@ -128,3 +144,660 @@ func (pm *popupManager) createPopupPane(
128 144
129 return paneID, popupPane145 return paneID, popupPane
130 }146 }
147+
148+func (pm *popupManager) relatedPopupSupportDisabled() bool {
149+ if pm == nil {
150+ return false
151+ }
152+ pm.mu.RLock()
153+ defer pm.mu.RUnlock()
154+ return pm.relatedPopupSupportDetected && pm.relatedPopupUnsupported
155+}
156+
157+func (pm *popupManager) markRelatedPopupUnsupported() {
158+ if pm == nil {
159+ return
160+ }
161+ pm.mu.Lock()
162+ defer pm.mu.Unlock()
163+ pm.relatedPopupSupportDetected = true
164+ pm.relatedPopupUnsupported = true
165+}
166+
167+func (pm *popupManager) markRelatedPopupSupported() {
168+ if pm == nil {
169+ return
170+ }
171+ pm.mu.Lock()
172+ defer pm.mu.Unlock()
173+ pm.relatedPopupSupportDetected = true
174+ pm.relatedPopupUnsupported = false
175+}
176+
177+func (pm *popupManager) lookupReusableNamedPopup(parentPaneID entity.PaneID, frameName string) (*namedPopupState, bool) {
178+ if pm == nil || !isReusableNamedPopupFrame(frameName) {
179+ return nil, false
180+ }
181+
182+ key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
183+
184+ pm.mu.RLock()
185+ state, ok := pm.namedPopups[key]
186+ pm.mu.RUnlock()
187+ if !ok || state == nil || state.WebView == nil {
188+ return nil, false
189+ }
190+
191+ if state.WebView.IsDestroyed() {
192+ pm.mu.Lock()
193+ if current, ok := pm.namedPopups[key]; ok && current == state {
194+ delete(pm.namedPopups, key)
195+ }
196+ pm.mu.Unlock()
197+ return nil, false
198+ }
199+
200+ return state, true
201+}
202+
203+func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, wv port.WebView) {
204+ if pm == nil || !isReusableNamedPopupFrame(frameName) || wv == nil {
205+ return
206+ }
207+
208+ key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
209+ pm.mu.Lock()
210+ pm.namedPopups[key] = &namedPopupState{WebView: wv}
211+ pm.mu.Unlock()
212+}
213+
214+func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
215+ if pm == nil {
216+ return
217+ }
218+ pm.mu.Lock()
219+ if pending, ok := pm.pendingPopups[popupID]; ok && pending != nil {
220+ pending.TargetURI = targetURI
221+ }
222+ pm.mu.Unlock()
223+}
224+
225+func (pm *popupManager) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
226+ if pm == nil {
227+ return
228+ }
229+ pm.mu.Lock()
230+ for key, state := range pm.namedPopups {
231+ if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
232+ delete(pm.namedPopups, key)
233+ }
234+ }
235+ pm.mu.Unlock()
236+}
237+
238+func (pm *popupManager) storePendingPopup(popupID port.WebViewID, pending *PendingPopup) {
239+ if pm == nil || pending == nil {
240+ return
241+ }
242+ pm.mu.Lock()
243+ pm.pendingPopups[popupID] = pending
244+ pm.mu.Unlock()
245+}
246+
247+func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, bool) {
248+ if pm == nil {
249+ return nil, false
250+ }
251+ pm.mu.Lock()
252+ defer pm.mu.Unlock()
253+ pending, ok := pm.pendingPopups[popupID]
254+ if ok {
255+ delete(pm.pendingPopups, popupID)
256+ }
257+ return pending, ok
258+}
259+
260+func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
261+ if pm == nil {
262+ return
263+ }
264+ pm.mu.Lock()
265+ pm.popupOAuth[popupID] = &popupOAuthState{
266+ ParentPaneID: parentPaneID,
267+ ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
268+ }
269+ pm.mu.Unlock()
270+}
271+
272+func (pm *popupManager) capturePopupOAuthState(popupID port.WebViewID, uri string) {
273+ if pm == nil {
274+ return
275+ }
276+ pm.mu.Lock()
277+ defer pm.mu.Unlock()
278+ state, ok := pm.popupOAuth[popupID]
279+ if !ok {
280+ return
281+ }
282+ state.Seen = true
283+ state.CallbackURI = uri
284+ state.Success = IsOAuthSuccess(uri)
285+ state.Error = IsOAuthError(uri)
286+}
287+
288+func (pm *popupManager) capturePopupOAuthMessage(popupID port.WebViewID) {
289+ if pm == nil {
290+ return
291+ }
292+ pm.mu.Lock()
293+ defer pm.mu.Unlock()
294+ state, ok := pm.popupOAuth[popupID]
295+ if !ok {
296+ return
297+ }
298+ state.Seen = true
299+ if state.CallbackURI == "" {
300+ state.CallbackURI = "postmessage://oauth-complete"
301+ }
302+ if !state.Error {
303+ state.Success = true
304+ }
305+}
306+
307+func (pm *popupManager) takePopupOAuthState(popupID port.WebViewID) (*popupOAuthState, bool) {
308+ if pm == nil {
309+ return nil, false
310+ }
311+ pm.mu.Lock()
312+ defer pm.mu.Unlock()
313+ state, ok := pm.popupOAuth[popupID]
314+ if ok {
315+ delete(pm.popupOAuth, popupID)
316+ }
317+ return state, ok
318+}
319+
320+func (pm *popupManager) schedulePopupRefresh(parentPaneID entity.PaneID, debounce time.Duration, fn func()) {
321+ if pm == nil || fn == nil {
322+ return
323+ }
324+ pm.mu.Lock()
325+ if existing := pm.popupRefresh[parentPaneID]; existing != nil {
326+ existing.Stop()
327+ }
328+ pm.popupRefresh[parentPaneID] = time.AfterFunc(debounce, func() {
329+ pm.mu.Lock()
330+ delete(pm.popupRefresh, parentPaneID)
331+ pm.mu.Unlock()
332+ fn()
333+ })
334+ pm.mu.Unlock()
335+}
336+
337+func (pm *popupManager) createPopupWebView(
338+ ctx context.Context,
339+ parentID port.WebViewID,
340+ targetURI string,
341+ noJavaScriptAccess bool,
342+) (port.WebView, string, bool, error) {
343+ if pm == nil || pm.factory == nil {
344+ return nil, targetURI, false, fmt.Errorf("no webview factory configured")
345+ }
346+
347+ log := logging.FromContext(ctx)
348+ relatedErr := error(nil)
349+
350+ if !noJavaScriptAccess && !pm.relatedPopupSupportDisabled() {
351+ popupWV, err := pm.factory.CreateRelated(ctx, parentID)
352+ if err == nil && popupWV != nil {
353+ pm.markRelatedPopupSupported()
354+ return popupWV, targetURI, false, nil
355+ }
356+ if err == nil {
357+ relatedErr = fmt.Errorf("related popup webview factory returned nil without error")
358+ log.Warn().
359+ Uint64("parent_webview_id", uint64(parentID)).
360+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
361+ Msg("related popup webview factory returned nil popup, falling back to regular webview")
362+ } else if errors.Is(err, domainerrors.ErrRelatedWebViewUnsupported) {
363+ relatedErr = err
364+ pm.markRelatedPopupUnsupported()
365+ log.Debug().
366+ Err(err).
367+ Uint64("parent_webview_id", uint64(parentID)).
368+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
369+ Msg("related popup webview unavailable, falling back to regular webview")
370+ } else {
371+ relatedErr = err
372+ log.Warn().
373+ Err(err).
374+ Uint64("parent_webview_id", uint64(parentID)).
375+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
376+ Msg("related popup webview creation failed, falling back to regular webview")
377+ }
378+ } else if noJavaScriptAccess {
379+ log.Debug().
380+ Uint64("parent_webview_id", uint64(parentID)).
381+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
382+ Msg("popup requested no JavaScript opener access, creating regular webview")
383+ } else {
384+ log.Debug().
385+ Uint64("parent_webview_id", uint64(parentID)).
386+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
387+ Msg("related popup webviews known unsupported, creating regular webview")
388+ }
389+
390+ popupWV, fallbackErr := pm.factory.Create(ctx)
391+ if fallbackErr != nil {
392+ if relatedErr != nil {
393+ return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
394+ }
395+ return nil, targetURI, false, fmt.Errorf("create popup webview: fallback failed: %w", fallbackErr)
396+ }
397+ if popupWV == nil {
398+ if relatedErr != nil {
399+ return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback returned nil", relatedErr)
400+ }
401+ return nil, targetURI, false, fmt.Errorf("create popup webview: fallback returned nil")
402+ }
403+
404+ return popupWV, targetURI, true, nil
405+}
406+
407+func (pm *popupManager) reuseNamedPopup(
408+ ctx context.Context,
409+ parentPaneID entity.PaneID,
410+ frameName string,
411+ targetURI string,
412+) (port.WebView, bool) {
413+ log := logging.FromContext(ctx)
414+
415+ if existing, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName); ok {
416+ pm.updatePendingPopupTarget(existing.WebView.ID(), targetURI)
417+ if err := existing.WebView.LoadURI(ctx, targetURI); err != nil {
418+ log.Warn().Err(err).
419+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
420+ Msg("failed to load target URI in reused popup")
421+ }
422+ log.Info().
423+ Str("parent_pane", string(parentPaneID)).
424+ Str("frame_name", frameName).
425+ Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
426+ Msg("reused named popup")
427+ return existing.WebView, true
428+ }
429+
430+ return nil, false
431+}
432+
433+func (pm *popupManager) handlePopupCreate(
434+ ctx context.Context,
435+ hooks popupCoordinatorHooks,
436+ parentPaneID entity.PaneID,
437+ parentWV port.WebView,
438+ req port.PopupRequest,
439+) port.WebView {
440+ log := logging.FromContext(ctx)
441+
442+ log.Debug().
443+ Str("parent_pane", string(parentPaneID)).
444+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
445+ Str("frame_name", req.FrameName).
446+ Bool("user_gesture", req.IsUserGesture).
447+ Bool("no_javascript_access", req.NoJavaScriptAccess).
448+ Msg("popup create request")
449+
450+ if pm.popupConfig != nil && !pm.popupConfig.OpenInNewPane {
451+ log.Debug().Msg("popups disabled by config, blocking")
452+ return nil
453+ }
454+ if pm.factory == nil {
455+ log.Warn().Msg("no webview factory, cannot create popup")
456+ return nil
457+ }
458+
459+ parentID := parentWV.ID()
460+ parentURIAtOpen := ""
461+ if pm.popupConfig != nil && pm.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
462+ parentURIAtOpen = parentWV.URI()
463+ if parentURIAtOpen == "" && hooks.getWebView != nil {
464+ if parent := hooks.getWebView(parentPaneID); parent != nil {
465+ parentURIAtOpen = parent.URI()
466+ }
467+ }
468+ }
469+ if !req.NoJavaScriptAccess {
470+ if reused, ok := pm.reuseNamedPopup(ctx, parentPaneID, req.FrameName, req.TargetURI); ok {
471+ return reused
472+ }
473+ }
474+
475+ popupWV, effectiveTargetURI, usedRegularFallback, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, req.NoJavaScriptAccess)
476+ if err != nil {
477+ log.Error().Err(err).Msg("failed to create webview for popup")
478+ return nil
479+ }
480+
481+ if effectiveTargetURI != req.TargetURI {
482+ req.TargetURI = effectiveTargetURI
483+ }
484+ if usedRegularFallback {
485+ if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {
486+ openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
487+ }
488+ }
489+
490+ popupType := DetectPopupType(req.FrameName)
491+ popupID := popupWV.ID()
492+ behavior := GetBehavior(popupType, pm.popupConfig)
493+ placement := "right"
494+ if pm.popupConfig != nil {
495+ placement = pm.popupConfig.Placement
496+ }
497+ paneID, popupPane := pm.createPopupPane(popupID, parentPaneID, req.TargetURI)
498+
499+ return pm.finishPopupCreate(ctx, hooks, popupCreateContext{
500+ ParentPaneID: parentPaneID,
501+ ParentWebViewID: parentID,
502+ ParentURIAtOpen: parentURIAtOpen,
503+ PopupID: popupID,
504+ PopupWebView: popupWV,
505+ PopupPane: popupPane,
506+ PopupPaneID: paneID,
507+ PopupType: popupType,
508+ Behavior: behavior,
509+ Placement: placement,
510+ Request: req,
511+ })
512+}
513+
514+func (pm *popupManager) finishPopupCreate(
515+ ctx context.Context,
516+ hooks popupCoordinatorHooks,
517+ create popupCreateContext,
518+) port.WebView {
519+ log := logging.FromContext(ctx)
520+ hasConfig := pm.popupConfig != nil
521+ oauthEnabled := hasConfig && pm.popupConfig.OAuthAutoClose
522+ isOAuth := IsOAuthURL(create.Request.TargetURI)
523+
524+ log.Debug().
525+ Bool("has_config", hasConfig).
526+ Bool("oauth_enabled", oauthEnabled).
527+ Bool("is_oauth", isOAuth).
528+ Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
529+ Msg("popup OAuth check")
530+
531+ if hooks.setupWebViewCallbacks != nil {
532+ hooks.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
533+ }
534+ if hooks.registerPopupWebView != nil {
535+ hooks.registerPopupWebView(create.PopupPaneID, create.PopupWebView)
536+ }
537+
538+ inserted := false
539+ defer func() {
540+ if inserted {
541+ return
542+ }
543+ if hooks.getWebView != nil && hooks.deleteWebView != nil {
544+ if current := hooks.getWebView(create.PopupPaneID); current == create.PopupWebView {
545+ hooks.deleteWebView(create.PopupPaneID)
546+ }
547+ }
548+ }()
549+
550+ if pm.onInsertPopup != nil {
551+ popupInput := InsertPopupInput{
552+ ParentPaneID: create.ParentPaneID,
553+ PopupPane: create.PopupPane,
554+ WebView: create.PopupWebView,
555+ Behavior: create.Behavior,
556+ Placement: create.Placement,
557+ PopupType: create.PopupType,
558+ TargetURI: create.Request.TargetURI,
559+ }
560+ if err := pm.onInsertPopup(ctx, popupInput); err != nil {
561+ log.Error().Err(err).Msg("failed to insert popup into workspace")
562+ create.PopupWebView.Destroy()
563+ return nil
564+ }
565+ }
566+ inserted = true
567+
568+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
569+ lifecycle.PrimePopupNavigation(create.Request.TargetURI)
570+ }
571+ if !create.Request.NoJavaScriptAccess {
572+ pm.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupWebView)
573+ }
574+ if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
575+ if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
576+ closeCapable.AddCloseCallback(func() {
577+ pm.handlePopupClose(ctx, hooks, create.PopupID)
578+ })
579+ }
580+ }
581+
582+ if hasConfig && oauthEnabled && isOAuth {
583+ create.PopupPane.AutoClose = true
584+ pm.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
585+ if hooks.setupOAuthAutoClose != nil {
586+ hooks.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
587+ }
588+ log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
589+ }
590+
591+ pending := &PendingPopup{
592+ PaneID: create.PopupPaneID,
593+ WebView: create.PopupWebView,
594+ ParentPaneID: create.ParentPaneID,
595+ ParentWebViewID: create.ParentWebViewID,
596+ TargetURI: create.Request.TargetURI,
597+ FrameName: create.Request.FrameName,
598+ IsUserGesture: create.Request.IsUserGesture,
599+ PopupType: create.PopupType,
600+ CreatedAt: time.Now(),
601+ }
602+ pm.storePendingPopup(create.PopupID, pending)
603+
604+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
605+ lifecycle.SetOnReadyToShow(func() {
606+ pm.handlePopupReadyToShow(ctx, create.PopupID)
607+ })
608+ lifecycle.SetOnClose(func() {
609+ pm.handlePopupClose(ctx, hooks, create.PopupID)
610+ })
611+ log.Info().
612+ Uint64("popup_id", uint64(create.PopupID)).
613+ Str("pane_id", string(create.PopupPaneID)).
614+ Str("popup_type", create.PopupType.String()).
615+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
616+ Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
617+ } else {
618+ log.Info().
619+ Uint64("popup_id", uint64(create.PopupID)).
620+ Str("pane_id", string(create.PopupPaneID)).
621+ Str("popup_type", create.PopupType.String()).
622+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
623+ Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
624+ pm.handlePopupReadyToShow(ctx, create.PopupID)
625+ }
626+
627+ return create.PopupWebView
628+}
629+
630+func (pm *popupManager) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
631+ log := logging.FromContext(ctx)
632+ pending, ok := pm.takePendingPopup(popupID)
633+ if !ok || pending == nil {
634+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
635+ return
636+ }
637+
638+ log.Debug().
639+ Uint64("popup_id", uint64(popupID)).
640+ Str("popup_type", pending.PopupType.String()).
641+ Msg("popup ready to show - making visible")
642+
643+ if pending.WebView != nil {
644+ if lifecycle, ok := pending.WebView.(port.PopupLifecycleCapable); ok {
645+ lifecycle.Show()
646+ }
647+ _, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
648+ if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
649+ if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
650+ log.Warn().Err(err).
651+ Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
652+ Msg("failed to load target URI in popup")
653+ }
654+ }
655+ }
656+
657+ log.Info().
658+ Uint64("popup_id", uint64(popupID)).
659+ Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
660+ Msg("popup now visible")
661+}
662+
663+func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordinatorHooks, popupID port.WebViewID) {
664+ log := logging.FromContext(ctx)
665+ log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
666+
667+ pending, wasPending := pm.takePendingPopup(popupID)
668+ if wasPending && pending != nil {
669+ if hooks.handlePopupOAuthClose != nil {
670+ hooks.handlePopupOAuthClose(ctx, popupID)
671+ }
672+ if pm.onClosePane != nil {
673+ if err := pm.onClosePane(ctx, pending.PaneID); err != nil {
674+ log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
675+ }
676+ }
677+ pm.clearReusableNamedPopupByWebViewID(popupID)
678+ if hooks.getWebView != nil && hooks.releaseWebView != nil && hooks.getWebView(pending.PaneID) != nil {
679+ hooks.releaseWebView(ctx, pending.PaneID)
680+ }
681+ log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
682+ return
683+ }
684+
685+ var (
686+ paneID entity.PaneID
687+ ok bool
688+ )
689+ if hooks.findPaneByWebViewID != nil {
690+ paneID, ok = hooks.findPaneByWebViewID(popupID)
691+ }
692+ if !ok || paneID == "" {
693+ if hooks.handlePopupOAuthClose != nil {
694+ hooks.handlePopupOAuthClose(ctx, popupID)
695+ }
696+ pm.clearReusableNamedPopupByWebViewID(popupID)
697+ log.Warn().Msg("popup close: could not find pane for webview")
698+ return
699+ }
700+
701+ if hooks.handlePopupOAuthClose != nil {
702+ hooks.handlePopupOAuthClose(ctx, popupID)
703+ }
704+ if pm.onClosePane != nil {
705+ if err := pm.onClosePane(ctx, paneID); err != nil {
706+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
707+ }
708+ }
709+ pm.clearReusableNamedPopupByWebViewID(popupID)
710+ if hooks.getWebView != nil && hooks.releaseWebView != nil && hooks.getWebView(paneID) != nil {
711+ hooks.releaseWebView(ctx, paneID)
712+ }
713+
714+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
715+}
716+
717+func (pm *popupManager) handleLinkMiddleClick(
718+ ctx context.Context,
719+ hooks popupCoordinatorHooks,
720+ parentPaneID entity.PaneID,
721+ uri string,
722+) bool {
723+ log := logging.FromContext(ctx)
724+
725+ log.Info().
726+ Str("parent_pane", string(parentPaneID)).
727+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
728+ Msg("middle-click/ctrl+click on link")
729+
730+ if pm.popupConfig != nil && !pm.popupConfig.OpenInNewPane {
731+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
732+ return false
733+ }
734+ if pm.factory == nil {
735+ log.Warn().Msg("no webview factory, cannot handle middle-click")
736+ return false
737+ }
738+
739+ newWV, err := pm.factory.Create(ctx)
740+ if err != nil {
741+ log.Error().Err(err).Msg("failed to create webview for middle-click")
742+ return false
743+ }
744+
745+ var paneID entity.PaneID
746+ if pm.generatePaneID != nil {
747+ paneID = entity.PaneID(pm.generatePaneID())
748+ } else {
749+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
750+ }
751+
752+ newPane := entity.NewPane(paneID)
753+ newPane.WindowType = entity.WindowPopup
754+ newPane.URI = uri
755+
756+ if hooks.setWebView != nil {
757+ hooks.setWebView(paneID, newWV)
758+ }
759+ if hooks.setupWebViewCallbacks != nil {
760+ hooks.setupWebViewCallbacks(ctx, paneID, newWV)
761+ }
762+
763+ behavior := entity.PopupBehaviorStacked
764+ if pm.popupConfig != nil && pm.popupConfig.BlankTargetBehavior != "" {
765+ behavior = entity.PopupBehavior(pm.popupConfig.BlankTargetBehavior)
766+ }
767+ placement := "right"
768+ if pm.popupConfig != nil {
769+ placement = pm.popupConfig.Placement
770+ }
771+
772+ if pm.onInsertPopup != nil {
773+ popupInput := InsertPopupInput{
774+ ParentPaneID: parentPaneID,
775+ PopupPane: newPane,
776+ WebView: newWV,
777+ Behavior: behavior,
778+ Placement: placement,
779+ PopupType: PopupTypeTab,
780+ TargetURI: uri,
781+ }
782+ if err := pm.onInsertPopup(ctx, popupInput); err != nil {
783+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
784+ if hooks.deleteWebView != nil {
785+ hooks.deleteWebView(paneID)
786+ }
787+ newWV.Destroy()
788+ return false
789+ }
790+ }
791+
792+ if err := newWV.LoadURI(ctx, uri); err != nil {
793+ log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
794+ }
795+
796+ log.Info().
797+ Str("pane_id", string(paneID)).
798+ Str("behavior", string(behavior)).
799+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
800+ Msg("middle-click link opened in new pane")
801+
802+ return true
803+}

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