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