v0.27.2 5960dd8

webview.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"fmt"
6	"net/url"
7	"os"
8	"strings"
9	"sync"
10	"sync/atomic"
11	"time"
12
13	"github.com/bnema/dumber/internal/application/port"
14	urlutil "github.com/bnema/dumber/internal/domain/url"
15	"github.com/bnema/dumber/internal/infrastructure/desktop"
16	"github.com/bnema/dumber/internal/logging"
17	"github.com/bnema/puregotk-webkit/webkit"
18	"github.com/jwijenbergh/puregotk/v4/gdk"
19	"github.com/jwijenbergh/puregotk/v4/gio"
20	"github.com/jwijenbergh/puregotk/v4/glib"
21	"github.com/jwijenbergh/puregotk/v4/gobject"
22	gtypes "github.com/jwijenbergh/puregotk/v4/gobject/types"
23	"github.com/jwijenbergh/puregotk/v4/gtk"
24	"github.com/rs/zerolog"
25)
26
27// Compile-time interface check: WebView must implement port.WebView.
28var _ port.WebView = (*WebView)(nil)
29
30// WebViewID is an alias to port.WebViewID for clean architecture compliance.
31// Infrastructure layer uses the type defined in the application port.
32type WebViewID = port.WebViewID
33
34// LoadEvent represents WebKit load events.
35type LoadEvent int
36
37const (
38	LoadStarted    LoadEvent = LoadEvent(webkit.LoadStartedValue)
39	LoadRedirected LoadEvent = LoadEvent(webkit.LoadRedirectedValue)
40	LoadCommitted  LoadEvent = LoadEvent(webkit.LoadCommittedValue)
41	LoadFinished   LoadEvent = LoadEvent(webkit.LoadFinishedValue)
42)
43
44// PopupRequest contains information about a popup window request from the create signal.
45type PopupRequest struct {
46	TargetURI     string
47	FrameName     string // e.g., "_blank", custom name, or empty
48	IsUserGesture bool
49	ParentID      WebViewID
50}
51
52// webViewRegistry tracks all active WebViews.
53type webViewRegistry struct {
54	views   map[WebViewID]*WebView
55	byUCM   map[uintptr]WebViewID
56	counter atomic.Uint64
57	mu      sync.RWMutex
58}
59
60var globalRegistry = &webViewRegistry{
61	views: make(map[WebViewID]*WebView),
62	byUCM: make(map[uintptr]WebViewID),
63}
64
65func (r *webViewRegistry) register(wv *WebView) WebViewID {
66	id := WebViewID(r.counter.Add(1))
67	r.mu.Lock()
68	r.views[id] = wv
69	if wv != nil && wv.ucm != nil {
70		r.byUCM[wv.ucm.GoPointer()] = id
71	}
72	r.mu.Unlock()
73	return id
74}
75
76func (r *webViewRegistry) unregister(id WebViewID) {
77	r.mu.Lock()
78	wv := r.views[id]
79	delete(r.views, id)
80	if wv != nil && wv.ucm != nil {
81		delete(r.byUCM, wv.ucm.GoPointer())
82	}
83	r.mu.Unlock()
84}
85
86// Lookup returns a WebView by ID, or nil if not found.
87func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
88	r.mu.RLock()
89	defer r.mu.RUnlock()
90	return r.views[id]
91}
92
93func (r *webViewRegistry) LookupByUCMPointer(ptr uintptr) *WebView {
94	if ptr == 0 {
95		return nil
96	}
97	r.mu.RLock()
98	defer r.mu.RUnlock()
99	id, ok := r.byUCM[ptr]
100	if !ok {
101		return nil
102	}
103	return r.views[id]
104}
105
106// LookupWebView returns a WebView by ID from the global registry.
107func LookupWebView(id WebViewID) *WebView {
108	return globalRegistry.Lookup(id)
109}
110
111// LookupWebViewByUCMPointer returns a WebView by its UserContentManager pointer.
112func LookupWebViewByUCMPointer(ptr uintptr) *WebView {
113	return globalRegistry.LookupByUCMPointer(ptr)
114}
115
116// WebView wraps webkit.WebView with Go-level state tracking and callbacks.
117type WebView struct {
118	id    WebViewID
119	inner *webkit.WebView
120	ucm   *webkit.UserContentManager
121
122	// State (protected by mutex)
123	destroyed atomic.Bool
124	isRelated bool // true if created via NewWebViewWithRelated (shares web process with parent)
125	uri       string
126	title     string
127	progress  float64
128	canGoBack bool
129	canGoFwd  bool
130	isLoading bool
131
132	// Media/fullscreen state for idle inhibition cleanup
133	isFullscreen   atomic.Bool
134	isPlayingAudio atomic.Bool
135
136	// Progress throttling (~60fps)
137	lastProgressUpdate atomic.Int64 // Unix nanoseconds
138
139	// Signal handler IDs for disconnection
140	signalIDs []uintptr
141
142	// Callbacks (set by UI layer)
143	OnLoadChanged          func(LoadEvent)
144	OnTitleChanged         func(string)
145	OnURIChanged           func(string)
146	OnProgressChanged      func(float64)
147	OnFaviconChanged       func(*gdk.Texture) // Called when page favicon changes
148	OnClose                func()
149	OnCreate               func(PopupRequest) *WebView // Return new WebView or nil to block popup
150	OnReadyToShow          func()                      // Called when popup is ready to display
151	OnLinkMiddleClick      func(uri string) bool       // Return true if handled (blocks navigation)
152	OnEnterFullscreen      func() bool                 // Return true to prevent fullscreen
153	OnLeaveFullscreen      func() bool                 // Return true to prevent leaving fullscreen
154	OnAudioStateChanged    func(playing bool)          // Called when audio playback starts/stops
155	OnLinkHover            func(uri string)            // Called when hovering over a link/image/media (empty string when leaving)
156	OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
157
158	// PermissionRequest is called when a site requests permission (mic, camera, screen sharing).
159	// Return true to indicate the request was handled. Call allow()/deny() to respond.
160	// The permission types are determined from the request object.
161	OnPermissionRequest func(origin string, permTypes []string, allow, deny func()) bool
162
163	logger zerolog.Logger
164	mu     sync.RWMutex
165
166	frontendAttached atomic.Bool
167	navigationActive atomic.Bool
168
169	// asyncCallbacks keeps references to async JS callbacks to prevent GC
170	asyncCallbacks []interface{}
171
172	// runJSErrorStats aggregates repeated non-fatal RunJavaScript errors by domain+signature.
173	runJSErrorStats map[string]runJSErrorStat
174
175	// findController is cached to prevent GC from collecting the Go wrapper
176	findController     *findControllerAdapter
177	findControllerOnce sync.Once
178
179	backForwardList         *webkit.BackForwardList
180	backForwardListSignalID uintptr
181}
182
183type runJSErrorStat struct {
184	count   uint64
185	lastLog time.Time
186}
187
188const (
189	terminatePolicyAuto   = "auto"
190	terminatePolicyAlways = "always"
191	terminatePolicyNever  = "never"
192
193	runJSNonFatalLogInterval = 30 * time.Second
194	runJSAggregateLogEvery   = 20
195	runJSUnknown             = "unknown"
196)
197
198type findControllerAdapter struct {
199	fc *webkit.FindController
200}
201
202func (a *findControllerAdapter) Search(text string, opts port.FindOptions, maxMatches uint) {
203	if a == nil || a.fc == nil {
204		return
205	}
206	var flags uint32
207	if opts.WrapAround {
208		flags |= uint32(webkit.FindOptionsWrapAroundValue)
209	}
210	if opts.CaseInsensitive {
211		flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
212	}
213	if opts.AtWordStarts {
214		flags |= uint32(webkit.FindOptionsAtWordStartsValue)
215	}
216	a.fc.Search(text, flags, maxMatches)
217}
218
219func (a *findControllerAdapter) CountMatches(text string, opts port.FindOptions, maxMatches uint) {
220	if a == nil || a.fc == nil {
221		return
222	}
223	var flags uint32
224	if opts.WrapAround {
225		flags |= uint32(webkit.FindOptionsWrapAroundValue)
226	}
227	if opts.CaseInsensitive {
228		flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
229	}
230	if opts.AtWordStarts {
231		flags |= uint32(webkit.FindOptionsAtWordStartsValue)
232	}
233	a.fc.CountMatches(text, flags, maxMatches)
234}
235
236func (a *findControllerAdapter) SearchNext() {
237	if a == nil || a.fc == nil {
238		return
239	}
240	a.fc.SearchNext()
241}
242
243func (a *findControllerAdapter) SearchPrevious() {
244	if a == nil || a.fc == nil {
245		return
246	}
247	a.fc.SearchPrevious()
248}
249
250func (a *findControllerAdapter) SearchFinish() {
251	if a == nil || a.fc == nil {
252		return
253	}
254	a.fc.SearchFinish()
255}
256
257func (a *findControllerAdapter) GetSearchText() string {
258	if a == nil || a.fc == nil {
259		return ""
260	}
261	return a.fc.GetSearchText()
262}
263
264func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint {
265	if a == nil || a.fc == nil || callback == nil {
266		return 0
267	}
268	cb := func(_ webkit.FindController, matchCount uint) {
269		callback(matchCount)
270	}
271	return a.fc.ConnectFoundText(&cb)
272}
273
274func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint {
275	if a == nil || a.fc == nil || callback == nil {
276		return 0
277	}
278	cb := func(_ webkit.FindController) {
279		callback()
280	}
281	return a.fc.ConnectFailedToFindText(&cb)
282}
283
284func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint {
285	if a == nil || a.fc == nil || callback == nil {
286		return 0
287	}
288	cb := func(_ webkit.FindController, matchCount uint) {
289		callback(matchCount)
290	}
291	return a.fc.ConnectCountedMatches(&cb)
292}
293
294func (a *findControllerAdapter) DisconnectSignal(id uint) {
295	if a == nil || a.fc == nil || id == 0 {
296		return
297	}
298	obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())
299	gobject.SignalHandlerDisconnect(obj, id)
300}
301
302// NewWebView creates a new WebView with the given context and settings.
303// Uses the persistent NetworkSession from wkCtx for cookie/data persistence.
304// bgColor is optional - if provided, sets background immediately to prevent white flash.
305func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, bgColor *gdk.RGBA) (*WebView, error) {
306	log := logging.FromContext(ctx)
307
308	if wkCtx == nil || !wkCtx.IsInitialized() {
309		return nil, fmt.Errorf("webkit context not initialized")
310	}
311
312	// Use options constructor to pass both WebContext and NetworkSession.
313	// This ensures WebViews use our custom WebContext (with memory pressure settings)
314	// and the persistent NetworkSession for cookie/storage.
315	inner := webkit.NewWebViewWithOptions(&webkit.WebViewOptions{
316		WebContext:     wkCtx.Context(),
317		NetworkSession: wkCtx.NetworkSession(),
318	})
319	if inner == nil {
320		return nil, fmt.Errorf("failed to create webkit webview with options")
321	}
322
323	// Set background color IMMEDIATELY after creation, before any other operations.
324	// This prevents white flash by ensuring WebKit's renderer starts with correct color.
325	if bgColor != nil {
326		inner.SetBackgroundColor(bgColor)
327	}
328
329	wv := &WebView{
330		inner:           inner,
331		ucm:             inner.GetUserContentManager(),
332		logger:          log.With().Str("component", "webview").Logger(),
333		signalIDs:       make([]uintptr, 0, 4),
334		runJSErrorStats: make(map[string]runJSErrorStat),
335	}
336
337	// Register in global registry
338	wv.id = globalRegistry.register(wv)
339
340	// Apply settings if provided
341	if settings != nil {
342		settings.ApplyToWebView(ctx, inner)
343	}
344
345	// Connect signals
346	wv.connectSignals()
347
348	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview created")
349
350	return wv, nil
351}
352
353// NewWebViewWithRelated creates a WebView that shares session/cookies with parent.
354// This is required for popup windows to maintain authentication state.
355func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *SettingsManager) (*WebView, error) {
356	log := logging.FromContext(ctx)
357
358	if parent == nil {
359		return nil, fmt.Errorf("parent webview is nil")
360	}
361	if parent.IsDestroyed() {
362		return nil, fmt.Errorf("parent webview %d is destroyed", parent.id)
363	}
364
365	log.Debug().
366		Uint64("parent_ptr", uint64(parent.inner.GoPointer())).
367		Msg("creating related webview with parent pointer")
368
369	inner := webkit.NewWebViewWithRelatedView(parent.inner)
370	if inner == nil {
371		return nil, fmt.Errorf("failed to create related webkit webview")
372	}
373
374	log.Debug().
375		Uint64("new_ptr", uint64(inner.GoPointer())).
376		Uint64("new_widget_ptr", uint64(inner.Widget.GoPointer())).
377		Msg("related webview created, checking pointers")
378
379	wv := &WebView{
380		inner:           inner,
381		isRelated:       true, // Shares web process with parent - must not terminate process on destroy
382		ucm:             inner.GetUserContentManager(),
383		logger:          log.With().Str("component", "webview-popup").Logger(),
384		signalIDs:       make([]uintptr, 0, 6),
385		runJSErrorStats: make(map[string]runJSErrorStat),
386	}
387
388	wv.id = globalRegistry.register(wv)
389
390	if settings != nil {
391		settings.ApplyToWebView(ctx, inner)
392	}
393
394	wv.connectSignals()
395
396	wv.logger.Debug().
397		Uint64("id", uint64(wv.id)).
398		Uint64("parent_id", uint64(parent.id)).
399		Msg("related webview created for popup")
400
401	return wv, nil
402}
403
404// connectSignals sets up signal handlers for the WebView.
405func (wv *WebView) connectSignals() {
406	wv.connectLoadChangedSignal()
407	wv.connectCloseSignal()
408	wv.connectCreateSignal()
409	wv.connectReadyToShowSignal()
410	wv.connectTitleSignal()
411	wv.connectURISignal()
412	wv.connectFaviconSignal()
413	wv.connectProgressSignal()
414	wv.connectDecidePolicySignal()
415	wv.connectEnterFullscreenSignal()
416	wv.connectLeaveFullscreenSignal()
417	wv.connectAudioStateSignal()
418	wv.connectMediaCaptureStateSignals()
419	wv.connectMouseTargetChangedSignal()
420	wv.connectBackForwardListChangedSignal()
421	wv.connectWebProcessTerminatedSignal()
422	wv.connectPermissionRequestSignal()
423}
424
425func (wv *WebView) connectLoadChangedSignal() {
426	loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
427		uri := inner.GetUri()
428		title := inner.GetTitle()
429
430		wv.mu.Lock()
431		wv.uri = uri
432		wv.title = title
433		// Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
434		// which fires for both traditional navigation and SPA history.pushState()
435		wv.progress = inner.GetEstimatedLoadProgress()
436
437		switch event {
438		case webkit.LoadStartedValue:
439			wv.navigationActive.Store(true)
440			wv.isLoading = true
441			wv.logger.Debug().Str("uri", uri).Msg("load started")
442		case webkit.LoadRedirectedValue:
443			wv.logger.Debug().Str("uri", uri).Msg("load redirected")
444		case webkit.LoadCommittedValue:
445			wv.logger.Debug().Str("uri", uri).Msg("load committed")
446		case webkit.LoadFinishedValue:
447			wv.isLoading = false
448			wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
449		}
450		wv.mu.Unlock()
451
452		if wv.OnLoadChanged != nil {
453			wv.OnLoadChanged(LoadEvent(event))
454		}
455	}
456	sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
457	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
458}
459
460func (wv *WebView) connectCloseSignal() {
461	closeCb := func(_ webkit.WebView) {
462		if wv.OnClose != nil {
463			wv.OnClose()
464		}
465	}
466	sigID := wv.inner.ConnectClose(&closeCb)
467	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
468}
469
470func (wv *WebView) connectCreateSignal() {
471	createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget {
472		if wv.OnCreate == nil {
473			return gtk.Widget{} // Block popup
474		}
475
476		navAction := webkit.NavigationActionFromPointer(navActionPtr)
477		if navAction == nil {
478			return gtk.Widget{} // Block popup
479		}
480		defer navAction.Free()
481
482		var targetURI string
483		if req := navAction.GetRequest(); req != nil {
484			targetURI = req.GetUri()
485		}
486
487		popupReq := PopupRequest{
488			TargetURI:     targetURI,
489			FrameName:     navAction.GetFrameName(),
490			IsUserGesture: navAction.IsUserGesture(),
491			ParentID:      wv.id,
492		}
493
494		wv.logger.Debug().Msg("create signal: invoking OnCreate handler")
495		newWV := wv.OnCreate(popupReq)
496		wv.logger.Debug().Bool("nil", newWV == nil).Msg("create signal: OnCreate returned")
497		if newWV == nil {
498			return gtk.Widget{} // Block popup
499		}
500
501		wv.logger.Debug().
502			Uint64("parent_id", uint64(wv.id)).
503			Uint64("popup_id", uint64(newWV.id)).
504			Msg("create signal: returning webview widget to WebKit")
505
506		return newWV.inner.Widget
507	}
508	sigID := wv.inner.ConnectCreate(&createCb)
509	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
510}
511
512func (wv *WebView) connectReadyToShowSignal() {
513	readyToShowCb := func(_ webkit.WebView) {
514		if wv.OnReadyToShow != nil {
515			wv.OnReadyToShow()
516		}
517	}
518	sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
519	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
520}
521
522func (wv *WebView) connectTitleSignal() {
523	titleCb := func() {
524		title := wv.inner.GetTitle()
525		wv.mu.Lock()
526		wv.title = title
527		wv.mu.Unlock()
528
529		if wv.OnTitleChanged != nil {
530			wv.OnTitleChanged(title)
531		}
532	}
533	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
534	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
535}
536
537func (wv *WebView) connectURISignal() {
538	uriCb := func() {
539		uri := wv.inner.GetUri()
540		wv.mu.Lock()
541		oldUri := wv.uri
542		wv.uri = uri
543		wv.mu.Unlock()
544
545		if wv.OnURIChanged != nil && uri != oldUri {
546			wv.OnURIChanged(uri)
547		}
548	}
549	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
550	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
551}
552
553func (wv *WebView) connectFaviconSignal() {
554	faviconCb := func() {
555		favicon := wv.inner.GetFavicon()
556		if wv.OnFaviconChanged != nil {
557			wv.OnFaviconChanged(favicon)
558		}
559	}
560	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
561	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
562}
563
564// progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
565const progressThrottleInterval = 16 * time.Millisecond
566
567func (wv *WebView) connectProgressSignal() {
568	progressCb := func() {
569		progress := wv.inner.GetEstimatedLoadProgress()
570
571		// Throttle progress updates to ~60fps (16ms interval)
572		// Always allow 0.0 (start) and 1.0 (complete) through
573		now := time.Now().UnixNano()
574		last := wv.lastProgressUpdate.Load()
575		if progress > 0.0 && progress < 1.0 {
576			if now-last < int64(progressThrottleInterval) {
577				return // Skip this update
578			}
579		}
580		wv.lastProgressUpdate.Store(now)
581
582		wv.mu.Lock()
583		wv.progress = progress
584		wv.mu.Unlock()
585
586		if wv.OnProgressChanged != nil {
587			wv.OnProgressChanged(progress)
588		}
589	}
590	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))
591	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
592}
593
594func (wv *WebView) connectDecidePolicySignal() {
595	decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
596		switch decisionType {
597		case webkit.PolicyDecisionTypeResponseValue:
598			return wv.handleResponsePolicyDecision(decisionPtr)
599		case webkit.PolicyDecisionTypeNavigationActionValue, webkit.PolicyDecisionTypeNewWindowActionValue:
600			// Both navigation and new window actions use NavigationPolicyDecision
601			return wv.handleNavigationPolicyDecision(decisionPtr)
602		default:
603			return false
604		}
605	}
606	sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
607	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
608}
609
610// handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
611func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
612	responseDecision := webkit.ResponsePolicyDecisionNewFromInternalPtr(decisionPtr)
613	if responseDecision == nil {
614		return false
615	}
616
617	if !responseDecision.IsMainFrameMainResource() {
618		return false
619	}
620
621	if !shouldForceDownload(responseDecision) {
622		return false
623	}
624
625	policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr)
626	if policyDecision == nil {
627		return false
628	}
629
630	response := responseDecision.GetResponse()
631	mimeType := ""
632	uri := ""
633	if response != nil {
634		mimeType = response.GetMimeType()
635		uri = response.GetUri()
636	}
637
638	wv.logger.Debug().
639		Str("mime_type", mimeType).
640		Str("uri", uri).
641		Msg("forcing download for response")
642
643	// Start a fresh download instead of converting the in-progress response.
644	// Using policyDecision.Download() can fail due to race conditions where
645	// the network connection is released before the download can use it.
646	// By ignoring the policy and starting a new download, we avoid this issue.
647	// We defer the download to the next idle cycle to let WebKit clean up
648	// the ignored navigation first.
649	if uri != "" {
650		policyDecision.Ignore()
651		downloadURI := uri
652		inner := wv.inner
653		cb := glib.SourceFunc(func(_ uintptr) bool {
654			inner.DownloadUri(downloadURI)
655			return false // Don't repeat
656		})
657		// Store callback reference to prevent GC before GTK calls it
658		wv.mu.Lock()
659		wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
660		wv.mu.Unlock()
661		glib.IdleAdd(&cb, 0)
662		return true
663	}
664
665	// Fallback to old behavior if URI is not available
666	policyDecision.Download()
667	return true
668}
669
670// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click, external schemes).
671func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
672	navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
673	if navDecision == nil {
674		return false
675	}
676
677	navAction := navDecision.GetNavigationAction()
678	if navAction == nil {
679		return false
680	}
681
682	request := navAction.GetRequest()
683	if request == nil {
684		return false
685	}
686
687	linkURI := request.GetUri()
688	if linkURI == "" {
689		return false
690	}
691
692	// Debug logging to trace navigation decisions
693	wv.logger.Debug().
694		Str("uri", linkURI).
695		Int("nav_type", int(navAction.GetNavigationType())).
696		Bool("user_gesture", navAction.IsUserGesture()).
697		Msg("navigation policy decision")
698
699	// Check for external URL schemes (e.g., vscode://, vscode-insiders://, spotify://)
700	// These need to be launched via xdg-open rather than handled by WebKit
701	// Only launch for user-initiated actions to prevent automatic redirects
702	// from silently opening external applications
703	if urlutil.IsExternalScheme(linkURI) {
704		if !navAction.IsUserGesture() {
705			// For non-user-initiated redirects, ignore silently
706			navDecision.Ignore()
707			return true
708		}
709
710		wv.logger.Info().
711			Str("uri", linkURI).
712			Msg("launching external URL scheme via xdg-open")
713
714		// Launch asynchronously to avoid blocking WebKit
715		go desktop.LaunchExternalURL(linkURI)
716
717		// Ignore the navigation to prevent WebKit from showing an error
718		navDecision.Ignore()
719
720		// Go back to the previous page to avoid showing WebKit's error page.
721		// This handles OAuth flows where after successful auth, the callback
722		// redirects to a custom scheme (vscode://, vscode-insiders://, etc.)
723		// Schedule on idle to let WebKit process the ignored decision first
724		cb := glib.SourceFunc(func(_ uintptr) bool {
725			if wv.inner != nil && !wv.destroyed.Load() && wv.inner.CanGoBack() {
726				wv.inner.GoBack()
727			}
728			return false // Don't repeat
729		})
730		wv.mu.Lock()
731		wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
732		wv.mu.Unlock()
733		glib.IdleAdd(&cb, 0)
734
735		return true
736	}
737
738	// Only handle link clicks for middle-click/ctrl-click (open in new tab)
739	if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
740		return false
741	}
742
743	mouseButton := navAction.GetMouseButton()
744	modifiers := navAction.GetModifiers()
745	isMiddleClick := mouseButton == 2
746	isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
747
748	if !isMiddleClick && !isCtrlClick {
749		return false
750	}
751
752	wv.logger.Debug().
753		Str("uri", linkURI).
754		Uint("button", mouseButton).
755		Bool("ctrl", isCtrlClick).
756		Msg("middle-click/ctrl+click on link detected")
757
758	if wv.OnLinkMiddleClick != nil {
759		if wv.OnLinkMiddleClick(linkURI) {
760			navDecision.Ignore()
761			return true
762		}
763	}
764
765	return false
766}
767
768func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
769	if responseDecision == nil {
770		return false
771	}
772
773	response := responseDecision.GetResponse()
774	if response == nil {
775		return false
776	}
777
778	mimeType := strings.ToLower(response.GetMimeType())
779	if strings.HasPrefix(mimeType, "application/pdf") {
780		return true
781	}
782
783	uri := response.GetUri()
784	if uri == "" {
785		return false
786	}
787
788	parsed, err := url.Parse(uri)
789	if err != nil {
790		return strings.Contains(strings.ToLower(uri), ".pdf")
791	}
792
793	return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf")
794}
795
796func (wv *WebView) connectEnterFullscreenSignal() {
797	enterFullscreenCb := func(_ webkit.WebView) bool {
798		wv.isFullscreen.Store(true)
799		wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
800		if wv.OnEnterFullscreen != nil {
801			return wv.OnEnterFullscreen()
802		}
803		return false // Allow fullscreen
804	}
805	sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
806	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
807}
808
809func (wv *WebView) connectLeaveFullscreenSignal() {
810	leaveFullscreenCb := func(_ webkit.WebView) bool {
811		wv.isFullscreen.Store(false)
812		wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
813		if wv.OnLeaveFullscreen != nil {
814			return wv.OnLeaveFullscreen()
815		}
816		return false // Allow leaving fullscreen
817	}
818	sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
819	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
820}
821
822func (wv *WebView) connectAudioStateSignal() {
823	audioCb := func() {
824		playing := wv.inner.IsPlayingAudio()
825		oldState := wv.isPlayingAudio.Swap(playing)
826		if oldState != playing {
827			wv.logger.Debug().
828				Uint64("id", uint64(wv.id)).
829				Bool("playing", playing).
830				Msg("audio state changed")
831			if wv.OnAudioStateChanged != nil {
832				wv.OnAudioStateChanged(playing)
833			}
834		}
835	}
836	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))
837	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
838}
839
840func (wv *WebView) connectMediaCaptureStateSignals() {
841	connect := func(signal string, readState func() webkit.MediaCaptureState, kind string) {
842		cb := func() {
843			state := readState()
844			wv.logger.Debug().
845				Uint64("id", uint64(wv.id)).
846				Str("kind", kind).
847				Str("state", mediaCaptureStateString(state)).
848				Int("state_code", int(state)).
849				Msg("media capture state changed")
850		}
851		sigID := gobject.SignalConnect(wv.inner.GoPointer(), signal, glib.NewCallback(&cb))
852		wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
853	}
854
855	connect("notify::camera-capture-state", wv.inner.GetCameraCaptureState, "camera")
856	connect("notify::microphone-capture-state", wv.inner.GetMicrophoneCaptureState, "microphone")
857	connect("notify::display-capture-state", wv.inner.GetDisplayCaptureState, "display")
858}
859
860func mediaCaptureStateString(state webkit.MediaCaptureState) string {
861	switch state {
862	case webkit.MediaCaptureStateNoneValue:
863		return "none"
864	case webkit.MediaCaptureStateActiveValue:
865		return "active"
866	case webkit.MediaCaptureStateMutedValue:
867		return "muted"
868	default:
869		return "unknown"
870	}
871}
872
873func (wv *WebView) connectMouseTargetChangedSignal() {
874	mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
875		if wv.OnLinkHover == nil {
876			return
877		}
878
879		hitResult := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
880		if hitResult == nil {
881			wv.OnLinkHover("")
882			return
883		}
884
885		var uri string
886		switch {
887		case hitResult.ContextIsLink():
888			uri = hitResult.GetLinkUri()
889		case hitResult.ContextIsImage():
890			uri = hitResult.GetImageUri()
891		case hitResult.ContextIsMedia():
892			uri = hitResult.GetMediaUri()
893		}
894
895		wv.OnLinkHover(uri)
896	}
897	sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
898	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
899}
900
901func (wv *WebView) connectBackForwardListChangedSignal() {
902	backForwardList := wv.inner.GetBackForwardList()
903	if backForwardList == nil {
904		wv.logger.Warn().Msg("failed to get back-forward-list")
905		return
906	}
907
908	// The "changed" signal is emitted when the back-forward list changes,
909	// including for SPA navigation via history.pushState/replaceState.
910	// Parameters: (item_added, items_removed) - we ignore them and just refresh state.
911	changedCb := func(_ webkit.BackForwardList, _ uintptr, _ uintptr) {
912		canBack := wv.inner.CanGoBack()
913		canFwd := wv.inner.CanGoForward()
914		wv.logger.Debug().
915			Bool("can_go_back", canBack).
916			Bool("can_go_forward", canFwd).
917			Uint("list_length", backForwardList.GetLength()).
918			Msg("back-forward-list changed")
919		wv.mu.Lock()
920		wv.canGoBack = canBack
921		wv.canGoFwd = canFwd
922		wv.mu.Unlock()
923	}
924	sigID := backForwardList.ConnectChanged(&changedCb)
925	wv.backForwardList = backForwardList
926	wv.backForwardListSignalID = uintptr(sigID)
927}
928
929func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string {
930	switch reason {
931	case webkit.WebProcessCrashedValue:
932		return "crashed"
933	case webkit.WebProcessExceededMemoryLimitValue:
934		return "exceeded_memory"
935	case webkit.WebProcessTerminatedByApiValue:
936		return "terminated_by_api"
937	default:
938		return "unknown"
939	}
940}
941
942func mapWebProcessTerminationReason(reason webkit.WebProcessTerminationReason) port.WebProcessTerminationReason {
943	switch reason {
944	case webkit.WebProcessCrashedValue:
945		return port.WebProcessTerminationCrashed
946	case webkit.WebProcessExceededMemoryLimitValue:
947		return port.WebProcessTerminationExceededMemory
948	case webkit.WebProcessTerminatedByApiValue:
949		return port.WebProcessTerminationByAPI
950	default:
951		return port.WebProcessTerminationUnknown
952	}
953}
954
955func (wv *WebView) connectWebProcessTerminatedSignal() {
956	terminatedCb := func(_ webkit.WebView, reason webkit.WebProcessTerminationReason) {
957		uri := wv.URI()
958		reasonLabel := webProcessTerminationReasonString(reason)
959		wv.logger.Warn().
960			Uint64("id", uint64(wv.id)).
961			Str("reason", reasonLabel).
962			Int("reason_code", int(reason)).
963			Str("uri", uri).
964			Msg("web process terminated")
965
966		if wv.OnWebProcessTerminated != nil {
967			wv.OnWebProcessTerminated(reason, reasonLabel, uri)
968		}
969	}
970	sigID := wv.inner.ConnectWebProcessTerminated(&terminatedCb)
971	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
972}
973
974// connectPermissionRequestSignal sets up the permission-request signal handler.
975// This is emitted when a site calls getUserMedia() or getDisplayMedia().
976func (wv *WebView) connectPermissionRequestSignal() {
977	permissionCb := func(_ webkit.WebView, requestPtr uintptr) bool {
978		ctx := logging.WithContext(context.Background(), wv.logger)
979
980		if wv.OnPermissionRequest == nil {
981			return false // Not handled, WebKit will deny by default
982		}
983
984		// Extract and normalize origin from current URI
985		uri := wv.URI()
986		if uri == "" {
987			wv.logger.Debug().Msg("permission request with empty origin, denying")
988			return false
989		}
990		origin, err := urlutil.ExtractOrigin(uri)
991		if err != nil {
992			wv.logger.Debug().Str("uri", uri).Err(err).Msg("permission request: failed to extract origin, denying")
993			return false
994		}
995
996		// Determine permission types from the request
997		permTypes := wv.determinePermissionTypes(ctx, requestPtr)
998		if len(permTypes) == 0 {
999			wv.logger.Warn().Msg("permission request with unknown type, denying")
1000			return false
1001		}
1002
1003		// Ref the request object to prevent use-after-free
1004		// The request may outlive the signal handler if we show a dialog
1005		requestObj := gobject.ObjectNewFromInternalPtr(requestPtr)
1006		if requestObj == nil {
1007			wv.logger.Warn().Msg("permission request: failed to wrap request object")
1008			return false
1009		}
1010		requestObj.Ref()
1011
1012		// Create allow/deny callbacks that wrap the WebKit permission request
1013		allowCalled := false
1014		denyCalled := false
1015
1016		allow := func() {
1017			if allowCalled || denyCalled {
1018				return
1019			}
1020			allowCalled = true
1021			wv.allowPermissionRequest(requestPtr)
1022			requestObj.Unref()
1023		}
1024
1025		deny := func() {
1026			if allowCalled || denyCalled {
1027				return
1028			}
1029			denyCalled = true
1030			wv.denyPermissionRequest(requestPtr)
1031			requestObj.Unref()
1032		}
1033
1034		// Call the handler
1035		return wv.OnPermissionRequest(origin, permTypes, allow, deny)
1036	}
1037
1038	sigID := wv.inner.ConnectPermissionRequest(&permissionCb)
1039	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
1040}
1041
1042// determinePermissionTypes extracts permission types from a WebKit permission request.
1043// This uses type checking to identify UserMediaPermissionRequest and its specific types.
1044// We use GObject property accessors for audio/video request flags. Display detection
1045// uses the dedicated WebKit API because this WebKit build does not expose an
1046// "is-for-display-device" GObject property.
1047func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uintptr) []string {
1048	requestKind := detectPermissionRequestKind(ctx, requestPtr)
1049	switch requestKind {
1050	case permissionRequestKindUserMedia:
1051		userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1052		if userMediaReq == nil {
1053			return nil
1054		}
1055
1056		// Use GObject property accessors — more reliable than the C function wrappers
1057		// which can hit the purego bool return value bug.
1058		isAudio := userMediaReq.GetPropertyIsForAudioDevice()
1059		isVideo := userMediaReq.GetPropertyIsForVideoDevice()
1060		isDisplay := webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq)
1061
1062		wv.logger.Debug().
1063			Bool("is_audio", isAudio).
1064			Bool("is_video", isVideo).
1065			Bool("is_display", isDisplay).
1066			Msg("permission request type detection")
1067
1068		return classifyPermissionRequestTypes(ctx, requestKind, isAudio, isVideo, isDisplay)
1069	case permissionRequestKindDeviceInfo:
1070		return classifyPermissionRequestTypes(ctx, requestKind, false, false, false)
1071	default:
1072		if requestPtr != 0 {
1073			typeName := permissionRequestTypeName(ctx, requestPtr)
1074			if typeName != "" {
1075				wv.logger.Warn().Str("request_type", typeName).Msg("unknown permission request type")
1076			} else {
1077				wv.logger.Warn().Msg("unknown permission request type")
1078			}
1079		}
1080		// Unknown permission type - could be clipboard, notifications, geolocation, etc.
1081		// For now, return empty to trigger denial. Future phases will add these types.
1082		return nil
1083	}
1084}
1085
1086type permissionRequestKind int
1087
1088const (
1089	permissionRequestKindUnknown permissionRequestKind = iota
1090	permissionRequestKindUserMedia
1091	permissionRequestKindDeviceInfo
1092)
1093
1094func detectPermissionRequestKind(ctx context.Context, requestPtr uintptr) permissionRequestKind {
1095	if isPermissionRequestType(ctx, requestPtr, webkit.UserMediaPermissionRequestGLibType()) {
1096		return permissionRequestKindUserMedia
1097	}
1098	if isPermissionRequestType(ctx, requestPtr, webkit.DeviceInfoPermissionRequestGLibType()) {
1099		return permissionRequestKindDeviceInfo
1100	}
1101	return permissionRequestKindUnknown
1102}
1103
1104func isPermissionRequestType(ctx context.Context, requestPtr uintptr, requestType gtypes.GType) bool {
1105	return gobjectTypeCheckInstanceIsAByPtr(ctx, requestPtr, requestType)
1106}
1107
1108func classifyPermissionRequestTypes(
1109	ctx context.Context,
1110	kind permissionRequestKind,
1111	isAudio, isVideo, isDisplay bool,
1112) []string {
1113	switch kind {
1114	case permissionRequestKindUserMedia:
1115		return classifyUserMediaPermissionTypes(ctx, isAudio, isVideo, isDisplay)
1116	case permissionRequestKindDeviceInfo:
1117		return []string{"device_info"}
1118	default:
1119		return nil
1120	}
1121}
1122
1123func classifyUserMediaPermissionTypes(ctx context.Context, isAudio, isVideo, isDisplay bool) []string {
1124	types := make([]string, 0, 2)
1125
1126	if isAudio {
1127		types = append(types, "microphone")
1128	}
1129
1130	if isDisplay {
1131		types = append(types, "display")
1132	} else if isVideo {
1133		types = append(types, "camera")
1134	}
1135
1136	if len(types) == 0 {
1137		// On some Wayland/WebKit paths, getDisplayMedia() can arrive with all
1138		// flags false even though the request is a screen-capture request.
1139		logging.FromContext(ctx).Debug().
1140			Bool("is_audio", isAudio).
1141			Bool("is_video", isVideo).
1142			Bool("is_display", isDisplay).
1143			Msg("user media request had no flags; using display fallback")
1144		return []string{"display"}
1145	}
1146
1147	return types
1148}
1149
1150// allowPermissionRequest calls Allow() on the WebKit permission request.
1151func (wv *WebView) allowPermissionRequest(requestPtr uintptr) {
1152	// Try UserMediaPermissionRequest first
1153	userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1154	if userMediaReq != nil {
1155		userMediaReq.Allow()
1156		wv.logger.Debug().Msg("permission request allowed")
1157		return
1158	}
1159
1160	// Try DeviceInfoPermissionRequest
1161	deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1162	if deviceInfoReq != nil {
1163		deviceInfoReq.Allow()
1164		wv.logger.Debug().Msg("permission request allowed")
1165		return
1166	}
1167
1168	wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot allow")
1169}
1170
1171// denyPermissionRequest calls Deny() on the WebKit permission request.
1172func (wv *WebView) denyPermissionRequest(requestPtr uintptr) {
1173	// Try UserMediaPermissionRequest first
1174	userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1175	if userMediaReq != nil {
1176		userMediaReq.Deny()
1177		wv.logger.Debug().Msg("permission request denied")
1178		return
1179	}
1180
1181	// Try DeviceInfoPermissionRequest
1182	deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1183	if deviceInfoReq != nil {
1184		deviceInfoReq.Deny()
1185		wv.logger.Debug().Msg("permission request denied")
1186		return
1187	}
1188
1189	wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot deny")
1190}
1191
1192// ID returns the unique identifier for this WebView.
1193func (wv *WebView) ID() WebViewID {
1194	return wv.id
1195}
1196
1197// UserContentManager returns the content manager associated with this WebView.
1198func (wv *WebView) UserContentManager() *webkit.UserContentManager {
1199	return wv.ucm
1200}
1201
1202// Widget returns the underlying webkit.WebView for GTK embedding.
1203func (wv *WebView) Widget() *webkit.WebView {
1204	return wv.inner
1205}
1206
1207// IsFullscreen returns true if the WebView is currently in fullscreen mode.
1208func (wv *WebView) IsFullscreen() bool {
1209	return wv.isFullscreen.Load()
1210}
1211
1212// IsPlayingAudio returns true if the WebView is currently playing audio.
1213func (wv *WebView) IsPlayingAudio() bool {
1214	return wv.isPlayingAudio.Load()
1215}
1216
1217// GetFindController returns the WebKit FindController wrapped in the port interface.
1218// The adapter is cached to prevent the Go wrapper from being garbage collected.
1219func (wv *WebView) GetFindController() port.FindController {
1220	if wv == nil || wv.inner == nil {
1221		return nil
1222	}
1223
1224	wv.findControllerOnce.Do(func() {
1225		fc := wv.inner.GetFindController()
1226		if fc != nil {
1227			wv.findController = &findControllerAdapter{fc: fc}
1228		}
1229	})
1230
1231	return wv.findController
1232}
1233
1234// LoadURI loads the given URI.
1235func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
1236	if wv.destroyed.Load() {
1237		return fmt.Errorf("webview %d is destroyed", wv.id)
1238	}
1239	wv.navigationActive.Store(true)
1240	wv.inner.LoadUri(uri)
1241	logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI")
1242	return nil
1243}
1244
1245// LoadHTML loads HTML content with an optional base URI.
1246func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error {
1247	if wv.destroyed.Load() {
1248		return fmt.Errorf("webview %d is destroyed", wv.id)
1249	}
1250	wv.navigationActive.Store(true)
1251	var baseURIPtr *string
1252	if baseURI != "" {
1253		baseURIPtr = &baseURI
1254	}
1255	wv.inner.LoadHtml(content, baseURIPtr)
1256	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("loading HTML content")
1257	return nil
1258}
1259
1260// Reload reloads the current page.
1261func (wv *WebView) Reload(ctx context.Context) error {
1262	if wv.destroyed.Load() {
1263		return fmt.Errorf("webview %d is destroyed", wv.id)
1264	}
1265	wv.inner.Reload()
1266	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview")
1267	return nil
1268}
1269
1270// ReloadBypassCache reloads the current page, bypassing the cache.
1271func (wv *WebView) ReloadBypassCache(ctx context.Context) error {
1272	if wv.destroyed.Load() {
1273		return fmt.Errorf("webview %d is destroyed", wv.id)
1274	}
1275	wv.inner.ReloadBypassCache()
1276	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview bypassing cache")
1277	return nil
1278}
1279
1280// Stop stops the current load operation.
1281func (wv *WebView) Stop(ctx context.Context) error {
1282	if wv.destroyed.Load() {
1283		return fmt.Errorf("webview %d is destroyed", wv.id)
1284	}
1285	wv.inner.StopLoading()
1286	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("stopping webview load")
1287	return nil
1288}
1289
1290// GoBack navigates back in history.
1291// Uses WebKit's native history navigation.
1292func (wv *WebView) GoBack(ctx context.Context) error {
1293	if wv.destroyed.Load() {
1294		return fmt.Errorf("webview %d is destroyed", wv.id)
1295	}
1296
1297	logging.FromContext(ctx).Debug().
1298		Int("webview_id", int(wv.id)).
1299		Bool("can_go_back", wv.inner.CanGoBack()).
1300		Str("current_uri", wv.inner.GetUri()).
1301		Msg("webview go back")
1302
1303	wv.inner.GoBack()
1304
1305	// Note: We don't dispatch popstate manually anymore.
1306	// WebKit's go_back() should handle SPA navigation correctly.
1307	// If issues persist, the problem is likely elsewhere.
1308
1309	return nil
1310}
1311
1312// GoForward navigates forward in history.
1313func (wv *WebView) GoForward(ctx context.Context) error {
1314	if wv.destroyed.Load() {
1315		return fmt.Errorf("webview %d is destroyed", wv.id)
1316	}
1317
1318	logging.FromContext(ctx).Debug().
1319		Int("webview_id", int(wv.id)).
1320		Bool("can_go_forward", wv.inner.CanGoForward()).
1321		Str("current_uri", wv.inner.GetUri()).
1322		Msg("webview go forward")
1323
1324	wv.inner.GoForward()
1325
1326	return nil
1327}
1328
1329// GoBackDirect calls WebKit's go_back directly without any wrappers.
1330// This is intended for use from gesture handlers where preserving user
1331// gesture context is critical for SPA popstate handling.
1332// Matches Epiphany: webkit_web_view_go_back(web_view) directly in gesture callback.
1333func (wv *WebView) GoBackDirect() {
1334	if wv.destroyed.Load() {
1335		return
1336	}
1337	wv.inner.GoBack()
1338}
1339
1340// GoForwardDirect calls WebKit's go_forward directly without any wrappers.
1341// This is intended for use from gesture handlers where preserving user
1342// gesture context is critical for SPA popstate handling.
1343func (wv *WebView) GoForwardDirect() {
1344	if wv.destroyed.Load() {
1345		return
1346	}
1347	wv.inner.GoForward()
1348}
1349
1350// CanGoBack returns true if back navigation is possible.
1351func (wv *WebView) CanGoBack() bool {
1352	wv.mu.RLock()
1353	defer wv.mu.RUnlock()
1354	return wv.canGoBack
1355}
1356
1357// CanGoForward returns true if forward navigation is possible.
1358func (wv *WebView) CanGoForward() bool {
1359	wv.mu.RLock()
1360	defer wv.mu.RUnlock()
1361	return wv.canGoFwd
1362}
1363
1364// URI returns the current URI.
1365func (wv *WebView) URI() string {
1366	wv.mu.RLock()
1367	defer wv.mu.RUnlock()
1368	return wv.uri
1369}
1370
1371// Title returns the current page title.
1372func (wv *WebView) Title() string {
1373	wv.mu.RLock()
1374	defer wv.mu.RUnlock()
1375	return wv.title
1376}
1377
1378// Favicon returns the current page favicon as a GdkTexture.
1379// Returns nil if no favicon is available.
1380func (wv *WebView) Favicon() *gdk.Texture {
1381	if wv.destroyed.Load() {
1382		return nil
1383	}
1384	return wv.inner.GetFavicon()
1385}
1386
1387// IsLoading returns true if a page is currently loading.
1388func (wv *WebView) IsLoading() bool {
1389	wv.mu.RLock()
1390	defer wv.mu.RUnlock()
1391	return wv.isLoading
1392}
1393
1394// EstimatedProgress returns the estimated load progress (0.0 to 1.0).
1395func (wv *WebView) EstimatedProgress() float64 {
1396	wv.mu.RLock()
1397	defer wv.mu.RUnlock()
1398	return wv.progress
1399}
1400
1401// SetZoomLevel sets the zoom level (1.0 = 100%).
1402func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error {
1403	if wv.destroyed.Load() {
1404		return fmt.Errorf("webview %d is destroyed", wv.id)
1405	}
1406	wv.inner.SetZoomLevel(level)
1407	logging.FromContext(ctx).Debug().Float64("factor", level).Int("webview_id", int(wv.id)).Msg("set webview zoom level")
1408	return nil
1409}
1410
1411// GetZoomLevel returns the current zoom level.
1412func (wv *WebView) GetZoomLevel() float64 {
1413	if wv.destroyed.Load() {
1414		return 1.0
1415	}
1416	return wv.inner.GetZoomLevel()
1417}
1418
1419// SetBackgroundColor sets the WebView background color.
1420// This color is shown before content is painted, eliminating white flash.
1421// Values are in range 0.0-1.0 for red, green, blue, alpha.
1422func (wv *WebView) SetBackgroundColor(r, g, b, a float32) {
1423	if wv.destroyed.Load() {
1424		return
1425	}
1426	rgba := &gdk.RGBA{
1427		Red:   r,
1428		Green: g,
1429		Blue:  b,
1430		Alpha: a,
1431	}
1432	wv.inner.SetBackgroundColor(rgba)
1433}
1434
1435// ResetBackgroundToDefault sets WebView background to white (browser default).
1436// Used for external pages to prevent dark background from bleeding through.
1437func (wv *WebView) ResetBackgroundToDefault() {
1438	wv.SetBackgroundColor(1.0, 1.0, 1.0, 1.0)
1439}
1440
1441// Show makes the WebView widget visible.
1442// This should be called after the WebView is ready to be displayed.
1443func (wv *WebView) Show() {
1444	if wv.destroyed.Load() {
1445		return
1446	}
1447	wv.inner.SetVisible(true)
1448}
1449
1450// State returns the current WebView state as a snapshot.
1451func (wv *WebView) State() port.WebViewState {
1452	return port.WebViewState{
1453		URI:       wv.uri,
1454		Title:     wv.title,
1455		IsLoading: wv.isLoading,
1456		Progress:  wv.progress,
1457		CanGoBack: wv.canGoBack,
1458		CanGoFwd:  wv.canGoFwd,
1459		ZoomLevel: wv.GetZoomLevel(),
1460	}
1461}
1462
1463// SetCallbacks registers callback handlers for WebView events.
1464// Pass nil to clear all callbacks.
1465func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1466	if callbacks == nil {
1467		wv.OnLoadChanged = nil
1468		wv.OnTitleChanged = nil
1469		wv.OnURIChanged = nil
1470		wv.OnProgressChanged = nil
1471		wv.OnFaviconChanged = nil
1472		wv.OnClose = nil
1473		wv.OnCreate = nil
1474		wv.OnLinkHover = nil
1475		wv.OnWebProcessTerminated = nil
1476		wv.OnPermissionRequest = nil
1477		return
1478	}
1479
1480	// Map port callbacks to webkit callbacks
1481	if callbacks.OnLoadChanged != nil {
1482		wv.OnLoadChanged = func(e LoadEvent) {
1483			callbacks.OnLoadChanged(port.LoadEvent(e))
1484		}
1485	}
1486	wv.OnTitleChanged = callbacks.OnTitleChanged
1487	wv.OnURIChanged = callbacks.OnURIChanged
1488	wv.OnProgressChanged = callbacks.OnProgressChanged
1489	if callbacks.OnFaviconChanged != nil {
1490		wv.OnFaviconChanged = func(texture *gdk.Texture) {
1491			callbacks.OnFaviconChanged(texture)
1492		}
1493	}
1494	wv.OnClose = callbacks.OnClose
1495	if callbacks.OnCreate != nil {
1496		wv.OnCreate = func(req PopupRequest) *WebView {
1497			portReq := port.PopupRequest{
1498				TargetURI:     req.TargetURI,
1499				FrameName:     req.FrameName,
1500				IsUserGesture: req.IsUserGesture,
1501				ParentViewID:  req.ParentID,
1502			}
1503			result := callbacks.OnCreate(portReq)
1504			if result == nil {
1505				return nil
1506			}
1507			// The callback returns port.WebView, we need to convert back
1508			// This assumes the returned WebView is actually a *webkit.WebView
1509			if wkView, ok := result.(*WebView); ok {
1510				return wkView
1511			}
1512			return nil
1513		}
1514	}
1515	wv.OnLinkHover = callbacks.OnLinkHover
1516	if callbacks.OnWebProcessTerminated != nil {
1517		wv.OnWebProcessTerminated = func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) {
1518			callbacks.OnWebProcessTerminated(mapWebProcessTerminationReason(reason), reasonLabel, uri)
1519		}
1520	} else {
1521		wv.OnWebProcessTerminated = nil
1522	}
1523	wv.OnPermissionRequest = callbacks.OnPermissionRequest
1524}
1525
1526// ShowDevTools opens the WebKit inspector/developer tools.
1527func (wv *WebView) ShowDevTools() error {
1528	if wv.destroyed.Load() {
1529		return fmt.Errorf("webview %d is destroyed", wv.id)
1530	}
1531	inspector := wv.inner.GetInspector()
1532	if inspector == nil {
1533		return fmt.Errorf("failed to get inspector for webview %d", wv.id)
1534	}
1535	inspector.Show()
1536	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("devtools shown")
1537	return nil
1538}
1539
1540// Print opens the print dialog for the current page.
1541func (wv *WebView) Print() error {
1542	if wv.destroyed.Load() {
1543		return fmt.Errorf("webview %d is destroyed", wv.id)
1544	}
1545	printOp := webkit.NewPrintOperation(wv.inner)
1546	if printOp == nil {
1547		return fmt.Errorf("failed to create print operation for webview %d", wv.id)
1548	}
1549	// RunDialog with nil parent window - GTK will use the widget's toplevel
1550	printOp.RunDialog(nil)
1551	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("print dialog opened")
1552	return nil
1553}
1554
1555// IsDestroyed returns true if the WebView has been destroyed.
1556func (wv *WebView) IsDestroyed() bool {
1557	return wv.destroyed.Load()
1558}
1559
1560// IsRelated returns true when this WebView shares process/session with a parent popup.
1561func (wv *WebView) IsRelated() bool {
1562	return wv.isRelated
1563}
1564
1565// HasNavigationActivity reports whether this WebView has been used for content navigation.
1566func (wv *WebView) HasNavigationActivity() bool {
1567	return wv.navigationActive.Load()
1568}
1569
1570// Close triggers the close callback as if window.close() was called.
1571// This is used for programmatic popup closure (e.g., OAuth auto-close).
1572func (wv *WebView) Close() {
1573	if wv.destroyed.Load() {
1574		return
1575	}
1576	if wv.OnClose != nil {
1577		wv.OnClose()
1578	}
1579}
1580
1581// DisconnectSignals disconnects all GLib signal handlers from the WebView.
1582// This must be called before releasing the WebView to the pool or destroying it
1583// to prevent callbacks from firing on freed/pooled WebViews.
1584func (wv *WebView) DisconnectSignals() {
1585	if wv.inner == nil {
1586		return
1587	}
1588
1589	obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
1590	for _, sigID := range wv.signalIDs {
1591		gobject.SignalHandlerDisconnect(obj, uint(sigID))
1592	}
1593	wv.signalIDs = wv.signalIDs[:0] // Clear the slice
1594
1595	if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
1596		bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1597		gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID))
1598		wv.backForwardListSignalID = 0
1599		wv.backForwardList = nil
1600	}
1601
1602	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
1603}
1604
1605// Destroy cleans up the WebView resources and terminates the web process.
1606// This must be called when a WebView is no longer needed to free GPU resources,
1607// VA-API decoder contexts, and DMA-BUF buffers held by the web process.
1608func (wv *WebView) Destroy() {
1609	wv.DestroyWithPolicy("")
1610}
1611
1612// DestroyWithPolicy cleans up the WebView resources with explicit process policy.
1613// Valid policies: auto, always, never.
1614func (wv *WebView) DestroyWithPolicy(policy string) {
1615	if wv.destroyed.Swap(true) {
1616		return // Already destroyed
1617	}
1618	policy = resolveTerminatePolicy(policy)
1619
1620	// 1. Disconnect all signal handlers first to prevent callbacks during cleanup
1621	wv.DisconnectSignals()
1622
1623	// 2. Clear all callbacks to prevent use-after-free
1624	wv.OnLoadChanged = nil
1625	wv.OnTitleChanged = nil
1626	wv.OnURIChanged = nil
1627	wv.OnProgressChanged = nil
1628	wv.OnFaviconChanged = nil
1629	wv.OnClose = nil
1630	wv.OnCreate = nil
1631	wv.OnReadyToShow = nil
1632	wv.OnLinkMiddleClick = nil
1633	wv.OnEnterFullscreen = nil
1634	wv.OnLeaveFullscreen = nil
1635	wv.OnAudioStateChanged = nil
1636	wv.OnLinkHover = nil
1637	wv.OnWebProcessTerminated = nil
1638	wv.OnPermissionRequest = nil
1639
1640	// 3. Clear async callback references
1641	wv.mu.Lock()
1642	wv.asyncCallbacks = nil
1643	wv.mu.Unlock()
1644
1645	// 4. Unparent from GTK hierarchy (must happen before process termination)
1646	// This releases the widget from any parent container.
1647	if wv.inner != nil {
1648		wv.inner.Unparent()
1649	}
1650
1651	// 5. Optionally terminate web process (skip for related popup views sharing parent process).
1652	terminate := shouldTerminateWebProcess(policy, wv.isRelated, wv.navigationActive.Load())
1653	if terminate && wv.inner != nil {
1654		wv.inner.TerminateWebProcess()
1655	}
1656
1657	// 6. Unregister from global registry
1658	globalRegistry.unregister(wv.id)
1659
1660	// 7. Clear internal references to allow GC
1661	wv.inner = nil
1662	wv.ucm = nil
1663	wv.findController = nil
1664
1665	wv.logger.Debug().
1666		Uint64("id", uint64(wv.id)).
1667		Bool("related", wv.isRelated).
1668		Bool("navigation_active", wv.navigationActive.Load()).
1669		Bool("terminated_process", terminate).
1670		Str("terminate_policy", policy).
1671		Msg("webview destroyed")
1672}
1673
1674// ResetForPoolReuse sanitizes callbacks/state so the WebView can be safely reused.
1675func (wv *WebView) ResetForPoolReuse() {
1676	if wv == nil || wv.destroyed.Load() {
1677		return
1678	}
1679
1680	wv.OnLoadChanged = nil
1681	wv.OnTitleChanged = nil
1682	wv.OnURIChanged = nil
1683	wv.OnProgressChanged = nil
1684	wv.OnFaviconChanged = nil
1685	wv.OnClose = nil
1686	wv.OnCreate = nil
1687	wv.OnReadyToShow = nil
1688	wv.OnLinkMiddleClick = nil
1689	wv.OnEnterFullscreen = nil
1690	wv.OnLeaveFullscreen = nil
1691	wv.OnAudioStateChanged = nil
1692	wv.OnLinkHover = nil
1693	wv.OnWebProcessTerminated = nil
1694
1695	wv.mu.Lock()
1696	wv.uri = ""
1697	wv.title = ""
1698	wv.progress = 0
1699	wv.canGoBack = false
1700	wv.canGoFwd = false
1701	wv.isLoading = false
1702	wv.asyncCallbacks = nil
1703	wv.runJSErrorStats = make(map[string]runJSErrorStat)
1704	wv.lastProgressUpdate.Store(0)
1705	wv.mu.Unlock()
1706
1707	wv.isFullscreen.Store(false)
1708	wv.isPlayingAudio.Store(false)
1709	wv.navigationActive.Store(false)
1710
1711	if wv.inner != nil {
1712		wv.inner.StopLoading()
1713		wv.inner.SetVisible(false)
1714	}
1715}
1716
1717func resolveTerminatePolicy(raw string) string {
1718	value := strings.ToLower(strings.TrimSpace(raw))
1719	if value == "" {
1720		value = strings.ToLower(strings.TrimSpace(os.Getenv("DUMBER_WEBVIEW_TERMINATE_POLICY")))
1721	}
1722	switch value {
1723	case terminatePolicyAlways:
1724		return terminatePolicyAlways
1725	case terminatePolicyNever:
1726		return terminatePolicyNever
1727	case terminatePolicyAuto, "":
1728		return terminatePolicyAuto
1729	default:
1730		return terminatePolicyAuto
1731	}
1732}
1733
1734func shouldTerminateWebProcess(policy string, isRelated, navigationActive bool) bool {
1735	if isRelated {
1736		return false
1737	}
1738	switch resolveTerminatePolicy(policy) {
1739	case terminatePolicyNever:
1740		return false
1741	case terminatePolicyAlways:
1742		return true
1743	case terminatePolicyAuto:
1744		return navigationActive
1745	default:
1746		return navigationActive
1747	}
1748}
1749
1750func classifyRunJSEvaluateError(err error) (nonFatal bool, signature string) {
1751	// Defensive guard: callers should only pass non-nil errors, but tolerate nil safely.
1752	if err == nil {
1753		return true, ""
1754	}
1755	msg := strings.ToLower(strings.TrimSpace(err.Error()))
1756	signature = "evaluate_error:" + normalizeRunJSErrorSignature(msg)
1757	switch {
1758	case strings.Contains(msg, "canceled"),
1759		strings.Contains(msg, "cancel"),
1760		strings.Contains(msg, "javascript execution context"),
1761		strings.Contains(msg, "execution context was destroyed"),
1762		strings.Contains(msg, "document unloaded"):
1763		return true, signature
1764	default:
1765		return false, signature
1766	}
1767}
1768
1769func normalizeRunJSErrorSignature(msg string) string {
1770	msg = strings.ToLower(strings.TrimSpace(msg))
1771	if msg == "" {
1772		return "empty"
1773	}
1774	fields := strings.Fields(msg)
1775	if len(fields) == 0 {
1776		return "empty"
1777	}
1778	return strings.Join(fields, " ")
1779}
1780
1781func (wv *WebView) runJSDomain() string {
1782	wv.mu.RLock()
1783	currentURI := strings.TrimSpace(wv.uri)
1784	wv.mu.RUnlock()
1785	if currentURI == "" {
1786		return runJSUnknown
1787	}
1788	parsed, err := url.Parse(currentURI)
1789	if err != nil {
1790		return runJSUnknown
1791	}
1792	host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
1793	if host == "" {
1794		return runJSUnknown
1795	}
1796	return host
1797}
1798
1799func (wv *WebView) shouldLogRunJSError(domain, signature string, nonFatal bool, now time.Time) (bool, uint64) {
1800	if wv == nil {
1801		return true, 1
1802	}
1803	if now.IsZero() {
1804		now = time.Now()
1805	}
1806	if domain == "" {
1807		domain = runJSUnknown
1808	}
1809	if signature == "" {
1810		signature = runJSUnknown
1811	}
1812	key := domain + "|" + signature
1813
1814	wv.mu.Lock()
1815	if wv.runJSErrorStats == nil {
1816		wv.runJSErrorStats = make(map[string]runJSErrorStat)
1817	}
1818	stat := wv.runJSErrorStats[key]
1819	stat.count++
1820	count := stat.count
1821
1822	shouldLog := true
1823	if nonFatal {
1824		shouldLog = count == 1 || count%runJSAggregateLogEvery == 0 || now.Sub(stat.lastLog) >= runJSNonFatalLogInterval
1825	}
1826	if shouldLog {
1827		stat.lastLog = now
1828	}
1829	wv.runJSErrorStats[key] = stat
1830	wv.mu.Unlock()
1831
1832	return shouldLog, count
1833}
1834
1835// RunJavaScript executes script in the specified world (empty for main world).
1836// This is fire-and-forget: it does not block and errors are logged asynchronously.
1837// Safe to call from any context including GTK signal handlers.
1838func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) {
1839	if wv.destroyed.Load() {
1840		return
1841	}
1842	if ctx == nil {
1843		ctx = context.Background()
1844	}
1845
1846	log := logging.FromContext(ctx)
1847
1848	cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
1849		if wv.destroyed.Load() {
1850			return
1851		}
1852		wv.mu.RLock()
1853		inner := wv.inner
1854		wv.mu.RUnlock()
1855		if inner == nil {
1856			return
1857		}
1858
1859		domain := wv.runJSDomain()
1860		if resPtr == 0 {
1861			signature := "nil_async_result"
1862			shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now())
1863			if shouldLog {
1864				log.Debug().
1865					Uint64("webview_id", uint64(wv.id)).
1866					Str("domain", domain).
1867					Str("signature", signature).
1868					Uint64("repeat_count", count).
1869					Msg("RunJavaScript: non-fatal nil async result")
1870			}
1871			return
1872		}
1873
1874		res := &gio.AsyncResultBase{Ptr: resPtr}
1875		value, err := inner.EvaluateJavascriptFinish(res)
1876		if err != nil {
1877			nonFatal, signature := classifyRunJSEvaluateError(err)
1878			shouldLog, count := wv.shouldLogRunJSError(domain, signature, nonFatal, time.Now())
1879			if shouldLog {
1880				ev := log.Warn()
1881				msg := "RunJavaScript: failed"
1882				if nonFatal {
1883					ev = log.Debug()
1884					msg = "RunJavaScript: non-fatal failure"
1885				}
1886				ev.Err(err).
1887					Uint64("webview_id", uint64(wv.id)).
1888					Str("domain", domain).
1889					Str("signature", signature).
1890					Uint64("repeat_count", count).
1891					Msg(msg)
1892			}
1893			return
1894		}
1895
1896		if value != nil {
1897			if jscCtx := value.GetContext(); jscCtx != nil {
1898				if exc := jscCtx.GetException(); exc != nil {
1899					exceptionMessage := strings.TrimSpace(exc.GetMessage())
1900					signature := "js_exception:" + normalizeRunJSErrorSignature(exceptionMessage)
1901					shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now())
1902					if shouldLog {
1903						log.Debug().
1904							Str("exception", exceptionMessage).
1905							Uint64("webview_id", uint64(wv.id)).
1906							Str("domain", domain).
1907							Str("signature", signature).
1908							Uint64("repeat_count", count).
1909							Msg("RunJavaScript: JS exception")
1910					}
1911				}
1912			}
1913		}
1914	})
1915
1916	// prevent callback from being GC'd before it's called
1917	wv.mu.Lock()
1918	wv.asyncCallbacks = append(wv.asyncCallbacks, cb)
1919	wv.mu.Unlock()
1920
1921	// worldName: nil for main world, &worldName for specific world
1922	// sourceUri: nil (not used)
1923	var worldNamePtr *string
1924	if worldName != "" {
1925		worldNamePtr = &worldName
1926	}
1927	wv.mu.RLock()
1928	inner := wv.inner
1929	wv.mu.RUnlock()
1930	if inner == nil || wv.destroyed.Load() {
1931		return
1932	}
1933	inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)
1934}
1935
1936// AttachFrontend injects scripts/styles and wires the message router once per WebView.
1937func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector, router *MessageRouter) error {
1938	if ctx == nil {
1939		ctx = context.Background()
1940	}
1941
1942	log := logging.FromContext(ctx).With().
1943		Str("component", "webview").
1944		Uint64("webview_id", uint64(wv.id)).
1945		Logger()
1946
1947	log.Debug().
1948		Bool("injector_nil", injector == nil).
1949		Bool("router_nil", router == nil).
1950		Msg("AttachFrontend called")
1951
1952	if wv.destroyed.Load() {
1953		log.Debug().Msg("AttachFrontend: webview is destroyed")
1954		return fmt.Errorf("webview %d is destroyed", wv.id)
1955	}
1956	if injector == nil && router == nil {
1957		log.Debug().Msg("AttachFrontend: both injector and router are nil, skipping")
1958		return nil
1959	}
1960
1961	if !wv.frontendAttached.CompareAndSwap(false, true) {
1962		log.Debug().Msg("AttachFrontend: already attached, skipping")
1963		return nil // already attached
1964	}
1965
1966	var attachErr error
1967
1968	defer func() {
1969		if attachErr != nil {
1970			// allow retry on next call
1971			wv.frontendAttached.Store(false)
1972		}
1973	}()
1974
1975	if router != nil {
1976		log.Debug().Msg("AttachFrontend: setting up message router")
1977		if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil {
1978			attachErr = fmt.Errorf("setup message router: %w", err)
1979			log.Warn().Err(err).Msg("failed to attach message router")
1980			return attachErr
1981		}
1982		log.Debug().Msg("AttachFrontend: message router setup complete")
1983	}
1984
1985	if injector != nil {
1986		log.Debug().Msg("AttachFrontend: injecting scripts")
1987		injector.InjectScripts(ctx, wv.ucm, wv.id)
1988	}
1989
1990	log.Debug().Msg("frontend assets attached to webview")
1991	return nil
1992}

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