v0.24.0 a261e52

app.go RawHistoryFolder

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

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