v0.33.1 9577fee

navigation.go RawHistoryFolder

1package content
2
3import (
4	"context"
5	neturl "net/url"
6	"strings"
7
8	"github.com/bnema/dumber/internal/application/port"
9	"github.com/bnema/dumber/internal/application/usecase"
10	"github.com/bnema/dumber/internal/domain/entity"
11	urlutil "github.com/bnema/dumber/internal/domain/url"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/internal/ui/component"
14	"github.com/bnema/puregotk/v4/gtk"
15)
16
17// onLoadCommitted re-applies zoom when page content starts loading and records history.
18// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
19// History is recorded here because the URI is guaranteed to be correct after commit.
20// Also shows the WebView widget (it's hidden during creation to avoid white flash).
21func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
22	log := logging.FromContext(ctx)
23
24	uri := wv.URI()
25	if uri == "" {
26		return
27	}
28
29	// Set appropriate background color based on page type to prevent dark background bleeding.
30	switch {
31	case strings.HasPrefix(uri, "dumb://"):
32		// Internal pages: apply themed background
33		theme, ok := c.getCurrentTheme()
34		if ok && theme.prefersDark {
35			wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
36		} else {
37			wv.ResetBackgroundToDefault()
38		}
39	case strings.HasPrefix(uri, "about:"):
40		// Keep pool background (no action)
41	default:
42		// External pages: white background
43		wv.ResetBackgroundToDefault()
44	}
45
46	// Show the WebView now that content is being painted
47	// (WebViews are hidden on creation to avoid white flash)
48	// Skip showing if this is about:blank but the pane is loading a different URL
49	// This prevents the brief flash of about:blank during initial navigation
50	shouldShow := true
51	if uri == aboutBlankURI {
52		// Get the pane's intended URI from the workspace
53		ws, _ := c.getActiveWS()
54		if ws != nil {
55			if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
56				// Don't show about:blank if the pane is supposed to load a different URL
57				if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
58					shouldShow = false
59					log.Debug().
60						Str("pane_id", string(paneID)).
61						Str("pane_uri", paneNode.Pane.URI).
62						Msg("skipping webview show for about:blank (pane loading different URL)")
63				}
64			}
65		}
66	}
67
68	if !shouldShow {
69		// Avoid updating UI/domain state to about:blank when we know the pane is
70		// navigating to a different URL. This prevents the omnibox/window title from
71		// briefly showing about:blank on cold start.
72		c.clearPendingReveal(paneID, identity)
73		return
74	}
75
76	if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
77		c.applyCurrentTheme(ctx, paneID, wv)
78	}
79
80	c.markPendingReveal(paneID, identity)
81	c.revealIfPending(ctx, paneID, wv, identity, uri, "progress-after-commit")
82
83	// Update domain model with current URI for session snapshots
84	c.updatePaneURI(paneID, uri)
85
86	// Sync StackedView title bar with the WebView's current title.
87	// This keeps the stacked title bar up-to-date immediately on navigation,
88	// before the asynchronous notify::title signal fires.
89	if title := wv.Title(); title != "" {
90		c.syncStackedTitle(ctx, paneID, title)
91	}
92
93	// Record history - URI is guaranteed to be correct at LoadCommitted
94	if c.onHistoryRecord != nil {
95		c.onHistoryRecord(ctx, paneID, uri)
96	}
97
98	// Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
99	c.resolveCommittedFavicon(ctx, paneID, wv)
100
101	// Notify active pane navigation for permission indicator reset.
102	c.notifyActiveNavigation(paneID, uri)
103
104	// Apply zoom
105	if c.zoomUC == nil {
106		return
107	}
108
109	zoomKey, err := usecase.ExtractZoomKey(uri)
110	if err != nil {
111		log.Debug().Err(err).Str("uri", uri).Msg("skipping zoom application: cannot extract zoom key")
112		return
113	}
114
115	_ = c.zoomUC.ApplyToWebView(ctx, wv, zoomKey)
116}
117
118func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
119	if c.onActiveNavigationCommitted == nil {
120		return
121	}
122	c.activePaneOverrideMu.RLock()
123	override := c.activePaneOverride
124	c.activePaneOverrideMu.RUnlock()
125	if override != "" {
126		if override == paneID {
127			c.onActiveNavigationCommitted(paneID, uri)
128		}
129		return
130	}
131	ws, _ := c.getActiveWS()
132	if ws != nil && ws.ActivePaneID == paneID {
133		c.onActiveNavigationCommitted(paneID, uri)
134	}
135}
136
137func (c *Coordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv port.WebView) bool {
138	if wv == nil || wv.IsDestroyed() {
139		return false
140	}
141	if wv.URI() != aboutBlankURI {
142		return false
143	}
144	ws, _ := c.getActiveWS()
145	if ws == nil {
146		return false
147	}
148	paneNode := ws.FindPane(paneID)
149	if paneNode == nil || paneNode.Pane == nil {
150		return false
151	}
152	if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
153		return true
154	}
155	return false
156}
157
158// onSPANavigation records history when URL changes via JavaScript (History API).
159// This handles SPA navigation like YouTube search, where the URL changes without a page load.
160func (c *Coordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
161	// Update domain model with current URI for session snapshots
162	c.updatePaneURI(paneID, uri)
163
164	// Record history for SPA navigation
165	if c.onHistoryRecord != nil {
166		c.onHistoryRecord(ctx, paneID, uri)
167	}
168}
169
170// updatePaneURI updates the pane's URI in the domain model.
171// This is called on navigation so that session snapshots capture the current URL.
172func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
173	if c.onPaneURIUpdated != nil {
174		c.onPaneURIUpdated(paneID, uri)
175	}
176}
177
178// onLoadStarted shows the progress bar when page loading begins.
179func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
180	// Trigger deferred initialization on first load_started.
181	// This ensures non-critical init runs after initial navigation starts.
182	c.loadStartedOnce.Do(func() {
183		if c.onFirstLoadStarted != nil {
184			c.onFirstLoadStarted()
185		}
186	})
187
188	_, wsView := c.getActiveWS()
189	var paneView *component.PaneView
190	if wsView != nil {
191		paneView = wsView.GetPaneView(paneID)
192	}
193
194	if wsView == nil {
195		return
196	}
197	if paneView != nil {
198		paneView.SetLoading(true)
199	}
200}
201
202// onLoadFinished hides the progress bar when page loading completes.
203func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
204	_, wsView := c.getActiveWS()
205	var paneView *component.PaneView
206	if wsView != nil {
207		paneView = wsView.GetPaneView(paneID)
208	}
209
210	if wsView == nil {
211		return
212	}
213	if paneView != nil {
214		paneView.SetLoading(false)
215	}
216
217	c.revealIfPending(ctx, paneID, wv, identity, "", "load-finished")
218	if c.shouldSkipAboutBlankAppearance(paneID, wv) {
219		return
220	}
221	c.applyPendingThemeUpdate(ctx, paneID, wv)
222	c.refreshPendingScripts(ctx, paneID, wv)
223}
224
225// onProgressChanged updates the progress bar with current load progress.
226func (c *Coordinator) onProgressChanged(paneID entity.PaneID, wv port.WebView, identity webViewIdentity, progress float64) {
227	if progress > 0 {
228		c.revealIfPending(context.Background(), paneID, wv, identity, "", "progress")
229	}
230
231	_, wsView := c.getActiveWS()
232	var paneView *component.PaneView
233	if wsView != nil {
234		paneView = wsView.GetPaneView(paneID)
235	}
236
237	if wsView == nil {
238		return
239	}
240	if paneView != nil {
241		paneView.SetLoadProgress(progress)
242	}
243}
244
245func (c *Coordinator) markPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
246	c.webViewsMu.RLock()
247	c.revealMu.Lock()
248	defer c.revealMu.Unlock()
249	defer c.webViewsMu.RUnlock()
250	currentWebView := c.webViews[paneID]
251	if currentWebView == nil {
252		return
253	}
254	currentIdentity, ok := identityForWebView(currentWebView)
255	if !ok || currentIdentity != identity {
256		return
257	}
258	c.ensureRevealMapsLocked()
259	c.pendingReveal[paneID] = identity
260}
261
262func (c *Coordinator) clearPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
263	c.webViewsMu.RLock()
264	c.revealMu.Lock()
265	defer c.revealMu.Unlock()
266	defer c.webViewsMu.RUnlock()
267	pendingIdentity, pending := c.pendingReveal[paneID]
268	if !pending || pendingIdentity != identity {
269		return
270	}
271	delete(c.pendingReveal, paneID)
272}
273
274// WebViewRevealed reports whether this pane's current WebView has completed a
275// reveal. State is identity-bound so a replacement cannot inherit it.
276func (c *Coordinator) WebViewRevealed(paneID entity.PaneID) bool {
277	return c.webViewRevealed(paneID, nil)
278}
279
280// webViewRevealed verifies the identity being attached when one is supplied.
281// This prevents a rebuild from applying a replacement's reveal state to a
282// previously read pooled WebView.
283func (c *Coordinator) webViewRevealed(paneID entity.PaneID, expected port.WebView) bool {
284	c.webViewsMu.RLock()
285	c.revealMu.Lock()
286	defer c.revealMu.Unlock()
287	defer c.webViewsMu.RUnlock()
288	current := c.webViews[paneID]
289	identity, ok := identityForWebView(current)
290	if !ok || expected != nil {
291		expectedIdentity, expectedOK := identityForWebView(expected)
292		if !expectedOK || expectedIdentity != identity {
293			return false
294		}
295	}
296	revealedIdentity, revealed := c.revealedWebViews[paneID]
297	return revealed && revealedIdentity == identity
298}
299
300func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID, identity webViewIdentity) bool {
301	c.webViewsMu.RLock()
302	c.revealMu.Lock()
303	defer c.revealMu.Unlock()
304	defer c.webViewsMu.RUnlock()
305	currentIdentity, current := identityForWebView(c.webViews[paneID])
306	if !current || currentIdentity != identity {
307		return false
308	}
309	c.ensureRevealMapsLocked()
310	c.revealedWebViews[paneID] = identity
311	return true
312}
313
314func (c *Coordinator) revealIfPending(
315	ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity, uri, reason string,
316) {
317	// Mapping replacement and release acquire this gate too. No ordinary map
318	// lock is held across the native call, but the gate keeps the validated
319	// identity current until its widget visibility has been changed.
320	c.revealMutationMu.Lock()
321
322	c.webViewsMu.RLock()
323	c.revealMu.Lock()
324	pendingIdentity, pending := c.pendingReveal[paneID]
325	if !pending || pendingIdentity != identity {
326		c.revealMu.Unlock()
327		c.webViewsMu.RUnlock()
328		c.revealMutationMu.Unlock()
329		return
330	}
331	currentIdentity, current := identityForWebView(c.webViews[paneID])
332	if !current || currentIdentity != identity {
333		c.revealMu.Unlock()
334		c.webViewsMu.RUnlock()
335		c.revealMutationMu.Unlock()
336		return
337	}
338	delete(c.pendingReveal, paneID)
339	c.revealMu.Unlock()
340	c.webViewsMu.RUnlock()
341
342	if wv.IsDestroyed() {
343		c.revealMutationMu.Unlock()
344		return
345	}
346
347	if c.setWebViewVisible != nil {
348		c.setWebViewVisible(wv)
349	} else if nwp, ok := wv.(port.NativeWidgetProvider); ok {
350		if ptr := nwp.NativeWidget(); ptr != 0 {
351			gtkWidget := &gtk.Widget{}
352			gtkWidget.Ptr = ptr
353			gtkWidget.SetVisible(true)
354			logging.FromContext(ctx).
355				Debug().
356				Str("pane_id", string(paneID)).
357				Str("uri", uri).
358				Str("reason", reason).
359				Msg("webview revealed")
360		}
361	}
362
363	// Keep the presentation record in the same serialized operation as the
364	// native mutation. User callbacks run only after the gate is released.
365	revealed := c.markWebViewRevealed(paneID, identity)
366	c.revealMutationMu.Unlock()
367	if revealed && c.onWebViewShown != nil {
368		c.onWebViewShown(paneID)
369	}
370}
371
372// onLinkHover updates the link status overlay when hovering over links.
373func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
374	_, wsView := c.getActiveWS()
375	if wsView == nil {
376		return
377	}
378
379	paneView := wsView.GetPaneView(paneID)
380	if paneView == nil {
381		return
382	}
383
384	if uri != "" {
385		paneView.ShowLinkStatus(uri)
386	} else {
387		paneView.HideLinkStatus()
388	}
389}
390
391// externalPrompt is a pane's outstanding external-launch confirmation.
392type externalPrompt struct {
393	token    uint64
394	identity webViewIdentity
395}
396
397// promptExternalLaunch asks the user whether the external URI may be handed to
398// the system handler. A pane has at most one outstanding prompt: further
399// external URIs from that pane are dropped until it is answered, so a page
400// cannot flood the window's prompt queue.
401func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, identity webViewIdentity, uri string) {
402	log := logging.FromContext(ctx)
403	if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
404		log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")
405		return
406	}
407
408	c.externalPromptMu.Lock()
409	if _, pending := c.externalPrompts[paneID]; pending {
410		c.externalPromptMu.Unlock()
411		return
412	}
413	if c.externalPrompts == nil {
414		c.externalPrompts = make(map[entity.PaneID]externalPrompt)
415	}
416	c.externalPromptSeq++
417	token := c.externalPromptSeq
418	c.externalPrompts[paneID] = externalPrompt{token: token, identity: identity}
419	origin := c.externalPromptOrigins[paneID]
420	c.externalPromptMu.Unlock()
421
422	c.onConfirmExternalLaunch(paneID, origin, uri, func(allowed bool) {
423		c.externalPromptMu.Lock()
424		prompt, pending := c.externalPrompts[paneID]
425		// The prompt is stale when its pane was released, rebound, or
426		// re-prompted while it was queued: never launch for it.
427		current := pending && prompt.token == token
428		if current {
429			delete(c.externalPrompts, paneID)
430		}
431		c.externalPromptMu.Unlock()
432		current = current && c.isBoundWebView(paneID, identity)
433
434		log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).Bool("stale", !current).
435			Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
436			Msg("external scheme launch decision")
437		if allowed && current {
438			c.onLaunchExternalURL(uri)
439		}
440	})
441}
442
443// rememberExternalPromptOrigin records the host of the http(s) document shown
444// in the pane, so a later external-launch prompt can name the requesting site.
445// Any other document clears it: the prompt then says "A page" rather than
446// attributing the request to a previous site.
447func (c *Coordinator) rememberExternalPromptOrigin(paneID entity.PaneID, uri string) {
448	host := ""
449	if parsed, err := neturl.Parse(uri); err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") {
450		host = parsed.Hostname()
451	}
452	c.externalPromptMu.Lock()
453	defer c.externalPromptMu.Unlock()
454	if host == "" {
455		delete(c.externalPromptOrigins, paneID)
456		return
457	}
458	if c.externalPromptOrigins == nil {
459		c.externalPromptOrigins = make(map[entity.PaneID]string)
460	}
461	c.externalPromptOrigins[paneID] = host
462}
463
464// isBoundWebView reports whether identity is still the WebView bound to the
465// pane. Engine callbacks are posted asynchronously and can arrive after the
466// pane was released or rebound.
467func (c *Coordinator) isBoundWebView(paneID entity.PaneID, identity webViewIdentity) bool {
468	bound, ok := identityForWebView(c.GetWebView(paneID))
469	return ok && bound == identity
470}
471
472// clearExternalPrompt invalidates the pane's outstanding external-launch
473// prompt so a late answer cannot launch anything, and forgets its origin.
474func (c *Coordinator) clearExternalPrompt(paneID entity.PaneID) {
475	c.externalPromptMu.Lock()
476	delete(c.externalPrompts, paneID)
477	delete(c.externalPromptOrigins, paneID)
478	c.externalPromptMu.Unlock()
479}
480
481// handleURIChanged handles URI changes from WebKit, including external scheme detection
482// and SPA navigation tracking.
483func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity, uri string) {
484	if uri == "" || !c.isBoundWebView(paneID, identity) {
485		return
486	}
487
488	log := logging.FromContext(ctx)
489
490	// Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
491	// These are typically triggered by JavaScript redirects (window.location)
492	isExternal := urlutil.IsExternalScheme(uri)
493
494	if isExternal {
495		log.Info().Str("pane_id", string(paneID)).Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
496			Msg("external scheme URI change intercepted")
497
498		if err := wv.Stop(ctx); err != nil {
499			log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("stop webview for external URL")
500		}
501
502		// URI changes can be driven by page script or redirects (OAuth flows end
503		// with a redirect to claude://, vscode://, ...), so the launch is never
504		// automatic: the user must confirm it. Do not navigate back: going back
505		// to an OAuth endpoint re-issues the redirect and loops.
506		c.promptExternalLaunch(ctx, paneID, identity, uri)
507		return
508	}
509
510	c.rememberExternalPromptOrigin(paneID, uri)
511	if !wv.IsLoading() {
512		c.onSPANavigation(ctx, paneID, uri)
513	}
514}

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