v0.20.1 d9a391f

app.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"sync"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/application/usecase"
12	"github.com/bnema/dumber/internal/domain/entity"
13	"github.com/bnema/dumber/internal/infrastructure/config"
14	"github.com/bnema/dumber/internal/infrastructure/filtering"
15	"github.com/bnema/dumber/internal/infrastructure/webkit"
16
17	"github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
18	"github.com/bnema/dumber/internal/logging"
19	"github.com/bnema/dumber/internal/ui/adapter"
20	"github.com/bnema/dumber/internal/ui/component"
21	"github.com/bnema/dumber/internal/ui/coordinator"
22	"github.com/bnema/dumber/internal/ui/dispatcher"
23	"github.com/bnema/dumber/internal/ui/focus"
24	"github.com/bnema/dumber/internal/ui/input"
25	"github.com/bnema/dumber/internal/ui/layout"
26	"github.com/bnema/dumber/internal/ui/theme"
27	"github.com/bnema/dumber/internal/ui/window"
28	"github.com/jwijenbergh/puregotk/v4/gdk"
29	"github.com/jwijenbergh/puregotk/v4/gio"
30	"github.com/jwijenbergh/puregotk/v4/glib"
31	"github.com/jwijenbergh/puregotk/v4/gtk"
32)
33
34const (
35	// AppID is the application identifier for GTK.
36	AppID = "com.github.bnema.dumber"
37)
38
39// App wraps the GTK Application and manages the browser lifecycle.
40type App struct {
41	deps       *Dependencies
42	gtkApp     *gtk.Application
43	mainWindow *window.MainWindow
44
45	// State
46	tabs   *entity.TabList
47	tabsUC *usecase.ManageTabsUseCase
48
49	// Coordinators (new architecture)
50	contentCoord  *coordinator.ContentCoordinator
51	tabCoord      *coordinator.TabCoordinator
52	wsCoord       *coordinator.WorkspaceCoordinator
53	navCoord      *coordinator.NavigationCoordinator
54	kbDispatcher  *dispatcher.KeyboardDispatcher
55	configManager *config.Manager
56
57	// Pane management (used by coordinators)
58	panesUC        *usecase.ManagePanesUseCase
59	workspaceViews map[entity.TabID]*component.WorkspaceView
60	widgetFactory  layout.WidgetFactory
61	stackedPaneMgr *component.StackedPaneManager
62
63	// Input handling
64	keyboardHandler       *input.KeyboardHandler
65	globalShortcutHandler *input.GlobalShortcutHandler
66
67	// Focus and border management
68	focusMgr  *focus.Manager
69	borderMgr *focus.BorderManager
70
71	// Omnibox configuration (omnibox is created per workspace view)
72	omniboxCfg component.OmniboxConfig
73	// Find bar configuration (find bar is created per workspace view)
74	findBarCfg component.FindBarConfig
75
76	// Web content (managed by ContentCoordinator)
77	pool           *webkit.WebViewPool
78	webViewFactory *webkit.WebViewFactory
79	injector       *webkit.ContentInjector
80	router         *webkit.MessageRouter
81	settings       *webkit.SettingsManager
82	faviconAdapter *adapter.FaviconAdapter
83
84	// App-level toaster for system notifications (filter status, etc.)
85	appToaster *component.Toaster
86
87	// ID generator for tabs/panes
88	idCounter uint64
89	idMu      sync.Mutex
90
91	// lifecycle
92	cancel context.CancelCauseFunc
93}
94
95// New creates a new App with the given dependencies.
96func New(deps *Dependencies) (*App, error) {
97	if err := deps.Validate(); err != nil {
98		return nil, err
99	}
100
101	ctx, cancel := context.WithCancelCause(deps.Ctx)
102
103	app := &App{
104		deps:           deps,
105		tabs:           entity.NewTabList(),
106		tabsUC:         deps.TabsUC,
107		panesUC:        deps.PanesUC,
108		workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
109		pool:           deps.Pool,
110		injector:       deps.Injector,
111		router:         deps.MessageRouter,
112		settings:       deps.Settings,
113		configManager:  config.GetManager(),
114		cancel:         cancel,
115	}
116	if app.router == nil {
117		app.router = webkit.NewMessageRouter(ctx)
118	}
119
120	// Register message handlers
121	if app.router != nil {
122		if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
123			HistoryUC:   deps.HistoryUC,
124			FavoritesUC: deps.FavoritesUC,
125		}); err != nil {
126			return nil, err
127		}
128	}
129
130	return app, nil
131}
132
133// Run starts the GTK application and blocks until it exits.
134// Returns the exit code.
135func (a *App) Run(ctx context.Context, args []string) int {
136	log := logging.FromContext(ctx)
137	log.Debug().Msg("creating GTK application")
138
139	// TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
140	a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
141	if a.gtkApp == nil {
142		log.Error().Msg("failed to create GTK application")
143		return 1
144	}
145	defer a.gtkApp.Unref()
146
147	// Connect activate signal
148	activateCb := func(_ gio.Application) {
149		a.onActivate(ctx)
150	}
151	a.gtkApp.ConnectActivate(&activateCb)
152
153	// Connect shutdown signal
154	shutdownCb := func(_ gio.Application) {
155		a.onShutdown(ctx)
156	}
157	a.gtkApp.ConnectShutdown(&shutdownCb)
158
159	log.Info().Msg("starting GTK main loop")
160	code := a.gtkApp.Run(len(args), args)
161
162	return code
163}
164
165// onActivate is called when the GTK application is activated.
166func (a *App) onActivate(ctx context.Context) {
167	log := logging.FromContext(ctx)
168	log.Debug().Msg("GTK application activated")
169
170	a.applyGTKColorSchemePreference(ctx)
171	a.prewarmWebViewPool(ctx)
172
173	if err := a.createMainWindow(ctx); err != nil {
174		log.Error().Err(err).Msg("failed to create main window")
175		return
176	}
177
178	a.initLayoutInfrastructure()
179	a.initAppToasterOverlay()
180	a.initFocusAndBorderOverlay()
181
182	a.initCoordinators(ctx)
183	a.initKeyboardHandler(ctx)
184	a.initOmniboxConfig(ctx)
185	a.initFindBarConfig()
186	a.createInitialTab(ctx)
187	a.finalizeActivation(ctx)
188}
189
190func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
191	log := logging.FromContext(ctx)
192
193	// Propagate app color scheme preference to GTK (and WebKit)
194	// so web content can correctly evaluate prefers-color-scheme.
195	settings := gtk.SettingsGetDefault()
196	if settings == nil {
197		return
198	}
199
200	scheme := "default"
201	if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
202		scheme = a.deps.Config.Appearance.ColorScheme
203	}
204	prefersDark := theme.ResolveColorScheme(scheme)
205	settings.SetPropertyGtkApplicationPreferDarkTheme(prefersDark)
206	log.Debug().
207		Str("scheme", scheme).
208		Bool("prefers_dark", prefersDark).
209		Msg("set gtk-application-prefer-dark-theme")
210}
211
212func (a *App) prewarmWebViewPool(ctx context.Context) {
213	// Set theme background color on pool to eliminate white flash.
214	// Must be done before prewarming so WebViews get the correct color.
215	if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
216		r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
217		a.pool.SetBackgroundColor(r, g, b, alpha)
218	}
219
220	// Prewarm WebView pool now that GTK is initialized.
221	if a.pool == nil {
222		return
223	}
224	a.pool.Prewarm(ctx, 0)
225}
226
227func (a *App) createMainWindow(ctx context.Context) error {
228	mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
229	if err != nil {
230		return err
231	}
232	a.mainWindow = mainWindow
233
234	// Apply GTK CSS styling from theme manager.
235	if a.deps == nil || a.deps.Theme == nil {
236		return nil
237	}
238	if display := a.mainWindow.Window().GetDisplay(); display != nil {
239		a.deps.Theme.ApplyToDisplay(ctx, display)
240	}
241	return nil
242}
243
244func (a *App) initLayoutInfrastructure() {
245	// Initialize widget factory for pane layout.
246	a.widgetFactory = layout.NewGtkWidgetFactory()
247
248	// Initialize stacked pane manager for incremental widget operations.
249	a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
250}
251
252func (a *App) initAppToasterOverlay() {
253	if a.mainWindow == nil {
254		return
255	}
256
257	// Create app-level toaster for system notifications.
258	a.appToaster = component.NewToaster(a.widgetFactory)
259	toasterWidget := a.appToaster.Widget()
260	if toasterWidget == nil {
261		return
262	}
263	gtkWidget := toasterWidget.GtkWidget()
264	if gtkWidget == nil {
265		return
266	}
267	a.mainWindow.AddOverlay(gtkWidget)
268}
269
270func (a *App) initFocusAndBorderOverlay() {
271	// Initialize focus manager for geometric navigation.
272	a.focusMgr = focus.NewManager(a.panesUC)
273
274	// Initialize border manager for mode indicators.
275	a.borderMgr = focus.NewBorderManager(a.widgetFactory)
276
277	// Attach border overlay to main window (visible for all tabs).
278	if a.borderMgr == nil || a.mainWindow == nil {
279		return
280	}
281	borderWidget := a.borderMgr.Widget()
282	if borderWidget == nil {
283		return
284	}
285	gtkWidget := borderWidget.GtkWidget()
286	if gtkWidget == nil {
287		return
288	}
289	a.mainWindow.AddOverlay(gtkWidget)
290}
291
292func (a *App) initKeyboardHandler(ctx context.Context) {
293	if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
294		return
295	}
296
297	// Create keyboard handler and wire to dispatcher.
298	a.keyboardHandler = input.NewKeyboardHandler(
299		ctx,
300		&a.deps.Config.Workspace,
301	)
302	a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
303		return a.kbDispatcher.Dispatch(ctx, action)
304	})
305	a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
306		a.handleModeChange(ctx, from, to)
307	})
308	a.keyboardHandler.SetShouldBypassInput(func() bool {
309		wsView := a.activeWorkspaceView()
310		if wsView == nil {
311			return false
312		}
313		return wsView.IsOmniboxVisible()
314	})
315	a.keyboardHandler.AttachTo(a.mainWindow.Window())
316
317	// Create global shortcut handler for Alt+1-9 tab switching.
318	// This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
319	// to intercept shortcuts even when WebView has focus.
320	a.globalShortcutHandler = input.NewGlobalShortcutHandler(
321		ctx,
322		a.mainWindow.Window(),
323		func(ctx context.Context, action input.Action) error {
324			return a.kbDispatcher.Dispatch(ctx, action)
325		},
326	)
327}
328
329func (a *App) initOmniboxConfig(ctx context.Context) {
330	if a.deps == nil || a.deps.Config == nil {
331		return
332	}
333
334	log := logging.FromContext(ctx)
335
336	// Store omnibox config (omnibox is created per-pane via WorkspaceView).
337	a.omniboxCfg = component.OmniboxConfig{
338		HistoryUC:       a.deps.HistoryUC,
339		FavoritesUC:     a.deps.FavoritesUC,
340		FaviconAdapter:  a.faviconAdapter,
341		CopyURLUC:       a.deps.CopyURLUC,
342		Shortcuts:       a.deps.Config.SearchShortcuts,
343		DefaultSearch:   a.deps.Config.DefaultSearchEngine,
344		InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
345		UIScale:         a.deps.Config.DefaultUIScale,
346		OnNavigate: func(url string) {
347			if err := a.navCoord.Navigate(ctx, url); err != nil {
348				log.Error().Err(err).Str("url", url).Msg("navigation failed")
349			}
350		},
351	}
352	a.navCoord.SetOmniboxProvider(a)
353	log.Debug().Msg("omnibox config stored, provider set")
354}
355
356func (a *App) initFindBarConfig() {
357	// Store find bar config (find bar is created per-pane via WorkspaceView).
358	a.findBarCfg = component.FindBarConfig{
359		GetFindController: func(paneID entity.PaneID) port.FindController {
360			if a.contentCoord == nil {
361				return nil
362			}
363			wv := a.contentCoord.GetWebView(paneID)
364			if wv == nil {
365				return nil
366			}
367			return wv.GetFindController()
368		},
369	}
370}
371
372func (a *App) createInitialTab(ctx context.Context) {
373	log := logging.FromContext(ctx)
374
375	// Create an initial tab using coordinator.
376	initialURL := "dumb://home"
377	if a.deps != nil && a.deps.InitialURL != "" {
378		initialURL = a.deps.InitialURL
379	}
380	if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
381		log.Error().Err(err).Msg("failed to create initial tab")
382	}
383}
384
385func (a *App) finalizeActivation(ctx context.Context) {
386	log := logging.FromContext(ctx)
387
388	// Show the window.
389	if a.mainWindow != nil {
390		a.mainWindow.Show()
391	}
392	log.Info().Msg("main window displayed")
393
394	// Start watching config for appearance changes.
395	a.initConfigWatcher(ctx)
396
397	// Ensure the current appearance config is applied once at startup.
398	// This avoids the WebUI showing default tokens until the first config change event.
399	if a.deps != nil {
400		a.applyAppearanceConfig(ctx, a.deps.Config)
401	}
402
403	// Start async filter loading after window is visible.
404	a.initFilteringAsync(ctx)
405}
406
407// onShutdown is called when the GTK application is shutting down.
408func (a *App) onShutdown(ctx context.Context) {
409	log := logging.FromContext(ctx)
410	log.Debug().Msg("GTK application shutting down")
411
412	// Cancel context to signal all goroutines
413	a.cancel(errors.New("application shutdown"))
414
415	// Cleanup resources
416	if a.faviconAdapter != nil {
417		a.faviconAdapter.Close()
418	}
419	if a.deps.Pool != nil {
420		a.deps.Pool.Close(ctx)
421	}
422	// Close idle inhibitor to release D-Bus connection
423	if a.deps.IdleInhibitor != nil {
424		if err := a.deps.IdleInhibitor.Close(); err != nil {
425			log.Warn().Err(err).Msg("failed to close idle inhibitor")
426		}
427	}
428
429	log.Info().Msg("application shutdown complete")
430}
431
432// initCoordinators initializes all coordinators and wires their callbacks.
433func (a *App) initCoordinators(ctx context.Context) {
434	log := logging.FromContext(ctx)
435	log.Debug().Msg("initializing coordinators")
436
437	// Helper to get active workspace and view
438	getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
439		return a.activeWorkspace(), a.activeWorkspaceView()
440	}
441
442	// Create FaviconAdapter with service and WebKit FaviconDatabase
443	var faviconDB *webkit.FaviconDatabase
444	if a.deps.WebContext != nil {
445		faviconDB = a.deps.WebContext.FaviconDatabase()
446	}
447	a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
448
449	// 1. Content Coordinator (no dependencies on other coordinators)
450	a.contentCoord = coordinator.NewContentCoordinator(
451		ctx,
452		a.pool,
453		a.widgetFactory,
454		a.faviconAdapter,
455		getActiveWS,
456		a.deps.ZoomUC,
457	)
458
459	// Set idle inhibitor for fullscreen video playback
460	if a.deps.IdleInhibitor != nil {
461		a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
462	}
463
464	// 2. Tab Coordinator
465	a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
466		TabsUC:     a.tabsUC,
467		Tabs:       a.tabs,
468		MainWindow: a.mainWindow,
469		Config:     a.deps.Config,
470	})
471	a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
472		a.createWorkspaceView(ctx, tab)
473	})
474	a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
475		a.switchWorkspaceView(ctx, tab.ID)
476	})
477	a.tabCoord.SetOnQuit(a.Quit)
478	// Wire popup tab WebView attachment
479	a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
480		a.attachPopupToTab(ctx, tabID, pane, wv)
481	})
482
483	// Wire tab bar click handling to coordinator
484	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
485		a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
486			if err := a.tabCoord.Switch(ctx, tabID); err != nil {
487				log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
488			}
489		})
490	}
491
492	// 3. Workspace Coordinator
493	a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
494		PanesUC:        a.panesUC,
495		FocusMgr:       a.focusMgr,
496		StackedPaneMgr: a.stackedPaneMgr,
497		WidgetFactory:  a.widgetFactory,
498		ContentCoord:   a.contentCoord,
499		GetActiveWS:    getActiveWS,
500		GenerateID:     a.generateID,
501	})
502	a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
503		return a.tabCoord.Close(ctx)
504	})
505
506	// Wire popup handling
507	a.webViewFactory = webkit.NewWebViewFactory(
508		a.deps.WebContext,
509		a.settings,
510		a.pool,
511		a.injector,
512		a.router,
513	)
514	// Set theme background color on factory to eliminate white flash
515	if a.deps.Theme != nil {
516		r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
517		a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
518	}
519	a.contentCoord.SetPopupConfig(
520		a.webViewFactory,
521		&a.deps.Config.Workspace.Popups,
522		a.generateID,
523	)
524	a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
525		return a.wsCoord.InsertPopup(ctx, input)
526	})
527	a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
528		return a.wsCoord.ClosePaneByID(ctx, paneID)
529	})
530	// Wire tabbed popup behavior to create new tabs
531	a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
532		// Create a new tab with the popup pane
533		tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
534		if err != nil {
535			return err
536		}
537		log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
538		return nil
539	})
540
541	// 4. Navigation Coordinator
542	a.navCoord = coordinator.NewNavigationCoordinator(
543		ctx,
544		a.deps.NavigateUC,
545		a.contentCoord,
546	)
547
548	// Wire title updates to history persistence
549	a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
550		a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
551	})
552
553	// Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
554	a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
555		a.navCoord.RecordHistory(ctx, paneID, url)
556	})
557
558	// 5. Keyboard Dispatcher
559	a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
560		ctx,
561		a.tabCoord,
562		a.wsCoord,
563		a.navCoord,
564		a.deps.ZoomUC,
565		a.deps.CopyURLUC,
566	)
567	a.kbDispatcher.SetOnQuit(a.Quit)
568	a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
569		a.ToggleFindBar(ctx)
570		return nil
571	})
572	a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
573		a.FindNext(ctx)
574		return nil
575	})
576	a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
577		a.FindPrevious(ctx)
578		return nil
579	})
580	a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
581		a.CloseFindBar(ctx)
582		return nil
583	})
584
585	// Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
586	a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
587		return a.kbDispatcher.Dispatch(ctx, action)
588	})
589
590	log.Debug().Msg("coordinators initialized")
591}
592
593// generateID generates a unique ID for tabs and panes.
594// Uses monotonically increasing counter to avoid ID collisions.
595func (a *App) generateID() string {
596	a.idMu.Lock()
597	defer a.idMu.Unlock()
598	a.idCounter++
599	return fmt.Sprintf("p%d", a.idCounter)
600}
601
602// Tabs returns the tab list.
603func (a *App) Tabs() *entity.TabList {
604	return a.tabs
605}
606
607// MainWindow returns the main window.
608func (a *App) MainWindow() *window.MainWindow {
609	return a.mainWindow
610}
611
612// Quit requests the application to quit.
613func (a *App) Quit() {
614	if a.gtkApp != nil {
615		a.gtkApp.Quit()
616	}
617}
618
619// RunWithArgs is a convenience function that creates and runs an App.
620func RunWithArgs(ctx context.Context, deps *Dependencies) int {
621	app, err := New(deps)
622	if err != nil {
623		log := logging.FromContext(ctx)
624		log.Error().Err(err).Msg("failed to create application")
625		return 1
626	}
627	return app.Run(ctx, os.Args)
628}
629
630// handleModeChange is called when the input mode changes.
631func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
632	log := logging.FromContext(ctx)
633	log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
634
635	// Update border overlay visibility based on mode
636	if a.borderMgr != nil {
637		a.borderMgr.OnModeChange(ctx, from, to)
638	}
639}
640
641// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
642func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
643	log := logging.FromContext(ctx)
644
645	if a.widgetFactory == nil {
646		log.Error().Msg("widget factory not initialized")
647		return
648	}
649
650	// Create workspace view
651	wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
652	if wsView == nil {
653		log.Error().Msg("failed to create workspace view")
654		return
655	}
656
657	// Set the workspace
658	if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
659		log.Error().Err(err).Msg("failed to set workspace in view")
660		return
661	}
662
663	// Ensure WebViews are attached to panes
664	if a.contentCoord != nil {
665		a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
666	}
667
668	// Note: Border overlay is attached to MainWindow, not per-workspace
669	// This ensures it's visible regardless of which tab is active
670
671	// Set omnibox config for this workspace view
672	wsView.SetOmniboxConfig(a.omniboxCfg)
673	// Set find bar config for this workspace view
674	wsView.SetFindBarConfig(a.findBarCfg)
675
676	// Store in map
677	a.workspaceViews[tab.ID] = wsView
678
679	// Attach to content area
680	if a.mainWindow != nil {
681		widget := wsView.Widget()
682		if widget != nil {
683			gtkWidget := widget.GtkWidget()
684			if gtkWidget != nil {
685				a.mainWindow.SetContent(gtkWidget)
686			}
687		}
688	}
689
690	log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created and attached")
691}
692
693// activeWorkspace returns the workspace of the active tab.
694func (a *App) activeWorkspace() *entity.Workspace {
695	activeTab := a.tabs.ActiveTab()
696	if activeTab == nil {
697		return nil
698	}
699	return activeTab.Workspace
700}
701
702// activeWorkspaceView returns the workspace view for the active tab.
703func (a *App) activeWorkspaceView() *component.WorkspaceView {
704	activeTab := a.tabs.ActiveTab()
705	if activeTab == nil {
706		return nil
707	}
708	return a.workspaceViews[activeTab.ID]
709}
710
711// attachPopupToTab attaches a popup WebView to a newly created tab.
712// This is called when a popup uses tabbed behavior.
713func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
714	log := logging.FromContext(ctx)
715
716	wsView := a.workspaceViews[tabID]
717	if wsView == nil {
718		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
719		return
720	}
721
722	// Register WebView with content coordinator
723	if a.contentCoord != nil {
724		// Track the WebView
725		a.contentCoord.RegisterPopupWebView(pane.ID, wv)
726
727		// Wrap and attach widget
728		widget := a.contentCoord.WrapWidget(ctx, wv)
729		if widget != nil {
730			paneView := wsView.GetPaneView(pane.ID)
731			if paneView != nil {
732				paneView.SetWebViewWidget(widget)
733			} else {
734				log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
735			}
736		}
737
738		// Setup popup handling for nested popups
739		a.contentCoord.SetupPopupHandling(ctx, pane.ID, wv)
740	}
741
742	log.Debug().
743		Str("tab_id", string(tabID)).
744		Str("pane_id", string(pane.ID)).
745		Msg("popup webview attached to tab")
746}
747
748// switchWorkspaceView swaps the displayed workspace view for a tab.
749func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
750	log := logging.FromContext(ctx)
751
752	wsView, exists := a.workspaceViews[tabID]
753	if !exists {
754		log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
755		return
756	}
757
758	// Get the workspace view's root widget
759	widget := wsView.Widget()
760	if widget == nil {
761		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
762		return
763	}
764
765	gtkWidget := widget.GtkWidget()
766	if gtkWidget == nil {
767		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
768		return
769	}
770
771	// Swap content (MainWindow.SetContent now properly removes old content)
772	if a.mainWindow != nil {
773		a.mainWindow.SetContent(gtkWidget)
774	}
775
776	log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
777}
778
779// ToggleOmnibox implements OmniboxProvider.
780// Toggles the omnibox visibility in the active workspace view.
781func (a *App) ToggleOmnibox(ctx context.Context) {
782	log := logging.FromContext(ctx)
783
784	wsView := a.activeWorkspaceView()
785	if wsView == nil {
786		log.Warn().Msg("no active workspace view for omnibox toggle")
787		return
788	}
789
790	if wsView.IsOmniboxVisible() {
791		wsView.HideOmnibox()
792	} else {
793		wsView.ShowOmnibox(ctx, "")
794	}
795}
796
797// ToggleFindBar shows or hides the find bar in the active workspace view.
798func (a *App) ToggleFindBar(ctx context.Context) {
799	log := logging.FromContext(ctx)
800
801	wsView := a.activeWorkspaceView()
802	if wsView == nil {
803		log.Warn().Msg("no active workspace view for find bar toggle")
804		return
805	}
806
807	if wsView.IsFindBarVisible() {
808		wsView.HideFindBar()
809	} else {
810		wsView.ShowFindBar(ctx)
811	}
812}
813
814// FindNext selects the next match in the active find bar.
815func (a *App) FindNext(ctx context.Context) {
816	wsView := a.activeWorkspaceView()
817	if wsView == nil {
818		return
819	}
820
821	if !wsView.IsFindBarVisible() {
822		wsView.ShowFindBar(ctx)
823	}
824	wsView.FindNext()
825}
826
827// FindPrevious selects the previous match in the active find bar.
828func (a *App) FindPrevious(ctx context.Context) {
829	wsView := a.activeWorkspaceView()
830	if wsView == nil {
831		return
832	}
833
834	if !wsView.IsFindBarVisible() {
835		wsView.ShowFindBar(ctx)
836	}
837	wsView.FindPrevious()
838}
839
840// CloseFindBar hides the find bar if visible.
841func (a *App) CloseFindBar(ctx context.Context) {
842	wsView := a.activeWorkspaceView()
843	if wsView == nil {
844		return
845	}
846	wsView.HideFindBar()
847}
848
849// UpdateOmniboxZoom implements OmniboxProvider.
850// Updates the zoom indicator on the current omnibox if visible.
851func (a *App) UpdateOmniboxZoom(factor float64) {
852	wsView := a.activeWorkspaceView()
853	if wsView == nil {
854		return
855	}
856
857	omnibox := wsView.GetOmnibox()
858	if omnibox != nil {
859		omnibox.UpdateZoomIndicator(factor)
860	}
861}
862
863// SetOmniboxOnNavigate implements OmniboxProvider.
864// This is called to set the navigation callback on new omniboxes.
865// Since omniboxes are created per-pane, we store the config with the callback.
866func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
867	// The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox
868	// The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
869}
870
871// initFilteringAsync starts background filter loading with toast feedback.
872func (a *App) initConfigWatcher(ctx context.Context) {
873	log := logging.FromContext(ctx)
874
875	if a.configManager == nil {
876		log.Debug().Msg("no config manager available, skipping watcher")
877		return
878	}
879
880	// Start viper watcher
881	if err := a.configManager.Watch(); err != nil {
882		log.Warn().Err(err).Msg("failed to start config watcher")
883		return
884	}
885
886	// Only appearance is hot-reloaded for now.
887	a.configManager.OnConfigChange(func(newCfg *config.Config) {
888		cfgCopy := newCfg
889		cb := glib.SourceFunc(func(_ uintptr) bool {
890			a.applyAppearanceConfig(ctx, cfgCopy)
891			return false
892		})
893		glib.IdleAdd(&cb, 0)
894	})
895
896	log.Debug().Msg("config watcher initialized")
897}
898
899func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
900	log := logging.FromContext(ctx)
901	if cfg == nil {
902		return
903	}
904
905	// Update the shared config pointer in-place so existing references see changes.
906	if a.deps != nil && a.deps.Config != nil {
907		*a.deps.Config = *cfg
908	}
909
910	// Update WebKit settings manager
911	if a.settings != nil {
912		a.settings.UpdateFromConfig(ctx, cfg)
913	}
914
915	// Apply settings to existing webviews via coordinator
916	if a.contentCoord != nil {
917		a.contentCoord.ApplySettingsToAll(ctx, a.settings)
918	}
919
920	// Update GTK theme and injected WebUI theme vars
921	if a.deps != nil && a.deps.Theme != nil {
922		var display *gdk.Display
923		if a.mainWindow != nil && a.mainWindow.Window() != nil {
924			display = a.mainWindow.Window().GetDisplay()
925		}
926		a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
927
928		// Keep injector's dark-mode flag in sync for future navigations
929		if a.injector != nil {
930			a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
931			findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
932			if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
933				log.Warn().Err(err).Msg("failed to update find highlight CSS")
934			}
935		}
936
937		prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
938		cssText := a.deps.Theme.GetWebUIThemeCSS()
939		if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
940			log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
941		}
942
943		// Refresh injected scripts so future navigations use the latest theme.
944		if a.contentCoord != nil && a.injector != nil {
945			a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
946		}
947
948		// Apply WebUI theme to already-loaded dumb:// pages
949		if a.contentCoord != nil {
950			a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
951		}
952	}
953
954	log.Info().Msg("appearance config updated")
955}
956
957func (a *App) initFilteringAsync(ctx context.Context) {
958	if a.deps.FilterManager == nil {
959		return
960	}
961
962	a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
963		statusCopy := status // Capture for closure
964		cb := glib.SourceFunc(func(_ uintptr) bool {
965			a.showFilterStatus(ctx, statusCopy)
966			return false // Don't repeat
967		})
968		glib.IdleAdd(&cb, 0)
969	})
970	a.deps.FilterManager.LoadAsync(ctx)
971}
972
973// showFilterStatus displays toast notification for filter status.
974func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
975	log := logging.FromContext(ctx)
976
977	switch status.State {
978	case filtering.StateLoading:
979		if a.appToaster != nil {
980			a.appToaster.Show(ctx, status.Message, component.ToastInfo)
981		}
982	case filtering.StateActive:
983		// Apply filters to existing webviews that were created before filters loaded
984		if a.contentCoord != nil && a.deps.FilterManager != nil {
985			a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
986			log.Debug().Msg("applied filters to all existing webviews")
987		}
988		if a.appToaster != nil {
989			a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
990		}
991	case filtering.StateError:
992		if a.appToaster != nil {
993			a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
994		}
995	}
996}

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