v0.33.0 62a09f5

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

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