v0.20.0 c741a39

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

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