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