v0.21.0 73bf644

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/desktop"
15	"github.com/bnema/dumber/internal/infrastructure/filtering"
16	"github.com/bnema/dumber/internal/infrastructure/snapshot"
17	"github.com/bnema/dumber/internal/infrastructure/webkit"
18
19	"github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
20	"github.com/bnema/dumber/internal/logging"
21	"github.com/bnema/dumber/internal/ui/adapter"
22	"github.com/bnema/dumber/internal/ui/component"
23	"github.com/bnema/dumber/internal/ui/coordinator"
24	"github.com/bnema/dumber/internal/ui/dispatcher"
25	"github.com/bnema/dumber/internal/ui/focus"
26	"github.com/bnema/dumber/internal/ui/input"
27	"github.com/bnema/dumber/internal/ui/layout"
28	"github.com/bnema/dumber/internal/ui/theme"
29	"github.com/bnema/dumber/internal/ui/window"
30	"github.com/jwijenbergh/puregotk/v4/gdk"
31	"github.com/jwijenbergh/puregotk/v4/gio"
32	"github.com/jwijenbergh/puregotk/v4/glib"
33	"github.com/jwijenbergh/puregotk/v4/gtk"
34)
35
36const (
37	// AppID is the application identifier for GTK.
38	AppID = "com.github.bnema.dumber"
39)
40
41// App wraps the GTK Application and manages the browser lifecycle.
42type App struct {
43	deps       *Dependencies
44	gtkApp     *gtk.Application
45	mainWindow *window.MainWindow
46
47	// State
48	tabs   *entity.TabList
49	tabsUC *usecase.ManageTabsUseCase
50
51	// Coordinators (new architecture)
52	contentCoord  *coordinator.ContentCoordinator
53	tabCoord      *coordinator.TabCoordinator
54	wsCoord       *coordinator.WorkspaceCoordinator
55	navCoord      *coordinator.NavigationCoordinator
56	kbDispatcher  *dispatcher.KeyboardDispatcher
57	configManager *config.Manager
58
59	// Pane management (used by coordinators)
60	panesUC        *usecase.ManagePanesUseCase
61	workspaceViews map[entity.TabID]*component.WorkspaceView
62	widgetFactory  layout.WidgetFactory
63	stackedPaneMgr *component.StackedPaneManager
64
65	// Input handling
66	keyboardHandler       *input.KeyboardHandler
67	globalShortcutHandler *input.GlobalShortcutHandler
68
69	// Focus and border management
70	focusMgr  *focus.Manager
71	borderMgr *focus.BorderManager
72
73	resizeModeBorderTarget layout.Widget
74
75	// Omnibox configuration (omnibox is created per workspace view)
76	omniboxCfg component.OmniboxConfig
77	// Find bar configuration (find bar is created per workspace view)
78	findBarCfg component.FindBarConfig
79
80	// Web content (managed by ContentCoordinator)
81	pool           *webkit.WebViewPool
82	webViewFactory *webkit.WebViewFactory
83	injector       *webkit.ContentInjector
84	router         *webkit.MessageRouter
85	settings       *webkit.SettingsManager
86	faviconAdapter *adapter.FaviconAdapter
87
88	// App-level toaster for system notifications (filter status, etc.)
89	appToaster *component.Toaster
90
91	// Session management
92	sessionManager  *component.SessionManager
93	snapshotService *snapshot.Service
94
95	// Update management
96	updateCoord *coordinator.UpdateCoordinator
97
98	// ID generator for tabs/panes
99	idCounter uint64
100	idMu      sync.Mutex
101
102	// lifecycle
103	cancel context.CancelCauseFunc
104}
105
106// New creates a new App with the given dependencies.
107func New(deps *Dependencies) (*App, error) {
108	if err := deps.Validate(); err != nil {
109		return nil, err
110	}
111
112	ctx, cancel := context.WithCancelCause(deps.Ctx)
113
114	app := &App{
115		deps:           deps,
116		tabs:           entity.NewTabList(),
117		tabsUC:         deps.TabsUC,
118		panesUC:        deps.PanesUC,
119		workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
120		pool:           deps.Pool,
121		injector:       deps.Injector,
122		router:         deps.MessageRouter,
123		settings:       deps.Settings,
124		configManager:  config.GetManager(),
125		cancel:         cancel,
126	}
127	if app.router == nil {
128		app.router = webkit.NewMessageRouter(ctx)
129	}
130
131	// Register message handlers
132	if app.router != nil {
133		if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
134			HistoryUC:   deps.HistoryUC,
135			FavoritesUC: deps.FavoritesUC,
136		}); err != nil {
137			return nil, err
138		}
139	}
140
141	return app, nil
142}
143
144// Run starts the GTK application and blocks until it exits.
145// Returns the exit code.
146func (a *App) Run(ctx context.Context, args []string) int {
147	log := logging.FromContext(ctx)
148	log.Debug().Msg("creating GTK application")
149
150	// TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
151	a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
152	if a.gtkApp == nil {
153		log.Error().Msg("failed to create GTK application")
154		return 1
155	}
156	defer a.gtkApp.Unref()
157
158	// Connect activate signal
159	activateCb := func(_ gio.Application) {
160		a.onActivate(ctx)
161	}
162	a.gtkApp.ConnectActivate(&activateCb)
163
164	// Connect shutdown signal
165	shutdownCb := func(_ gio.Application) {
166		a.onShutdown(ctx)
167	}
168	a.gtkApp.ConnectShutdown(&shutdownCb)
169
170	log.Info().Msg("starting GTK main loop")
171	code := a.gtkApp.Run(len(args), args)
172
173	return code
174}
175
176// onActivate is called when the GTK application is activated.
177func (a *App) onActivate(ctx context.Context) {
178	log := logging.FromContext(ctx)
179	log.Debug().Msg("GTK application activated")
180
181	a.applyGTKColorSchemePreference(ctx)
182	a.prewarmWebViewPool(ctx)
183
184	if err := a.createMainWindow(ctx); err != nil {
185		log.Error().Err(err).Msg("failed to create main window")
186		return
187	}
188
189	a.initLayoutInfrastructure()
190	a.initAppToasterOverlay()
191	a.initFocusAndBorderOverlay()
192
193	a.initCoordinators(ctx)
194	a.initKeyboardHandler(ctx)
195	a.initOmniboxConfig(ctx)
196	a.initFindBarConfig()
197	a.initSessionManager(ctx)
198	a.initSnapshotService(ctx)
199	a.initUpdateCoordinator(ctx)
200	a.createInitialTab(ctx)
201	a.finalizeActivation(ctx)
202}
203
204func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
205	log := logging.FromContext(ctx)
206
207	// Propagate app color scheme preference to GTK (and WebKit)
208	// so web content can correctly evaluate prefers-color-scheme.
209	settings := gtk.SettingsGetDefault()
210	if settings == nil {
211		return
212	}
213
214	scheme := "default"
215	if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
216		scheme = a.deps.Config.Appearance.ColorScheme
217	}
218	prefersDark := theme.ResolveColorScheme(scheme)
219	settings.SetPropertyGtkApplicationPreferDarkTheme(prefersDark)
220	log.Debug().
221		Str("scheme", scheme).
222		Bool("prefers_dark", prefersDark).
223		Msg("set gtk-application-prefer-dark-theme")
224}
225
226func (a *App) prewarmWebViewPool(ctx context.Context) {
227	// Set theme background color on pool to eliminate white flash.
228	// Must be done before prewarming so WebViews get the correct color.
229	if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
230		r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
231		a.pool.SetBackgroundColor(r, g, b, alpha)
232	}
233
234	// Prewarm WebView pool now that GTK is initialized.
235	if a.pool == nil {
236		return
237	}
238	a.pool.Prewarm(ctx, 0)
239}
240
241func (a *App) createMainWindow(ctx context.Context) error {
242	mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
243	if err != nil {
244		return err
245	}
246	a.mainWindow = mainWindow
247
248	// Apply GTK CSS styling from theme manager.
249	if a.deps == nil || a.deps.Theme == nil {
250		return nil
251	}
252	if display := a.mainWindow.Window().GetDisplay(); display != nil {
253		a.deps.Theme.ApplyToDisplay(ctx, display)
254	}
255	return nil
256}
257
258func (a *App) initLayoutInfrastructure() {
259	// Initialize widget factory for pane layout.
260	a.widgetFactory = layout.NewGtkWidgetFactory()
261
262	// Initialize stacked pane manager for incremental widget operations.
263	a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
264}
265
266func (a *App) initAppToasterOverlay() {
267	if a.mainWindow == nil {
268		return
269	}
270
271	// Create app-level toaster for system notifications.
272	a.appToaster = component.NewToaster(a.widgetFactory)
273	toasterWidget := a.appToaster.Widget()
274	if toasterWidget == nil {
275		return
276	}
277	gtkWidget := toasterWidget.GtkWidget()
278	if gtkWidget == nil {
279		return
280	}
281	a.mainWindow.AddOverlay(gtkWidget)
282}
283
284func (a *App) initFocusAndBorderOverlay() {
285	// Initialize focus manager for geometric navigation.
286	a.focusMgr = focus.NewManager(a.panesUC)
287
288	// Initialize border manager for mode indicators.
289	a.borderMgr = focus.NewBorderManager(a.widgetFactory)
290
291	// Attach border overlay to main window (visible for all tabs).
292	if a.borderMgr == nil || a.mainWindow == nil {
293		return
294	}
295	borderWidget := a.borderMgr.Widget()
296	if borderWidget == nil {
297		return
298	}
299	gtkWidget := borderWidget.GtkWidget()
300	if gtkWidget == nil {
301		return
302	}
303	a.mainWindow.AddOverlay(gtkWidget)
304}
305
306func (a *App) initKeyboardHandler(ctx context.Context) {
307	if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
308		return
309	}
310
311	// Create keyboard handler and wire to dispatcher.
312	a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
313	a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
314		return a.kbDispatcher.Dispatch(ctx, action)
315	})
316	a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
317		a.handleModeChange(ctx, from, to)
318	})
319	a.keyboardHandler.SetShouldBypassInput(func() bool {
320		// Bypass keyboard handler when session manager is visible
321		if a.sessionManager != nil && a.sessionManager.IsVisible() {
322			return true
323		}
324		wsView := a.activeWorkspaceView()
325		if wsView == nil {
326			return false
327		}
328		return wsView.IsOmniboxVisible()
329	})
330	a.keyboardHandler.AttachTo(a.mainWindow.Window())
331
332	// Create global shortcut handler for Alt+1-9 tab switching.
333	// This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
334	// to intercept shortcuts even when WebView has focus.
335	a.globalShortcutHandler = input.NewGlobalShortcutHandler(
336		ctx,
337		a.mainWindow.Window(),
338		a.deps.Config,
339		func(ctx context.Context, action input.Action) error {
340			return a.kbDispatcher.Dispatch(ctx, action)
341		},
342	)
343}
344
345func (a *App) initOmniboxConfig(ctx context.Context) {
346	if a.deps == nil || a.deps.Config == nil {
347		return
348	}
349
350	log := logging.FromContext(ctx)
351
352	// Store omnibox config (omnibox is created per-pane via WorkspaceView).
353	a.omniboxCfg = component.OmniboxConfig{
354		HistoryUC:       a.deps.HistoryUC,
355		FavoritesUC:     a.deps.FavoritesUC,
356		FaviconAdapter:  a.faviconAdapter,
357		CopyURLUC:       a.deps.CopyURLUC,
358		Shortcuts:       a.deps.Config.SearchShortcuts,
359		DefaultSearch:   a.deps.Config.DefaultSearchEngine,
360		InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
361		UIScale:         a.deps.Config.DefaultUIScale,
362		OnNavigate: func(url string) {
363			if err := a.navCoord.Navigate(ctx, url); err != nil {
364				log.Error().Err(err).Str("url", url).Msg("navigation failed")
365			}
366		},
367	}
368	a.navCoord.SetOmniboxProvider(a)
369	log.Debug().Msg("omnibox config stored, provider set")
370}
371
372func (a *App) initFindBarConfig() {
373	// Store find bar config (find bar is created per-pane via WorkspaceView).
374	a.findBarCfg = component.FindBarConfig{
375		GetFindController: func(paneID entity.PaneID) port.FindController {
376			if a.contentCoord == nil {
377				return nil
378			}
379			wv := a.contentCoord.GetWebView(paneID)
380			if wv == nil {
381				return nil
382			}
383			return wv.GetFindController()
384		},
385	}
386}
387
388func (a *App) initSessionManager(ctx context.Context) {
389	log := logging.FromContext(ctx)
390
391	if a.deps == nil {
392		log.Debug().Msg("deps not available, skipping session manager")
393		return
394	}
395
396	// Session manager can work without a repo - it just shows an empty list
397	var listSessionsUC *usecase.ListSessionsUseCase
398	var deleteSessionUC *usecase.DeleteSessionUseCase
399	if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
400		listSessionsUC = usecase.NewListSessionsUseCase(
401			a.deps.SessionRepo,
402			a.deps.SessionStateRepo,
403			a.deps.Config.Logging.LogDir,
404		)
405		deleteSessionUC = usecase.NewDeleteSessionUseCase(
406			a.deps.SessionStateRepo,
407			a.deps.SessionRepo,
408			a.deps.Config.Logging.LogDir,
409		)
410	}
411
412	// Create session spawner for restoration
413	spawner := desktop.NewSessionSpawner(ctx)
414
415	// Create session manager component
416	a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
417		ListSessionsUC:  listSessionsUC,
418		DeleteSessionUC: deleteSessionUC,
419		CurrentSession:  a.deps.CurrentSessionID,
420		UIScale:         a.deps.Config.DefaultUIScale,
421		OnClose: func() {
422			log.Debug().Msg("session manager closed")
423		},
424		OnOpen: func(sessionID entity.SessionID) {
425			log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
426			if err := spawner.SpawnWithSession(sessionID); err != nil {
427				log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
428			}
429		},
430		OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
431			if a.appToaster != nil {
432				a.appToaster.Show(ctx, message, level)
433			}
434		},
435	})
436
437	if a.sessionManager == nil {
438		log.Warn().Msg("failed to create session manager")
439		return
440	}
441
442	// Add session manager to main window overlay
443	if a.mainWindow != nil {
444		widget := a.sessionManager.Widget()
445		if widget != nil {
446			a.mainWindow.AddOverlay(widget)
447		}
448	}
449
450	// Wire session manager action to keyboard dispatcher
451	a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
452		a.ToggleSessionManager(ctx)
453		return nil
454	})
455
456	log.Debug().Msg("session manager initialized")
457}
458
459// ToggleSessionManager shows or hides the session manager.
460func (a *App) ToggleSessionManager(ctx context.Context) {
461	if a.sessionManager == nil {
462		return
463	}
464	a.sessionManager.Toggle(ctx)
465}
466
467func (a *App) initSnapshotService(ctx context.Context) {
468	log := logging.FromContext(ctx)
469
470	if a.deps == nil || a.deps.SnapshotUC == nil {
471		log.Debug().Msg("snapshot use case not available, skipping snapshot service")
472		return
473	}
474
475	intervalMs := 5000 // default
476	if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
477		intervalMs = a.deps.Config.Session.SnapshotIntervalMs
478	}
479
480	a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
481	a.snapshotService.Start(ctx)
482
483	log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
484}
485
486func (a *App) initUpdateCoordinator(ctx context.Context) {
487	log := logging.FromContext(ctx)
488
489	if a.deps == nil || a.deps.CheckUpdateUC == nil {
490		log.Debug().Msg("update use cases not available, skipping update coordinator")
491		return
492	}
493
494	a.updateCoord = coordinator.NewUpdateCoordinator(
495		a.deps.CheckUpdateUC,
496		a.deps.ApplyUpdateUC,
497		a.appToaster,
498		a.deps.Config,
499	)
500
501	// Start async update check
502	a.updateCoord.CheckOnStartup(ctx)
503
504	log.Debug().Msg("update coordinator initialized")
505}
506
507// GetTabList implements port.TabListProvider.
508func (a *App) GetTabList() *entity.TabList {
509	return a.tabs
510}
511
512// GetSessionID implements port.TabListProvider.
513func (a *App) GetSessionID() entity.SessionID {
514	if a.deps == nil {
515		return ""
516	}
517	return a.deps.CurrentSessionID
518}
519
520// MarkDirty signals that session state has changed and should be saved.
521func (a *App) MarkDirty() {
522	if a.snapshotService != nil {
523		a.snapshotService.MarkDirty()
524	}
525}
526
527func (a *App) createInitialTab(ctx context.Context) {
528	log := logging.FromContext(ctx)
529
530	// Check if we should restore a session
531	if a.deps != nil && a.deps.RestoreSessionID != "" {
532		if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
533			log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
534			// Show error toast and fall through to create default tab
535			if a.appToaster != nil {
536				a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
537			}
538		} else {
539			log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
540			// Show success toast for session restoration
541			if a.appToaster != nil {
542				a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
543			}
544			return
545		}
546	}
547
548	// Create an initial tab using coordinator.
549	initialURL := "dumb://home"
550	if a.deps != nil && a.deps.InitialURL != "" {
551		initialURL = a.deps.InitialURL
552	}
553	if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
554		log.Error().Err(err).Msg("failed to create initial tab")
555	}
556}
557
558func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
559	log := logging.FromContext(ctx)
560
561	if a.deps == nil || a.deps.SessionStateRepo == nil {
562		return fmt.Errorf("session state repo not available")
563	}
564
565	// Load session state
566	state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
567	if err != nil {
568		return fmt.Errorf("load session state: %w", err)
569	}
570	if state == nil {
571		return fmt.Errorf("session state not found")
572	}
573
574	log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
575
576	// Build complete TabList from snapshot (state-first approach)
577	// This reconstructs the full pane tree with new IDs
578	restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
579	if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
580		return fmt.Errorf("failed to build tab list from snapshot")
581	}
582
583	// Replace tabs in-place to preserve references held by TabCoordinator
584	a.tabs.ReplaceFrom(restoredTabs)
585
586	// Create UI for each restored tab and add to tab bar
587	// Don't attach to content area yet - only the active tab should be attached
588	tabBar := a.mainWindow.TabBar()
589	for _, tab := range a.tabs.Tabs {
590		a.createWorkspaceViewWithoutAttach(ctx, tab)
591		if tabBar != nil {
592			tabBar.AddTab(tab)
593		}
594		log.Debug().
595			Str("tab_id", string(tab.ID)).
596			Str("name", tab.Name).
597			Int("panes", tab.Workspace.PaneCount()).
598			Msg("restored tab with workspace")
599	}
600
601	// Switch to active tab - this attaches the active workspace view to content area
602	activeTab := a.tabs.ActiveTab()
603	if activeTab != nil {
604		a.switchWorkspaceView(ctx, activeTab.ID)
605		if tabBar != nil {
606			tabBar.SetActive(activeTab.ID)
607		}
608	}
609
610	return nil
611}
612
613func (a *App) finalizeActivation(ctx context.Context) {
614	log := logging.FromContext(ctx)
615
616	// Show the window.
617	if a.mainWindow != nil {
618		a.mainWindow.Show()
619	}
620	log.Info().Msg("main window displayed")
621
622	// Start watching config for appearance changes.
623	a.initConfigWatcher(ctx)
624
625	// Ensure the current appearance config is applied once at startup.
626	// This avoids the WebUI showing default tokens until the first config change event.
627	if a.deps != nil {
628		a.applyAppearanceConfig(ctx, a.deps.Config)
629	}
630
631	// Start async filter loading after window is visible.
632	a.initFilteringAsync(ctx)
633}
634
635// onShutdown is called when the GTK application is shutting down.
636func (a *App) onShutdown(ctx context.Context) {
637	log := logging.FromContext(ctx)
638	log.Debug().Msg("GTK application shutting down")
639
640	// Save final session state before shutdown
641	if a.snapshotService != nil {
642		if err := a.snapshotService.Stop(ctx); err != nil {
643			log.Warn().Err(err).Msg("failed to save final session state")
644		}
645	}
646
647	// Apply staged update if available (before cleanup)
648	if a.updateCoord != nil {
649		if err := a.updateCoord.FinalizeOnExit(ctx); err != nil {
650			log.Warn().Err(err).Msg("failed to apply staged update")
651		}
652	}
653
654	// Cancel context to signal all goroutines
655	a.cancel(errors.New("application shutdown"))
656
657	// Cleanup resources
658	if a.faviconAdapter != nil {
659		a.faviconAdapter.Close()
660	}
661	if a.deps.Pool != nil {
662		a.deps.Pool.Close(ctx)
663	}
664	// Close idle inhibitor to release D-Bus connection
665	if a.deps.IdleInhibitor != nil {
666		if err := a.deps.IdleInhibitor.Close(); err != nil {
667			log.Warn().Err(err).Msg("failed to close idle inhibitor")
668		}
669	}
670
671	log.Info().Msg("application shutdown complete")
672}
673
674// initCoordinators initializes all coordinators and wires their callbacks.
675func (a *App) initCoordinators(ctx context.Context) {
676	log := logging.FromContext(ctx)
677	log.Debug().Msg("initializing coordinators")
678
679	// Helper to get active workspace and view
680	getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
681		return a.activeWorkspace(), a.activeWorkspaceView()
682	}
683
684	// Create FaviconAdapter with service and WebKit FaviconDatabase
685	var faviconDB *webkit.FaviconDatabase
686	if a.deps.WebContext != nil {
687		faviconDB = a.deps.WebContext.FaviconDatabase()
688	}
689	a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
690
691	// 1. Content Coordinator (no dependencies on other coordinators)
692	a.contentCoord = coordinator.NewContentCoordinator(
693		ctx,
694		a.pool,
695		a.widgetFactory,
696		a.faviconAdapter,
697		getActiveWS,
698		a.deps.ZoomUC,
699	)
700
701	// Set idle inhibitor for fullscreen video playback
702	if a.deps.IdleInhibitor != nil {
703		a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
704	}
705
706	// 2. Tab Coordinator
707	a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
708		TabsUC:     a.tabsUC,
709		Tabs:       a.tabs,
710		MainWindow: a.mainWindow,
711		Config:     a.deps.Config,
712	})
713	a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
714		a.createWorkspaceView(ctx, tab)
715	})
716	a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
717		a.switchWorkspaceView(ctx, tab.ID)
718	})
719	a.tabCoord.SetOnQuit(a.Quit)
720	a.tabCoord.SetOnStateChanged(a.MarkDirty)
721	// Wire popup tab WebView attachment
722	a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
723		a.attachPopupToTab(ctx, tabID, pane, wv)
724	})
725
726	// Wire tab bar click handling to coordinator
727	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
728		a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
729			if err := a.tabCoord.Switch(ctx, tabID); err != nil {
730				log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
731			}
732		})
733	}
734
735	// 3. Workspace Coordinator
736	a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
737		PanesUC:        a.panesUC,
738		FocusMgr:       a.focusMgr,
739		StackedPaneMgr: a.stackedPaneMgr,
740		WidgetFactory:  a.widgetFactory,
741		ContentCoord:   a.contentCoord,
742		GetActiveWS:    getActiveWS,
743		GenerateID:     a.generateID,
744	})
745	a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
746		return a.tabCoord.Close(ctx)
747	})
748	a.wsCoord.SetOnStateChanged(a.MarkDirty)
749
750	// Wire popup handling
751	a.webViewFactory = webkit.NewWebViewFactory(
752		a.deps.WebContext,
753		a.settings,
754		a.pool,
755		a.injector,
756		a.router,
757	)
758	// Set theme background color on factory to eliminate white flash
759	if a.deps.Theme != nil {
760		r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
761		a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
762	}
763	a.contentCoord.SetPopupConfig(
764		a.webViewFactory,
765		&a.deps.Config.Workspace.Popups,
766		a.generateID,
767	)
768	a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
769		return a.wsCoord.InsertPopup(ctx, input)
770	})
771	a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
772		return a.wsCoord.ClosePaneByID(ctx, paneID)
773	})
774	// Wire tabbed popup behavior to create new tabs
775	a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
776		// Create a new tab with the popup pane
777		tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
778		if err != nil {
779			return err
780		}
781		log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
782		return nil
783	})
784
785	// 4. Navigation Coordinator
786	a.navCoord = coordinator.NewNavigationCoordinator(
787		ctx,
788		a.deps.NavigateUC,
789		a.contentCoord,
790	)
791
792	// Wire title updates to history persistence
793	a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
794		a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
795	})
796
797	// Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
798	a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
799		a.navCoord.RecordHistory(ctx, paneID, url)
800	})
801
802	// Wire window title updates when active pane's title changes
803	a.contentCoord.SetOnWindowTitleChanged(func(title string) {
804		a.updateWindowTitle(title)
805	})
806
807	// Wire pane URI updates for session snapshots (searches all tabs)
808	a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
809		a.updatePaneURIInAllTabs(paneID, url)
810		// Mark dirty so snapshot captures the new URI
811		a.MarkDirty()
812	})
813
814	// 5. Keyboard Dispatcher
815	a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
816		ctx,
817		a.tabCoord,
818		a.wsCoord,
819		a.navCoord,
820		a.deps.ZoomUC,
821		a.deps.CopyURLUC,
822	)
823	a.kbDispatcher.SetOnQuit(a.Quit)
824	a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
825		a.ToggleFindBar(ctx)
826		return nil
827	})
828	a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
829		a.FindNext(ctx)
830		return nil
831	})
832	a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
833		a.FindPrevious(ctx)
834		return nil
835	})
836	a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
837		a.CloseFindBar(ctx)
838		return nil
839	})
840
841	// Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
842	a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
843		return a.kbDispatcher.Dispatch(ctx, action)
844	})
845
846	log.Debug().Msg("coordinators initialized")
847}
848
849// generateID generates a unique ID for tabs and panes.
850// Uses monotonically increasing counter to avoid ID collisions.
851func (a *App) generateID() string {
852	a.idMu.Lock()
853	defer a.idMu.Unlock()
854	a.idCounter++
855	return fmt.Sprintf("p%d", a.idCounter)
856}
857
858// Tabs returns the tab list.
859func (a *App) Tabs() *entity.TabList {
860	return a.tabs
861}
862
863// MainWindow returns the main window.
864func (a *App) MainWindow() *window.MainWindow {
865	return a.mainWindow
866}
867
868// Quit requests the application to quit.
869func (a *App) Quit() {
870	if a.gtkApp != nil {
871		a.gtkApp.Quit()
872	}
873}
874
875// RunWithArgs is a convenience function that creates and runs an App.
876func RunWithArgs(ctx context.Context, deps *Dependencies) int {
877	app, err := New(deps)
878	if err != nil {
879		log := logging.FromContext(ctx)
880		log.Error().Err(err).Msg("failed to create application")
881		return 1
882	}
883	return app.Run(ctx, os.Args)
884}
885
886// updateWindowTitle updates the window title with the given page title.
887// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
888func (a *App) updateWindowTitle(pageTitle string) {
889	if a.mainWindow == nil {
890		return
891	}
892
893	title := "Dumber"
894	if pageTitle != "" {
895		title = pageTitle + " - Dumber"
896	}
897	a.mainWindow.SetTitle(title)
898}
899
900// updateWindowTitleFromActivePane updates the window title based on the current active pane.
901func (a *App) updateWindowTitleFromActivePane() {
902	ws := a.activeWorkspace()
903	if ws == nil || a.contentCoord == nil {
904		a.updateWindowTitle("")
905		return
906	}
907	title := a.contentCoord.GetTitle(ws.ActivePaneID)
908	a.updateWindowTitle(title)
909}
910
911// handleModeChange is called when the input mode changes.
912func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
913	log := logging.FromContext(ctx)
914	log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
915
916	if from == input.ModeResize && to != input.ModeResize {
917		a.clearResizeModeBorder()
918	}
919	if to == input.ModeResize {
920		a.applyResizeModeBorder(ctx, a.activeWorkspace())
921	}
922
923	// Update global border overlay visibility based on mode.
924	// Note: resize mode border is handled per-pane (stack container), not via global overlay.
925	if a.borderMgr != nil {
926		a.borderMgr.OnModeChange(ctx, from, to)
927	}
928}
929
930func (a *App) clearResizeModeBorder() {
931	if a.resizeModeBorderTarget != nil {
932		a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
933		a.resizeModeBorderTarget = nil
934	}
935}
936
937func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
938	wsView := a.activeWorkspaceView()
939	if wsView == nil || ws == nil {
940		a.clearResizeModeBorder()
941		return
942	}
943
944	paneID := ws.ActivePaneID
945	if paneID == "" {
946		a.clearResizeModeBorder()
947		return
948	}
949
950	// Important: wrap the whole stack container when the active pane is in a stack.
951	target := wsView.GetStackContainerWidget(paneID)
952	if target == nil {
953		a.clearResizeModeBorder()
954		return
955	}
956
957	if !target.HasCssClass("resize-mode-active") {
958		target.AddCssClass("resize-mode-active")
959	}
960
961	if a.resizeModeBorderTarget != target {
962		if a.resizeModeBorderTarget != nil {
963			a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
964		} else {
965			logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
966		}
967		a.resizeModeBorderTarget = target
968	}
969}
970
971// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
972func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
973	a.createWorkspaceViewWithoutAttach(ctx, tab)
974
975	// Attach to content area
976	wsView := a.workspaceViews[tab.ID]
977	if wsView != nil && a.mainWindow != nil {
978		widget := wsView.Widget()
979		if widget != nil {
980			gtkWidget := widget.GtkWidget()
981			if gtkWidget != nil {
982				a.mainWindow.SetContent(gtkWidget)
983			}
984		}
985	}
986}
987
988// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
989// Used during session restoration where we create all views first, then attach only the active one.
990func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
991	log := logging.FromContext(ctx)
992
993	if a.widgetFactory == nil {
994		log.Error().Msg("widget factory not initialized")
995		return
996	}
997
998	// Create workspace view
999	wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
1000	if wsView == nil {
1001		log.Error().Msg("failed to create workspace view")
1002		return
1003	}
1004
1005	// Set the workspace
1006	if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
1007		log.Error().Err(err).Msg("failed to set workspace in view")
1008		return
1009	}
1010
1011	// Ensure WebViews are attached to panes
1012	if a.contentCoord != nil {
1013		a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1014	}
1015
1016	// Note: Mode borders for tab/pane/session are attached to MainWindow.
1017	// Resize mode border is attached to the active pane's stack container.
1018
1019	// Set omnibox config for this workspace view
1020	wsView.SetOmniboxConfig(a.omniboxCfg)
1021	// Set find bar config for this workspace view
1022	wsView.SetFindBarConfig(a.findBarCfg)
1023
1024	wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1025		if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
1026			ws := a.activeWorkspace()
1027			if ws != nil {
1028				ws.ActivePaneID = paneID
1029			}
1030			a.applyResizeModeBorder(ctx, ws)
1031		}
1032	})
1033
1034	wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
1035		if a.wsCoord != nil {
1036			_ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
1037		}
1038	})
1039
1040	// Store in map
1041	a.workspaceViews[tab.ID] = wsView
1042
1043	log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
1044}
1045
1046// activeWorkspace returns the workspace of the active tab.
1047func (a *App) activeWorkspace() *entity.Workspace {
1048	activeTab := a.tabs.ActiveTab()
1049	if activeTab == nil {
1050		return nil
1051	}
1052	return activeTab.Workspace
1053}
1054
1055// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
1056// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
1057func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
1058	for _, tab := range a.tabs.Tabs {
1059		if tab.Workspace == nil {
1060			continue
1061		}
1062		paneNode := tab.Workspace.FindPane(paneID)
1063		if paneNode != nil && paneNode.Pane != nil {
1064			paneNode.Pane.URI = url
1065			return // Pane IDs are unique, no need to continue
1066		}
1067	}
1068}
1069
1070// activeWorkspaceView returns the workspace view for the active tab.
1071func (a *App) activeWorkspaceView() *component.WorkspaceView {
1072	activeTab := a.tabs.ActiveTab()
1073	if activeTab == nil {
1074		return nil
1075	}
1076	return a.workspaceViews[activeTab.ID]
1077}
1078
1079// attachPopupToTab attaches a popup WebView to a newly created tab.
1080// This is called when a popup uses tabbed behavior.
1081func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1082	log := logging.FromContext(ctx)
1083
1084	wsView := a.workspaceViews[tabID]
1085	if wsView == nil {
1086		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
1087		return
1088	}
1089
1090	// Register WebView with content coordinator
1091	if a.contentCoord != nil {
1092		// Track the WebView
1093		a.contentCoord.RegisterPopupWebView(pane.ID, wv)
1094
1095		// Wrap and attach widget
1096		widget := a.contentCoord.WrapWidget(ctx, wv)
1097		if widget != nil {
1098			paneView := wsView.GetPaneView(pane.ID)
1099			if paneView != nil {
1100				paneView.SetWebViewWidget(widget)
1101			} else {
1102				log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
1103			}
1104		}
1105
1106		// Setup popup handling for nested popups
1107		a.contentCoord.SetupPopupHandling(ctx, pane.ID, wv)
1108	}
1109
1110	log.Debug().
1111		Str("tab_id", string(tabID)).
1112		Str("pane_id", string(pane.ID)).
1113		Msg("popup webview attached to tab")
1114}
1115
1116// switchWorkspaceView swaps the displayed workspace view for a tab.
1117func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
1118	log := logging.FromContext(ctx)
1119
1120	wsView, exists := a.workspaceViews[tabID]
1121	if !exists {
1122		log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
1123		return
1124	}
1125
1126	// Get the workspace view's root widget
1127	widget := wsView.Widget()
1128	if widget == nil {
1129		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
1130		return
1131	}
1132
1133	gtkWidget := widget.GtkWidget()
1134	if gtkWidget == nil {
1135		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
1136		return
1137	}
1138
1139	// Swap content (MainWindow.SetContent now properly removes old content)
1140	if a.mainWindow != nil {
1141		a.mainWindow.SetContent(gtkWidget)
1142	}
1143
1144	// Update window title with the new active pane's title
1145	a.updateWindowTitleFromActivePane()
1146
1147	log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
1148}
1149
1150// ToggleOmnibox implements OmniboxProvider.
1151// Toggles the omnibox visibility in the active workspace view.
1152func (a *App) ToggleOmnibox(ctx context.Context) {
1153	log := logging.FromContext(ctx)
1154
1155	wsView := a.activeWorkspaceView()
1156	if wsView == nil {
1157		log.Warn().Msg("no active workspace view for omnibox toggle")
1158		return
1159	}
1160
1161	if wsView.IsOmniboxVisible() {
1162		wsView.HideOmnibox()
1163	} else {
1164		wsView.ShowOmnibox(ctx, "")
1165	}
1166}
1167
1168// ToggleFindBar shows or hides the find bar in the active workspace view.
1169func (a *App) ToggleFindBar(ctx context.Context) {
1170	log := logging.FromContext(ctx)
1171
1172	wsView := a.activeWorkspaceView()
1173	if wsView == nil {
1174		log.Warn().Msg("no active workspace view for find bar toggle")
1175		return
1176	}
1177
1178	if wsView.IsFindBarVisible() {
1179		wsView.HideFindBar()
1180	} else {
1181		wsView.ShowFindBar(ctx)
1182	}
1183}
1184
1185// FindNext selects the next match in the active find bar.
1186func (a *App) FindNext(ctx context.Context) {
1187	wsView := a.activeWorkspaceView()
1188	if wsView == nil {
1189		return
1190	}
1191
1192	if !wsView.IsFindBarVisible() {
1193		wsView.ShowFindBar(ctx)
1194	}
1195	wsView.FindNext()
1196}
1197
1198// FindPrevious selects the previous match in the active find bar.
1199func (a *App) FindPrevious(ctx context.Context) {
1200	wsView := a.activeWorkspaceView()
1201	if wsView == nil {
1202		return
1203	}
1204
1205	if !wsView.IsFindBarVisible() {
1206		wsView.ShowFindBar(ctx)
1207	}
1208	wsView.FindPrevious()
1209}
1210
1211// CloseFindBar hides the find bar if visible.
1212func (a *App) CloseFindBar(ctx context.Context) {
1213	wsView := a.activeWorkspaceView()
1214	if wsView == nil {
1215		return
1216	}
1217	wsView.HideFindBar()
1218}
1219
1220// UpdateOmniboxZoom implements OmniboxProvider.
1221// Updates the zoom indicator on the current omnibox if visible.
1222func (a *App) UpdateOmniboxZoom(factor float64) {
1223	wsView := a.activeWorkspaceView()
1224	if wsView == nil {
1225		return
1226	}
1227
1228	omnibox := wsView.GetOmnibox()
1229	if omnibox != nil {
1230		omnibox.UpdateZoomIndicator(factor)
1231	}
1232}
1233
1234// SetOmniboxOnNavigate implements OmniboxProvider.
1235// This is called to set the navigation callback on new omniboxes.
1236// Since omniboxes are created per-pane, we store the config with the callback.
1237func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
1238	// The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox
1239	// The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
1240}
1241
1242// initFilteringAsync starts background filter loading with toast feedback.
1243func (a *App) initConfigWatcher(ctx context.Context) {
1244	log := logging.FromContext(ctx)
1245
1246	if a.configManager == nil {
1247		log.Debug().Msg("no config manager available, skipping watcher")
1248		return
1249	}
1250
1251	// Start viper watcher
1252	if err := a.configManager.Watch(); err != nil {
1253		log.Warn().Err(err).Msg("failed to start config watcher")
1254		return
1255	}
1256
1257	// Only appearance is hot-reloaded for now.
1258	a.configManager.OnConfigChange(func(newCfg *config.Config) {
1259		cfgCopy := newCfg
1260		cb := glib.SourceFunc(func(_ uintptr) bool {
1261			a.applyAppearanceConfig(ctx, cfgCopy)
1262			return false
1263		})
1264		glib.IdleAdd(&cb, 0)
1265	})
1266
1267	log.Debug().Msg("config watcher initialized")
1268}
1269
1270func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
1271	log := logging.FromContext(ctx)
1272	if cfg == nil {
1273		return
1274	}
1275
1276	// Update the shared config pointer in-place so existing references see changes.
1277	if a.deps != nil && a.deps.Config != nil {
1278		*a.deps.Config = *cfg
1279	}
1280
1281	// Update WebKit settings manager
1282	if a.settings != nil {
1283		a.settings.UpdateFromConfig(ctx, cfg)
1284	}
1285
1286	// Apply settings to existing webviews via coordinator
1287	if a.contentCoord != nil {
1288		a.contentCoord.ApplySettingsToAll(ctx, a.settings)
1289	}
1290
1291	// Update GTK theme and injected WebUI theme vars
1292	if a.deps != nil && a.deps.Theme != nil {
1293		var display *gdk.Display
1294		if a.mainWindow != nil && a.mainWindow.Window() != nil {
1295			display = a.mainWindow.Window().GetDisplay()
1296		}
1297		a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
1298
1299		// Keep injector's dark-mode flag in sync for future navigations
1300		if a.injector != nil {
1301			a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
1302			findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
1303			if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
1304				log.Warn().Err(err).Msg("failed to update find highlight CSS")
1305			}
1306		}
1307
1308		prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
1309		cssText := a.deps.Theme.GetWebUIThemeCSS()
1310		if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
1311			log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
1312		}
1313
1314		// Refresh injected scripts so future navigations use the latest theme.
1315		if a.contentCoord != nil && a.injector != nil {
1316			a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
1317		}
1318
1319		// Apply WebUI theme to already-loaded dumb:// pages
1320		if a.contentCoord != nil {
1321			a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
1322		}
1323	}
1324
1325	log.Info().Msg("appearance config updated")
1326}
1327
1328func (a *App) initFilteringAsync(ctx context.Context) {
1329	if a.deps.FilterManager == nil {
1330		return
1331	}
1332
1333	a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
1334		statusCopy := status // Capture for closure
1335		cb := glib.SourceFunc(func(_ uintptr) bool {
1336			a.showFilterStatus(ctx, statusCopy)
1337			return false // Don't repeat
1338		})
1339		glib.IdleAdd(&cb, 0)
1340	})
1341	a.deps.FilterManager.LoadAsync(ctx)
1342}
1343
1344// showFilterStatus displays toast notification for filter status.
1345func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
1346	log := logging.FromContext(ctx)
1347
1348	switch status.State {
1349	case filtering.StateLoading:
1350		if a.appToaster != nil {
1351			a.appToaster.Show(ctx, status.Message, component.ToastInfo)
1352		}
1353	case filtering.StateActive:
1354		// Apply filters to existing webviews that were created before filters loaded
1355		if a.contentCoord != nil && a.deps.FilterManager != nil {
1356			a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
1357			log.Debug().Msg("applied filters to all existing webviews")
1358		}
1359		if a.appToaster != nil {
1360			a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
1361		}
1362	case filtering.StateError:
1363		if a.appToaster != nil {
1364			a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
1365		}
1366	}
1367}

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