v0.28.0 3cb3770

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

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