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

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