v0.30.0 eb3068f

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) {
21	log := logging.FromContext(ctx)
22	logging.Trace().Mark("load_committed")
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)
73		return
74	}
75
76	if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
77		c.applyCurrentTheme(ctx, paneID, wv)
78	}
79
80	c.markPendingReveal(paneID)
81	c.revealIfPending(ctx, paneID, 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	logging.Trace().Mark("load_started")
181
182	// Trigger deferred initialization on first load_started.
183	// This ensures non-critical init runs after initial navigation starts.
184	c.loadStartedOnce.Do(func() {
185		if c.onFirstLoadStarted != nil {
186			c.onFirstLoadStarted()
187		}
188	})
189
190	_, wsView := c.getActiveWS()
191	var paneView *component.PaneView
192	if wsView != nil {
193		paneView = wsView.GetPaneView(paneID)
194	}
195
196	if wsView == nil {
197		return
198	}
199	if paneView != nil {
200		paneView.SetLoading(true)
201	}
202}
203
204// onLoadFinished hides the progress bar when page loading completes.
205func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
206	_, wsView := c.getActiveWS()
207	var paneView *component.PaneView
208	if wsView != nil {
209		paneView = wsView.GetPaneView(paneID)
210	}
211
212	if wsView == nil {
213		return
214	}
215	if paneView != nil {
216		paneView.SetLoading(false)
217	}
218
219	c.revealIfPending(ctx, paneID, "", "load-finished")
220	if c.shouldSkipAboutBlankAppearance(paneID, wv) {
221		return
222	}
223	c.applyPendingThemeUpdate(ctx, paneID, wv)
224	c.refreshPendingScripts(ctx, paneID, wv)
225}
226
227// onProgressChanged updates the progress bar with current load progress.
228func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
229	if progress > 0 {
230		c.revealIfPending(context.Background(), paneID, "", "progress")
231	}
232
233	_, wsView := c.getActiveWS()
234	var paneView *component.PaneView
235	if wsView != nil {
236		paneView = wsView.GetPaneView(paneID)
237	}
238
239	if wsView == nil {
240		return
241	}
242	if paneView != nil {
243		paneView.SetLoadProgress(progress)
244	}
245}
246
247func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
248	c.revealMu.Lock()
249	c.pendingReveal[paneID] = true
250	c.revealMu.Unlock()
251}
252
253func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
254	c.revealMu.Lock()
255	delete(c.pendingReveal, paneID)
256	c.revealMu.Unlock()
257}
258
259func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
260	c.revealMu.Lock()
261	pending := c.pendingReveal[paneID]
262	if pending {
263		delete(c.pendingReveal, paneID)
264	}
265	c.revealMu.Unlock()
266
267	if !pending {
268		return
269	}
270
271	wv := c.getWebViewLocked(paneID)
272	if wv == nil || wv.IsDestroyed() {
273		return
274	}
275
276	// Use NativeWidgetProvider to make the widget visible (engine-agnostic)
277	if nwp, ok := wv.(port.NativeWidgetProvider); ok {
278		if ptr := nwp.NativeWidget(); ptr != 0 {
279			gtkWidget := &gtk.Widget{}
280			gtkWidget.Ptr = ptr
281			gtkWidget.SetVisible(true)
282			logging.FromContext(ctx).
283				Debug().
284				Str("pane_id", string(paneID)).
285				Str("uri", uri).
286				Str("reason", reason).
287				Msg("webview revealed")
288		}
289	}
290
291	// Mark first_paint and finish startup trace
292	logging.Trace().Mark("first_paint")
293	logging.Trace().Finish()
294
295	if c.onWebViewShown != nil {
296		c.onWebViewShown(paneID)
297	}
298}
299
300// onLinkHover updates the link status overlay when hovering over links.
301func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
302	_, wsView := c.getActiveWS()
303	if wsView == nil {
304		return
305	}
306
307	paneView := wsView.GetPaneView(paneID)
308	if paneView == nil {
309		return
310	}
311
312	if uri != "" {
313		paneView.ShowLinkStatus(uri)
314	} else {
315		paneView.HideLinkStatus()
316	}
317}
318
319// handleURIChanged handles URI changes from WebKit, including external scheme detection
320// and SPA navigation tracking.
321func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
322	if uri == "" {
323		return
324	}
325
326	log := logging.FromContext(ctx)
327
328	// Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
329	// These are typically triggered by JavaScript redirects (window.location)
330	isExternal := urlutil.IsExternalScheme(uri)
331
332	if isExternal {
333		log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme URI change blocked")
334
335		// External schemes must only be launched from WebKit's navigation-policy path,
336		// where NavigationAction.IsUserGesture is available and verified. URI changes can
337		// be driven by page script or redirects, so this path only cleans up WebKit state.
338		if err := wv.Stop(ctx); err != nil {
339			log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for blocked external URL")
340		}
341
342		// Navigate back to avoid stale URI in omnibox/history.
343		if wv.CanGoBack() {
344			if err := wv.GoBack(ctx); err != nil {
345				log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after blocked external URL")
346			}
347		}
348		return
349	}
350
351	if !wv.IsLoading() {
352		c.onSPANavigation(ctx, paneID, uri)
353	}
354}

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