v0.27.0 dcb4c51

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

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