main 5041353

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

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