v0.15.0 9006957

webview.go RawHistoryFolder

1package browser
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"path/filepath"
9	"time"
10
11	"github.com/bnema/dumber/internal/app/constants"
12	"github.com/bnema/dumber/internal/app/control"
13	"github.com/bnema/dumber/internal/app/messaging"
14	"github.com/bnema/dumber/internal/config"
15	"github.com/bnema/dumber/internal/filtering"
16	"github.com/bnema/dumber/internal/logging"
17	"github.com/bnema/dumber/internal/services"
18	"github.com/bnema/dumber/pkg/webkit"
19)
20
21// Note: Memory management and color palettes are now handled at the application level
22// The gotk4 webkit.Config only contains basic WebKit settings
23
24func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
25	// Ensure data directories exist for WebKit
26	dataDir, err := config.GetDataDir()
27	if err != nil {
28		return nil, err
29	}
30	stateDir, err := config.GetStateDir()
31	if err != nil {
32		return nil, err
33	}
34
35	webkitData := filepath.Join(dataDir, "webkit")
36	webkitCache := filepath.Join(stateDir, "webkit-cache")
37	if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
38		return nil, err
39	}
40	if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
41		return nil, err
42	}
43
44	// Build WebKit configuration with data directories for persistence
45	cfg := &webkit.Config{
46		UserAgent:             buildUserAgent(app.config),
47		EnableJavaScript:      true,
48		EnableWebGL:           true,
49		EnableMediaStream:     true,
50		HardwareAcceleration:  true,
51		DefaultFontSize:       app.config.Appearance.DefaultFontSize,
52		MinimumFontSize:       8,
53		EnablePageCache:       true, // Instant back/forward navigation (bfcache)
54		EnableSmoothScrolling: true, // Smooth scrolling animations
55		DataDir:               webkitData,
56		CacheDir:              webkitCache,
57		AppearanceConfigJSON:  app.buildAppearanceConfigJSON(),
58		CreateWindow:          true, // Default to creating a window for standalone WebViews
59	}
60
61	return cfg, nil
62}
63
64// buildUserAgent constructs the user agent string from config
65func buildUserAgent(cfg *config.Config) string {
66	if cfg.CodecPreferences.CustomUserAgent != "" {
67		return cfg.CodecPreferences.CustomUserAgent
68	}
69	// Use default WebKit user agent
70	return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
71}
72
73// buildAppearanceConfigJSON builds the palette configuration as JSON
74// The GUI expects window.__dumber_palette with just the palette data
75func (app *BrowserApp) buildAppearanceConfigJSON() string {
76	if app.config == nil {
77		return ""
78	}
79
80	// Build palette config in the format main-world.ts expects
81	// It looks for window.__dumber_palette = { "light": {...}, "dark": {...} }
82	paletteConfig := map[string]interface{}{
83		"light": map[string]string{
84			"background":      app.config.Appearance.LightPalette.Background,
85			"surface":         app.config.Appearance.LightPalette.Surface,
86			"surface_variant": app.config.Appearance.LightPalette.SurfaceVariant,
87			"text":            app.config.Appearance.LightPalette.Text,
88			"muted":           app.config.Appearance.LightPalette.Muted,
89			"accent":          app.config.Appearance.LightPalette.Accent,
90			"border":          app.config.Appearance.LightPalette.Border,
91		},
92		"dark": map[string]string{
93			"background":      app.config.Appearance.DarkPalette.Background,
94			"surface":         app.config.Appearance.DarkPalette.Surface,
95			"surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
96			"text":            app.config.Appearance.DarkPalette.Text,
97			"muted":           app.config.Appearance.DarkPalette.Muted,
98			"accent":          app.config.Appearance.DarkPalette.Accent,
99			"border":          app.config.Appearance.DarkPalette.Border,
100		},
101	}
102
103	payload, err := json.Marshal(paletteConfig)
104	if err != nil {
105		logging.Error(fmt.Sprintf("[webview] Failed to marshal palette config: %v", err))
106		return ""
107	}
108
109	return string(payload)
110}
111
112func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
113	pane := &BrowserPane{
114		webView: view,
115	}
116
117	pane.clipboardController = control.NewClipboardController(view)
118	pane.zoomController = control.NewZoomController(app.browserService, view)
119	pane.navigationController = control.NewNavigationController(
120		app.parserService,
121		app.browserService,
122		view,
123		pane.zoomController,
124	)
125	pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
126	pane.messageHandler.SetWebView(view)
127	pane.messageHandler.SetNavigationController(pane.navigationController)
128	pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
129
130	pane.zoomController.RegisterHandlers()
131	pane.shortcutHandler.RegisterShortcuts()
132
133	// Register navigation handler for workspace focus events if workspace exists
134	if app.workspace != nil {
135		app.workspace.RegisterNavigationHandler(view)
136	}
137
138	return pane, nil
139}
140
141func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
142	pane, err := app.buildPane(view)
143	if err != nil {
144		return nil, err
145	}
146
147	// Generate unique pane ID
148	pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
149	pane.initializeGUITracking()
150
151	// GUI manager will be loaded on-demand when needed
152	logging.Debug(fmt.Sprintf("[webview] Created pane for webview: %d", view.ID()))
153
154	app.attachPaneHandlers(pane)
155	return pane, nil
156}
157
158func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
159	if pane == nil || pane.webView == nil {
160		return
161	}
162
163	pane.webView.RegisterTitleChangedHandler(func(title string) {
164		if title == "" {
165			return
166		}
167		url := pane.webView.GetCurrentURL()
168		if url == "" {
169			return
170		}
171
172		// Update database with new title
173		go func(url, title string) {
174			ctx := context.Background()
175			if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
176				logging.Warn(fmt.Sprintf("Warning: failed to update page title: %v", err))
177			}
178		}(url, title)
179
180		// Update stacked pane title bar if this pane is in a stack
181		if app.workspace != nil {
182			app.workspace.UpdateTitleBar(pane.webView, title)
183		}
184	})
185
186	pane.webView.RegisterScriptMessageHandler(func(payload string) {
187		if app.workspace != nil && shouldFocusForScriptMessage(payload) {
188			app.workspace.focusByView(pane.webView)
189		}
190		pane.messageHandler.Handle(payload)
191	})
192
193	// Page load progress - drive tab-level progress bar
194	pane.webView.RegisterLoadStartedHandler(func() {
195		app.handleLoadProgress(pane.webView, 0.0, true)
196	})
197	pane.webView.RegisterLoadFinishedHandler(func() {
198		app.handleLoadProgress(pane.webView, 1.0, false)
199	})
200	pane.webView.RegisterLoadProgressHandler(func(progress float64) {
201		app.handleLoadProgress(pane.webView, progress, true)
202	})
203
204	if pane.messageHandler != nil && pane.navigationController != nil {
205		pane.messageHandler.SetNavigationController(pane.navigationController)
206	}
207
208	// Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
209	if app.workspace != nil {
210		node := app.workspace.GetNodeForWebView(pane.webView)
211		if node != nil {
212			app.workspace.setupPopupHandling(pane.webView, node)
213			logging.Debug(fmt.Sprintf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID()))
214		}
215	}
216}
217
218// handleLoadProgress routes WebView load progress events to the tab manager for UI display.
219func (app *BrowserApp) handleLoadProgress(view *webkit.WebView, progress float64, loading bool) {
220	if app == nil || view == nil || app.tabManager == nil {
221		return
222	}
223
224	// Run asynchronously to avoid blocking the GTK/main thread during load-changed signals.
225	go app.tabManager.updateProgressForWebView(view, progress, loading)
226}
227
228// shouldFocusForScriptMessage filters script messages and only allows focus handoff
229// for explicit user-driven actions (navigation, history commands, popup lifecycle).
230// Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
231// ignored so they don't steal focus from the user's active pane.
232func shouldFocusForScriptMessage(payload string) bool {
233	var msg messaging.Message
234	if err := json.Unmarshal([]byte(payload), &msg); err != nil {
235		return true // keep legacy behaviour if parsing fails
236	}
237
238	switch msg.Type {
239	case "navigate", "query", "wails", "history_recent", "history_stats", "history_search", "history_delete", "close-popup":
240		return true
241	default:
242		return false
243	}
244}
245
246// createWebView creates and configures the WebView
247func (app *BrowserApp) createWebView() error {
248	logging.Info(fmt.Sprintf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()))
249
250	cfg, err := app.buildWebkitConfig()
251	if err != nil {
252		return err
253	}
254
255	// Note: Window creation is now handled automatically by webkit.NewWebView
256	view, err := webkit.NewWebView(cfg)
257
258	if err != nil {
259		return err
260	}
261
262	pane, err := app.createPaneForView(view)
263	if err != nil {
264		return err
265	}
266
267	app.webView = view
268	app.zoomController = pane.zoomController
269	app.navigationController = pane.navigationController
270	app.clipboardController = pane.clipboardController
271	app.messageHandler = pane.messageHandler
272	app.shortcutHandler = pane.shortcutHandler
273
274	// Initialize tab manager (which manages workspaces)
275	window := view.Window()
276	if window != nil && app.tabManager == nil {
277		app.tabManager = NewTabManager(app, window)
278
279		// Determine initial URL
280		initialURL := "about:blank"
281
282		// Initialize tab system (creates root container with tab bar and first tab)
283		if err := app.tabManager.Initialize(initialURL); err != nil {
284			logging.Error(fmt.Sprintf("ERROR: failed to initialize tab manager: %v", err))
285			return err
286		}
287
288		// Set tab manager's root container as window content
289		rootContainer := app.tabManager.GetRootContainer()
290		if rootContainer != nil {
291			window.SetChild(rootContainer)
292			logging.Info(fmt.Sprintf("[tabs] Tab system initialized and set as window content"))
293		} else {
294			logging.Error(fmt.Sprintf("ERROR: tab manager root container is nil"))
295			return fmt.Errorf("failed to get tab manager root container")
296		}
297	}
298
299	// Initialize window-level global shortcuts AFTER tab manager is set up
300	if window != nil {
301		app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
302		if app.windowShortcutHandler != nil {
303			logging.Info(fmt.Sprintf("Window-level global shortcuts initialized"))
304		} else {
305			logging.Warn(fmt.Sprintf("Warning: failed to initialize window-level shortcuts"))
306		}
307	}
308
309	app.setupWebViewIntegration()
310
311	// Initialize FaviconService after WebView is created
312	if err := app.initializeFaviconService(); err != nil {
313		logging.Warn(fmt.Sprintf("Warning: failed to initialize favicon service: %v", err))
314		// Continue without favicon service - not critical
315	}
316
317	// Apply initial zoom and show window
318	app.zoomController.ApplyInitialZoom()
319
320	logging.Info(fmt.Sprintf("Showing WebView window…"))
321	if err := view.Show(); err != nil {
322		logging.Warn(fmt.Sprintf("Warning: failed to show WebView: %v", err))
323	} else if !webkit.IsNativeAvailable() {
324		logging.Info(fmt.Sprintf("Notice: running without webkit_cgo tag — no native window will be displayed."))
325	}
326
327	return nil
328}
329
330// setupWebViewIntegration connects the WebView to browser services
331func (app *BrowserApp) setupWebViewIntegration() {
332	app.browserService.AttachWebView(app.webView)
333
334	// GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
335	// Appearance config is also injected at document-start via UserContentManager
336
337	// Use native window as title updater
338	if win := app.webView.Window(); win != nil {
339		app.browserService.SetWindowTitleUpdater(win)
340	}
341}
342
343// initializeFaviconService creates and initializes the FaviconService
344func (app *BrowserApp) initializeFaviconService() error {
345	// Get the FaviconDatabase from the WebView
346	faviconDB := app.webView.GetFaviconDatabase()
347	if faviconDB == nil {
348		return fmt.Errorf("favicon database not available")
349	}
350
351	// Get the WebKit data directory
352	dataDir, err := config.GetDataDir()
353	if err != nil {
354		return fmt.Errorf("failed to get data directory: %w", err)
355	}
356	webkitData := filepath.Join(dataDir, "webkit")
357
358	// Create FaviconService
359	faviconService, err := services.NewFaviconService(faviconDB, app.queries, webkitData)
360	if err != nil {
361		return fmt.Errorf("failed to create favicon service: %w", err)
362	}
363
364	app.faviconService = faviconService
365	logging.Info(fmt.Sprintf("[favicon] FaviconService initialized successfully"))
366	return nil
367}
368
369// setupContentBlocking initializes the content blocking system with proper timing
370func (app *BrowserApp) setupContentBlocking() error {
371	logging.Info(fmt.Sprintf("Initializing content blocking system..."))
372
373	// Enable WebKit debug logging if requested
374	webkit.SetupWebKitDebugLogging()
375
376	defer func() {
377		if r := recover(); r != nil {
378			logging.Error(fmt.Sprintf("Content blocking setup panic recovered: %v", r))
379		}
380	}()
381
382	// Setup filter system
383	filterManager, err := filtering.SetupFilterSystem()
384	if err != nil {
385		logging.Warn(fmt.Sprintf("Warning: Failed to setup filter system: %v", err))
386		return nil // Don't fail browser startup, continue without filters
387	}
388
389	// Store reference to filter manager
390	app.filterManager = filterManager
391
392	// Get data directory for content blocking service
393	dataDir, err := config.GetDataDir()
394	if err != nil {
395		logging.Warn(fmt.Sprintf("Warning: Failed to get data dir for content blocking: %v", err))
396		return nil
397	}
398
399	// Create content blocking service
400	cbService, err := filtering.NewContentBlockingService(dataDir+"/webkit", filterManager)
401	if err != nil {
402		logging.Warn(fmt.Sprintf("Warning: Failed to create content blocking service: %v", err))
403		return nil
404	}
405	app.contentBlockingService = cbService
406
407	// Set up callback to apply network filters when they become ready
408	// This callback will be called from the async filter loading process
409	filterManager.SetFiltersReadyCallback(func() {
410		logging.Info(fmt.Sprintf("[filtering] Filters ready, distributing to all WebViews..."))
411
412		// Get the WebKit JSON rules from filter manager
413		filterJSON, err := filterManager.GetNetworkFilters()
414		if err != nil {
415			logging.Error(fmt.Sprintf("[filtering] Failed to get network filters: %v", err))
416			return
417		}
418
419		if len(filterJSON) == 0 {
420			logging.Info(fmt.Sprintf("[filtering] No network filters to apply"))
421			return
422		}
423
424		logging.Debug(fmt.Sprintf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON)))
425
426		// Signal the service that filters are ready - it will apply to all registered WebViews
427		cbService.SetFiltersReady(filterJSON)
428		logging.Info(fmt.Sprintf("[filtering] Content blocking enabled for all WebViews"))
429	})
430
431	// Start async filter loading
432	// This allows filters to compile in the background while the browser starts
433	go func() {
434		if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
435			logging.Warn(fmt.Sprintf("Warning: failed to initialize filters asynchronously: %v", err))
436		}
437	}()
438
439	logging.Info(fmt.Sprintf("Content blocking system initialization started"))
440	return nil
441}
442
443// RegisterWebViewForFiltering registers a WebView with the content blocking service.
444// This should be called for every new WebView created in the application.
445func (app *BrowserApp) RegisterWebViewForFiltering(wv *webkit.WebView) {
446	if app.contentBlockingService != nil && wv != nil {
447		app.contentBlockingService.RegisterWebView(wv)
448	}
449}
450
451// UnregisterWebViewFromFiltering removes a WebView from the content blocking service.
452func (app *BrowserApp) UnregisterWebViewFromFiltering(wv *webkit.WebView) {
453	if app.contentBlockingService != nil && wv != nil {
454		app.contentBlockingService.UnregisterWebView(wv)
455	}
456}

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