v0.27.2 5960dd8

app.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11	"unsafe"
12
13	"github.com/bnema/dumber/internal/application/port"
14	"github.com/bnema/dumber/internal/application/usecase"
15	"github.com/bnema/dumber/internal/domain/entity"
16	urlutil "github.com/bnema/dumber/internal/domain/url"
17	"github.com/bnema/dumber/internal/infrastructure/config"
18	"github.com/bnema/dumber/internal/infrastructure/desktop"
19	"github.com/bnema/dumber/internal/infrastructure/filesystem"
20	"github.com/bnema/dumber/internal/infrastructure/filtering"
21	"github.com/bnema/dumber/internal/infrastructure/snapshot"
22	"github.com/bnema/dumber/internal/infrastructure/textinput"
23	"github.com/bnema/dumber/internal/infrastructure/webkit"
24
25	"github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
26	"github.com/bnema/dumber/internal/logging"
27	"github.com/bnema/dumber/internal/ui/adapter"
28	"github.com/bnema/dumber/internal/ui/component"
29	"github.com/bnema/dumber/internal/ui/coordinator"
30	"github.com/bnema/dumber/internal/ui/dialog"
31	"github.com/bnema/dumber/internal/ui/dispatcher"
32	"github.com/bnema/dumber/internal/ui/focus"
33	"github.com/bnema/dumber/internal/ui/input"
34	"github.com/bnema/dumber/internal/ui/layout"
35	"github.com/bnema/dumber/internal/ui/theme"
36	"github.com/bnema/dumber/internal/ui/window"
37	"github.com/jwijenbergh/puregotk/v4/adw"
38	"github.com/jwijenbergh/puregotk/v4/gdk"
39	"github.com/jwijenbergh/puregotk/v4/gio"
40	"github.com/jwijenbergh/puregotk/v4/glib"
41	"github.com/jwijenbergh/puregotk/v4/gtk"
42)
43
44const (
45	// AppID is the application identifier for GTK.
46	AppID = "com.github.bnema.dumber"
47	// crashReportToastDurationMs keeps startup crash-report toast visible longer.
48	crashReportToastDurationMs = 5000
49	floatingPaneFallbackWidth  = 1200
50	floatingPaneFallbackHeight = 800
51	floatingPaneIDPrefix       = "floating-pane:"
52	floatingSessionIDDefault   = "default"
53	floatingPaneVisibleClass   = "floating-pane-visible"
54)
55
56func gtkApplicationFlags() gio.ApplicationFlags {
57	return gio.GApplicationNonUniqueValue
58}
59
60// App wraps the GTK Application and manages the browser lifecycle.
61type App struct {
62	deps       *Dependencies
63	gtkApp     *gtk.Application
64	mainWindow *window.MainWindow
65
66	// State
67	tabs   *entity.TabList
68	tabsUC *usecase.ManageTabsUseCase
69
70	// Coordinators (new architecture)
71	contentCoord  *coordinator.ContentCoordinator
72	tabCoord      *coordinator.TabCoordinator
73	wsCoord       *coordinator.WorkspaceCoordinator
74	navCoord      *coordinator.NavigationCoordinator
75	kbDispatcher  *dispatcher.KeyboardDispatcher
76	configManager *config.Manager
77
78	// Pane management (used by coordinators)
79	panesUC        *usecase.ManagePanesUseCase
80	workspaceViews map[entity.TabID]*component.WorkspaceView
81	widgetFactory  layout.WidgetFactory
82	stackedPaneMgr *component.StackedPaneManager
83
84	// Input handling
85	keyboardHandler       *input.KeyboardHandler
86	globalShortcutHandler *input.GlobalShortcutHandler
87
88	// Focus and border management
89	focusMgr  *focus.Manager
90	borderMgr *focus.BorderManager
91
92	resizeModeBorderTarget layout.Widget
93
94	// Omnibox configuration (omnibox is created per workspace view)
95	omniboxCfg        component.OmniboxConfig
96	omniboxNavigateFn func(url string)
97	// Find bar configuration (find bar is created per workspace view)
98	findBarCfg component.FindBarConfig
99	// Floating pane sessions keyed by tab and profile session ID.
100	floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
101
102	// Web content (managed by ContentCoordinator)
103	pool           *webkit.WebViewPool
104	webViewFactory *webkit.WebViewFactory
105	injector       *webkit.ContentInjector
106	router         *webkit.MessageRouter
107	settings       *webkit.SettingsManager
108	faviconAdapter *adapter.FaviconAdapter
109
110	// App-level toaster for system notifications (filter status, etc.)
111	appToaster *component.Toaster
112	// Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
113	modeToaster *component.Toaster
114	// Top-right WebRTC permission activity indicator
115	webrtcIndicator *component.WebRTCPermissionIndicator
116
117	// Session management
118	sessionManager  *component.SessionManager
119	tabPicker       *component.TabPicker
120	tabPickerWidget layout.Widget
121	tabPickerPaneID entity.PaneID
122	snapshotService *snapshot.Service
123
124	// Update management
125	updateCoord *coordinator.UpdateCoordinator
126
127	// ID generator for tabs/panes
128	idCounter             uint64
129	idMu                  sync.Mutex
130	firstWebViewShownOnce sync.Once
131
132	movePaneToTabUC *usecase.MovePaneToTabUseCase
133
134	// Accent picker for dead keys support
135	accentPicker        *component.AccentPicker
136	insertAccentUC      *usecase.InsertAccentUseCase
137	accentFocusProvider *textinput.FocusProvider
138
139	// Deferred initialization - runs after first load_started to avoid blocking initial navigation
140	deferredInitOnce sync.Once
141	deferredInitFn   func()
142
143	// lifecycle
144	cancel context.CancelCauseFunc
145}
146
147type floatingWorkspaceSession struct {
148	paneID              entity.PaneID
149	pane                *component.FloatingPane
150	paneView            *component.PaneView
151	webView             *webkit.WebView
152	overlay             layout.OverlayWidget
153	widget              layout.Widget
154	focusWidget         layout.Widget
155	omnibox             *component.Omnibox
156	omniboxWidget       layout.Widget
157	resizeWatcherActive bool
158	resizeTickID        uint
159	appliedWidth        int
160	appliedHeight       int
161}
162
163type floatingSessionKey struct {
164	tabID     entity.TabID
165	sessionID string
166}
167
168// New creates a new App with the given dependencies.
169func New(deps *Dependencies) (*App, error) {
170	if err := deps.Validate(); err != nil {
171		return nil, err
172	}
173	ctx, cancel := context.WithCancelCause(deps.Ctx)
174
175	app := &App{
176		deps:             deps,
177		tabs:             entity.NewTabList(),
178		tabsUC:           deps.TabsUC,
179		panesUC:          deps.PanesUC,
180		workspaceViews:   make(map[entity.TabID]*component.WorkspaceView),
181		floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
182		pool:             deps.Pool,
183		injector:         deps.Injector,
184		router:           deps.MessageRouter,
185		settings:         deps.Settings,
186		configManager:    config.GetManager(),
187		cancel:           cancel,
188	}
189	if app.router == nil {
190		app.router = webkit.NewMessageRouter(ctx)
191	}
192
193	// Register message handlers
194	if app.router != nil {
195		if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
196			HistoryUC:    deps.HistoryUC,
197			FavoritesUC:  deps.FavoritesUC,
198			Clipboard:    deps.Clipboard,
199			ConfigGetter: config.Get,
200			OnClipboardCopied: func(textLen int) {
201				// Show brief toast on auto-copy (similar to zellij footer notification)
202				// Must schedule on GTK main thread since this is called from WebKit handler
203				cb := glib.SourceFunc(func(_ uintptr) bool {
204					if app.appToaster != nil {
205						app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
206							component.WithDuration(component.ToastBriefDurationMs),
207							component.WithPosition(component.ToastPositionBottomRight),
208						)
209					}
210					return false
211				})
212				glib.IdleAdd(&cb, 0)
213			},
214		}); err != nil {
215			return nil, err
216		}
217	}
218
219	return app, nil
220}
221
222// Run starts the GTK application and blocks until it exits.
223// Returns the exit code.
224func (a *App) Run(ctx context.Context, args []string) int {
225	log := logging.FromContext(ctx)
226	log.Debug().Msg("creating GTK application")
227
228	// Initialize libadwaita once (required before using StyleManager).
229	// This also initializes GTK implicitly.
230	adw.Init()
231	logging.Trace().Mark("gtk_init")
232
233	// Mark adwaita detector as available now that adw.Init() is complete.
234	// This enables the highest-priority color scheme detector.
235	if a.deps != nil && a.deps.AdwaitaDetector != nil {
236		a.deps.AdwaitaDetector.MarkAvailable()
237		log.Debug().Msg("adwaita detector marked available")
238
239		// Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
240		// They have the wrong dark mode preference injected; re-inject with correct value.
241		if a.pool != nil {
242			a.pool.RefreshScripts(ctx)
243		}
244	}
245
246	appID := AppID
247	a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags())
248	if a.gtkApp == nil {
249		log.Error().Msg("failed to create GTK application")
250		return 1
251	}
252	defer a.gtkApp.Unref()
253	logging.Trace().Mark("gtk_app_created")
254
255	// Connect activate signal
256	activateCb := func(_ gio.Application) {
257		a.onActivate(ctx)
258	}
259	a.gtkApp.ConnectActivate(&activateCb)
260
261	// Connect shutdown signal
262	shutdownCb := func(_ gio.Application) {
263		a.onShutdown(ctx)
264	}
265	a.gtkApp.ConnectShutdown(&shutdownCb)
266
267	log.Info().Msg("starting GTK main loop")
268	code := a.gtkApp.Run(len(args), args)
269
270	return code
271}
272
273// onActivate is called when the GTK application is activated.
274func (a *App) onActivate(ctx context.Context) {
275	log := logging.FromContext(ctx)
276	log.Debug().Msg("GTK application activated")
277
278	a.applyGTKColorSchemePreference(ctx)
279	// Configure pool background color early (prevents white flash), but avoid
280	// synchronous prewarming that delays the first navigation on cold start.
281	a.setupPoolBackgroundColor(ctx)
282
283	if err := a.createMainWindow(ctx); err != nil {
284		log.Error().Err(err).Msg("failed to create main window")
285		return
286	}
287	logging.Trace().Mark("window_created")
288
289	a.initLayoutInfrastructure()
290	a.initAppToasterOverlay()
291	a.installCrashReportNotifier(ctx)
292	a.initFocusAndBorderOverlay()
293	a.initAccentPicker(ctx)
294	a.initDownloadHandler(ctx)
295
296	a.initCoordinators(ctx)
297	a.wireWebRTCPermissionIndicator()
298	logging.Trace().Mark("coordinators_init")
299	a.initKeyboardHandler(ctx)
300	a.initOmniboxConfig(ctx)
301	a.initFindBarConfig()
302	a.initSessionManager(ctx)
303	a.initTabPicker(ctx)
304	a.initSnapshotService(ctx)
305	a.initUpdateCoordinator(ctx)
306	a.createInitialTab(ctx)
307	a.finalizeActivation(ctx)
308}
309
310func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
311	log := logging.FromContext(ctx)
312
313	// Get the config color scheme preference (default follows system theme)
314	scheme := "default"
315	if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
316		scheme = a.deps.Config.Appearance.ColorScheme
317	}
318
319	// Apply color scheme via libadwaita's StyleManager.
320	// This properly communicates the preference to GTK and WebKit,
321	// so web content can correctly evaluate prefers-color-scheme media queries.
322	styleMgr := adw.StyleManagerGetDefault()
323	if styleMgr == nil {
324		log.Warn().Msg("failed to get adw.StyleManager")
325		return
326	}
327
328	var adwScheme adw.ColorScheme
329	switch scheme {
330	case "dark", "prefer-dark":
331		adwScheme = adw.ColorSchemeForceDarkValue
332	case "light", "prefer-light":
333		adwScheme = adw.ColorSchemeForceLightValue
334	default:
335		// "default" or empty - follow system preference
336		adwScheme = adw.ColorSchemeDefaultValue
337	}
338
339	styleMgr.SetColorScheme(adwScheme)
340
341	// Refresh the color resolver now that adwaita detector is available.
342	// This ensures all components get the correct preference.
343	var pref string
344	var prefersDark bool
345	if a.deps != nil && a.deps.ColorResolver != nil {
346		result := a.deps.ColorResolver.Refresh()
347		prefersDark = result.PrefersDark
348		pref = result.Source
349	} else {
350		prefersDark = styleMgr.GetDark()
351		pref = "adw.StyleManager"
352	}
353
354	log.Debug().
355		Str("scheme", scheme).
356		Bool("prefers_dark", prefersDark).
357		Str("source", pref).
358		Msg("applied color scheme via adw.StyleManager")
359}
360
361func (a *App) setupPoolBackgroundColor(ctx context.Context) {
362	log := logging.FromContext(ctx)
363
364	// Set theme background color on pool to eliminate white flash.
365	// Must be done before any WebView creation so WebViews get the correct color.
366	if a.pool == nil {
367		log.Debug().Msg("webview pool not available; skipping background color setup")
368		return
369	}
370	if a.deps == nil || a.deps.Theme == nil {
371		log.Debug().Msg("theme not available; skipping webview pool background color setup")
372		return
373	}
374
375	r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
376	a.pool.SetBackgroundColor(r, g, b, alpha)
377	log.Debug().Msg("configured webview pool background color")
378}
379
380func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
381	// Prewarm WebView pool after startup so cold-start navigation is not blocked.
382	if a.pool == nil {
383		return
384	}
385	a.pool.PrewarmAsync(ctx, 0)
386}
387
388func (a *App) createMainWindow(ctx context.Context) error {
389	mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
390	if err != nil {
391		return err
392	}
393	a.mainWindow = mainWindow
394
395	// Create permission popup and dialog presenter
396	if a.deps != nil && a.deps.PermissionUC != nil {
397		uiScale := 1.0
398		if a.deps.Config != nil {
399			uiScale = a.deps.Config.DefaultUIScale
400		}
401		permPopup := component.NewPermissionPopup(nil, uiScale)
402		if permPopup != nil {
403			// Add popup to the main window's content overlay
404			if w := permPopup.Widget(); w != nil {
405				a.mainWindow.AddOverlay(w)
406			}
407			permDialog := dialog.NewPermissionDialog(permPopup)
408			a.deps.PermissionUC.SetDialogPresenter(permDialog)
409		}
410	}
411
412	// Create top-right WebRTC permission activity indicator.
413	indicator := component.NewWebRTCPermissionIndicator()
414	if indicator != nil {
415		if w := indicator.Widget(); w != nil {
416			a.mainWindow.AddOverlay(w)
417			a.webrtcIndicator = indicator
418		}
419	}
420
421	// Apply GTK CSS styling from theme manager.
422	if a.deps == nil || a.deps.Theme == nil {
423		return nil
424	}
425	if display := a.mainWindow.Window().GetDisplay(); display != nil {
426		a.deps.Theme.ApplyToDisplay(ctx, display)
427	}
428	return nil
429}
430
431func (a *App) initLayoutInfrastructure() {
432	// Initialize widget factory for pane layout.
433	a.widgetFactory = layout.NewGtkWidgetFactory()
434
435	// Initialize stacked pane manager for incremental widget operations.
436	a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
437}
438
439func (a *App) initAppToasterOverlay() {
440	if a.mainWindow == nil {
441		return
442	}
443
444	// Create app-level toaster for system notifications.
445	a.appToaster = component.NewToaster(a.widgetFactory)
446	toasterWidget := a.appToaster.Widget()
447	if toasterWidget == nil {
448		return
449	}
450	gtkWidget := toasterWidget.GtkWidget()
451	if gtkWidget == nil {
452		return
453	}
454	a.mainWindow.AddOverlay(gtkWidget)
455
456	// Create mode indicator toaster for modal mode notifications.
457	a.modeToaster = component.NewToaster(a.widgetFactory)
458	modeToasterWidget := a.modeToaster.Widget()
459	if modeToasterWidget == nil {
460		return
461	}
462	modeGtkWidget := modeToasterWidget.GtkWidget()
463	if modeGtkWidget == nil {
464		return
465	}
466	a.mainWindow.AddOverlay(modeGtkWidget)
467}
468
469func (a *App) installCrashReportNotifier(ctx context.Context) {
470	if a.deps == nil {
471		return
472	}
473
474	a.deps.OnCrashReportsDetected = func(paths []string) {
475		a.showCrashReportToast(ctx, paths)
476	}
477}
478
479func (a *App) initFocusAndBorderOverlay() {
480	// Initialize focus manager for geometric navigation.
481	a.focusMgr = focus.NewManager(a.panesUC)
482
483	// Initialize border manager for mode indicators.
484	a.borderMgr = focus.NewBorderManager(a.widgetFactory)
485
486	// Attach border overlay to main window (visible for all tabs).
487	if a.borderMgr == nil || a.mainWindow == nil {
488		return
489	}
490	borderWidget := a.borderMgr.Widget()
491	if borderWidget == nil {
492		return
493	}
494	gtkWidget := borderWidget.GtkWidget()
495	if gtkWidget == nil {
496		return
497	}
498	a.mainWindow.AddOverlay(gtkWidget)
499}
500
501func (a *App) initAccentPicker(ctx context.Context) {
502	log := logging.FromContext(ctx)
503
504	if a.widgetFactory == nil || a.mainWindow == nil {
505		log.Debug().Msg("widget factory or main window not available, skipping accent picker")
506		return
507	}
508
509	// Create accent picker component
510	a.accentPicker = component.NewAccentPicker(a.widgetFactory)
511	if a.accentPicker == nil {
512		log.Warn().Msg("failed to create accent picker")
513		return
514	}
515
516	// Add to main window overlay
517	pickerWidget := a.accentPicker.Widget()
518	if pickerWidget != nil {
519		gtkWidget := pickerWidget.GtkWidget()
520		if gtkWidget != nil {
521			a.mainWindow.AddOverlay(gtkWidget)
522		}
523	}
524
525	// Create focus provider (tracks which input has focus)
526	a.accentFocusProvider = textinput.NewFocusProvider()
527
528	// Create the use case with glib.IdleAdd for thread-safe GTK calls
529	a.insertAccentUC = usecase.NewInsertAccentUseCase(
530		a.accentFocusProvider,
531		a.accentPicker,
532		func(fn func()) {
533			cb := glib.SourceFunc(func(_ uintptr) bool {
534				fn()
535				return false // Don't repeat
536			})
537			glib.IdleAdd(&cb, 0)
538		},
539	)
540
541	log.Debug().Msg("accent picker initialized")
542}
543
544func (a *App) initDownloadHandler(ctx context.Context) {
545	log := logging.FromContext(ctx)
546
547	if a.deps == nil || a.deps.WebContext == nil {
548		log.Debug().Msg("WebContext not available, skipping download handler")
549		return
550	}
551
552	// Determine download path from config, fallback to XDG.
553	downloadPath := ""
554	if a.deps.Config != nil {
555		downloadPath = a.deps.Config.Downloads.Path
556	}
557	if downloadPath == "" && a.deps.XDG != nil {
558		var err error
559		downloadPath, err = a.deps.XDG.DownloadDir()
560		if err != nil {
561			log.Warn().Err(err).Msg("failed to get XDG download dir")
562		}
563	}
564	if downloadPath == "" {
565		// Final fallback to ~/Downloads, with /tmp as last resort.
566		home, err := os.UserHomeDir()
567		if err != nil {
568			log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
569			downloadPath = "/tmp"
570		} else {
571			downloadPath = filepath.Join(home, "Downloads")
572		}
573	}
574
575	// Create download event adapter to show toasts.
576	eventAdapter := &downloadEventAdapter{app: a}
577
578	// Create use case for preparing download destinations with file deduplication.
579	prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New())
580
581	// Create and wire the download handler.
582	handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC)
583	a.deps.WebContext.SetDownloadHandler(ctx, handler)
584
585	log.Info().Str("path", downloadPath).Msg("download handler initialized")
586}
587
588// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
589type downloadEventAdapter struct {
590	app *App
591}
592
593func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
594	// Must schedule on GTK main thread.
595	cb := glib.SourceFunc(func(_ uintptr) bool {
596		if d.app.appToaster == nil {
597			return false
598		}
599
600		switch event.Type {
601		case port.DownloadEventStarted:
602			d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
603		case port.DownloadEventFinished:
604			d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
605		case port.DownloadEventFailed:
606			d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
607		}
608		return false
609	})
610	glib.IdleAdd(&cb, 0)
611}
612
613func (a *App) initKeyboardHandler(ctx context.Context) {
614	if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
615		return
616	}
617
618	// Create keyboard handler and wire to dispatcher.
619	a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
620	a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
621		if action == input.ActionClosePane {
622			if a.closeAndReleaseActiveFloatingPane(ctx) {
623				return nil
624			}
625		}
626		return a.kbDispatcher.Dispatch(ctx, action)
627	})
628	a.keyboardHandler.SetOnEscape(func(ctx context.Context) bool {
629		return a.handleGlobalEscape(ctx)
630	})
631	a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
632		a.handleModeChange(ctx, from, to)
633	})
634	a.keyboardHandler.SetShouldBypassInput(func(modifiers input.Modifier) bool {
635		// Bypass keyboard handler when modals are visible
636		if a.sessionManager != nil && a.sessionManager.IsVisible() {
637			return true
638		}
639		if a.tabPicker != nil && a.tabPicker.IsVisible() {
640			return true
641		}
642		wsView := a.activeWorkspaceView()
643		if wsView == nil {
644			return false
645		}
646		if wsView.IsOmniboxVisible() {
647			// Let Alt-modified keys through for pane navigation
648			return modifiers&input.ModAlt == 0
649		}
650		return false
651	})
652	// Wire accent handler for dead keys support
653	if a.insertAccentUC != nil {
654		a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
655	}
656	a.keyboardHandler.AttachTo(a.mainWindow.Window())
657
658	// Create global shortcut handler for Alt+1-9 tab switching.
659	// This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
660	// to intercept shortcuts even when WebView has focus.
661	a.globalShortcutHandler = input.NewGlobalShortcutHandler(
662		ctx,
663		a.mainWindow.Window(),
664		a.deps.Config,
665		func(ctx context.Context, action input.Action) error {
666			return a.kbDispatcher.Dispatch(ctx, action)
667		},
668	)
669}
670
671func (a *App) initOmniboxConfig(ctx context.Context) {
672	if a.deps == nil || a.deps.Config == nil {
673		return
674	}
675
676	log := logging.FromContext(ctx)
677
678	// Convert config shortcuts to use case type
679	shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))
680	for key, shortcut := range a.deps.Config.SearchShortcuts {
681		shortcuts[key] = usecase.SearchShortcut{
682			URL:         shortcut.URL,
683			Description: shortcut.Description,
684		}
685	}
686	shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
687
688	// Create autocomplete use case for inline suggestions
689	autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC)
690
691	// Store omnibox config (omnibox is created per-pane via WorkspaceView).
692	a.omniboxCfg = component.OmniboxConfig{
693		HistoryUC:       a.deps.HistoryUC,
694		FavoritesUC:     a.deps.FavoritesUC,
695		FaviconAdapter:  a.faviconAdapter,
696		CopyURLUC:       a.deps.CopyURLUC,
697		ShortcutsUC:     shortcutsUC,
698		AutocompleteUC:  autocompleteUC,
699		DefaultSearch:   a.deps.Config.DefaultSearchEngine,
700		InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
701		UIScale:         a.deps.Config.DefaultUIScale,
702		OnNavigate: func(url string) {
703			if err := a.navigateFromOmnibox(ctx, url); err != nil {
704				log.Error().Err(err).Str("url", url).Msg("navigation failed")
705			}
706		},
707		OnFocusIn: func(entry *gtk.SearchEntry) {
708			// Set omnibox entry as the focused input for accent picker
709			if a.accentFocusProvider != nil && entry != nil {
710				a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
711			}
712		},
713		OnFocusOut: func() {
714			// When omnibox loses focus, set WebView as the focused input
715			if a.accentFocusProvider != nil {
716				a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
717			}
718		},
719	}
720	a.navCoord.SetOmniboxProvider(a)
721	log.Debug().Msg("omnibox config stored, provider set")
722}
723
724func (a *App) initFindBarConfig() {
725	// Store find bar config (find bar is created per-pane via WorkspaceView).
726	a.findBarCfg = component.FindBarConfig{
727		GetFindController: func(paneID entity.PaneID) port.FindController {
728			if a.contentCoord == nil {
729				return nil
730			}
731			wv := a.contentCoord.GetWebView(paneID)
732			if wv == nil {
733				return nil
734			}
735			return wv.GetFindController()
736		},
737		OnFocusIn: func(entry *gtk.SearchEntry) {
738			// Set find bar entry as the focused input for accent picker
739			if a.accentFocusProvider != nil && entry != nil {
740				a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
741			}
742		},
743		OnFocusOut: func() {
744			// When find bar loses focus, set WebView as the focused input
745			if a.accentFocusProvider != nil {
746				a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
747			}
748		},
749	}
750}
751
752func (a *App) initSessionManager(ctx context.Context) {
753	log := logging.FromContext(ctx)
754
755	if a.deps == nil {
756		log.Debug().Msg("deps not available, skipping session manager")
757		return
758	}
759
760	// Session manager can work without a repo - it just shows an empty list
761	var listSessionsUC *usecase.ListSessionsUseCase
762	var deleteSessionUC *usecase.DeleteSessionUseCase
763	if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
764		listSessionsUC = usecase.NewListSessionsUseCase(
765			a.deps.SessionRepo,
766			a.deps.SessionStateRepo,
767			a.deps.Config.Logging.LogDir,
768		)
769		deleteSessionUC = usecase.NewDeleteSessionUseCase(
770			a.deps.SessionStateRepo,
771			a.deps.SessionRepo,
772			a.deps.Config.Logging.LogDir,
773		)
774	}
775
776	// Create session spawner for restoration
777	spawner := desktop.NewSessionSpawner(ctx)
778
779	// Create session manager component
780	a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
781		ListSessionsUC:  listSessionsUC,
782		DeleteSessionUC: deleteSessionUC,
783		CurrentSession:  a.deps.CurrentSessionID,
784		UIScale:         a.deps.Config.DefaultUIScale,
785		OnClose: func() {
786			log.Debug().Msg("session manager closed")
787		},
788		OnOpen: func(sessionID entity.SessionID) {
789			log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
790			if err := spawner.SpawnWithSession(sessionID); err != nil {
791				log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
792			}
793		},
794		OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
795			if a.appToaster != nil {
796				a.appToaster.Show(ctx, message, level)
797			}
798		},
799	})
800
801	if a.sessionManager == nil {
802		log.Warn().Msg("failed to create session manager")
803		return
804	}
805
806	// Add session manager to main window overlay
807	if a.mainWindow != nil {
808		widget := a.sessionManager.Widget()
809		if widget != nil {
810			a.mainWindow.AddOverlay(widget)
811		}
812	}
813
814	// Wire session manager action to keyboard dispatcher
815	a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
816		a.ToggleSessionManager(ctx)
817		return nil
818	})
819
820	log.Debug().Msg("session manager initialized")
821}
822
823// ToggleSessionManager shows or hides the session manager.
824func (a *App) ToggleSessionManager(ctx context.Context) {
825	if a.sessionManager == nil {
826		return
827	}
828	a.sessionManager.Toggle(ctx)
829}
830
831func (a *App) attachTabPickerToActivePane() {
832	if a.tabPicker == nil || a.widgetFactory == nil {
833		return
834	}
835	wsView := a.activeWorkspaceView()
836	if wsView == nil {
837		return
838	}
839
840	activeTab := a.tabs.ActiveTab()
841	if activeTab == nil || activeTab.Workspace == nil {
842		return
843	}
844	activePaneID := activeTab.Workspace.ActivePaneID
845	if activePaneID == "" {
846		return
847	}
848	pv := wsView.GetPaneView(activePaneID)
849	if pv == nil {
850		return
851	}
852
853	if a.tabPickerWidget == nil {
854		a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
855		if a.tabPickerWidget == nil {
856			return
857		}
858	}
859
860	// If currently attached to a different pane overlay, detach.
861	if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
862		for _, view := range a.workspaceViews {
863			if view == nil {
864				continue
865			}
866			if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
867				if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
868					oldPV.RemoveOverlayWidget(a.tabPickerWidget)
869				} else if parent != nil {
870					a.tabPickerWidget.Unparent()
871				}
872				break
873			}
874		}
875	}
876
877	// Ensure the widget can be reparented.
878	if parent := a.tabPickerWidget.GetParent(); parent != nil {
879		a.tabPickerWidget.Unparent()
880	}
881
882	a.tabPicker.SetParentOverlay(pv.Overlay())
883	pv.AddOverlayWidget(a.tabPickerWidget)
884	a.tabPickerPaneID = activePaneID
885}
886
887func (a *App) HandleMovePaneToTab(ctx context.Context) error {
888	if a.movePaneToTabUC == nil {
889		return nil
890	}
891	if a.tabPicker == nil {
892		return nil
893	}
894
895	sourceTab := a.tabs.ActiveTab()
896	if sourceTab == nil || sourceTab.Workspace == nil {
897		return nil
898	}
899
900	// If there is only 1 tab, auto-create a new tab and move there.
901	if a.tabs.Count() <= 1 {
902		return a.MoveActivePaneToTab(ctx, "")
903	}
904
905	items := make([]component.TabPickerItem, 0, a.tabs.Count())
906	for _, tab := range a.tabs.Tabs {
907		if tab == nil {
908			continue
909		}
910		if tab.ID == sourceTab.ID {
911			continue
912		}
913		items = append(items, component.TabPickerItem{
914			TabID: tab.ID,
915			Title: tab.Title(),
916			IsNew: false,
917			Index: tab.Position,
918		})
919	}
920	items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
921
922	a.attachTabPickerToActivePane()
923	a.tabPicker.Show(ctx, items)
924	return nil
925}
926
927func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
928	if a.movePaneToTabUC == nil {
929		return nil
930	}
931	active := a.tabs.ActiveTab()
932	if active == nil {
933		return nil
934	}
935
936	nextPos := active.Position + 1
937	if nextPos >= a.tabs.Count() {
938		// Create a new tab on the right.
939		return a.MoveActivePaneToTab(ctx, "")
940	}
941	next := a.tabs.TabAt(nextPos)
942	if next == nil {
943		return nil
944	}
945	return a.MoveActivePaneToTab(ctx, next.ID)
946}
947
948func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
949	in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
950	if in == nil {
951		return nil
952	}
953
954	out, err := a.movePaneToTabUC.Execute(*in)
955	if err != nil {
956		return err
957	}
958	if out == nil || out.TargetTab == nil {
959		return nil
960	}
961
962	a.applyMovePaneToTabUI(ctx, out, sourceTab)
963	a.switchToTargetTabIfConfigured(ctx, out)
964	a.updateTabBarVisibility(ctx)
965	a.MarkDirty()
966	return nil
967}
968
969func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
970	if a.movePaneToTabUC == nil {
971		return nil, nil
972	}
973	activeTab := a.tabs.ActiveTab()
974	if activeTab == nil || activeTab.Workspace == nil {
975		return nil, nil
976	}
977	sourcePaneID := activeTab.Workspace.ActivePaneID
978	if sourcePaneID == "" {
979		return nil, nil
980	}
981
982	in := &usecase.MovePaneToTabInput{
983		TabList:      a.tabs,
984		SourceTabID:  activeTab.ID,
985		SourcePaneID: sourcePaneID,
986		TargetTabID:  targetTabID,
987	}
988	return in, activeTab
989}
990
991func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
992	if out == nil || out.TargetTab == nil {
993		return
994	}
995	sourceTabID := entity.TabID("")
996	if sourceTab != nil {
997		sourceTabID = sourceTab.ID
998	}
999
1000	if out.SourceTabClosed {
1001		a.removeSourceTabUI(sourceTabID)
1002	}
1003	if out.NewTabCreated {
1004		a.ensureTargetTabUI(ctx, out.TargetTab)
1005	}
1006
1007	if !out.SourceTabClosed {
1008		a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
1009	}
1010	a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
1011}
1012
1013func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
1014	a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
1015	delete(a.workspaceViews, sourceTabID)
1016	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1017		a.mainWindow.TabBar().RemoveTab(sourceTabID)
1018	}
1019}
1020
1021func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
1022	if tab == nil {
1023		return
1024	}
1025	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1026		a.mainWindow.TabBar().AddTab(tab)
1027	}
1028	a.createWorkspaceViewWithoutAttach(ctx, tab)
1029}
1030
1031func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
1032	wsView := a.workspaceViews[tabID]
1033	if wsView == nil {
1034		return
1035	}
1036	_ = wsView.Rebuild(ctx)
1037	a.reattachFloatingSessions(tabID, wsView)
1038	a.syncFloatingFocus()
1039
1040	if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
1041		return
1042	}
1043	a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1044	if a.wsCoord != nil {
1045		a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
1046	}
1047}
1048
1049func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.WorkspaceView) {
1050	if wsView == nil {
1051		return
1052	}
1053
1054	for key, session := range a.floatingSessions {
1055		if key.tabID != tabID || session == nil || session.pane == nil {
1056			continue
1057		}
1058
1059		session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
1060		if session.widget != nil {
1061			wsView.AddWorkspaceOverlayWidget(session.widget)
1062			configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
1063			setFloatingWidgetShown(session.widget, session.pane.IsVisible())
1064		}
1065		if session.paneView != nil {
1066			wsView.RegisterPaneView(session.paneID, session.paneView)
1067			if session.focusWidget == nil {
1068				session.focusWidget = session.paneView.WebViewWidget()
1069			}
1070		}
1071	}
1072}
1073
1074func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
1075	if out == nil || out.TargetTab == nil {
1076		return
1077	}
1078
1079	switchToTarget := config.Get().Workspace.SwitchToTabOnMove
1080	if out.SourceTabClosed {
1081		switchToTarget = true
1082	}
1083	if !switchToTarget {
1084		return
1085	}
1086
1087	a.tabs.SetActive(out.TargetTab.ID)
1088	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1089		a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
1090	}
1091	a.switchWorkspaceView(ctx, out.TargetTab.ID)
1092}
1093
1094func (a *App) updateTabBarVisibility(ctx context.Context) {
1095	if a.tabCoord != nil {
1096		a.tabCoord.UpdateBarVisibility(ctx)
1097	}
1098}
1099
1100func (a *App) initTabPicker(ctx context.Context) {
1101	log := logging.FromContext(ctx)
1102
1103	if a.deps == nil || a.deps.Config == nil {
1104		log.Debug().Msg("deps/config not available, skipping tab picker")
1105		return
1106	}
1107
1108	a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
1109		UIScale: a.deps.Config.DefaultUIScale,
1110		OnClose: func() {
1111			log.Debug().Msg("tab picker closed")
1112		},
1113		OnSelect: func(item component.TabPickerItem) {
1114			cb := glib.SourceFunc(func(_ uintptr) bool {
1115				var targetID entity.TabID
1116				if !item.IsNew {
1117					targetID = item.TabID
1118				}
1119				if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
1120					log.Warn().Err(err).Msg("move pane to tab failed")
1121				}
1122				return false
1123			})
1124			glib.IdleAdd(&cb, 0)
1125		},
1126	})
1127
1128	if a.tabPicker == nil {
1129		log.Warn().Msg("failed to create tab picker")
1130		return
1131	}
1132
1133	log.Debug().Msg("tab picker initialized")
1134}
1135
1136func (a *App) initSnapshotService(ctx context.Context) {
1137	log := logging.FromContext(ctx)
1138
1139	if a.deps == nil || a.deps.SnapshotUC == nil {
1140		log.Debug().Msg("snapshot use case not available, skipping snapshot service")
1141		return
1142	}
1143
1144	intervalMs := 5000 // default
1145	if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
1146		intervalMs = a.deps.Config.Session.SnapshotIntervalMs
1147	}
1148
1149	a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
1150	a.snapshotService.Start(ctx)
1151
1152	// Set up callback for main.go to notify when session is persisted
1153	a.deps.OnSessionPersisted = func() {
1154		a.snapshotService.SetReady()
1155	}
1156
1157	log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
1158}
1159
1160func (a *App) initUpdateCoordinator(ctx context.Context) {
1161	log := logging.FromContext(ctx)
1162
1163	if a.deps == nil || a.deps.CheckUpdateUC == nil {
1164		log.Debug().Msg("update use cases not available, skipping update coordinator")
1165		return
1166	}
1167
1168	a.updateCoord = coordinator.NewUpdateCoordinator(
1169		a.deps.CheckUpdateUC,
1170		a.deps.ApplyUpdateUC,
1171		a.appToaster,
1172		a.deps.Config,
1173	)
1174
1175	// Start async update check
1176	a.updateCoord.CheckOnStartup(ctx)
1177
1178	log.Debug().Msg("update coordinator initialized")
1179}
1180
1181// GetTabList implements port.TabListProvider.
1182func (a *App) GetTabList() *entity.TabList {
1183	return a.tabs
1184}
1185
1186// GetSessionID implements port.TabListProvider.
1187func (a *App) GetSessionID() entity.SessionID {
1188	if a.deps == nil {
1189		return ""
1190	}
1191	return a.deps.CurrentSessionID
1192}
1193
1194// MarkDirty signals that session state has changed and should be saved.
1195func (a *App) MarkDirty() {
1196	if a.snapshotService != nil {
1197		a.snapshotService.MarkDirty()
1198	}
1199}
1200
1201func (a *App) createInitialTab(ctx context.Context) {
1202	log := logging.FromContext(ctx)
1203
1204	// Check if we should restore a session
1205	if a.deps != nil && a.deps.RestoreSessionID != "" {
1206		if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
1207			log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
1208			// Show error toast and fall through to create default tab
1209			if a.appToaster != nil {
1210				a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1211			}
1212		} else {
1213			log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
1214			// Show success toast for session restoration
1215			if a.appToaster != nil {
1216				a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1217			}
1218			return
1219		}
1220	}
1221
1222	// Create an initial tab using coordinator.
1223	initialURL := "dumb://home"
1224	if a.deps != nil && a.deps.InitialURL != "" {
1225		initialURL = a.deps.InitialURL
1226	}
1227	if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
1228		log.Error().Err(err).Msg("failed to create initial tab")
1229	}
1230}
1231
1232func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1233	log := logging.FromContext(ctx)
1234
1235	if a.deps == nil || a.deps.SessionStateRepo == nil {
1236		return fmt.Errorf("session state repo not available")
1237	}
1238
1239	// Load session state
1240	state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
1241	if err != nil {
1242		return fmt.Errorf("load session state: %w", err)
1243	}
1244	if state == nil {
1245		return fmt.Errorf("session state not found")
1246	}
1247
1248	log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
1249
1250	// Build complete TabList from snapshot (state-first approach)
1251	// This reconstructs the full pane tree with new IDs
1252	restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
1253	if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
1254		return fmt.Errorf("failed to build tab list from snapshot")
1255	}
1256
1257	// Replace tabs in-place to preserve references held by TabCoordinator
1258	a.tabs.ReplaceFrom(restoredTabs)
1259
1260	// Create UI for each restored tab and add to tab bar
1261	// Don't attach to content area yet - only the active tab should be attached
1262	tabBar := a.mainWindow.TabBar()
1263	for _, tab := range a.tabs.Tabs {
1264		a.createWorkspaceViewWithoutAttach(ctx, tab)
1265		if tabBar != nil {
1266			tabBar.AddTab(tab)
1267		}
1268		log.Debug().
1269			Str("tab_id", string(tab.ID)).
1270			Str("name", tab.Name).
1271			Int("panes", tab.Workspace.PaneCount()).
1272			Msg("restored tab with workspace")
1273	}
1274
1275	// Switch to active tab - this attaches the active workspace view to content area
1276	activeTab := a.tabs.ActiveTab()
1277	if activeTab != nil {
1278		a.switchWorkspaceView(ctx, activeTab.ID)
1279		if tabBar != nil {
1280			tabBar.SetActive(activeTab.ID)
1281		}
1282	}
1283
1284	return nil
1285}
1286
1287func (a *App) finalizeActivation(ctx context.Context) {
1288	log := logging.FromContext(ctx)
1289
1290	// Show the window
1291	if a.mainWindow != nil {
1292		a.mainWindow.Show()
1293	}
1294	log.Info().Msg("main window displayed")
1295
1296	if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
1297		a.showCrashReportToast(ctx, a.deps.StartupCrashReports)
1298	}
1299
1300	// Defer non-critical initialization until after first navigation starts.
1301	// This keeps pool prewarm, config watcher, and filter loading from
1302	// competing with the initial page load.
1303	a.runAfterFirstLoadStarted(func() {
1304		a.prewarmWebViewPoolAsync(ctx)
1305		a.initConfigWatcher(ctx)
1306		a.initFilteringAsync(ctx)
1307		a.checkConfigMigration(ctx)
1308	})
1309}
1310
1311func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
1312	if len(paths) == 0 {
1313		return
1314	}
1315	log := logging.FromContext(ctx)
1316	log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
1317
1318	cb := glib.SourceFunc(func(_ uintptr) bool {
1319		if a.appToaster != nil {
1320			msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1321			a.appToaster.Show(ctx, msg, component.ToastWarning,
1322				component.WithDuration(crashReportToastDurationMs),
1323				component.WithPosition(component.ToastPositionBottomRight),
1324			)
1325		}
1326		return false
1327	})
1328	glib.IdleAdd(&cb, 0)
1329}
1330
1331// runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1332// This keeps the GTK main loop free to process the initial load_uri() quickly,
1333// reducing the gap between webview_attached and load_started.
1334func (a *App) runAfterFirstLoadStarted(fn func()) {
1335	a.deferredInitFn = fn
1336}
1337
1338// triggerDeferredInit is called from ContentCoordinator on first load_started.
1339// It runs deferred initialization at LOW priority so navigation continues unblocked.
1340func (a *App) triggerDeferredInit(ctx context.Context) {
1341	a.deferredInitOnce.Do(func() {
1342		if a.deferredInitFn == nil {
1343			return
1344		}
1345		log := logging.FromContext(ctx)
1346		log.Debug().Msg("triggering deferred initialization")
1347
1348		fn := a.deferredInitFn
1349		cb := glib.SourceFunc(func(_ uintptr) bool {
1350			fn()
1351			return false
1352		})
1353		// Use LOW priority so navigation processing continues first
1354		glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
1355	})
1356}
1357
1358// onShutdown is called when the GTK application is shutting down.
1359func (a *App) onShutdown(ctx context.Context) {
1360	log := logging.FromContext(ctx)
1361	log.Debug().Msg("GTK application shutting down")
1362
1363	// Save final session state before shutdown
1364	if a.snapshotService != nil {
1365		if err := a.snapshotService.Stop(ctx); err != nil {
1366			log.Warn().Err(err).Msg("failed to save final session state")
1367		}
1368	}
1369
1370	// Apply staged update if available (before cleanup)
1371	if a.updateCoord != nil {
1372		if err := a.updateCoord.FinalizeOnExit(ctx); err != nil {
1373			log.Warn().Err(err).Msg("failed to apply staged update")
1374		}
1375	}
1376
1377	// Cancel context to signal all goroutines
1378	a.cancel(errors.New("application shutdown"))
1379
1380	// Cleanup resources
1381	if a.faviconAdapter != nil {
1382		a.faviconAdapter.Close()
1383	}
1384	tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions))
1385	for key := range a.floatingSessions {
1386		tabIDSet[key.tabID] = struct{}{}
1387	}
1388	tabIDs := make([]entity.TabID, 0, len(tabIDSet))
1389	for tabID := range tabIDSet {
1390		tabIDs = append(tabIDs, tabID)
1391	}
1392	for _, tabID := range tabIDs {
1393		a.releaseFloatingSessionsForTab(ctx, tabID)
1394	}
1395	if a.deps.Pool != nil {
1396		a.deps.Pool.Close(ctx)
1397	}
1398	// Close idle inhibitor to release D-Bus connection
1399	if a.deps.IdleInhibitor != nil {
1400		if err := a.deps.IdleInhibitor.Close(); err != nil {
1401			log.Warn().Err(err).Msg("failed to close idle inhibitor")
1402		}
1403	}
1404
1405	log.Info().Msg("application shutdown complete")
1406}
1407
1408// initCoordinators initializes all coordinators and wires their callbacks.
1409func (a *App) initCoordinators(ctx context.Context) {
1410	log := logging.FromContext(ctx)
1411	log.Debug().Msg("initializing coordinators")
1412
1413	// Helper to get active workspace and view
1414	getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
1415		return a.activeWorkspace(), a.activeWorkspaceView()
1416	}
1417
1418	// Create FaviconAdapter with service and WebKit FaviconDatabase
1419	var faviconDB *webkit.FaviconDatabase
1420	if a.deps.WebContext != nil {
1421		faviconDB = a.deps.WebContext.FaviconDatabase()
1422	}
1423	a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
1424
1425	// 1. Content Coordinator (no dependencies on other coordinators)
1426	a.contentCoord = coordinator.NewContentCoordinator(
1427		ctx,
1428		a.pool,
1429		a.widgetFactory,
1430		a.faviconAdapter,
1431		getActiveWS,
1432		a.deps.ZoomUC,
1433		a.deps.PermissionUC,
1434	)
1435
1436	// Set idle inhibitor for fullscreen video playback
1437	if a.deps.IdleInhibitor != nil {
1438		a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
1439	}
1440
1441	// Wire deferred init trigger - runs after first navigation starts
1442	a.contentCoord.SetOnFirstLoadStarted(func() {
1443		a.triggerDeferredInit(ctx)
1444	})
1445
1446	// 2. Tab Coordinator
1447	a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1448		TabsUC:     a.tabsUC,
1449		Tabs:       a.tabs,
1450		MainWindow: a.mainWindow,
1451		Config:     a.deps.Config,
1452	})
1453	a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
1454		a.createWorkspaceView(ctx, tab)
1455	})
1456	a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
1457		a.switchWorkspaceView(ctx, tab.ID)
1458	})
1459	a.tabCoord.SetOnQuit(a.Quit)
1460	a.tabCoord.SetOnStateChanged(a.MarkDirty)
1461	// Wire popup tab WebView attachment
1462	a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1463		a.attachPopupToTab(ctx, tabID, pane, wv)
1464	})
1465
1466	// Wire tab bar click handling to coordinator
1467	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1468		a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1469			if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1470				log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
1471			}
1472		})
1473	}
1474
1475	// Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1476	a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1477		if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1478			return
1479		}
1480		if entering {
1481			// Hide tab bar when entering fullscreen video
1482			a.mainWindow.TabBar().SetVisible(false)
1483		} else {
1484			// Restore tab bar visibility based on normal logic
1485			a.tabCoord.UpdateBarVisibility(ctx)
1486		}
1487	})
1488
1489	// 3. Workspace Coordinator
1490	a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1491		PanesUC:        a.panesUC,
1492		FocusMgr:       a.focusMgr,
1493		StackedPaneMgr: a.stackedPaneMgr,
1494		WidgetFactory:  a.widgetFactory,
1495		ContentCoord:   a.contentCoord,
1496		GetActiveWS:    getActiveWS,
1497		GenerateID:     a.generateID,
1498	})
1499	a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
1500		return a.tabCoord.Close(ctx)
1501	})
1502	a.wsCoord.SetOnStateChanged(a.MarkDirty)
1503
1504	// Wire popup handling
1505	a.webViewFactory = webkit.NewWebViewFactory(
1506		a.deps.WebContext,
1507		a.settings,
1508		a.pool,
1509		a.injector,
1510		a.router,
1511	)
1512	// Set theme background color on factory to eliminate white flash
1513	if a.deps.Theme != nil {
1514		r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
1515		a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
1516	}
1517	a.contentCoord.SetPopupConfig(
1518		a.webViewFactory,
1519		&a.deps.Config.Workspace.Popups,
1520		a.generateID,
1521	)
1522	a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1523		return a.wsCoord.InsertPopup(ctx, input)
1524	})
1525	a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
1526		return a.wsCoord.ClosePaneByID(ctx, paneID)
1527	})
1528	// Wire tabbed popup behavior to create new tabs
1529	a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1530		// Create a new tab with the popup pane
1531		tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
1532		if err != nil {
1533			return err
1534		}
1535		log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
1536		return nil
1537	})
1538
1539	// Move pane use case (cross-tab)
1540	a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1541
1542	// 4. Navigation Coordinator
1543	a.navCoord = coordinator.NewNavigationCoordinator(
1544		ctx,
1545		a.deps.NavigateUC,
1546		a.contentCoord,
1547	)
1548	a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
1549		a.navCoord.ClearPaneHistory(paneID)
1550	})
1551
1552	// Wire title updates to history persistence
1553	a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
1554		a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
1555	})
1556
1557	// Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
1558	a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
1559		a.navCoord.RecordHistory(ctx, paneID, url)
1560	})
1561
1562	// Wire window title updates when active pane's title changes
1563	a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1564		a.updateWindowTitle(title)
1565	})
1566
1567	// Wire pane URI updates for session snapshots (searches all tabs)
1568	a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
1569		a.updatePaneURIInAllTabs(paneID, url)
1570		a.updateFloatingSessionURI(paneID, url)
1571		// Mark dirty so snapshot captures the new URI
1572		a.MarkDirty()
1573	})
1574
1575	// Hide loading skeleton once the WebView paints
1576	a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
1577		// The WebView can be shown while its pane is in a background tab, so scan
1578		// all WorkspaceViews rather than only the active one.
1579		for _, wsView := range a.workspaceViews {
1580			if wsView == nil {
1581				continue
1582			}
1583			if pv := wsView.GetPaneView(paneID); pv != nil {
1584				pv.HideLoadingSkeleton()
1585			}
1586		}
1587		if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1588			a.firstWebViewShownOnce.Do(func() {
1589				a.deps.OnFirstWebViewShown(ctx)
1590			})
1591		}
1592
1593		// Set the WebView as the focused input for accent picker (if this is the active pane)
1594		ws := a.activeWorkspace()
1595		if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1596			a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1597		}
1598	})
1599
1600	// 5. Keyboard Dispatcher
1601	a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
1602		ctx,
1603		a.tabCoord,
1604		a.wsCoord,
1605		a.navCoord,
1606		a.deps.ZoomUC,
1607		a.deps.CopyURLUC,
1608	)
1609	a.wireKeyboardActions()
1610
1611	log.Debug().Msg("coordinators initialized")
1612}
1613
1614func (a *App) wireKeyboardActions() {
1615	a.kbDispatcher.SetOnQuit(a.Quit)
1616	a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
1617		a.ToggleFindBar(ctx)
1618		return nil
1619	})
1620	a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
1621		a.FindNext(ctx)
1622		return nil
1623	})
1624	a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
1625		a.FindPrevious(ctx)
1626		return nil
1627	})
1628	a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
1629		a.CloseFindBar(ctx)
1630		return nil
1631	})
1632	a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1633		return a.HandleMovePaneToTab(ctx)
1634	})
1635	a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1636		return a.HandleMovePaneToNextTab(ctx)
1637	})
1638	a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
1639		return a.ToggleFloatingPane(ctx)
1640	})
1641	a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error {
1642		return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL)
1643	})
1644
1645	// Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
1646	a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
1647		return a.kbDispatcher.Dispatch(ctx, action)
1648	})
1649}
1650
1651func (a *App) wireWebRTCPermissionIndicator() {
1652	if a.webrtcIndicator == nil || a.contentCoord == nil {
1653		return
1654	}
1655	ctx := context.Background()
1656	if a.deps != nil && a.deps.Ctx != nil {
1657		ctx = a.deps.Ctx
1658	}
1659	log := logging.FromContext(ctx)
1660
1661	a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {
1662		a.webrtcIndicator.SetOrigin(origin)
1663
1664		switch state {
1665		case coordinator.PermissionActivityRequesting:
1666			a.webrtcIndicator.MarkRequesting(permTypes)
1667		case coordinator.PermissionActivityAllowed:
1668			a.webrtcIndicator.MarkAllowed(permTypes)
1669		case coordinator.PermissionActivityBlocked:
1670			a.webrtcIndicator.MarkBlocked(permTypes)
1671		}
1672
1673		for _, permType := range permTypes {
1674			a.syncWebRTCPermissionLockState(ctx, origin, permType)
1675		}
1676	})
1677
1678	a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
1679		if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1680			return
1681		}
1682
1683		if hasStored {
1684			// Already locked: reset to prompt (will re-ask on next request).
1685			if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil {
1686				log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision")
1687				return
1688			}
1689		} else {
1690			// Not locked: flip the state.
1691			// Allowed/requesting → deny future requests.
1692			// Blocked → allow future requests.
1693			decision := entity.PermissionDenied
1694			if state == string(coordinator.PermissionActivityBlocked) {
1695				decision = entity.PermissionGranted
1696			}
1697			if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
1698				log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision")
1699				return
1700			}
1701		}
1702
1703		a.syncWebRTCPermissionLockState(ctx, origin, permType)
1704	})
1705
1706	// Reset indicator when the active pane navigates away from the current origin.
1707	a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) {
1708		newOrigin, err := urlutil.ExtractOrigin(uri)
1709		if err != nil {
1710			// Internal pages (dumb://, about:) — clear the indicator.
1711			a.webrtcIndicator.Reset()
1712			return
1713		}
1714
1715		currentOrigin := a.webrtcIndicator.Origin()
1716		if currentOrigin != "" && currentOrigin != newOrigin {
1717			a.webrtcIndicator.Reset()
1718		}
1719	})
1720}
1721
1722func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) {
1723	if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1724		return
1725	}
1726
1727	record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType)
1728	if err != nil {
1729		logging.FromContext(ctx).Warn().
1730			Err(err).
1731			Str("origin", origin).
1732			Str("type", string(permType)).
1733			Msg("failed to read manual permission decision")
1734		return
1735	}
1736
1737	if record == nil {
1738		a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
1739		return
1740	}
1741
1742	a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true)
1743}
1744
1745// generateID generates a unique ID for tabs and panes.
1746// Uses monotonically increasing counter to avoid ID collisions.
1747func (a *App) generateID() string {
1748	a.idMu.Lock()
1749	defer a.idMu.Unlock()
1750	a.idCounter++
1751	return fmt.Sprintf("p%d", a.idCounter)
1752}
1753
1754// Tabs returns the tab list.
1755func (a *App) Tabs() *entity.TabList {
1756	return a.tabs
1757}
1758
1759// MainWindow returns the main window.
1760func (a *App) MainWindow() *window.MainWindow {
1761	return a.mainWindow
1762}
1763
1764// Quit requests the application to quit.
1765func (a *App) Quit() {
1766	if a.gtkApp != nil {
1767		a.gtkApp.Quit()
1768	}
1769}
1770
1771// RunWithArgs is a convenience function that creates and runs an App.
1772func RunWithArgs(ctx context.Context, deps *Dependencies) int {
1773	app, err := New(deps)
1774	if err != nil {
1775		log := logging.FromContext(ctx)
1776		log.Error().Err(err).Msg("failed to create application")
1777		return 1
1778	}
1779	return app.Run(ctx, os.Args)
1780}
1781
1782// updateWindowTitle updates the window title with the given page title.
1783// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
1784func (a *App) updateWindowTitle(pageTitle string) {
1785	if a.mainWindow == nil {
1786		return
1787	}
1788
1789	title := "Dumber"
1790	if pageTitle != "" {
1791		title = pageTitle + " - Dumber"
1792	}
1793	a.mainWindow.SetTitle(title)
1794}
1795
1796// updateWindowTitleFromActivePane updates the window title based on the current active pane.
1797func (a *App) updateWindowTitleFromActivePane() {
1798	ws := a.activeWorkspace()
1799	if ws == nil || a.contentCoord == nil {
1800		a.updateWindowTitle("")
1801		return
1802	}
1803	title := a.contentCoord.GetTitle(ws.ActivePaneID)
1804	a.updateWindowTitle(title)
1805}
1806
1807// handleModeChange is called when the input mode changes.
1808func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
1809	log := logging.FromContext(ctx)
1810	log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
1811
1812	if from == input.ModeResize && to != input.ModeResize {
1813		a.clearResizeModeBorder()
1814	}
1815	if to == input.ModeResize {
1816		a.applyResizeModeBorder(ctx, a.activeWorkspace())
1817	}
1818
1819	// Update global border overlay visibility based on mode.
1820	// Note: resize mode border is handled per-pane (stack container), not via global overlay.
1821	if a.borderMgr != nil {
1822		a.borderMgr.OnModeChange(ctx, from, to)
1823	}
1824
1825	// Show/hide mode indicator toaster based on config.
1826	a.updateModeIndicatorToaster(ctx, to)
1827}
1828
1829// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1830func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1831	if a.modeToaster == nil {
1832		return
1833	}
1834
1835	// Check if mode indicator toaster is enabled in config.
1836	if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1837		a.modeToaster.Hide()
1838		return
1839	}
1840
1841	if mode == input.ModeNormal {
1842		a.modeToaster.Hide()
1843		return
1844	}
1845
1846	// Show persistent toaster at bottom-left with mode display name.
1847	// Mode class is applied atomically with Show() to avoid visual flicker.
1848	modeClass := getModeToastClass(mode)
1849	a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1850		component.WithDuration(0), // Persistent until mode exits.
1851		component.WithPosition(component.ToastPositionBottomLeft),
1852		component.WithModeClass(modeClass),
1853	)
1854}
1855
1856// getModeToastClass returns the CSS class for the given mode's toast styling.
1857func getModeToastClass(mode input.Mode) string {
1858	switch mode {
1859	case input.ModePane:
1860		return "toast-pane-mode"
1861	case input.ModeTab:
1862		return "toast-tab-mode"
1863	case input.ModeSession:
1864		return "toast-session-mode"
1865	case input.ModeResize:
1866		return "toast-resize-mode"
1867	default:
1868		return ""
1869	}
1870}
1871
1872func (a *App) clearResizeModeBorder() {
1873	if a.resizeModeBorderTarget != nil {
1874		a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1875		a.resizeModeBorderTarget = nil
1876	}
1877}
1878
1879func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
1880	wsView := a.activeWorkspaceView()
1881	if wsView == nil || ws == nil {
1882		a.clearResizeModeBorder()
1883		return
1884	}
1885
1886	paneID := ws.ActivePaneID
1887	if paneID == "" {
1888		a.clearResizeModeBorder()
1889		return
1890	}
1891
1892	// Important: wrap the whole stack container when the active pane is in a stack.
1893	target := wsView.GetStackContainerWidget(paneID)
1894	if target == nil {
1895		a.clearResizeModeBorder()
1896		return
1897	}
1898
1899	if !target.HasCssClass("resize-mode-active") {
1900		target.AddCssClass("resize-mode-active")
1901	}
1902
1903	if a.resizeModeBorderTarget != target {
1904		if a.resizeModeBorderTarget != nil {
1905			a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1906		} else {
1907			logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
1908		}
1909		a.resizeModeBorderTarget = target
1910	}
1911}
1912
1913// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
1914func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
1915	a.createWorkspaceViewWithoutAttach(ctx, tab)
1916
1917	// Attach to content area
1918	wsView := a.workspaceViews[tab.ID]
1919	if wsView != nil && a.mainWindow != nil {
1920		widget := wsView.Widget()
1921		if widget != nil {
1922			gtkWidget := widget.GtkWidget()
1923			if gtkWidget != nil {
1924				a.mainWindow.SetContent(gtkWidget)
1925			}
1926		}
1927	}
1928}
1929
1930// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
1931// Used during session restoration where we create all views first, then attach only the active one.
1932func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
1933	log := logging.FromContext(ctx)
1934
1935	if a.widgetFactory == nil {
1936		log.Error().Msg("widget factory not initialized")
1937		return
1938	}
1939
1940	// Create workspace view
1941	wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
1942	if wsView == nil {
1943		log.Error().Msg("failed to create workspace view")
1944		return
1945	}
1946	a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
1947
1948	// Set the workspace
1949	if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
1950		log.Error().Err(err).Msg("failed to set workspace in view")
1951		return
1952	}
1953
1954	// Ensure WebViews are attached to panes
1955	if a.contentCoord != nil {
1956		a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1957	}
1958
1959	// Note: Mode borders for tab/pane/session are attached to MainWindow.
1960	// Resize mode border is attached to the active pane's stack container.
1961
1962	// Set omnibox config for this workspace view
1963	wsView.SetOmniboxConfig(a.omniboxCfg)
1964	// Set find bar config for this workspace view
1965	wsView.SetFindBarConfig(a.findBarCfg)
1966	// Set auto-open omnibox on new pane
1967	wsView.SetAutoOpenOnNewPane(config.Get().Omnibox.AutoOpenOnNewPane)
1968
1969	wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1970		if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
1971			ws := a.activeWorkspace()
1972			if ws != nil {
1973				ws.ActivePaneID = paneID
1974			}
1975			a.applyResizeModeBorder(ctx, ws)
1976		}
1977	})
1978
1979	wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
1980		if a.wsCoord != nil {
1981			_ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
1982		}
1983	})
1984
1985	// Store in map
1986	a.workspaceViews[tab.ID] = wsView
1987	a.reattachFloatingSessions(tab.ID, wsView)
1988	a.syncFloatingFocus()
1989
1990	log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
1991}
1992
1993// activeWorkspace returns the workspace of the active tab.
1994func (a *App) activeWorkspace() *entity.Workspace {
1995	activeTab := a.tabs.ActiveTab()
1996	if activeTab == nil {
1997		return nil
1998	}
1999	return activeTab.Workspace
2000}
2001
2002// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
2003// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
2004func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
2005	for _, tab := range a.tabs.Tabs {
2006		if tab.Workspace == nil {
2007			continue
2008		}
2009		paneNode := tab.Workspace.FindPane(paneID)
2010		if paneNode != nil && paneNode.Pane != nil {
2011			paneNode.Pane.URI = url
2012			return // Pane IDs are unique, no need to continue
2013		}
2014	}
2015}
2016
2017// activeWorkspaceView returns the workspace view for the active tab.
2018func (a *App) activeWorkspaceView() *component.WorkspaceView {
2019	activeTab := a.tabs.ActiveTab()
2020	if activeTab == nil {
2021		return nil
2022	}
2023	return a.workspaceViews[activeTab.ID]
2024}
2025
2026// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
2027// Used by the accent picker to insert accented characters into web content.
2028func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2029	if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
2030		return nil
2031	}
2032
2033	wv := a.contentCoord.ActiveWebView(context.Background())
2034	if wv == nil {
2035		return nil
2036	}
2037
2038	// Get the underlying webkit.WebView for the text input target
2039	return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
2040}
2041
2042// attachPopupToTab attaches a popup WebView to a newly created tab.
2043// This is called when a popup uses tabbed behavior.
2044func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
2045	log := logging.FromContext(ctx)
2046
2047	wsView := a.workspaceViews[tabID]
2048	if wsView == nil {
2049		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
2050		return
2051	}
2052
2053	// Register WebView with content coordinator
2054	if a.contentCoord != nil {
2055		// Track the WebView
2056		a.contentCoord.RegisterPopupWebView(pane.ID, wv)
2057
2058		// Wrap and attach widget
2059		widget := a.contentCoord.WrapWidget(ctx, wv)
2060		if widget != nil {
2061			paneView := wsView.GetPaneView(pane.ID)
2062			if paneView != nil {
2063				paneView.SetWebViewWidget(widget)
2064			} else {
2065				log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
2066			}
2067		}
2068	}
2069	log.Debug().
2070		Str("tab_id", string(tabID)).
2071		Str("pane_id", string(pane.ID)).
2072		Msg("popup webview attached to tab")
2073}
2074
2075// switchWorkspaceView swaps the displayed workspace view for a tab.
2076func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
2077	log := logging.FromContext(ctx)
2078
2079	wsView, exists := a.workspaceViews[tabID]
2080	if !exists {
2081		log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
2082		return
2083	}
2084
2085	// Get the workspace view's root widget
2086	widget := wsView.Widget()
2087	if widget == nil {
2088		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
2089		return
2090	}
2091
2092	gtkWidget := widget.GtkWidget()
2093	if gtkWidget == nil {
2094		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
2095		return
2096	}
2097
2098	// Swap content (MainWindow.SetContent now properly removes old content)
2099	if a.mainWindow != nil {
2100		a.mainWindow.SetContent(gtkWidget)
2101	}
2102
2103	// Update window title with the new active pane's title
2104	a.updateWindowTitleFromActivePane()
2105	a.syncFloatingFocus()
2106
2107	log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
2108}
2109
2110func normalizeFloatingSessionID(sessionID string) string {
2111	sessionID = strings.TrimSpace(sessionID)
2112	if sessionID == "" {
2113		return floatingSessionIDDefault
2114	}
2115	return sessionID
2116}
2117
2118func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
2119	return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
2120}
2121
2122func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
2123	return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
2124}
2125
2126func decorateFloatingOverlay(overlay layout.OverlayWidget) {
2127	if overlay == nil {
2128		return
2129	}
2130
2131	overlay.SetHexpand(false)
2132	overlay.SetVexpand(false)
2133	overlay.AddCssClass("floating-pane-container")
2134	overlay.AddCssClass("pane-border")
2135	overlay.AddCssClass("pane-active")
2136}
2137
2138// showFloatingWidget makes a floating pane widget interactive and visible.
2139// Visibility is controlled by CSS class to animate opacity while keeping the
2140// widget mapped so WebKit can redraw without a hard pop.
2141func showFloatingWidget(w layout.Widget) {
2142	if w == nil {
2143		return
2144	}
2145	w.AddCssClass(floatingPaneVisibleClass)
2146	w.SetCanTarget(true)
2147	w.SetCanFocus(true)
2148}
2149
2150// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
2151// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
2152// widget mapped and WebKit retains its rendered surface in GPU memory.
2153func hideFloatingWidget(w layout.Widget) {
2154	if w == nil {
2155		return
2156	}
2157	w.RemoveCssClass(floatingPaneVisibleClass)
2158	w.SetCanTarget(false)
2159	w.SetCanFocus(false)
2160}
2161
2162// setFloatingWidgetShown applies show or hide based on the visible flag.
2163func setFloatingWidgetShown(w layout.Widget, visible bool) {
2164	if visible {
2165		showFloatingWidget(w)
2166	} else {
2167		hideFloatingWidget(w)
2168	}
2169}
2170
2171func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
2172	if workspaceOverlay == nil || floatingOverlay == nil {
2173		return
2174	}
2175
2176	workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
2177	workspaceOverlay.SetClipOverlay(floatingOverlay, false)
2178}
2179
2180func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
2181	if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
2182		return 0, 0, 0, 0, false
2183	}
2184
2185	width = desiredWidth
2186	height = desiredHeight
2187	if width > overlayWidth {
2188		width = overlayWidth
2189	}
2190	if height > overlayHeight {
2191		height = overlayHeight
2192	}
2193
2194	x = (overlayWidth - width) / 2
2195	y = (overlayHeight - height) / 2
2196	if x < 0 {
2197		x = 0
2198	}
2199	if y < 0 {
2200		y = 0
2201	}
2202
2203	return x, y, width, height, true
2204}
2205
2206type gtkAllocationLayout [4]int32
2207
2208const gtkAllocationLayoutSize = 16
2209
2210var (
2211	_ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
2212	_ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
2213)
2214
2215// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
2216// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
2217// 32-bit signed integers {x, y, width, height} in native endianness.
2218// If GTK struct layout changes, revisit offsets and writes below.
2219//
2220//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
2221func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
2222	if allocationPtr == nil {
2223		return false
2224	}
2225
2226	const (
2227		gtkAllocationOffsetX      = uintptr(0)
2228		gtkAllocationOffsetY      = uintptr(4)
2229		gtkAllocationOffsetWidth  = uintptr(8)
2230		gtkAllocationOffsetHeight = uintptr(12)
2231	)
2232
2233	// GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
2234	base := unsafe.Pointer(allocationPtr)
2235	*(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetX)) = int32(x)
2236	*(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetY)) = int32(y)
2237	*(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetWidth)) = int32(width)
2238	*(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetHeight)) = int32(height)
2239	return true
2240}
2241
2242func (a *App) floatingAllocationForWidget(
2243	tabID entity.TabID,
2244	widgetPtr uintptr,
2245	overlayWidth, overlayHeight int,
2246) (x, y, width, height int, ok bool) {
2247	for key, session := range a.floatingSessions {
2248		if key.tabID != tabID || session == nil || session.widget == nil {
2249			continue
2250		}
2251
2252		gtkWidget := session.widget.GtkWidget()
2253		if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
2254			continue
2255		}
2256
2257		desiredWidth := session.appliedWidth
2258		desiredHeight := session.appliedHeight
2259		if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
2260			session.pane.Resize()
2261			desiredWidth, desiredHeight = session.pane.Dimensions()
2262		}
2263
2264		return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
2265	}
2266
2267	return 0, 0, 0, 0, false
2268}
2269
2270func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
2271	if workspaceOverlay == nil {
2272		return
2273	}
2274
2275	workspaceWidget := workspaceOverlay.GtkWidget()
2276	if workspaceWidget == nil {
2277		return
2278	}
2279
2280	gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
2281	if gtkOverlay == nil {
2282		return
2283	}
2284
2285	cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
2286		overlayWidth := workspaceOverlay.GetAllocatedWidth()
2287		overlayHeight := workspaceOverlay.GetAllocatedHeight()
2288		x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
2289		if !ok {
2290			return false
2291		}
2292		return writeOverlayAllocation(allocationPtr, x, y, width, height)
2293	}
2294
2295	gtkOverlay.ConnectGetChildPosition(&cb)
2296}
2297
2298func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
2299	if a.deps != nil && a.deps.Config != nil {
2300		return a.deps.Config.Workspace.FloatingPane
2301	}
2302	return config.Get().Workspace.FloatingPane
2303}
2304
2305func (a *App) ensureFloatingSession(
2306	ctx context.Context,
2307	tabID entity.TabID,
2308	sessionID string,
2309	wsView *component.WorkspaceView,
2310) (*floatingWorkspaceSession, error) {
2311	key := floatingSessionMapKey(tabID, sessionID)
2312	if session, ok := a.floatingSessions[key]; ok && session != nil {
2313		if wsView == nil {
2314			return session, nil
2315		}
2316		session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
2317		if session.widget != nil {
2318			wsView.AddWorkspaceOverlayWidget(session.widget)
2319			configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
2320		}
2321		if session.paneView != nil {
2322			wsView.RegisterPaneView(session.paneID, session.paneView)
2323			if session.focusWidget == nil {
2324				session.focusWidget = session.paneView.WebViewWidget()
2325			}
2326		}
2327		return session, nil
2328	}
2329
2330	if wsView == nil {
2331		return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
2332	}
2333	if a.contentCoord == nil {
2334		return nil, fmt.Errorf("content coordinator not initialized")
2335	}
2336	if a.widgetFactory == nil {
2337		return nil, fmt.Errorf("widget factory not initialized")
2338	}
2339
2340	paneID := floatingPaneIDForSession(tabID, sessionID)
2341	wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
2342	if err != nil {
2343		return nil, fmt.Errorf("ensure floating webview: %w", err)
2344	}
2345
2346	webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
2347	if webViewWidget == nil {
2348		return nil, fmt.Errorf("wrap floating webview widget")
2349	}
2350	webViewWidget.SetHexpand(true)
2351	webViewWidget.SetVexpand(true)
2352
2353	// Wrap the WebView in a PaneView so it gets progress bar, loading
2354	// skeleton, and link status overlays — the same widget hierarchy as
2355	// regular workspace panes. This allows the content coordinator's
2356	// existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
2357	// and update the floating pane automatically.
2358	pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)
2359	pv.SetActive(true)
2360	// Hide loading skeleton — floating panes have their own themed container.
2361	pv.HideLoadingSkeleton()
2362	wsView.RegisterPaneView(paneID, pv)
2363
2364	// Use the PaneView's overlay directly as the floating overlay.
2365	// This avoids nesting two overlays which causes GTK4 allocation
2366	// issues (inner overlay expanding to fill the parent's allocation).
2367	pvOverlay := pv.Overlay()
2368	decorateFloatingOverlay(pvOverlay)
2369	pvOverlay.SetHalign(gtk.AlignCenterValue)
2370	pvOverlay.SetValign(gtk.AlignCenterValue)
2371	hideFloatingWidget(pvOverlay)
2372	wsView.AddWorkspaceOverlayWidget(pvOverlay)
2373	configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
2374
2375	floatingCfg := a.currentFloatingConfig()
2376	floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
2377		WidthPct:       floatingCfg.WidthPct,
2378		HeightPct:      floatingCfg.HeightPct,
2379		FallbackWidth:  floatingPaneFallbackWidth,
2380		FallbackHeight: floatingPaneFallbackHeight,
2381		OnNavigate: func(navCtx context.Context, url string) error {
2382			return wv.LoadURI(navCtx, url)
2383		},
2384	})
2385
2386	session := &floatingWorkspaceSession{
2387		paneID:      paneID,
2388		pane:        floatingPane,
2389		paneView:    pv,
2390		webView:     wv,
2391		overlay:     pvOverlay,
2392		widget:      pvOverlay,
2393		focusWidget: webViewWidget,
2394	}
2395	a.floatingSessions[key] = session
2396	return session, nil
2397}
2398
2399func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
2400	sessions := make(map[floatingSessionKey]*floatingWorkspaceSession)
2401	for key, session := range a.floatingSessions {
2402		if key.tabID != tabID || session == nil {
2403			continue
2404		}
2405		sessions[key] = session
2406	}
2407	return sessions
2408}
2409
2410func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
2411	sessions := a.floatingSessionsForTab(tabID)
2412	if len(sessions) == 0 {
2413		return
2414	}
2415
2416	for key, session := range sessions {
2417		a.releaseFloatingSession(ctx, key, session)
2418	}
2419	a.syncFloatingFocus()
2420}
2421
2422func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
2423	activeTab := a.tabs.ActiveTab()
2424	if activeTab == nil {
2425		return floatingSessionKey{}, nil, false
2426	}
2427	for key, session := range a.floatingSessions {
2428		if key.tabID != activeTab.ID || session == nil || session.pane == nil {
2429			continue
2430		}
2431		if session.pane.IsVisible() {
2432			return key, session, true
2433		}
2434	}
2435	return floatingSessionKey{tabID: activeTab.ID}, nil, false
2436}
2437
2438func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
2439	key, session, ok := a.activeFloatingSessionEntry()
2440	if !ok {
2441		return nil, key.tabID
2442	}
2443	return session, key.tabID
2444}
2445
2446func (a *App) releaseFloatingSession(ctx context.Context, key floatingSessionKey, session *floatingWorkspaceSession) {
2447	if session == nil {
2448		return
2449	}
2450
2451	a.stopFloatingResizeWatcher(session)
2452	if session.pane != nil {
2453		session.pane.Hide(ctx)
2454	}
2455	a.hideFloatingOmnibox(ctx, session)
2456	if wsView := a.workspaceViews[key.tabID]; wsView != nil {
2457		if session.widget != nil {
2458			wsView.RemoveWorkspaceOverlayWidget(session.widget)
2459		}
2460		if session.paneID != "" {
2461			wsView.UnregisterPaneView(session.paneID)
2462		}
2463	}
2464	if a.contentCoord != nil && session.paneID != "" {
2465		a.contentCoord.ReleaseWebView(ctx, session.paneID)
2466	}
2467	delete(a.floatingSessions, key)
2468
2469	session.appliedWidth = 0
2470	session.appliedHeight = 0
2471	session.focusWidget = nil
2472	session.paneView = nil
2473	session.widget = nil
2474	session.overlay = nil
2475	session.webView = nil
2476	session.pane = nil
2477	// Session is fully released/zeroed; caller must drop this reference and not reuse it.
2478}
2479
2480func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
2481	if paneID == "" {
2482		return
2483	}
2484
2485	for _, session := range a.floatingSessions {
2486		if session == nil || session.pane == nil {
2487			continue
2488		}
2489		if session.paneID != paneID {
2490			continue
2491		}
2492		session.pane.RecordLoadedURL(url)
2493		return
2494	}
2495}
2496
2497func (a *App) syncFloatingFocus() {
2498	for _, wsView := range a.workspaceViews {
2499		if wsView != nil {
2500			wsView.SetHoverFocusLocked(false)
2501		}
2502	}
2503
2504	if a.contentCoord == nil {
2505		return
2506	}
2507
2508	activeTab := a.tabs.ActiveTab()
2509	if activeTab == nil {
2510		a.contentCoord.ClearActivePaneOverride()
2511		return
2512	}
2513
2514	if session, _ := a.activeFloatingSession(); session != nil {
2515		if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
2516			wsView.SetHoverFocusLocked(true)
2517		}
2518		a.startFloatingResizeWatcher(session)
2519		a.contentCoord.SetActivePaneOverride(session.paneID)
2520		return
2521	}
2522
2523	a.contentCoord.ClearActivePaneOverride()
2524}
2525
2526func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2527	if session == nil || session.overlay == nil || a.widgetFactory == nil {
2528		return
2529	}
2530
2531	if session.omnibox == nil {
2532		cfg := a.omniboxCfg
2533		cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
2534			if a.appToaster != nil {
2535				a.appToaster.Show(toastCtx, message, level)
2536			}
2537		}
2538
2539		omnibox := component.NewOmnibox(ctx, cfg)
2540		if omnibox == nil {
2541			return
2542		}
2543
2544		omnibox.SetParentOverlay(session.overlay)
2545		omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
2546		if omniboxWidget == nil {
2547			return
2548		}
2549
2550		session.overlay.AddOverlay(omniboxWidget)
2551		session.overlay.SetClipOverlay(omniboxWidget, false)
2552		session.overlay.SetMeasureOverlay(omniboxWidget, false)
2553
2554		session.omnibox = omnibox
2555		session.omniboxWidget = omniboxWidget
2556	}
2557
2558	session.pane.SetOmniboxVisible(true)
2559	session.omnibox.Show(ctx, "")
2560}
2561
2562func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2563	if session == nil || session.pane == nil {
2564		return
2565	}
2566
2567	session.pane.SetOmniboxVisible(false)
2568	if session.omnibox == nil {
2569		return
2570	}
2571
2572	session.omnibox.Hide(ctx)
2573	if session.overlay != nil && session.omniboxWidget != nil {
2574		if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
2575			session.overlay.RemoveOverlay(session.omniboxWidget)
2576		} else if parent != nil {
2577			session.omniboxWidget.Unparent()
2578		}
2579	}
2580	session.omnibox = nil
2581	session.omniboxWidget = nil
2582}
2583
2584func floatingFocusWidget(session *floatingWorkspaceSession) layout.Widget {
2585	if session == nil {
2586		return nil
2587	}
2588	if session.focusWidget != nil {
2589		return session.focusWidget
2590	}
2591	if session.paneView != nil {
2592		return session.paneView.WebViewWidget()
2593	}
2594	return nil
2595}
2596
2597func focusFloatingSessionContent(session *floatingWorkspaceSession) {
2598	if session == nil || session.pane == nil {
2599		return
2600	}
2601	if !session.pane.IsVisible() || session.pane.IsOmniboxVisible() {
2602		return
2603	}
2604	w := floatingFocusWidget(session)
2605	if w == nil {
2606		return
2607	}
2608	w.GrabFocus()
2609}
2610
2611func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
2612	if session == nil || session.pane == nil {
2613		return
2614	}
2615	session.pane.Resize()
2616	if session.widget == nil {
2617		return
2618	}
2619	width, height := session.pane.Dimensions()
2620	if session.appliedWidth == width && session.appliedHeight == height {
2621		return
2622	}
2623	session.widget.SetSizeRequest(width, height)
2624	session.appliedWidth = width
2625	session.appliedHeight = height
2626}
2627
2628func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
2629	if session == nil || session.pane == nil {
2630		return false
2631	}
2632	if !session.pane.IsVisible() {
2633		session.resizeWatcherActive = false
2634		session.resizeTickID = 0
2635		return false
2636	}
2637
2638	a.resizeFloatingWidget(session)
2639	return true
2640}
2641
2642func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
2643	if paneID == "" {
2644		return nil
2645	}
2646	for _, session := range a.floatingSessions {
2647		if session == nil || session.paneID != paneID {
2648			continue
2649		}
2650		return session
2651	}
2652	return nil
2653}
2654
2655func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
2656	if session == nil || session.overlay == nil || session.resizeWatcherActive {
2657		return
2658	}
2659
2660	overlayWidget := session.overlay.GtkWidget()
2661	if overlayWidget == nil {
2662		return
2663	}
2664
2665	session.resizeWatcherActive = true
2666	paneID := session.paneID
2667	tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
2668		liveSession := a.floatingSessionByPaneID(paneID)
2669		if liveSession == nil {
2670			session.resizeWatcherActive = false
2671			session.resizeTickID = 0
2672			return false
2673		}
2674
2675		keepRunning := a.handleFloatingViewportTick(liveSession)
2676		if !keepRunning {
2677			liveSession.resizeWatcherActive = false
2678			liveSession.resizeTickID = 0
2679		}
2680		return keepRunning
2681	})
2682	session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)
2683}
2684
2685func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
2686	if session == nil {
2687		return
2688	}
2689
2690	if session.resizeTickID != 0 && session.overlay != nil {
2691		if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
2692			overlayWidget.RemoveTickCallback(session.resizeTickID)
2693		}
2694	}
2695	session.resizeTickID = 0
2696	session.resizeWatcherActive = false
2697}
2698
2699func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
2700	if session == nil || session.pane == nil {
2701		return
2702	}
2703
2704	a.stopFloatingResizeWatcher(session)
2705	session.pane.Hide(ctx)
2706	a.hideFloatingOmnibox(ctx, session)
2707	hideFloatingWidget(session.widget)
2708	// Force a fresh size request on next show so WebKit gets a new
2709	// allocation cycle and repaints content after rapid toggles.
2710	session.appliedWidth = 0
2711	session.appliedHeight = 0
2712}
2713
2714func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
2715	for key, session := range a.floatingSessions {
2716		if key.tabID != tabID || session == nil || session == except || session.pane == nil {
2717			continue
2718		}
2719		if !session.pane.IsVisible() {
2720			continue
2721		}
2722		a.hideFloatingSession(ctx, session)
2723	}
2724}
2725
2726func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
2727	session, _ := a.activeFloatingSession()
2728	if session == nil {
2729		return false
2730	}
2731	a.hideFloatingSession(ctx, session)
2732	a.syncFloatingFocus()
2733	return true
2734}
2735
2736func (a *App) handleGlobalEscape(ctx context.Context) bool {
2737	return a.closeActiveFloatingPane(ctx)
2738}
2739
2740func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
2741	key, session, ok := a.activeFloatingSessionEntry()
2742	if !ok {
2743		return false
2744	}
2745	a.releaseFloatingSession(ctx, key, session)
2746	a.syncFloatingFocus()
2747	return true
2748}
2749
2750// ToggleFloatingPane toggles the active workspace floating pane visibility.
2751func (a *App) ToggleFloatingPane(ctx context.Context) error {
2752	activeTab := a.tabs.ActiveTab()
2753	if activeTab == nil {
2754		return nil
2755	}
2756	if a.closeActiveFloatingPane(ctx) {
2757		return nil
2758	}
2759	return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
2760}
2761
2762// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
2763func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
2764	return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
2765}
2766
2767// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
2768func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
2769	return a.openFloatingPaneSession(ctx, sessionID, url)
2770}
2771
2772func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
2773	activeTab := a.tabs.ActiveTab()
2774	if activeTab == nil {
2775		return nil
2776	}
2777
2778	wsView := a.workspaceViews[activeTab.ID]
2779	session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
2780	if err != nil {
2781		return err
2782	}
2783	hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2784	isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
2785
2786	if hasURL && isProfileSession && session.pane.IsVisible() {
2787		a.hideFloatingSession(ctx, session)
2788		a.syncFloatingFocus()
2789		return nil
2790	}
2791
2792	a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
2793
2794	if hasURL {
2795		if isProfileSession && session.pane.SessionStarted() {
2796			session.pane.Show()
2797		} else {
2798			if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2799				return err
2800			}
2801		}
2802	} else {
2803		if err := session.pane.ShowToggle(ctx); err != nil {
2804			return err
2805		}
2806	}
2807
2808	a.resizeFloatingWidget(session)
2809	setFloatingWidgetShown(session.widget, session.pane.IsVisible())
2810	if session.pane.IsOmniboxVisible() {
2811		a.showFloatingOmnibox(ctx, session)
2812	} else {
2813		a.hideFloatingOmnibox(ctx, session)
2814		focusFloatingSessionContent(session)
2815	}
2816	a.startFloatingResizeWatcher(session)
2817	a.syncFloatingFocus()
2818
2819	return nil
2820}
2821
2822func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
2823	session, _ := a.activeFloatingSession()
2824	if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
2825		return session.pane.Navigate(ctx, url)
2826	}
2827	if a.omniboxNavigateFn != nil {
2828		a.omniboxNavigateFn(url)
2829		return nil
2830	}
2831	if a.navCoord == nil {
2832		return fmt.Errorf("navigation coordinator not initialized")
2833	}
2834	return a.navCoord.Navigate(ctx, url)
2835}
2836
2837// ToggleOmnibox implements OmniboxProvider.
2838// Toggles the omnibox visibility in the active workspace view.
2839func (a *App) ToggleOmnibox(ctx context.Context) {
2840	log := logging.FromContext(ctx)
2841	if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() {
2842		wsView := a.activeWorkspaceView()
2843		if wsView != nil && wsView.IsOmniboxVisible() {
2844			wsView.HideOmnibox()
2845		}
2846
2847		if session.omnibox != nil && session.pane.IsOmniboxVisible() {
2848			a.hideFloatingOmnibox(ctx, session)
2849		} else {
2850			a.showFloatingOmnibox(ctx, session)
2851		}
2852		a.syncFloatingFocus()
2853		return
2854	}
2855
2856	wsView := a.activeWorkspaceView()
2857	if wsView == nil {
2858		log.Warn().Msg("no active workspace view for omnibox toggle")
2859		return
2860	}
2861
2862	if wsView.IsOmniboxVisible() {
2863		wsView.HideOmnibox()
2864	} else {
2865		wsView.ShowOmnibox(ctx, "")
2866	}
2867}
2868
2869// ToggleFindBar shows or hides the find bar in the active workspace view.
2870func (a *App) ToggleFindBar(ctx context.Context) {
2871	log := logging.FromContext(ctx)
2872
2873	wsView := a.activeWorkspaceView()
2874	if wsView == nil {
2875		log.Warn().Msg("no active workspace view for find bar toggle")
2876		return
2877	}
2878
2879	if wsView.IsFindBarVisible() {
2880		wsView.HideFindBar()
2881	} else {
2882		wsView.ShowFindBar(ctx)
2883	}
2884}
2885
2886// FindNext selects the next match in the active find bar.
2887func (a *App) FindNext(ctx context.Context) {
2888	wsView := a.activeWorkspaceView()
2889	if wsView == nil {
2890		return
2891	}
2892
2893	if !wsView.IsFindBarVisible() {
2894		wsView.ShowFindBar(ctx)
2895	}
2896	wsView.FindNext()
2897}
2898
2899// FindPrevious selects the previous match in the active find bar.
2900func (a *App) FindPrevious(ctx context.Context) {
2901	wsView := a.activeWorkspaceView()
2902	if wsView == nil {
2903		return
2904	}
2905
2906	if !wsView.IsFindBarVisible() {
2907		wsView.ShowFindBar(ctx)
2908	}
2909	wsView.FindPrevious()
2910}
2911
2912// CloseFindBar hides the find bar if visible.
2913func (a *App) CloseFindBar(ctx context.Context) {
2914	wsView := a.activeWorkspaceView()
2915	if wsView == nil {
2916		return
2917	}
2918	wsView.HideFindBar()
2919}
2920
2921// UpdateOmniboxZoom implements OmniboxProvider.
2922// Updates the zoom indicator on the current omnibox if visible.
2923func (a *App) UpdateOmniboxZoom(factor float64) {
2924	wsView := a.activeWorkspaceView()
2925	if wsView == nil {
2926		return
2927	}
2928
2929	omnibox := wsView.GetOmnibox()
2930	if omnibox != nil {
2931		omnibox.UpdateZoomIndicator(factor)
2932	}
2933}
2934
2935// SetOmniboxOnNavigate implements OmniboxProvider.
2936// This is called to set the navigation callback on new omniboxes.
2937// Since omniboxes are created per-pane, we store the config with the callback.
2938func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
2939	a.omniboxNavigateFn = fn
2940}
2941
2942// initFilteringAsync starts background filter loading with toast feedback.
2943func (a *App) initConfigWatcher(ctx context.Context) {
2944	log := logging.FromContext(ctx)
2945
2946	if a.configManager == nil {
2947		log.Debug().Msg("no config manager available, skipping watcher")
2948		return
2949	}
2950
2951	// Start viper watcher
2952	if err := a.configManager.Watch(); err != nil {
2953		log.Warn().Err(err).Msg("failed to start config watcher")
2954		return
2955	}
2956
2957	// Hot-reload appearance and keybindings on config change.
2958	a.configManager.OnConfigChange(func(newCfg *config.Config) {
2959		cfgCopy := newCfg
2960		cb := glib.SourceFunc(func(_ uintptr) bool {
2961			a.applyAppearanceConfig(ctx, cfgCopy)
2962			// Reload keyboard shortcuts
2963			if a.keyboardHandler != nil {
2964				a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy)
2965			}
2966			return false
2967		})
2968		glib.IdleAdd(&cb, 0)
2969	})
2970
2971	log.Debug().Msg("config watcher initialized")
2972}
2973
2974func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
2975	log := logging.FromContext(ctx)
2976	if cfg == nil {
2977		return
2978	}
2979
2980	// Update the shared config pointer in-place so existing references see changes.
2981	if a.deps != nil && a.deps.Config != nil {
2982		*a.deps.Config = *cfg
2983	}
2984
2985	// Update WebKit settings manager
2986	if a.settings != nil {
2987		a.settings.UpdateFromConfig(ctx, cfg)
2988	}
2989
2990	// Apply settings to existing webviews via coordinator
2991	if a.contentCoord != nil {
2992		a.contentCoord.ApplySettingsToAll(ctx, a.settings)
2993	}
2994
2995	// Update GTK theme and injected WebUI theme vars
2996	if a.deps != nil && a.deps.Theme != nil {
2997		var display *gdk.Display
2998		if a.mainWindow != nil && a.mainWindow.Window() != nil {
2999			display = a.mainWindow.Window().GetDisplay()
3000		}
3001		a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
3002
3003		// Update find highlight CSS for future navigations
3004		if a.injector != nil {
3005			findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
3006			if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
3007				log.Warn().Err(err).Msg("failed to update find highlight CSS")
3008			}
3009		}
3010
3011		prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
3012		cssText := a.deps.Theme.GetWebUIThemeCSS()
3013		if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
3014			log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
3015		}
3016
3017		// Refresh injected scripts so future navigations use the latest theme.
3018		if a.contentCoord != nil && a.injector != nil {
3019			a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
3020		}
3021
3022		// Apply WebUI theme to already-loaded dumb:// pages
3023		if a.contentCoord != nil {
3024			a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
3025		}
3026	}
3027
3028	log.Info().Msg("appearance config updated")
3029}
3030
3031func (a *App) initFilteringAsync(ctx context.Context) {
3032	if a.deps.FilterManager == nil {
3033		return
3034	}
3035
3036	a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
3037		statusCopy := status // Capture for closure
3038		cb := glib.SourceFunc(func(_ uintptr) bool {
3039			a.showFilterStatus(ctx, statusCopy)
3040			return false // Don't repeat
3041		})
3042		glib.IdleAdd(&cb, 0)
3043	})
3044	a.deps.FilterManager.LoadAsync(ctx)
3045}
3046
3047// showFilterStatus displays toast notification for filter status.
3048func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
3049	log := logging.FromContext(ctx)
3050
3051	switch status.State {
3052	case filtering.StateLoading:
3053		if a.appToaster != nil {
3054			a.appToaster.Show(ctx, status.Message, component.ToastInfo)
3055		}
3056	case filtering.StateActive:
3057		// Apply filters to existing webviews that were created before filters loaded
3058		if a.contentCoord != nil && a.deps.FilterManager != nil {
3059			a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
3060			log.Debug().Msg("applied filters to all existing webviews")
3061		}
3062		if a.appToaster != nil {
3063			a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
3064		}
3065	case filtering.StateError:
3066		if a.appToaster != nil {
3067			a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
3068		}
3069	}
3070}

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