v0.26.2 3cd9d2e

app.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"sync"
10
11	"github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/application/usecase"
13	"github.com/bnema/dumber/internal/domain/entity"
14	"github.com/bnema/dumber/internal/infrastructure/config"
15	"github.com/bnema/dumber/internal/infrastructure/desktop"
16	"github.com/bnema/dumber/internal/infrastructure/filesystem"
17	"github.com/bnema/dumber/internal/infrastructure/filtering"
18	"github.com/bnema/dumber/internal/infrastructure/snapshot"
19	"github.com/bnema/dumber/internal/infrastructure/textinput"
20	"github.com/bnema/dumber/internal/infrastructure/webkit"
21
22	"github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
23	"github.com/bnema/dumber/internal/logging"
24	"github.com/bnema/dumber/internal/ui/adapter"
25	"github.com/bnema/dumber/internal/ui/component"
26	"github.com/bnema/dumber/internal/ui/coordinator"
27	"github.com/bnema/dumber/internal/ui/dispatcher"
28	"github.com/bnema/dumber/internal/ui/focus"
29	"github.com/bnema/dumber/internal/ui/input"
30	"github.com/bnema/dumber/internal/ui/layout"
31	"github.com/bnema/dumber/internal/ui/theme"
32	"github.com/bnema/dumber/internal/ui/window"
33	"github.com/jwijenbergh/puregotk/v4/adw"
34	"github.com/jwijenbergh/puregotk/v4/gdk"
35	"github.com/jwijenbergh/puregotk/v4/gio"
36	"github.com/jwijenbergh/puregotk/v4/glib"
37	"github.com/jwijenbergh/puregotk/v4/gtk"
38)
39
40const (
41	// AppID is the application identifier for GTK.
42	AppID = "com.github.bnema.dumber"
43)
44
45// App wraps the GTK Application and manages the browser lifecycle.
46type App struct {
47	deps       *Dependencies
48	gtkApp     *gtk.Application
49	mainWindow *window.MainWindow
50
51	// State
52	tabs   *entity.TabList
53	tabsUC *usecase.ManageTabsUseCase
54
55	// Coordinators (new architecture)
56	contentCoord  *coordinator.ContentCoordinator
57	tabCoord      *coordinator.TabCoordinator
58	wsCoord       *coordinator.WorkspaceCoordinator
59	navCoord      *coordinator.NavigationCoordinator
60	kbDispatcher  *dispatcher.KeyboardDispatcher
61	configManager *config.Manager
62
63	// Pane management (used by coordinators)
64	panesUC        *usecase.ManagePanesUseCase
65	workspaceViews map[entity.TabID]*component.WorkspaceView
66	widgetFactory  layout.WidgetFactory
67	stackedPaneMgr *component.StackedPaneManager
68
69	// Input handling
70	keyboardHandler       *input.KeyboardHandler
71	globalShortcutHandler *input.GlobalShortcutHandler
72
73	// Focus and border management
74	focusMgr  *focus.Manager
75	borderMgr *focus.BorderManager
76
77	resizeModeBorderTarget layout.Widget
78
79	// Omnibox configuration (omnibox is created per workspace view)
80	omniboxCfg component.OmniboxConfig
81	// Find bar configuration (find bar is created per workspace view)
82	findBarCfg component.FindBarConfig
83
84	// Web content (managed by ContentCoordinator)
85	pool           *webkit.WebViewPool
86	webViewFactory *webkit.WebViewFactory
87	injector       *webkit.ContentInjector
88	router         *webkit.MessageRouter
89	settings       *webkit.SettingsManager
90	faviconAdapter *adapter.FaviconAdapter
91
92	// App-level toaster for system notifications (filter status, etc.)
93	appToaster *component.Toaster
94	// Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
95	modeToaster *component.Toaster
96
97	// Session management
98	sessionManager  *component.SessionManager
99	tabPicker       *component.TabPicker
100	tabPickerWidget layout.Widget
101	tabPickerPaneID entity.PaneID
102	snapshotService *snapshot.Service
103
104	// Update management
105	updateCoord *coordinator.UpdateCoordinator
106
107	// ID generator for tabs/panes
108	idCounter             uint64
109	idMu                  sync.Mutex
110	firstWebViewShownOnce sync.Once
111
112	movePaneToTabUC *usecase.MovePaneToTabUseCase
113
114	// Accent picker for dead keys support
115	accentPicker        *component.AccentPicker
116	insertAccentUC      *usecase.InsertAccentUseCase
117	accentFocusProvider *textinput.FocusProvider
118
119	// Deferred initialization - runs after first load_started to avoid blocking initial navigation
120	deferredInitOnce sync.Once
121	deferredInitFn   func()
122
123	// lifecycle
124	cancel context.CancelCauseFunc
125}
126
127// New creates a new App with the given dependencies.
128func New(deps *Dependencies) (*App, error) {
129	if err := deps.Validate(); err != nil {
130		return nil, err
131	}
132	ctx, cancel := context.WithCancelCause(deps.Ctx)
133
134	app := &App{
135		deps:           deps,
136		tabs:           entity.NewTabList(),
137		tabsUC:         deps.TabsUC,
138		panesUC:        deps.PanesUC,
139		workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
140		pool:           deps.Pool,
141		injector:       deps.Injector,
142		router:         deps.MessageRouter,
143		settings:       deps.Settings,
144		configManager:  config.GetManager(),
145		cancel:         cancel,
146	}
147	if app.router == nil {
148		app.router = webkit.NewMessageRouter(ctx)
149	}
150
151	// Register message handlers
152	if app.router != nil {
153		if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
154			HistoryUC:    deps.HistoryUC,
155			FavoritesUC:  deps.FavoritesUC,
156			Clipboard:    deps.Clipboard,
157			ConfigGetter: config.Get,
158			OnClipboardCopied: func(textLen int) {
159				// Show brief toast on auto-copy (similar to zellij footer notification)
160				// Must schedule on GTK main thread since this is called from WebKit handler
161				cb := glib.SourceFunc(func(_ uintptr) bool {
162					if app.appToaster != nil {
163						app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
164							component.WithDuration(component.ToastBriefDurationMs),
165							component.WithPosition(component.ToastPositionBottomRight),
166						)
167					}
168					return false
169				})
170				glib.IdleAdd(&cb, 0)
171			},
172		}); err != nil {
173			return nil, err
174		}
175	}
176
177	return app, nil
178}
179
180// Run starts the GTK application and blocks until it exits.
181// Returns the exit code.
182func (a *App) Run(ctx context.Context, args []string) int {
183	log := logging.FromContext(ctx)
184	log.Debug().Msg("creating GTK application")
185
186	// Initialize libadwaita once (required before using StyleManager).
187	// This also initializes GTK implicitly.
188	adw.Init()
189	logging.Trace().Mark("gtk_init")
190
191	// Mark adwaita detector as available now that adw.Init() is complete.
192	// This enables the highest-priority color scheme detector.
193	if a.deps != nil && a.deps.AdwaitaDetector != nil {
194		a.deps.AdwaitaDetector.MarkAvailable()
195		log.Debug().Msg("adwaita detector marked available")
196
197		// Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
198		// They have the wrong dark mode preference injected; re-inject with correct value.
199		if a.pool != nil {
200			a.pool.RefreshScripts(ctx)
201		}
202	}
203
204	// TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
205	a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
206	if a.gtkApp == nil {
207		log.Error().Msg("failed to create GTK application")
208		return 1
209	}
210	defer a.gtkApp.Unref()
211	logging.Trace().Mark("gtk_app_created")
212
213	// Connect activate signal
214	activateCb := func(_ gio.Application) {
215		a.onActivate(ctx)
216	}
217	a.gtkApp.ConnectActivate(&activateCb)
218
219	// Connect shutdown signal
220	shutdownCb := func(_ gio.Application) {
221		a.onShutdown(ctx)
222	}
223	a.gtkApp.ConnectShutdown(&shutdownCb)
224
225	log.Info().Msg("starting GTK main loop")
226	code := a.gtkApp.Run(len(args), args)
227
228	return code
229}
230
231// onActivate is called when the GTK application is activated.
232func (a *App) onActivate(ctx context.Context) {
233	log := logging.FromContext(ctx)
234	log.Debug().Msg("GTK application activated")
235
236	a.applyGTKColorSchemePreference(ctx)
237	// Configure pool background color early (prevents white flash), but avoid
238	// synchronous prewarming that delays the first navigation on cold start.
239	a.setupPoolBackgroundColor(ctx)
240
241	if err := a.createMainWindow(ctx); err != nil {
242		log.Error().Err(err).Msg("failed to create main window")
243		return
244	}
245	logging.Trace().Mark("window_created")
246
247	a.initLayoutInfrastructure()
248	a.initAppToasterOverlay()
249	a.initFocusAndBorderOverlay()
250	a.initAccentPicker(ctx)
251	a.initDownloadHandler(ctx)
252
253	a.initCoordinators(ctx)
254	logging.Trace().Mark("coordinators_init")
255	a.initKeyboardHandler(ctx)
256	a.initOmniboxConfig(ctx)
257	a.initFindBarConfig()
258	a.initSessionManager(ctx)
259	a.initTabPicker(ctx)
260	a.initSnapshotService(ctx)
261	a.initUpdateCoordinator(ctx)
262	a.createInitialTab(ctx)
263	a.finalizeActivation(ctx)
264}
265
266func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
267	log := logging.FromContext(ctx)
268
269	// Get the config color scheme preference (default follows system theme)
270	scheme := "default"
271	if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
272		scheme = a.deps.Config.Appearance.ColorScheme
273	}
274
275	// Apply color scheme via libadwaita's StyleManager.
276	// This properly communicates the preference to GTK and WebKit,
277	// so web content can correctly evaluate prefers-color-scheme media queries.
278	styleMgr := adw.StyleManagerGetDefault()
279	if styleMgr == nil {
280		log.Warn().Msg("failed to get adw.StyleManager")
281		return
282	}
283
284	var adwScheme adw.ColorScheme
285	switch scheme {
286	case "dark", "prefer-dark":
287		adwScheme = adw.ColorSchemeForceDarkValue
288	case "light", "prefer-light":
289		adwScheme = adw.ColorSchemeForceLightValue
290	default:
291		// "default" or empty - follow system preference
292		adwScheme = adw.ColorSchemeDefaultValue
293	}
294
295	styleMgr.SetColorScheme(adwScheme)
296
297	// Refresh the color resolver now that adwaita detector is available.
298	// This ensures all components get the correct preference.
299	var pref string
300	var prefersDark bool
301	if a.deps != nil && a.deps.ColorResolver != nil {
302		result := a.deps.ColorResolver.Refresh()
303		prefersDark = result.PrefersDark
304		pref = result.Source
305	} else {
306		prefersDark = styleMgr.GetDark()
307		pref = "adw.StyleManager"
308	}
309
310	log.Debug().
311		Str("scheme", scheme).
312		Bool("prefers_dark", prefersDark).
313		Str("source", pref).
314		Msg("applied color scheme via adw.StyleManager")
315}
316
317func (a *App) setupPoolBackgroundColor(ctx context.Context) {
318	log := logging.FromContext(ctx)
319
320	// Set theme background color on pool to eliminate white flash.
321	// Must be done before any WebView creation so WebViews get the correct color.
322	if a.pool == nil {
323		log.Debug().Msg("webview pool not available; skipping background color setup")
324		return
325	}
326	if a.deps == nil || a.deps.Theme == nil {
327		log.Debug().Msg("theme not available; skipping webview pool background color setup")
328		return
329	}
330
331	r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
332	a.pool.SetBackgroundColor(r, g, b, alpha)
333	log.Debug().Msg("configured webview pool background color")
334}
335
336func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
337	// Prewarm WebView pool after startup so cold-start navigation is not blocked.
338	if a.pool == nil {
339		return
340	}
341	a.pool.PrewarmAsync(ctx, 0)
342}
343
344func (a *App) createMainWindow(ctx context.Context) error {
345	mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
346	if err != nil {
347		return err
348	}
349	a.mainWindow = mainWindow
350
351	// Apply GTK CSS styling from theme manager.
352	if a.deps == nil || a.deps.Theme == nil {
353		return nil
354	}
355	if display := a.mainWindow.Window().GetDisplay(); display != nil {
356		a.deps.Theme.ApplyToDisplay(ctx, display)
357	}
358	return nil
359}
360
361func (a *App) initLayoutInfrastructure() {
362	// Initialize widget factory for pane layout.
363	a.widgetFactory = layout.NewGtkWidgetFactory()
364
365	// Initialize stacked pane manager for incremental widget operations.
366	a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
367}
368
369func (a *App) initAppToasterOverlay() {
370	if a.mainWindow == nil {
371		return
372	}
373
374	// Create app-level toaster for system notifications.
375	a.appToaster = component.NewToaster(a.widgetFactory)
376	toasterWidget := a.appToaster.Widget()
377	if toasterWidget == nil {
378		return
379	}
380	gtkWidget := toasterWidget.GtkWidget()
381	if gtkWidget == nil {
382		return
383	}
384	a.mainWindow.AddOverlay(gtkWidget)
385
386	// Create mode indicator toaster for modal mode notifications.
387	a.modeToaster = component.NewToaster(a.widgetFactory)
388	modeToasterWidget := a.modeToaster.Widget()
389	if modeToasterWidget == nil {
390		return
391	}
392	modeGtkWidget := modeToasterWidget.GtkWidget()
393	if modeGtkWidget == nil {
394		return
395	}
396	a.mainWindow.AddOverlay(modeGtkWidget)
397}
398
399func (a *App) initFocusAndBorderOverlay() {
400	// Initialize focus manager for geometric navigation.
401	a.focusMgr = focus.NewManager(a.panesUC)
402
403	// Initialize border manager for mode indicators.
404	a.borderMgr = focus.NewBorderManager(a.widgetFactory)
405
406	// Attach border overlay to main window (visible for all tabs).
407	if a.borderMgr == nil || a.mainWindow == nil {
408		return
409	}
410	borderWidget := a.borderMgr.Widget()
411	if borderWidget == nil {
412		return
413	}
414	gtkWidget := borderWidget.GtkWidget()
415	if gtkWidget == nil {
416		return
417	}
418	a.mainWindow.AddOverlay(gtkWidget)
419}
420
421func (a *App) initAccentPicker(ctx context.Context) {
422	log := logging.FromContext(ctx)
423
424	if a.widgetFactory == nil || a.mainWindow == nil {
425		log.Debug().Msg("widget factory or main window not available, skipping accent picker")
426		return
427	}
428
429	// Create accent picker component
430	a.accentPicker = component.NewAccentPicker(a.widgetFactory)
431	if a.accentPicker == nil {
432		log.Warn().Msg("failed to create accent picker")
433		return
434	}
435
436	// Add to main window overlay
437	pickerWidget := a.accentPicker.Widget()
438	if pickerWidget != nil {
439		gtkWidget := pickerWidget.GtkWidget()
440		if gtkWidget != nil {
441			a.mainWindow.AddOverlay(gtkWidget)
442		}
443	}
444
445	// Create focus provider (tracks which input has focus)
446	a.accentFocusProvider = textinput.NewFocusProvider()
447
448	// Create the use case with glib.IdleAdd for thread-safe GTK calls
449	a.insertAccentUC = usecase.NewInsertAccentUseCase(
450		a.accentFocusProvider,
451		a.accentPicker,
452		func(fn func()) {
453			cb := glib.SourceFunc(func(_ uintptr) bool {
454				fn()
455				return false // Don't repeat
456			})
457			glib.IdleAdd(&cb, 0)
458		},
459	)
460
461	log.Debug().Msg("accent picker initialized")
462}
463
464func (a *App) initDownloadHandler(ctx context.Context) {
465	log := logging.FromContext(ctx)
466
467	if a.deps == nil || a.deps.WebContext == nil {
468		log.Debug().Msg("WebContext not available, skipping download handler")
469		return
470	}
471
472	// Determine download path from config, fallback to XDG.
473	downloadPath := ""
474	if a.deps.Config != nil {
475		downloadPath = a.deps.Config.Downloads.Path
476	}
477	if downloadPath == "" && a.deps.XDG != nil {
478		var err error
479		downloadPath, err = a.deps.XDG.DownloadDir()
480		if err != nil {
481			log.Warn().Err(err).Msg("failed to get XDG download dir")
482		}
483	}
484	if downloadPath == "" {
485		// Final fallback to ~/Downloads, with /tmp as last resort.
486		home, err := os.UserHomeDir()
487		if err != nil {
488			log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
489			downloadPath = "/tmp"
490		} else {
491			downloadPath = filepath.Join(home, "Downloads")
492		}
493	}
494
495	// Create download event adapter to show toasts.
496	eventAdapter := &downloadEventAdapter{app: a}
497
498	// Create use case for preparing download destinations with file deduplication.
499	prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New())
500
501	// Create and wire the download handler.
502	handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC)
503	a.deps.WebContext.SetDownloadHandler(ctx, handler)
504
505	log.Info().Str("path", downloadPath).Msg("download handler initialized")
506}
507
508// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
509type downloadEventAdapter struct {
510	app *App
511}
512
513func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
514	// Must schedule on GTK main thread.
515	cb := glib.SourceFunc(func(_ uintptr) bool {
516		if d.app.appToaster == nil {
517			return false
518		}
519
520		switch event.Type {
521		case port.DownloadEventStarted:
522			d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
523		case port.DownloadEventFinished:
524			d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
525		case port.DownloadEventFailed:
526			d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
527		}
528		return false
529	})
530	glib.IdleAdd(&cb, 0)
531}
532
533func (a *App) initKeyboardHandler(ctx context.Context) {
534	if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
535		return
536	}
537
538	// Create keyboard handler and wire to dispatcher.
539	a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
540	a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
541		return a.kbDispatcher.Dispatch(ctx, action)
542	})
543	a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
544		a.handleModeChange(ctx, from, to)
545	})
546	a.keyboardHandler.SetShouldBypassInput(func() bool {
547		// Bypass keyboard handler when modals are visible
548		if a.sessionManager != nil && a.sessionManager.IsVisible() {
549			return true
550		}
551		if a.tabPicker != nil && a.tabPicker.IsVisible() {
552			return true
553		}
554		wsView := a.activeWorkspaceView()
555		if wsView == nil {
556			return false
557		}
558		return wsView.IsOmniboxVisible()
559	})
560	// Wire accent handler for dead keys support
561	if a.insertAccentUC != nil {
562		a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
563	}
564	a.keyboardHandler.AttachTo(a.mainWindow.Window())
565
566	// Create global shortcut handler for Alt+1-9 tab switching.
567	// This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
568	// to intercept shortcuts even when WebView has focus.
569	a.globalShortcutHandler = input.NewGlobalShortcutHandler(
570		ctx,
571		a.mainWindow.Window(),
572		a.deps.Config,
573		func(ctx context.Context, action input.Action) error {
574			return a.kbDispatcher.Dispatch(ctx, action)
575		},
576	)
577}
578
579func (a *App) initOmniboxConfig(ctx context.Context) {
580	if a.deps == nil || a.deps.Config == nil {
581		return
582	}
583
584	log := logging.FromContext(ctx)
585
586	// Convert config shortcuts to use case type
587	shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))
588	for key, shortcut := range a.deps.Config.SearchShortcuts {
589		shortcuts[key] = usecase.SearchShortcut{
590			URL:         shortcut.URL,
591			Description: shortcut.Description,
592		}
593	}
594	shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
595
596	// Create autocomplete use case for inline suggestions
597	autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC)
598
599	// Store omnibox config (omnibox is created per-pane via WorkspaceView).
600	a.omniboxCfg = component.OmniboxConfig{
601		HistoryUC:       a.deps.HistoryUC,
602		FavoritesUC:     a.deps.FavoritesUC,
603		FaviconAdapter:  a.faviconAdapter,
604		CopyURLUC:       a.deps.CopyURLUC,
605		ShortcutsUC:     shortcutsUC,
606		AutocompleteUC:  autocompleteUC,
607		DefaultSearch:   a.deps.Config.DefaultSearchEngine,
608		InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
609		UIScale:         a.deps.Config.DefaultUIScale,
610		OnNavigate: func(url string) {
611			if err := a.navCoord.Navigate(ctx, url); err != nil {
612				log.Error().Err(err).Str("url", url).Msg("navigation failed")
613			}
614		},
615		OnFocusIn: func(entry *gtk.SearchEntry) {
616			// Set omnibox entry as the focused input for accent picker
617			if a.accentFocusProvider != nil && entry != nil {
618				a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
619			}
620		},
621		OnFocusOut: func() {
622			// When omnibox loses focus, set WebView as the focused input
623			if a.accentFocusProvider != nil {
624				a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
625			}
626		},
627	}
628	a.navCoord.SetOmniboxProvider(a)
629	log.Debug().Msg("omnibox config stored, provider set")
630}
631
632func (a *App) initFindBarConfig() {
633	// Store find bar config (find bar is created per-pane via WorkspaceView).
634	a.findBarCfg = component.FindBarConfig{
635		GetFindController: func(paneID entity.PaneID) port.FindController {
636			if a.contentCoord == nil {
637				return nil
638			}
639			wv := a.contentCoord.GetWebView(paneID)
640			if wv == nil {
641				return nil
642			}
643			return wv.GetFindController()
644		},
645		OnFocusIn: func(entry *gtk.SearchEntry) {
646			// Set find bar entry as the focused input for accent picker
647			if a.accentFocusProvider != nil && entry != nil {
648				a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
649			}
650		},
651		OnFocusOut: func() {
652			// When find bar loses focus, set WebView as the focused input
653			if a.accentFocusProvider != nil {
654				a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
655			}
656		},
657	}
658}
659
660func (a *App) initSessionManager(ctx context.Context) {
661	log := logging.FromContext(ctx)
662
663	if a.deps == nil {
664		log.Debug().Msg("deps not available, skipping session manager")
665		return
666	}
667
668	// Session manager can work without a repo - it just shows an empty list
669	var listSessionsUC *usecase.ListSessionsUseCase
670	var deleteSessionUC *usecase.DeleteSessionUseCase
671	if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
672		listSessionsUC = usecase.NewListSessionsUseCase(
673			a.deps.SessionRepo,
674			a.deps.SessionStateRepo,
675			a.deps.Config.Logging.LogDir,
676		)
677		deleteSessionUC = usecase.NewDeleteSessionUseCase(
678			a.deps.SessionStateRepo,
679			a.deps.SessionRepo,
680			a.deps.Config.Logging.LogDir,
681		)
682	}
683
684	// Create session spawner for restoration
685	spawner := desktop.NewSessionSpawner(ctx)
686
687	// Create session manager component
688	a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
689		ListSessionsUC:  listSessionsUC,
690		DeleteSessionUC: deleteSessionUC,
691		CurrentSession:  a.deps.CurrentSessionID,
692		UIScale:         a.deps.Config.DefaultUIScale,
693		OnClose: func() {
694			log.Debug().Msg("session manager closed")
695		},
696		OnOpen: func(sessionID entity.SessionID) {
697			log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
698			if err := spawner.SpawnWithSession(sessionID); err != nil {
699				log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
700			}
701		},
702		OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
703			if a.appToaster != nil {
704				a.appToaster.Show(ctx, message, level)
705			}
706		},
707	})
708
709	if a.sessionManager == nil {
710		log.Warn().Msg("failed to create session manager")
711		return
712	}
713
714	// Add session manager to main window overlay
715	if a.mainWindow != nil {
716		widget := a.sessionManager.Widget()
717		if widget != nil {
718			a.mainWindow.AddOverlay(widget)
719		}
720	}
721
722	// Wire session manager action to keyboard dispatcher
723	a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
724		a.ToggleSessionManager(ctx)
725		return nil
726	})
727
728	log.Debug().Msg("session manager initialized")
729}
730
731// ToggleSessionManager shows or hides the session manager.
732func (a *App) ToggleSessionManager(ctx context.Context) {
733	if a.sessionManager == nil {
734		return
735	}
736	a.sessionManager.Toggle(ctx)
737}
738
739func (a *App) attachTabPickerToActivePane() {
740	if a.tabPicker == nil || a.widgetFactory == nil {
741		return
742	}
743	wsView := a.activeWorkspaceView()
744	if wsView == nil {
745		return
746	}
747
748	activeTab := a.tabs.ActiveTab()
749	if activeTab == nil || activeTab.Workspace == nil {
750		return
751	}
752	activePaneID := activeTab.Workspace.ActivePaneID
753	if activePaneID == "" {
754		return
755	}
756	pv := wsView.GetPaneView(activePaneID)
757	if pv == nil {
758		return
759	}
760
761	if a.tabPickerWidget == nil {
762		a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
763		if a.tabPickerWidget == nil {
764			return
765		}
766	}
767
768	// If currently attached to a different pane overlay, detach.
769	if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
770		for _, view := range a.workspaceViews {
771			if view == nil {
772				continue
773			}
774			if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
775				if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
776					oldPV.RemoveOverlayWidget(a.tabPickerWidget)
777				} else if parent != nil {
778					a.tabPickerWidget.Unparent()
779				}
780				break
781			}
782		}
783	}
784
785	// Ensure the widget can be reparented.
786	if parent := a.tabPickerWidget.GetParent(); parent != nil {
787		a.tabPickerWidget.Unparent()
788	}
789
790	a.tabPicker.SetParentOverlay(pv.Overlay())
791	pv.AddOverlayWidget(a.tabPickerWidget)
792	a.tabPickerPaneID = activePaneID
793}
794
795func (a *App) HandleMovePaneToTab(ctx context.Context) error {
796	if a.movePaneToTabUC == nil {
797		return nil
798	}
799	if a.tabPicker == nil {
800		return nil
801	}
802
803	sourceTab := a.tabs.ActiveTab()
804	if sourceTab == nil || sourceTab.Workspace == nil {
805		return nil
806	}
807
808	// If there is only 1 tab, auto-create a new tab and move there.
809	if a.tabs.Count() <= 1 {
810		return a.MoveActivePaneToTab(ctx, "")
811	}
812
813	items := make([]component.TabPickerItem, 0, a.tabs.Count())
814	for _, tab := range a.tabs.Tabs {
815		if tab == nil {
816			continue
817		}
818		if tab.ID == sourceTab.ID {
819			continue
820		}
821		items = append(items, component.TabPickerItem{
822			TabID: tab.ID,
823			Title: tab.Title(),
824			IsNew: false,
825			Index: tab.Position,
826		})
827	}
828	items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
829
830	a.attachTabPickerToActivePane()
831	a.tabPicker.Show(ctx, items)
832	return nil
833}
834
835func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
836	if a.movePaneToTabUC == nil {
837		return nil
838	}
839	active := a.tabs.ActiveTab()
840	if active == nil {
841		return nil
842	}
843
844	nextPos := active.Position + 1
845	if nextPos >= a.tabs.Count() {
846		// Create a new tab on the right.
847		return a.MoveActivePaneToTab(ctx, "")
848	}
849	next := a.tabs.TabAt(nextPos)
850	if next == nil {
851		return nil
852	}
853	return a.MoveActivePaneToTab(ctx, next.ID)
854}
855
856func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
857	in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
858	if in == nil {
859		return nil
860	}
861
862	out, err := a.movePaneToTabUC.Execute(*in)
863	if err != nil {
864		return err
865	}
866	if out == nil || out.TargetTab == nil {
867		return nil
868	}
869
870	a.applyMovePaneToTabUI(ctx, out, sourceTab)
871	a.switchToTargetTabIfConfigured(ctx, out)
872	a.updateTabBarVisibility(ctx)
873	a.MarkDirty()
874	return nil
875}
876
877func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
878	if a.movePaneToTabUC == nil {
879		return nil, nil
880	}
881	activeTab := a.tabs.ActiveTab()
882	if activeTab == nil || activeTab.Workspace == nil {
883		return nil, nil
884	}
885	sourcePaneID := activeTab.Workspace.ActivePaneID
886	if sourcePaneID == "" {
887		return nil, nil
888	}
889
890	in := &usecase.MovePaneToTabInput{
891		TabList:      a.tabs,
892		SourceTabID:  activeTab.ID,
893		SourcePaneID: sourcePaneID,
894		TargetTabID:  targetTabID,
895	}
896	return in, activeTab
897}
898
899func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
900	if out == nil || out.TargetTab == nil {
901		return
902	}
903	sourceTabID := entity.TabID("")
904	if sourceTab != nil {
905		sourceTabID = sourceTab.ID
906	}
907
908	if out.SourceTabClosed {
909		a.removeSourceTabUI(sourceTabID)
910	}
911	if out.NewTabCreated {
912		a.ensureTargetTabUI(ctx, out.TargetTab)
913	}
914
915	if !out.SourceTabClosed {
916		a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
917	}
918	a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
919}
920
921func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
922	delete(a.workspaceViews, sourceTabID)
923	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
924		a.mainWindow.TabBar().RemoveTab(sourceTabID)
925	}
926}
927
928func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
929	if tab == nil {
930		return
931	}
932	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
933		a.mainWindow.TabBar().AddTab(tab)
934	}
935	a.createWorkspaceViewWithoutAttach(ctx, tab)
936}
937
938func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
939	wsView := a.workspaceViews[tabID]
940	if wsView == nil {
941		return
942	}
943	_ = wsView.Rebuild(ctx)
944
945	if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
946		return
947	}
948	a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
949	if a.wsCoord != nil {
950		a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
951	}
952}
953
954func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
955	if out == nil || out.TargetTab == nil {
956		return
957	}
958
959	switchToTarget := config.Get().Workspace.SwitchToTabOnMove
960	if out.SourceTabClosed {
961		switchToTarget = true
962	}
963	if !switchToTarget {
964		return
965	}
966
967	a.tabs.SetActive(out.TargetTab.ID)
968	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
969		a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
970	}
971	a.switchWorkspaceView(ctx, out.TargetTab.ID)
972}
973
974func (a *App) updateTabBarVisibility(ctx context.Context) {
975	if a.tabCoord != nil {
976		a.tabCoord.UpdateBarVisibility(ctx)
977	}
978}
979
980func (a *App) initTabPicker(ctx context.Context) {
981	log := logging.FromContext(ctx)
982
983	if a.deps == nil || a.deps.Config == nil {
984		log.Debug().Msg("deps/config not available, skipping tab picker")
985		return
986	}
987
988	a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
989		UIScale: a.deps.Config.DefaultUIScale,
990		OnClose: func() {
991			log.Debug().Msg("tab picker closed")
992		},
993		OnSelect: func(item component.TabPickerItem) {
994			cb := glib.SourceFunc(func(_ uintptr) bool {
995				var targetID entity.TabID
996				if !item.IsNew {
997					targetID = item.TabID
998				}
999				if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
1000					log.Warn().Err(err).Msg("move pane to tab failed")
1001				}
1002				return false
1003			})
1004			glib.IdleAdd(&cb, 0)
1005		},
1006	})
1007
1008	if a.tabPicker == nil {
1009		log.Warn().Msg("failed to create tab picker")
1010		return
1011	}
1012
1013	log.Debug().Msg("tab picker initialized")
1014}
1015
1016func (a *App) initSnapshotService(ctx context.Context) {
1017	log := logging.FromContext(ctx)
1018
1019	if a.deps == nil || a.deps.SnapshotUC == nil {
1020		log.Debug().Msg("snapshot use case not available, skipping snapshot service")
1021		return
1022	}
1023
1024	intervalMs := 5000 // default
1025	if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
1026		intervalMs = a.deps.Config.Session.SnapshotIntervalMs
1027	}
1028
1029	a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
1030	a.snapshotService.Start(ctx)
1031
1032	// Set up callback for main.go to notify when session is persisted
1033	a.deps.OnSessionPersisted = func() {
1034		a.snapshotService.SetReady()
1035	}
1036
1037	log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
1038}
1039
1040func (a *App) initUpdateCoordinator(ctx context.Context) {
1041	log := logging.FromContext(ctx)
1042
1043	if a.deps == nil || a.deps.CheckUpdateUC == nil {
1044		log.Debug().Msg("update use cases not available, skipping update coordinator")
1045		return
1046	}
1047
1048	a.updateCoord = coordinator.NewUpdateCoordinator(
1049		a.deps.CheckUpdateUC,
1050		a.deps.ApplyUpdateUC,
1051		a.appToaster,
1052		a.deps.Config,
1053	)
1054
1055	// Start async update check
1056	a.updateCoord.CheckOnStartup(ctx)
1057
1058	log.Debug().Msg("update coordinator initialized")
1059}
1060
1061// GetTabList implements port.TabListProvider.
1062func (a *App) GetTabList() *entity.TabList {
1063	return a.tabs
1064}
1065
1066// GetSessionID implements port.TabListProvider.
1067func (a *App) GetSessionID() entity.SessionID {
1068	if a.deps == nil {
1069		return ""
1070	}
1071	return a.deps.CurrentSessionID
1072}
1073
1074// MarkDirty signals that session state has changed and should be saved.
1075func (a *App) MarkDirty() {
1076	if a.snapshotService != nil {
1077		a.snapshotService.MarkDirty()
1078	}
1079}
1080
1081func (a *App) createInitialTab(ctx context.Context) {
1082	log := logging.FromContext(ctx)
1083
1084	// Check if we should restore a session
1085	if a.deps != nil && a.deps.RestoreSessionID != "" {
1086		if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
1087			log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
1088			// Show error toast and fall through to create default tab
1089			if a.appToaster != nil {
1090				a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1091			}
1092		} else {
1093			log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
1094			// Show success toast for session restoration
1095			if a.appToaster != nil {
1096				a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1097			}
1098			return
1099		}
1100	}
1101
1102	// Create an initial tab using coordinator.
1103	initialURL := "dumb://home"
1104	if a.deps != nil && a.deps.InitialURL != "" {
1105		initialURL = a.deps.InitialURL
1106	}
1107	if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
1108		log.Error().Err(err).Msg("failed to create initial tab")
1109	}
1110}
1111
1112func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1113	log := logging.FromContext(ctx)
1114
1115	if a.deps == nil || a.deps.SessionStateRepo == nil {
1116		return fmt.Errorf("session state repo not available")
1117	}
1118
1119	// Load session state
1120	state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
1121	if err != nil {
1122		return fmt.Errorf("load session state: %w", err)
1123	}
1124	if state == nil {
1125		return fmt.Errorf("session state not found")
1126	}
1127
1128	log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
1129
1130	// Build complete TabList from snapshot (state-first approach)
1131	// This reconstructs the full pane tree with new IDs
1132	restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
1133	if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
1134		return fmt.Errorf("failed to build tab list from snapshot")
1135	}
1136
1137	// Replace tabs in-place to preserve references held by TabCoordinator
1138	a.tabs.ReplaceFrom(restoredTabs)
1139
1140	// Create UI for each restored tab and add to tab bar
1141	// Don't attach to content area yet - only the active tab should be attached
1142	tabBar := a.mainWindow.TabBar()
1143	for _, tab := range a.tabs.Tabs {
1144		a.createWorkspaceViewWithoutAttach(ctx, tab)
1145		if tabBar != nil {
1146			tabBar.AddTab(tab)
1147		}
1148		log.Debug().
1149			Str("tab_id", string(tab.ID)).
1150			Str("name", tab.Name).
1151			Int("panes", tab.Workspace.PaneCount()).
1152			Msg("restored tab with workspace")
1153	}
1154
1155	// Switch to active tab - this attaches the active workspace view to content area
1156	activeTab := a.tabs.ActiveTab()
1157	if activeTab != nil {
1158		a.switchWorkspaceView(ctx, activeTab.ID)
1159		if tabBar != nil {
1160			tabBar.SetActive(activeTab.ID)
1161		}
1162	}
1163
1164	return nil
1165}
1166
1167func (a *App) finalizeActivation(ctx context.Context) {
1168	log := logging.FromContext(ctx)
1169
1170	// Show the window
1171	if a.mainWindow != nil {
1172		a.mainWindow.Show()
1173	}
1174	log.Info().Msg("main window displayed")
1175
1176	// Defer non-critical initialization until after first navigation starts.
1177	// This keeps pool prewarm, config watcher, and filter loading from
1178	// competing with the initial page load.
1179	a.runAfterFirstLoadStarted(func() {
1180		a.prewarmWebViewPoolAsync(ctx)
1181		a.initConfigWatcher(ctx)
1182		a.initFilteringAsync(ctx)
1183		a.checkConfigMigration(ctx)
1184	})
1185}
1186
1187// runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1188// This keeps the GTK main loop free to process the initial load_uri() quickly,
1189// reducing the gap between webview_attached and load_started.
1190func (a *App) runAfterFirstLoadStarted(fn func()) {
1191	a.deferredInitFn = fn
1192}
1193
1194// triggerDeferredInit is called from ContentCoordinator on first load_started.
1195// It runs deferred initialization at LOW priority so navigation continues unblocked.
1196func (a *App) triggerDeferredInit(ctx context.Context) {
1197	a.deferredInitOnce.Do(func() {
1198		if a.deferredInitFn == nil {
1199			return
1200		}
1201		log := logging.FromContext(ctx)
1202		log.Debug().Msg("triggering deferred initialization")
1203
1204		fn := a.deferredInitFn
1205		cb := glib.SourceFunc(func(_ uintptr) bool {
1206			fn()
1207			return false
1208		})
1209		// Use LOW priority so navigation processing continues first
1210		glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
1211	})
1212}
1213
1214// onShutdown is called when the GTK application is shutting down.
1215func (a *App) onShutdown(ctx context.Context) {
1216	log := logging.FromContext(ctx)
1217	log.Debug().Msg("GTK application shutting down")
1218
1219	// Save final session state before shutdown
1220	if a.snapshotService != nil {
1221		if err := a.snapshotService.Stop(ctx); err != nil {
1222			log.Warn().Err(err).Msg("failed to save final session state")
1223		}
1224	}
1225
1226	// Apply staged update if available (before cleanup)
1227	if a.updateCoord != nil {
1228		if err := a.updateCoord.FinalizeOnExit(ctx); err != nil {
1229			log.Warn().Err(err).Msg("failed to apply staged update")
1230		}
1231	}
1232
1233	// Cancel context to signal all goroutines
1234	a.cancel(errors.New("application shutdown"))
1235
1236	// Cleanup resources
1237	if a.faviconAdapter != nil {
1238		a.faviconAdapter.Close()
1239	}
1240	if a.deps.Pool != nil {
1241		a.deps.Pool.Close(ctx)
1242	}
1243	// Close idle inhibitor to release D-Bus connection
1244	if a.deps.IdleInhibitor != nil {
1245		if err := a.deps.IdleInhibitor.Close(); err != nil {
1246			log.Warn().Err(err).Msg("failed to close idle inhibitor")
1247		}
1248	}
1249
1250	log.Info().Msg("application shutdown complete")
1251}
1252
1253// initCoordinators initializes all coordinators and wires their callbacks.
1254func (a *App) initCoordinators(ctx context.Context) {
1255	log := logging.FromContext(ctx)
1256	log.Debug().Msg("initializing coordinators")
1257
1258	// Helper to get active workspace and view
1259	getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
1260		return a.activeWorkspace(), a.activeWorkspaceView()
1261	}
1262
1263	// Create FaviconAdapter with service and WebKit FaviconDatabase
1264	var faviconDB *webkit.FaviconDatabase
1265	if a.deps.WebContext != nil {
1266		faviconDB = a.deps.WebContext.FaviconDatabase()
1267	}
1268	a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
1269
1270	// 1. Content Coordinator (no dependencies on other coordinators)
1271	a.contentCoord = coordinator.NewContentCoordinator(
1272		ctx,
1273		a.pool,
1274		a.widgetFactory,
1275		a.faviconAdapter,
1276		getActiveWS,
1277		a.deps.ZoomUC,
1278	)
1279
1280	// Set idle inhibitor for fullscreen video playback
1281	if a.deps.IdleInhibitor != nil {
1282		a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
1283	}
1284
1285	// Wire deferred init trigger - runs after first navigation starts
1286	a.contentCoord.SetOnFirstLoadStarted(func() {
1287		a.triggerDeferredInit(ctx)
1288	})
1289
1290	// 2. Tab Coordinator
1291	a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1292		TabsUC:     a.tabsUC,
1293		Tabs:       a.tabs,
1294		MainWindow: a.mainWindow,
1295		Config:     a.deps.Config,
1296	})
1297	a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
1298		a.createWorkspaceView(ctx, tab)
1299	})
1300	a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
1301		a.switchWorkspaceView(ctx, tab.ID)
1302	})
1303	a.tabCoord.SetOnQuit(a.Quit)
1304	a.tabCoord.SetOnStateChanged(a.MarkDirty)
1305	// Wire popup tab WebView attachment
1306	a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1307		a.attachPopupToTab(ctx, tabID, pane, wv)
1308	})
1309
1310	// Wire tab bar click handling to coordinator
1311	if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1312		a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1313			if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1314				log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
1315			}
1316		})
1317	}
1318
1319	// Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1320	a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1321		if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1322			return
1323		}
1324		if entering {
1325			// Hide tab bar when entering fullscreen video
1326			a.mainWindow.TabBar().SetVisible(false)
1327		} else {
1328			// Restore tab bar visibility based on normal logic
1329			a.tabCoord.UpdateBarVisibility(ctx)
1330		}
1331	})
1332
1333	// 3. Workspace Coordinator
1334	a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1335		PanesUC:        a.panesUC,
1336		FocusMgr:       a.focusMgr,
1337		StackedPaneMgr: a.stackedPaneMgr,
1338		WidgetFactory:  a.widgetFactory,
1339		ContentCoord:   a.contentCoord,
1340		GetActiveWS:    getActiveWS,
1341		GenerateID:     a.generateID,
1342	})
1343	a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
1344		return a.tabCoord.Close(ctx)
1345	})
1346	a.wsCoord.SetOnStateChanged(a.MarkDirty)
1347
1348	// Wire popup handling
1349	a.webViewFactory = webkit.NewWebViewFactory(
1350		a.deps.WebContext,
1351		a.settings,
1352		a.pool,
1353		a.injector,
1354		a.router,
1355	)
1356	// Set theme background color on factory to eliminate white flash
1357	if a.deps.Theme != nil {
1358		r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
1359		a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
1360	}
1361	a.contentCoord.SetPopupConfig(
1362		a.webViewFactory,
1363		&a.deps.Config.Workspace.Popups,
1364		a.generateID,
1365	)
1366	a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1367		return a.wsCoord.InsertPopup(ctx, input)
1368	})
1369	a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
1370		return a.wsCoord.ClosePaneByID(ctx, paneID)
1371	})
1372	// Wire tabbed popup behavior to create new tabs
1373	a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1374		// Create a new tab with the popup pane
1375		tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
1376		if err != nil {
1377			return err
1378		}
1379		log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
1380		return nil
1381	})
1382
1383	// Move pane use case (cross-tab)
1384	a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1385
1386	// 4. Navigation Coordinator
1387	a.navCoord = coordinator.NewNavigationCoordinator(
1388		ctx,
1389		a.deps.NavigateUC,
1390		a.contentCoord,
1391	)
1392
1393	// Wire title updates to history persistence
1394	a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
1395		a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
1396	})
1397
1398	// Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
1399	a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
1400		a.navCoord.RecordHistory(ctx, paneID, url)
1401	})
1402
1403	// Wire window title updates when active pane's title changes
1404	a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1405		a.updateWindowTitle(title)
1406	})
1407
1408	// Wire pane URI updates for session snapshots (searches all tabs)
1409	a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
1410		a.updatePaneURIInAllTabs(paneID, url)
1411		// Mark dirty so snapshot captures the new URI
1412		a.MarkDirty()
1413	})
1414
1415	// Hide loading skeleton once the WebView paints
1416	a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
1417		// The WebView can be shown while its pane is in a background tab, so scan
1418		// all WorkspaceViews rather than only the active one.
1419		for _, wsView := range a.workspaceViews {
1420			if wsView == nil {
1421				continue
1422			}
1423			if pv := wsView.GetPaneView(paneID); pv != nil {
1424				pv.HideLoadingSkeleton()
1425			}
1426		}
1427		if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1428			a.firstWebViewShownOnce.Do(func() {
1429				a.deps.OnFirstWebViewShown(ctx)
1430			})
1431		}
1432
1433		// Set the WebView as the focused input for accent picker (if this is the active pane)
1434		ws := a.activeWorkspace()
1435		if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1436			a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1437		}
1438	})
1439
1440	// 5. Keyboard Dispatcher
1441	a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
1442		ctx,
1443		a.tabCoord,
1444		a.wsCoord,
1445		a.navCoord,
1446		a.deps.ZoomUC,
1447		a.deps.CopyURLUC,
1448	)
1449	a.kbDispatcher.SetOnQuit(a.Quit)
1450	a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
1451		a.ToggleFindBar(ctx)
1452		return nil
1453	})
1454	a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
1455		a.FindNext(ctx)
1456		return nil
1457	})
1458	a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
1459		a.FindPrevious(ctx)
1460		return nil
1461	})
1462	a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
1463		a.CloseFindBar(ctx)
1464		return nil
1465	})
1466	a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1467		return a.HandleMovePaneToTab(ctx)
1468	})
1469	a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1470		return a.HandleMovePaneToNextTab(ctx)
1471	})
1472
1473	// Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
1474	a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
1475		return a.kbDispatcher.Dispatch(ctx, action)
1476	})
1477
1478	log.Debug().Msg("coordinators initialized")
1479}
1480
1481// generateID generates a unique ID for tabs and panes.
1482// Uses monotonically increasing counter to avoid ID collisions.
1483func (a *App) generateID() string {
1484	a.idMu.Lock()
1485	defer a.idMu.Unlock()
1486	a.idCounter++
1487	return fmt.Sprintf("p%d", a.idCounter)
1488}
1489
1490// Tabs returns the tab list.
1491func (a *App) Tabs() *entity.TabList {
1492	return a.tabs
1493}
1494
1495// MainWindow returns the main window.
1496func (a *App) MainWindow() *window.MainWindow {
1497	return a.mainWindow
1498}
1499
1500// Quit requests the application to quit.
1501func (a *App) Quit() {
1502	if a.gtkApp != nil {
1503		a.gtkApp.Quit()
1504	}
1505}
1506
1507// RunWithArgs is a convenience function that creates and runs an App.
1508func RunWithArgs(ctx context.Context, deps *Dependencies) int {
1509	app, err := New(deps)
1510	if err != nil {
1511		log := logging.FromContext(ctx)
1512		log.Error().Err(err).Msg("failed to create application")
1513		return 1
1514	}
1515	return app.Run(ctx, os.Args)
1516}
1517
1518// updateWindowTitle updates the window title with the given page title.
1519// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
1520func (a *App) updateWindowTitle(pageTitle string) {
1521	if a.mainWindow == nil {
1522		return
1523	}
1524
1525	title := "Dumber"
1526	if pageTitle != "" {
1527		title = pageTitle + " - Dumber"
1528	}
1529	a.mainWindow.SetTitle(title)
1530}
1531
1532// updateWindowTitleFromActivePane updates the window title based on the current active pane.
1533func (a *App) updateWindowTitleFromActivePane() {
1534	ws := a.activeWorkspace()
1535	if ws == nil || a.contentCoord == nil {
1536		a.updateWindowTitle("")
1537		return
1538	}
1539	title := a.contentCoord.GetTitle(ws.ActivePaneID)
1540	a.updateWindowTitle(title)
1541}
1542
1543// handleModeChange is called when the input mode changes.
1544func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
1545	log := logging.FromContext(ctx)
1546	log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
1547
1548	if from == input.ModeResize && to != input.ModeResize {
1549		a.clearResizeModeBorder()
1550	}
1551	if to == input.ModeResize {
1552		a.applyResizeModeBorder(ctx, a.activeWorkspace())
1553	}
1554
1555	// Update global border overlay visibility based on mode.
1556	// Note: resize mode border is handled per-pane (stack container), not via global overlay.
1557	if a.borderMgr != nil {
1558		a.borderMgr.OnModeChange(ctx, from, to)
1559	}
1560
1561	// Show/hide mode indicator toaster based on config.
1562	a.updateModeIndicatorToaster(ctx, to)
1563}
1564
1565// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1566func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1567	if a.modeToaster == nil {
1568		return
1569	}
1570
1571	// Check if mode indicator toaster is enabled in config.
1572	if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1573		a.modeToaster.Hide()
1574		return
1575	}
1576
1577	if mode == input.ModeNormal {
1578		a.modeToaster.Hide()
1579		return
1580	}
1581
1582	// Show persistent toaster at bottom-left with mode display name.
1583	// Mode class is applied atomically with Show() to avoid visual flicker.
1584	modeClass := getModeToastClass(mode)
1585	a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1586		component.WithDuration(0), // Persistent until mode exits.
1587		component.WithPosition(component.ToastPositionBottomLeft),
1588		component.WithModeClass(modeClass),
1589	)
1590}
1591
1592// getModeToastClass returns the CSS class for the given mode's toast styling.
1593func getModeToastClass(mode input.Mode) string {
1594	switch mode {
1595	case input.ModePane:
1596		return "toast-pane-mode"
1597	case input.ModeTab:
1598		return "toast-tab-mode"
1599	case input.ModeSession:
1600		return "toast-session-mode"
1601	case input.ModeResize:
1602		return "toast-resize-mode"
1603	default:
1604		return ""
1605	}
1606}
1607
1608func (a *App) clearResizeModeBorder() {
1609	if a.resizeModeBorderTarget != nil {
1610		a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1611		a.resizeModeBorderTarget = nil
1612	}
1613}
1614
1615func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
1616	wsView := a.activeWorkspaceView()
1617	if wsView == nil || ws == nil {
1618		a.clearResizeModeBorder()
1619		return
1620	}
1621
1622	paneID := ws.ActivePaneID
1623	if paneID == "" {
1624		a.clearResizeModeBorder()
1625		return
1626	}
1627
1628	// Important: wrap the whole stack container when the active pane is in a stack.
1629	target := wsView.GetStackContainerWidget(paneID)
1630	if target == nil {
1631		a.clearResizeModeBorder()
1632		return
1633	}
1634
1635	if !target.HasCssClass("resize-mode-active") {
1636		target.AddCssClass("resize-mode-active")
1637	}
1638
1639	if a.resizeModeBorderTarget != target {
1640		if a.resizeModeBorderTarget != nil {
1641			a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1642		} else {
1643			logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
1644		}
1645		a.resizeModeBorderTarget = target
1646	}
1647}
1648
1649// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
1650func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
1651	a.createWorkspaceViewWithoutAttach(ctx, tab)
1652
1653	// Attach to content area
1654	wsView := a.workspaceViews[tab.ID]
1655	if wsView != nil && a.mainWindow != nil {
1656		widget := wsView.Widget()
1657		if widget != nil {
1658			gtkWidget := widget.GtkWidget()
1659			if gtkWidget != nil {
1660				a.mainWindow.SetContent(gtkWidget)
1661			}
1662		}
1663	}
1664}
1665
1666// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
1667// Used during session restoration where we create all views first, then attach only the active one.
1668func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
1669	log := logging.FromContext(ctx)
1670
1671	if a.widgetFactory == nil {
1672		log.Error().Msg("widget factory not initialized")
1673		return
1674	}
1675
1676	// Create workspace view
1677	wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
1678	if wsView == nil {
1679		log.Error().Msg("failed to create workspace view")
1680		return
1681	}
1682
1683	// Set the workspace
1684	if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
1685		log.Error().Err(err).Msg("failed to set workspace in view")
1686		return
1687	}
1688
1689	// Ensure WebViews are attached to panes
1690	if a.contentCoord != nil {
1691		a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1692	}
1693
1694	// Note: Mode borders for tab/pane/session are attached to MainWindow.
1695	// Resize mode border is attached to the active pane's stack container.
1696
1697	// Set omnibox config for this workspace view
1698	wsView.SetOmniboxConfig(a.omniboxCfg)
1699	// Set find bar config for this workspace view
1700	wsView.SetFindBarConfig(a.findBarCfg)
1701	// Set auto-open omnibox on new pane
1702	wsView.SetAutoOpenOnNewPane(config.Get().Omnibox.AutoOpenOnNewPane)
1703
1704	wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1705		if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
1706			ws := a.activeWorkspace()
1707			if ws != nil {
1708				ws.ActivePaneID = paneID
1709			}
1710			a.applyResizeModeBorder(ctx, ws)
1711		}
1712	})
1713
1714	wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
1715		if a.wsCoord != nil {
1716			_ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
1717		}
1718	})
1719
1720	// Store in map
1721	a.workspaceViews[tab.ID] = wsView
1722
1723	log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
1724}
1725
1726// activeWorkspace returns the workspace of the active tab.
1727func (a *App) activeWorkspace() *entity.Workspace {
1728	activeTab := a.tabs.ActiveTab()
1729	if activeTab == nil {
1730		return nil
1731	}
1732	return activeTab.Workspace
1733}
1734
1735// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
1736// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
1737func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
1738	for _, tab := range a.tabs.Tabs {
1739		if tab.Workspace == nil {
1740			continue
1741		}
1742		paneNode := tab.Workspace.FindPane(paneID)
1743		if paneNode != nil && paneNode.Pane != nil {
1744			paneNode.Pane.URI = url
1745			return // Pane IDs are unique, no need to continue
1746		}
1747	}
1748}
1749
1750// activeWorkspaceView returns the workspace view for the active tab.
1751func (a *App) activeWorkspaceView() *component.WorkspaceView {
1752	activeTab := a.tabs.ActiveTab()
1753	if activeTab == nil {
1754		return nil
1755	}
1756	return a.workspaceViews[activeTab.ID]
1757}
1758
1759// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
1760// Used by the accent picker to insert accented characters into web content.
1761func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1762	ws := a.activeWorkspace()
1763	if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1764		return nil
1765	}
1766
1767	wv := a.contentCoord.GetWebView(ws.ActivePaneID)
1768	if wv == nil {
1769		return nil
1770	}
1771
1772	// Get the underlying webkit.WebView for the text input target
1773	return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
1774}
1775
1776// attachPopupToTab attaches a popup WebView to a newly created tab.
1777// This is called when a popup uses tabbed behavior.
1778func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1779	log := logging.FromContext(ctx)
1780
1781	wsView := a.workspaceViews[tabID]
1782	if wsView == nil {
1783		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
1784		return
1785	}
1786
1787	// Register WebView with content coordinator
1788	if a.contentCoord != nil {
1789		// Track the WebView
1790		a.contentCoord.RegisterPopupWebView(pane.ID, wv)
1791
1792		// Wrap and attach widget
1793		widget := a.contentCoord.WrapWidget(ctx, wv)
1794		if widget != nil {
1795			paneView := wsView.GetPaneView(pane.ID)
1796			if paneView != nil {
1797				paneView.SetWebViewWidget(widget)
1798			} else {
1799				log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
1800			}
1801		}
1802	}
1803	log.Debug().
1804		Str("tab_id", string(tabID)).
1805		Str("pane_id", string(pane.ID)).
1806		Msg("popup webview attached to tab")
1807}
1808
1809// switchWorkspaceView swaps the displayed workspace view for a tab.
1810func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
1811	log := logging.FromContext(ctx)
1812
1813	wsView, exists := a.workspaceViews[tabID]
1814	if !exists {
1815		log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
1816		return
1817	}
1818
1819	// Get the workspace view's root widget
1820	widget := wsView.Widget()
1821	if widget == nil {
1822		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
1823		return
1824	}
1825
1826	gtkWidget := widget.GtkWidget()
1827	if gtkWidget == nil {
1828		log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
1829		return
1830	}
1831
1832	// Swap content (MainWindow.SetContent now properly removes old content)
1833	if a.mainWindow != nil {
1834		a.mainWindow.SetContent(gtkWidget)
1835	}
1836
1837	// Update window title with the new active pane's title
1838	a.updateWindowTitleFromActivePane()
1839
1840	log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
1841}
1842
1843// ToggleOmnibox implements OmniboxProvider.
1844// Toggles the omnibox visibility in the active workspace view.
1845func (a *App) ToggleOmnibox(ctx context.Context) {
1846	log := logging.FromContext(ctx)
1847
1848	wsView := a.activeWorkspaceView()
1849	if wsView == nil {
1850		log.Warn().Msg("no active workspace view for omnibox toggle")
1851		return
1852	}
1853
1854	if wsView.IsOmniboxVisible() {
1855		wsView.HideOmnibox()
1856	} else {
1857		wsView.ShowOmnibox(ctx, "")
1858	}
1859}
1860
1861// ToggleFindBar shows or hides the find bar in the active workspace view.
1862func (a *App) ToggleFindBar(ctx context.Context) {
1863	log := logging.FromContext(ctx)
1864
1865	wsView := a.activeWorkspaceView()
1866	if wsView == nil {
1867		log.Warn().Msg("no active workspace view for find bar toggle")
1868		return
1869	}
1870
1871	if wsView.IsFindBarVisible() {
1872		wsView.HideFindBar()
1873	} else {
1874		wsView.ShowFindBar(ctx)
1875	}
1876}
1877
1878// FindNext selects the next match in the active find bar.
1879func (a *App) FindNext(ctx context.Context) {
1880	wsView := a.activeWorkspaceView()
1881	if wsView == nil {
1882		return
1883	}
1884
1885	if !wsView.IsFindBarVisible() {
1886		wsView.ShowFindBar(ctx)
1887	}
1888	wsView.FindNext()
1889}
1890
1891// FindPrevious selects the previous match in the active find bar.
1892func (a *App) FindPrevious(ctx context.Context) {
1893	wsView := a.activeWorkspaceView()
1894	if wsView == nil {
1895		return
1896	}
1897
1898	if !wsView.IsFindBarVisible() {
1899		wsView.ShowFindBar(ctx)
1900	}
1901	wsView.FindPrevious()
1902}
1903
1904// CloseFindBar hides the find bar if visible.
1905func (a *App) CloseFindBar(ctx context.Context) {
1906	wsView := a.activeWorkspaceView()
1907	if wsView == nil {
1908		return
1909	}
1910	wsView.HideFindBar()
1911}
1912
1913// UpdateOmniboxZoom implements OmniboxProvider.
1914// Updates the zoom indicator on the current omnibox if visible.
1915func (a *App) UpdateOmniboxZoom(factor float64) {
1916	wsView := a.activeWorkspaceView()
1917	if wsView == nil {
1918		return
1919	}
1920
1921	omnibox := wsView.GetOmnibox()
1922	if omnibox != nil {
1923		omnibox.UpdateZoomIndicator(factor)
1924	}
1925}
1926
1927// SetOmniboxOnNavigate implements OmniboxProvider.
1928// This is called to set the navigation callback on new omniboxes.
1929// Since omniboxes are created per-pane, we store the config with the callback.
1930func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
1931	// The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox
1932	// The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
1933}
1934
1935// initFilteringAsync starts background filter loading with toast feedback.
1936func (a *App) initConfigWatcher(ctx context.Context) {
1937	log := logging.FromContext(ctx)
1938
1939	if a.configManager == nil {
1940		log.Debug().Msg("no config manager available, skipping watcher")
1941		return
1942	}
1943
1944	// Start viper watcher
1945	if err := a.configManager.Watch(); err != nil {
1946		log.Warn().Err(err).Msg("failed to start config watcher")
1947		return
1948	}
1949
1950	// Hot-reload appearance and keybindings on config change.
1951	a.configManager.OnConfigChange(func(newCfg *config.Config) {
1952		cfgCopy := newCfg
1953		cb := glib.SourceFunc(func(_ uintptr) bool {
1954			a.applyAppearanceConfig(ctx, cfgCopy)
1955			// Reload keyboard shortcuts
1956			if a.keyboardHandler != nil {
1957				a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy)
1958			}
1959			return false
1960		})
1961		glib.IdleAdd(&cb, 0)
1962	})
1963
1964	log.Debug().Msg("config watcher initialized")
1965}
1966
1967func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
1968	log := logging.FromContext(ctx)
1969	if cfg == nil {
1970		return
1971	}
1972
1973	// Update the shared config pointer in-place so existing references see changes.
1974	if a.deps != nil && a.deps.Config != nil {
1975		*a.deps.Config = *cfg
1976	}
1977
1978	// Update WebKit settings manager
1979	if a.settings != nil {
1980		a.settings.UpdateFromConfig(ctx, cfg)
1981	}
1982
1983	// Apply settings to existing webviews via coordinator
1984	if a.contentCoord != nil {
1985		a.contentCoord.ApplySettingsToAll(ctx, a.settings)
1986	}
1987
1988	// Update GTK theme and injected WebUI theme vars
1989	if a.deps != nil && a.deps.Theme != nil {
1990		var display *gdk.Display
1991		if a.mainWindow != nil && a.mainWindow.Window() != nil {
1992			display = a.mainWindow.Window().GetDisplay()
1993		}
1994		a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
1995
1996		// Update find highlight CSS for future navigations
1997		if a.injector != nil {
1998			findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
1999			if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
2000				log.Warn().Err(err).Msg("failed to update find highlight CSS")
2001			}
2002		}
2003
2004		prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
2005		cssText := a.deps.Theme.GetWebUIThemeCSS()
2006		if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
2007			log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
2008		}
2009
2010		// Refresh injected scripts so future navigations use the latest theme.
2011		if a.contentCoord != nil && a.injector != nil {
2012			a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
2013		}
2014
2015		// Apply WebUI theme to already-loaded dumb:// pages
2016		if a.contentCoord != nil {
2017			a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
2018		}
2019	}
2020
2021	log.Info().Msg("appearance config updated")
2022}
2023
2024func (a *App) initFilteringAsync(ctx context.Context) {
2025	if a.deps.FilterManager == nil {
2026		return
2027	}
2028
2029	a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
2030		statusCopy := status // Capture for closure
2031		cb := glib.SourceFunc(func(_ uintptr) bool {
2032			a.showFilterStatus(ctx, statusCopy)
2033			return false // Don't repeat
2034		})
2035		glib.IdleAdd(&cb, 0)
2036	})
2037	a.deps.FilterManager.LoadAsync(ctx)
2038}
2039
2040// showFilterStatus displays toast notification for filter status.
2041func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
2042	log := logging.FromContext(ctx)
2043
2044	switch status.State {
2045	case filtering.StateLoading:
2046		if a.appToaster != nil {
2047			a.appToaster.Show(ctx, status.Message, component.ToastInfo)
2048		}
2049	case filtering.StateActive:
2050		// Apply filters to existing webviews that were created before filters loaded
2051		if a.contentCoord != nil && a.deps.FilterManager != nil {
2052			a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
2053			log.Debug().Msg("applied filters to all existing webviews")
2054		}
2055		if a.appToaster != nil {
2056			a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
2057		}
2058	case filtering.StateError:
2059		if a.appToaster != nil {
2060			a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
2061		}
2062	}
2063}

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