v0.33.0 62a09f5

navigation.go RawHistoryFolder

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

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