v0.26.0 ae3a77a

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

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