v0.23.1 6c872d5

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

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