v0.25.0 2e6c485

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

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