v0.30.0 eb3068f

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

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