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