v0.28.0 3cb3770

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/puregotk/v4/gtk"
13)
14
15// onLoadCommitted re-applies zoom when page content starts loading and records history.
16// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
17// History is recorded here because the URI is guaranteed to be correct after commit.
18// Also shows the WebView widget (it's hidden during creation to avoid white flash).
19func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
20	log := logging.FromContext(ctx)
21	logging.Trace().Mark("load_committed")
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)
72		return
73	}
74
75	if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
76		c.applyCurrentTheme(ctx, paneID, wv)
77	}
78
79	c.markPendingReveal(paneID)
80	if wv.EstimatedProgress() > 0 {
81		c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
82	}
83
84	// Update domain model with current URI for session snapshots
85	c.updatePaneURI(paneID, uri)
86
87	// Sync StackedView title bar with the WebView's current title.
88	// This keeps the stacked title bar up-to-date immediately on navigation,
89	// before the asynchronous notify::title signal fires.
90	if title := wv.Title(); title != "" {
91		c.syncStackedTitle(ctx, paneID, title)
92	}
93
94	// Record history - URI is guaranteed to be correct at LoadCommitted
95	if c.onHistoryRecord != nil {
96		c.onHistoryRecord(ctx, paneID, uri)
97	}
98
99	// Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
100	c.resolveCommittedFavicon(ctx, paneID, wv)
101
102	// Notify active pane navigation for permission indicator reset.
103	c.notifyActiveNavigation(paneID, uri)
104
105	// Apply zoom
106	if c.zoomUC == nil {
107		return
108	}
109
110	domain, err := usecase.ExtractDomain(uri)
111	if err != nil {
112		return
113	}
114
115	_ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
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(uri)
128		}
129		return
130	}
131	ws, _ := c.getActiveWS()
132	if ws != nil && ws.ActivePaneID == paneID {
133		c.onActiveNavigationCommitted(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	if wsView == nil {
192		return
193	}
194
195	paneView := wsView.GetPaneView(paneID)
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) {
203	_, wsView := c.getActiveWS()
204	if wsView == nil {
205		return
206	}
207
208	paneView := wsView.GetPaneView(paneID)
209	if paneView != nil {
210		paneView.SetLoading(false)
211	}
212
213	c.revealIfPending(ctx, paneID, "", "load-finished")
214	if c.shouldSkipAboutBlankAppearance(paneID, wv) {
215		return
216	}
217	c.applyPendingThemeUpdate(ctx, paneID, wv)
218	c.refreshPendingScripts(ctx, paneID, wv)
219}
220
221// onProgressChanged updates the progress bar with current load progress.
222func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
223	if progress > 0 {
224		c.revealIfPending(context.Background(), paneID, "", "progress")
225	}
226
227	_, wsView := c.getActiveWS()
228	if wsView == nil {
229		return
230	}
231
232	paneView := wsView.GetPaneView(paneID)
233	if paneView != nil {
234		paneView.SetLoadProgress(progress)
235	}
236}
237
238func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
239	c.revealMu.Lock()
240	c.pendingReveal[paneID] = true
241	c.revealMu.Unlock()
242}
243
244func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
245	c.revealMu.Lock()
246	delete(c.pendingReveal, paneID)
247	c.revealMu.Unlock()
248}
249
250func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
251	c.revealMu.Lock()
252	pending := c.pendingReveal[paneID]
253	if pending {
254		delete(c.pendingReveal, paneID)
255	}
256	c.revealMu.Unlock()
257
258	if !pending {
259		return
260	}
261
262	wv := c.getWebViewLocked(paneID)
263	if wv == nil || wv.IsDestroyed() {
264		return
265	}
266
267	// Use NativeWidgetProvider to make the widget visible (engine-agnostic)
268	if nwp, ok := wv.(port.NativeWidgetProvider); ok {
269		if ptr := nwp.NativeWidget(); ptr != 0 {
270			gtkWidget := &gtk.Widget{}
271			gtkWidget.Ptr = ptr
272			gtkWidget.SetVisible(true)
273			logging.FromContext(ctx).
274				Debug().
275				Str("pane_id", string(paneID)).
276				Str("uri", uri).
277				Str("reason", reason).
278				Msg("webview revealed")
279		}
280	}
281
282	// Mark first_paint and finish startup trace
283	logging.Trace().Mark("first_paint")
284	logging.Trace().Finish()
285
286	if c.onWebViewShown != nil {
287		c.onWebViewShown(paneID)
288	}
289}
290
291// onLinkHover updates the link status overlay when hovering over links.
292func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
293	_, wsView := c.getActiveWS()
294	if wsView == nil {
295		return
296	}
297
298	paneView := wsView.GetPaneView(paneID)
299	if paneView == nil {
300		return
301	}
302
303	if uri != "" {
304		paneView.ShowLinkStatus(uri)
305	} else {
306		paneView.HideLinkStatus()
307	}
308}
309
310// handleURIChanged handles URI changes from WebKit, including external scheme detection
311// and SPA navigation tracking.
312func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
313	if uri == "" {
314		return
315	}
316
317	log := logging.FromContext(ctx)
318
319	// Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
320	// These are typically triggered by JavaScript redirects (window.location)
321	isExternal := urlutil.IsExternalScheme(uri)
322
323	if isExternal {
324		log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
325
326		// Launch externally via injected callback
327		if c.onLaunchExternalURL != nil {
328			c.onLaunchExternalURL(uri)
329		} else {
330			log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external URL not launched: no handler registered")
331		}
332
333		// Stop loading to prevent WebKit from showing an error page
334		// The page stays on the previous URL before the JS redirect
335		if err := wv.Stop(ctx); err != nil {
336			log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for external URL")
337		}
338
339		// Navigate back to avoid stale URI in omnibox/history
340		if wv.CanGoBack() {
341			if err := wv.GoBack(ctx); err != nil {
342				log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after external URL")
343			}
344		}
345		return
346	}
347
348	if !wv.IsLoading() {
349		c.onSPANavigation(ctx, paneID, uri)
350	}
351}

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