v0.32.0 0617278

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

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