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