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