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

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