v0.31.0 bf0756b

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

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