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}