v0.30.0 eb3068f

app.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close