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