1package ui
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/infrastructure/desktop"
16 "github.com/bnema/dumber/internal/infrastructure/filtering"
17 "github.com/bnema/dumber/internal/infrastructure/snapshot"
18 "github.com/bnema/dumber/internal/infrastructure/textinput"
19 "github.com/bnema/dumber/internal/infrastructure/webkit"
20
21 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
22 "github.com/bnema/dumber/internal/logging"
23 "github.com/bnema/dumber/internal/ui/adapter"
24 "github.com/bnema/dumber/internal/ui/component"
25 "github.com/bnema/dumber/internal/ui/coordinator"
26 "github.com/bnema/dumber/internal/ui/dispatcher"
27 "github.com/bnema/dumber/internal/ui/focus"
28 "github.com/bnema/dumber/internal/ui/input"
29 "github.com/bnema/dumber/internal/ui/layout"
30 "github.com/bnema/dumber/internal/ui/theme"
31 "github.com/bnema/dumber/internal/ui/window"
32 "github.com/jwijenbergh/puregotk/v4/adw"
33 "github.com/jwijenbergh/puregotk/v4/gdk"
34 "github.com/jwijenbergh/puregotk/v4/gio"
35 "github.com/jwijenbergh/puregotk/v4/glib"
36 "github.com/jwijenbergh/puregotk/v4/gtk"
37)
38
39const (
40 // AppID is the application identifier for GTK.
41 AppID = "com.github.bnema.dumber"
42)
43
44// App wraps the GTK Application and manages the browser lifecycle.
45type App struct {
46 deps *Dependencies
47 gtkApp *gtk.Application
48 mainWindow *window.MainWindow
49
50 // State
51 tabs *entity.TabList
52 tabsUC *usecase.ManageTabsUseCase
53
54 // Coordinators (new architecture)
55 contentCoord *coordinator.ContentCoordinator
56 tabCoord *coordinator.TabCoordinator
57 wsCoord *coordinator.WorkspaceCoordinator
58 navCoord *coordinator.NavigationCoordinator
59 kbDispatcher *dispatcher.KeyboardDispatcher
60 configManager *config.Manager
61
62 // Pane management (used by coordinators)
63 panesUC *usecase.ManagePanesUseCase
64 workspaceViews map[entity.TabID]*component.WorkspaceView
65 widgetFactory layout.WidgetFactory
66 stackedPaneMgr *component.StackedPaneManager
67
68 // Input handling
69 keyboardHandler *input.KeyboardHandler
70 globalShortcutHandler *input.GlobalShortcutHandler
71
72 // Focus and border management
73 focusMgr *focus.Manager
74 borderMgr *focus.BorderManager
75
76 resizeModeBorderTarget layout.Widget
77
78 // Omnibox configuration (omnibox is created per workspace view)
79 omniboxCfg component.OmniboxConfig
80 // Find bar configuration (find bar is created per workspace view)
81 findBarCfg component.FindBarConfig
82
83 // Web content (managed by ContentCoordinator)
84 pool *webkit.WebViewPool
85 webViewFactory *webkit.WebViewFactory
86 injector *webkit.ContentInjector
87 router *webkit.MessageRouter
88 settings *webkit.SettingsManager
89 faviconAdapter *adapter.FaviconAdapter
90
91 // App-level toaster for system notifications (filter status, etc.)
92 appToaster *component.Toaster
93 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
94 modeToaster *component.Toaster
95
96 // Session management
97 sessionManager *component.SessionManager
98 tabPicker *component.TabPicker
99 tabPickerWidget layout.Widget
100 tabPickerPaneID entity.PaneID
101 snapshotService *snapshot.Service
102
103 // Update management
104 updateCoord *coordinator.UpdateCoordinator
105
106 // ID generator for tabs/panes
107 idCounter uint64
108 idMu sync.Mutex
109 firstWebViewShownOnce sync.Once
110
111 movePaneToTabUC *usecase.MovePaneToTabUseCase
112
113 // Accent picker for dead keys support
114 accentPicker *component.AccentPicker
115 insertAccentUC *usecase.InsertAccentUseCase
116 accentFocusProvider *textinput.FocusProvider
117
118 // lifecycle
119 cancel context.CancelCauseFunc
120}
121
122// New creates a new App with the given dependencies.
123func New(deps *Dependencies) (*App, error) {
124 if err := deps.Validate(); err != nil {
125 return nil, err
126 }
127
128 ctx, cancel := context.WithCancelCause(deps.Ctx)
129
130 app := &App{
131 deps: deps,
132 tabs: entity.NewTabList(),
133 tabsUC: deps.TabsUC,
134 panesUC: deps.PanesUC,
135 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
136 pool: deps.Pool,
137 injector: deps.Injector,
138 router: deps.MessageRouter,
139 settings: deps.Settings,
140 configManager: config.GetManager(),
141 cancel: cancel,
142 }
143 if app.router == nil {
144 app.router = webkit.NewMessageRouter(ctx)
145 }
146
147 // Register message handlers
148 if app.router != nil {
149 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
150 HistoryUC: deps.HistoryUC,
151 FavoritesUC: deps.FavoritesUC,
152 Clipboard: deps.Clipboard,
153 ConfigGetter: config.Get,
154 OnClipboardCopied: func(textLen int) {
155 // Show brief toast on auto-copy (similar to zellij footer notification)
156 // Must schedule on GTK main thread since this is called from WebKit handler
157 cb := glib.SourceFunc(func(_ uintptr) bool {
158 if app.appToaster != nil {
159 app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
160 component.WithDuration(component.ToastBriefDurationMs),
161 component.WithPosition(component.ToastPositionBottomRight),
162 )
163 }
164 return false
165 })
166 glib.IdleAdd(&cb, 0)
167 },
168 }); err != nil {
169 return nil, err
170 }
171 }
172
173 return app, nil
174}
175
176// Run starts the GTK application and blocks until it exits.
177// Returns the exit code.
178func (a *App) Run(ctx context.Context, args []string) int {
179 log := logging.FromContext(ctx)
180 log.Debug().Msg("creating GTK application")
181
182 // Initialize libadwaita once (required before using StyleManager).
183 // This also initializes GTK implicitly.
184 adw.Init()
185
186 // Mark adwaita detector as available now that adw.Init() is complete.
187 // This enables the highest-priority color scheme detector.
188 if a.deps != nil && a.deps.AdwaitaDetector != nil {
189 a.deps.AdwaitaDetector.MarkAvailable()
190 log.Debug().Msg("adwaita detector marked available")
191
192 // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
193 // They have the wrong dark mode preference injected; re-inject with correct value.
194 if a.pool != nil {
195 a.pool.RefreshScripts(ctx)
196 }
197 }
198
199 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
200 a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
201 if a.gtkApp == nil {
202 log.Error().Msg("failed to create GTK application")
203 return 1
204 }
205 defer a.gtkApp.Unref()
206
207 // Connect activate signal
208 activateCb := func(_ gio.Application) {
209 a.onActivate(ctx)
210 }
211 a.gtkApp.ConnectActivate(&activateCb)
212
213 // Connect shutdown signal
214 shutdownCb := func(_ gio.Application) {
215 a.onShutdown(ctx)
216 }
217 a.gtkApp.ConnectShutdown(&shutdownCb)
218
219 log.Info().Msg("starting GTK main loop")
220 code := a.gtkApp.Run(len(args), args)
221
222 return code
223}
224
225// onActivate is called when the GTK application is activated.
226func (a *App) onActivate(ctx context.Context) {
227 log := logging.FromContext(ctx)
228 log.Debug().Msg("GTK application activated")
229
230 a.applyGTKColorSchemePreference(ctx)
231 // Configure pool background color early (prevents white flash), but avoid
232 // synchronous prewarming that delays the first navigation on cold start.
233 a.setupPoolBackgroundColor(ctx)
234
235 if err := a.createMainWindow(ctx); err != nil {
236 log.Error().Err(err).Msg("failed to create main window")
237 return
238 }
239
240 a.initLayoutInfrastructure()
241 a.initAppToasterOverlay()
242 a.initFocusAndBorderOverlay()
243 a.initAccentPicker(ctx)
244 a.initDownloadHandler(ctx)
245
246 a.initCoordinators(ctx)
247 a.initKeyboardHandler(ctx)
248 a.initOmniboxConfig(ctx)
249 a.initFindBarConfig()
250 a.initSessionManager(ctx)
251 a.initTabPicker(ctx)
252 a.initSnapshotService(ctx)
253 a.initUpdateCoordinator(ctx)
254 a.createInitialTab(ctx)
255 // Prewarm the remaining WebViews after the initial tab starts loading.
256 a.prewarmWebViewPoolAsync(ctx)
257 a.finalizeActivation(ctx)
258}
259
260func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
261 log := logging.FromContext(ctx)
262
263 // Get the config color scheme preference (default follows system theme)
264 scheme := "default"
265 if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
266 scheme = a.deps.Config.Appearance.ColorScheme
267 }
268
269 // Apply color scheme via libadwaita's StyleManager.
270 // This properly communicates the preference to GTK and WebKit,
271 // so web content can correctly evaluate prefers-color-scheme media queries.
272 styleMgr := adw.StyleManagerGetDefault()
273 if styleMgr == nil {
274 log.Warn().Msg("failed to get adw.StyleManager")
275 return
276 }
277
278 var adwScheme adw.ColorScheme
279 switch scheme {
280 case "dark", "prefer-dark":
281 adwScheme = adw.ColorSchemeForceDarkValue
282 case "light", "prefer-light":
283 adwScheme = adw.ColorSchemeForceLightValue
284 default:
285 // "default" or empty - follow system preference
286 adwScheme = adw.ColorSchemeDefaultValue
287 }
288
289 styleMgr.SetColorScheme(adwScheme)
290
291 // Refresh the color resolver now that adwaita detector is available.
292 // This ensures all components get the correct preference.
293 var pref string
294 var prefersDark bool
295 if a.deps != nil && a.deps.ColorResolver != nil {
296 result := a.deps.ColorResolver.Refresh()
297 prefersDark = result.PrefersDark
298 pref = result.Source
299 } else {
300 prefersDark = styleMgr.GetDark()
301 pref = "adw.StyleManager"
302 }
303
304 log.Debug().
305 Str("scheme", scheme).
306 Bool("prefers_dark", prefersDark).
307 Str("source", pref).
308 Msg("applied color scheme via adw.StyleManager")
309}
310
311func (a *App) setupPoolBackgroundColor(ctx context.Context) {
312 log := logging.FromContext(ctx)
313
314 // Set theme background color on pool to eliminate white flash.
315 // Must be done before any WebView creation so WebViews get the correct color.
316 if a.pool == nil {
317 log.Debug().Msg("webview pool not available; skipping background color setup")
318 return
319 }
320 if a.deps == nil || a.deps.Theme == nil {
321 log.Debug().Msg("theme not available; skipping webview pool background color setup")
322 return
323 }
324
325 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
326 a.pool.SetBackgroundColor(r, g, b, alpha)
327 log.Debug().Msg("configured webview pool background color")
328}
329
330func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
331 // Prewarm WebView pool after startup so cold-start navigation is not blocked.
332 if a.pool == nil {
333 return
334 }
335 a.pool.PrewarmAsync(ctx, 0)
336}
337
338func (a *App) createMainWindow(ctx context.Context) error {
339 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
340 if err != nil {
341 return err
342 }
343 a.mainWindow = mainWindow
344
345 // Apply GTK CSS styling from theme manager.
346 if a.deps == nil || a.deps.Theme == nil {
347 return nil
348 }
349 if display := a.mainWindow.Window().GetDisplay(); display != nil {
350 a.deps.Theme.ApplyToDisplay(ctx, display)
351 }
352 return nil
353}
354
355func (a *App) initLayoutInfrastructure() {
356 // Initialize widget factory for pane layout.
357 a.widgetFactory = layout.NewGtkWidgetFactory()
358
359 // Initialize stacked pane manager for incremental widget operations.
360 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
361}
362
363func (a *App) initAppToasterOverlay() {
364 if a.mainWindow == nil {
365 return
366 }
367
368 // Create app-level toaster for system notifications.
369 a.appToaster = component.NewToaster(a.widgetFactory)
370 toasterWidget := a.appToaster.Widget()
371 if toasterWidget == nil {
372 return
373 }
374 gtkWidget := toasterWidget.GtkWidget()
375 if gtkWidget == nil {
376 return
377 }
378 a.mainWindow.AddOverlay(gtkWidget)
379
380 // Create mode indicator toaster for modal mode notifications.
381 a.modeToaster = component.NewToaster(a.widgetFactory)
382 modeToasterWidget := a.modeToaster.Widget()
383 if modeToasterWidget == nil {
384 return
385 }
386 modeGtkWidget := modeToasterWidget.GtkWidget()
387 if modeGtkWidget == nil {
388 return
389 }
390 a.mainWindow.AddOverlay(modeGtkWidget)
391}
392
393func (a *App) initFocusAndBorderOverlay() {
394 // Initialize focus manager for geometric navigation.
395 a.focusMgr = focus.NewManager(a.panesUC)
396
397 // Initialize border manager for mode indicators.
398 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
399
400 // Attach border overlay to main window (visible for all tabs).
401 if a.borderMgr == nil || a.mainWindow == nil {
402 return
403 }
404 borderWidget := a.borderMgr.Widget()
405 if borderWidget == nil {
406 return
407 }
408 gtkWidget := borderWidget.GtkWidget()
409 if gtkWidget == nil {
410 return
411 }
412 a.mainWindow.AddOverlay(gtkWidget)
413}
414
415func (a *App) initAccentPicker(ctx context.Context) {
416 log := logging.FromContext(ctx)
417
418 if a.widgetFactory == nil || a.mainWindow == nil {
419 log.Debug().Msg("widget factory or main window not available, skipping accent picker")
420 return
421 }
422
423 // Create accent picker component
424 a.accentPicker = component.NewAccentPicker(a.widgetFactory)
425 if a.accentPicker == nil {
426 log.Warn().Msg("failed to create accent picker")
427 return
428 }
429
430 // Add to main window overlay
431 pickerWidget := a.accentPicker.Widget()
432 if pickerWidget != nil {
433 gtkWidget := pickerWidget.GtkWidget()
434 if gtkWidget != nil {
435 a.mainWindow.AddOverlay(gtkWidget)
436 }
437 }
438
439 // Create focus provider (tracks which input has focus)
440 a.accentFocusProvider = textinput.NewFocusProvider()
441
442 // Create the use case with glib.IdleAdd for thread-safe GTK calls
443 a.insertAccentUC = usecase.NewInsertAccentUseCase(
444 a.accentFocusProvider,
445 a.accentPicker,
446 func(fn func()) {
447 cb := glib.SourceFunc(func(_ uintptr) bool {
448 fn()
449 return false // Don't repeat
450 })
451 glib.IdleAdd(&cb, 0)
452 },
453 )
454
455 log.Debug().Msg("accent picker initialized")
456}
457
458func (a *App) initDownloadHandler(ctx context.Context) {
459 log := logging.FromContext(ctx)
460
461 if a.deps == nil || a.deps.WebContext == nil {
462 log.Debug().Msg("WebContext not available, skipping download handler")
463 return
464 }
465
466 // Determine download path from config, fallback to XDG.
467 downloadPath := ""
468 if a.deps.Config != nil {
469 downloadPath = a.deps.Config.Downloads.Path
470 }
471 if downloadPath == "" && a.deps.XDG != nil {
472 var err error
473 downloadPath, err = a.deps.XDG.DownloadDir()
474 if err != nil {
475 log.Warn().Err(err).Msg("failed to get XDG download dir")
476 }
477 }
478 if downloadPath == "" {
479 // Final fallback to ~/Downloads, with /tmp as last resort.
480 home, err := os.UserHomeDir()
481 if err != nil {
482 log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
483 downloadPath = "/tmp"
484 } else {
485 downloadPath = filepath.Join(home, "Downloads")
486 }
487 }
488
489 // Create download event adapter to show toasts.
490 eventAdapter := &downloadEventAdapter{app: a}
491
492 // Create and wire the download handler.
493 handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
494 a.deps.WebContext.SetDownloadHandler(ctx, handler)
495
496 log.Info().Str("path", downloadPath).Msg("download handler initialized")
497}
498
499// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
500type downloadEventAdapter struct {
501 app *App
502}
503
504func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
505 // Must schedule on GTK main thread.
506 cb := glib.SourceFunc(func(_ uintptr) bool {
507 if d.app.appToaster == nil {
508 return false
509 }
510
511 switch event.Type {
512 case port.DownloadEventStarted:
513 d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
514 case port.DownloadEventFinished:
515 d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
516 case port.DownloadEventFailed:
517 d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
518 }
519 return false
520 })
521 glib.IdleAdd(&cb, 0)
522}
523
524func (a *App) initKeyboardHandler(ctx context.Context) {
525 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
526 return
527 }
528
529 // Create keyboard handler and wire to dispatcher.
530 a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
531 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
532 return a.kbDispatcher.Dispatch(ctx, action)
533 })
534 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
535 a.handleModeChange(ctx, from, to)
536 })
537 a.keyboardHandler.SetShouldBypassInput(func() bool {
538 // Bypass keyboard handler when modals are visible
539 if a.sessionManager != nil && a.sessionManager.IsVisible() {
540 return true
541 }
542 if a.tabPicker != nil && a.tabPicker.IsVisible() {
543 return true
544 }
545 wsView := a.activeWorkspaceView()
546 if wsView == nil {
547 return false
548 }
549 return wsView.IsOmniboxVisible()
550 })
551 // Wire accent handler for dead keys support
552 if a.insertAccentUC != nil {
553 a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
554 }
555 a.keyboardHandler.AttachTo(a.mainWindow.Window())
556
557 // Create global shortcut handler for Alt+1-9 tab switching.
558 // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
559 // to intercept shortcuts even when WebView has focus.
560 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
561 ctx,
562 a.mainWindow.Window(),
563 a.deps.Config,
564 func(ctx context.Context, action input.Action) error {
565 return a.kbDispatcher.Dispatch(ctx, action)
566 },
567 )
568}
569
570func (a *App) initOmniboxConfig(ctx context.Context) {
571 if a.deps == nil || a.deps.Config == nil {
572 return
573 }
574
575 log := logging.FromContext(ctx)
576
577 // Convert config shortcuts to use case type
578 shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))
579 for key, shortcut := range a.deps.Config.SearchShortcuts {
580 shortcuts[key] = usecase.SearchShortcut{
581 URL: shortcut.URL,
582 Description: shortcut.Description,
583 }
584 }
585 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
586
587 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
588 a.omniboxCfg = component.OmniboxConfig{
589 HistoryUC: a.deps.HistoryUC,
590 FavoritesUC: a.deps.FavoritesUC,
591 FaviconAdapter: a.faviconAdapter,
592 CopyURLUC: a.deps.CopyURLUC,
593 ShortcutsUC: shortcutsUC,
594 DefaultSearch: a.deps.Config.DefaultSearchEngine,
595 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
596 UIScale: a.deps.Config.DefaultUIScale,
597 OnNavigate: func(url string) {
598 if err := a.navCoord.Navigate(ctx, url); err != nil {
599 log.Error().Err(err).Str("url", url).Msg("navigation failed")
600 }
601 },
602 OnFocusIn: func(entry *gtk.SearchEntry) {
603 // Set omnibox entry as the focused input for accent picker
604 if a.accentFocusProvider != nil && entry != nil {
605 a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
606 }
607 },
608 OnFocusOut: func() {
609 // When omnibox loses focus, set WebView as the focused input
610 if a.accentFocusProvider != nil {
611 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
612 }
613 },
614 }
615 a.navCoord.SetOmniboxProvider(a)
616 log.Debug().Msg("omnibox config stored, provider set")
617}
618
619func (a *App) initFindBarConfig() {
620 // Store find bar config (find bar is created per-pane via WorkspaceView).
621 a.findBarCfg = component.FindBarConfig{
622 GetFindController: func(paneID entity.PaneID) port.FindController {
623 if a.contentCoord == nil {
624 return nil
625 }
626 wv := a.contentCoord.GetWebView(paneID)
627 if wv == nil {
628 return nil
629 }
630 return wv.GetFindController()
631 },
632 OnFocusIn: func(entry *gtk.SearchEntry) {
633 // Set find bar entry as the focused input for accent picker
634 if a.accentFocusProvider != nil && entry != nil {
635 a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
636 }
637 },
638 OnFocusOut: func() {
639 // When find bar loses focus, set WebView as the focused input
640 if a.accentFocusProvider != nil {
641 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
642 }
643 },
644 }
645}
646
647func (a *App) initSessionManager(ctx context.Context) {
648 log := logging.FromContext(ctx)
649
650 if a.deps == nil {
651 log.Debug().Msg("deps not available, skipping session manager")
652 return
653 }
654
655 // Session manager can work without a repo - it just shows an empty list
656 var listSessionsUC *usecase.ListSessionsUseCase
657 var deleteSessionUC *usecase.DeleteSessionUseCase
658 if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
659 listSessionsUC = usecase.NewListSessionsUseCase(
660 a.deps.SessionRepo,
661 a.deps.SessionStateRepo,
662 a.deps.Config.Logging.LogDir,
663 )
664 deleteSessionUC = usecase.NewDeleteSessionUseCase(
665 a.deps.SessionStateRepo,
666 a.deps.SessionRepo,
667 a.deps.Config.Logging.LogDir,
668 )
669 }
670
671 // Create session spawner for restoration
672 spawner := desktop.NewSessionSpawner(ctx)
673
674 // Create session manager component
675 a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
676 ListSessionsUC: listSessionsUC,
677 DeleteSessionUC: deleteSessionUC,
678 CurrentSession: a.deps.CurrentSessionID,
679 UIScale: a.deps.Config.DefaultUIScale,
680 OnClose: func() {
681 log.Debug().Msg("session manager closed")
682 },
683 OnOpen: func(sessionID entity.SessionID) {
684 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
685 if err := spawner.SpawnWithSession(sessionID); err != nil {
686 log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
687 }
688 },
689 OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
690 if a.appToaster != nil {
691 a.appToaster.Show(ctx, message, level)
692 }
693 },
694 })
695
696 if a.sessionManager == nil {
697 log.Warn().Msg("failed to create session manager")
698 return
699 }
700
701 // Add session manager to main window overlay
702 if a.mainWindow != nil {
703 widget := a.sessionManager.Widget()
704 if widget != nil {
705 a.mainWindow.AddOverlay(widget)
706 }
707 }
708
709 // Wire session manager action to keyboard dispatcher
710 a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
711 a.ToggleSessionManager(ctx)
712 return nil
713 })
714
715 log.Debug().Msg("session manager initialized")
716}
717
718// ToggleSessionManager shows or hides the session manager.
719func (a *App) ToggleSessionManager(ctx context.Context) {
720 if a.sessionManager == nil {
721 return
722 }
723 a.sessionManager.Toggle(ctx)
724}
725
726func (a *App) attachTabPickerToActivePane() {
727 if a.tabPicker == nil || a.widgetFactory == nil {
728 return
729 }
730 wsView := a.activeWorkspaceView()
731 if wsView == nil {
732 return
733 }
734
735 activeTab := a.tabs.ActiveTab()
736 if activeTab == nil || activeTab.Workspace == nil {
737 return
738 }
739 activePaneID := activeTab.Workspace.ActivePaneID
740 if activePaneID == "" {
741 return
742 }
743 pv := wsView.GetPaneView(activePaneID)
744 if pv == nil {
745 return
746 }
747
748 if a.tabPickerWidget == nil {
749 a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
750 if a.tabPickerWidget == nil {
751 return
752 }
753 }
754
755 // If currently attached to a different pane overlay, detach.
756 if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
757 for _, view := range a.workspaceViews {
758 if view == nil {
759 continue
760 }
761 if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
762 if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
763 oldPV.RemoveOverlayWidget(a.tabPickerWidget)
764 } else if parent != nil {
765 a.tabPickerWidget.Unparent()
766 }
767 break
768 }
769 }
770 }
771
772 // Ensure the widget can be reparented.
773 if parent := a.tabPickerWidget.GetParent(); parent != nil {
774 a.tabPickerWidget.Unparent()
775 }
776
777 a.tabPicker.SetParentOverlay(pv.Overlay())
778 pv.AddOverlayWidget(a.tabPickerWidget)
779 a.tabPickerPaneID = activePaneID
780}
781
782func (a *App) HandleMovePaneToTab(ctx context.Context) error {
783 if a.movePaneToTabUC == nil {
784 return nil
785 }
786 if a.tabPicker == nil {
787 return nil
788 }
789
790 sourceTab := a.tabs.ActiveTab()
791 if sourceTab == nil || sourceTab.Workspace == nil {
792 return nil
793 }
794
795 // If there is only 1 tab, auto-create a new tab and move there.
796 if a.tabs.Count() <= 1 {
797 return a.MoveActivePaneToTab(ctx, "")
798 }
799
800 items := make([]component.TabPickerItem, 0, a.tabs.Count())
801 for _, tab := range a.tabs.Tabs {
802 if tab == nil {
803 continue
804 }
805 if tab.ID == sourceTab.ID {
806 continue
807 }
808 items = append(items, component.TabPickerItem{
809 TabID: tab.ID,
810 Title: tab.Title(),
811 IsNew: false,
812 Index: tab.Position,
813 })
814 }
815 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
816
817 a.attachTabPickerToActivePane()
818 a.tabPicker.Show(ctx, items)
819 return nil
820}
821
822func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
823 if a.movePaneToTabUC == nil {
824 return nil
825 }
826 active := a.tabs.ActiveTab()
827 if active == nil {
828 return nil
829 }
830
831 nextPos := active.Position + 1
832 if nextPos >= a.tabs.Count() {
833 // Create a new tab on the right.
834 return a.MoveActivePaneToTab(ctx, "")
835 }
836 next := a.tabs.TabAt(nextPos)
837 if next == nil {
838 return nil
839 }
840 return a.MoveActivePaneToTab(ctx, next.ID)
841}
842
843func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
844 in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
845 if in == nil {
846 return nil
847 }
848
849 out, err := a.movePaneToTabUC.Execute(*in)
850 if err != nil {
851 return err
852 }
853 if out == nil || out.TargetTab == nil {
854 return nil
855 }
856
857 a.applyMovePaneToTabUI(ctx, out, sourceTab)
858 a.switchToTargetTabIfConfigured(ctx, out)
859 a.updateTabBarVisibility(ctx)
860 a.MarkDirty()
861 return nil
862}
863
864func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
865 if a.movePaneToTabUC == nil {
866 return nil, nil
867 }
868 activeTab := a.tabs.ActiveTab()
869 if activeTab == nil || activeTab.Workspace == nil {
870 return nil, nil
871 }
872 sourcePaneID := activeTab.Workspace.ActivePaneID
873 if sourcePaneID == "" {
874 return nil, nil
875 }
876
877 in := &usecase.MovePaneToTabInput{
878 TabList: a.tabs,
879 SourceTabID: activeTab.ID,
880 SourcePaneID: sourcePaneID,
881 TargetTabID: targetTabID,
882 }
883 return in, activeTab
884}
885
886func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
887 if out == nil || out.TargetTab == nil {
888 return
889 }
890 sourceTabID := entity.TabID("")
891 if sourceTab != nil {
892 sourceTabID = sourceTab.ID
893 }
894
895 if out.SourceTabClosed {
896 a.removeSourceTabUI(sourceTabID)
897 }
898 if out.NewTabCreated {
899 a.ensureTargetTabUI(ctx, out.TargetTab)
900 }
901
902 if !out.SourceTabClosed {
903 a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
904 }
905 a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
906}
907
908func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
909 delete(a.workspaceViews, sourceTabID)
910 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
911 a.mainWindow.TabBar().RemoveTab(sourceTabID)
912 }
913}
914
915func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
916 if tab == nil {
917 return
918 }
919 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
920 a.mainWindow.TabBar().AddTab(tab)
921 }
922 a.createWorkspaceViewWithoutAttach(ctx, tab)
923}
924
925func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
926 wsView := a.workspaceViews[tabID]
927 if wsView == nil {
928 return
929 }
930 _ = wsView.Rebuild(ctx)
931
932 if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
933 return
934 }
935 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
936 if a.wsCoord != nil {
937 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
938 }
939}
940
941func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
942 if out == nil || out.TargetTab == nil {
943 return
944 }
945
946 switchToTarget := config.Get().Workspace.SwitchToTabOnMove
947 if out.SourceTabClosed {
948 switchToTarget = true
949 }
950 if !switchToTarget {
951 return
952 }
953
954 a.tabs.SetActive(out.TargetTab.ID)
955 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
956 a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
957 }
958 a.switchWorkspaceView(ctx, out.TargetTab.ID)
959}
960
961func (a *App) updateTabBarVisibility(ctx context.Context) {
962 if a.tabCoord != nil {
963 a.tabCoord.UpdateBarVisibility(ctx)
964 }
965}
966
967func (a *App) initTabPicker(ctx context.Context) {
968 log := logging.FromContext(ctx)
969
970 if a.deps == nil || a.deps.Config == nil {
971 log.Debug().Msg("deps/config not available, skipping tab picker")
972 return
973 }
974
975 a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
976 UIScale: a.deps.Config.DefaultUIScale,
977 OnClose: func() {
978 log.Debug().Msg("tab picker closed")
979 },
980 OnSelect: func(item component.TabPickerItem) {
981 cb := glib.SourceFunc(func(_ uintptr) bool {
982 var targetID entity.TabID
983 if !item.IsNew {
984 targetID = item.TabID
985 }
986 if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
987 log.Warn().Err(err).Msg("move pane to tab failed")
988 }
989 return false
990 })
991 glib.IdleAdd(&cb, 0)
992 },
993 })
994
995 if a.tabPicker == nil {
996 log.Warn().Msg("failed to create tab picker")
997 return
998 }
999
1000 log.Debug().Msg("tab picker initialized")
1001}
1002
1003func (a *App) initSnapshotService(ctx context.Context) {
1004 log := logging.FromContext(ctx)
1005
1006 if a.deps == nil || a.deps.SnapshotUC == nil {
1007 log.Debug().Msg("snapshot use case not available, skipping snapshot service")
1008 return
1009 }
1010
1011 intervalMs := 5000 // default
1012 if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
1013 intervalMs = a.deps.Config.Session.SnapshotIntervalMs
1014 }
1015
1016 a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
1017 a.snapshotService.Start(ctx)
1018
1019 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
1020}
1021
1022func (a *App) initUpdateCoordinator(ctx context.Context) {
1023 log := logging.FromContext(ctx)
1024
1025 if a.deps == nil || a.deps.CheckUpdateUC == nil {
1026 log.Debug().Msg("update use cases not available, skipping update coordinator")
1027 return
1028 }
1029
1030 a.updateCoord = coordinator.NewUpdateCoordinator(
1031 a.deps.CheckUpdateUC,
1032 a.deps.ApplyUpdateUC,
1033 a.appToaster,
1034 a.deps.Config,
1035 )
1036
1037 // Start async update check
1038 a.updateCoord.CheckOnStartup(ctx)
1039
1040 log.Debug().Msg("update coordinator initialized")
1041}
1042
1043// GetTabList implements port.TabListProvider.
1044func (a *App) GetTabList() *entity.TabList {
1045 return a.tabs
1046}
1047
1048// GetSessionID implements port.TabListProvider.
1049func (a *App) GetSessionID() entity.SessionID {
1050 if a.deps == nil {
1051 return ""
1052 }
1053 return a.deps.CurrentSessionID
1054}
1055
1056// MarkDirty signals that session state has changed and should be saved.
1057func (a *App) MarkDirty() {
1058 if a.snapshotService != nil {
1059 a.snapshotService.MarkDirty()
1060 }
1061}
1062
1063func (a *App) createInitialTab(ctx context.Context) {
1064 log := logging.FromContext(ctx)
1065
1066 // Check if we should restore a session
1067 if a.deps != nil && a.deps.RestoreSessionID != "" {
1068 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
1069 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
1070 // Show error toast and fall through to create default tab
1071 if a.appToaster != nil {
1072 a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1073 }
1074 } else {
1075 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
1076 // Show success toast for session restoration
1077 if a.appToaster != nil {
1078 a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1079 }
1080 return
1081 }
1082 }
1083
1084 // Create an initial tab using coordinator.
1085 initialURL := "dumb://home"
1086 if a.deps != nil && a.deps.InitialURL != "" {
1087 initialURL = a.deps.InitialURL
1088 }
1089 if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
1090 log.Error().Err(err).Msg("failed to create initial tab")
1091 }
1092}
1093
1094func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1095 log := logging.FromContext(ctx)
1096
1097 if a.deps == nil || a.deps.SessionStateRepo == nil {
1098 return fmt.Errorf("session state repo not available")
1099 }
1100
1101 // Load session state
1102 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
1103 if err != nil {
1104 return fmt.Errorf("load session state: %w", err)
1105 }
1106 if state == nil {
1107 return fmt.Errorf("session state not found")
1108 }
1109
1110 log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
1111
1112 // Build complete TabList from snapshot (state-first approach)
1113 // This reconstructs the full pane tree with new IDs
1114 restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
1115 if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
1116 return fmt.Errorf("failed to build tab list from snapshot")
1117 }
1118
1119 // Replace tabs in-place to preserve references held by TabCoordinator
1120 a.tabs.ReplaceFrom(restoredTabs)
1121
1122 // Create UI for each restored tab and add to tab bar
1123 // Don't attach to content area yet - only the active tab should be attached
1124 tabBar := a.mainWindow.TabBar()
1125 for _, tab := range a.tabs.Tabs {
1126 a.createWorkspaceViewWithoutAttach(ctx, tab)
1127 if tabBar != nil {
1128 tabBar.AddTab(tab)
1129 }
1130 log.Debug().
1131 Str("tab_id", string(tab.ID)).
1132 Str("name", tab.Name).
1133 Int("panes", tab.Workspace.PaneCount()).
1134 Msg("restored tab with workspace")
1135 }
1136
1137 // Switch to active tab - this attaches the active workspace view to content area
1138 activeTab := a.tabs.ActiveTab()
1139 if activeTab != nil {
1140 a.switchWorkspaceView(ctx, activeTab.ID)
1141 if tabBar != nil {
1142 tabBar.SetActive(activeTab.ID)
1143 }
1144 }
1145
1146 return nil
1147}
1148
1149func (a *App) finalizeActivation(ctx context.Context) {
1150 log := logging.FromContext(ctx)
1151
1152 // Show the window.
1153 if a.mainWindow != nil {
1154 a.mainWindow.Show()
1155 }
1156 log.Info().Msg("main window displayed")
1157
1158 // Start watching config for appearance changes.
1159 a.initConfigWatcher(ctx)
1160
1161 // Ensure the current appearance config is applied once at startup.
1162 // This avoids the WebUI showing default tokens until the first config change event.
1163 if a.deps != nil {
1164 a.applyAppearanceConfig(ctx, a.deps.Config)
1165 }
1166
1167 // Start async filter loading after window is visible.
1168 a.initFilteringAsync(ctx)
1169
1170 // Check for config migration after window is visible.
1171 a.checkConfigMigration(ctx)
1172}
1173
1174// onShutdown is called when the GTK application is shutting down.
1175func (a *App) onShutdown(ctx context.Context) {
1176 log := logging.FromContext(ctx)
1177 log.Debug().Msg("GTK application shutting down")
1178
1179 // Save final session state before shutdown
1180 if a.snapshotService != nil {
1181 if err := a.snapshotService.Stop(ctx); err != nil {
1182 log.Warn().Err(err).Msg("failed to save final session state")
1183 }
1184 }
1185
1186 // Apply staged update if available (before cleanup)
1187 if a.updateCoord != nil {
1188 if err := a.updateCoord.FinalizeOnExit(ctx); err != nil {
1189 log.Warn().Err(err).Msg("failed to apply staged update")
1190 }
1191 }
1192
1193 // Cancel context to signal all goroutines
1194 a.cancel(errors.New("application shutdown"))
1195
1196 // Cleanup resources
1197 if a.faviconAdapter != nil {
1198 a.faviconAdapter.Close()
1199 }
1200 if a.deps.Pool != nil {
1201 a.deps.Pool.Close(ctx)
1202 }
1203 // Close idle inhibitor to release D-Bus connection
1204 if a.deps.IdleInhibitor != nil {
1205 if err := a.deps.IdleInhibitor.Close(); err != nil {
1206 log.Warn().Err(err).Msg("failed to close idle inhibitor")
1207 }
1208 }
1209
1210 log.Info().Msg("application shutdown complete")
1211}
1212
1213// initCoordinators initializes all coordinators and wires their callbacks.
1214func (a *App) initCoordinators(ctx context.Context) {
1215 log := logging.FromContext(ctx)
1216 log.Debug().Msg("initializing coordinators")
1217
1218 // Helper to get active workspace and view
1219 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
1220 return a.activeWorkspace(), a.activeWorkspaceView()
1221 }
1222
1223 // Create FaviconAdapter with service and WebKit FaviconDatabase
1224 var faviconDB *webkit.FaviconDatabase
1225 if a.deps.WebContext != nil {
1226 faviconDB = a.deps.WebContext.FaviconDatabase()
1227 }
1228 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
1229
1230 // 1. Content Coordinator (no dependencies on other coordinators)
1231 a.contentCoord = coordinator.NewContentCoordinator(
1232 ctx,
1233 a.pool,
1234 a.widgetFactory,
1235 a.faviconAdapter,
1236 getActiveWS,
1237 a.deps.ZoomUC,
1238 )
1239
1240 // Set idle inhibitor for fullscreen video playback
1241 if a.deps.IdleInhibitor != nil {
1242 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
1243 }
1244
1245 // 2. Tab Coordinator
1246 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1247 TabsUC: a.tabsUC,
1248 Tabs: a.tabs,
1249 MainWindow: a.mainWindow,
1250 Config: a.deps.Config,
1251 })
1252 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
1253 a.createWorkspaceView(ctx, tab)
1254 })
1255 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
1256 a.switchWorkspaceView(ctx, tab.ID)
1257 })
1258 a.tabCoord.SetOnQuit(a.Quit)
1259 a.tabCoord.SetOnStateChanged(a.MarkDirty)
1260 // Wire popup tab WebView attachment
1261 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1262 a.attachPopupToTab(ctx, tabID, pane, wv)
1263 })
1264
1265 // Wire tab bar click handling to coordinator
1266 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1267 a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1268 if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1269 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
1270 }
1271 })
1272 }
1273
1274 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1275 a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1276 if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1277 return
1278 }
1279 if entering {
1280 // Hide tab bar when entering fullscreen video
1281 a.mainWindow.TabBar().SetVisible(false)
1282 } else {
1283 // Restore tab bar visibility based on normal logic
1284 a.tabCoord.UpdateBarVisibility(ctx)
1285 }
1286 })
1287
1288 // 3. Workspace Coordinator
1289 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1290 PanesUC: a.panesUC,
1291 FocusMgr: a.focusMgr,
1292 StackedPaneMgr: a.stackedPaneMgr,
1293 WidgetFactory: a.widgetFactory,
1294 ContentCoord: a.contentCoord,
1295 GetActiveWS: getActiveWS,
1296 GenerateID: a.generateID,
1297 })
1298 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
1299 return a.tabCoord.Close(ctx)
1300 })
1301 a.wsCoord.SetOnStateChanged(a.MarkDirty)
1302
1303 // Wire popup handling
1304 a.webViewFactory = webkit.NewWebViewFactory(
1305 a.deps.WebContext,
1306 a.settings,
1307 a.pool,
1308 a.injector,
1309 a.router,
1310 )
1311 // Set theme background color on factory to eliminate white flash
1312 if a.deps.Theme != nil {
1313 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
1314 a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
1315 }
1316 a.contentCoord.SetPopupConfig(
1317 a.webViewFactory,
1318 &a.deps.Config.Workspace.Popups,
1319 a.generateID,
1320 )
1321 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1322 return a.wsCoord.InsertPopup(ctx, input)
1323 })
1324 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
1325 return a.wsCoord.ClosePaneByID(ctx, paneID)
1326 })
1327 // Wire tabbed popup behavior to create new tabs
1328 a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1329 // Create a new tab with the popup pane
1330 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
1331 if err != nil {
1332 return err
1333 }
1334 log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
1335 return nil
1336 })
1337
1338 // Move pane use case (cross-tab)
1339 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1340
1341 // 4. Navigation Coordinator
1342 a.navCoord = coordinator.NewNavigationCoordinator(
1343 ctx,
1344 a.deps.NavigateUC,
1345 a.contentCoord,
1346 )
1347
1348 // Wire title updates to history persistence
1349 a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
1350 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
1351 })
1352
1353 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
1354 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
1355 a.navCoord.RecordHistory(ctx, paneID, url)
1356 })
1357
1358 // Wire window title updates when active pane's title changes
1359 a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1360 a.updateWindowTitle(title)
1361 })
1362
1363 // Wire pane URI updates for session snapshots (searches all tabs)
1364 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
1365 a.updatePaneURIInAllTabs(paneID, url)
1366 // Mark dirty so snapshot captures the new URI
1367 a.MarkDirty()
1368 })
1369
1370 // Hide loading skeleton once the WebView paints
1371 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
1372 // The WebView can be shown while its pane is in a background tab, so scan
1373 // all WorkspaceViews rather than only the active one.
1374 for _, wsView := range a.workspaceViews {
1375 if wsView == nil {
1376 continue
1377 }
1378 if pv := wsView.GetPaneView(paneID); pv != nil {
1379 pv.HideLoadingSkeleton()
1380 }
1381 }
1382 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1383 a.firstWebViewShownOnce.Do(func() {
1384 a.deps.OnFirstWebViewShown(ctx)
1385 })
1386 }
1387
1388 // Set the WebView as the focused input for accent picker (if this is the active pane)
1389 ws := a.activeWorkspace()
1390 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1391 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1392 }
1393 })
1394
1395 // 5. Keyboard Dispatcher
1396 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
1397 ctx,
1398 a.tabCoord,
1399 a.wsCoord,
1400 a.navCoord,
1401 a.deps.ZoomUC,
1402 a.deps.CopyURLUC,
1403 )
1404 a.kbDispatcher.SetOnQuit(a.Quit)
1405 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
1406 a.ToggleFindBar(ctx)
1407 return nil
1408 })
1409 a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
1410 a.FindNext(ctx)
1411 return nil
1412 })
1413 a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
1414 a.FindPrevious(ctx)
1415 return nil
1416 })
1417 a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
1418 a.CloseFindBar(ctx)
1419 return nil
1420 })
1421 a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1422 return a.HandleMovePaneToTab(ctx)
1423 })
1424 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1425 return a.HandleMovePaneToNextTab(ctx)
1426 })
1427
1428 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
1429 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
1430 return a.kbDispatcher.Dispatch(ctx, action)
1431 })
1432
1433 log.Debug().Msg("coordinators initialized")
1434}
1435
1436// generateID generates a unique ID for tabs and panes.
1437// Uses monotonically increasing counter to avoid ID collisions.
1438func (a *App) generateID() string {
1439 a.idMu.Lock()
1440 defer a.idMu.Unlock()
1441 a.idCounter++
1442 return fmt.Sprintf("p%d", a.idCounter)
1443}
1444
1445// Tabs returns the tab list.
1446func (a *App) Tabs() *entity.TabList {
1447 return a.tabs
1448}
1449
1450// MainWindow returns the main window.
1451func (a *App) MainWindow() *window.MainWindow {
1452 return a.mainWindow
1453}
1454
1455// Quit requests the application to quit.
1456func (a *App) Quit() {
1457 if a.gtkApp != nil {
1458 a.gtkApp.Quit()
1459 }
1460}
1461
1462// RunWithArgs is a convenience function that creates and runs an App.
1463func RunWithArgs(ctx context.Context, deps *Dependencies) int {
1464 app, err := New(deps)
1465 if err != nil {
1466 log := logging.FromContext(ctx)
1467 log.Error().Err(err).Msg("failed to create application")
1468 return 1
1469 }
1470 return app.Run(ctx, os.Args)
1471}
1472
1473// updateWindowTitle updates the window title with the given page title.
1474// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
1475func (a *App) updateWindowTitle(pageTitle string) {
1476 if a.mainWindow == nil {
1477 return
1478 }
1479
1480 title := "Dumber"
1481 if pageTitle != "" {
1482 title = pageTitle + " - Dumber"
1483 }
1484 a.mainWindow.SetTitle(title)
1485}
1486
1487// updateWindowTitleFromActivePane updates the window title based on the current active pane.
1488func (a *App) updateWindowTitleFromActivePane() {
1489 ws := a.activeWorkspace()
1490 if ws == nil || a.contentCoord == nil {
1491 a.updateWindowTitle("")
1492 return
1493 }
1494 title := a.contentCoord.GetTitle(ws.ActivePaneID)
1495 a.updateWindowTitle(title)
1496}
1497
1498// handleModeChange is called when the input mode changes.
1499func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
1500 log := logging.FromContext(ctx)
1501 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
1502
1503 if from == input.ModeResize && to != input.ModeResize {
1504 a.clearResizeModeBorder()
1505 }
1506 if to == input.ModeResize {
1507 a.applyResizeModeBorder(ctx, a.activeWorkspace())
1508 }
1509
1510 // Update global border overlay visibility based on mode.
1511 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
1512 if a.borderMgr != nil {
1513 a.borderMgr.OnModeChange(ctx, from, to)
1514 }
1515
1516 // Show/hide mode indicator toaster based on config.
1517 a.updateModeIndicatorToaster(ctx, to)
1518}
1519
1520// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1521func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1522 if a.modeToaster == nil {
1523 return
1524 }
1525
1526 // Check if mode indicator toaster is enabled in config.
1527 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1528 a.modeToaster.Hide()
1529 return
1530 }
1531
1532 if mode == input.ModeNormal {
1533 a.modeToaster.Hide()
1534 return
1535 }
1536
1537 // Show persistent toaster at bottom-left with mode display name.
1538 // Mode class is applied atomically with Show() to avoid visual flicker.
1539 modeClass := getModeToastClass(mode)
1540 a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1541 component.WithDuration(0), // Persistent until mode exits.
1542 component.WithPosition(component.ToastPositionBottomLeft),
1543 component.WithModeClass(modeClass),
1544 )
1545}
1546
1547// getModeToastClass returns the CSS class for the given mode's toast styling.
1548func getModeToastClass(mode input.Mode) string {
1549 switch mode {
1550 case input.ModePane:
1551 return "toast-pane-mode"
1552 case input.ModeTab:
1553 return "toast-tab-mode"
1554 case input.ModeSession:
1555 return "toast-session-mode"
1556 case input.ModeResize:
1557 return "toast-resize-mode"
1558 default:
1559 return ""
1560 }
1561}
1562
1563func (a *App) clearResizeModeBorder() {
1564 if a.resizeModeBorderTarget != nil {
1565 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1566 a.resizeModeBorderTarget = nil
1567 }
1568}
1569
1570func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
1571 wsView := a.activeWorkspaceView()
1572 if wsView == nil || ws == nil {
1573 a.clearResizeModeBorder()
1574 return
1575 }
1576
1577 paneID := ws.ActivePaneID
1578 if paneID == "" {
1579 a.clearResizeModeBorder()
1580 return
1581 }
1582
1583 // Important: wrap the whole stack container when the active pane is in a stack.
1584 target := wsView.GetStackContainerWidget(paneID)
1585 if target == nil {
1586 a.clearResizeModeBorder()
1587 return
1588 }
1589
1590 if !target.HasCssClass("resize-mode-active") {
1591 target.AddCssClass("resize-mode-active")
1592 }
1593
1594 if a.resizeModeBorderTarget != target {
1595 if a.resizeModeBorderTarget != nil {
1596 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1597 } else {
1598 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
1599 }
1600 a.resizeModeBorderTarget = target
1601 }
1602}
1603
1604// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
1605func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
1606 a.createWorkspaceViewWithoutAttach(ctx, tab)
1607
1608 // Attach to content area
1609 wsView := a.workspaceViews[tab.ID]
1610 if wsView != nil && a.mainWindow != nil {
1611 widget := wsView.Widget()
1612 if widget != nil {
1613 gtkWidget := widget.GtkWidget()
1614 if gtkWidget != nil {
1615 a.mainWindow.SetContent(gtkWidget)
1616 }
1617 }
1618 }
1619}
1620
1621// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
1622// Used during session restoration where we create all views first, then attach only the active one.
1623func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
1624 log := logging.FromContext(ctx)
1625
1626 if a.widgetFactory == nil {
1627 log.Error().Msg("widget factory not initialized")
1628 return
1629 }
1630
1631 // Create workspace view
1632 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
1633 if wsView == nil {
1634 log.Error().Msg("failed to create workspace view")
1635 return
1636 }
1637
1638 // Set the workspace
1639 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
1640 log.Error().Err(err).Msg("failed to set workspace in view")
1641 return
1642 }
1643
1644 // Ensure WebViews are attached to panes
1645 if a.contentCoord != nil {
1646 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1647 }
1648
1649 // Note: Mode borders for tab/pane/session are attached to MainWindow.
1650 // Resize mode border is attached to the active pane's stack container.
1651
1652 // Set omnibox config for this workspace view
1653 wsView.SetOmniboxConfig(a.omniboxCfg)
1654 // Set find bar config for this workspace view
1655 wsView.SetFindBarConfig(a.findBarCfg)
1656
1657 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1658 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
1659 ws := a.activeWorkspace()
1660 if ws != nil {
1661 ws.ActivePaneID = paneID
1662 }
1663 a.applyResizeModeBorder(ctx, ws)
1664 }
1665 })
1666
1667 wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
1668 if a.wsCoord != nil {
1669 _ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
1670 }
1671 })
1672
1673 // Store in map
1674 a.workspaceViews[tab.ID] = wsView
1675
1676 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
1677}
1678
1679// activeWorkspace returns the workspace of the active tab.
1680func (a *App) activeWorkspace() *entity.Workspace {
1681 activeTab := a.tabs.ActiveTab()
1682 if activeTab == nil {
1683 return nil
1684 }
1685 return activeTab.Workspace
1686}
1687
1688// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
1689// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
1690func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
1691 for _, tab := range a.tabs.Tabs {
1692 if tab.Workspace == nil {
1693 continue
1694 }
1695 paneNode := tab.Workspace.FindPane(paneID)
1696 if paneNode != nil && paneNode.Pane != nil {
1697 paneNode.Pane.URI = url
1698 return // Pane IDs are unique, no need to continue
1699 }
1700 }
1701}
1702
1703// activeWorkspaceView returns the workspace view for the active tab.
1704func (a *App) activeWorkspaceView() *component.WorkspaceView {
1705 activeTab := a.tabs.ActiveTab()
1706 if activeTab == nil {
1707 return nil
1708 }
1709 return a.workspaceViews[activeTab.ID]
1710}
1711
1712// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
1713// Used by the accent picker to insert accented characters into web content.
1714func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1715 ws := a.activeWorkspace()
1716 if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1717 return nil
1718 }
1719
1720 wv := a.contentCoord.GetWebView(ws.ActivePaneID)
1721 if wv == nil {
1722 return nil
1723 }
1724
1725 // Get the underlying webkit.WebView for the text input target
1726 return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
1727}
1728
1729// attachPopupToTab attaches a popup WebView to a newly created tab.
1730// This is called when a popup uses tabbed behavior.
1731func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1732 log := logging.FromContext(ctx)
1733
1734 wsView := a.workspaceViews[tabID]
1735 if wsView == nil {
1736 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
1737 return
1738 }
1739
1740 // Register WebView with content coordinator
1741 if a.contentCoord != nil {
1742 // Track the WebView
1743 a.contentCoord.RegisterPopupWebView(pane.ID, wv)
1744
1745 // Wrap and attach widget
1746 widget := a.contentCoord.WrapWidget(ctx, wv)
1747 if widget != nil {
1748 paneView := wsView.GetPaneView(pane.ID)
1749 if paneView != nil {
1750 paneView.SetWebViewWidget(widget)
1751 } else {
1752 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
1753 }
1754 }
1755
1756 // Setup popup handling for nested popups
1757 a.contentCoord.SetupPopupHandling(ctx, pane.ID, wv)
1758 }
1759
1760 log.Debug().
1761 Str("tab_id", string(tabID)).
1762 Str("pane_id", string(pane.ID)).
1763 Msg("popup webview attached to tab")
1764}
1765
1766// switchWorkspaceView swaps the displayed workspace view for a tab.
1767func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
1768 log := logging.FromContext(ctx)
1769
1770 wsView, exists := a.workspaceViews[tabID]
1771 if !exists {
1772 log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
1773 return
1774 }
1775
1776 // Get the workspace view's root widget
1777 widget := wsView.Widget()
1778 if widget == nil {
1779 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
1780 return
1781 }
1782
1783 gtkWidget := widget.GtkWidget()
1784 if gtkWidget == nil {
1785 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
1786 return
1787 }
1788
1789 // Swap content (MainWindow.SetContent now properly removes old content)
1790 if a.mainWindow != nil {
1791 a.mainWindow.SetContent(gtkWidget)
1792 }
1793
1794 // Update window title with the new active pane's title
1795 a.updateWindowTitleFromActivePane()
1796
1797 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
1798}
1799
1800// ToggleOmnibox implements OmniboxProvider.
1801// Toggles the omnibox visibility in the active workspace view.
1802func (a *App) ToggleOmnibox(ctx context.Context) {
1803 log := logging.FromContext(ctx)
1804
1805 wsView := a.activeWorkspaceView()
1806 if wsView == nil {
1807 log.Warn().Msg("no active workspace view for omnibox toggle")
1808 return
1809 }
1810
1811 if wsView.IsOmniboxVisible() {
1812 wsView.HideOmnibox()
1813 } else {
1814 wsView.ShowOmnibox(ctx, "")
1815 }
1816}
1817
1818// ToggleFindBar shows or hides the find bar in the active workspace view.
1819func (a *App) ToggleFindBar(ctx context.Context) {
1820 log := logging.FromContext(ctx)
1821
1822 wsView := a.activeWorkspaceView()
1823 if wsView == nil {
1824 log.Warn().Msg("no active workspace view for find bar toggle")
1825 return
1826 }
1827
1828 if wsView.IsFindBarVisible() {
1829 wsView.HideFindBar()
1830 } else {
1831 wsView.ShowFindBar(ctx)
1832 }
1833}
1834
1835// FindNext selects the next match in the active find bar.
1836func (a *App) FindNext(ctx context.Context) {
1837 wsView := a.activeWorkspaceView()
1838 if wsView == nil {
1839 return
1840 }
1841
1842 if !wsView.IsFindBarVisible() {
1843 wsView.ShowFindBar(ctx)
1844 }
1845 wsView.FindNext()
1846}
1847
1848// FindPrevious selects the previous match in the active find bar.
1849func (a *App) FindPrevious(ctx context.Context) {
1850 wsView := a.activeWorkspaceView()
1851 if wsView == nil {
1852 return
1853 }
1854
1855 if !wsView.IsFindBarVisible() {
1856 wsView.ShowFindBar(ctx)
1857 }
1858 wsView.FindPrevious()
1859}
1860
1861// CloseFindBar hides the find bar if visible.
1862func (a *App) CloseFindBar(ctx context.Context) {
1863 wsView := a.activeWorkspaceView()
1864 if wsView == nil {
1865 return
1866 }
1867 wsView.HideFindBar()
1868}
1869
1870// UpdateOmniboxZoom implements OmniboxProvider.
1871// Updates the zoom indicator on the current omnibox if visible.
1872func (a *App) UpdateOmniboxZoom(factor float64) {
1873 wsView := a.activeWorkspaceView()
1874 if wsView == nil {
1875 return
1876 }
1877
1878 omnibox := wsView.GetOmnibox()
1879 if omnibox != nil {
1880 omnibox.UpdateZoomIndicator(factor)
1881 }
1882}
1883
1884// SetOmniboxOnNavigate implements OmniboxProvider.
1885// This is called to set the navigation callback on new omniboxes.
1886// Since omniboxes are created per-pane, we store the config with the callback.
1887func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
1888 // The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox
1889 // The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
1890}
1891
1892// initFilteringAsync starts background filter loading with toast feedback.
1893func (a *App) initConfigWatcher(ctx context.Context) {
1894 log := logging.FromContext(ctx)
1895
1896 if a.configManager == nil {
1897 log.Debug().Msg("no config manager available, skipping watcher")
1898 return
1899 }
1900
1901 // Start viper watcher
1902 if err := a.configManager.Watch(); err != nil {
1903 log.Warn().Err(err).Msg("failed to start config watcher")
1904 return
1905 }
1906
1907 // Only appearance is hot-reloaded for now.
1908 a.configManager.OnConfigChange(func(newCfg *config.Config) {
1909 cfgCopy := newCfg
1910 cb := glib.SourceFunc(func(_ uintptr) bool {
1911 a.applyAppearanceConfig(ctx, cfgCopy)
1912 return false
1913 })
1914 glib.IdleAdd(&cb, 0)
1915 })
1916
1917 log.Debug().Msg("config watcher initialized")
1918}
1919
1920func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
1921 log := logging.FromContext(ctx)
1922 if cfg == nil {
1923 return
1924 }
1925
1926 // Update the shared config pointer in-place so existing references see changes.
1927 if a.deps != nil && a.deps.Config != nil {
1928 *a.deps.Config = *cfg
1929 }
1930
1931 // Update WebKit settings manager
1932 if a.settings != nil {
1933 a.settings.UpdateFromConfig(ctx, cfg)
1934 }
1935
1936 // Apply settings to existing webviews via coordinator
1937 if a.contentCoord != nil {
1938 a.contentCoord.ApplySettingsToAll(ctx, a.settings)
1939 }
1940
1941 // Update GTK theme and injected WebUI theme vars
1942 if a.deps != nil && a.deps.Theme != nil {
1943 var display *gdk.Display
1944 if a.mainWindow != nil && a.mainWindow.Window() != nil {
1945 display = a.mainWindow.Window().GetDisplay()
1946 }
1947 a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
1948
1949 // Update find highlight CSS for future navigations
1950 if a.injector != nil {
1951 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
1952 if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
1953 log.Warn().Err(err).Msg("failed to update find highlight CSS")
1954 }
1955 }
1956
1957 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
1958 cssText := a.deps.Theme.GetWebUIThemeCSS()
1959 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
1960 log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
1961 }
1962
1963 // Refresh injected scripts so future navigations use the latest theme.
1964 if a.contentCoord != nil && a.injector != nil {
1965 a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
1966 }
1967
1968 // Apply WebUI theme to already-loaded dumb:// pages
1969 if a.contentCoord != nil {
1970 a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
1971 }
1972 }
1973
1974 log.Info().Msg("appearance config updated")
1975}
1976
1977func (a *App) initFilteringAsync(ctx context.Context) {
1978 if a.deps.FilterManager == nil {
1979 return
1980 }
1981
1982 a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
1983 statusCopy := status // Capture for closure
1984 cb := glib.SourceFunc(func(_ uintptr) bool {
1985 a.showFilterStatus(ctx, statusCopy)
1986 return false // Don't repeat
1987 })
1988 glib.IdleAdd(&cb, 0)
1989 })
1990 a.deps.FilterManager.LoadAsync(ctx)
1991}
1992
1993// showFilterStatus displays toast notification for filter status.
1994func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
1995 log := logging.FromContext(ctx)
1996
1997 switch status.State {
1998 case filtering.StateLoading:
1999 if a.appToaster != nil {
2000 a.appToaster.Show(ctx, status.Message, component.ToastInfo)
2001 }
2002 case filtering.StateActive:
2003 // Apply filters to existing webviews that were created before filters loaded
2004 if a.contentCoord != nil && a.deps.FilterManager != nil {
2005 a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
2006 log.Debug().Msg("applied filters to all existing webviews")
2007 }
2008 if a.appToaster != nil {
2009 a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
2010 }
2011 case filtering.StateError:
2012 if a.appToaster != nil {
2013 a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
2014 }
2015 }
2016}