v0.29.0 21b3150

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

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