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