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	// Hide loading skeleton once the WebView paints
3428	a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
3429		// The WebView can be shown while its pane is in a background tab, so scan
3430		// all WorkspaceViews rather than only the active one.
3431		for _, wsView := range a.workspaceViews {
3432			if wsView == nil {
3433				continue
3434			}
3435			if pv := wsView.GetPaneView(paneID); pv != nil {
3436				pv.HideLoadingSkeleton()
3437			}
3438		}
3439		if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
3440			a.firstWebViewShownOnce.Do(func() {
3441				a.deps.OnFirstWebViewShown(ctx)
3442			})
3443		}
3444		a.preloadAccessibilityForShownPane(paneID)
3445
3446		// This only updates focus when the shown pane belongs to the last-focused
3447		// window's active workspace. activeWorkspace() delegates to
3448		// lastFocusedBrowserWindow, so the accent-picker does not activate for
3449		// background windows.
3450		ws := a.activeWorkspace()
3451		if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
3452			a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
3453		}
3454	})
3455
3456	// 5. Keyboard Dispatcher
3457	// Dispatcher receives an app-level active pane provider (contentCoord.ActivePaneID)
3458	// and per-window handlers route source-window actions through
3459	// dispatchBrowserWindowAction for proper multi-window isolation.
3460	a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
3461		ctx,
3462		a.wsCoord,
3463		a.navCoord,
3464		a.deps.ZoomUC,
3465		a.deps.CopyURLUC,
3466		a.keyboardActions(),
3467		a.contentCoord.ActivePaneID,
3468	)
3469	a.wireKeyboardActions()
3470	for _, bw := range a.browserWindows {
3471		a.initBrowserWindowInput(ctx, bw)
3472	}
3473
3474	log.Debug().Msg("coordinators initialized")
3475}
3476
3477func (a *App) keyboardActions() dispatcher.KeyboardActions {
3478	return dispatcher.KeyboardActions{
3479		NewTab: func(ctx context.Context) error {
3480			newPaneURL := a.runtimeConfigSnapshot().UI.Workspace.NewPaneURL
3481			if newPaneURL == "" {
3482				logging.FromContext(ctx).Warn().Msg("new tab ignored: new pane URL is not configured")
3483				return fmt.Errorf("newPaneURL is not configured")
3484			}
3485			return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {
3486				_, err := a.tabCoord.Create(ctx, target, newPaneURL)
3487				return err
3488			})
3489		},
3490		CloseTab: func(ctx context.Context) error {
3491			return a.withFocusedTabTarget(ctx, "close tab", false, func(target coordinator.TabTarget) error {
3492				return a.tabCoord.Close(ctx, target)
3493			})
3494		},
3495		NextTab: func(ctx context.Context) error {
3496			return a.withFocusedTabTarget(ctx, "next tab", false, func(target coordinator.TabTarget) error {
3497				return a.tabCoord.SwitchNext(ctx, target)
3498			})
3499		},
3500		PreviousTab: func(ctx context.Context) error {
3501			return a.withFocusedTabTarget(ctx, "previous tab", false, func(target coordinator.TabTarget) error {
3502				return a.tabCoord.SwitchPrev(ctx, target)
3503			})
3504		},
3505		SwitchLastTab: func(ctx context.Context) error {
3506			return a.withFocusedTabTarget(ctx, "switch last tab", false, func(target coordinator.TabTarget) error {
3507				return a.tabCoord.SwitchToLastActive(ctx, target)
3508			})
3509		},
3510		SwitchTabIndex: func(ctx context.Context, index int) error {
3511			return a.switchBrowserWindowTabIndex(ctx, a.lastFocusedBrowserWindow(), index)
3512		},
3513		ActiveWebView: func(context.Context) port.WebView {
3514			_, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
3515			return wv
3516		},
3517	}
3518}
3519
3520func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bool, fn func(coordinator.TabTarget) error) error {
3521	if a.tabCoord == nil {
3522		logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: tab coordinator is nil")
3523		return nil
3524	}
3525	bw := a.lastFocusedBrowserWindow()
3526	if bw == nil {
3527		logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: no focused browser window")
3528		return nil
3529	}
3530	target := a.tabTargetForBrowserWindow(bw)
3531	if ensure {
3532		target = a.ensureTabTargetForBrowserWindow(bw)
3533	}
3534	return fn(target)
3535}
3536
3537func (a *App) wireKeyboardActions() {
3538	a.kbDispatcher.SetOnQuit(a.Quit)
3539	a.kbDispatcher.SetOnVimModePulse(func(ctx context.Context, fast bool) {
3540		a.triggerVimModePulse(ctx, fast)
3541	})
3542	a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
3543		a.ToggleFindBar(ctx)
3544		return nil
3545	})
3546	a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
3547		a.FindNext(ctx)
3548		return nil
3549	})
3550	a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
3551		a.FindPrevious(ctx)
3552		return nil
3553	})
3554	a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
3555		a.CloseFindBar(ctx)
3556		return nil
3557	})
3558	a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context, paneID entity.PaneID) error {
3559		if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
3560			a.activateBrowserWindow(bw)
3561		}
3562		return a.HandleMovePaneToTab(ctx)
3563	})
3564	a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context, paneID entity.PaneID) error {
3565		if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
3566			a.activateBrowserWindow(bw)
3567		}
3568		return a.HandleMovePaneToNextTab(ctx)
3569	})
3570	a.kbDispatcher.SetOnEjectPaneToWindow(func(ctx context.Context, paneID entity.PaneID) error {
3571		if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
3572			a.activateBrowserWindow(bw)
3573		}
3574		return a.EjectActivePaneToWindow(ctx, paneID)
3575	})
3576	a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
3577	a.kbDispatcher.SetOnToggleFavoritesSidebar(a.toggleFavoritesSidebarAction)
3578	a.kbDispatcher.SetOnToggleCurrentPageFavorite(a.toggleCurrentPageFavoriteAction)
3579	a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
3580		return a.ToggleFloatingPane(ctx)
3581	})
3582	a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error {
3583		return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL)
3584	})
3585
3586	// Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
3587	a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
3588		return a.kbDispatcher.Dispatch(ctx, action)
3589	})
3590}
3591
3592func (a *App) wireWebRTCPermissionIndicator() {
3593	if a.contentCoord == nil {
3594		return
3595	}
3596	ctx := context.Background()
3597	if a.deps != nil && a.deps.Ctx != nil {
3598		ctx = a.deps.Ctx
3599	}
3600
3601	a.contentCoord.SetOnPermissionActivity(func(
3602		paneID entity.PaneID,
3603		origin string,
3604		permTypes []entity.PermissionType,
3605		state content.PermissionActivityState,
3606	) {
3607		bw := a.browserWindowForPane(paneID)
3608		if bw == nil || bw.webrtcIndicator == nil {
3609			return
3610		}
3611		if state == content.PermissionActivityRequesting && a.deps != nil && a.deps.PermissionUC != nil && bw.permissionDialog != nil {
3612			a.deps.PermissionUC.SetDialogPresenter(bw.permissionDialog)
3613		}
3614		bw.webrtcIndicator.SetOrigin(origin)
3615
3616		switch state {
3617		case content.PermissionActivityRequesting:
3618			bw.webrtcIndicator.MarkRequesting(permTypes)
3619		case content.PermissionActivityAllowed:
3620			bw.webrtcIndicator.MarkAllowed(permTypes)
3621		case content.PermissionActivityBlocked:
3622			bw.webrtcIndicator.MarkBlocked(permTypes)
3623		}
3624
3625		for _, permType := range permTypes {
3626			a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType)
3627		}
3628	})
3629
3630	// Reset the owning window's indicator when that pane navigates away.
3631	a.contentCoord.SetOnActiveNavigationCommitted(func(paneID entity.PaneID, uri string) {
3632		bw := a.browserWindowForPane(paneID)
3633		if bw == nil || bw.webrtcIndicator == nil {
3634			return
3635		}
3636		newOrigin, err := urlutil.ExtractOrigin(uri)
3637		if err != nil {
3638			bw.webrtcIndicator.Reset()
3639			return
3640		}
3641
3642		currentOrigin := bw.webrtcIndicator.Origin()
3643		if currentOrigin != "" && currentOrigin != newOrigin {
3644			bw.webrtcIndicator.Reset()
3645		}
3646	})
3647}
3648
3649func (a *App) wireBrowserWindowPermissionIndicator(bw *browserWindow) {
3650	if bw == nil || bw.webrtcIndicator == nil {
3651		return
3652	}
3653	ctx := context.Background()
3654	if a.deps != nil && a.deps.Ctx != nil {
3655		ctx = a.deps.Ctx
3656	}
3657	log := logging.FromContext(ctx)
3658
3659	bw.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
3660		if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
3661			return
3662		}
3663
3664		if hasStored {
3665			// Already locked: reset to prompt (will re-ask on next request).
3666			if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil {
3667				log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision")
3668				return
3669			}
3670		} else {
3671			// Not locked: flip the state.
3672			// Allowed/requesting → deny future requests.
3673			// Blocked → allow future requests.
3674			decision := entity.PermissionDenied
3675			if state == string(content.PermissionActivityBlocked) {
3676				decision = entity.PermissionGranted
3677			}
3678			if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
3679				log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision")
3680				return
3681			}
3682		}
3683
3684		a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType)
3685	})
3686}
3687
3688func (a *App) syncWebRTCPermissionLockState(
3689	ctx context.Context,
3690	indicator *component.WebRTCPermissionIndicator,
3691	origin string,
3692	permType entity.PermissionType,
3693) {
3694	if indicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
3695		return
3696	}
3697
3698	record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType)
3699	if err != nil {
3700		logging.FromContext(ctx).Warn().
3701			Err(err).
3702			Str("origin", origin).
3703			Str("type", string(permType)).
3704			Msg("failed to read manual permission decision")
3705		return
3706	}
3707
3708	if record == nil {
3709		indicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
3710		return
3711	}
3712
3713	indicator.SetStoredDecision(permType, record.Decision, true)
3714}
3715
3716// generateWindowID generates a unique ID for top-level browser windows.
3717func (a *App) generateWindowID() string {
3718	a.windowIDMu.Lock()
3719	defer a.windowIDMu.Unlock()
3720	a.windowIDCounter++
3721	return fmt.Sprintf("w%d", a.windowIDCounter)
3722}
3723
3724const (
3725	uiMainThreadDispatchTimeout       = 2 * time.Second
3726	uiMainThreadDispatchSlowThreshold = 250 * time.Millisecond
3727)
3728
3729// runOnMainThread executes fn inline when already on the GTK main context;
3730// otherwise it dispatches fn to the GTK main loop and waits for bounded
3731// completion so IPC and CEF callback paths do not wait forever on a wedged UI.
3732func (a *App) runOnMainThread(label string, fn func()) syncdispatch.SyncDispatchResult {
3733	result := syncdispatch.RunSynchronousDispatch(syncdispatch.SyncDispatchOptions{
3734		Label:   label,
3735		Timeout: uiMainThreadDispatchTimeout,
3736		IsOwner: func() bool {
3737			glibCtx := glib.MainContextDefault()
3738			return glibCtx != nil && glibCtx.IsOwner()
3739		},
3740		Dispatch: func(cb func()) {
3741			wrapped := new(glib.SourceOnceFunc)
3742			*wrapped = func(_ uintptr) { cb() }
3743			glib.IdleAddOnce(wrapped, 0)
3744		},
3745	}, fn)
3746	a.logMainThreadDispatchResult(result)
3747	return result
3748}
3749
3750func (a *App) logMainThreadDispatchResult(result syncdispatch.SyncDispatchResult) {
3751	ctx := context.Background()
3752	if a != nil && a.deps != nil && a.deps.Ctx != nil {
3753		ctx = a.deps.Ctx
3754	}
3755	logger := logging.FromContext(ctx)
3756	switch result.Status {
3757	case syncdispatch.SyncDispatchTimedOut:
3758		logger.Warn().
3759			Str("dispatch_label", result.Label).
3760			Dur("elapsed", result.Elapsed).
3761			Dur("timeout", uiMainThreadDispatchTimeout).
3762			Msg("ui: main-thread dispatch timed out before callback started")
3763	case syncdispatch.SyncDispatchCompletedAfterTimeout:
3764		logger.Warn().
3765			Str("dispatch_label", result.Label).
3766			Dur("elapsed", result.Elapsed).
3767			Dur("timeout", uiMainThreadDispatchTimeout).
3768			Msg("ui: main-thread dispatch completed after timeout")
3769	case syncdispatch.SyncDispatchQueuedAfterTimeout:
3770		logger.Warn().
3771			Str("dispatch_label", result.Label).
3772			Dur("elapsed", result.Elapsed).
3773			Dur("timeout", uiMainThreadDispatchTimeout).
3774			Msg("ui: main-thread dispatch left queued after timeout")
3775	case syncdispatch.SyncDispatchCompleted:
3776		if result.Elapsed >= uiMainThreadDispatchSlowThreshold {
3777			logger.Debug().
3778				Str("dispatch_label", result.Label).
3779				Dur("elapsed", result.Elapsed).
3780				Msg("ui: main-thread dispatch completed slowly")
3781		}
3782	}
3783}
3784
3785// generateID generates a unique ID for tabs and panes.
3786// Uses monotonically increasing counter to avoid ID collisions.
3787func (a *App) generateID() string {
3788	a.idMu.Lock()
3789	defer a.idMu.Unlock()
3790	a.idCounter++
3791	return fmt.Sprintf("p%d", a.idCounter)
3792}
3793
3794// Tabs returns the tab list.
3795func (a *App) Tabs() *entity.TabList {
3796	return a.tabs
3797}
3798
3799// MainWindow returns the main window.
3800func (a *App) MainWindow() *window.MainWindow {
3801	return a.mainWindow
3802}
3803
3804// Quit requests the application to quit.
3805func (a *App) Quit() {
3806	if a == nil || a.gtkApp == nil {
3807		return
3808	}
3809	quit := func() {
3810		// The whole residency/GTK quit transition runs on the GTK thread:
3811		// canceling the idle timer touches GLib sources, which is only
3812		// safe on its owner thread. Explicit quits and signals never wait
3813		// for the idle deadline and must not bypass orderly cleanup.
3814		if a.residency != nil {
3815			a.residency.NoteExplicitQuit()
3816		}
3817		if a.gtkApp != nil {
3818			a.gtkApp.Quit()
3819		}
3820	}
3821	glibCtx := glib.MainContextDefault()
3822	if glibCtx == nil || !glibCtx.IsOwner() {
3823		// Off-thread quits (signals, relay threads) drain admission and
3824		// persistence BEFORE entering GTK shutdown, while the loop can
3825		// still service admitted work. On the GTK thread itself this wait
3826		// is impossible, so shutdown relies on close-then-teardown
3827		// ordering instead; the dispatched closure below never blocks.
3828		a.drainForShutdownOffThread()
3829	}
3830	if glibCtx != nil && glibCtx.IsOwner() {
3831		quit()
3832		return
3833	}
3834	cb := glib.SourceOnceFunc(func(_ uintptr) {
3835		quit()
3836	})
3837	glib.IdleAddOnce(&cb, 0)
3838}
3839
3840// RunWithArgs is a convenience function that creates and runs an App.
3841func RunWithArgs(ctx context.Context, deps *Dependencies) int {
3842	app, err := New(deps)
3843	if err != nil {
3844		log := logging.FromContext(ctx)
3845		log.Error().Err(err).Msg("failed to create application")
3846		return 1
3847	}
3848	return app.Run(ctx, os.Args)
3849}
3850
3851// updateWindowTitle updates the window title with the given page title.
3852// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
3853func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
3854	if target == nil {
3855		target = a.browserWindowForMainWindow(a.mainWindow)
3856	}
3857	if target == nil || target.mainWindow == nil {
3858		return
3859	}
3860
3861	title := appTitle
3862	if pageTitle != "" {
3863		title = pageTitle + " - " + appTitle
3864	}
3865	target.mainWindow.SetTitle(title)
3866}
3867
3868// updateWindowTitleFromActivePane updates the window title based on the current active pane.
3869func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
3870	var ws *entity.Workspace
3871	bw := a.browserWindowForTab(tabID)
3872	if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
3873		if tab := tabs.Find(tabID); tab != nil {
3874			ws = tab.Workspace
3875		}
3876	}
3877	if ws == nil {
3878		ws = a.activeWorkspace()
3879	}
3880	if ws == nil || a.contentCoord == nil {
3881		a.updateWindowTitle("", a.browserWindowForTab(tabID))
3882		return
3883	}
3884	title := a.contentCoord.GetTitle(ws.ActivePaneID)
3885	a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
3886}
3887
3888// getModeToastClass returns the CSS class for the given mode's toast styling.
3889func getModeToastClass(mode input.Mode) string {
3890	switch mode {
3891	case input.ModePane:
3892		return "toast-pane-mode"
3893	case input.ModeVim:
3894		return "toast-vim-mode"
3895	case input.ModeTab:
3896		return "toast-tab-mode"
3897	case input.ModeSession:
3898		return "toast-session-mode"
3899	case input.ModeResize:
3900		return "toast-resize-mode"
3901	default:
3902		return ""
3903	}
3904}
3905
3906// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
3907func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
3908	if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
3909		return
3910	}
3911
3912	target := a.browserWindowForTab(tab.ID)
3913	if target != nil {
3914		a.setBrowserWindowForTab(tab.ID, target)
3915	}
3916
3917	// Attach to content area
3918	wsView := a.workspaceViews[tab.ID]
3919	if wsView != nil && target != nil && target.mainWindow != nil {
3920		widget := wsView.Widget()
3921		if widget != nil {
3922			gtkWidget := widget.GtkWidget()
3923			if gtkWidget != nil {
3924				target.mainWindow.SetContent(gtkWidget)
3925			}
3926		}
3927	}
3928}
3929
3930// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
3931// Used during session restoration where we create all views first, then attach only the active one.
3932func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) bool {
3933	if a.workspaceViewCreateOverride != nil {
3934		return a.workspaceViewCreateOverride(ctx, tab)
3935	}
3936
3937	log := logging.FromContext(ctx)
3938
3939	if a.widgetFactory == nil {
3940		log.Error().Msg("widget factory not initialized")
3941		return false
3942	}
3943
3944	// Create workspace view
3945	wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
3946	if wsView == nil {
3947		log.Error().Msg("failed to create workspace view")
3948		return false
3949	}
3950	a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
3951	if a.contentCoord != nil {
3952		syncCtx := context.Background()
3953		if a.deps != nil && a.deps.Ctx != nil {
3954			syncCtx = a.deps.Ctx
3955		}
3956		wsView.SetOnWebViewAttached(func(paneID entity.PaneID) {
3957			a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-widget-attached")
3958		})
3959		wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) {
3960			a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
3961			if bw := a.browserWindowForTab(tab.ID); bw != nil {
3962				a.transferVimModeOwnershipToPane(ctx, bw, paneID)
3963				if bw.keyboardHandler != nil {
3964					a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
3965				}
3966			}
3967		})
3968	}
3969
3970	// Set the workspace
3971	if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
3972		log.Error().Err(err).Msg("failed to set workspace in view")
3973		return false
3974	}
3975
3976	// Ensure WebViews are attached to panes
3977	if a.contentCoord != nil {
3978		a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
3979	}
3980
3981	// Set omnibox config for this workspace view.
3982	// When the owning browser window is known, bind navigation to the
3983	// owning window so that the omnibox always navigates in the correct
3984	// window, regardless of global focus.
3985	if owner := a.browserWindowForTab(tab.ID); owner != nil {
3986		cfg := a.omniboxCfg
3987		cfg.OnNavigate = omniboxNavigateForBrowserWindow(ctx, owner, a.navigateFromBrowserWindow)
3988		wsView.SetOmniboxConfig(cfg)
3989	} else {
3990		wsView.SetOmniboxConfig(a.omniboxCfg)
3991	}
3992	// Set find bar config for this workspace view
3993	wsView.SetFindBarConfig(a.findBarCfg)
3994	// Set auto-open omnibox on new pane
3995	wsView.SetAutoOpenOnNewPane(a.runtimeConfigSnapshot().UI.Omnibox.AutoOpenOnNewPane)
3996
3997	wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
3998		if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.keyboardHandler != nil {
3999			a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
4000		}
4001	})
4002
4003	wsView.SetOnRebuilt(func() {
4004		if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.keyboardHandler != nil {
4005			a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
4006		}
4007	})
4008
4009	wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
4010		if a.wsCoord != nil {
4011			_ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
4012		}
4013	})
4014
4015	// Store in map
4016	a.workspaceViews[tab.ID] = wsView
4017	a.reattachFloatingSessions(tab.ID, wsView)
4018	a.syncFloatingFocus()
4019
4020	log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
4021	return true
4022}
4023
4024// activeWorkspace returns the workspace of the focused browser window's active tab.
4025func (a *App) activeWorkspace() *entity.Workspace {
4026	return a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow())
4027}
4028
4029// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
4030// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
4031func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
4032	for _, bw := range a.browserWindows {
4033		if bw == nil || bw.tabs == nil {
4034			continue
4035		}
4036		for _, tab := range bw.tabs.Tabs {
4037			if tab == nil || tab.Workspace == nil {
4038				continue
4039			}
4040			paneNode := tab.Workspace.FindPane(paneID)
4041			if paneNode != nil && paneNode.Pane != nil {
4042				paneNode.Pane.URI = url
4043				return // Pane IDs are unique, no need to continue
4044			}
4045		}
4046	}
4047}
4048
4049// activeWorkspaceView returns the workspace view for the focused browser window's active tab.
4050func (a *App) activeWorkspaceView() *component.WorkspaceView {
4051	return a.activeWorkspaceViewForBrowserWindow(a.lastFocusedBrowserWindow())
4052}
4053
4054// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
4055// Used by the accent picker to insert accented characters into web content.
4056func (a *App) getActiveWebViewTarget() port.TextInputTarget {
4057	_, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
4058	if wv == nil {
4059		return nil
4060	}
4061
4062	if provider, ok := wv.(port.TextInputTargetProvider); ok {
4063		return provider.TextInputTarget()
4064	}
4065	return nil
4066}
4067
4068// attachPopupToTab attaches a popup WebView to a newly created tab.
4069// This is called when a popup uses tabbed behavior.
4070func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error {
4071	log := logging.FromContext(ctx)
4072	wsView := a.workspaceViews[tabID]
4073	if wsView == nil {
4074		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
4075		return fmt.Errorf("workspace view not found for popup tab %q", tabID)
4076	}
4077	if pane == nil {
4078		log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil")
4079		return fmt.Errorf("popup pane is nil")
4080	}
4081	if wsView.GetPaneView(pane.ID) == nil {
4082		log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
4083		return fmt.Errorf("pane view not found for popup %q", pane.ID)
4084	}
4085	if a.contentCoord == nil {
4086		log.Warn().Str("pane_id", string(pane.ID)).Msg("content coordinator not configured for popup")
4087		return fmt.Errorf("content coordinator not configured for popup")
4088	}
4089
4090	// Wrap and attach before registration so a failed attachment leaves the
4091	// caller-owned WebView untouched and unregistered.
4092	widget := a.contentCoord.WrapWidget(ctx, wv)
4093	if widget == nil {
4094		log.Warn().Str("pane_id", string(pane.ID)).Msg("failed to wrap popup webview")
4095		return fmt.Errorf("wrap popup webview")
4096	}
4097	if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
4098		log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
4099		return fmt.Errorf("set popup webview widget: %w", err)
4100	}
4101	a.contentCoord.RegisterPopupWebView(pane.ID, wv)
4102	log.Debug().
4103		Str("tab_id", string(tabID)).
4104		Str("pane_id", string(pane.ID)).
4105		Msg("popup webview attached to tab")
4106	return nil
4107}
4108
4109// switchWorkspaceView swaps the displayed workspace view for a tab.
4110func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
4111	log := logging.FromContext(ctx)
4112
4113	wsView, exists := a.workspaceViews[tabID]
4114	if !exists {
4115		log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
4116		return
4117	}
4118
4119	// Get the workspace view's root widget
4120	widget := wsView.Widget()
4121	if widget == nil {
4122		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
4123		return
4124	}
4125
4126	gtkWidget := widget.GtkWidget()
4127	if gtkWidget == nil {
4128		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
4129		return
4130	}
4131
4132	// Swap content (MainWindow.SetContent now properly removes old content).
4133	// Active tab state is managed by TabList.SetActive; no per-window field needed.
4134	if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
4135		target.mainWindow.SetContent(gtkWidget)
4136		if target.keyboardHandler != nil {
4137			a.retargetModeFrame(target, target.keyboardHandler.Mode())
4138		}
4139	} else if a.mainWindow != nil {
4140		a.mainWindow.SetContent(gtkWidget)
4141	}
4142
4143	// Update window title with the new active pane's title
4144	a.updateWindowTitleFromActivePane(tabID)
4145	a.syncFloatingFocus()
4146
4147	log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
4148}
4149
4150func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
4151	session, _ := a.activeFloatingSession()
4152	if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
4153		return session.pane.Navigate(ctx, url)
4154	}
4155	if bw := a.lastFocusedBrowserWindow(); bw != nil {
4156		return a.navigateFromBrowserWindow(ctx, bw, url)
4157	}
4158	return fmt.Errorf("no focused browser window for omnibox navigation")
4159}
4160
4161// ToggleOmnibox implements OmniboxProvider.
4162// Toggles the omnibox visibility in the active workspace view.
4163func (a *App) ToggleOmnibox(ctx context.Context) {
4164	log := logging.FromContext(ctx)
4165	if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() {
4166		wsView := a.activeWorkspaceView()
4167		if wsView != nil && wsView.IsOmniboxVisible() {
4168			wsView.HideOmnibox()
4169		}
4170
4171		if session.omnibox != nil && session.pane.IsOmniboxVisible() {
4172			a.hideFloatingOmnibox(ctx, session)
4173		} else {
4174			a.showFloatingOmnibox(ctx, session)
4175		}
4176		a.syncFloatingFocus()
4177		return
4178	}
4179
4180	wsView := a.activeWorkspaceView()
4181	if wsView == nil {
4182		log.Warn().Msg("no active workspace view for omnibox toggle")
4183		return
4184	}
4185
4186	if wsView.IsOmniboxVisible() {
4187		wsView.HideOmnibox()
4188	} else {
4189		wsView.ShowOmnibox(ctx, "")
4190	}
4191}
4192
4193// ToggleFindBar shows or hides the find bar in the active workspace view.
4194func (a *App) ToggleFindBar(ctx context.Context) {
4195	log := logging.FromContext(ctx)
4196
4197	wsView := a.activeWorkspaceView()
4198	if wsView == nil {
4199		log.Warn().Msg("no active workspace view for find bar toggle")
4200		return
4201	}
4202
4203	if wsView.IsFindBarVisible() {
4204		wsView.HideFindBar()
4205	} else {
4206		wsView.ShowFindBar(ctx)
4207	}
4208}
4209
4210// FindNext selects the next match in the active find bar.
4211func (a *App) FindNext(ctx context.Context) {
4212	wsView := a.activeWorkspaceView()
4213	if wsView == nil {
4214		return
4215	}
4216
4217	if !wsView.IsFindBarVisible() {
4218		wsView.ShowFindBar(ctx)
4219	}
4220	wsView.FindNext()
4221}
4222
4223// FindPrevious selects the previous match in the active find bar.
4224func (a *App) FindPrevious(ctx context.Context) {
4225	wsView := a.activeWorkspaceView()
4226	if wsView == nil {
4227		return
4228	}
4229
4230	if !wsView.IsFindBarVisible() {
4231		wsView.ShowFindBar(ctx)
4232	}
4233	wsView.FindPrevious()
4234}
4235
4236// CloseFindBar hides the find bar if visible.
4237func (a *App) CloseFindBar(ctx context.Context) {
4238	wsView := a.activeWorkspaceView()
4239	if wsView == nil {
4240		return
4241	}
4242	wsView.HideFindBar()
4243}
4244
4245// UpdateOmniboxZoom implements OmniboxProvider.
4246// Updates the zoom indicator on the current omnibox if visible.
4247func (a *App) UpdateOmniboxZoom(factor float64) {
4248	wsView := a.activeWorkspaceView()
4249	if wsView == nil {
4250		return
4251	}
4252
4253	omnibox := wsView.GetOmnibox()
4254	if omnibox != nil {
4255		omnibox.UpdateZoomIndicator(factor)
4256	}
4257}
4258
4259// initFilteringAsync starts background filter loading with toast feedback.
4260func (a *App) initConfigWatcher(ctx context.Context) {
4261	log := logging.FromContext(ctx)
4262
4263	a.syncExternalThemeWatcher(ctx)
4264
4265	if a.deps == nil || a.deps.RuntimeConfig == nil {
4266		log.Debug().Msg("no config watcher available, skipping config file watcher")
4267		return
4268	}
4269
4270	if err := a.deps.RuntimeConfig.Watch(); err != nil {
4271		log.Warn().Err(err).Msg("failed to start config watcher")
4272		return
4273	}
4274
4275	// Hot-reload appearance and keybindings on config change.
4276	a.deps.RuntimeConfig.OnChange(func(snapshot entity.RuntimeConfigSnapshot) {
4277		a.dispatchRuntimeConfigChange(ctx, snapshot)
4278	})
4279
4280	log.Debug().Msg("config watcher initialized")
4281}
4282
4283func (a *App) dispatchRuntimeConfigChange(ctx context.Context, snapshot entity.RuntimeConfigSnapshot) {
4284	run := func() {
4285		a.applyRuntimeConfigChange(ctx, snapshot)
4286	}
4287	if a != nil && a.dispatchOnMainThread != nil {
4288		a.dispatchOnMainThread("ui.runtime_config_reload", run)
4289		return
4290	}
4291	run()
4292}
4293
4294func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.RuntimeConfigSnapshot) {
4295	if a == nil {
4296		return
4297	}
4298	if a.runtimeConfig == nil {
4299		a.runtimeConfig = newRuntimeConfigState(nil)
4300	}
4301	a.runtimeConfig.Update(snapshot)
4302	if a.contentCoord != nil {
4303		a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts)
4304	}
4305	runtimeCfg := snapshot.UI
4306	workspaceCfg := runtimeCfg.Workspace
4307	sessionCfg := runtimeCfg.Session
4308	a.syncExternalThemeWatcher(ctx)
4309	a.applyAppearanceConfig(ctx)
4310	a.omniboxCfg.UIScale = runtimeCfg.DefaultUIScale
4311	for _, view := range a.workspaceViews {
4312		if view != nil {
4313			view.SetOmniboxUIScale(runtimeCfg.DefaultUIScale)
4314		}
4315	}
4316	for _, session := range a.floatingSessions.all() {
4317		if session.omnibox != nil {
4318			session.omnibox.SetUIScale(runtimeCfg.DefaultUIScale)
4319		}
4320	}
4321	for _, bw := range a.browserWindows {
4322		if bw == nil {
4323			continue
4324		}
4325		if bw.sessionManager != nil {
4326			bw.sessionManager.SetUIScale(runtimeCfg.DefaultUIScale)
4327		}
4328		// Reapply sidebar width from live config (reloads after sidebar_width
4329		// changes in the config file).
4330		bw.applySidebarWidthConfig(a)
4331		if bw.keyboardHandler != nil {
4332			bw.keyboardHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)
4333		}
4334		if bw.globalShortcutHandler != nil {
4335			bw.globalShortcutHandler.ReloadShortcuts(ctx, &workspaceCfg, &sessionCfg)
4336		}
4337		mode := input.ModeNormal
4338		if bw.keyboardHandler != nil {
4339			mode = bw.keyboardHandler.Mode()
4340		}
4341		a.updateModeFrame(bw, mode)
4342		a.updateModeIndicatorToaster(ctx, bw, mode)
4343	}
4344}
4345
4346func (a *App) syncExternalThemeWatcher(ctx context.Context) {
4347	if a == nil || a.deps == nil || a.deps.ExternalThemeWatcher == nil {
4348		return
4349	}
4350	log := logging.FromContext(ctx)
4351	cfg := a.runtimeConfigSnapshot().UI.Appearance.ExternalTheme
4352	if err := a.deps.ExternalThemeWatcher.Start(ctx, cfg, func() {
4353		a.dispatchOnMainThread("ui.external_theme_reload", func() {
4354			a.applyAppearanceConfig(ctx)
4355		})
4356	}); err != nil {
4357		log.Warn().Err(err).Msg("failed to start external theme watcher")
4358	}
4359}
4360
4361func (a *App) applyAppearanceConfig(ctx context.Context) {
4362	log := logging.FromContext(ctx)
4363	if a == nil {
4364		return
4365	}
4366	snapshot := a.runtimeConfigSnapshot()
4367
4368	if a.engine != nil {
4369		if err := a.engine.UpdateSettings(ctx, entity.EngineSettingsUpdate{
4370			Settings: snapshot.EngineSettings,
4371		}); err != nil {
4372			log.Warn().Err(err).Msg("failed to apply engine settings update")
4373		}
4374	}
4375
4376	// Apply settings to existing webviews via coordinator
4377	if a.contentCoord != nil {
4378		a.contentCoord.ApplySettingsToAll(ctx)
4379	}
4380
4381	a.applyThemeAppearance(ctx)
4382
4383	log.Info().Msg("appearance config updated")
4384}
4385
4386func (a *App) applyThemeAppearance(ctx context.Context) {
4387	log := logging.FromContext(ctx)
4388	if a.deps == nil || a.deps.Theme == nil {
4389		return
4390	}
4391	runtimeCfg := a.runtimeConfigSnapshot().UI
4392	appearanceCfg := runtimeCfg.Appearance
4393	workspaceStyling := runtimeCfg.Workspace.Styling
4394
4395	var display *gdk.Display
4396	if a.mainWindow != nil && a.mainWindow.Window() != nil {
4397		display = a.mainWindow.Window().GetDisplay()
4398	}
4399
4400	preference := port.ColorSchemePreference{PrefersDark: a.deps.Theme.PrefersDark(), Source: "theme"}
4401	if a.deps.ColorResolver != nil {
4402		preference = a.deps.ColorResolver.Refresh()
4403	}
4404	if a.deps.ExternalThemeSource != nil {
4405		a.deps.ExternalThemeSource.Configure(appearanceCfg.ExternalTheme)
4406	}
4407	resolveThemeUC := a.deps.ResolveThemeUC
4408	if resolveThemeUC == nil {
4409		resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)
4410	}
4411	a.deps.Theme.SetTransitionDuration(workspaceStyling.TransitionDuration)
4412	resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(
4413		&appearanceCfg,
4414		runtimeCfg.DefaultUIScale,
4415		&workspaceStyling,
4416		preference,
4417	))
4418	if err != nil {
4419		log.Warn().Err(err).Msg("failed to resolve theme")
4420	} else {
4421		logThemeResolution(ctx, resolved.Theme)
4422		a.deps.Theme.UpdateFromResolved(ctx, resolved.Theme, display)
4423		a.setupPoolBackgroundColor(ctx)
4424	}
4425
4426	var inj port.ContentInjector
4427	if a.engine != nil {
4428		inj = a.engine.ContentInjector()
4429	}
4430
4431	if inj != nil {
4432		findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
4433		if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil {
4434			log.Warn().Err(err).Msg("failed to update find highlight CSS")
4435		}
4436	}
4437
4438	prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj)
4439	cssText := a.deps.Theme.GetWebUIThemeCSS()
4440	if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
4441		log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
4442	}
4443
4444	if a.contentCoord != nil && inj != nil {
4445		a.contentCoord.RefreshInjectedScriptsToAll(ctx)
4446	}
4447
4448	// Apply WebUI theme to already-loaded dumb:// pages
4449	if a.contentCoord != nil {
4450		a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
4451	}
4452}
4453
4454func logThemeResolution(ctx context.Context, resolved entity.ResolvedTheme) {
4455	log := logging.FromContext(ctx)
4456	for _, warning := range resolved.Warnings {
4457		log.Warn().Str("field", warning.Field).Msg(warning.Message)
4458	}
4459	event := log.Info().
4460		Str("theme_source", string(resolved.ThemeSource.Kind)).
4461		Str("color_scheme_source", resolved.ColorSchemeSource).
4462		Bool("prefers_dark", resolved.PrefersDark)
4463	if resolved.ThemeSource.Provider != "" {
4464		event = event.Str("provider", resolved.ThemeSource.Provider)
4465	}
4466	if resolved.ThemeSource.LastGood {
4467		event = event.Bool("last_good", true)
4468	}
4469	event.Msg("theme resolved")
4470}
4471
4472func (a *App) initFilteringAsync(ctx context.Context) {
4473	if a.deps.FilterManager == nil {
4474		return
4475	}
4476
4477	a.deps.FilterManager.SetStatusCallback(func(status port.FilterStatus) {
4478		statusCopy := status // Capture for closure
4479		cb := glib.SourceFunc(func(_ uintptr) bool {
4480			a.showFilterStatus(ctx, statusCopy)
4481			return false // Don't repeat
4482		})
4483		glib.IdleAdd(&cb, 0)
4484	})
4485	a.deps.FilterManager.LoadAsync(ctx)
4486}
4487
4488// showFilterStatus displays toast notification for filter status.
4489func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) {
4490	log := logging.FromContext(ctx)
4491
4492	switch status.State {
4493	case port.FilterStateLoading:
4494		a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo)
4495	case port.FilterStateActive:
4496		// Apply filters to existing webviews that were created before filters loaded
4497		if a.contentCoord != nil && a.deps.FilterManager != nil {
4498			a.contentCoord.ApplyFiltersToAll(ctx)
4499			log.Debug().Msg("applied filters to all existing webviews")
4500		}
4501		a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
4502	case port.FilterStateError:
4503		a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError)
4504	}
4505}

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