1package ui
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "sync"
13 "time"
14 "unsafe"
15
16 "github.com/bnema/dumber/internal/application/port"
17 "github.com/bnema/dumber/internal/application/usecase"
18 "github.com/bnema/dumber/internal/domain/entity"
19 urlutil "github.com/bnema/dumber/internal/domain/url"
20 "github.com/bnema/dumber/internal/shared/syncdispatch"
21
22 "github.com/bnema/dumber/internal/logging"
23 "github.com/bnema/dumber/internal/ui/adapter"
24 "github.com/bnema/dumber/internal/ui/component"
25 "github.com/bnema/dumber/internal/ui/coordinator"
26 "github.com/bnema/dumber/internal/ui/coordinator/content"
27 "github.com/bnema/dumber/internal/ui/dialog"
28 "github.com/bnema/dumber/internal/ui/dispatcher"
29 "github.com/bnema/dumber/internal/ui/focus"
30 "github.com/bnema/dumber/internal/ui/input"
31 "github.com/bnema/dumber/internal/ui/layout"
32 "github.com/bnema/dumber/internal/ui/theme"
33 "github.com/bnema/dumber/internal/ui/window"
34 "github.com/bnema/puregotk/v4/adw"
35 "github.com/bnema/puregotk/v4/gdk"
36 "github.com/bnema/puregotk/v4/gio"
37 "github.com/bnema/puregotk/v4/glib"
38 "github.com/bnema/puregotk/v4/gtk"
39)
40
41const (
42 // AppID is the application identifier for GTK.
43 AppID = "com.github.bnema.dumber"
44 appTitle = "Dumber"
45 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
46 crashReportToastDurationMs = 5000
47 downloadToastTerminalDuration = 2500
48 downloadProgressRoundBias = 0.5
49 floatingPaneFallbackWidth = 1200
50 floatingPaneFallbackHeight = 800
51 floatingPaneIDPrefix = "floating-pane:"
52 floatingSessionIDDefault = "default"
53 floatingPaneVisibleClass = "floating-pane-visible"
54)
55
56func gtkApplicationFlags() gio.ApplicationFlags {
57 return gio.GApplicationNonUniqueValue
58}
59
60var adwaitaInitOnce sync.Once
61
62// EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once.
63func EnsureAdwaitaInitialized() {
64 adwaitaInitOnce.Do(func() {
65 adw.Init()
66 })
67}
68
69// defaultTabName returns a window-scoped default tab name.
70// index is the 1-based tab position within the window.
71func defaultTabName(index int) string {
72 return fmt.Sprintf("Tab %d", index)
73}
74
75// App wraps the GTK Application and manages the browser lifecycle.
76type App struct {
77 deps *Dependencies
78 gtkApp *gtk.Application
79 mainWindow *window.MainWindow
80
81 runtimeConfig *runtimeConfigState
82
83 browserWindows map[string]*browserWindow
84 browserWindowOrder []string // registration order of window IDs
85 lastFocusedWindowID string
86 nativePopupWindows map[port.WebViewID]*nativePopupWindow
87 browserWindowFactory func(context.Context, string) (*browserWindow, error)
88 dispatchOnMainThread func(string, func()) syncdispatch.SyncDispatchResult
89
90 // State
91 tabs *entity.TabList
92 tabsUC *usecase.ManageTabsUseCase
93
94 // Coordinators (new architecture)
95 contentCoord *content.Coordinator
96 tabCoord *coordinator.TabCoordinator
97 wsCoord *coordinator.WorkspaceCoordinator
98 navCoord *coordinator.NavigationCoordinator
99 kbDispatcher *dispatcher.KeyboardDispatcher
100
101 // Pane management (used by coordinators)
102 panesUC *usecase.ManagePanesUseCase
103 workspaceViews map[entity.TabID]*component.WorkspaceView
104 windowForTab map[entity.TabID]*browserWindow
105 widgetFactory layout.WidgetFactory
106 workspaceViewCreateOverride func(context.Context, *entity.Tab) bool
107 stackedPaneMgr *component.StackedPaneManager
108
109 // Input handling
110 keyboardHandler *input.KeyboardHandler
111 globalShortcutHandler *input.GlobalShortcutHandler
112
113 // Focus tracking stays app-global.
114 focusMgr *focus.Manager
115
116 resizeModeBorderTarget layout.Widget
117
118 // Omnibox configuration (omnibox is created per workspace view)
119 omniboxCfg component.OmniboxConfig
120 // Find bar configuration (find bar is created per workspace view)
121 findBarCfg component.FindBarConfig
122 // Floating pane sessions keyed by tab and profile session ID.
123 floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
124
125 // Engine
126 engine port.Engine
127
128 // Web content (managed by content.Coordinator)
129 faviconAdapter *adapter.FaviconAdapter
130
131 snapshotService port.SnapshotService
132
133 // Update management
134 updateCoord *coordinator.UpdateCoordinator
135
136 // ID generator for tabs/panes
137 idCounter uint64
138 idMu sync.Mutex
139 windowIDCounter uint64
140 windowIDMu sync.Mutex
141 firstWebViewShownOnce sync.Once
142
143 movePaneToTabUC *usecase.MovePaneToTabUseCase
144 extractPaneToTabListUC *usecase.ExtractPaneToTabListUseCase
145
146 // Accent picker for dead keys support
147 accentFocusProvider port.FocusedInputProvider
148
149 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
150 deferredInitOnce sync.Once
151 deferredInitFn func()
152
153 // lifecycle
154 cancel context.CancelCauseFunc
155 browserLaunchRelayOnce sync.Once
156 browserLaunchRelayCloser io.Closer
157}
158
159type floatingWorkspaceSession struct {
160 paneID entity.PaneID
161 pane *component.FloatingPane
162 paneView *component.PaneView
163 webView port.WebView
164 overlay layout.OverlayWidget
165 widget layout.Widget
166 focusWidget layout.Widget
167 omnibox *component.Omnibox
168 omniboxWidget layout.Widget
169 resizeWatcherActive bool
170 resizeTickID uint
171 appliedWidth int
172 appliedHeight int
173}
174
175type floatingSessionKey struct {
176 tabID entity.TabID
177 sessionID string
178}
179
180// New creates a new App with the given dependencies.
181func New(deps *Dependencies) (*App, error) {
182 if err := deps.Validate(); err != nil {
183 return nil, err
184 }
185 ctx, cancel := context.WithCancelCause(deps.Ctx)
186
187 app := &App{
188 deps: deps,
189 runtimeConfig: newRuntimeConfigState(deps.RuntimeConfig),
190 tabs: entity.NewTabList(),
191 tabsUC: deps.TabsUC,
192 panesUC: deps.PanesUC,
193 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
194 windowForTab: make(map[entity.TabID]*browserWindow),
195 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
196 browserWindows: make(map[string]*browserWindow),
197 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
198 if fn != nil {
199 fn()
200 }
201 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
202 },
203 engine: deps.Engine,
204 cancel: cancel,
205 }
206 var autoCopyConfig port.AutoCopyConfig
207 var clipboardOrchestrator port.ClipboardTextOrchestrator
208 if deps.Clipboard != nil {
209 autoCopyConfig = &autoCopyConfigFn{fn: func() bool {
210 return app.runtimeConfigSnapshot().UI.Clipboard.AutoCopyOnSelection
211 }}
212 clipboardOrchestrator = usecase.NewClipboardTextOrchestrator(deps.Clipboard, autoCopyConfig, func(textLen int) {
213 cb := glib.SourceFunc(func(_ uintptr) bool {
214 app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,
215 component.WithDuration(component.ToastBriefDurationMs),
216 component.WithPosition(component.ToastPositionBottomRight),
217 )
218 return false
219 })
220 glib.IdleAdd(&cb, 0)
221 })
222 }
223 historyChangeSink := newHistoryChangeAdapter(app)
224 if deps.HistoryRecorderUC != nil {
225 deps.HistoryRecorderUC.SetHistoryChangeSink(historyChangeSink)
226 }
227 if deps.HistoryUC != nil {
228 deps.HistoryUC.SetHistoryChangeSink(historyChangeSink)
229 }
230
231 if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconResolverSetter); ok {
232 resolver := deps.SystemviewFaviconResolver
233 if resolver == nil {
234 resolver = deps.FaviconResolver
235 }
236 if resolver != nil {
237 faviconSetter.SetSystemviewFaviconResolver(resolver)
238 }
239 }
240
241 // Register message handlers through the engine.
242 if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
243 HistoryUC: deps.HistoryUC,
244 FavoritesUC: deps.FavoritesUC,
245 Clipboard: deps.Clipboard,
246 AutoCopyConfig: autoCopyConfig,
247 ClipboardTextOrchestrator: clipboardOrchestrator,
248 OnClipboardCopied: func(textLen int) {
249 cb := glib.SourceFunc(func(_ uintptr) bool {
250 app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,
251 component.WithDuration(component.ToastBriefDurationMs),
252 component.WithPosition(component.ToastPositionBottomRight),
253 )
254 return false
255 })
256 glib.IdleAdd(&cb, 0)
257 },
258 HandlerDeps: deps.HandlerDeps,
259 }); err != nil {
260 cancel(err)
261 if deps.HistoryRecorderUC != nil {
262 deps.HistoryRecorderUC.Close()
263 }
264 return nil, err
265 }
266
267 return app, nil
268}
269
270func (a *App) runtimeConfigSnapshot() entity.RuntimeConfigSnapshot {
271 if a == nil {
272 return entity.RuntimeConfigSnapshot{}
273 }
274 if a.runtimeConfig != nil {
275 return a.runtimeConfig.Current()
276 }
277 if a.deps != nil && a.deps.RuntimeConfig != nil {
278 return a.deps.RuntimeConfig.Current()
279 }
280 return entity.RuntimeConfigSnapshot{}
281}
282
283// Run starts the GTK application and blocks until it exits.
284// Returns the exit code.
285func (a *App) Run(ctx context.Context, args []string) int {
286 log := logging.FromContext(ctx)
287 log.Debug().Msg("creating GTK application")
288
289 // Initialize libadwaita once (required before using StyleManager).
290 // This also initializes GTK implicitly.
291 EnsureAdwaitaInitialized()
292 logging.Trace().Mark("gtk_init")
293
294 // Mark adwaita detector as available now that adw.Init() is complete.
295 // This enables the highest-priority color scheme detector.
296 if a.deps != nil && a.deps.AdwaitaDetector != nil {
297 a.deps.AdwaitaDetector.MarkAvailable()
298 log.Debug().Msg("adwaita detector marked available")
299
300 // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
301 // They have the wrong dark mode preference injected; re-inject with correct value.
302 if a.engine != nil {
303 _ = a.engine.OnToolkitReady(ctx)
304 }
305 }
306
307 appID := AppID
308 a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags())
309 if a.gtkApp == nil {
310 log.Error().Msg("failed to create GTK application")
311 return 1
312 }
313 defer a.gtkApp.Unref()
314 logging.Trace().Mark("gtk_app_created")
315 a.dispatchOnMainThread = a.runOnMainThread
316
317 // Connect activate signal
318 activateCb := func(_ gio.Application) {
319 a.onActivate(ctx)
320 }
321 a.gtkApp.ConnectActivate(&activateCb)
322
323 // Connect shutdown signal
324 shutdownCb := func(_ gio.Application) {
325 a.onShutdown(ctx)
326 }
327 a.gtkApp.ConnectShutdown(&shutdownCb)
328
329 log.Info().Msg("starting GTK main loop")
330 code := a.gtkApp.Run(len(args), args)
331
332 return code
333}
334
335// onActivate is called when the GTK application is activated.
336func (a *App) onActivate(ctx context.Context) {
337 log := logging.FromContext(ctx)
338 log.Debug().Msg("GTK application activated")
339
340 a.applyGTKColorSchemePreference(ctx)
341 // Configure pool background color early (prevents white flash), but avoid
342 // synchronous prewarming that delays the first navigation on cold start.
343 a.setupPoolBackgroundColor(ctx)
344 a.initLayoutInfrastructure()
345
346 if err := a.openInitialBrowserWindowShell(ctx, a.initialWindowURL()); err != nil {
347 log.Error().Err(err).Msg("failed to create main window")
348 return
349 }
350 logging.Trace().Mark("window_created")
351
352 a.installCrashReportNotifier(ctx)
353 a.initFocusManager()
354 a.registerAccentHandlers(ctx)
355 a.initDownloadHandler(ctx)
356
357 a.initCoordinators(ctx)
358 a.wireWebRTCPermissionIndicator()
359 logging.Trace().Mark("coordinators_init")
360 a.initKeyboardHandler(ctx)
361 a.initOmniboxConfig(ctx)
362 a.initFindBarConfig(ctx)
363 a.wireSessionManagerShortcut()
364 a.initSnapshotService(ctx)
365 a.initUpdateCoordinator(ctx)
366 a.createInitialTab(ctx)
367 a.finalizeActivation(ctx)
368}
369
370func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
371 log := logging.FromContext(ctx)
372
373 // Get the config color scheme preference (default follows system theme)
374 scheme := "default"
375 if runtimeScheme := a.runtimeConfigSnapshot().UI.Appearance.ColorScheme; runtimeScheme != "" {
376 scheme = runtimeScheme
377 }
378
379 // Apply color scheme via libadwaita's StyleManager.
380 // This properly communicates the preference to GTK and WebKit,
381 // so web content can correctly evaluate prefers-color-scheme media queries.
382 styleMgr := adw.StyleManagerGetDefault()
383 if styleMgr == nil {
384 log.Warn().Msg("failed to get adw.StyleManager")
385 return
386 }
387
388 var adwScheme adw.ColorScheme
389 switch scheme {
390 case "dark", "prefer-dark":
391 adwScheme = adw.ColorSchemeForceDarkValue
392 case "light", "prefer-light":
393 adwScheme = adw.ColorSchemeForceLightValue
394 default:
395 // "default" or empty - follow system preference
396 adwScheme = adw.ColorSchemeDefaultValue
397 }
398
399 styleMgr.SetColorScheme(adwScheme)
400
401 // Refresh the color resolver now that adwaita detector is available.
402 // This ensures all components get the correct preference.
403 var pref string
404 var prefersDark bool
405 if a.deps != nil && a.deps.ColorResolver != nil {
406 result := a.deps.ColorResolver.Refresh()
407 prefersDark = result.PrefersDark
408 pref = result.Source
409 } else {
410 prefersDark = styleMgr.GetDark()
411 pref = "adw.StyleManager"
412 }
413
414 log.Debug().
415 Str("scheme", scheme).
416 Bool("prefers_dark", prefersDark).
417 Str("source", pref).
418 Msg("applied color scheme via adw.StyleManager")
419}
420
421func (a *App) setupPoolBackgroundColor(ctx context.Context) {
422 log := logging.FromContext(ctx)
423 if a.engine == nil {
424 log.Debug().Msg("engine not available; skipping background color setup")
425 return
426 }
427 if a.deps == nil || a.deps.Theme == nil {
428 log.Debug().Msg("theme not available; skipping webview pool background color setup")
429 return
430 }
431 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
432 if err := a.engine.UpdateAppearance(ctx, float64(r), float64(g), float64(b), float64(alpha)); err != nil {
433 log.Warn().Err(err).Msg("failed to update engine appearance")
434 }
435 log.Debug().Msg("configured webview pool background color")
436}
437
438func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
439 if a.engine == nil {
440 return
441 }
442 pool := a.engine.Pool()
443 if pool == nil {
444 return
445 }
446 pool.PrewarmAsync(ctx, 0)
447}
448
449func (a *App) initBrowserWindowOverlays(mainWindow *window.MainWindow, browserWindow *browserWindow, runtimeCfg entity.RuntimeUIConfig) {
450 // Create permission popup and dialog presenter
451 if a.deps != nil && a.deps.PermissionUC != nil {
452 uiScale := runtimeCfg.DefaultUIScale
453 if uiScale == 0 {
454 uiScale = 1.0
455 }
456 permPopup := component.NewPermissionPopup(nil, uiScale)
457 if permPopup != nil {
458 // Add popup to the main window's content overlay
459 if w := permPopup.Widget(); w != nil {
460 mainWindow.AddOverlay(w)
461 }
462 browserWindow.permissionDialog = dialog.NewPermissionDialog(permPopup)
463 }
464 }
465
466 // Create top-right WebRTC permission activity indicator.
467 indicator := component.NewWebRTCPermissionIndicator()
468 if indicator != nil {
469 if w := indicator.Widget(); w != nil {
470 mainWindow.AddOverlay(w)
471 }
472 browserWindow.webrtcIndicator = indicator
473 }
474}
475
476func (a *App) ensureWindowForTabMap() {
477 if a.windowForTab == nil {
478 a.windowForTab = make(map[entity.TabID]*browserWindow)
479 }
480}
481
482func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browserWindow {
483 if mainWindow == nil {
484 return nil
485 }
486 for _, bw := range a.browserWindows {
487 if bw != nil && bw.mainWindow == mainWindow {
488 return bw
489 }
490 }
491 return nil
492}
493
494func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
495 if a.windowForTab == nil {
496 return nil
497 }
498 return a.windowForTab[tabID]
499}
500
501func (a *App) browserWindowForTabTarget(target coordinator.TabTarget) *browserWindow {
502 if target.Tabs != nil {
503 for _, bw := range a.browserWindows {
504 if bw != nil && bw.tabs == target.Tabs {
505 return bw
506 }
507 }
508 }
509 if target.MainWindow != nil {
510 return a.browserWindowForMainWindow(target.MainWindow)
511 }
512 return nil
513}
514
515func (a *App) tabCountForBrowserWindow(bw *browserWindow) int {
516 if bw == nil || bw.tabs == nil {
517 return 0
518 }
519 return len(bw.tabs.Tabs)
520}
521
522func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
523 if paneID == "" {
524 return nil
525 }
526 for _, bw := range a.browserWindows {
527 if bw == nil || bw.tabs == nil {
528 continue
529 }
530 for _, tab := range bw.tabs.Tabs {
531 if tab == nil || tab.Workspace == nil {
532 continue
533 }
534 if tab.Workspace.FindPane(paneID) != nil {
535 return bw
536 }
537 }
538 }
539 return nil
540}
541
542func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow {
543 if bw := a.browserWindowForPane(paneID); bw != nil {
544 return bw
545 }
546 for key, session := range a.floatingSessions {
547 if session != nil && session.paneID == paneID {
548 return a.browserWindowForTab(key.tabID)
549 }
550 }
551 return nil
552}
553
554func (a *App) handleTouchpadNavigationGesture(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {
555 if a == nil {
556 return
557 }
558 run := func() {
559 bw := a.browserWindowForAnyPane(paneID)
560 if bw == nil || bw.touchpadNavIndicator == nil {
561 return
562 }
563 bw.touchpadNavIndicator.ShowGesture(gesture)
564 }
565 if a.dispatchOnMainThread != nil {
566 a.dispatchOnMainThread("touchpad-navigation-gesture", run)
567 return
568 }
569 run()
570}
571
572func (a *App) lastFocusedBrowserWindow() *browserWindow {
573 if a.lastFocusedWindowID == "" || a.browserWindows == nil {
574 return nil
575 }
576 return a.browserWindows[a.lastFocusedWindowID]
577}
578
579func (a *App) showToastOnBrowserWindow(
580 ctx context.Context,
581 bw *browserWindow,
582 message string,
583 level component.ToastLevel,
584 opts ...component.ToastOption,
585) {
586 if bw == nil || bw.appToaster == nil {
587 return
588 }
589 bw.appToaster.Show(ctx, message, level, opts...)
590}
591
592func (a *App) showToastOnLastFocusedBrowserWindow(
593 ctx context.Context,
594 message string,
595 level component.ToastLevel,
596 opts ...component.ToastOption,
597) {
598 a.showToastOnBrowserWindow(ctx, a.lastFocusedBrowserWindow(), message, level, opts...)
599}
600
601func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.PaneID) *browserWindow {
602 if paneID != "" {
603 if bw := a.browserWindowForPane(paneID); bw != nil {
604 return bw
605 }
606 }
607 if tabID != "" {
608 if bw := a.browserWindowForTab(tabID); bw != nil {
609 return bw
610 }
611 }
612 return a.lastFocusedBrowserWindow()
613}
614
615func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error {
616 bw := a.browserWindowForAnyPane(popupInput.ParentPaneID)
617 if bw == nil {
618 return fmt.Errorf("popup owner window not found for parent pane %q", popupInput.ParentPaneID)
619 }
620 tab, err := a.tabCoord.CreateWithPane(
621 ctx,
622 a.ensureTabTargetForBrowserWindow(bw),
623 popupInput.PopupPane,
624 popupInput.WebView,
625 popupInput.TargetURI,
626 )
627 if err != nil {
628 return err
629 }
630 logging.FromContext(ctx).Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
631 return nil
632}
633
634func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {
635 a.updateWindowTitle(title, a.browserWindowForPane(paneID))
636}
637
638func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) {
639 bw := a.browserWindowForPane(paneID)
640 if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil {
641 return
642 }
643 if entering {
644 bw.mainWindow.TabBar().SetVisible(false)
645 bw.mainWindow.SetTabBarContentInsetVisible(false)
646 return
647 }
648 bw.mainWindow.TabBar().SetVisible(true)
649 a.updateBrowserWindowTabBarVisibility(bw)
650}
651
652func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) {
653 if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil {
654 return
655 }
656 if !a.runtimeConfigSnapshot().UI.Workspace.HideTabBarWhenSingleTab {
657 bw.mainWindow.TabBar().SetAutoHidden(false)
658 bw.mainWindow.SetTabBarContentInsetVisible(true)
659 return
660 }
661 shouldShow := bw.mainWindow.TabBar().Count() > 1
662 bw.mainWindow.TabBar().SetAutoHidden(!shouldShow)
663 bw.mainWindow.SetTabBarContentInsetVisible(shouldShow)
664}
665
666func (a *App) activateBrowserWindow(bw *browserWindow) {
667 if bw == nil {
668 return
669 }
670 a.lastFocusedWindowID = bw.id
671 if bw.mainWindow != nil {
672 a.mainWindow = bw.mainWindow
673 }
674 a.keyboardHandler = bw.keyboardHandler
675 a.globalShortcutHandler = bw.globalShortcutHandler
676 if a.tabCoord != nil {
677 a.tabCoord.SetCurrentTarget(a.tabTargetForBrowserWindow(bw))
678 }
679 // Sync the derived global App.tabs mirror from the active browser window.
680 // Some usecase and snapshot/export boundaries still accept a single TabList,
681 // so the mirror follows per-window active tab state without selecting runtime targets.
682 activeTab := a.activeTabForBrowserWindow(bw)
683 activeID := entity.TabID("")
684 if activeTab != nil {
685 activeID = activeTab.ID
686 }
687 perWinTabs := a.tabListForBrowserWindow(bw)
688 if a.tabs != nil && activeID != "" {
689 if a.tabs.Find(activeID) == nil {
690 if tab := perWinTabs.Find(activeID); tab != nil {
691 a.tabs.Add(cloneTabForGlobalList(tab))
692 }
693 }
694 if a.tabs.Find(activeID) != nil {
695 a.tabs.SetActive(activeID)
696 }
697 }
698 a.updateWindowTitleFromActivePane(activeID)
699}
700
701func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {
702 if !active || bw == nil {
703 return
704 }
705 a.activateBrowserWindow(bw)
706}
707
708func (a *App) wireBrowserWindowActivationTracking(bw *browserWindow) {
709 if bw == nil || bw.mainWindow == nil {
710 return
711 }
712 bw.mainWindow.ConnectActiveNotify(func(active bool) {
713 a.handleBrowserWindowActivationChanged(bw, active)
714 })
715}
716
717func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
718 if bw == nil {
719 return
720 }
721 a.ensureWindowForTabMap()
722 a.windowForTab[tabID] = bw
723 tabs := a.ensureTabListForBrowserWindow(bw)
724 if tabs.ActiveTabID == "" {
725 tabs.SetActive(tabID)
726 }
727}
728
729func (a *App) tabBarForBrowserWindow(bw *browserWindow) *component.TabBar {
730 if bw != nil && bw.mainWindow != nil {
731 return bw.mainWindow.TabBar()
732 }
733 if a.mainWindow != nil {
734 return a.mainWindow.TabBar()
735 }
736 return nil
737}
738
739func (a *App) startBrowserLaunchRelayListener(ctx context.Context) {
740 if a == nil || a.deps == nil || a.deps.BrowserLaunchRelay == nil {
741 return
742 }
743 a.browserLaunchRelayOnce.Do(func() {
744 closer, err := a.deps.BrowserLaunchRelay.Listen(ctx, a)
745 if err != nil {
746 logging.FromContext(ctx).Warn().Err(err).Msg("failed to start browser launch relay listener")
747 return
748 }
749 a.browserLaunchRelayCloser = closer
750 })
751}
752
753func (a *App) closeBrowserLaunchRelayListener() {
754 if a.browserLaunchRelayCloser == nil {
755 return
756 }
757 _ = a.browserLaunchRelayCloser.Close()
758 a.browserLaunchRelayCloser = nil
759}
760
761func (a *App) initialWindowURL() string {
762 if a.deps != nil {
763 return urlutil.ResolveBrowserStartupURL(a.deps.InitialURL)
764 }
765 return urlutil.DefaultBrowserStartupURL()
766}
767
768func (a *App) initLayoutInfrastructure() {
769 // Initialize widget factory for pane layout.
770 a.widgetFactory = layout.NewGtkWidgetFactory()
771
772 // Initialize stacked pane manager for incremental widget operations.
773 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
774}
775
776func (a *App) installCrashReportNotifier(ctx context.Context) {
777 if a.deps == nil {
778 return
779 }
780
781 a.deps.OnCrashReportsDetected = func(paths []string) {
782 a.showCrashReportToast(ctx, paths)
783 }
784}
785
786func (a *App) initFocusManager() {
787 a.focusMgr = focus.NewManager(a.panesUC)
788}
789
790func (a *App) wireSessionManagerShortcut() {
791 if a.kbDispatcher == nil {
792 return
793 }
794 a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context, paneID entity.PaneID) error {
795 bw := a.ownerOrLastFocusedBrowserWindow("", paneID)
796 if bw == nil || bw.sessionManager == nil {
797 return nil
798 }
799 bw.sessionManager.Toggle(ctx)
800 return nil
801 })
802}
803
804// registerAccentHandlers registers the accent key press/release message handlers
805// with the router.
806func (a *App) registerAccentHandlers(ctx context.Context) {
807 log := logging.FromContext(ctx)
808
809 if a.engine == nil {
810 log.Debug().Msg("engine not available, skipping accent handler registration")
811 return
812 }
813
814 if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil {
815 log.Error().Err(err).Msg("failed to register accent handlers")
816 } else {
817 log.Info().Msg("registered accent key handlers")
818 }
819}
820
821func (a *App) activeAccentHandler() input.AccentHandler {
822 bw := a.lastFocusedBrowserWindow()
823 if bw == nil {
824 return nil
825 }
826 return bw.insertAccentUC
827}
828
829func (a *App) initDownloadHandler(ctx context.Context) {
830 log := logging.FromContext(ctx)
831
832 if a.deps == nil || a.engine == nil {
833 log.Debug().Msg("WebContext not available, skipping download handler")
834 return
835 }
836
837 // Determine download path from config, fallback to XDG.
838 downloadPath := a.runtimeConfigSnapshot().UI.Downloads.Path
839 if downloadPath == "" && a.deps.XDG != nil {
840 var err error
841 downloadPath, err = a.deps.XDG.DownloadDir()
842 if err != nil {
843 log.Warn().Err(err).Msg("failed to get XDG download dir")
844 }
845 }
846 if downloadPath == "" {
847 // Final fallback to ~/Downloads, with /tmp as last resort.
848 home, err := os.UserHomeDir()
849 if err != nil {
850 log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
851 downloadPath = "/tmp"
852 } else {
853 downloadPath = filepath.Join(home, "Downloads")
854 }
855 }
856
857 // Create download event adapter to show toasts.
858 eventAdapter := newDownloadEventAdapter(a)
859
860 // Create use case for preparing download destinations with file deduplication.
861 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)
862
863 if err := a.engine.ConfigureDownloads(ctx, downloadPath, eventAdapter, prepareDownloadUC); err != nil {
864 log.Error().Err(err).Msg("failed to configure downloads")
865 return
866 }
867
868 log.Info().Str("path", downloadPath).Msg("download handler initialized")
869}
870
871// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
872type downloadEventAdapter struct {
873 app *App
874 mu sync.Mutex
875 active map[string]downloadToastState
876}
877
878type downloadToastState struct {
879 filename string
880 percent int
881}
882
883type downloadToastSpec struct {
884 message string
885 level component.ToastLevel
886 duration int
887 show bool
888}
889
890func newDownloadEventAdapter(app *App) *downloadEventAdapter {
891 return &downloadEventAdapter{
892 app: app,
893 active: make(map[string]downloadToastState),
894 }
895}
896
897func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
898 spec := d.toastSpecForEvent(event)
899 if !spec.show {
900 return
901 }
902
903 // Must schedule on GTK main thread.
904 cb := glib.SourceFunc(func(_ uintptr) bool {
905 d.app.showToastOnLastFocusedBrowserWindow(
906 ctx,
907 spec.message,
908 spec.level,
909 component.WithDuration(spec.duration),
910 )
911 return false
912 })
913 glib.IdleAdd(&cb, 0)
914}
915
916func (d *downloadEventAdapter) toastSpecForEvent(event port.DownloadEvent) downloadToastSpec {
917 key := downloadToastKey(event)
918 filename := event.Filename
919 if filename == "" {
920 filename = filepath.Base(event.Destination)
921 }
922 if filename == "" {
923 filename = "download"
924 }
925
926 d.mu.Lock()
927 defer d.mu.Unlock()
928
929 state := d.active[key]
930 if state.filename == "" {
931 state.filename = filename
932 }
933
934 switch event.Type {
935 case port.DownloadEventStarted:
936 state.percent = 0
937 d.active[key] = state
938 return downloadToastSpec{
939 message: fmt.Sprintf("Download started: %s (0%%)", state.filename),
940 level: component.ToastInfo,
941 duration: 0,
942 show: true,
943 }
944 case port.DownloadEventProgress:
945 percent := downloadProgressPercent(event.Progress)
946 if percent <= state.percent {
947 if _, ok := d.active[key]; !ok {
948 d.active[key] = state
949 }
950 return downloadToastSpec{}
951 }
952 state.percent = percent
953 d.active[key] = state
954 return downloadToastSpec{
955 message: fmt.Sprintf("Downloading: %s (%d%%)", state.filename, percent),
956 level: component.ToastInfo,
957 duration: 0,
958 show: true,
959 }
960 case port.DownloadEventFinished:
961 delete(d.active, key)
962 return downloadToastSpec{
963 message: "Download complete: " + state.filename,
964 level: component.ToastSuccess,
965 duration: downloadToastTerminalDuration,
966 show: true,
967 }
968 case port.DownloadEventFailed:
969 delete(d.active, key)
970 return downloadToastSpec{
971 message: "Download failed: " + state.filename,
972 level: component.ToastError,
973 duration: downloadToastTerminalDuration,
974 show: true,
975 }
976 default:
977 return downloadToastSpec{}
978 }
979}
980
981func downloadToastKey(event port.DownloadEvent) string {
982 if event.Destination != "" {
983 return event.Destination
984 }
985 if event.Filename != "" {
986 return event.Filename
987 }
988 return "download"
989}
990
991func downloadProgressPercent(progress float64) int {
992 if progress <= 0 {
993 return 0
994 }
995 if progress >= 1 {
996 return 100
997 }
998 percent := int(progress*100 + downloadProgressRoundBias)
999 if percent < 0 {
1000 return 0
1001 }
1002 if percent > 100 {
1003 return 100
1004 }
1005 return percent
1006}
1007
1008// autoCopyConfigFn implements port.AutoCopyConfig using a function closure.
1009type autoCopyConfigFn struct {
1010 fn func() bool
1011}
1012
1013func (a *autoCopyConfigFn) IsAutoCopyEnabled() bool {
1014 if a.fn == nil {
1015 return false
1016 }
1017 return a.fn()
1018}
1019
1020func (a *App) initKeyboardHandler(ctx context.Context) {
1021 bw := a.browserWindowForMainWindow(a.mainWindow)
1022 if bw == nil {
1023 return
1024 }
1025 a.initBrowserWindowInput(ctx, bw)
1026}
1027
1028func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1029 log := logging.FromContext(ctx)
1030
1031 if bw == nil || bw.mainWindow == nil || a.deps == nil || a.kbDispatcher == nil {
1032 return
1033 }
1034 if bw.keyboardHandler != nil || bw.globalShortcutHandler != nil {
1035 return
1036 }
1037
1038 runtimeCfg := a.runtimeConfigSnapshot().UI
1039 workspaceCfg := runtimeCfg.Workspace
1040 sessionCfg := runtimeCfg.Session
1041 bw.keyboardHandler = input.NewKeyboardHandler(ctx, &workspaceCfg, &sessionCfg)
1042 bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error {
1043 a.activateBrowserWindow(bw)
1044 if action == input.ActionClosePane {
1045 if a.closeAndReleaseActiveFloatingPane(actionCtx) {
1046 return nil
1047 }
1048 }
1049 return a.dispatchBrowserWindowAction(actionCtx, bw, action)
1050 })
1051 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
1052 a.activateBrowserWindow(bw)
1053 return a.handleGlobalEscape(escapeCtx)
1054 })
1055 bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
1056 a.activateBrowserWindow(bw)
1057 a.handleModeChange(ctx, from, to)
1058 })
1059 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
1060 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
1061 return input.RoutePassToWidget
1062 }
1063 if bw.tabPicker != nil && bw.tabPicker.IsVisible() {
1064 return input.RoutePassToWidget
1065 }
1066
1067 if a.accentFocusProvider != nil {
1068 if _, ok := a.accentFocusProvider.GetFocusedInput().(port.EntryInputTarget); ok {
1069 if kc.Modifiers&input.ModAlt != 0 {
1070 return input.RouteHandleShortcuts
1071 }
1072 return input.RoutePassToWidget
1073 }
1074 }
1075
1076 if input.IsShortcutModified(kc.Modifiers) {
1077 return input.RouteHandleShortcuts
1078 }
1079 if input.IsTextInputKey(kc.Keyval) {
1080 return input.RoutePassToWidget
1081 }
1082
1083 return input.RouteHandleShortcuts
1084 })
1085 bw.keyboardHandler.SetAccentHandler(a)
1086 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())
1087
1088 bw.globalShortcutHandler = input.NewGlobalShortcutHandler(
1089 ctx,
1090 bw.mainWindow.Window(),
1091 &workspaceCfg,
1092 &sessionCfg,
1093 bw.keyboardHandler,
1094 func(actionCtx context.Context, action input.Action) error {
1095 a.activateBrowserWindow(bw)
1096 return a.dispatchBrowserWindowAction(actionCtx, bw, action)
1097 },
1098 )
1099 if bw.globalShortcutHandler == nil {
1100 log.Warn().Str("window_id", bw.id).Msg("global shortcut handler creation failed, shortcuts may not work when WebView has focus")
1101 }
1102
1103 a.activateBrowserWindow(bw)
1104}
1105
1106func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) {
1107 if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil || a.tabCoord == nil {
1108 return
1109 }
1110 bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1111 a.activateBrowserWindow(bw)
1112 if err := a.tabCoord.Switch(ctx, a.ensureTabTargetForBrowserWindow(bw), tabID); err != nil {
1113 logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed")
1114 return
1115 }
1116 // Active tab state is managed by TabCoordinator.Switch → TabList.SetActive.
1117 })
1118}
1119
1120// handleAccentKeyPress handles accent key press events for GTK entry widgets
1121// (omnibox and find bar). Returns true if the key was consumed.
1122func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool {
1123 handler := a.activeAccentHandler()
1124 if handler == nil {
1125 return false
1126 }
1127 if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 {
1128 return false
1129 }
1130 shiftHeld := state&gdk.ShiftMaskValue != 0
1131 if char := input.KeyvalToRune(keyval); char != 0 {
1132 return handler.OnKeyPressed(ctx, char, shiftHeld)
1133 }
1134 return false
1135}
1136
1137// handleAccentKeyRelease handles accent key release events for GTK entry widgets
1138// (omnibox and find bar).
1139func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {
1140 handler := a.activeAccentHandler()
1141 if handler == nil {
1142 return
1143 }
1144 if char := input.KeyvalToRune(keyval); char != 0 {
1145 handler.OnKeyReleased(ctx, char)
1146 }
1147}
1148
1149func (a *App) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
1150 handler := a.activeAccentHandler()
1151 if handler == nil {
1152 return false
1153 }
1154 return handler.OnKeyPressed(ctx, char, shiftHeld)
1155}
1156
1157func (a *App) OnKeyReleased(ctx context.Context, char rune) {
1158 handler := a.activeAccentHandler()
1159 if handler == nil {
1160 return
1161 }
1162 handler.OnKeyReleased(ctx, char)
1163}
1164
1165func (a *App) IsPickerVisible() bool {
1166 handler := a.activeAccentHandler()
1167 if handler == nil {
1168 return false
1169 }
1170 return handler.IsPickerVisible()
1171}
1172
1173func (a *App) Cancel(ctx context.Context) {
1174 handler := a.activeAccentHandler()
1175 if handler == nil {
1176 return
1177 }
1178 handler.Cancel(ctx)
1179}
1180
1181type omniboxCallbacks struct {
1182 OnNavigate func(ctx context.Context, url string) error
1183 NormalizeNavigationURL func(ctx context.Context, input string) string
1184 OnToast func(ctx context.Context, message string, level component.ToastLevel)
1185 OnFocusIn func(entry *gtk.SearchEntry)
1186 OnFocusOut func()
1187 OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
1188 OnAccentKeyRelease func(keyval uint)
1189}
1190
1191func buildOmniboxConfig(
1192 deps *Dependencies,
1193 runtimeCfg entity.RuntimeUIConfig,
1194 faviconAdapter *adapter.FaviconAdapter,
1195 callbacks omniboxCallbacks,
1196) component.OmniboxConfig {
1197 if deps == nil {
1198 return component.OmniboxConfig{}
1199 }
1200
1201 shortcuts := make(map[string]usecase.SearchShortcut, len(runtimeCfg.SearchShortcuts))
1202 for key, shortcut := range runtimeCfg.SearchShortcuts {
1203 shortcuts[key] = usecase.SearchShortcut{
1204 URL: shortcut.URL,
1205 Description: shortcut.Description,
1206 }
1207 }
1208
1209 return component.OmniboxConfig{
1210 HistoryUC: deps.HistoryUC,
1211 FavoritesUC: deps.FavoritesUC,
1212 FaviconAdapter: faviconAdapter,
1213 CopyURLUC: deps.CopyURLUC,
1214 ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
1215 DefaultSearch: runtimeCfg.DefaultSearchEngine,
1216 NormalizeNavigationURL: callbacks.NormalizeNavigationURL,
1217 InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1218 MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,
1219 SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
1220 UIScale: runtimeCfg.DefaultUIScale,
1221 OnNavigate: callbacks.OnNavigate,
1222 OnToast: callbacks.OnToast,
1223 OnFocusIn: callbacks.OnFocusIn,
1224 OnFocusOut: callbacks.OnFocusOut,
1225 OnAccentKeyPress: callbacks.OnAccentKeyPress,
1226 OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
1227 }
1228}
1229
1230func registerFaviconInvalidator(resolver port.FaviconResolver, invalidator port.FaviconInvalidator) {
1231 registry, ok := resolver.(port.FaviconInvalidatorRegistry)
1232 if !ok || invalidator == nil {
1233 return
1234 }
1235 registry.RegisterFaviconInvalidator(invalidator)
1236}
1237
1238func NewStandaloneOmniboxRuntime(
1239 ctx context.Context,
1240 deps *Dependencies,
1241 faviconDB port.FaviconDatabase,
1242) *StandaloneOmniboxRuntime {
1243 var faviconAdapter *adapter.FaviconAdapter
1244 if deps != nil && (deps.FaviconService != nil || deps.FaviconResolver != nil) {
1245 faviconAdapter = adapter.NewFaviconAdapterWithResolver(deps.FaviconService, deps.FaviconResolver, faviconDB, deps.FaviconAdapterConfig)
1246 registerFaviconInvalidator(deps.FaviconResolver, faviconAdapter)
1247 }
1248
1249 var runtimeCfg entity.RuntimeUIConfig
1250 if deps != nil && deps.RuntimeConfig != nil {
1251 runtimeCfg = deps.RuntimeConfig.Current().UI
1252 }
1253 var localPathResolver port.LocalPathResolver
1254 if deps != nil {
1255 localPathResolver = deps.LocalPathResolver
1256 }
1257 navigationURLNormalizer := usecase.NewNavigationURLNormalizer(localPathResolver)
1258 normalizeNavigationURL := func(navCtx context.Context, input string) string {
1259 return navigationURLNormalizer.Normalize(navCtx, input)
1260 }
1261 omniboxCfg := buildOmniboxConfig(deps, runtimeCfg, faviconAdapter, omniboxCallbacks{
1262 OnNavigate: func(navCtx context.Context, url string) error {
1263 return handleStandaloneOmniboxNavigation(deps, navCtx, url)
1264 },
1265 NormalizeNavigationURL: normalizeNavigationURL,
1266 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
1267 logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")
1268 },
1269 OnFocusIn: func(*gtk.SearchEntry) {},
1270 OnFocusOut: func() {},
1271 OnAccentKeyPress: func(uint, gdk.ModifierType) bool { return false },
1272 OnAccentKeyRelease: func(uint) {},
1273 })
1274
1275 return &StandaloneOmniboxRuntime{OmniboxCfg: omniboxCfg, ApplyTheme: func(display *gdk.Display) {
1276 if deps != nil && deps.Theme != nil && display != nil {
1277 deps.Theme.ApplyToDisplay(ctx, display)
1278 }
1279 }}
1280}
1281
1282func handleStandaloneOmniboxNavigation(deps *Dependencies, ctx context.Context, rawURL string) error {
1283 if deps == nil {
1284 return fmt.Errorf("standalone omnibox dependencies are not configured")
1285 }
1286 if urlutil.IsExternalScheme(rawURL) {
1287 if deps.LaunchExternalURL == nil {
1288 return fmt.Errorf("external launcher is not configured")
1289 }
1290 deps.LaunchExternalURL(rawURL)
1291 return nil
1292 }
1293 if deps.LaunchBrowserURL == nil {
1294 return fmt.Errorf("browser launcher is not configured")
1295 }
1296 return deps.LaunchBrowserURL(ctx, rawURL)
1297}
1298
1299func (a *App) initOmniboxConfig(ctx context.Context) {
1300 if a.deps == nil {
1301 return
1302 }
1303
1304 navigationURLNormalizer := usecase.NewNavigationURLNormalizer(nil)
1305 a.omniboxCfg = buildOmniboxConfig(a.deps, a.runtimeConfigSnapshot().UI, a.faviconAdapter, omniboxCallbacks{
1306 OnNavigate: func(navCtx context.Context, url string) error {
1307 return a.navigateFromOmnibox(navCtx, url)
1308 },
1309 NormalizeNavigationURL: func(navCtx context.Context, input string) string {
1310 if a.panesUC != nil {
1311 return a.panesUC.NormalizeNavigationURL(navCtx, input)
1312 }
1313 return navigationURLNormalizer.Normalize(navCtx, input)
1314 },
1315 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
1316 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
1317 },
1318 OnFocusIn: func(entry *gtk.SearchEntry) {
1319 // Set omnibox entry as the focused input for accent picker
1320 if a.accentFocusProvider != nil && entry != nil {
1321 if a.deps.NewGTKEntryTarget != nil {
1322 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
1323 }
1324 }
1325 },
1326 OnFocusOut: func() {
1327 // When omnibox loses focus, set WebView as the focused input
1328 if a.accentFocusProvider != nil {
1329 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1330 }
1331 },
1332 OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool {
1333 return a.handleAccentKeyPress(ctx, keyval, state)
1334 },
1335 OnAccentKeyRelease: func(keyval uint) {
1336 a.handleAccentKeyRelease(ctx, keyval)
1337 },
1338 })
1339 a.navCoord.SetOmniboxProvider(a)
1340 logging.FromContext(ctx).Debug().Msg("omnibox config stored, provider set")
1341}
1342
1343func (a *App) initFindBarConfig(ctx context.Context) {
1344 // Store find bar config (find bar is created per-pane via WorkspaceView).
1345 a.findBarCfg = component.FindBarConfig{
1346 GetFindController: func(paneID entity.PaneID) port.FindController {
1347 if a.contentCoord == nil {
1348 return nil
1349 }
1350 wv := a.contentCoord.GetWebView(paneID)
1351 if wv == nil {
1352 return nil
1353 }
1354 return wv.GetFindController()
1355 },
1356 OnFocusIn: func(entry *gtk.SearchEntry) {
1357 // Set find bar entry as the focused input for accent picker
1358 if a.accentFocusProvider != nil && entry != nil {
1359 if a.deps.NewGTKEntryTarget != nil {
1360 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
1361 }
1362 }
1363 },
1364 OnFocusOut: func() {
1365 // When find bar loses focus, set WebView as the focused input
1366 if a.accentFocusProvider != nil {
1367 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1368 }
1369 },
1370 OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool {
1371 return a.handleAccentKeyPress(ctx, keyval, state)
1372 },
1373 OnAccentKeyRelease: func(keyval uint) {
1374 a.handleAccentKeyRelease(ctx, keyval)
1375 },
1376 }
1377}
1378
1379// ToggleSessionManager shows or hides the session manager.
1380func (a *App) ToggleSessionManager(ctx context.Context) {
1381 bw := a.lastFocusedBrowserWindow()
1382 if bw == nil || bw.sessionManager == nil {
1383 return
1384 }
1385 bw.sessionManager.Toggle(ctx)
1386}
1387
1388func (a *App) attachTabPickerToActivePane() {
1389 bw := a.lastFocusedBrowserWindow()
1390 if bw == nil || bw.tabPicker == nil || a.widgetFactory == nil {
1391 return
1392 }
1393 wsView := a.activeWorkspaceView()
1394 if wsView == nil {
1395 return
1396 }
1397
1398 activeTab := a.activeTabForBrowserWindow(bw)
1399 if activeTab == nil || activeTab.Workspace == nil {
1400 return
1401 }
1402 activePaneID := activeTab.Workspace.ActivePaneID
1403 if activePaneID == "" {
1404 return
1405 }
1406 pv := wsView.GetPaneView(activePaneID)
1407 if pv == nil {
1408 return
1409 }
1410
1411 if bw.tabPickerWidget == nil {
1412 bw.tabPickerWidget = bw.tabPicker.WidgetAsLayout(a.widgetFactory)
1413 if bw.tabPickerWidget == nil {
1414 return
1415 }
1416 }
1417
1418 // If currently attached to a different pane overlay, detach.
1419 if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID {
1420 for _, view := range a.workspaceViews {
1421 if view == nil {
1422 continue
1423 }
1424 if oldPV := view.GetPaneView(bw.tabPickerPaneID); oldPV != nil {
1425 if parent := bw.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
1426 oldPV.RemoveOverlayWidget(bw.tabPickerWidget)
1427 } else if parent != nil {
1428 bw.tabPickerWidget.Unparent()
1429 }
1430 break
1431 }
1432 }
1433 }
1434
1435 // Ensure the widget can be reparented.
1436 if parent := bw.tabPickerWidget.GetParent(); parent != nil {
1437 bw.tabPickerWidget.Unparent()
1438 }
1439
1440 bw.tabPicker.SetParentOverlay(pv.Overlay())
1441 pv.AddOverlayWidget(bw.tabPickerWidget)
1442 bw.tabPickerPaneID = activePaneID
1443}
1444
1445func (a *App) HandleMovePaneToTab(ctx context.Context) error {
1446 if a.movePaneToTabUC == nil {
1447 return nil
1448 }
1449 bw := a.lastFocusedBrowserWindow()
1450 if bw == nil || bw.tabPicker == nil {
1451 return nil
1452 }
1453
1454 sourceTabs := a.tabListForBrowserWindow(bw)
1455 sourceTab := a.activeTabForBrowserWindow(bw)
1456 if sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil {
1457 return nil
1458 }
1459
1460 // If there is only 1 tab, auto-create a new tab and move there.
1461 if sourceTabs.Count() <= 1 {
1462 return a.MoveActivePaneToTab(ctx, "")
1463 }
1464
1465 items := make([]component.TabPickerItem, 0, sourceTabs.Count())
1466 for _, tab := range sourceTabs.Tabs {
1467 if tab == nil {
1468 continue
1469 }
1470 if tab.ID == sourceTab.ID {
1471 continue
1472 }
1473 items = append(items, component.TabPickerItem{
1474 TabID: tab.ID,
1475 Title: tab.Title(),
1476 IsNew: false,
1477 Index: tab.Position,
1478 })
1479 }
1480 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
1481
1482 a.attachTabPickerToActivePane()
1483 bw.tabPicker.Show(ctx, items)
1484 return nil
1485}
1486
1487func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
1488 if a.movePaneToTabUC == nil {
1489 return nil
1490 }
1491 bw := a.lastFocusedBrowserWindow()
1492 sourceTabs := a.tabListForBrowserWindow(bw)
1493 active := a.activeTabForBrowserWindow(bw)
1494 if sourceTabs == nil || active == nil {
1495 return nil
1496 }
1497
1498 nextPos := active.Position + 1
1499 if nextPos >= sourceTabs.Count() {
1500 // Create a new tab on the right.
1501 return a.MoveActivePaneToTab(ctx, "")
1502 }
1503 next := sourceTabs.TabAt(nextPos)
1504 if next == nil {
1505 return nil
1506 }
1507 return a.MoveActivePaneToTab(ctx, next.ID)
1508}
1509
1510func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
1511 in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
1512 if in == nil {
1513 return nil
1514 }
1515
1516 sourceWindow := a.browserWindowForTab(sourceTab.ID)
1517 if sourceWindow == nil {
1518 sourceWindow = a.browserWindowForMainWindow(a.mainWindow)
1519 }
1520 targetWindow := sourceWindow
1521 if targetTabID != "" {
1522 if owner := a.browserWindowForTab(targetTabID); owner != nil {
1523 targetWindow = owner
1524 }
1525 }
1526
1527 out, err := a.movePaneToTabUC.Execute(*in)
1528 if err != nil {
1529 return err
1530 }
1531 if out == nil || out.TargetTab == nil {
1532 return nil
1533 }
1534 if out.NewTabCreated && targetWindow != nil {
1535 a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)
1536 }
1537 a.syncCrossWindowMovePaneTabLists(out, sourceTab, sourceWindow, targetWindow)
1538
1539 a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow)
1540 a.switchToTargetTabIfConfigured(ctx, out, targetWindow)
1541 if a.tabCoord != nil {
1542 seen := map[*browserWindow]struct{}{}
1543 for _, bw := range []*browserWindow{sourceWindow, targetWindow} {
1544 if bw == nil {
1545 continue
1546 }
1547 if _, ok := seen[bw]; ok {
1548 continue
1549 }
1550 seen[bw] = struct{}{}
1551 a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(bw))
1552 }
1553 }
1554 a.MarkDirty()
1555 return nil
1556}
1557
1558func (a *App) moveActivePaneToTabFromBrowserWindow(ctx context.Context, bw *browserWindow, targetTabID entity.TabID) error {
1559 if bw != nil {
1560 a.activateBrowserWindow(bw)
1561 }
1562 return a.MoveActivePaneToTab(ctx, targetTabID)
1563}
1564
1565func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
1566 if a.movePaneToTabUC == nil {
1567 return nil, nil
1568 }
1569 sourceWindow := a.lastFocusedBrowserWindow()
1570 tabList := a.tabListForBrowserWindow(sourceWindow)
1571 activeTab := a.activeTabForBrowserWindow(sourceWindow)
1572 if tabList == nil || activeTab == nil || activeTab.Workspace == nil {
1573 return nil, nil
1574 }
1575 if targetTabID != "" {
1576 if targetWindow := a.browserWindowForTab(targetTabID); targetWindow != nil && targetWindow != sourceWindow {
1577 // Cross-window pane moves still pass a single derived tab list because
1578 // MovePaneToTabInput accepts one TabList. Ensure the derived global
1579 // mirror contains source and target tabs before passing a.tabs.
1580 a.syncDerivedGlobalTabMirror(activeTab)
1581 if targetWindowTabs := a.tabListForBrowserWindow(targetWindow); targetWindowTabs != nil {
1582 if targetTab := targetWindowTabs.Find(targetTabID); targetTab != nil {
1583 a.syncDerivedGlobalTabMirror(targetTab)
1584 }
1585 }
1586 if a.tabs == nil {
1587 return nil, nil
1588 }
1589 tabList = a.tabs
1590 }
1591 }
1592 sourcePaneID := activeTab.Workspace.ActivePaneID
1593 if sourcePaneID == "" {
1594 return nil, nil
1595 }
1596
1597 in := &usecase.MovePaneToTabInput{
1598 TabList: tabList,
1599 SourceTabID: activeTab.ID,
1600 SourcePaneID: sourcePaneID,
1601 TargetTabID: targetTabID,
1602 }
1603 return in, activeTab
1604}
1605
1606func (a *App) syncCrossWindowMovePaneTabLists(
1607 out *usecase.MovePaneToTabOutput,
1608 sourceTab *entity.Tab,
1609 sourceWindow *browserWindow,
1610 targetWindow *browserWindow,
1611) {
1612 if out == nil || out.TargetTab == nil || sourceWindow == nil || targetWindow == nil {
1613 return
1614 }
1615
1616 if out.NewTabCreated {
1617 targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
1618 if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil {
1619 targetTabs.Add(out.TargetTab)
1620 }
1621 }
1622
1623 if sourceWindow == targetWindow {
1624 return
1625 }
1626
1627 if out.SourceTabClosed && sourceTab != nil {
1628 if sourceTabs := a.tabListForBrowserWindow(sourceWindow); sourceTabs != nil {
1629 sourceTabs.Remove(sourceTab.ID)
1630 }
1631 }
1632
1633 if owner := a.browserWindowForTab(out.TargetTab.ID); owner == targetWindow {
1634 targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
1635 if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil {
1636 targetTabs.Add(out.TargetTab)
1637 }
1638 }
1639}
1640
1641func (a *App) applyMovePaneToTabUI(
1642 ctx context.Context,
1643 out *usecase.MovePaneToTabOutput,
1644 sourceTab *entity.Tab,
1645 sourceWindow *browserWindow,
1646 targetWindow *browserWindow,
1647) {
1648 if out == nil || out.TargetTab == nil {
1649 return
1650 }
1651 sourceTabID := entity.TabID("")
1652 if sourceTab != nil {
1653 sourceTabID = sourceTab.ID
1654 }
1655
1656 if out.SourceTabClosed {
1657 a.removeSourceTabUI(sourceTabID, sourceWindow)
1658 }
1659 if out.NewTabCreated {
1660 a.ensureTargetTabUI(ctx, out.TargetTab, targetWindow)
1661 }
1662
1663 if !out.SourceTabClosed {
1664 a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
1665 }
1666 a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
1667}
1668
1669func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserWindow) {
1670 a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
1671 delete(a.workspaceViews, sourceTabID)
1672 if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil {
1673 tabBar.RemoveTab(sourceTabID)
1674 }
1675 // Remove the tab from the derived global tab mirror after the per-window
1676 // TabList is modified.
1677 if a.tabs != nil {
1678 a.tabs.Remove(sourceTabID)
1679 }
1680}
1681
1682func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {
1683 if tab == nil {
1684 return
1685 }
1686 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
1687 tabBar.AddTab(tab)
1688 }
1689 if a.workspaceViews[tab.ID] == nil {
1690 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
1691 return
1692 }
1693 }
1694}
1695
1696func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
1697 wsView := a.workspaceViews[tabID]
1698 if wsView == nil {
1699 return
1700 }
1701 _ = wsView.Rebuild(ctx)
1702 a.reattachFloatingSessions(tabID, wsView)
1703 a.syncFloatingFocus()
1704
1705 if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
1706 return
1707 }
1708 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1709 if a.wsCoord != nil {
1710 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
1711 }
1712}
1713
1714func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.WorkspaceView) {
1715 if wsView == nil {
1716 return
1717 }
1718
1719 for key, session := range a.floatingSessions {
1720 if key.tabID != tabID || session == nil || session.pane == nil {
1721 continue
1722 }
1723
1724 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
1725 if session.widget != nil {
1726 wsView.AddWorkspaceOverlayWidget(session.widget)
1727 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
1728 setFloatingWidgetShown(session.widget, session.pane.IsVisible())
1729 }
1730 if session.paneView != nil {
1731 wsView.RegisterPaneView(session.paneID, session.paneView)
1732 if session.focusWidget == nil {
1733 session.focusWidget = session.paneView.WebViewWidget()
1734 }
1735 }
1736 }
1737}
1738
1739func (a *App) switchToTargetTabIfConfigured(
1740 ctx context.Context,
1741 out *usecase.MovePaneToTabOutput,
1742 targetWindow *browserWindow,
1743) {
1744 if out == nil || out.TargetTab == nil {
1745 return
1746 }
1747
1748 switchToTarget := false
1749 switchToTarget = a.runtimeConfigSnapshot().UI.Workspace.SwitchToTabOnMove
1750 if out.SourceTabClosed {
1751 switchToTarget = true
1752 }
1753 if !switchToTarget {
1754 return
1755 }
1756
1757 if targetWindow == nil {
1758 targetWindow = a.lastFocusedBrowserWindow()
1759 }
1760 if targetWindow != nil {
1761 a.ensureTabListForBrowserWindow(targetWindow).SetActive(out.TargetTab.ID)
1762 }
1763 // Sync derived global active state after the target window is resolved
1764 // and the per-window TabList has been updated.
1765 if a.tabs != nil && a.tabs.Find(out.TargetTab.ID) != nil {
1766 a.tabs.SetActive(out.TargetTab.ID)
1767 }
1768 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
1769 tabBar.SetActive(out.TargetTab.ID)
1770 }
1771 if targetWindow != nil {
1772 a.activateBrowserWindow(targetWindow)
1773 }
1774 a.switchWorkspaceView(ctx, out.TargetTab.ID)
1775}
1776
1777func (a *App) initSnapshotService(ctx context.Context) {
1778 log := logging.FromContext(ctx)
1779
1780 if a.deps == nil || a.deps.SnapshotServiceFactory == nil {
1781 log.Debug().Msg("snapshot service factory not available, skipping")
1782 return
1783 }
1784
1785 intervalMs := 5000 // default
1786 if runtimeIntervalMs := a.runtimeConfigSnapshot().UI.Session.SnapshotIntervalMs; runtimeIntervalMs > 0 {
1787 intervalMs = runtimeIntervalMs
1788 }
1789
1790 a.snapshotService = a.deps.SnapshotServiceFactory(a, intervalMs)
1791 if a.snapshotService == nil {
1792 log.Warn().Msg("snapshot service factory returned nil")
1793 return
1794 }
1795 a.snapshotService.Start(ctx)
1796
1797 // Set up callback for main.go to notify when session is persisted
1798 a.deps.OnSessionPersisted = func() {
1799 a.snapshotService.SetReady()
1800 }
1801
1802 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
1803}
1804
1805func (a *App) initUpdateCoordinator(ctx context.Context) {
1806 log := logging.FromContext(ctx)
1807
1808 if a.deps == nil || a.deps.CheckUpdateUC == nil {
1809 log.Debug().Msg("update use cases not available, skipping update coordinator")
1810 return
1811 }
1812
1813 runtimeCfg := a.runtimeConfigSnapshot().UI
1814 a.updateCoord = coordinator.NewUpdateCoordinator(
1815 a.deps.CheckUpdateUC,
1816 a.deps.ApplyUpdateUC,
1817 func(ctx context.Context, msg string, level component.ToastLevel) {
1818 a.showToastOnLastFocusedBrowserWindow(ctx, msg, level)
1819 },
1820 runtimeCfg.Update.EnableOnStartup,
1821 runtimeCfg.Update.AutoDownload,
1822 )
1823
1824 // Start async update check
1825 a.updateCoord.CheckOnStartup(ctx)
1826
1827 log.Debug().Msg("update coordinator initialized")
1828}
1829
1830// GetSessionID implements port.WindowStateProvider.
1831func (a *App) GetSessionID() entity.SessionID {
1832 if a.deps == nil {
1833 return ""
1834 }
1835 return a.deps.CurrentSessionID
1836}
1837
1838type windowSnapshotCapture struct {
1839 browserTabs map[string]*entity.TabList
1840 windowOrder []string
1841 globalTabs *entity.TabList
1842 tabOwners map[entity.TabID]string
1843 lastFocusedWindowID string
1844}
1845
1846// GetWindowSnapshotState implements port.WindowStateProvider.
1847// Returns per-window tab lists and active window index from one main-thread capture.
1848func (a *App) GetWindowSnapshotState() ([]entity.WindowTabListState, int) {
1849 capture, ok := a.captureWindowSnapshotState()
1850 if !ok {
1851 return nil, -1
1852 }
1853 return buildWindowSnapshotState(capture)
1854}
1855
1856func (a *App) captureWindowSnapshotState() (windowSnapshotCapture, bool) {
1857 var capture windowSnapshotCapture
1858 captureFn := func() {
1859 capture = a.captureWindowSnapshotStateOnMainThread()
1860 }
1861 if a.dispatchOnMainThread == nil {
1862 // nil dispatchOnMainThread is for single-threaded tests only;
1863 // production should marshal capture to GTK main thread.
1864 captureFn()
1865 return capture, true
1866 }
1867
1868 result := a.dispatchOnMainThread("ui.snapshot_window_state", captureFn)
1869 if result.Completed() {
1870 return capture, true
1871 }
1872
1873 ctx := context.Background()
1874 if a.deps != nil && a.deps.Ctx != nil {
1875 ctx = a.deps.Ctx
1876 }
1877 logging.FromContext(ctx).Warn().
1878 Dur("elapsed", result.Elapsed).
1879 Str("dispatch_status", string(result.Status)).
1880 Msg("ui: window state snapshot unavailable after main-thread dispatch did not complete")
1881 return windowSnapshotCapture{}, false
1882}
1883
1884func (a *App) captureWindowSnapshotStateOnMainThread() windowSnapshotCapture {
1885 capture := windowSnapshotCapture{
1886 globalTabs: entity.NewTabList(),
1887 }
1888 if a.tabs != nil {
1889 capture.globalTabs = a.tabs.Snapshot()
1890 }
1891
1892 capture.browserTabs = make(map[string]*entity.TabList, len(a.browserWindows))
1893 for id, bw := range a.browserWindows {
1894 if bw == nil {
1895 continue
1896 }
1897 if bw.tabs != nil {
1898 capture.browserTabs[id] = bw.tabs.Snapshot()
1899 } else {
1900 capture.browserTabs[id] = nil
1901 }
1902 }
1903
1904 capture.windowOrder = append([]string(nil), a.browserWindowOrder...)
1905 capture.tabOwners = make(map[entity.TabID]string, len(a.windowForTab))
1906 for tabID, bw := range a.windowForTab {
1907 if bw != nil {
1908 capture.tabOwners[tabID] = bw.id
1909 }
1910 }
1911 capture.lastFocusedWindowID = a.lastFocusedWindowID
1912 return capture
1913}
1914
1915func buildWindowSnapshotState(capture windowSnapshotCapture) ([]entity.WindowTabListState, int) {
1916 if len(capture.browserTabs) == 0 {
1917 return []entity.WindowTabListState{
1918 {WindowID: "", Tabs: capture.globalTabs},
1919 }, 0
1920 }
1921
1922 windowIDs := windowOrderFrom(capture.browserTabs, capture.windowOrder)
1923 result := make([]entity.WindowTabListState, 0, len(windowIDs))
1924 activeWindowIndex := 0
1925 anyOwnedWindow := false
1926 for i, wid := range windowIDs {
1927 if wid == capture.lastFocusedWindowID {
1928 activeWindowIndex = i
1929 }
1930
1931 tabs := capture.browserTabs[wid]
1932 if tabs == nil {
1933 tabs = tabsForWindowFromGlobalList(capture.globalTabs, capture.tabOwners, wid)
1934 }
1935 if tabs.Count() > 0 {
1936 anyOwnedWindow = true
1937 }
1938
1939 result = append(result, entity.WindowTabListState{
1940 WindowID: entity.WindowID(wid),
1941 Tabs: tabs,
1942 })
1943 }
1944 if !anyOwnedWindow && capture.globalTabs.Count() > 0 {
1945 // No window ownership populated; return the flat global list for
1946 // snapshot/export compatibility rather than snapshotting empty windows.
1947 return []entity.WindowTabListState{{WindowID: "", Tabs: capture.globalTabs}}, 0
1948 }
1949
1950 return result, activeWindowIndex
1951}
1952
1953func tabsForWindowFromGlobalList(globalTabs *entity.TabList, tabOwners map[entity.TabID]string, windowID string) *entity.TabList {
1954 tabs := entity.NewTabList()
1955 if globalTabs == nil {
1956 return tabs
1957 }
1958 for _, tab := range globalTabs.Tabs {
1959 if tabOwners[tab.ID] == windowID {
1960 tabs.Add(cloneTabForGlobalList(tab))
1961 }
1962 }
1963
1964 // Derive active tab from the global list when no per-window TabList exists
1965 // only for snapshot/export compatibility with older flat snapshot state.
1966 globalActive := globalTabs.ActiveTabID
1967 if globalActive != "" && tabs.Find(globalActive) != nil {
1968 tabs.SetActive(globalActive)
1969 }
1970 return tabs
1971}
1972
1973// GetWindowTabLists implements port.WindowStateProvider.
1974// Returns per-window tab lists in browser window registration order.
1975// Falls back to a single window with the shared tab list when no browser windows exist.
1976func (a *App) GetWindowTabLists() []entity.WindowTabListState {
1977 windows, _ := a.GetWindowSnapshotState()
1978 return windows
1979}
1980
1981// GetActiveWindowIndex implements port.WindowStateProvider.
1982// Returns the index of the last focused window in registration order.
1983func (a *App) GetActiveWindowIndex() int {
1984 _, activeWindowIndex := a.GetWindowSnapshotState()
1985 return activeWindowIndex
1986}
1987
1988// tabListForBrowserWindow returns the per-window TabList for a browser window.
1989// Returns nil when bw is nil or bw.tabs has not been initialized.
1990func (a *App) tabListForBrowserWindow(bw *browserWindow) *entity.TabList {
1991 if bw == nil {
1992 return nil
1993 }
1994 return bw.tabs
1995}
1996
1997// ensureTabListForBrowserWindow returns the per-window TabList for a browser window,
1998// lazy-initializing bw.tabs when nil. Returns nil when bw is nil.
1999func (a *App) ensureTabListForBrowserWindow(bw *browserWindow) *entity.TabList {
2000 if bw == nil {
2001 return nil
2002 }
2003 if bw.tabs == nil {
2004 bw.tabs = entity.NewTabList()
2005 }
2006 return bw.tabs
2007}
2008
2009// activeTabForBrowserWindow returns the active tab for a browser window.
2010// Returns nil when bw is nil or the window has no tabs.
2011func (a *App) activeTabForBrowserWindow(bw *browserWindow) *entity.Tab {
2012 tabs := a.tabListForBrowserWindow(bw)
2013 if tabs == nil {
2014 return nil
2015 }
2016 return tabs.ActiveTab()
2017}
2018
2019// activeWorkspaceForBrowserWindow returns the active workspace for a browser window.
2020// Returns nil when bw is nil or the active tab has no workspace.
2021func (a *App) activeWorkspaceForBrowserWindow(bw *browserWindow) *entity.Workspace {
2022 tab := a.activeTabForBrowserWindow(bw)
2023 if tab == nil {
2024 return nil
2025 }
2026 return tab.Workspace
2027}
2028
2029func (a *App) activeWorkspaceViewForBrowserWindow(bw *browserWindow) *component.WorkspaceView {
2030 tab := a.activeTabForBrowserWindow(bw)
2031 if tab == nil {
2032 return nil
2033 }
2034 return a.workspaceViews[tab.ID]
2035}
2036
2037// hideAndRestoreFocusForBrowserWindow hides the sidebar and restores focus to
2038// the active content pane/webview of the given browser window.
2039func (a *App) hideAndRestoreFocusForBrowserWindow(bw *browserWindow) {
2040 if bw == nil {
2041 return
2042 }
2043 bw.hideHistorySidebar()
2044 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
2045 if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {
2046 wsView.FocusPane(ws.ActivePaneID)
2047 }
2048 }
2049}
2050
2051// tabTargetForBrowserWindow returns a coordinator.TabTarget scoped to the given browser window.
2052// It does not allocate a TabList; callers that mutate tabs should use
2053// ensureTabTargetForBrowserWindow instead.
2054func (a *App) tabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget {
2055 if bw == nil {
2056 return coordinator.TabTarget{}
2057 }
2058 return coordinator.TabTarget{
2059 Tabs: a.tabListForBrowserWindow(bw),
2060 MainWindow: bw.mainWindow,
2061 }
2062}
2063
2064func (a *App) ensureTabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget {
2065 if bw == nil {
2066 return coordinator.TabTarget{}
2067 }
2068 return coordinator.TabTarget{
2069 Tabs: a.ensureTabListForBrowserWindow(bw),
2070 MainWindow: bw.mainWindow,
2071 }
2072}
2073
2074// activePaneIDForBrowserWindow resolves the active pane ID for a browser window.
2075// A content-coordinator override wins so floating panes can receive navigation.
2076// Returns the empty string when bw is nil or the active tab has no workspace.
2077func (a *App) activePaneIDForBrowserWindow(bw *browserWindow) entity.PaneID {
2078 if a.contentCoord != nil && bw != nil {
2079 if paneID, ok := a.contentCoord.ActivePaneOverrideID(); ok && a.browserWindowForAnyPane(paneID) == bw {
2080 return paneID
2081 }
2082 }
2083 ws := a.activeWorkspaceForBrowserWindow(bw)
2084 if ws == nil {
2085 return ""
2086 }
2087 return ws.ActivePaneID
2088}
2089
2090// activeWebViewForBrowserWindow resolves the active WebView for a browser window.
2091// Uses the active pane ID and contentCoord.GetWebView (public getter) — never
2092// contentCoord.ActiveWebView — so that stale global focus cannot be picked.
2093func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, port.WebView) {
2094 paneID := a.activePaneIDForBrowserWindow(bw)
2095 if paneID == "" {
2096 return "", nil
2097 }
2098 if a.contentCoord == nil {
2099 return paneID, nil
2100 }
2101 return paneID, a.contentCoord.GetWebView(paneID)
2102}
2103
2104// omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
2105// navigation through navigate for the supplied browser window. The closure captures
2106// bw at creation time, so navigation targets the owning window even when another
2107// window is globally focused.
2108func omniboxNavigateForBrowserWindow(
2109 ctx context.Context,
2110 bw *browserWindow,
2111 navigate func(context.Context, *browserWindow, string) error,
2112) func(context.Context, string) error {
2113 return func(navCtx context.Context, url string) error {
2114 if navCtx == nil {
2115 navCtx = ctx
2116 }
2117 return navigate(navCtx, bw, url)
2118 }
2119}
2120
2121// navigateFromBrowserWindow loads a URL in the active pane of the given browser window.
2122// Uses NavigationCoordinator.NavigateWebView for explicit target resolution.
2123func (a *App) navigateFromBrowserWindow(ctx context.Context, bw *browserWindow, rawURL string) error {
2124 if a.navCoord == nil {
2125 return fmt.Errorf("navigation coordinator not initialized")
2126 }
2127 paneID, wv := a.activeWebViewForBrowserWindow(bw)
2128 return a.navCoord.NavigateWebView(ctx, rawURL, paneID, wv)
2129}
2130
2131// withBrowserWindowWebView resolves the active WebView for bw, checks that navCoord
2132// is initialized and wv is not nil, then runs fn with the WebView. Avoids nil bw
2133// panic in error formatting by falling back to empty window id.
2134func (a *App) withBrowserWindowWebView(_ context.Context, bw *browserWindow, fn func(wv port.WebView) error) error {
2135 if a.navCoord == nil {
2136 return fmt.Errorf("navigation coordinator not initialized")
2137 }
2138 _, wv := a.activeWebViewForBrowserWindow(bw)
2139 if wv == nil {
2140 windowID := ""
2141 if bw != nil {
2142 windowID = bw.id
2143 }
2144 return fmt.Errorf("no active webview for browser window %s", windowID)
2145 }
2146 return fn(wv)
2147}
2148
2149// reloadBrowserWindow reloads the active page in the given browser window.
2150// When bypassCache is true, bypasses the cache (hard reload).
2151func (a *App) reloadBrowserWindow(ctx context.Context, bw *browserWindow, bypassCache bool) error {
2152 return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2153 return a.navCoord.ReloadWebView(ctx, wv, bypassCache)
2154 })
2155}
2156
2157// stopBrowserWindow stops loading in the active pane of the given browser window.
2158func (a *App) stopBrowserWindow(ctx context.Context, bw *browserWindow) error {
2159 return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2160 return a.navCoord.StopWebView(ctx, wv)
2161 })
2162}
2163
2164// goBackBrowserWindow navigates back in history for the active pane of the given browser window.
2165func (a *App) goBackBrowserWindow(ctx context.Context, bw *browserWindow) error {
2166 return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2167 return a.navCoord.GoBackWebView(ctx, wv)
2168 })
2169}
2170
2171// goForwardBrowserWindow navigates forward in history for the active pane of the given browser window.
2172func (a *App) goForwardBrowserWindow(ctx context.Context, bw *browserWindow) error {
2173 return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2174 return a.navCoord.GoForwardWebView(ctx, wv)
2175 })
2176}
2177
2178func (a *App) printBrowserWindow(ctx context.Context, bw *browserWindow) error {
2179 return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2180 return a.navCoord.PrintWebView(ctx, wv)
2181 })
2182}
2183
2184func (a *App) openDevToolsBrowserWindow(ctx context.Context, bw *browserWindow) error {
2185 return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2186 return a.navCoord.OpenDevToolsWebView(ctx, wv)
2187 })
2188}
2189
2190func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action string) error {
2191 if a.deps == nil || a.deps.ZoomUC == nil {
2192 logging.FromContext(ctx).Warn().Msg("zoom use case not available")
2193 return nil
2194 }
2195
2196 paneID, wv := a.activeWebViewForBrowserWindow(bw)
2197 if wv == nil {
2198 logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for zoom")
2199 return nil
2200 }
2201
2202 zoomKey, err := usecase.ExtractZoomKey(wv.URI())
2203 if err != nil {
2204 logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
2205 return nil
2206 }
2207
2208 current := wv.GetZoomLevel()
2209 var newZoom *entity.ZoomLevel
2210 switch action {
2211 case "in":
2212 newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, zoomKey, current)
2213 case "out":
2214 newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, zoomKey, current)
2215 case "reset":
2216 err = a.deps.ZoomUC.ResetZoom(ctx, zoomKey)
2217 if err == nil {
2218 newZoom = entity.NewZoomLevel(zoomKey, a.deps.ZoomUC.DefaultZoom())
2219 }
2220 default:
2221 return fmt.Errorf("unknown zoom action %q", action)
2222 }
2223 if err != nil {
2224 return err
2225 }
2226 if newZoom == nil {
2227 return nil
2228 }
2229 if err := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); err != nil {
2230 return err
2231 }
2232 if a.navCoord != nil {
2233 a.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
2234 }
2235 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
2236 if paneView := wsView.GetPaneView(paneID); paneView != nil {
2237 paneView.ShowZoomToast(ctx, int(newZoom.ZoomFactor*100))
2238 }
2239 }
2240 return nil
2241}
2242
2243const (
2244 tabSwitchIndex0 = iota // 0
2245 tabSwitchIndex1 // 1
2246 tabSwitchIndex2 // 2
2247 tabSwitchIndex3 // 3
2248 tabSwitchIndex4 // 4
2249 tabSwitchIndex5 // 5
2250 tabSwitchIndex6 // 6
2251 tabSwitchIndex7 // 7
2252 tabSwitchIndex8 // 8
2253 tabSwitchIndex9 // 9
2254)
2255
2256// switchTabIndexActionIndex maps ActionSwitchTabIndex1..10 to 0..9.
2257func switchTabIndexActionIndex(action input.Action) (int, bool) {
2258 switch action {
2259 case input.ActionSwitchTabIndex1:
2260 return tabSwitchIndex0, true
2261 case input.ActionSwitchTabIndex2:
2262 return tabSwitchIndex1, true
2263 case input.ActionSwitchTabIndex3:
2264 return tabSwitchIndex2, true
2265 case input.ActionSwitchTabIndex4:
2266 return tabSwitchIndex3, true
2267 case input.ActionSwitchTabIndex5:
2268 return tabSwitchIndex4, true
2269 case input.ActionSwitchTabIndex6:
2270 return tabSwitchIndex5, true
2271 case input.ActionSwitchTabIndex7:
2272 return tabSwitchIndex6, true
2273 case input.ActionSwitchTabIndex8:
2274 return tabSwitchIndex7, true
2275 case input.ActionSwitchTabIndex9:
2276 return tabSwitchIndex8, true
2277 case input.ActionSwitchTabIndex10:
2278 return tabSwitchIndex9, true
2279 default:
2280 return 0, false
2281 }
2282}
2283
2284// dispatchBrowserWindowAction routes keyboard/global shortcut actions through the
2285// source browser window helpers instead of falling back to the global active
2286// webview/tab. Browser-window-owning callbacks (keyboardHandler, globalShortcutHandler)
2287// must call this instead of activating the window then dispatching globally.
2288func (a *App) dispatchBrowserWindowAction(ctx context.Context, bw *browserWindow, action input.Action) error {
2289 switch action {
2290 case input.ActionReload:
2291 return a.reloadBrowserWindow(ctx, bw, false)
2292 case input.ActionHardReload:
2293 return a.reloadBrowserWindow(ctx, bw, true)
2294 case input.ActionStop:
2295 return a.stopBrowserWindow(ctx, bw)
2296 case input.ActionGoBack:
2297 return a.goBackBrowserWindow(ctx, bw)
2298 case input.ActionGoForward:
2299 return a.goForwardBrowserWindow(ctx, bw)
2300 case input.ActionPrintPage:
2301 return a.printBrowserWindow(ctx, bw)
2302 case input.ActionOpenDevTools:
2303 return a.openDevToolsBrowserWindow(ctx, bw)
2304 case input.ActionZoomIn:
2305 return a.zoomBrowserWindow(ctx, bw, "in")
2306 case input.ActionZoomOut:
2307 return a.zoomBrowserWindow(ctx, bw, "out")
2308 case input.ActionZoomReset:
2309 return a.zoomBrowserWindow(ctx, bw, "reset")
2310 case input.ActionSwitchTabIndex1, input.ActionSwitchTabIndex2, input.ActionSwitchTabIndex3,
2311 input.ActionSwitchTabIndex4, input.ActionSwitchTabIndex5, input.ActionSwitchTabIndex6,
2312 input.ActionSwitchTabIndex7, input.ActionSwitchTabIndex8, input.ActionSwitchTabIndex9,
2313 input.ActionSwitchTabIndex10:
2314 index, ok := switchTabIndexActionIndex(action)
2315 if !ok {
2316 return nil
2317 }
2318 return a.switchBrowserWindowTabIndex(ctx, bw, index)
2319 default:
2320 if a.kbDispatcher != nil {
2321 return a.kbDispatcher.Dispatch(ctx, action)
2322 }
2323 return nil
2324 }
2325}
2326
2327// switchBrowserWindowTabIndex activates the given browser window and switches to
2328// the tab at the given 0-based index in that window.
2329//
2330// If the requested tab already exists, it switches to it. If the index is out
2331// of range, it creates exactly one new tab instead of backfilling all missing
2332// intermediate tab slots.
2333func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error {
2334 if a.tabCoord == nil {
2335 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: tab coordinator is nil")
2336 return nil
2337 }
2338 if bw == nil {
2339 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window")
2340 return nil
2341 }
2342 if index < 0 {
2343 logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index ignored: invalid negative index")
2344 return nil
2345 }
2346 target := a.tabTargetForBrowserWindow(bw)
2347 tabCount := 0
2348 if target.Tabs != nil {
2349 tabCount = target.Tabs.Count()
2350 }
2351 if index < tabCount {
2352 a.activateBrowserWindow(bw)
2353 return a.tabCoord.SwitchByIndex(ctx, target, index)
2354 }
2355 newPaneURL := a.runtimeConfigSnapshot().UI.Workspace.NewPaneURL
2356 if newPaneURL == "" {
2357 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: new pane URL is not configured")
2358 return fmt.Errorf("newPaneURL is not configured")
2359 }
2360 a.activateBrowserWindow(bw)
2361 ensureTarget := a.ensureTabTargetForBrowserWindow(bw)
2362 _, err := a.tabCoord.Create(ctx, ensureTarget, newPaneURL)
2363 return err
2364}
2365
2366// windowOrder returns window IDs in registration order.
2367// Falls back to sorted map keys when no registration order is recorded
2368// (e.g., in old tests or direct map manipulation).
2369func (a *App) windowOrder() []string {
2370 if len(a.browserWindowOrder) > 0 {
2371 // Deduplicate against browserWindows, skipping stale entries.
2372 seen := make(map[string]bool, len(a.browserWindowOrder))
2373 result := make([]string, 0, len(a.browserWindowOrder))
2374 for _, wid := range a.browserWindowOrder {
2375 if seen[wid] || a.browserWindows[wid] == nil {
2376 continue
2377 }
2378 seen[wid] = true
2379 result = append(result, wid)
2380 }
2381 // Include any live browserWindows not yet tracked in order
2382 // (inserted via direct map manipulation) deterministically.
2383 untracked := make([]string, 0)
2384 for id, bw := range a.browserWindows {
2385 if bw != nil && !seen[id] {
2386 untracked = append(untracked, id)
2387 }
2388 }
2389 sort.Strings(untracked)
2390 return append(result, untracked...)
2391 }
2392
2393 // Fallback: sort map keys for deterministic output.
2394 windowIDs := make([]string, 0, len(a.browserWindows))
2395 for id := range a.browserWindows {
2396 windowIDs = append(windowIDs, id)
2397 }
2398 sort.Strings(windowIDs)
2399 return windowIDs
2400}
2401
2402func windowOrderFrom(browserTabs map[string]*entity.TabList, browserWindowOrder []string) []string {
2403 if len(browserWindowOrder) > 0 {
2404 seen := make(map[string]bool, len(browserWindowOrder))
2405 result := make([]string, 0, len(browserWindowOrder))
2406 for _, wid := range browserWindowOrder {
2407 if seen[wid] {
2408 continue
2409 }
2410 if _, ok := browserTabs[wid]; !ok {
2411 continue
2412 }
2413 seen[wid] = true
2414 result = append(result, wid)
2415 }
2416 untracked := make([]string, 0)
2417 for id := range browserTabs {
2418 if !seen[id] {
2419 untracked = append(untracked, id)
2420 }
2421 }
2422 sort.Strings(untracked)
2423 return append(result, untracked...)
2424 }
2425
2426 windowIDs := make([]string, 0, len(browserTabs))
2427 for id := range browserTabs {
2428 windowIDs = append(windowIDs, id)
2429 }
2430 sort.Strings(windowIDs)
2431 return windowIDs
2432}
2433
2434// MarkDirty signals that session state has changed and should be saved.
2435func (a *App) MarkDirty() {
2436 if a.snapshotService != nil {
2437 a.snapshotService.MarkDirty()
2438 }
2439}
2440
2441func (a *App) createInitialTab(ctx context.Context) {
2442 log := logging.FromContext(ctx)
2443
2444 // Check if we should restore a session
2445 if a.deps != nil && a.deps.RestoreSessionID != "" {
2446 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
2447 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
2448 // Show error toast and fall through to create default tab
2449 a.showToastOnLastFocusedBrowserWindow(ctx, "Session restore failed", component.ToastWarning)
2450 } else {
2451 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
2452 // Show success toast for session restoration
2453 a.showToastOnLastFocusedBrowserWindow(ctx, "Session restored", component.ToastSuccess)
2454 return
2455 }
2456 }
2457
2458 focusedWindow := a.lastFocusedBrowserWindow()
2459
2460 // Create an initial tab using coordinator.
2461 target := a.ensureTabTargetForBrowserWindow(focusedWindow)
2462 if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {
2463 log.Error().Err(err).Msg("failed to create initial tab")
2464 }
2465}
2466
2467func (a *App) clearRuntimeWindowUI(runtimeWindows []*browserWindow) {
2468 for _, bw := range runtimeWindows {
2469 if bw == nil || bw.mainWindow == nil {
2470 continue
2471 }
2472 if tabBar := bw.mainWindow.TabBar(); tabBar != nil {
2473 if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
2474 for _, tab := range tabs.Tabs {
2475 if tab != nil {
2476 tabBar.RemoveTab(tab.ID)
2477 }
2478 }
2479 }
2480 }
2481 bw.mainWindow.SetContent(nil)
2482 }
2483}
2484
2485func (a *App) clearSessionRestoreUIState(ctx context.Context) {
2486 if a == nil {
2487 return
2488 }
2489
2490 tabIDs := make(map[entity.TabID]struct{})
2491 for _, bw := range a.browserWindows {
2492 if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
2493 for _, tab := range tabs.Tabs {
2494 if tab != nil {
2495 tabIDs[tab.ID] = struct{}{}
2496 }
2497 }
2498 }
2499 }
2500 for tabID := range a.workspaceViews {
2501 tabIDs[tabID] = struct{}{}
2502 }
2503 for tabID := range a.windowForTab {
2504 tabIDs[tabID] = struct{}{}
2505 }
2506
2507 var tabBar *component.TabBar
2508 if a.mainWindow != nil {
2509 tabBar = a.mainWindow.TabBar()
2510 }
2511 for tabID := range tabIDs {
2512 if tabBar != nil {
2513 tabBar.RemoveTab(tabID)
2514 }
2515 a.releaseFloatingSessionsForTab(ctx, tabID)
2516 delete(a.workspaceViews, tabID)
2517 delete(a.windowForTab, tabID)
2518 }
2519 if a.mainWindow != nil {
2520 a.mainWindow.SetContent(nil)
2521 }
2522}
2523
2524func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
2525 state, restoredWindows, err := a.loadRestoredWindowStates(ctx, sessionID)
2526 if err != nil {
2527 return err
2528 }
2529
2530 logging.FromContext(ctx).Info().
2531 Int("windows", len(restoredWindows)).
2532 Int("panes", state.CountPanes()).
2533 Msg("restoring session state")
2534
2535 if len(restoredWindows) == 0 {
2536 if state.Version >= entity.SessionStateVersion {
2537 // v2+ empty-window snapshots are valid: replace with a single empty window.
2538 restoredWindows = []entity.WindowTabListState{{
2539 WindowID: "",
2540 Tabs: entity.NewTabList(),
2541 }}
2542 } else {
2543 return fmt.Errorf("restored session contains no windows")
2544 }
2545 }
2546
2547 runtimeWindows, firstBWReused, err := a.prepareRuntimeWindows(ctx, len(restoredWindows))
2548 if err != nil {
2549 return err
2550 }
2551 a.clearSessionRestoreUIState(ctx)
2552 a.clearRuntimeWindowUI(runtimeWindows)
2553 a.assignRuntimeWindowTabLists(runtimeWindows, restoredWindows, firstBWReused)
2554 a.pruneStaleBrowserWindows(runtimeWindows)
2555
2556 activeBW := runtimeWindows[safeWindowIndex(state.ActiveWindowIndex, len(runtimeWindows))]
2557 a.activateBrowserWindow(activeBW)
2558 a.replaceGlobalTabsFromRuntimeWindows(runtimeWindows, activeBW)
2559 a.buildRestoredWindowUI(ctx, runtimeWindows)
2560 a.showRestoredRuntimeWindows(runtimeWindows)
2561
2562 return nil
2563}
2564
2565func (a *App) loadRestoredWindowStates(
2566 ctx context.Context,
2567 sessionID entity.SessionID,
2568) (*entity.SessionState, []entity.WindowTabListState, error) {
2569 if a.deps == nil || a.deps.SessionStateRepo == nil {
2570 return nil, nil, fmt.Errorf("session state repo not available")
2571 }
2572
2573 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
2574 if err != nil {
2575 return nil, nil, fmt.Errorf("load session state: %w", err)
2576 }
2577 if state == nil {
2578 return nil, nil, fmt.Errorf("session state not found")
2579 }
2580
2581 restoredWindows := entity.WindowTabListsFromSnapshot(state, a.generateID)
2582 return state, restoredWindows, nil
2583}
2584
2585func (a *App) prepareRuntimeWindows(ctx context.Context, windowCount int) ([]*browserWindow, bool, error) {
2586 firstBW := a.browserWindowForMainWindow(a.mainWindow)
2587 if firstBW == nil {
2588 firstBW = a.lastFocusedBrowserWindow()
2589 }
2590 firstBWReused := firstBW != nil
2591 createdWindows := make([]*browserWindow, 0)
2592
2593 if firstBW == nil {
2594 if a.mainWindow != nil {
2595 firstBW = a.wrapExistingMainWindowForRestore(ctx, a.mainWindow)
2596 } else {
2597 created, err := a.createBrowserWindow(ctx, "")
2598 if err != nil {
2599 return nil, false, fmt.Errorf("create browser window for restore: %w", err)
2600 }
2601 firstBW = created
2602 createdWindows = append(createdWindows, created)
2603 }
2604 }
2605
2606 runtimeWindows := make([]*browserWindow, windowCount)
2607 runtimeWindows[0] = firstBW
2608 for i := 1; i < windowCount; i++ {
2609 extraBW, err := a.createBrowserWindow(ctx, "")
2610 if err != nil {
2611 a.cleanupCreatedBrowserWindows(createdWindows)
2612 return nil, false, fmt.Errorf("create browser window %d for restore: %w", i, err)
2613 }
2614 createdWindows = append(createdWindows, extraBW)
2615 runtimeWindows[i] = extraBW
2616 }
2617
2618 return runtimeWindows, firstBWReused, nil
2619}
2620
2621func (a *App) wrapExistingMainWindowForRestore(ctx context.Context, mainWindow *window.MainWindow) *browserWindow {
2622 bw := &browserWindow{id: a.generateWindowID(), tabs: entity.NewTabList(), mainWindow: mainWindow}
2623 a.wireBrowserWindowActivationTracking(bw)
2624 if a.kbDispatcher != nil {
2625 a.initBrowserWindowInput(ctx, bw)
2626 }
2627 if a.tabCoord != nil {
2628 a.wireBrowserWindowTabBar(ctx, bw)
2629 }
2630 bw.initChrome(ctx, a)
2631 return bw
2632}
2633
2634func (a *App) cleanupCreatedBrowserWindows(createdWindows []*browserWindow) {
2635 for _, bw := range createdWindows {
2636 if bw == nil {
2637 continue
2638 }
2639 mainWindow := bw.mainWindow
2640 if _, tracked := a.browserWindows[bw.id]; tracked {
2641 a.removeBrowserWindow(bw.id)
2642 } else {
2643 a.cleanupTransientBrowserWindowForDestroy(bw)
2644 }
2645 if mainWindow != nil {
2646 if a.mainWindow == mainWindow {
2647 a.mainWindow = nil
2648 }
2649 mainWindow.Destroy()
2650 }
2651 }
2652}
2653
2654func (a *App) assignRuntimeWindowTabLists(
2655 runtimeWindows []*browserWindow,
2656 restoredWindows []entity.WindowTabListState,
2657 firstBWReused bool,
2658) {
2659 for i, bw := range runtimeWindows {
2660 if i > 0 || !firstBWReused {
2661 a.registerBrowserWindow(bw)
2662 }
2663 a.assignRestoredWindowTabList(bw, restoredWindows[i])
2664 }
2665}
2666
2667func (a *App) pruneStaleBrowserWindows(runtimeWindows []*browserWindow) {
2668 runtimeSet := make(map[string]bool, len(runtimeWindows))
2669 for _, bw := range runtimeWindows {
2670 runtimeSet[bw.id] = true
2671 }
2672 for id, bw := range a.browserWindows {
2673 if !runtimeSet[id] {
2674 var mainWindowToDestroy *window.MainWindow
2675 if bw != nil {
2676 mainWindowToDestroy = bw.mainWindow
2677 }
2678 a.removeBrowserWindow(id)
2679 if mainWindowToDestroy != nil {
2680 mainWindowToDestroy.Destroy()
2681 }
2682 }
2683 }
2684
2685 a.browserWindowOrder = make([]string, len(runtimeWindows))
2686 for i, bw := range runtimeWindows {
2687 a.browserWindowOrder[i] = bw.id
2688 }
2689}
2690
2691func safeWindowIndex(index, count int) int {
2692 if index < 0 || index >= count {
2693 return 0
2694 }
2695 return index
2696}
2697
2698func (a *App) syncDerivedGlobalTabMirror(tab *entity.Tab) {
2699 if a.tabs != nil && tab != nil && a.tabs.Find(tab.ID) == nil {
2700 a.tabs.Add(cloneTabForGlobalList(tab))
2701 }
2702}
2703
2704// workspace pointer is intentionally shared because App.tabs is a derived
2705// snapshot/export mirror, not runtime command target; runtime mutations must
2706// target browserWindow tabs.
2707func cloneTabForGlobalList(tab *entity.Tab) *entity.Tab {
2708 if tab == nil {
2709 return nil
2710 }
2711 return &entity.Tab{
2712 ID: tab.ID,
2713 Name: tab.Name,
2714 Workspace: tab.Workspace,
2715 IsPinned: tab.IsPinned,
2716 CreatedAt: tab.CreatedAt,
2717 }
2718}
2719
2720func (a *App) replaceGlobalTabsFromRuntimeWindows(runtimeWindows []*browserWindow, activeBW *browserWindow) {
2721 globalTabs := entity.NewTabList()
2722 for _, bw := range runtimeWindows {
2723 perWinTabs := a.tabListForBrowserWindow(bw)
2724 if perWinTabs == nil {
2725 continue
2726 }
2727 for _, tab := range perWinTabs.Tabs {
2728 if cloned := cloneTabForGlobalList(tab); cloned != nil {
2729 globalTabs.Add(cloned)
2730 }
2731 }
2732 }
2733
2734 if activeTabs := a.tabListForBrowserWindow(activeBW); activeTabs != nil {
2735 activeID := activeTabs.ActiveTabID
2736 if activeID != "" && globalTabs.Find(activeID) != nil {
2737 globalTabs.SetActive(activeID)
2738 }
2739 }
2740 a.tabs.ReplaceFrom(globalTabs)
2741}
2742
2743func (a *App) showRestoredRuntimeWindows(runtimeWindows []*browserWindow) {
2744 for _, bw := range runtimeWindows {
2745 if bw != nil && bw.mainWindow != nil {
2746 bw.mainWindow.Show()
2747 }
2748 }
2749}
2750
2751func (a *App) buildRestoredWindowUI(ctx context.Context, runtimeWindows []*browserWindow) {
2752 for _, bw := range runtimeWindows {
2753 perWinTabs := a.tabListForBrowserWindow(bw)
2754 if perWinTabs == nil || perWinTabs.Count() == 0 {
2755 a.updateBrowserWindowTabBarVisibility(bw)
2756 continue
2757 }
2758
2759 tabBar := bw.mainWindow.TabBar()
2760 activeTab := perWinTabs.ActiveTab()
2761 for _, tab := range perWinTabs.Tabs {
2762 a.buildRestoredTabUI(ctx, bw, tabBar, tab)
2763 }
2764
2765 if activeTab != nil {
2766 a.switchWorkspaceView(ctx, activeTab.ID)
2767 if tabBar != nil {
2768 tabBar.SetActive(activeTab.ID)
2769 }
2770 }
2771 a.updateBrowserWindowTabBarVisibility(bw)
2772 }
2773}
2774
2775func (a *App) buildRestoredTabUI(ctx context.Context, bw *browserWindow, tabBar *component.TabBar, tab *entity.Tab) {
2776 if tab == nil {
2777 return
2778 }
2779 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
2780 return
2781 }
2782 wsView := a.workspaceViews[tab.ID]
2783 if a.wsCoord != nil && wsView != nil {
2784 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
2785 }
2786 if tabBar != nil {
2787 tabBar.AddTab(tab)
2788 }
2789 logging.FromContext(ctx).Debug().
2790 Str("tab_id", string(tab.ID)).
2791 Str("name", tab.Name).
2792 Str("window_id", bw.id).
2793 Int("panes", tab.Workspace.PaneCount()).
2794 Msg("restored tab with workspace")
2795}
2796
2797// assignRestoredWindowTabList assigns a restored WindowTabListState to a runtime browser window.
2798// It sets bw.tabs to the restored TabList (or a new empty one if nil) and registers each
2799// tab's ownership via windowForTab. It never inserts saved window IDs into browserWindows;
2800// the runtime window ID is owned by the caller.
2801func (a *App) assignRestoredWindowTabList(bw *browserWindow, restored entity.WindowTabListState) {
2802 if bw == nil {
2803 return
2804 }
2805
2806 a.ensureWindowForTabMap()
2807
2808 if restored.Tabs == nil {
2809 bw.tabs = entity.NewTabList()
2810 return
2811 }
2812
2813 bw.tabs = restored.Tabs
2814 for _, tab := range restored.Tabs.Tabs {
2815 if tab != nil {
2816 a.windowForTab[tab.ID] = bw
2817 }
2818 }
2819}
2820
2821func (a *App) finalizeActivation(ctx context.Context) {
2822 log := logging.FromContext(ctx)
2823
2824 // Show the window
2825 if a.mainWindow != nil {
2826 a.mainWindow.Show()
2827 }
2828 a.startBrowserLaunchRelayListener(ctx)
2829 log.Info().Msg("main window displayed")
2830
2831 if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
2832 a.showCrashReportToast(ctx, a.deps.StartupCrashReports)
2833 }
2834
2835 // Defer non-critical initialization until after first navigation starts.
2836 // This keeps pool prewarm, config watcher, and filter loading from
2837 // competing with the initial page load.
2838 a.runAfterFirstLoadStarted(func() {
2839 a.prewarmWebViewPoolAsync(ctx)
2840 a.initConfigWatcher(ctx)
2841 a.initFilteringAsync(ctx)
2842 a.checkConfigMigration(ctx)
2843 })
2844}
2845
2846func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
2847 if len(paths) == 0 {
2848 return
2849 }
2850 log := logging.FromContext(ctx)
2851 log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
2852
2853 cb := glib.SourceFunc(func(_ uintptr) bool {
2854 msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
2855 a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastWarning,
2856 component.WithDuration(crashReportToastDurationMs),
2857 component.WithPosition(component.ToastPositionBottomRight),
2858 )
2859 return false
2860 })
2861 glib.IdleAdd(&cb, 0)
2862}
2863
2864// runAfterFirstLoadStarted schedules work to run after the first navigation starts.
2865// This keeps the GTK main loop free to process the initial load_uri() quickly,
2866// reducing the gap between webview_attached and load_started.
2867func (a *App) runAfterFirstLoadStarted(fn func()) {
2868 a.deferredInitFn = fn
2869}
2870
2871// triggerDeferredInit is called from content.Coordinator on first load_started.
2872// It runs deferred initialization at LOW priority so navigation continues unblocked.
2873func (a *App) triggerDeferredInit(ctx context.Context) {
2874 a.deferredInitOnce.Do(func() {
2875 if a.deferredInitFn == nil {
2876 return
2877 }
2878 log := logging.FromContext(ctx)
2879 log.Debug().Msg("triggering deferred initialization")
2880
2881 fn := a.deferredInitFn
2882 cb := glib.SourceFunc(func(_ uintptr) bool {
2883 fn()
2884 return false
2885 })
2886 // Use LOW priority so navigation processing continues first
2887 glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
2888 })
2889}
2890
2891// onShutdown is called when the GTK application is shutting down.
2892func (a *App) onShutdown(ctx context.Context) {
2893 log := logging.FromContext(ctx)
2894 log.Debug().Msg("GTK application shutting down")
2895
2896 // Save final session state before shutdown
2897 if a.snapshotService != nil {
2898 if err := a.snapshotService.Stop(ctx); err != nil {
2899 log.Warn().Err(err).Msg("failed to save final session state")
2900 }
2901 }
2902
2903 // Apply staged update if available (before cleanup)
2904 if a.updateCoord != nil {
2905 if err := a.updateCoord.FinalizeOnExit(ctx); err != nil {
2906 log.Warn().Err(err).Msg("failed to apply staged update")
2907 }
2908 }
2909
2910 // Stop accepting relaunches before teardown starts.
2911 a.closeBrowserLaunchRelayListener()
2912 if a.deps != nil && a.deps.ExternalThemeWatcher != nil {
2913 if err := a.deps.ExternalThemeWatcher.Stop(); err != nil {
2914 log.Warn().Err(err).Msg("failed to stop external theme watcher")
2915 }
2916 }
2917
2918 // Cancel context to signal all goroutines
2919 a.cancel(errors.New("application shutdown"))
2920
2921 // Cleanup resources
2922 a.closeHistoryRecorder()
2923 if a.faviconAdapter != nil {
2924 a.faviconAdapter.Close()
2925 }
2926 tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions))
2927 for key := range a.floatingSessions {
2928 tabIDSet[key.tabID] = struct{}{}
2929 }
2930 tabIDs := make([]entity.TabID, 0, len(tabIDSet))
2931 for tabID := range tabIDSet {
2932 tabIDs = append(tabIDs, tabID)
2933 }
2934 for _, tabID := range tabIDs {
2935 a.releaseFloatingSessionsForTab(ctx, tabID)
2936 }
2937 if len(a.nativePopupWindows) > 0 {
2938 popupIDs := make([]port.WebViewID, 0, len(a.nativePopupWindows))
2939 for popupID := range a.nativePopupWindows {
2940 popupIDs = append(popupIDs, popupID)
2941 }
2942 for _, popupID := range popupIDs {
2943 a.releaseNativePopupWindow(popupID, false, true)
2944 }
2945 }
2946 if a.engine != nil {
2947 if err := a.engine.Close(); err != nil {
2948 log.Warn().Err(err).Msg("failed to close engine")
2949 }
2950 }
2951 // Close idle inhibitor to release D-Bus connection
2952 if a.deps.IdleInhibitor != nil {
2953 if err := a.deps.IdleInhibitor.Close(); err != nil {
2954 log.Warn().Err(err).Msg("failed to close idle inhibitor")
2955 }
2956 }
2957
2958 log.Info().Msg("application shutdown complete")
2959}
2960
2961func (a *App) closeHistoryRecorder() {
2962 if a.deps == nil || a.deps.HistoryRecorderUC == nil {
2963 return
2964 }
2965 a.deps.HistoryRecorderUC.Close()
2966}
2967
2968// initContentCoordinator creates the content coordinator and wires its optional dependencies.
2969func (a *App) initContentCoordinator(
2970 ctx context.Context,
2971 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
2972) {
2973 a.contentCoord = content.NewCoordinator(
2974 ctx,
2975 a.engine.Pool(),
2976 a.engine.ContentInjector(),
2977 a.widgetFactory,
2978 a.faviconAdapter,
2979 getActiveWS,
2980 a.deps.ZoomUC,
2981 a.deps.PermissionUC,
2982 )
2983
2984 // Set idle inhibitor for fullscreen video playback
2985 if a.deps.IdleInhibitor != nil {
2986 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
2987 }
2988
2989 // Wire engine settings and filter appliers for hot-reload and late-binding filters
2990 if sa := a.deps.Engine.SettingsApplier(); sa != nil {
2991 a.contentCoord.SetSettingsApplier(sa)
2992 }
2993 if fa := a.deps.Engine.FilterApplier(); fa != nil {
2994 a.contentCoord.SetFilterApplier(fa)
2995 }
2996
2997 // Wire external URL launcher (e.g. xdg-open for vscode://, spotify://)
2998 if a.deps.LaunchExternalURL != nil {
2999 a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
3000 }
3001
3002 a.contentCoord.SetOnTouchpadNavigationGesture(func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {
3003 a.handleTouchpadNavigationGesture(paneID, gesture)
3004 })
3005
3006 // Wire deferred init trigger - runs after first navigation starts
3007 a.contentCoord.SetOnFirstLoadStarted(func() {
3008 a.triggerDeferredInit(ctx)
3009 })
3010}
3011
3012// initTabCoordinator creates the TabCoordinator and wires all its callbacks.
3013func (a *App) initTabCoordinator(ctx context.Context) {
3014 runtimeCfg := a.runtimeConfigSnapshot().UI
3015 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
3016 TabsUC: a.tabsUC,
3017 MainWindow: a.mainWindow,
3018 HideTabBarWhenSingleTab: runtimeCfg.Workspace.HideTabBarWhenSingleTab,
3019 })
3020 a.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
3021 // Assign ownership from the callback target, not focus/global state.
3022 bw := a.browserWindowForTabTarget(target)
3023 if bw != nil {
3024 a.setBrowserWindowForTab(tab.ID, bw)
3025 }
3026 // Set a window-scoped default title so "Tab N" doesn't use global position.
3027 // Count only live tabs owned by this window via the helper.
3028 // Only assign a default name if the creator did not supply one.
3029 if strings.TrimSpace(tab.Name) == "" {
3030 count := a.tabCountForBrowserWindow(bw)
3031 if count <= 0 {
3032 count = 1
3033 }
3034 tab.Name = defaultTabName(count)
3035 }
3036 // Keep the derived global App.tabs mirror populated from per-window
3037 // tab creation for usecase and snapshot/export boundaries.
3038 // This must happen AFTER default tab name assignment so the clone
3039 // has the final window-scoped default name.
3040 if a.tabs != nil && a.tabs.Find(tab.ID) == nil {
3041 a.tabs.Add(cloneTabForGlobalList(tab))
3042 }
3043 a.createWorkspaceView(ctx, tab)
3044 })
3045 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
3046 // TabList.SetActive (called by TabCoordinator.Switch → tabsUC.Switch) already
3047 // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
3048 if bw := a.browserWindowForTabTarget(target); bw != nil {
3049 a.activateBrowserWindow(bw)
3050 }
3051 a.switchWorkspaceView(ctx, tab.ID)
3052 })
3053 a.tabCoord.SetOnTabClosed(func(ctx context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
3054 a.releaseTabWorkspace(ctx, tab)
3055 })
3056 a.tabCoord.SetOnStateChanged(a.MarkDirty)
3057 // Per-window tab ownership is now enforced through explicit TabTarget.
3058 // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed.
3059 a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, target coordinator.TabTarget) {
3060 bw := a.browserWindowForTabTarget(target)
3061 if bw != nil {
3062 a.removeBrowserWindow(bw.id)
3063 if bw.mainWindow != nil {
3064 bw.mainWindow.Destroy()
3065 }
3066 }
3067 // Quit the app only when all browser windows are gone.
3068 if len(a.browserWindows) == 0 {
3069 a.Quit()
3070 }
3071 })
3072 // Wire popup tab WebView attachment
3073 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
3074 a.attachPopupToTab(ctx, tabID, pane, wv)
3075 })
3076
3077 for _, bw := range a.browserWindows {
3078 a.wireBrowserWindowTabBar(ctx, bw)
3079 }
3080}
3081
3082// initCoordinators initializes all coordinators and wires their callbacks.
3083func (a *App) initCoordinators(ctx context.Context) {
3084 log := logging.FromContext(ctx)
3085 log.Debug().Msg("initializing coordinators")
3086
3087 // Coordinators resolve the workspace and view through
3088 // lastFocusedBrowserWindow via activeWorkspace helpers.
3089 // Shared coordinator instances defer to window-scoped resolution;
3090 // the startup edge case (no focused window yet) is handled inside
3091 // activeWorkspace/activeWorkspaceView.
3092 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
3093 return a.activeWorkspace(), a.activeWorkspaceView()
3094 }
3095
3096 // Create FaviconAdapter with resolver/service and engine FaviconDatabase.
3097 // Skip if favicon support is not wired (e.g. in tests or when disabled).
3098 if a.deps.FaviconService != nil || a.deps.FaviconResolver != nil {
3099 a.faviconAdapter = adapter.NewFaviconAdapterWithResolver(
3100 a.deps.FaviconService,
3101 a.deps.FaviconResolver,
3102 a.engine.FaviconDatabase(),
3103 a.deps.FaviconAdapterConfig,
3104 )
3105 registerFaviconInvalidator(a.deps.FaviconResolver, a.faviconAdapter)
3106 if invalidator, ok := a.engine.(port.FaviconInvalidator); ok {
3107 registerFaviconInvalidator(a.deps.FaviconResolver, invalidator)
3108 }
3109 }
3110
3111 // 1. Content Coordinator (no dependencies on other coordinators)
3112 a.initContentCoordinator(ctx, getActiveWS)
3113
3114 // 2. Tab Coordinator
3115 a.initTabCoordinator(ctx)
3116
3117 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
3118 a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) {
3119 a.handlePaneFullscreenChanged(paneID, entering)
3120 })
3121
3122 // 3. Workspace Coordinator
3123 runtimeCfg := a.runtimeConfigSnapshot().UI
3124 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
3125 PanesUC: a.panesUC,
3126 FocusMgr: a.focusMgr,
3127 StackedPaneMgr: a.stackedPaneMgr,
3128 WidgetFactory: a.widgetFactory,
3129 ContentCoord: a.contentCoord,
3130 GetActiveWS: getActiveWS,
3131 GenerateID: a.generateID,
3132 NewPaneURL: runtimeCfg.Workspace.NewPaneURL,
3133 ResizeStepPercent: runtimeCfg.Workspace.ResizeMode.StepPercent,
3134 ResizeMinPanePercent: runtimeCfg.Workspace.ResizeMode.MinPanePercent,
3135 })
3136 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
3137 bw := a.lastFocusedBrowserWindow()
3138 return a.tabCoord.Close(ctx, a.ensureTabTargetForBrowserWindow(bw))
3139 })
3140 a.wsCoord.SetOnStateChanged(a.MarkDirty)
3141
3142 // Wire popup handling
3143 // Set theme background color on the engine's popup factory to eliminate white flash.
3144 if a.engine != nil && a.deps.Theme != nil {
3145 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
3146 _ = a.engine.UpdateAppearance(ctx, float64(r), float64(g), float64(b), float64(alpha))
3147 }
3148 a.contentCoord.SetPopupConfig(
3149 a.engine.Factory(),
3150 &runtimeCfg.Workspace.BrowsingContexts,
3151 a.generateID,
3152 )
3153 a.contentCoord.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
3154 bw := a.browserWindowForAnyPane(paneID)
3155 if bw == nil {
3156 return "", false
3157 }
3158 return bw.id, true
3159 })
3160 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
3161 if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
3162 a.activateBrowserWindow(bw)
3163 }
3164 return a.wsCoord.InsertPopup(ctx, input)
3165 })
3166 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
3167 if bw := a.browserWindowForAnyPane(paneID); bw != nil {
3168 a.activateBrowserWindow(bw)
3169 }
3170 return a.wsCoord.ClosePaneByID(ctx, paneID)
3171 })
3172 a.contentCoord.SetOnOpenNativePopup(a.openNativePopupWindow)
3173 // Wire tabbed popup behavior to create new tabs in the originating window.
3174 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
3175
3176 // Move pane use cases (cross-tab/cross-window)
3177 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
3178 a.extractPaneToTabListUC = usecase.NewExtractPaneToTabListUseCase(a.generateID)
3179
3180 // 4. Navigation Coordinator
3181 a.navCoord = coordinator.NewNavigationCoordinatorWithHistoryRecorder(
3182 ctx,
3183 a.deps.NavigateUC,
3184 a.deps.HistoryRecorderUC,
3185 a.contentCoord,
3186 )
3187 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
3188 a.navCoord.ClearPaneHistory(paneID)
3189 })
3190
3191 // Wire title updates to history persistence
3192 a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
3193 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
3194 })
3195
3196 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
3197 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
3198 a.navCoord.RecordHistory(ctx, paneID, url)
3199 })
3200
3201 // Wire window title updates when active pane's title changes
3202 a.contentCoord.SetOnWindowTitleChanged(func(paneID entity.PaneID, title string) {
3203 a.handlePaneWindowTitleChanged(paneID, title)
3204 })
3205
3206 // Wire pane URI updates for session snapshots (searches all tabs)
3207 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
3208 a.updatePaneURIInAllTabs(paneID, url)
3209 a.updateFloatingSessionURI(paneID, url)
3210 // Mark dirty so snapshot captures the new URI
3211 a.MarkDirty()
3212 })
3213
3214 // Hide loading skeleton once the WebView paints
3215 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
3216 // The WebView can be shown while its pane is in a background tab, so scan
3217 // all WorkspaceViews rather than only the active one.
3218 for _, wsView := range a.workspaceViews {
3219 if wsView == nil {
3220 continue
3221 }
3222 if pv := wsView.GetPaneView(paneID); pv != nil {
3223 pv.HideLoadingSkeleton()
3224 }
3225 }
3226 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
3227 a.firstWebViewShownOnce.Do(func() {
3228 a.deps.OnFirstWebViewShown(ctx)
3229 })
3230 }
3231
3232 // This only updates focus when the shown pane belongs to the last-focused
3233 // window's active workspace. activeWorkspace() delegates to
3234 // lastFocusedBrowserWindow, so the accent-picker does not activate for
3235 // background windows.
3236 ws := a.activeWorkspace()
3237 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
3238 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
3239 }
3240 })
3241
3242 // 5. Keyboard Dispatcher
3243 // Dispatcher receives an app-level active pane provider (contentCoord.ActivePaneID)
3244 // and per-window handlers route source-window actions through
3245 // dispatchBrowserWindowAction for proper multi-window isolation.
3246 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
3247 ctx,
3248 a.wsCoord,
3249 a.navCoord,
3250 a.deps.ZoomUC,
3251 a.deps.CopyURLUC,
3252 a.keyboardActions(),
3253 a.contentCoord.ActivePaneID,
3254 )
3255 a.wireKeyboardActions()
3256 for _, bw := range a.browserWindows {
3257 a.initBrowserWindowInput(ctx, bw)
3258 }
3259
3260 log.Debug().Msg("coordinators initialized")
3261}
3262
3263func (a *App) keyboardActions() dispatcher.KeyboardActions {
3264 return dispatcher.KeyboardActions{
3265 NewTab: func(ctx context.Context) error {
3266 newPaneURL := a.runtimeConfigSnapshot().UI.Workspace.NewPaneURL
3267 if newPaneURL == "" {
3268 logging.FromContext(ctx).Warn().Msg("new tab ignored: new pane URL is not configured")
3269 return fmt.Errorf("newPaneURL is not configured")
3270 }
3271 return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {
3272 _, err := a.tabCoord.Create(ctx, target, newPaneURL)
3273 return err
3274 })
3275 },
3276 CloseTab: func(ctx context.Context) error {
3277 return a.withFocusedTabTarget(ctx, "close tab", false, func(target coordinator.TabTarget) error {
3278 return a.tabCoord.Close(ctx, target)
3279 })
3280 },
3281 NextTab: func(ctx context.Context) error {
3282 return a.withFocusedTabTarget(ctx, "next tab", false, func(target coordinator.TabTarget) error {
3283 return a.tabCoord.SwitchNext(ctx, target)
3284 })
3285 },
3286 PreviousTab: func(ctx context.Context) error {
3287 return a.withFocusedTabTarget(ctx, "previous tab", false, func(target coordinator.TabTarget) error {
3288 return a.tabCoord.SwitchPrev(ctx, target)
3289 })
3290 },
3291 SwitchLastTab: func(ctx context.Context) error {
3292 return a.withFocusedTabTarget(ctx, "switch last tab", false, func(target coordinator.TabTarget) error {
3293 return a.tabCoord.SwitchToLastActive(ctx, target)
3294 })
3295 },
3296 SwitchTabIndex: func(ctx context.Context, index int) error {
3297 return a.switchBrowserWindowTabIndex(ctx, a.lastFocusedBrowserWindow(), index)
3298 },
3299 ActiveWebView: func(context.Context) port.WebView {
3300 _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
3301 return wv
3302 },
3303 }
3304}
3305
3306func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bool, fn func(coordinator.TabTarget) error) error {
3307 if a.tabCoord == nil {
3308 logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: tab coordinator is nil")
3309 return nil
3310 }
3311 bw := a.lastFocusedBrowserWindow()
3312 if bw == nil {
3313 logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: no focused browser window")
3314 return nil
3315 }
3316 target := a.tabTargetForBrowserWindow(bw)
3317 if ensure {
3318 target = a.ensureTabTargetForBrowserWindow(bw)
3319 }
3320 return fn(target)
3321}
3322
3323func (a *App) wireKeyboardActions() {
3324 a.kbDispatcher.SetOnQuit(a.Quit)
3325 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
3326 a.ToggleFindBar(ctx)
3327 return nil
3328 })
3329 a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
3330 a.FindNext(ctx)
3331 return nil
3332 })
3333 a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
3334 a.FindPrevious(ctx)
3335 return nil
3336 })
3337 a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
3338 a.CloseFindBar(ctx)
3339 return nil
3340 })
3341 a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context, paneID entity.PaneID) error {
3342 if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
3343 a.activateBrowserWindow(bw)
3344 }
3345 return a.HandleMovePaneToTab(ctx)
3346 })
3347 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context, paneID entity.PaneID) error {
3348 if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
3349 a.activateBrowserWindow(bw)
3350 }
3351 return a.HandleMovePaneToNextTab(ctx)
3352 })
3353 a.kbDispatcher.SetOnEjectPaneToWindow(func(ctx context.Context, paneID entity.PaneID) error {
3354 if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
3355 a.activateBrowserWindow(bw)
3356 }
3357 return a.EjectActivePaneToWindow(ctx, paneID)
3358 })
3359 a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
3360 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
3361 return a.ToggleFloatingPane(ctx)
3362 })
3363 a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error {
3364 return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL)
3365 })
3366
3367 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
3368 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
3369 return a.kbDispatcher.Dispatch(ctx, action)
3370 })
3371}
3372
3373func (a *App) wireWebRTCPermissionIndicator() {
3374 if a.contentCoord == nil {
3375 return
3376 }
3377 ctx := context.Background()
3378 if a.deps != nil && a.deps.Ctx != nil {
3379 ctx = a.deps.Ctx
3380 }
3381
3382 a.contentCoord.SetOnPermissionActivity(func(
3383 paneID entity.PaneID,
3384 origin string,
3385 permTypes []entity.PermissionType,
3386 state content.PermissionActivityState,
3387 ) {
3388 bw := a.browserWindowForPane(paneID)
3389 if bw == nil || bw.webrtcIndicator == nil {
3390 return
3391 }
3392 if state == content.PermissionActivityRequesting && a.deps != nil && a.deps.PermissionUC != nil && bw.permissionDialog != nil {
3393 a.deps.PermissionUC.SetDialogPresenter(bw.permissionDialog)
3394 }
3395 bw.webrtcIndicator.SetOrigin(origin)
3396
3397 switch state {
3398 case content.PermissionActivityRequesting:
3399 bw.webrtcIndicator.MarkRequesting(permTypes)
3400 case content.PermissionActivityAllowed:
3401 bw.webrtcIndicator.MarkAllowed(permTypes)
3402 case content.PermissionActivityBlocked:
3403 bw.webrtcIndicator.MarkBlocked(permTypes)
3404 }
3405
3406 for _, permType := range permTypes {
3407 a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType)
3408 }
3409 })
3410
3411 // Reset the owning window's indicator when that pane navigates away.
3412 a.contentCoord.SetOnActiveNavigationCommitted(func(paneID entity.PaneID, uri string) {
3413 bw := a.browserWindowForPane(paneID)
3414 if bw == nil || bw.webrtcIndicator == nil {
3415 return
3416 }
3417 newOrigin, err := urlutil.ExtractOrigin(uri)
3418 if err != nil {
3419 bw.webrtcIndicator.Reset()
3420 return
3421 }
3422
3423 currentOrigin := bw.webrtcIndicator.Origin()
3424 if currentOrigin != "" && currentOrigin != newOrigin {
3425 bw.webrtcIndicator.Reset()
3426 }
3427 })
3428}
3429
3430func (a *App) wireBrowserWindowPermissionIndicator(bw *browserWindow) {
3431 if bw == nil || bw.webrtcIndicator == nil {
3432 return
3433 }
3434 ctx := context.Background()
3435 if a.deps != nil && a.deps.Ctx != nil {
3436 ctx = a.deps.Ctx
3437 }
3438 log := logging.FromContext(ctx)
3439
3440 bw.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
3441 if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
3442 return
3443 }
3444
3445 if hasStored {
3446 // Already locked: reset to prompt (will re-ask on next request).
3447 if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil {
3448 log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision")
3449 return
3450 }
3451 } else {
3452 // Not locked: flip the state.
3453 // Allowed/requesting → deny future requests.
3454 // Blocked → allow future requests.
3455 decision := entity.PermissionDenied
3456 if state == string(content.PermissionActivityBlocked) {
3457 decision = entity.PermissionGranted
3458 }
3459 if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
3460 log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision")
3461 return
3462 }
3463 }
3464
3465 a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType)
3466 })
3467}
3468
3469func (a *App) syncWebRTCPermissionLockState(
3470 ctx context.Context,
3471 indicator *component.WebRTCPermissionIndicator,
3472 origin string,
3473 permType entity.PermissionType,
3474) {
3475 if indicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
3476 return
3477 }
3478
3479 record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType)
3480 if err != nil {
3481 logging.FromContext(ctx).Warn().
3482 Err(err).
3483 Str("origin", origin).
3484 Str("type", string(permType)).
3485 Msg("failed to read manual permission decision")
3486 return
3487 }
3488
3489 if record == nil {
3490 indicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
3491 return
3492 }
3493
3494 indicator.SetStoredDecision(permType, record.Decision, true)
3495}
3496
3497// generateWindowID generates a unique ID for top-level browser windows.
3498func (a *App) generateWindowID() string {
3499 a.windowIDMu.Lock()
3500 defer a.windowIDMu.Unlock()
3501 a.windowIDCounter++
3502 return fmt.Sprintf("w%d", a.windowIDCounter)
3503}
3504
3505const (
3506 uiMainThreadDispatchTimeout = 2 * time.Second
3507 uiMainThreadDispatchSlowThreshold = 250 * time.Millisecond
3508)
3509
3510// runOnMainThread executes fn inline when already on the GTK main context;
3511// otherwise it dispatches fn to the GTK main loop and waits for bounded
3512// completion so IPC and CEF callback paths do not wait forever on a wedged UI.
3513func (a *App) runOnMainThread(label string, fn func()) syncdispatch.SyncDispatchResult {
3514 result := syncdispatch.RunSynchronousDispatch(syncdispatch.SyncDispatchOptions{
3515 Label: label,
3516 Timeout: uiMainThreadDispatchTimeout,
3517 IsOwner: func() bool {
3518 glibCtx := glib.MainContextDefault()
3519 return glibCtx != nil && glibCtx.IsOwner()
3520 },
3521 Dispatch: func(cb func()) {
3522 wrapped := new(glib.SourceOnceFunc)
3523 *wrapped = func(_ uintptr) { cb() }
3524 glib.IdleAddOnce(wrapped, 0)
3525 },
3526 }, fn)
3527 a.logMainThreadDispatchResult(result)
3528 return result
3529}
3530
3531func (a *App) logMainThreadDispatchResult(result syncdispatch.SyncDispatchResult) {
3532 ctx := context.Background()
3533 if a != nil && a.deps != nil && a.deps.Ctx != nil {
3534 ctx = a.deps.Ctx
3535 }
3536 logger := logging.FromContext(ctx)
3537 switch result.Status {
3538 case syncdispatch.SyncDispatchTimedOut:
3539 logger.Warn().
3540 Str("dispatch_label", result.Label).
3541 Dur("elapsed", result.Elapsed).
3542 Dur("timeout", uiMainThreadDispatchTimeout).
3543 Msg("ui: main-thread dispatch timed out before callback started")
3544 case syncdispatch.SyncDispatchCompletedAfterTimeout:
3545 logger.Warn().
3546 Str("dispatch_label", result.Label).
3547 Dur("elapsed", result.Elapsed).
3548 Dur("timeout", uiMainThreadDispatchTimeout).
3549 Msg("ui: main-thread dispatch completed after timeout")
3550 case syncdispatch.SyncDispatchQueuedAfterTimeout:
3551 logger.Warn().
3552 Str("dispatch_label", result.Label).
3553 Dur("elapsed", result.Elapsed).
3554 Dur("timeout", uiMainThreadDispatchTimeout).
3555 Msg("ui: main-thread dispatch left queued after timeout")
3556 case syncdispatch.SyncDispatchCompleted:
3557 if result.Elapsed >= uiMainThreadDispatchSlowThreshold {
3558 logger.Debug().
3559 Str("dispatch_label", result.Label).
3560 Dur("elapsed", result.Elapsed).
3561 Msg("ui: main-thread dispatch completed slowly")
3562 }
3563 }
3564}
3565
3566// generateID generates a unique ID for tabs and panes.
3567// Uses monotonically increasing counter to avoid ID collisions.
3568func (a *App) generateID() string {
3569 a.idMu.Lock()
3570 defer a.idMu.Unlock()
3571 a.idCounter++
3572 return fmt.Sprintf("p%d", a.idCounter)
3573}
3574
3575// Tabs returns the tab list.
3576func (a *App) Tabs() *entity.TabList {
3577 return a.tabs
3578}
3579
3580// MainWindow returns the main window.
3581func (a *App) MainWindow() *window.MainWindow {
3582 return a.mainWindow
3583}
3584
3585// Quit requests the application to quit.
3586func (a *App) Quit() {
3587 if a == nil || a.gtkApp == nil {
3588 return
3589 }
3590 quit := func() {
3591 if a.gtkApp != nil {
3592 a.gtkApp.Quit()
3593 }
3594 }
3595 glibCtx := glib.MainContextDefault()
3596 if glibCtx != nil && glibCtx.IsOwner() {
3597 quit()
3598 return
3599 }
3600 cb := glib.SourceOnceFunc(func(_ uintptr) {
3601 quit()
3602 })
3603 glib.IdleAddOnce(&cb, 0)
3604}
3605
3606// RunWithArgs is a convenience function that creates and runs an App.
3607func RunWithArgs(ctx context.Context, deps *Dependencies) int {
3608 app, err := New(deps)
3609 if err != nil {
3610 log := logging.FromContext(ctx)
3611 log.Error().Err(err).Msg("failed to create application")
3612 return 1
3613 }
3614 return app.Run(ctx, os.Args)
3615}
3616
3617// updateWindowTitle updates the window title with the given page title.
3618// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
3619func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
3620 if target == nil {
3621 target = a.browserWindowForMainWindow(a.mainWindow)
3622 }
3623 if target == nil || target.mainWindow == nil {
3624 return
3625 }
3626
3627 title := appTitle
3628 if pageTitle != "" {
3629 title = pageTitle + " - " + appTitle
3630 }
3631 target.mainWindow.SetTitle(title)
3632}
3633
3634// updateWindowTitleFromActivePane updates the window title based on the current active pane.
3635func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
3636 var ws *entity.Workspace
3637 bw := a.browserWindowForTab(tabID)
3638 if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
3639 if tab := tabs.Find(tabID); tab != nil {
3640 ws = tab.Workspace
3641 }
3642 }
3643 if ws == nil {
3644 ws = a.activeWorkspace()
3645 }
3646 if ws == nil || a.contentCoord == nil {
3647 a.updateWindowTitle("", a.browserWindowForTab(tabID))
3648 return
3649 }
3650 title := a.contentCoord.GetTitle(ws.ActivePaneID)
3651 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
3652}
3653
3654// handleModeChange is called when the input mode changes.
3655func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
3656 log := logging.FromContext(ctx)
3657 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
3658
3659 if from == input.ModeResize && to != input.ModeResize {
3660 a.clearResizeModeBorder()
3661 }
3662 if to == input.ModeResize {
3663 // Resize mode targets the last-focused browser window's active workspace.
3664 a.applyResizeModeBorder(ctx, a.activeWorkspace())
3665 }
3666
3667 // Update global border overlay visibility based on mode.
3668 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
3669 if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil {
3670 bw.borderMgr.OnModeChange(ctx, from, to)
3671 }
3672
3673 // Show/hide mode indicator toaster based on config.
3674 a.updateModeIndicatorToaster(ctx, to)
3675}
3676
3677// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
3678func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
3679 bw := a.lastFocusedBrowserWindow()
3680 if bw == nil || bw.modeToaster == nil {
3681 return
3682 }
3683
3684 // Check if mode indicator toaster is enabled in config.
3685 if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
3686 bw.modeToaster.Hide()
3687 return
3688 }
3689
3690 if mode == input.ModeNormal {
3691 bw.modeToaster.Hide()
3692 return
3693 }
3694
3695 // Show persistent toaster at bottom-left with mode display name.
3696 // Mode class is applied atomically with Show() to avoid visual flicker.
3697 modeClass := getModeToastClass(mode)
3698 bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
3699 component.WithDuration(0), // Persistent until mode exits.
3700 component.WithPosition(component.ToastPositionBottomLeft),
3701 component.WithModeClass(modeClass),
3702 )
3703}
3704
3705// getModeToastClass returns the CSS class for the given mode's toast styling.
3706func getModeToastClass(mode input.Mode) string {
3707 switch mode {
3708 case input.ModePane:
3709 return "toast-pane-mode"
3710 case input.ModeTab:
3711 return "toast-tab-mode"
3712 case input.ModeSession:
3713 return "toast-session-mode"
3714 case input.ModeResize:
3715 return "toast-resize-mode"
3716 default:
3717 return ""
3718 }
3719}
3720
3721func (a *App) clearResizeModeBorder() {
3722 if a.resizeModeBorderTarget != nil {
3723 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
3724 a.resizeModeBorderTarget = nil
3725 }
3726}
3727
3728func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
3729 // activeWorkspaceView resolves via lastFocusedBrowserWindow; resize mode
3730 // always operates on the last-focused window.
3731 wsView := a.activeWorkspaceView()
3732 if wsView == nil || ws == nil {
3733 a.clearResizeModeBorder()
3734 return
3735 }
3736
3737 paneID := ws.ActivePaneID
3738 if paneID == "" {
3739 a.clearResizeModeBorder()
3740 return
3741 }
3742
3743 // Important: wrap the whole stack container when the active pane is in a stack.
3744 target := wsView.GetStackContainerWidget(paneID)
3745 if target == nil {
3746 a.clearResizeModeBorder()
3747 return
3748 }
3749
3750 if !target.HasCssClass("resize-mode-active") {
3751 target.AddCssClass("resize-mode-active")
3752 }
3753
3754 if a.resizeModeBorderTarget != target {
3755 if a.resizeModeBorderTarget != nil {
3756 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
3757 } else {
3758 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
3759 }
3760 a.resizeModeBorderTarget = target
3761 }
3762}
3763
3764// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
3765func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
3766 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
3767 return
3768 }
3769
3770 target := a.browserWindowForTab(tab.ID)
3771 if target != nil {
3772 a.setBrowserWindowForTab(tab.ID, target)
3773 }
3774
3775 // Attach to content area
3776 wsView := a.workspaceViews[tab.ID]
3777 if wsView != nil && target != nil && target.mainWindow != nil {
3778 widget := wsView.Widget()
3779 if widget != nil {
3780 gtkWidget := widget.GtkWidget()
3781 if gtkWidget != nil {
3782 target.mainWindow.SetContent(gtkWidget)
3783 }
3784 }
3785 }
3786}
3787
3788// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
3789// Used during session restoration where we create all views first, then attach only the active one.
3790func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) bool {
3791 if a.workspaceViewCreateOverride != nil {
3792 return a.workspaceViewCreateOverride(ctx, tab)
3793 }
3794
3795 log := logging.FromContext(ctx)
3796
3797 if a.widgetFactory == nil {
3798 log.Error().Msg("widget factory not initialized")
3799 return false
3800 }
3801
3802 // Create workspace view
3803 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
3804 if wsView == nil {
3805 log.Error().Msg("failed to create workspace view")
3806 return false
3807 }
3808 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
3809 if a.contentCoord != nil {
3810 syncCtx := context.Background()
3811 if a.deps != nil && a.deps.Ctx != nil {
3812 syncCtx = a.deps.Ctx
3813 }
3814 wsView.SetOnWebViewAttached(func(paneID entity.PaneID) {
3815 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-widget-attached")
3816 })
3817 wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {
3818 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
3819 })
3820 }
3821
3822 // Set the workspace
3823 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
3824 log.Error().Err(err).Msg("failed to set workspace in view")
3825 return false
3826 }
3827
3828 // Ensure WebViews are attached to panes
3829 if a.contentCoord != nil {
3830 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
3831 }
3832
3833 // Note: Mode borders for tab/pane/session are attached to MainWindow.
3834 // Resize mode border is attached to the active pane's stack container.
3835
3836 // Set omnibox config for this workspace view.
3837 // When the owning browser window is known, bind navigation to the
3838 // owning window so that the omnibox always navigates in the correct
3839 // window, regardless of global focus.
3840 if owner := a.browserWindowForTab(tab.ID); owner != nil {
3841 cfg := a.omniboxCfg
3842 cfg.OnNavigate = omniboxNavigateForBrowserWindow(ctx, owner, a.navigateFromBrowserWindow)
3843 wsView.SetOmniboxConfig(cfg)
3844 } else {
3845 wsView.SetOmniboxConfig(a.omniboxCfg)
3846 }
3847 // Set find bar config for this workspace view
3848 wsView.SetFindBarConfig(a.findBarCfg)
3849 // Set auto-open omnibox on new pane
3850 wsView.SetAutoOpenOnNewPane(a.runtimeConfigSnapshot().UI.Omnibox.AutoOpenOnNewPane)
3851
3852 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
3853 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
3854 // Resize-mode pane focus tracks the last-focused window's workspace.
3855 ws := a.activeWorkspace()
3856 if ws != nil {
3857 ws.ActivePaneID = paneID
3858 }
3859 a.applyResizeModeBorder(ctx, ws)
3860 }
3861 })
3862
3863 wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
3864 if a.wsCoord != nil {
3865 _ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
3866 }
3867 })
3868
3869 // Store in map
3870 a.workspaceViews[tab.ID] = wsView
3871 a.reattachFloatingSessions(tab.ID, wsView)
3872 a.syncFloatingFocus()
3873
3874 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
3875 return true
3876}
3877
3878// activeWorkspace returns the workspace of the focused browser window's active tab.
3879func (a *App) activeWorkspace() *entity.Workspace {
3880 return a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow())
3881}
3882
3883// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
3884// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
3885func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
3886 for _, bw := range a.browserWindows {
3887 if bw == nil || bw.tabs == nil {
3888 continue
3889 }
3890 for _, tab := range bw.tabs.Tabs {
3891 if tab == nil || tab.Workspace == nil {
3892 continue
3893 }
3894 paneNode := tab.Workspace.FindPane(paneID)
3895 if paneNode != nil && paneNode.Pane != nil {
3896 paneNode.Pane.URI = url
3897 return // Pane IDs are unique, no need to continue
3898 }
3899 }
3900 }
3901}
3902
3903// activeWorkspaceView returns the workspace view for the focused browser window's active tab.
3904func (a *App) activeWorkspaceView() *component.WorkspaceView {
3905 return a.activeWorkspaceViewForBrowserWindow(a.lastFocusedBrowserWindow())
3906}
3907
3908// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
3909// Used by the accent picker to insert accented characters into web content.
3910func (a *App) getActiveWebViewTarget() port.TextInputTarget {
3911 _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
3912 if wv == nil {
3913 return nil
3914 }
3915
3916 if provider, ok := wv.(port.TextInputTargetProvider); ok {
3917 return provider.TextInputTarget()
3918 }
3919 return nil
3920}
3921
3922// attachPopupToTab attaches a popup WebView to a newly created tab.
3923// This is called when a popup uses tabbed behavior.
3924func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
3925 log := logging.FromContext(ctx)
3926 cleanupUnattachedPopup := func() {
3927 if wv != nil {
3928 wv.Destroy()
3929 }
3930 }
3931
3932 wsView := a.workspaceViews[tabID]
3933 if wsView == nil {
3934 cleanupUnattachedPopup()
3935 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
3936 return
3937 }
3938 if pane == nil {
3939 cleanupUnattachedPopup()
3940 log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil")
3941 return
3942 }
3943 if wsView.GetPaneView(pane.ID) == nil {
3944 cleanupUnattachedPopup()
3945 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3946 return
3947 }
3948 if a.contentCoord == nil {
3949 cleanupUnattachedPopup()
3950 log.Warn().Str("pane_id", string(pane.ID)).Msg("content coordinator not configured for popup")
3951 return
3952 }
3953
3954 // Register WebView with content coordinator.
3955 a.contentCoord.RegisterPopupWebView(pane.ID, wv)
3956
3957 // Wrap and attach widget.
3958 widget := a.contentCoord.WrapWidget(ctx, wv)
3959 if widget == nil {
3960 a.contentCoord.ReleaseWebView(ctx, pane.ID)
3961 log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview")
3962 return
3963 }
3964 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
3965 a.contentCoord.ReleaseWebView(ctx, pane.ID)
3966 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
3967 return
3968 }
3969 log.Debug().
3970 Str("tab_id", string(tabID)).
3971 Str("pane_id", string(pane.ID)).
3972 Msg("popup webview attached to tab")
3973}
3974
3975// switchWorkspaceView swaps the displayed workspace view for a tab.
3976func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
3977 log := logging.FromContext(ctx)
3978
3979 wsView, exists := a.workspaceViews[tabID]
3980 if !exists {
3981 log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
3982 return
3983 }
3984
3985 // Get the workspace view's root widget
3986 widget := wsView.Widget()
3987 if widget == nil {
3988 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
3989 return
3990 }
3991
3992 gtkWidget := widget.GtkWidget()
3993 if gtkWidget == nil {
3994 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
3995 return
3996 }
3997
3998 // Swap content (MainWindow.SetContent now properly removes old content).
3999 // Active tab state is managed by TabList.SetActive; no per-window field needed.
4000 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
4001 target.mainWindow.SetContent(gtkWidget)
4002 } else if a.mainWindow != nil {
4003 a.mainWindow.SetContent(gtkWidget)
4004 }
4005
4006 // Update window title with the new active pane's title
4007 a.updateWindowTitleFromActivePane(tabID)
4008 a.syncFloatingFocus()
4009
4010 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
4011}
4012
4013func normalizeFloatingSessionID(sessionID string) string {
4014 sessionID = strings.TrimSpace(sessionID)
4015 if sessionID == "" {
4016 return floatingSessionIDDefault
4017 }
4018 return sessionID
4019}
4020
4021func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
4022 return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
4023}
4024
4025func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
4026 return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
4027}
4028
4029func decorateFloatingOverlay(overlay layout.OverlayWidget) {
4030 if overlay == nil {
4031 return
4032 }
4033
4034 overlay.SetHexpand(false)
4035 overlay.SetVexpand(false)
4036 overlay.AddCssClass("floating-pane-container")
4037 overlay.AddCssClass("pane-border")
4038 overlay.AddCssClass("pane-active")
4039}
4040
4041// showFloatingWidget makes a floating pane widget interactive and visible.
4042// Visibility is controlled by CSS class to animate opacity while keeping the
4043// widget mapped so WebKit can redraw without a hard pop.
4044func showFloatingWidget(w layout.Widget) {
4045 if w == nil {
4046 return
4047 }
4048 w.AddCssClass(floatingPaneVisibleClass)
4049 w.SetCanTarget(true)
4050 w.SetCanFocus(true)
4051}
4052
4053// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
4054// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
4055// widget mapped and WebKit retains its rendered surface in GPU memory.
4056func hideFloatingWidget(w layout.Widget) {
4057 if w == nil {
4058 return
4059 }
4060 w.RemoveCssClass(floatingPaneVisibleClass)
4061 w.SetCanTarget(false)
4062 w.SetCanFocus(false)
4063}
4064
4065// setFloatingWidgetShown applies show or hide based on the visible flag.
4066func setFloatingWidgetShown(w layout.Widget, visible bool) {
4067 if visible {
4068 showFloatingWidget(w)
4069 } else {
4070 hideFloatingWidget(w)
4071 }
4072}
4073
4074func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
4075 if workspaceOverlay == nil || floatingOverlay == nil {
4076 return
4077 }
4078
4079 workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
4080 workspaceOverlay.SetClipOverlay(floatingOverlay, false)
4081}
4082
4083func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
4084 if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
4085 return 0, 0, 0, 0, false
4086 }
4087
4088 width = desiredWidth
4089 height = desiredHeight
4090 if width > overlayWidth {
4091 width = overlayWidth
4092 }
4093 if height > overlayHeight {
4094 height = overlayHeight
4095 }
4096
4097 x = (overlayWidth - width) / 2
4098 y = (overlayHeight - height) / 2
4099 if x < 0 {
4100 x = 0
4101 }
4102 if y < 0 {
4103 y = 0
4104 }
4105
4106 return x, y, width, height, true
4107}
4108
4109type gtkAllocationLayout [4]int32
4110
4111const gtkAllocationLayoutSize = 16
4112
4113var (
4114 _ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
4115 _ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
4116)
4117
4118// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
4119// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
4120// 32-bit signed integers {x, y, width, height} in native endianness.
4121// If GTK struct layout changes, revisit offsets and writes below.
4122//
4123//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
4124func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
4125 if allocationPtr == nil {
4126 return false
4127 }
4128
4129 const (
4130 gtkAllocationOffsetX = uintptr(0)
4131 gtkAllocationOffsetY = uintptr(4)
4132 gtkAllocationOffsetWidth = uintptr(8)
4133 gtkAllocationOffsetHeight = uintptr(12)
4134 )
4135
4136 // GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
4137 base := unsafe.Pointer(allocationPtr)
4138 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetX)) = int32(x)
4139 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetY)) = int32(y)
4140 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetWidth)) = int32(width)
4141 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetHeight)) = int32(height)
4142 return true
4143}
4144
4145func (a *App) floatingAllocationForWidget(
4146 tabID entity.TabID,
4147 widgetPtr uintptr,
4148 overlayWidth, overlayHeight int,
4149) (x, y, width, height int, ok bool) {
4150 for key, session := range a.floatingSessions {
4151 if key.tabID != tabID || session == nil || session.widget == nil {
4152 continue
4153 }
4154
4155 gtkWidget := session.widget.GtkWidget()
4156 if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
4157 continue
4158 }
4159
4160 desiredWidth := session.appliedWidth
4161 desiredHeight := session.appliedHeight
4162 if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
4163 session.pane.Resize()
4164 desiredWidth, desiredHeight = session.pane.Dimensions()
4165 }
4166
4167 return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
4168 }
4169
4170 return 0, 0, 0, 0, false
4171}
4172
4173func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
4174 if workspaceOverlay == nil {
4175 return
4176 }
4177
4178 workspaceWidget := workspaceOverlay.GtkWidget()
4179 if workspaceWidget == nil {
4180 return
4181 }
4182
4183 gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
4184 if gtkOverlay == nil {
4185 return
4186 }
4187
4188 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
4189 widget := gtk.WidgetNewFromInternalPtr(widgetPtr)
4190 if widget != nil {
4191 widgetPtr = widget.GoPointer()
4192 }
4193 overlayWidth := workspaceOverlay.GetAllocatedWidth()
4194 overlayHeight := workspaceOverlay.GetAllocatedHeight()
4195 x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
4196 if !ok {
4197 return false
4198 }
4199 return writeOverlayAllocation(allocationPtr, x, y, width, height)
4200 }
4201
4202 gtkOverlay.ConnectGetChildPosition(&cb)
4203}
4204
4205// currentFloatingWidthPct returns the configured floating pane width percentage.
4206func (a *App) currentFloatingWidthPct() float64 {
4207 return a.runtimeConfigSnapshot().UI.Workspace.FloatingPane.WidthPct
4208}
4209
4210// currentFloatingHeightPct returns the configured floating pane height percentage.
4211func (a *App) currentFloatingHeightPct() float64 {
4212 return a.runtimeConfigSnapshot().UI.Workspace.FloatingPane.HeightPct
4213}
4214
4215func (a *App) ensureFloatingSession(
4216 ctx context.Context,
4217 tabID entity.TabID,
4218 sessionID string,
4219 wsView *component.WorkspaceView,
4220) (*floatingWorkspaceSession, error) {
4221 key := floatingSessionMapKey(tabID, sessionID)
4222 if session, ok := a.floatingSessions[key]; ok && session != nil {
4223 if wsView == nil {
4224 return session, nil
4225 }
4226 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
4227 if session.widget != nil {
4228 wsView.AddWorkspaceOverlayWidget(session.widget)
4229 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
4230 }
4231 if session.paneView != nil {
4232 wsView.RegisterPaneView(session.paneID, session.paneView)
4233 if session.focusWidget == nil {
4234 session.focusWidget = session.paneView.WebViewWidget()
4235 }
4236 }
4237 return session, nil
4238 }
4239
4240 if wsView == nil {
4241 return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
4242 }
4243 if a.contentCoord == nil {
4244 return nil, fmt.Errorf("content coordinator not initialized")
4245 }
4246 if a.widgetFactory == nil {
4247 return nil, fmt.Errorf("widget factory not initialized")
4248 }
4249
4250 paneID := floatingPaneIDForSession(tabID, sessionID)
4251 wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
4252 if err != nil {
4253 return nil, fmt.Errorf("ensure floating webview: %w", err)
4254 }
4255
4256 webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
4257 if webViewWidget == nil {
4258 return nil, fmt.Errorf("wrap floating webview widget")
4259 }
4260 webViewWidget.SetHexpand(true)
4261 webViewWidget.SetVexpand(true)
4262
4263 // Wrap the WebView in a PaneView so it gets progress bar, loading
4264 // skeleton, and link status overlays — the same widget hierarchy as
4265 // regular workspace panes. This allows the content coordinator's
4266 // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
4267 // and update the floating pane automatically.
4268 pv := component.NewPaneView(ctx, a.widgetFactory, paneID, webViewWidget)
4269 pv.SetActive(true)
4270 // Hide loading skeleton — floating panes have their own themed container.
4271 pv.HideLoadingSkeleton()
4272 wsView.RegisterPaneView(paneID, pv)
4273
4274 // Use the PaneView's overlay directly as the floating overlay.
4275 // This avoids nesting two overlays which causes GTK4 allocation
4276 // issues (inner overlay expanding to fill the parent's allocation).
4277 pvOverlay := pv.Overlay()
4278 decorateFloatingOverlay(pvOverlay)
4279 pvOverlay.SetHalign(gtk.AlignCenterValue)
4280 pvOverlay.SetValign(gtk.AlignCenterValue)
4281 hideFloatingWidget(pvOverlay)
4282 wsView.AddWorkspaceOverlayWidget(pvOverlay)
4283 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
4284
4285 floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
4286 WidthPct: a.currentFloatingWidthPct(),
4287 HeightPct: a.currentFloatingHeightPct(),
4288 FallbackWidth: floatingPaneFallbackWidth,
4289 FallbackHeight: floatingPaneFallbackHeight,
4290 OnNavigate: func(navCtx context.Context, url string) error {
4291 return wv.LoadURI(navCtx, url)
4292 },
4293 })
4294
4295 session := &floatingWorkspaceSession{
4296 paneID: paneID,
4297 pane: floatingPane,
4298 paneView: pv,
4299 webView: wv,
4300 overlay: pvOverlay,
4301 widget: pvOverlay,
4302 focusWidget: webViewWidget,
4303 }
4304 a.floatingSessions[key] = session
4305 return session, nil
4306}
4307
4308func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
4309 sessions := make(map[floatingSessionKey]*floatingWorkspaceSession)
4310 for key, session := range a.floatingSessions {
4311 if key.tabID != tabID || session == nil {
4312 continue
4313 }
4314 sessions[key] = session
4315 }
4316 return sessions
4317}
4318
4319func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
4320 sessions := a.floatingSessionsForTab(tabID)
4321 if len(sessions) == 0 {
4322 return
4323 }
4324
4325 for key, session := range sessions {
4326 a.releaseFloatingSession(ctx, key, session)
4327 }
4328 a.syncFloatingFocus()
4329}
4330
4331func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
4332 activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
4333 if activeTab == nil {
4334 return floatingSessionKey{}, nil, false
4335 }
4336 for key, session := range a.floatingSessions {
4337 if key.tabID != activeTab.ID || session == nil || session.pane == nil {
4338 continue
4339 }
4340 if session.pane.IsVisible() {
4341 return key, session, true
4342 }
4343 }
4344 return floatingSessionKey{tabID: activeTab.ID}, nil, false
4345}
4346
4347func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
4348 key, session, ok := a.activeFloatingSessionEntry()
4349 if !ok {
4350 return nil, key.tabID
4351 }
4352 return session, key.tabID
4353}
4354
4355func (a *App) releaseFloatingSession(ctx context.Context, key floatingSessionKey, session *floatingWorkspaceSession) {
4356 if session == nil {
4357 return
4358 }
4359
4360 a.stopFloatingResizeWatcher(session)
4361 if session.pane != nil {
4362 session.pane.Hide(ctx)
4363 }
4364 a.hideFloatingOmnibox(ctx, session)
4365 if wsView := a.workspaceViews[key.tabID]; wsView != nil {
4366 if session.widget != nil {
4367 wsView.RemoveWorkspaceOverlayWidget(session.widget)
4368 }
4369 if session.paneID != "" {
4370 wsView.UnregisterPaneView(session.paneID)
4371 }
4372 }
4373 if a.contentCoord != nil && session.paneID != "" {
4374 a.contentCoord.ReleaseWebView(ctx, session.paneID)
4375 }
4376 delete(a.floatingSessions, key)
4377
4378 session.appliedWidth = 0
4379 session.appliedHeight = 0
4380 session.focusWidget = nil
4381 session.paneView = nil
4382 session.widget = nil
4383 session.overlay = nil
4384 session.webView = nil
4385 session.pane = nil
4386 // Session is fully released/zeroed; caller must drop this reference and not reuse it.
4387}
4388
4389func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
4390 if paneID == "" {
4391 return
4392 }
4393
4394 for _, session := range a.floatingSessions {
4395 if session == nil || session.pane == nil {
4396 continue
4397 }
4398 if session.paneID != paneID {
4399 continue
4400 }
4401 session.pane.RecordLoadedURL(url)
4402 return
4403 }
4404}
4405
4406func (a *App) syncFloatingFocus() {
4407 for _, wsView := range a.workspaceViews {
4408 if wsView != nil {
4409 wsView.SetHoverFocusLocked(false)
4410 }
4411 }
4412
4413 if a.contentCoord == nil {
4414 return
4415 }
4416
4417 activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
4418 if activeTab == nil {
4419 a.contentCoord.ClearActivePaneOverride()
4420 return
4421 }
4422
4423 if session, _ := a.activeFloatingSession(); session != nil {
4424 if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
4425 wsView.SetHoverFocusLocked(true)
4426 }
4427 a.startFloatingResizeWatcher(session)
4428 a.contentCoord.SetActivePaneOverride(session.paneID)
4429 return
4430 }
4431
4432 a.contentCoord.ClearActivePaneOverride()
4433}
4434
4435func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
4436 if session == nil || session.overlay == nil || a.widgetFactory == nil {
4437 return
4438 }
4439
4440 if session.omnibox == nil {
4441 cfg := a.omniboxCfg
4442 cfg.OnNavigate = func(navCtx context.Context, url string) error {
4443 if session.pane == nil {
4444 return fmt.Errorf("floating pane is not available")
4445 }
4446 if navCtx == nil {
4447 navCtx = ctx
4448 }
4449 return session.pane.Navigate(navCtx, url)
4450 }
4451 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
4452 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
4453 }
4454
4455 omnibox := component.NewOmnibox(ctx, cfg)
4456 if omnibox == nil {
4457 return
4458 }
4459
4460 omnibox.SetParentOverlay(session.overlay)
4461 omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
4462 if omniboxWidget == nil {
4463 return
4464 }
4465
4466 session.overlay.AddOverlay(omniboxWidget)
4467 session.overlay.SetClipOverlay(omniboxWidget, false)
4468 session.overlay.SetMeasureOverlay(omniboxWidget, false)
4469
4470 session.omnibox = omnibox
4471 session.omniboxWidget = omniboxWidget
4472 }
4473
4474 session.pane.SetOmniboxVisible(true)
4475 session.omnibox.Show(ctx, "")
4476}
4477
4478func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
4479 if session == nil || session.pane == nil {
4480 return
4481 }
4482
4483 session.pane.SetOmniboxVisible(false)
4484 if session.omnibox == nil {
4485 return
4486 }
4487
4488 session.omnibox.Hide(ctx)
4489 if session.overlay != nil && session.omniboxWidget != nil {
4490 if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
4491 session.overlay.RemoveOverlay(session.omniboxWidget)
4492 } else if parent != nil {
4493 session.omniboxWidget.Unparent()
4494 }
4495 }
4496 session.omnibox = nil
4497 session.omniboxWidget = nil
4498}
4499
4500func floatingFocusWidget(session *floatingWorkspaceSession) layout.Widget {
4501 if session == nil {
4502 return nil
4503 }
4504 if session.focusWidget != nil {
4505 return session.focusWidget
4506 }
4507 if session.paneView != nil {
4508 return session.paneView.WebViewWidget()
4509 }
4510 return nil
4511}
4512
4513func focusFloatingSessionContent(session *floatingWorkspaceSession) {
4514 if session == nil || session.pane == nil {
4515 return
4516 }
4517 if !session.pane.IsVisible() || session.pane.IsOmniboxVisible() {
4518 return
4519 }
4520 w := floatingFocusWidget(session)
4521 if w == nil {
4522 return
4523 }
4524 w.GrabFocus()
4525}
4526
4527func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
4528 if session == nil || session.pane == nil {
4529 return
4530 }
4531 session.pane.Resize()
4532 if session.widget == nil {
4533 return
4534 }
4535 width, height := session.pane.Dimensions()
4536 if session.appliedWidth == width && session.appliedHeight == height {
4537 return
4538 }
4539 session.widget.SetSizeRequest(width, height)
4540 session.appliedWidth = width
4541 session.appliedHeight = height
4542 if a.contentCoord != nil {
4543 syncCtx := context.Background()
4544 if a.deps != nil && a.deps.Ctx != nil {
4545 syncCtx = a.deps.Ctx
4546 }
4547 a.contentCoord.SyncWebViewViewport(syncCtx, session.paneID, "floating-resize")
4548 }
4549}
4550
4551func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
4552 if session == nil || session.pane == nil {
4553 return false
4554 }
4555 if !session.pane.IsVisible() {
4556 session.resizeWatcherActive = false
4557 session.resizeTickID = 0
4558 return false
4559 }
4560
4561 a.resizeFloatingWidget(session)
4562 return true
4563}
4564
4565func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
4566 if paneID == "" {
4567 return nil
4568 }
4569 for _, session := range a.floatingSessions {
4570 if session == nil || session.paneID != paneID {
4571 continue
4572 }
4573 return session
4574 }
4575 return nil
4576}
4577
4578func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
4579 if session == nil || session.overlay == nil || session.resizeWatcherActive {
4580 return
4581 }
4582
4583 overlayWidget := session.overlay.GtkWidget()
4584 if overlayWidget == nil {
4585 return
4586 }
4587
4588 session.resizeWatcherActive = true
4589 paneID := session.paneID
4590 tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
4591 liveSession := a.floatingSessionByPaneID(paneID)
4592 if liveSession == nil {
4593 session.resizeWatcherActive = false
4594 session.resizeTickID = 0
4595 return false
4596 }
4597
4598 keepRunning := a.handleFloatingViewportTick(liveSession)
4599 if !keepRunning {
4600 liveSession.resizeWatcherActive = false
4601 liveSession.resizeTickID = 0
4602 }
4603 return keepRunning
4604 })
4605 session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)
4606}
4607
4608func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
4609 if session == nil {
4610 return
4611 }
4612
4613 if session.resizeTickID != 0 && session.overlay != nil {
4614 if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
4615 overlayWidget.RemoveTickCallback(session.resizeTickID)
4616 }
4617 }
4618 session.resizeTickID = 0
4619 session.resizeWatcherActive = false
4620}
4621
4622func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
4623 if session == nil || session.pane == nil {
4624 return
4625 }
4626
4627 a.stopFloatingResizeWatcher(session)
4628 session.pane.Hide(ctx)
4629 a.hideFloatingOmnibox(ctx, session)
4630 hideFloatingWidget(session.widget)
4631 // Force a fresh size request on next show so WebKit gets a new
4632 // allocation cycle and repaints content after rapid toggles.
4633 session.appliedWidth = 0
4634 session.appliedHeight = 0
4635}
4636
4637func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
4638 for key, session := range a.floatingSessions {
4639 if key.tabID != tabID || session == nil || session == except || session.pane == nil {
4640 continue
4641 }
4642 if !session.pane.IsVisible() {
4643 continue
4644 }
4645 a.hideFloatingSession(ctx, session)
4646 }
4647}
4648
4649func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
4650 session, _ := a.activeFloatingSession()
4651 if session == nil {
4652 return false
4653 }
4654 a.hideFloatingSession(ctx, session)
4655 a.syncFloatingFocus()
4656 return true
4657}
4658
4659func (a *App) handleGlobalEscape(ctx context.Context) bool {
4660 return a.closeActiveFloatingPane(ctx)
4661}
4662
4663func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
4664 key, session, ok := a.activeFloatingSessionEntry()
4665 if !ok {
4666 return false
4667 }
4668 a.releaseFloatingSession(ctx, key, session)
4669 a.syncFloatingFocus()
4670 return true
4671}
4672
4673// ToggleFloatingPane toggles the active workspace floating pane visibility.
4674func (a *App) ToggleFloatingPane(ctx context.Context) error {
4675 activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
4676 if activeTab == nil {
4677 return nil
4678 }
4679 if a.closeActiveFloatingPane(ctx) {
4680 return nil
4681 }
4682 return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
4683}
4684
4685// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
4686func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
4687 return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
4688}
4689
4690// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
4691func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
4692 return a.openFloatingPaneSession(ctx, sessionID, url)
4693}
4694
4695func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
4696 activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
4697 if activeTab == nil {
4698 return nil
4699 }
4700
4701 wsView := a.workspaceViews[activeTab.ID]
4702 session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
4703 if err != nil {
4704 return err
4705 }
4706 hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
4707 isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
4708
4709 if hasURL && isProfileSession && session.pane.IsVisible() {
4710 a.hideFloatingSession(ctx, session)
4711 a.syncFloatingFocus()
4712 return nil
4713 }
4714
4715 a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
4716
4717 if hasURL {
4718 if isProfileSession && session.pane.SessionStarted() {
4719 session.pane.Show()
4720 } else {
4721 if err := session.pane.ShowURL(ctx, url[0]); err != nil {
4722 return err
4723 }
4724 }
4725 } else {
4726 if err := session.pane.ShowToggle(ctx); err != nil {
4727 return err
4728 }
4729 }
4730
4731 a.resizeFloatingWidget(session)
4732 setFloatingWidgetShown(session.widget, session.pane.IsVisible())
4733 if session.pane.IsOmniboxVisible() {
4734 a.showFloatingOmnibox(ctx, session)
4735 } else {
4736 a.hideFloatingOmnibox(ctx, session)
4737 focusFloatingSessionContent(session)
4738 }
4739 a.startFloatingResizeWatcher(session)
4740 a.syncFloatingFocus()
4741
4742 return nil
4743}
4744
4745func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
4746 session, _ := a.activeFloatingSession()
4747 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
4748 return session.pane.Navigate(ctx, url)
4749 }
4750 if bw := a.lastFocusedBrowserWindow(); bw != nil {
4751 return a.navigateFromBrowserWindow(ctx, bw, url)
4752 }
4753 return fmt.Errorf("no focused browser window for omnibox navigation")
4754}
4755
4756// ToggleOmnibox implements OmniboxProvider.
4757// Toggles the omnibox visibility in the active workspace view.
4758func (a *App) ToggleOmnibox(ctx context.Context) {
4759 log := logging.FromContext(ctx)
4760 if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() {
4761 wsView := a.activeWorkspaceView()
4762 if wsView != nil && wsView.IsOmniboxVisible() {
4763 wsView.HideOmnibox()
4764 }
4765
4766 if session.omnibox != nil && session.pane.IsOmniboxVisible() {
4767 a.hideFloatingOmnibox(ctx, session)
4768 } else {
4769 a.showFloatingOmnibox(ctx, session)
4770 }
4771 a.syncFloatingFocus()
4772 return
4773 }
4774
4775 wsView := a.activeWorkspaceView()
4776 if wsView == nil {
4777 log.Warn().Msg("no active workspace view for omnibox toggle")
4778 return
4779 }
4780
4781 if wsView.IsOmniboxVisible() {
4782 wsView.HideOmnibox()
4783 } else {
4784 wsView.ShowOmnibox(ctx, "")
4785 }
4786}
4787
4788// ToggleFindBar shows or hides the find bar in the active workspace view.
4789func (a *App) ToggleFindBar(ctx context.Context) {
4790 log := logging.FromContext(ctx)
4791
4792 wsView := a.activeWorkspaceView()
4793 if wsView == nil {
4794 log.Warn().Msg("no active workspace view for find bar toggle")
4795 return
4796 }
4797
4798 if wsView.IsFindBarVisible() {
4799 wsView.HideFindBar()
4800 } else {
4801 wsView.ShowFindBar(ctx)
4802 }
4803}
4804
4805// FindNext selects the next match in the active find bar.
4806func (a *App) FindNext(ctx context.Context) {
4807 wsView := a.activeWorkspaceView()
4808 if wsView == nil {
4809 return
4810 }
4811
4812 if !wsView.IsFindBarVisible() {
4813 wsView.ShowFindBar(ctx)
4814 }
4815 wsView.FindNext()
4816}
4817
4818// FindPrevious selects the previous match in the active find bar.
4819func (a *App) FindPrevious(ctx context.Context) {
4820 wsView := a.activeWorkspaceView()
4821 if wsView == nil {
4822 return
4823 }
4824
4825 if !wsView.IsFindBarVisible() {
4826 wsView.ShowFindBar(ctx)
4827 }
4828 wsView.FindPrevious()
4829}
4830
4831// CloseFindBar hides the find bar if visible.
4832func (a *App) CloseFindBar(ctx context.Context) {
4833 wsView := a.activeWorkspaceView()
4834 if wsView == nil {
4835 return
4836 }
4837 wsView.HideFindBar()
4838}
4839
4840// UpdateOmniboxZoom implements OmniboxProvider.
4841// Updates the zoom indicator on the current omnibox if visible.
4842func (a *App) UpdateOmniboxZoom(factor float64) {
4843 wsView := a.activeWorkspaceView()
4844 if wsView == nil {
4845 return
4846 }
4847
4848 omnibox := wsView.GetOmnibox()
4849 if omnibox != nil {
4850 omnibox.UpdateZoomIndicator(factor)
4851 }
4852}
4853
4854// initFilteringAsync starts background filter loading with toast feedback.
4855func (a *App) initConfigWatcher(ctx context.Context) {
4856 log := logging.FromContext(ctx)
4857
4858 a.syncExternalThemeWatcher(ctx)
4859
4860 if a.deps == nil || a.deps.RuntimeConfig == nil {
4861 log.Debug().Msg("no config watcher available, skipping config file watcher")
4862 return
4863 }
4864
4865 if err := a.deps.RuntimeConfig.Watch(); err != nil {
4866 log.Warn().Err(err).Msg("failed to start config watcher")
4867 return
4868 }
4869
4870 // Hot-reload appearance and keybindings on config change.
4871 a.deps.RuntimeConfig.OnChange(func(snapshot entity.RuntimeConfigSnapshot) {
4872 a.dispatchRuntimeConfigChange(ctx, snapshot)
4873 })
4874
4875 log.Debug().Msg("config watcher initialized")
4876}
4877
4878func (a *App) dispatchRuntimeConfigChange(ctx context.Context, snapshot entity.RuntimeConfigSnapshot) {
4879 run := func() {
4880 a.applyRuntimeConfigChange(ctx, snapshot)
4881 }
4882 if a != nil && a.dispatchOnMainThread != nil {
4883 a.dispatchOnMainThread("ui.runtime_config_reload", run)
4884 return
4885 }
4886 run()
4887}
4888
4889func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.RuntimeConfigSnapshot) {
4890 if a == nil {
4891 return
4892 }
4893 if a.runtimeConfig == nil {
4894 a.runtimeConfig = newRuntimeConfigState(nil)
4895 }
4896 a.runtimeConfig.Update(snapshot)
4897 if a.contentCoord != nil {
4898 a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts)
4899 }
4900 runtimeCfg := snapshot.UI
4901 workspaceCfg := runtimeCfg.Workspace
4902 sessionCfg := runtimeCfg.Session
4903 a.syncExternalThemeWatcher(ctx)
4904 a.applyAppearanceConfig(ctx)
4905 for _, bw := range a.browserWindows {
4906 if bw == nil {
4907 continue
4908 }
4909 // Reapply sidebar width from live config (reloads after sidebar_width
4910 // changes in the config file).
4911 bw.applySidebarWidthConfig(a)
4912 if bw.keyboardHandler != nil {
4913 bw.keyboardHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)
4914 }
4915 if bw.globalShortcutHandler != nil {
4916 bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)
4917 }
4918 }
4919}
4920
4921func (a *App) syncExternalThemeWatcher(ctx context.Context) {
4922 if a == nil || a.deps == nil || a.deps.ExternalThemeWatcher == nil {
4923 return
4924 }
4925 log := logging.FromContext(ctx)
4926 cfg := a.runtimeConfigSnapshot().UI.Appearance.ExternalTheme
4927 if err := a.deps.ExternalThemeWatcher.Start(ctx, cfg, func() {
4928 a.dispatchOnMainThread("ui.external_theme_reload", func() {
4929 a.applyAppearanceConfig(ctx)
4930 })
4931 }); err != nil {
4932 log.Warn().Err(err).Msg("failed to start external theme watcher")
4933 }
4934}
4935
4936func (a *App) applyAppearanceConfig(ctx context.Context) {
4937 log := logging.FromContext(ctx)
4938 if a == nil {
4939 return
4940 }
4941 snapshot := a.runtimeConfigSnapshot()
4942
4943 if a.engine != nil {
4944 if err := a.engine.UpdateSettings(ctx, entity.EngineSettingsUpdate{
4945 Settings: snapshot.EngineSettings,
4946 }); err != nil {
4947 log.Warn().Err(err).Msg("failed to apply engine settings update")
4948 }
4949 }
4950
4951 // Apply settings to existing webviews via coordinator
4952 if a.contentCoord != nil {
4953 a.contentCoord.ApplySettingsToAll(ctx)
4954 }
4955
4956 a.applyThemeAppearance(ctx)
4957
4958 log.Info().Msg("appearance config updated")
4959}
4960
4961func (a *App) applyThemeAppearance(ctx context.Context) {
4962 log := logging.FromContext(ctx)
4963 if a.deps == nil || a.deps.Theme == nil {
4964 return
4965 }
4966 runtimeCfg := a.runtimeConfigSnapshot().UI
4967 appearanceCfg := runtimeCfg.Appearance
4968 workspaceStyling := runtimeCfg.Workspace.Styling
4969
4970 var display *gdk.Display
4971 if a.mainWindow != nil && a.mainWindow.Window() != nil {
4972 display = a.mainWindow.Window().GetDisplay()
4973 }
4974
4975 preference := port.ColorSchemePreference{PrefersDark: a.deps.Theme.PrefersDark(), Source: "theme"}
4976 if a.deps.ColorResolver != nil {
4977 preference = a.deps.ColorResolver.Refresh()
4978 }
4979 if a.deps.ExternalThemeSource != nil {
4980 a.deps.ExternalThemeSource.Configure(appearanceCfg.ExternalTheme)
4981 }
4982 resolveThemeUC := a.deps.ResolveThemeUC
4983 if resolveThemeUC == nil {
4984 resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)
4985 }
4986 resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(
4987 &appearanceCfg,
4988 runtimeCfg.DefaultUIScale,
4989 &workspaceStyling,
4990 preference,
4991 ))
4992 if err != nil {
4993 log.Warn().Err(err).Msg("failed to resolve theme")
4994 } else {
4995 logThemeResolution(ctx, resolved.Theme)
4996 a.deps.Theme.UpdateFromResolved(ctx, resolved.Theme, display)
4997 a.setupPoolBackgroundColor(ctx)
4998 }
4999
5000 var inj port.ContentInjector
5001 if a.engine != nil {
5002 inj = a.engine.ContentInjector()
5003 }
5004
5005 if inj != nil {
5006 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
5007 if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil {
5008 log.Warn().Err(err).Msg("failed to update find highlight CSS")
5009 }
5010 }
5011
5012 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj)
5013 cssText := a.deps.Theme.GetWebUIThemeCSS()
5014 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
5015 log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
5016 }
5017
5018 if a.contentCoord != nil && inj != nil {
5019 a.contentCoord.RefreshInjectedScriptsToAll(ctx)
5020 }
5021
5022 // Apply WebUI theme to already-loaded dumb:// pages
5023 if a.contentCoord != nil {
5024 a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
5025 }
5026}
5027
5028func logThemeResolution(ctx context.Context, resolved entity.ResolvedTheme) {
5029 log := logging.FromContext(ctx)
5030 for _, warning := range resolved.Warnings {
5031 log.Warn().Str("field", warning.Field).Msg(warning.Message)
5032 }
5033 event := log.Info().
5034 Str("theme_source", string(resolved.ThemeSource.Kind)).
5035 Str("color_scheme_source", resolved.ColorSchemeSource).
5036 Bool("prefers_dark", resolved.PrefersDark)
5037 if resolved.ThemeSource.Provider != "" {
5038 event = event.Str("provider", resolved.ThemeSource.Provider)
5039 }
5040 if resolved.ThemeSource.LastGood {
5041 event = event.Bool("last_good", true)
5042 }
5043 event.Msg("theme resolved")
5044}
5045
5046func (a *App) initFilteringAsync(ctx context.Context) {
5047 if a.deps.FilterManager == nil {
5048 return
5049 }
5050
5051 a.deps.FilterManager.SetStatusCallback(func(status port.FilterStatus) {
5052 statusCopy := status // Capture for closure
5053 cb := glib.SourceFunc(func(_ uintptr) bool {
5054 a.showFilterStatus(ctx, statusCopy)
5055 return false // Don't repeat
5056 })
5057 glib.IdleAdd(&cb, 0)
5058 })
5059 a.deps.FilterManager.LoadAsync(ctx)
5060}
5061
5062// showFilterStatus displays toast notification for filter status.
5063func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) {
5064 log := logging.FromContext(ctx)
5065
5066 switch status.State {
5067 case port.FilterStateLoading:
5068 a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo)
5069 case port.FilterStateActive:
5070 // Apply filters to existing webviews that were created before filters loaded
5071 if a.contentCoord != nil && a.deps.FilterManager != nil {
5072 a.contentCoord.ApplyFiltersToAll(ctx)
5073 log.Debug().Msg("applied filters to all existing webviews")
5074 }
5075 a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
5076 case port.FilterStateError:
5077 a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError)
5078 }
5079}