v0.33.2 57f21d6

popup_manager.go RawHistoryFolder

1package content
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"sync"
8	"time"
9
10	"github.com/bnema/dumber/internal/application/dto"
11	"github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/application/usecase"
13	"github.com/bnema/dumber/internal/domain/entity"
14	"github.com/bnema/dumber/internal/logging"
15)
16
17// popupManager owns popup-specific coordinator state. It stays in the UI
18// adapter layer so popup pane bookkeeping and workspace orchestration do not
19// leak into the application/usecase or domain layers.
20type popupManager struct {
21	factory             port.WebViewFactory
22	popupConfig         entity.BrowsingContextConfig
23	hasPopupConfig      bool
24	onInsertPopup       func(ctx context.Context, input InsertPopupInput) error
25	onOpenBrowserWindow func(ctx context.Context, input BrowserWindowInput) (BrowserWindowResult, error)
26	onOpenNativePopup   func(ctx context.Context, input NativePopupInput) error
27	onStagePopup        func(ctx context.Context, input StagePopupInput) (PopupStagingHost, error)
28	onClosePane         func(ctx context.Context, paneID entity.PaneID) error
29	generatePaneID      func() string
30	windowIDForPane     func(entity.PaneID) (string, bool)
31	sourceHostForPane   func(entity.PaneID) dto.SourceHostKind
32	policy              usecase.BrowsingContextPolicy
33	namedContexts       *namedBrowsingContextRegistry
34	pendingPopups       map[port.WebViewID]*PendingPopup
35	deferredPopups      map[port.WebViewID]*deferredPendingPopup
36	popupOAuth          map[port.WebViewID]*popupOAuthState
37	popupRefresh        map[entity.PaneID]*time.Timer
38	mu                  sync.RWMutex
39}
40
41type deferredPendingPopup struct {
42	*PendingPopup
43	Request       port.PopupRequest
44	Decision      dto.HostDecision
45	StagingHost   PopupStagingHost
46	OwnerWindowID string
47	ParentWebView port.WebView
48	cleanupOnce   sync.Once
49}
50
51type popupOAuthState struct {
52	ParentPaneID    entity.PaneID
53	ParentURIAtOpen string
54	CallbackURI     string
55	Success         bool
56	Error           bool
57	Seen            bool
58}
59
60type popupCreateContext struct {
61	ParentPaneID    entity.PaneID
62	ParentWebViewID port.WebViewID
63	ParentURIAtOpen string
64	PopupID         port.WebViewID
65	PopupWebView    port.WebView
66	PopupPane       *entity.Pane
67	PopupPaneID     entity.PaneID
68	PopupType       PopupType
69	Behavior        entity.PopupBehavior
70	Placement       string
71	Request         port.PopupRequest
72}
73
74type popupCoordinatorHooks struct {
75	setupWebViewCallbacks func(context.Context, entity.PaneID, port.WebView)
76	registerPopupWebView  func(entity.PaneID, port.WebView)
77	setWebView            func(entity.PaneID, port.WebView)
78	getWebView            func(entity.PaneID) port.WebView
79	deleteWebView         func(entity.PaneID) port.WebView
80	releaseWebView        func(context.Context, entity.PaneID)
81	findPaneByWebViewID   func(port.WebViewID) (entity.PaneID, bool)
82	setupOAuthAutoClose   func(context.Context, entity.PaneID, port.WebViewID, port.WebView)
83	handlePopupOAuthClose func(context.Context, port.WebViewID)
84}
85
86func newPopupManager() *popupManager {
87	pm := &popupManager{}
88	pm.ensureInitialized()
89	return pm
90}
91
92func (pm *popupManager) ensureInitialized() {
93	if pm == nil {
94		return
95	}
96	pm.mu.Lock()
97	defer pm.mu.Unlock()
98	if pm.pendingPopups == nil {
99		pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
100	}
101	if pm.deferredPopups == nil {
102		pm.deferredPopups = make(map[port.WebViewID]*deferredPendingPopup)
103	}
104	if pm.namedContexts == nil {
105		pm.namedContexts = newNamedBrowsingContextRegistry()
106	}
107	if pm.popupOAuth == nil {
108		pm.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
109	}
110	if pm.popupRefresh == nil {
111		pm.popupRefresh = make(map[entity.PaneID]*time.Timer)
112	}
113}
114
115func (pm *popupManager) setConfig(
116	factory port.WebViewFactory,
117	popupConfig *entity.BrowsingContextConfig,
118	generateID func() string,
119) {
120	if pm == nil {
121		return
122	}
123	pm.ensureInitialized()
124	pm.factory = factory
125	pm.setPopupConfig(popupConfig)
126	pm.generatePaneID = generateID
127}
128
129func (pm *popupManager) setPopupConfig(popupConfig *entity.BrowsingContextConfig) {
130	if pm == nil {
131		return
132	}
133	pm.mu.Lock()
134	defer pm.mu.Unlock()
135	if popupConfig == nil {
136		pm.popupConfig = entity.BrowsingContextConfig{}
137		pm.hasPopupConfig = false
138		return
139	}
140	pm.popupConfig = *popupConfig
141	pm.hasPopupConfig = true
142}
143
144func (pm *popupManager) updatePopupConfig(popupConfig entity.BrowsingContextConfig) {
145	if pm == nil {
146		return
147	}
148	pm.mu.Lock()
149	defer pm.mu.Unlock()
150	pm.popupConfig = popupConfig
151	pm.hasPopupConfig = true
152}
153
154// currentPopupConfig returns a snapshot copy of the popup config (nil when
155// unset). The copy keeps callers from racing with concurrent config updates.
156func (pm *popupManager) currentPopupConfig() *entity.BrowsingContextConfig {
157	if pm == nil {
158		return nil
159	}
160	pm.mu.RLock()
161	defer pm.mu.RUnlock()
162	if !pm.hasPopupConfig {
163		return nil
164	}
165	cfg := pm.popupConfig
166	return &cfg
167}
168
169func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool)) {
170	if pm == nil {
171		return
172	}
173	pm.windowIDForPane = fn
174}
175
176func (pm *popupManager) setSourceHostResolver(fn func(entity.PaneID) dto.SourceHostKind) {
177	if pm != nil {
178		pm.sourceHostForPane = fn
179	}
180}
181
182func (pm *popupManager) resolveSourceHost(paneID entity.PaneID) dto.SourceHostKind {
183	if pm != nil && pm.sourceHostForPane != nil {
184		if kind := pm.sourceHostForPane(paneID); kind != "" {
185			return kind
186		}
187	}
188	return dto.SourceHostWorkspace
189}
190
191func (pm *popupManager) setOnOpenBrowserWindow(fn func(context.Context, BrowserWindowInput) (BrowserWindowResult, error)) {
192	if pm != nil {
193		pm.onOpenBrowserWindow = fn
194	}
195}
196
197func (pm *popupManager) setOnStagePopup(fn func(context.Context, StagePopupInput) (PopupStagingHost, error)) {
198	if pm != nil {
199		pm.onStagePopup = fn
200	}
201}
202
203func (pm *popupManager) browsingContextRoutingConfig(
204	ctx context.Context,
205	parentPaneID entity.PaneID,
206) (*entity.BrowsingContextConfig, dto.SourceHostKind, bool) {
207	cfg := pm.currentPopupConfig()
208	sourceHost := pm.resolveSourceHost(parentPaneID)
209	if sourceHost == dto.SourceHostWorkspace && cfg != nil && !cfg.OpenInNewPane {
210		logging.FromContext(ctx).Debug().Msg("workspace browsing contexts disabled by config")
211		return cfg, sourceHost, false
212	}
213	if pm.factory == nil {
214		logging.FromContext(ctx).Warn().Msg("no webview factory, cannot create browsing context")
215		return cfg, sourceHost, false
216	}
217	return cfg, sourceHost, true
218}
219
220func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) {
221	behavior := GetBehavior(PopupTypeTab, cfg)
222	placement := "right"
223	if cfg != nil {
224		placement = cfg.Placement
225	}
226	return behavior, placement
227}
228
229func (*popupManager) setBrowsingContextDecision(wv port.WebView, decision dto.HostDecision) {
230	if carrier, ok := wv.(port.BrowsingContextHostDecisionCapable); ok {
231		carrier.SetBrowsingContextHostDecision(decision)
232	}
233}
234
235func (pm *popupManager) setOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
236	if pm == nil {
237		return
238	}
239	pm.onInsertPopup = fn
240}
241
242func (pm *popupManager) setOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
243	if pm == nil {
244		return
245	}
246	pm.onClosePane = fn
247}
248
249func (pm *popupManager) setOnOpenNativePopup(fn func(ctx context.Context, input NativePopupInput) error) {
250	if pm == nil {
251		return
252	}
253	pm.onOpenNativePopup = fn
254}
255
256func (pm *popupManager) createPopupPane(
257	popupID port.WebViewID,
258	parentPaneID entity.PaneID,
259	targetURI string,
260) (entity.PaneID, *entity.Pane) {
261	var paneID entity.PaneID
262	if pm != nil && pm.generatePaneID != nil {
263		paneID = entity.PaneID(pm.generatePaneID())
264	} else {
265		paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
266	}
267
268	popupPane := entity.NewPane(paneID)
269	popupPane.WindowType = entity.WindowPopup
270	popupPane.IsRelated = true
271	popupPane.ParentPaneID = &parentPaneID
272	popupPane.URI = targetURI
273
274	return paneID, popupPane
275}
276
277func (pm *popupManager) lookupReusableNamedPopup(
278	parentPaneID entity.PaneID,
279	frameName string,
280	hooks popupCoordinatorHooks,
281) (port.WebView, bool) {
282	if pm == nil || pm.namedContexts == nil {
283		return nil, false
284	}
285
286	name := usecase.ReusableBrowsingContextName(frameName)
287	if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil {
288		return nil, false
289	}
290
291	windowID, ok := pm.windowIDForPane(parentPaneID)
292	if !ok || windowID == "" {
293		return nil, false
294	}
295
296	_, wv, ok := pm.namedContexts.Lookup(windowID, name, hooks.getWebView, pm.windowIDForPane)
297	if !ok {
298		return nil, false
299	}
300	return wv, true
301}
302
303func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) {
304	if pm == nil || pm.windowIDForPane == nil {
305		return
306	}
307	hostWindowID, ok := pm.windowIDForPane(paneID)
308	if !ok {
309		return
310	}
311	pm.storeReusableNamedPopupWithHost(parentPaneID, frameName, paneID, wv, hostWindowID)
312}
313
314func (pm *popupManager) storeReusableNamedPopupWithHost(
315	parentPaneID entity.PaneID,
316	frameName string,
317	paneID entity.PaneID,
318	wv port.WebView,
319	hostWindowID string,
320) {
321	if pm == nil || pm.namedContexts == nil || wv == nil || pm.windowIDForPane == nil {
322		return
323	}
324	name := usecase.ReusableBrowsingContextName(frameName)
325	ownerWindowID, ok := pm.windowIDForPane(parentPaneID)
326	if name == "" || !ok || ownerWindowID == "" || hostWindowID == "" {
327		return
328	}
329	pm.namedContexts.Register(ownerWindowID, hostWindowID, name, paneID, wv.ID())
330}
331
332func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
333	if pm == nil {
334		return
335	}
336	pm.mu.Lock()
337	if pending, ok := pm.pendingPopups[popupID]; ok && pending != nil {
338		pending.TargetURI = targetURI
339	}
340	pm.mu.Unlock()
341}
342
343func (pm *popupManager) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
344	if pm == nil || pm.namedContexts == nil {
345		return
346	}
347	pm.namedContexts.UnregisterByWebViewID(popupID)
348}
349
350func (pm *popupManager) clearReusableNamedPopupByPaneID(paneID entity.PaneID) {
351	if pm == nil || pm.namedContexts == nil {
352		return
353	}
354	pm.namedContexts.UnregisterByPaneID(paneID)
355}
356
357func (pm *popupManager) clearReusableNamedPopupsForWindow(windowID string) {
358	if pm == nil || pm.namedContexts == nil {
359		return
360	}
361	pm.namedContexts.UnregisterWindow(windowID)
362}
363
364func (pm *popupManager) storePendingPopup(popupID port.WebViewID, pending *PendingPopup) {
365	if pm == nil || pending == nil {
366		return
367	}
368	pm.mu.Lock()
369	pm.pendingPopups[popupID] = pending
370	pm.mu.Unlock()
371}
372
373func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, bool) {
374	if pm == nil {
375		return nil, false
376	}
377	pm.mu.Lock()
378	defer pm.mu.Unlock()
379	pending, ok := pm.pendingPopups[popupID]
380	if ok {
381		delete(pm.pendingPopups, popupID)
382	}
383	return pending, ok
384}
385
386func (pm *popupManager) storeDeferredPopup(popupID port.WebViewID, pending *deferredPendingPopup) {
387	if pm == nil || pending == nil {
388		return
389	}
390	pm.mu.Lock()
391	pm.deferredPopups[popupID] = pending
392	pm.mu.Unlock()
393}
394
395func (pm *popupManager) takeDeferredPopup(popupID port.WebViewID) (*deferredPendingPopup, bool) {
396	if pm == nil {
397		return nil, false
398	}
399	pm.mu.Lock()
400	defer pm.mu.Unlock()
401	pending, ok := pm.deferredPopups[popupID]
402	if ok {
403		delete(pm.deferredPopups, popupID)
404	}
405	return pending, ok
406}
407
408func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
409	if pm == nil {
410		return
411	}
412	pm.mu.Lock()
413	pm.popupOAuth[popupID] = &popupOAuthState{
414		ParentPaneID:    parentPaneID,
415		ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
416	}
417	pm.mu.Unlock()
418}
419
420func (pm *popupManager) capturePopupOAuthState(popupID port.WebViewID, uri string) {
421	if pm == nil {
422		return
423	}
424	pm.mu.Lock()
425	defer pm.mu.Unlock()
426	state, ok := pm.popupOAuth[popupID]
427	if !ok {
428		return
429	}
430	state.Seen = true
431	state.CallbackURI = uri
432	state.Success = IsOAuthSuccess(uri)
433	state.Error = IsOAuthError(uri)
434}
435
436func (pm *popupManager) capturePopupOAuthMessage(popupID port.WebViewID) {
437	if pm == nil {
438		return
439	}
440	pm.mu.Lock()
441	defer pm.mu.Unlock()
442	state, ok := pm.popupOAuth[popupID]
443	if !ok {
444		return
445	}
446	state.Seen = true
447	if state.CallbackURI == "" {
448		state.CallbackURI = "postmessage://oauth-complete"
449	}
450	if !state.Error {
451		state.Success = true
452	}
453}
454
455func (pm *popupManager) takePopupOAuthState(popupID port.WebViewID) (*popupOAuthState, bool) {
456	if pm == nil {
457		return nil, false
458	}
459	pm.mu.Lock()
460	defer pm.mu.Unlock()
461	state, ok := pm.popupOAuth[popupID]
462	if ok {
463		delete(pm.popupOAuth, popupID)
464	}
465	return state, ok
466}
467
468func (pm *popupManager) schedulePopupRefresh(parentPaneID entity.PaneID, debounce time.Duration, fn func()) {
469	if pm == nil || fn == nil {
470		return
471	}
472
473	var timer *time.Timer
474	pm.mu.Lock()
475	if existing := pm.popupRefresh[parentPaneID]; existing != nil {
476		existing.Stop()
477	}
478	timer = time.AfterFunc(debounce, func() {
479		pm.mu.Lock()
480		if pm.popupRefresh[parentPaneID] != timer {
481			pm.mu.Unlock()
482			return
483		}
484		delete(pm.popupRefresh, parentPaneID)
485		pm.mu.Unlock()
486		fn()
487	})
488	pm.popupRefresh[parentPaneID] = timer
489	pm.mu.Unlock()
490}
491
492func (pm *popupManager) createPopupWebView(
493	ctx context.Context,
494	parentID port.WebViewID,
495	targetURI string,
496	preparePaneHosted bool,
497) (port.WebView, error) {
498	if pm == nil || pm.factory == nil {
499		return nil, fmt.Errorf("no webview factory configured")
500	}
501
502	popupWV, err := pm.factory.CreateRelated(ctx, parentID)
503	if err != nil {
504		return nil, fmt.Errorf("create related popup webview for %s: %w", logging.TruncateURL(targetURI, logURLMaxLen), err)
505	}
506	if popupWV == nil {
507		return nil, fmt.Errorf("related popup webview factory returned nil for %s", logging.TruncateURL(targetURI, logURLMaxLen))
508	}
509	if preparePaneHosted {
510		if paneHosted, ok := popupWV.(port.PaneHostedBrowsingContextCapable); ok {
511			paneHosted.PreparePaneHostedBrowsingContext()
512		}
513	}
514	return popupWV, nil
515}
516
517func (pm *popupManager) reuseNamedPopup(
518	ctx context.Context,
519	hooks popupCoordinatorHooks,
520	parentPaneID entity.PaneID,
521	frameName string,
522	targetURI string,
523) (port.WebView, bool) {
524	log := logging.FromContext(ctx)
525
526	existingWV, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName, hooks)
527	if !ok {
528		return nil, false
529	}
530
531	pm.updatePendingPopupTarget(existingWV.ID(), targetURI)
532	if err := existingWV.LoadURI(ctx, targetURI); err != nil {
533		log.Warn().Err(err).
534			Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
535			Msg("failed to load target URI in reused popup")
536	}
537	log.Info().
538		Str("parent_pane", string(parentPaneID)).
539		Str("frame_name", frameName).
540		Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
541		Msg("reused named popup")
542	return existingWV, true
543}
544
545func (pm *popupManager) popupParentURIAtOpen(
546	parentPaneID entity.PaneID,
547	parentWV port.WebView,
548	hooks popupCoordinatorHooks,
549	targetURI string,
550) string {
551	cfg := pm.currentPopupConfig()
552	if cfg == nil || !cfg.OAuthAutoClose || !IsOAuthURL(targetURI) {
553		return ""
554	}
555	parentURIAtOpen := parentWV.URI()
556	if parentURIAtOpen == "" && hooks.getWebView != nil {
557		if parent := hooks.getWebView(parentPaneID); parent != nil {
558			parentURIAtOpen = parent.URI()
559		}
560	}
561	return parentURIAtOpen
562}
563
564func (pm *popupManager) handlePopupCreate(
565	ctx context.Context,
566	hooks popupCoordinatorHooks,
567	parentPaneID entity.PaneID,
568	parentWV port.WebView,
569	req port.PopupRequest,
570) port.WebView {
571	log := logging.FromContext(ctx)
572
573	cfg, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID)
574	if !routingEnabled {
575		return nil
576	}
577
578	parentID := parentWV.ID()
579	parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI)
580
581	request := buildPopupBrowsingContextRequest(req)
582	request.SourceHost = sourceHost
583	namedContextExists := false
584	if !req.NoJavaScriptAccess {
585		_, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks)
586	}
587	decision := pm.policy.Decide(request, namedContextExists)
588	logBrowsingContextDecision(*log, request, decision)
589
590	switch decision.Kind {
591	case dto.HostDecisionDeny:
592		return nil
593	case dto.HostDecisionReuseNamedPane:
594		if req.NoJavaScriptAccess {
595			log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse denied for noopener popup")
596			return nil
597		}
598		reused, ok := pm.reuseNamedPopup(ctx, hooks, parentPaneID, req.FrameName, req.TargetURI)
599		if ok {
600			pm.setBrowsingContextDecision(reused, decision)
601			// The existing browsing context already owns its engine view. Returning
602			// it would ask CEF/WebKit to create a second native popup around an
603			// already-hosted view, so block engine creation after navigation.
604			return nil
605		}
606		log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable")
607		// Lookup removes the stale registry entry. Re-enter policy once so the
608		// replacement host remains source-aware instead of forcing a workspace pane.
609		return pm.handlePopupCreate(ctx, hooks, parentPaneID, parentWV, req)
610	case dto.HostDecisionNavigateSource:
611		pm.navigatePopupSource(ctx, parentID, parentWV, req, request, decision)
612		return nil
613	case dto.HostDecisionCreateBrowserWindow:
614		return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, true)
615	case dto.HostDecisionCreateNativePopup:
616		return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
617	case dto.HostDecisionAwaitPopupFeatures:
618		return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentWV, parentURIAtOpen, req, decision)
619	case dto.HostDecisionCreatePane:
620		// Continue below.
621		break
622	}
623
624	popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, true)
625	if err != nil {
626		log.Error().Err(err).Msg("failed to create webview for popup")
627		return nil
628	}
629
630	pm.setBrowsingContextDecision(popupWV, decision)
631
632	popupType := DetectPopupType(req.FrameName)
633	popupID := popupWV.ID()
634	behavior := GetBehavior(popupType, cfg)
635	placement := "right"
636	if cfg != nil {
637		placement = cfg.Placement
638	}
639	paneID, popupPane := pm.createPopupPane(popupID, parentPaneID, req.TargetURI)
640
641	return pm.finishPopupCreate(ctx, hooks, popupCreateContext{
642		ParentPaneID:    parentPaneID,
643		ParentWebViewID: parentID,
644		ParentURIAtOpen: parentURIAtOpen,
645		PopupID:         popupID,
646		PopupWebView:    popupWV,
647		PopupPane:       popupPane,
648		PopupPaneID:     paneID,
649		PopupType:       popupType,
650		Behavior:        behavior,
651		Placement:       placement,
652		Request:         req,
653	})
654}
655
656func (pm *popupManager) finishPopupCreate(
657	ctx context.Context,
658	hooks popupCoordinatorHooks,
659	create popupCreateContext,
660) port.WebView {
661	log := logging.FromContext(ctx)
662	cfg := pm.currentPopupConfig()
663	hasConfig := cfg != nil
664	oauthEnabled := hasConfig && cfg.OAuthAutoClose
665	isOAuth := IsOAuthURL(create.Request.TargetURI)
666
667	log.Debug().
668		Bool("has_config", hasConfig).
669		Bool("oauth_enabled", oauthEnabled).
670		Bool("is_oauth", isOAuth).
671		Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
672		Msg("popup OAuth check")
673
674	if hooks.setupWebViewCallbacks != nil {
675		hooks.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
676	}
677	if hooks.registerPopupWebView != nil {
678		hooks.registerPopupWebView(create.PopupPaneID, create.PopupWebView)
679	}
680
681	inserted := false
682	defer func() {
683		if inserted {
684			return
685		}
686		if hooks.getWebView != nil && hooks.deleteWebView != nil {
687			if current := hooks.getWebView(create.PopupPaneID); current == create.PopupWebView {
688				hooks.deleteWebView(create.PopupPaneID)
689			}
690		}
691	}()
692
693	if pm.onInsertPopup != nil {
694		popupInput := InsertPopupInput{
695			ParentPaneID: create.ParentPaneID,
696			PopupPane:    create.PopupPane,
697			WebView:      create.PopupWebView,
698			Behavior:     create.Behavior,
699			Placement:    create.Placement,
700			PopupType:    create.PopupType,
701			TargetURI:    create.Request.TargetURI,
702		}
703		if err := pm.onInsertPopup(ctx, popupInput); err != nil {
704			log.Error().Err(err).Msg("failed to insert popup into workspace")
705			create.PopupWebView.Destroy()
706			return nil
707		}
708	}
709	inserted = true
710
711	if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
712		lifecycle.PrimePopupNavigation(create.Request.TargetURI)
713	}
714	if !create.Request.NoJavaScriptAccess {
715		pm.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupPaneID, create.PopupWebView)
716	}
717	if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
718		if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
719			closeCapable.AddCloseCallback(func() {
720				pm.handlePopupClose(context.Background(), hooks, create.PopupID)
721			})
722		}
723	}
724
725	if hasConfig && oauthEnabled && isOAuth {
726		create.PopupPane.AutoClose = true
727		pm.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
728		if hooks.setupOAuthAutoClose != nil {
729			hooks.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
730		}
731		log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
732	}
733
734	pending := &PendingPopup{
735		PaneID:          create.PopupPaneID,
736		WebView:         create.PopupWebView,
737		ParentPaneID:    create.ParentPaneID,
738		ParentWebViewID: create.ParentWebViewID,
739		TargetURI:       create.Request.TargetURI,
740		FrameName:       create.Request.FrameName,
741		IsUserGesture:   create.Request.IsUserGesture,
742		PopupType:       create.PopupType,
743		CreatedAt:       time.Now(),
744	}
745	pm.storePendingPopup(create.PopupID, pending)
746
747	if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
748		lifecycle.SetOnReadyToShow(func() {
749			pm.handlePopupReadyToShow(context.Background(), create.PopupID)
750		})
751		lifecycle.SetOnClose(func() {
752			pm.handlePopupClose(context.Background(), hooks, create.PopupID)
753		})
754		log.Info().
755			Uint64("popup_id", uint64(create.PopupID)).
756			Str("pane_id", string(create.PopupPaneID)).
757			Str("popup_type", create.PopupType.String()).
758			Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
759			Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
760	} else {
761		log.Info().
762			Uint64("popup_id", uint64(create.PopupID)).
763			Str("pane_id", string(create.PopupPaneID)).
764			Str("popup_type", create.PopupType.String()).
765			Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
766			Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
767		pm.handlePopupReadyToShow(context.Background(), create.PopupID)
768	}
769
770	return create.PopupWebView
771}
772
773func (pm *popupManager) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
774	log := logging.FromContext(ctx)
775	pending, ok := pm.takePendingPopup(popupID)
776	if !ok || pending == nil {
777		log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
778		return
779	}
780
781	log.Debug().
782		Uint64("popup_id", uint64(popupID)).
783		Str("popup_type", pending.PopupType.String()).
784		Msg("popup ready to show - making visible")
785
786	if pending.WebView != nil {
787		lifecycle, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
788		if preloadsNavigation {
789			lifecycle.Show()
790		}
791		if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
792			if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
793				log.Warn().Err(err).
794					Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
795					Msg("failed to load target URI in popup")
796			}
797		}
798	}
799
800	log.Info().
801		Uint64("popup_id", uint64(popupID)).
802		Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
803		Msg("popup now visible")
804}
805
806func (pm *popupManager) logPopupCloseSignal(ctx context.Context, hooks popupCoordinatorHooks, popupID port.WebViewID) {
807	fields := logging.FromContext(ctx).Debug().Uint64("popup_id", uint64(popupID))
808	if hooks.findPaneByWebViewID != nil && hooks.getWebView != nil {
809		if paneID, ok := hooks.findPaneByWebViewID(popupID); ok && paneID != "" {
810			fields = fields.Str("pane_id", string(paneID))
811			if wv := hooks.getWebView(paneID); wv != nil {
812				fields = fields.
813					Str("current_uri", logging.TruncateURL(wv.URI(), logURLMaxLen)).
814					Bool("is_loading", wv.IsLoading())
815			}
816		}
817	}
818	fields.Msg("popup close signal received")
819}
820
821func (pm *popupManager) findPopupPaneForClose(hooks popupCoordinatorHooks, popupID port.WebViewID) (entity.PaneID, bool) {
822	if hooks.findPaneByWebViewID == nil {
823		return "", false
824	}
825	paneID, ok := hooks.findPaneByWebViewID(popupID)
826	return paneID, ok && paneID != ""
827}
828
829func (pm *popupManager) releasePopupWebView(ctx context.Context, hooks popupCoordinatorHooks, paneID entity.PaneID) {
830	if paneID == "" || hooks.getWebView == nil || hooks.releaseWebView == nil {
831		return
832	}
833	if hooks.getWebView(paneID) != nil {
834		hooks.releaseWebView(ctx, paneID)
835	}
836}
837
838func (pm *popupManager) cleanupPopupClose(
839	ctx context.Context,
840	hooks popupCoordinatorHooks,
841	popupID port.WebViewID,
842	paneID entity.PaneID,
843	closeErrMsg string,
844) {
845	log := logging.FromContext(ctx)
846	if hooks.handlePopupOAuthClose != nil {
847		hooks.handlePopupOAuthClose(ctx, popupID)
848	}
849	if paneID != "" && pm.onClosePane != nil {
850		if err := pm.onClosePane(ctx, paneID); err != nil {
851			log.Warn().Err(err).Str("pane_id", string(paneID)).Msg(closeErrMsg)
852		}
853	}
854	pm.clearReusableNamedPopupByWebViewID(popupID)
855	pm.releasePopupWebView(ctx, hooks, paneID)
856}
857
858func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordinatorHooks, popupID port.WebViewID) {
859	log := logging.FromContext(ctx)
860	pm.logPopupCloseSignal(ctx, hooks, popupID)
861
862	pending, wasPending := pm.takePendingPopup(popupID)
863	if wasPending && pending != nil {
864		pm.cleanupPopupClose(ctx, hooks, popupID, pending.PaneID, "failed to close pending popup pane")
865		log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
866		return
867	}
868
869	paneID, ok := pm.findPopupPaneForClose(hooks, popupID)
870	if !ok {
871		pm.cleanupPopupClose(ctx, hooks, popupID, "", "")
872		log.Warn().Msg("popup close: could not find pane for webview")
873		return
874	}
875
876	pm.cleanupPopupClose(ctx, hooks, popupID, paneID, "failed to close popup pane")
877	log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
878}
879
880func (pm *popupManager) openMiddleClickBrowserWindow(
881	ctx context.Context,
882	hooks popupCoordinatorHooks,
883	parentPaneID entity.PaneID,
884	parentWV port.WebView,
885	uri string,
886	decision dto.HostDecision,
887) bool {
888	req := port.PopupRequest{
889		TargetURI:         uri,
890		FrameName:         "_blank",
891		TargetDisposition: dto.WindowDispositionNewTab,
892		PopupFeatures:     dto.PopupFeatures{State: dto.PopupFeaturesNone},
893	}
894	newWV := pm.openBrowserWindow(ctx, hooks, parentPaneID, parentWV.ID(), req, decision, true)
895	if newWV == nil {
896		return false
897	}
898	if err := newWV.LoadURI(ctx, uri); err != nil {
899		logging.FromContext(ctx).Error().Err(err).
900			Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
901			Msg("failed to load URI in detached window")
902	}
903	return true
904}
905
906func (pm *popupManager) handleLinkMiddleClick(
907	ctx context.Context,
908	hooks popupCoordinatorHooks,
909	parentPaneID entity.PaneID,
910	uri string,
911) bool {
912	log := logging.FromContext(ctx)
913
914	log.Info().
915		Str("parent_pane", string(parentPaneID)).
916		Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
917		Msg("middle-click/ctrl+click on link")
918
919	_, sourceHost, routingEnabled := pm.browsingContextRoutingConfig(ctx, parentPaneID)
920	if !routingEnabled {
921		return false
922	}
923	if hooks.getWebView == nil {
924		log.Warn().Msg("no parent webview lookup available for middle-click browsing context")
925		return false
926	}
927	parentWV := hooks.getWebView(parentPaneID)
928	if parentWV == nil {
929		log.Warn().Str("parent_pane", string(parentPaneID)).Msg("parent webview not found for middle-click browsing context")
930		return false
931	}
932
933	request := buildLinkBrowsingContextRequest(parentWV.ID(), uri)
934	request.SourceHost = sourceHost
935	decision := pm.policy.Decide(request, false)
936	logBrowsingContextDecision(*log, request, decision)
937	if decision.Kind == dto.HostDecisionNavigateSource {
938		return pm.navigateSource(ctx, parentWV, uri)
939	}
940	if decision.Kind == dto.HostDecisionCreateBrowserWindow {
941		return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision)
942	}
943	if decision.Kind != dto.HostDecisionCreatePane {
944		log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")
945		return false
946	}
947
948	return pm.openMiddleClickPane(ctx, hooks, parentPaneID, parentWV, uri, decision)
949}
950
951func (pm *popupManager) openMiddleClickPane(
952	ctx context.Context,
953	hooks popupCoordinatorHooks,
954	parentPaneID entity.PaneID,
955	parentWV port.WebView,
956	uri string,
957	decision dto.HostDecision,
958) bool {
959	log := logging.FromContext(ctx)
960	newWV, err := pm.createPopupWebView(ctx, parentWV.ID(), uri, true)
961	if err != nil {
962		log.Error().Err(err).Msg("failed to create webview for middle-click")
963		return false
964	}
965	pm.setBrowsingContextDecision(newWV, decision)
966
967	var paneID entity.PaneID
968	if pm.generatePaneID != nil {
969		paneID = entity.PaneID(pm.generatePaneID())
970	} else {
971		paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
972	}
973
974	newPane := entity.NewPane(paneID)
975	newPane.WindowType = entity.WindowPopup
976	newPane.URI = uri
977
978	if hooks.setWebView != nil {
979		hooks.setWebView(paneID, newWV)
980	}
981	if hooks.setupWebViewCallbacks != nil {
982		hooks.setupWebViewCallbacks(ctx, paneID, newWV)
983	}
984
985	behavior, placement := popupTabInsertionConfig(pm.currentPopupConfig())
986
987	if pm.onInsertPopup != nil {
988		popupInput := InsertPopupInput{
989			ParentPaneID: parentPaneID,
990			PopupPane:    newPane,
991			WebView:      newWV,
992			Behavior:     behavior,
993			Placement:    placement,
994			PopupType:    PopupTypeTab,
995			TargetURI:    uri,
996		}
997		if err := pm.onInsertPopup(ctx, popupInput); err != nil {
998			log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
999			if hooks.deleteWebView != nil {
1000				hooks.deleteWebView(paneID)
1001			}
1002			newWV.Destroy()
1003			return false
1004		}
1005	}
1006
1007	if err := newWV.LoadURI(ctx, uri); err != nil {
1008		log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
1009	}
1010
1011	log.Info().
1012		Str("pane_id", string(paneID)).
1013		Str("behavior", string(behavior)).
1014		Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
1015		Msg("middle-click link opened in new pane")
1016
1017	return true
1018}

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