v0.22.0 a85da87

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

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