v0.20.0 c741a39

webview.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"fmt"
6	"sync"
7	"sync/atomic"
8
9	"github.com/bnema/dumber/internal/application/port"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/puregotk-webkit/webkit"
12	"github.com/jwijenbergh/puregotk/v4/gdk"
13	"github.com/jwijenbergh/puregotk/v4/gio"
14	"github.com/jwijenbergh/puregotk/v4/glib"
15	"github.com/jwijenbergh/puregotk/v4/gobject"
16	"github.com/jwijenbergh/puregotk/v4/gtk"
17	"github.com/rs/zerolog"
18)
19
20// Compile-time interface check: WebView must implement port.WebView.
21var _ port.WebView = (*WebView)(nil)
22
23// WebViewID is an alias to port.WebViewID for clean architecture compliance.
24// Infrastructure layer uses the type defined in the application port.
25type WebViewID = port.WebViewID
26
27// LoadEvent represents WebKit load events.
28type LoadEvent int
29
30const (
31	LoadStarted    LoadEvent = LoadEvent(webkit.LoadStartedValue)
32	LoadRedirected LoadEvent = LoadEvent(webkit.LoadRedirectedValue)
33	LoadCommitted  LoadEvent = LoadEvent(webkit.LoadCommittedValue)
34	LoadFinished   LoadEvent = LoadEvent(webkit.LoadFinishedValue)
35)
36
37// PopupRequest contains information about a popup window request from the create signal.
38type PopupRequest struct {
39	TargetURI     string
40	FrameName     string // e.g., "_blank", custom name, or empty
41	IsUserGesture bool
42	ParentID      WebViewID
43}
44
45// webViewRegistry tracks all active WebViews.
46type webViewRegistry struct {
47	views   map[WebViewID]*WebView
48	counter atomic.Uint64
49	mu      sync.RWMutex
50}
51
52var globalRegistry = &webViewRegistry{
53	views: make(map[WebViewID]*WebView),
54}
55
56func (r *webViewRegistry) register(wv *WebView) WebViewID {
57	id := WebViewID(r.counter.Add(1))
58	r.mu.Lock()
59	r.views[id] = wv
60	r.mu.Unlock()
61	return id
62}
63
64func (r *webViewRegistry) unregister(id WebViewID) {
65	r.mu.Lock()
66	delete(r.views, id)
67	r.mu.Unlock()
68}
69
70// Lookup returns a WebView by ID, or nil if not found.
71func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
72	r.mu.RLock()
73	defer r.mu.RUnlock()
74	return r.views[id]
75}
76
77// LookupWebView returns a WebView by ID from the global registry.
78func LookupWebView(id WebViewID) *WebView {
79	return globalRegistry.Lookup(id)
80}
81
82// WebView wraps webkit.WebView with Go-level state tracking and callbacks.
83type WebView struct {
84	id    WebViewID
85	inner *webkit.WebView
86	ucm   *webkit.UserContentManager
87
88	// State (protected by mutex)
89	destroyed atomic.Bool
90	uri       string
91	title     string
92	progress  float64
93	canGoBack bool
94	canGoFwd  bool
95	isLoading bool
96
97	// Signal handler IDs for disconnection
98	signalIDs []uint32
99
100	// Callbacks (set by UI layer)
101	OnLoadChanged     func(LoadEvent)
102	OnTitleChanged    func(string)
103	OnURIChanged      func(string)
104	OnProgressChanged func(float64)
105	OnFaviconChanged  func(*gdk.Texture) // Called when page favicon changes
106	OnClose           func()
107	OnCreate          func(PopupRequest) *WebView // Return new WebView or nil to block popup
108	OnReadyToShow     func()                      // Called when popup is ready to display
109	OnLinkMiddleClick func(uri string) bool       // Return true if handled (blocks navigation)
110
111	logger zerolog.Logger
112	mu     sync.RWMutex
113
114	frontendAttached atomic.Bool
115
116	// asyncCallbacks keeps references to async JS callbacks to prevent GC
117	asyncCallbacks []interface{}
118
119	// findController is cached to prevent GC from collecting the Go wrapper
120	findController     *findControllerAdapter
121	findControllerOnce sync.Once
122}
123
124type findControllerAdapter struct {
125	fc *webkit.FindController
126}
127
128func (a *findControllerAdapter) Search(text string, opts port.FindOptions, maxMatches uint) {
129	if a == nil || a.fc == nil {
130		return
131	}
132	var flags uint32
133	if opts.WrapAround {
134		flags |= uint32(webkit.FindOptionsWrapAroundValue)
135	}
136	if opts.CaseInsensitive {
137		flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
138	}
139	if opts.AtWordStarts {
140		flags |= uint32(webkit.FindOptionsAtWordStartsValue)
141	}
142	a.fc.Search(text, flags, maxMatches)
143}
144
145func (a *findControllerAdapter) CountMatches(text string, opts port.FindOptions, maxMatches uint) {
146	if a == nil || a.fc == nil {
147		return
148	}
149	var flags uint32
150	if opts.WrapAround {
151		flags |= uint32(webkit.FindOptionsWrapAroundValue)
152	}
153	if opts.CaseInsensitive {
154		flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
155	}
156	if opts.AtWordStarts {
157		flags |= uint32(webkit.FindOptionsAtWordStartsValue)
158	}
159	a.fc.CountMatches(text, flags, maxMatches)
160}
161
162func (a *findControllerAdapter) SearchNext() {
163	if a == nil || a.fc == nil {
164		return
165	}
166	a.fc.SearchNext()
167}
168
169func (a *findControllerAdapter) SearchPrevious() {
170	if a == nil || a.fc == nil {
171		return
172	}
173	a.fc.SearchPrevious()
174}
175
176func (a *findControllerAdapter) SearchFinish() {
177	if a == nil || a.fc == nil {
178		return
179	}
180	a.fc.SearchFinish()
181}
182
183func (a *findControllerAdapter) GetSearchText() string {
184	if a == nil || a.fc == nil {
185		return ""
186	}
187	return a.fc.GetSearchText()
188}
189
190func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint32 {
191	if a == nil || a.fc == nil || callback == nil {
192		return 0
193	}
194	cb := func(_ webkit.FindController, matchCount uint) {
195		callback(matchCount)
196	}
197	return a.fc.ConnectFoundText(&cb)
198}
199
200func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
201	if a == nil || a.fc == nil || callback == nil {
202		return 0
203	}
204	cb := func(_ webkit.FindController) {
205		callback()
206	}
207	return a.fc.ConnectFailedToFindText(&cb)
208}
209
210func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {
211	if a == nil || a.fc == nil || callback == nil {
212		return 0
213	}
214	cb := func(_ webkit.FindController, matchCount uint) {
215		callback(matchCount)
216	}
217	return a.fc.ConnectCountedMatches(&cb)
218}
219
220func (a *findControllerAdapter) DisconnectSignal(id uint32) {
221	if a == nil || a.fc == nil || id == 0 {
222		return
223	}
224	obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())
225	gobject.SignalHandlerDisconnect(obj, id)
226}
227
228// NewWebView creates a new WebView with the given context and settings.
229// Uses the persistent NetworkSession from wkCtx for cookie/data persistence.
230func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager) (*WebView, error) {
231	log := logging.FromContext(ctx)
232
233	if wkCtx == nil || !wkCtx.IsInitialized() {
234		return nil, fmt.Errorf("webkit context not initialized")
235	}
236
237	// Use NetworkSession-aware constructor for persistent cookie storage
238	inner := webkit.NewWebViewWithNetworkSession(wkCtx.NetworkSession())
239	if inner == nil {
240		return nil, fmt.Errorf("failed to create webkit webview with network session")
241	}
242
243	wv := &WebView{
244		inner:     inner,
245		ucm:       inner.GetUserContentManager(),
246		logger:    log.With().Str("component", "webview").Logger(),
247		signalIDs: make([]uint32, 0, 4),
248	}
249
250	// Register in global registry
251	wv.id = globalRegistry.register(wv)
252
253	// Apply settings if provided
254	if settings != nil {
255		settings.ApplyToWebView(ctx, inner)
256	}
257
258	// Connect signals
259	wv.connectSignals()
260
261	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview created")
262
263	return wv, nil
264}
265
266// NewWebViewWithRelated creates a WebView that shares session/cookies with parent.
267// This is required for popup windows to maintain authentication state.
268func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *SettingsManager) (*WebView, error) {
269	log := logging.FromContext(ctx)
270
271	if parent == nil {
272		return nil, fmt.Errorf("parent webview is nil")
273	}
274	if parent.IsDestroyed() {
275		return nil, fmt.Errorf("parent webview %d is destroyed", parent.id)
276	}
277
278	inner := webkit.NewWebViewWithRelatedView(parent.inner)
279	if inner == nil {
280		return nil, fmt.Errorf("failed to create related webkit webview")
281	}
282
283	wv := &WebView{
284		inner:     inner,
285		ucm:       inner.GetUserContentManager(),
286		logger:    log.With().Str("component", "webview-popup").Logger(),
287		signalIDs: make([]uint32, 0, 6),
288	}
289
290	wv.id = globalRegistry.register(wv)
291
292	if settings != nil {
293		settings.ApplyToWebView(ctx, inner)
294	}
295
296	wv.connectSignals()
297
298	wv.logger.Debug().
299		Uint64("id", uint64(wv.id)).
300		Uint64("parent_id", uint64(parent.id)).
301		Msg("related webview created for popup")
302
303	return wv, nil
304}
305
306// connectSignals sets up signal handlers for the WebView.
307func (wv *WebView) connectSignals() {
308	wv.connectLoadChangedSignal()
309	wv.connectCloseSignal()
310	wv.connectCreateSignal()
311	wv.connectReadyToShowSignal()
312	wv.connectTitleSignal()
313	wv.connectURISignal()
314	wv.connectFaviconSignal()
315	wv.connectProgressSignal()
316	wv.connectDecidePolicySignal()
317}
318
319func (wv *WebView) connectLoadChangedSignal() {
320	loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
321		uri := inner.GetUri()
322		title := inner.GetTitle()
323
324		wv.mu.Lock()
325		wv.uri = uri
326		wv.title = title
327		wv.canGoBack = inner.CanGoBack()
328		wv.canGoFwd = inner.CanGoForward()
329		wv.progress = inner.GetEstimatedLoadProgress()
330
331		switch event {
332		case webkit.LoadStartedValue:
333			wv.isLoading = true
334			wv.logger.Debug().Str("uri", uri).Msg("load started")
335		case webkit.LoadRedirectedValue:
336			wv.logger.Debug().Str("uri", uri).Msg("load redirected")
337		case webkit.LoadCommittedValue:
338			wv.logger.Debug().Str("uri", uri).Msg("load committed")
339		case webkit.LoadFinishedValue:
340			wv.isLoading = false
341			wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
342		}
343		wv.mu.Unlock()
344
345		if wv.OnLoadChanged != nil {
346			wv.OnLoadChanged(LoadEvent(event))
347		}
348	}
349	sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
350	wv.signalIDs = append(wv.signalIDs, sigID)
351}
352
353func (wv *WebView) connectCloseSignal() {
354	closeCb := func(_ webkit.WebView) {
355		if wv.OnClose != nil {
356			wv.OnClose()
357		}
358	}
359	sigID := wv.inner.ConnectClose(&closeCb)
360	wv.signalIDs = append(wv.signalIDs, sigID)
361}
362
363func (wv *WebView) connectCreateSignal() {
364	createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget {
365		if wv.OnCreate == nil {
366			return gtk.Widget{} // Block popup
367		}
368
369		navAction := webkit.NavigationActionFromPointer(navActionPtr)
370		if navAction == nil {
371			return gtk.Widget{} // Block popup
372		}
373		defer navAction.Free()
374
375		var targetURI string
376		if req := navAction.GetRequest(); req != nil {
377			targetURI = req.GetUri()
378		}
379
380		popupReq := PopupRequest{
381			TargetURI:     targetURI,
382			FrameName:     navAction.GetFrameName(),
383			IsUserGesture: navAction.IsUserGesture(),
384			ParentID:      wv.id,
385		}
386
387		newWV := wv.OnCreate(popupReq)
388		if newWV == nil {
389			return gtk.Widget{} // Block popup
390		}
391
392		return newWV.inner.Widget
393	}
394	sigID := wv.inner.ConnectCreate(&createCb)
395	wv.signalIDs = append(wv.signalIDs, sigID)
396}
397
398func (wv *WebView) connectReadyToShowSignal() {
399	readyToShowCb := func(_ webkit.WebView) {
400		if wv.OnReadyToShow != nil {
401			wv.OnReadyToShow()
402		}
403	}
404	sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
405	wv.signalIDs = append(wv.signalIDs, sigID)
406}
407
408func (wv *WebView) connectTitleSignal() {
409	titleCb := func() {
410		title := wv.inner.GetTitle()
411		wv.mu.Lock()
412		wv.title = title
413		wv.mu.Unlock()
414
415		if wv.OnTitleChanged != nil {
416			wv.OnTitleChanged(title)
417		}
418	}
419	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
420	wv.signalIDs = append(wv.signalIDs, sigID)
421}
422
423func (wv *WebView) connectURISignal() {
424	uriCb := func() {
425		uri := wv.inner.GetUri()
426		wv.mu.Lock()
427		oldUri := wv.uri
428		wv.uri = uri
429		wv.mu.Unlock()
430
431		if wv.OnURIChanged != nil && uri != oldUri {
432			wv.OnURIChanged(uri)
433		}
434	}
435	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
436	wv.signalIDs = append(wv.signalIDs, sigID)
437}
438
439func (wv *WebView) connectFaviconSignal() {
440	faviconCb := func() {
441		favicon := wv.inner.GetFavicon()
442		if wv.OnFaviconChanged != nil {
443			wv.OnFaviconChanged(favicon)
444		}
445	}
446	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
447	wv.signalIDs = append(wv.signalIDs, sigID)
448}
449
450func (wv *WebView) connectProgressSignal() {
451	progressCb := func() {
452		progress := wv.inner.GetEstimatedLoadProgress()
453		wv.mu.Lock()
454		wv.progress = progress
455		wv.mu.Unlock()
456
457		if wv.OnProgressChanged != nil {
458			wv.OnProgressChanged(progress)
459		}
460	}
461	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))
462	wv.signalIDs = append(wv.signalIDs, sigID)
463}
464
465func (wv *WebView) connectDecidePolicySignal() {
466	decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
467		if decisionType != webkit.PolicyDecisionTypeNavigationActionValue {
468			return false // Let WebKit handle
469		}
470
471		navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
472		if navDecision == nil {
473			return false
474		}
475
476		navAction := navDecision.GetNavigationAction()
477		if navAction == nil {
478			return false
479		}
480
481		if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
482			return false
483		}
484
485		mouseButton := navAction.GetMouseButton()
486		modifiers := navAction.GetModifiers()
487		isMiddleClick := mouseButton == 2
488		isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
489
490		if !isMiddleClick && !isCtrlClick {
491			return false
492		}
493
494		request := navAction.GetRequest()
495		if request == nil {
496			return false
497		}
498
499		linkURI := request.GetUri()
500		if linkURI == "" {
501			return false
502		}
503
504		wv.logger.Debug().
505			Str("uri", linkURI).
506			Uint("button", mouseButton).
507			Bool("ctrl", isCtrlClick).
508			Msg("middle-click/ctrl+click on link detected")
509
510		if wv.OnLinkMiddleClick != nil {
511			if wv.OnLinkMiddleClick(linkURI) {
512				navDecision.Ignore()
513				return true
514			}
515		}
516
517		return false
518	}
519	sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
520	wv.signalIDs = append(wv.signalIDs, sigID)
521}
522
523// ID returns the unique identifier for this WebView.
524func (wv *WebView) ID() WebViewID {
525	return wv.id
526}
527
528// UserContentManager returns the content manager associated with this WebView.
529func (wv *WebView) UserContentManager() *webkit.UserContentManager {
530	return wv.ucm
531}
532
533// Widget returns the underlying webkit.WebView for GTK embedding.
534func (wv *WebView) Widget() *webkit.WebView {
535	return wv.inner
536}
537
538// GetFindController returns the WebKit FindController wrapped in the port interface.
539// The adapter is cached to prevent the Go wrapper from being garbage collected.
540func (wv *WebView) GetFindController() port.FindController {
541	if wv == nil || wv.inner == nil {
542		return nil
543	}
544
545	wv.findControllerOnce.Do(func() {
546		fc := wv.inner.GetFindController()
547		if fc != nil {
548			wv.findController = &findControllerAdapter{fc: fc}
549		}
550	})
551
552	return wv.findController
553}
554
555// LoadURI loads the given URI.
556func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
557	if wv.destroyed.Load() {
558		return fmt.Errorf("webview %d is destroyed", wv.id)
559	}
560	wv.inner.LoadUri(uri)
561	logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI")
562	return nil
563}
564
565// LoadHTML loads HTML content with an optional base URI.
566func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error {
567	if wv.destroyed.Load() {
568		return fmt.Errorf("webview %d is destroyed", wv.id)
569	}
570	var baseURIPtr *string
571	if baseURI != "" {
572		baseURIPtr = &baseURI
573	}
574	wv.inner.LoadHtml(content, baseURIPtr)
575	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("loading HTML content")
576	return nil
577}
578
579// Reload reloads the current page.
580func (wv *WebView) Reload(ctx context.Context) error {
581	if wv.destroyed.Load() {
582		return fmt.Errorf("webview %d is destroyed", wv.id)
583	}
584	wv.inner.Reload()
585	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview")
586	return nil
587}
588
589// ReloadBypassCache reloads the current page, bypassing the cache.
590func (wv *WebView) ReloadBypassCache(ctx context.Context) error {
591	if wv.destroyed.Load() {
592		return fmt.Errorf("webview %d is destroyed", wv.id)
593	}
594	wv.inner.ReloadBypassCache()
595	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview bypassing cache")
596	return nil
597}
598
599// Stop stops the current load operation.
600func (wv *WebView) Stop(ctx context.Context) error {
601	if wv.destroyed.Load() {
602		return fmt.Errorf("webview %d is destroyed", wv.id)
603	}
604	wv.inner.StopLoading()
605	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("stopping webview load")
606	return nil
607}
608
609// GoBack navigates back in history.
610func (wv *WebView) GoBack(ctx context.Context) error {
611	if wv.destroyed.Load() {
612		return fmt.Errorf("webview %d is destroyed", wv.id)
613	}
614	if !wv.inner.CanGoBack() {
615		return fmt.Errorf("cannot go back")
616	}
617	wv.inner.GoBack()
618	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back")
619	return nil
620}
621
622// GoForward navigates forward in history.
623func (wv *WebView) GoForward(ctx context.Context) error {
624	if wv.destroyed.Load() {
625		return fmt.Errorf("webview %d is destroyed", wv.id)
626	}
627	if !wv.inner.CanGoForward() {
628		return fmt.Errorf("cannot go forward")
629	}
630	wv.inner.GoForward()
631	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward")
632	return nil
633}
634
635// CanGoBack returns true if back navigation is possible.
636func (wv *WebView) CanGoBack() bool {
637	wv.mu.RLock()
638	defer wv.mu.RUnlock()
639	return wv.canGoBack
640}
641
642// CanGoForward returns true if forward navigation is possible.
643func (wv *WebView) CanGoForward() bool {
644	wv.mu.RLock()
645	defer wv.mu.RUnlock()
646	return wv.canGoFwd
647}
648
649// URI returns the current URI.
650func (wv *WebView) URI() string {
651	wv.mu.RLock()
652	defer wv.mu.RUnlock()
653	return wv.uri
654}
655
656// Title returns the current page title.
657func (wv *WebView) Title() string {
658	wv.mu.RLock()
659	defer wv.mu.RUnlock()
660	return wv.title
661}
662
663// Favicon returns the current page favicon as a GdkTexture.
664// Returns nil if no favicon is available.
665func (wv *WebView) Favicon() *gdk.Texture {
666	if wv.destroyed.Load() {
667		return nil
668	}
669	return wv.inner.GetFavicon()
670}
671
672// IsLoading returns true if a page is currently loading.
673func (wv *WebView) IsLoading() bool {
674	wv.mu.RLock()
675	defer wv.mu.RUnlock()
676	return wv.isLoading
677}
678
679// EstimatedProgress returns the estimated load progress (0.0 to 1.0).
680func (wv *WebView) EstimatedProgress() float64 {
681	wv.mu.RLock()
682	defer wv.mu.RUnlock()
683	return wv.progress
684}
685
686// SetZoomLevel sets the zoom level (1.0 = 100%).
687func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error {
688	if wv.destroyed.Load() {
689		return fmt.Errorf("webview %d is destroyed", wv.id)
690	}
691	wv.inner.SetZoomLevel(level)
692	logging.FromContext(ctx).Debug().Float64("factor", level).Int("webview_id", int(wv.id)).Msg("set webview zoom level")
693	return nil
694}
695
696// GetZoomLevel returns the current zoom level.
697func (wv *WebView) GetZoomLevel() float64 {
698	if wv.destroyed.Load() {
699		return 1.0
700	}
701	return wv.inner.GetZoomLevel()
702}
703
704// State returns the current WebView state as a snapshot.
705func (wv *WebView) State() port.WebViewState {
706	return port.WebViewState{
707		URI:       wv.uri,
708		Title:     wv.title,
709		IsLoading: wv.isLoading,
710		Progress:  wv.progress,
711		CanGoBack: wv.canGoBack,
712		CanGoFwd:  wv.canGoFwd,
713		ZoomLevel: wv.GetZoomLevel(),
714	}
715}
716
717// SetCallbacks registers callback handlers for WebView events.
718// Pass nil to clear all callbacks.
719func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
720	if callbacks == nil {
721		wv.OnLoadChanged = nil
722		wv.OnTitleChanged = nil
723		wv.OnURIChanged = nil
724		wv.OnProgressChanged = nil
725		wv.OnFaviconChanged = nil
726		wv.OnClose = nil
727		wv.OnCreate = nil
728		return
729	}
730
731	// Map port callbacks to webkit callbacks
732	if callbacks.OnLoadChanged != nil {
733		wv.OnLoadChanged = func(e LoadEvent) {
734			callbacks.OnLoadChanged(port.LoadEvent(e))
735		}
736	}
737	wv.OnTitleChanged = callbacks.OnTitleChanged
738	wv.OnURIChanged = callbacks.OnURIChanged
739	wv.OnProgressChanged = callbacks.OnProgressChanged
740	if callbacks.OnFaviconChanged != nil {
741		wv.OnFaviconChanged = func(texture *gdk.Texture) {
742			callbacks.OnFaviconChanged(texture)
743		}
744	}
745	wv.OnClose = callbacks.OnClose
746	if callbacks.OnCreate != nil {
747		wv.OnCreate = func(req PopupRequest) *WebView {
748			portReq := port.PopupRequest{
749				TargetURI:     req.TargetURI,
750				FrameName:     req.FrameName,
751				IsUserGesture: req.IsUserGesture,
752				ParentViewID:  req.ParentID,
753			}
754			result := callbacks.OnCreate(portReq)
755			if result == nil {
756				return nil
757			}
758			// The callback returns port.WebView, we need to convert back
759			// This assumes the returned WebView is actually a *webkit.WebView
760			if wkView, ok := result.(*WebView); ok {
761				return wkView
762			}
763			return nil
764		}
765	}
766}
767
768// ShowDevTools opens the WebKit inspector/developer tools.
769func (wv *WebView) ShowDevTools() error {
770	if wv.destroyed.Load() {
771		return fmt.Errorf("webview %d is destroyed", wv.id)
772	}
773	inspector := wv.inner.GetInspector()
774	if inspector == nil {
775		return fmt.Errorf("failed to get inspector for webview %d", wv.id)
776	}
777	inspector.Show()
778	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("devtools shown")
779	return nil
780}
781
782// IsDestroyed returns true if the WebView has been destroyed.
783func (wv *WebView) IsDestroyed() bool {
784	return wv.destroyed.Load()
785}
786
787// Close triggers the close callback as if window.close() was called.
788// This is used for programmatic popup closure (e.g., OAuth auto-close).
789func (wv *WebView) Close() {
790	if wv.destroyed.Load() {
791		return
792	}
793	if wv.OnClose != nil {
794		wv.OnClose()
795	}
796}
797
798// DisconnectSignals disconnects all GLib signal handlers from the WebView.
799// This must be called before releasing the WebView to the pool or destroying it
800// to prevent callbacks from firing on freed/pooled WebViews.
801func (wv *WebView) DisconnectSignals() {
802	if wv.inner == nil {
803		return
804	}
805
806	obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
807	for _, sigID := range wv.signalIDs {
808		gobject.SignalHandlerDisconnect(obj, sigID)
809	}
810	wv.signalIDs = wv.signalIDs[:0] // Clear the slice
811
812	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
813}
814
815// Destroy cleans up the WebView resources.
816func (wv *WebView) Destroy() {
817	if wv.destroyed.Swap(true) {
818		return // Already destroyed
819	}
820
821	// Disconnect all signal handlers first
822	wv.DisconnectSignals()
823
824	// Unregister from global registry
825	globalRegistry.unregister(wv.id)
826
827	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed")
828}
829
830// RunJavaScript executes script in the specified world (empty for main world).
831// This is fire-and-forget: it does not block and errors are logged asynchronously.
832// Safe to call from any context including GTK signal handlers.
833func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) {
834	if wv.destroyed.Load() {
835		return
836	}
837	if ctx == nil {
838		ctx = context.Background()
839	}
840
841	log := logging.FromContext(ctx)
842
843	cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
844		if resPtr == 0 {
845			log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: nil async result")
846			return
847		}
848
849		res := &gio.AsyncResultBase{Ptr: resPtr}
850		value, err := wv.inner.EvaluateJavascriptFinish(res)
851		if err != nil {
852			log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: failed")
853			return
854		}
855
856		if value != nil {
857			if jscCtx := value.GetContext(); jscCtx != nil {
858				if exc := jscCtx.GetException(); exc != nil {
859					log.Warn().
860						Str("exception", exc.GetMessage()).
861						Uint64("webview_id", uint64(wv.id)).
862						Msg("RunJavaScript: JS exception")
863				}
864			}
865		}
866	})
867
868	// prevent callback from being GC'd before it's called
869	wv.mu.Lock()
870	wv.asyncCallbacks = append(wv.asyncCallbacks, cb)
871	wv.mu.Unlock()
872
873	// worldName: nil for main world, &worldName for specific world
874	// sourceUri: nil (not used)
875	var worldNamePtr *string
876	if worldName != "" {
877		worldNamePtr = &worldName
878	}
879	wv.inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)
880}
881
882// AttachFrontend injects scripts/styles and wires the message router once per WebView.
883func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector, router *MessageRouter) error {
884	if ctx == nil {
885		ctx = context.Background()
886	}
887
888	log := logging.FromContext(ctx).With().
889		Str("component", "webview").
890		Uint64("webview_id", uint64(wv.id)).
891		Logger()
892
893	log.Debug().
894		Bool("injector_nil", injector == nil).
895		Bool("router_nil", router == nil).
896		Msg("AttachFrontend called")
897
898	if wv.destroyed.Load() {
899		log.Debug().Msg("AttachFrontend: webview is destroyed")
900		return fmt.Errorf("webview %d is destroyed", wv.id)
901	}
902	if injector == nil && router == nil {
903		log.Debug().Msg("AttachFrontend: both injector and router are nil, skipping")
904		return nil
905	}
906
907	if !wv.frontendAttached.CompareAndSwap(false, true) {
908		log.Debug().Msg("AttachFrontend: already attached, skipping")
909		return nil // already attached
910	}
911
912	var attachErr error
913
914	defer func() {
915		if attachErr != nil {
916			// allow retry on next call
917			wv.frontendAttached.Store(false)
918		}
919	}()
920
921	if router != nil {
922		log.Debug().Msg("AttachFrontend: setting up message router")
923		if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil {
924			attachErr = fmt.Errorf("setup message router: %w", err)
925			log.Warn().Err(err).Msg("failed to attach message router")
926			return attachErr
927		}
928		log.Debug().Msg("AttachFrontend: message router setup complete")
929	}
930
931	if injector != nil {
932		log.Debug().Msg("AttachFrontend: injecting scripts")
933		injector.InjectScripts(ctx, wv.ucm, wv.id)
934	}
935
936	log.Debug().Msg("frontend assets attached to webview")
937	return nil
938}

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