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