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