1package ui
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "unsafe"
12
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/domain/entity"
16 urlutil "github.com/bnema/dumber/internal/domain/url"
17 "github.com/bnema/dumber/internal/infrastructure/config"
18 "github.com/bnema/dumber/internal/infrastructure/desktop"
19 "github.com/bnema/dumber/internal/infrastructure/filesystem"
20 "github.com/bnema/dumber/internal/infrastructure/filtering"
21 "github.com/bnema/dumber/internal/infrastructure/snapshot"
22 "github.com/bnema/dumber/internal/infrastructure/textinput"
23 "github.com/bnema/dumber/internal/infrastructure/webkit"
24
25 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
26 "github.com/bnema/dumber/internal/logging"
27 "github.com/bnema/dumber/internal/ui/adapter"
28 "github.com/bnema/dumber/internal/ui/component"
29 "github.com/bnema/dumber/internal/ui/coordinator"
30 "github.com/bnema/dumber/internal/ui/dialog"
31 "github.com/bnema/dumber/internal/ui/dispatcher"
32 "github.com/bnema/dumber/internal/ui/focus"
33 "github.com/bnema/dumber/internal/ui/input"
34 "github.com/bnema/dumber/internal/ui/layout"
35 "github.com/bnema/dumber/internal/ui/theme"
36 "github.com/bnema/dumber/internal/ui/window"
37 "github.com/jwijenbergh/puregotk/v4/adw"
38 "github.com/jwijenbergh/puregotk/v4/gdk"
39 "github.com/jwijenbergh/puregotk/v4/gio"
40 "github.com/jwijenbergh/puregotk/v4/glib"
41 "github.com/jwijenbergh/puregotk/v4/gtk"
42)
43
44const (
45 // AppID is the application identifier for GTK.
46 AppID = "com.github.bnema.dumber"
47 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
48 crashReportToastDurationMs = 5000
49 floatingPaneFallbackWidth = 1200
50 floatingPaneFallbackHeight = 800
51 floatingPaneIDPrefix = "floating-pane:"
52 floatingSessionIDDefault = "default"
53 floatingPaneVisibleClass = "floating-pane-visible"
54)
55
56func gtkApplicationFlags() gio.ApplicationFlags {
57 return gio.GApplicationNonUniqueValue
58}
59
60// App wraps the GTK Application and manages the browser lifecycle.
61type App struct {
62 deps *Dependencies
63 gtkApp *gtk.Application
64 mainWindow *window.MainWindow
65
66 // State
67 tabs *entity.TabList
68 tabsUC *usecase.ManageTabsUseCase
69
70 // Coordinators (new architecture)
71 contentCoord *coordinator.ContentCoordinator
72 tabCoord *coordinator.TabCoordinator
73 wsCoord *coordinator.WorkspaceCoordinator
74 navCoord *coordinator.NavigationCoordinator
75 kbDispatcher *dispatcher.KeyboardDispatcher
76 configManager *config.Manager
77
78 // Pane management (used by coordinators)
79 panesUC *usecase.ManagePanesUseCase
80 workspaceViews map[entity.TabID]*component.WorkspaceView
81 widgetFactory layout.WidgetFactory
82 stackedPaneMgr *component.StackedPaneManager
83
84 // Input handling
85 keyboardHandler *input.KeyboardHandler
86 globalShortcutHandler *input.GlobalShortcutHandler
87
88 // Focus and border management
89 focusMgr *focus.Manager
90 borderMgr *focus.BorderManager
91
92 resizeModeBorderTarget layout.Widget
93
94 // Omnibox configuration (omnibox is created per workspace view)
95 omniboxCfg component.OmniboxConfig
96 omniboxNavigateFn func(url string)
97 // Find bar configuration (find bar is created per workspace view)
98 findBarCfg component.FindBarConfig
99 // Floating pane sessions keyed by tab and profile session ID.
100 floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
101
102 // Web content (managed by ContentCoordinator)
103 pool *webkit.WebViewPool
104 webViewFactory *webkit.WebViewFactory
105 injector *webkit.ContentInjector
106 router *webkit.MessageRouter
107 settings *webkit.SettingsManager
108 faviconAdapter *adapter.FaviconAdapter
109
110 // App-level toaster for system notifications (filter status, etc.)
111 appToaster *component.Toaster
112 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
113 modeToaster *component.Toaster
114 // Top-right WebRTC permission activity indicator
115 webrtcIndicator *component.WebRTCPermissionIndicator
116
117 // Session management
118 sessionManager *component.SessionManager
119 tabPicker *component.TabPicker
120 tabPickerWidget layout.Widget
121 tabPickerPaneID entity.PaneID
122 snapshotService *snapshot.Service
123
124 // Update management
125 updateCoord *coordinator.UpdateCoordinator
126
127 // ID generator for tabs/panes
128 idCounter uint64
129 idMu sync.Mutex
130 firstWebViewShownOnce sync.Once
131
132 movePaneToTabUC *usecase.MovePaneToTabUseCase
133
134 // Accent picker for dead keys support
135 accentPicker *component.AccentPicker
136 insertAccentUC *usecase.InsertAccentUseCase
137 accentFocusProvider *textinput.FocusProvider
138
139 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
140 deferredInitOnce sync.Once
141 deferredInitFn func()
142
143 // lifecycle
144 cancel context.CancelCauseFunc
145}
146
147type floatingWorkspaceSession struct {
148 paneID entity.PaneID
149 pane *component.FloatingPane
150 paneView *component.PaneView
151 webView *webkit.WebView
152 overlay layout.OverlayWidget
153 widget layout.Widget
154 focusWidget layout.Widget
155 omnibox *component.Omnibox
156 omniboxWidget layout.Widget
157 resizeWatcherActive bool
158 resizeTickID uint
159 appliedWidth int
160 appliedHeight int
161}
162
163type floatingSessionKey struct {
164 tabID entity.TabID
165 sessionID string
166}
167
168// New creates a new App with the given dependencies.
169func New(deps *Dependencies) (*App, error) {
170 if err := deps.Validate(); err != nil {
171 return nil, err
172 }
173 ctx, cancel := context.WithCancelCause(deps.Ctx)
174
175 app := &App{
176 deps: deps,
177 tabs: entity.NewTabList(),
178 tabsUC: deps.TabsUC,
179 panesUC: deps.PanesUC,
180 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
181 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
182 pool: deps.Pool,
183 injector: deps.Injector,
184 router: deps.MessageRouter,
185 settings: deps.Settings,
186 configManager: config.GetManager(),
187 cancel: cancel,
188 }
189 if app.router == nil {
190 app.router = webkit.NewMessageRouter(ctx)
191 }
192
193 // Register message handlers
194 if app.router != nil {
195 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
196 HistoryUC: deps.HistoryUC,
197 FavoritesUC: deps.FavoritesUC,
198 Clipboard: deps.Clipboard,
199 ConfigGetter: config.Get,
200 OnClipboardCopied: func(textLen int) {
201 // Show brief toast on auto-copy (similar to zellij footer notification)
202 // Must schedule on GTK main thread since this is called from WebKit handler
203 cb := glib.SourceFunc(func(_ uintptr) bool {
204 if app.appToaster != nil {
205 app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
206 component.WithDuration(component.ToastBriefDurationMs),
207 component.WithPosition(component.ToastPositionBottomRight),
208 )
209 }
210 return false
211 })
212 glib.IdleAdd(&cb, 0)
213 },
214 }); err != nil {
215 return nil, err
216 }
217 }
218
219 return app, nil
220}
221
222// Run starts the GTK application and blocks until it exits.
223// Returns the exit code.
224func (a *App) Run(ctx context.Context, args []string) int {
225 log := logging.FromContext(ctx)
226 log.Debug().Msg("creating GTK application")
227
228 // Initialize libadwaita once (required before using StyleManager).
229 // This also initializes GTK implicitly.
230 adw.Init()
231 logging.Trace().Mark("gtk_init")
232
233 // Mark adwaita detector as available now that adw.Init() is complete.
234 // This enables the highest-priority color scheme detector.
235 if a.deps != nil && a.deps.AdwaitaDetector != nil {
236 a.deps.AdwaitaDetector.MarkAvailable()
237 log.Debug().Msg("adwaita detector marked available")
238
239 // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
240 // They have the wrong dark mode preference injected; re-inject with correct value.
241 if a.pool != nil {
242 a.pool.RefreshScripts(ctx)
243 }
244 }
245
246 appID := AppID
247 a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags())
248 if a.gtkApp == nil {
249 log.Error().Msg("failed to create GTK application")
250 return 1
251 }
252 defer a.gtkApp.Unref()
253 logging.Trace().Mark("gtk_app_created")
254
255 // Connect activate signal
256 activateCb := func(_ gio.Application) {
257 a.onActivate(ctx)
258 }
259 a.gtkApp.ConnectActivate(&activateCb)
260
261 // Connect shutdown signal
262 shutdownCb := func(_ gio.Application) {
263 a.onShutdown(ctx)
264 }
265 a.gtkApp.ConnectShutdown(&shutdownCb)
266
267 log.Info().Msg("starting GTK main loop")
268 code := a.gtkApp.Run(len(args), args)
269
270 return code
271}
272
273// onActivate is called when the GTK application is activated.
274func (a *App) onActivate(ctx context.Context) {
275 log := logging.FromContext(ctx)
276 log.Debug().Msg("GTK application activated")
277
278 a.applyGTKColorSchemePreference(ctx)
279 // Configure pool background color early (prevents white flash), but avoid
280 // synchronous prewarming that delays the first navigation on cold start.
281 a.setupPoolBackgroundColor(ctx)
282
283 if err := a.createMainWindow(ctx); err != nil {
284 log.Error().Err(err).Msg("failed to create main window")
285 return
286 }
287 logging.Trace().Mark("window_created")
288
289 a.initLayoutInfrastructure()
290 a.initAppToasterOverlay()
291 a.installCrashReportNotifier(ctx)
292 a.initFocusAndBorderOverlay()
293 a.initAccentPicker(ctx)
294 a.initDownloadHandler(ctx)
295
296 a.initCoordinators(ctx)
297 a.wireWebRTCPermissionIndicator()
298 logging.Trace().Mark("coordinators_init")
299 a.initKeyboardHandler(ctx)
300 a.initOmniboxConfig(ctx)
301 a.initFindBarConfig()
302 a.initSessionManager(ctx)
303 a.initTabPicker(ctx)
304 a.initSnapshotService(ctx)
305 a.initUpdateCoordinator(ctx)
306 a.createInitialTab(ctx)
307 a.finalizeActivation(ctx)
308}
309
310func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
311 log := logging.FromContext(ctx)
312
313 // Get the config color scheme preference (default follows system theme)
314 scheme := "default"
315 if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
316 scheme = a.deps.Config.Appearance.ColorScheme
317 }
318
319 // Apply color scheme via libadwaita's StyleManager.
320 // This properly communicates the preference to GTK and WebKit,
321 // so web content can correctly evaluate prefers-color-scheme media queries.
322 styleMgr := adw.StyleManagerGetDefault()
323 if styleMgr == nil {
324 log.Warn().Msg("failed to get adw.StyleManager")
325 return
326 }
327
328 var adwScheme adw.ColorScheme
329 switch scheme {
330 case "dark", "prefer-dark":
331 adwScheme = adw.ColorSchemeForceDarkValue
332 case "light", "prefer-light":
333 adwScheme = adw.ColorSchemeForceLightValue
334 default:
335 // "default" or empty - follow system preference
336 adwScheme = adw.ColorSchemeDefaultValue
337 }
338
339 styleMgr.SetColorScheme(adwScheme)
340
341 // Refresh the color resolver now that adwaita detector is available.
342 // This ensures all components get the correct preference.
343 var pref string
344 var prefersDark bool
345 if a.deps != nil && a.deps.ColorResolver != nil {
346 result := a.deps.ColorResolver.Refresh()
347 prefersDark = result.PrefersDark
348 pref = result.Source
349 } else {
350 prefersDark = styleMgr.GetDark()
351 pref = "adw.StyleManager"
352 }
353
354 log.Debug().
355 Str("scheme", scheme).
356 Bool("prefers_dark", prefersDark).
357 Str("source", pref).
358 Msg("applied color scheme via adw.StyleManager")
359}
360
361func (a *App) setupPoolBackgroundColor(ctx context.Context) {
362 log := logging.FromContext(ctx)
363
364 // Set theme background color on pool to eliminate white flash.
365 // Must be done before any WebView creation so WebViews get the correct color.
366 if a.pool == nil {
367 log.Debug().Msg("webview pool not available; skipping background color setup")
368 return
369 }
370 if a.deps == nil || a.deps.Theme == nil {
371 log.Debug().Msg("theme not available; skipping webview pool background color setup")
372 return
373 }
374
375 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
376 a.pool.SetBackgroundColor(r, g, b, alpha)
377 log.Debug().Msg("configured webview pool background color")
378}
379
380func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
381 // Prewarm WebView pool after startup so cold-start navigation is not blocked.
382 if a.pool == nil {
383 return
384 }
385 a.pool.PrewarmAsync(ctx, 0)
386}
387
388func (a *App) createMainWindow(ctx context.Context) error {
389 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
390 if err != nil {
391 return err
392 }
393 a.mainWindow = mainWindow
394
395 // Create permission popup and dialog presenter
396 if a.deps != nil && a.deps.PermissionUC != nil {
397 uiScale := 1.0
398 if a.deps.Config != nil {
399 uiScale = a.deps.Config.DefaultUIScale
400 }
401 permPopup := component.NewPermissionPopup(nil, uiScale)
402 if permPopup != nil {
403 // Add popup to the main window's content overlay
404 if w := permPopup.Widget(); w != nil {
405 a.mainWindow.AddOverlay(w)
406 }
407 permDialog := dialog.NewPermissionDialog(permPopup)
408 a.deps.PermissionUC.SetDialogPresenter(permDialog)
409 }
410 }
411
412 // Create top-right WebRTC permission activity indicator.
413 indicator := component.NewWebRTCPermissionIndicator()
414 if indicator != nil {
415 if w := indicator.Widget(); w != nil {
416 a.mainWindow.AddOverlay(w)
417 a.webrtcIndicator = indicator
418 }
419 }
420
421 // Apply GTK CSS styling from theme manager.
422 if a.deps == nil || a.deps.Theme == nil {
423 return nil
424 }
425 if display := a.mainWindow.Window().GetDisplay(); display != nil {
426 a.deps.Theme.ApplyToDisplay(ctx, display)
427 }
428 return nil
429}
430
431func (a *App) initLayoutInfrastructure() {
432 // Initialize widget factory for pane layout.
433 a.widgetFactory = layout.NewGtkWidgetFactory()
434
435 // Initialize stacked pane manager for incremental widget operations.
436 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
437}
438
439func (a *App) initAppToasterOverlay() {
440 if a.mainWindow == nil {
441 return
442 }
443
444 // Create app-level toaster for system notifications.
445 a.appToaster = component.NewToaster(a.widgetFactory)
446 toasterWidget := a.appToaster.Widget()
447 if toasterWidget == nil {
448 return
449 }
450 gtkWidget := toasterWidget.GtkWidget()
451 if gtkWidget == nil {
452 return
453 }
454 a.mainWindow.AddOverlay(gtkWidget)
455
456 // Create mode indicator toaster for modal mode notifications.
457 a.modeToaster = component.NewToaster(a.widgetFactory)
458 modeToasterWidget := a.modeToaster.Widget()
459 if modeToasterWidget == nil {
460 return
461 }
462 modeGtkWidget := modeToasterWidget.GtkWidget()
463 if modeGtkWidget == nil {
464 return
465 }
466 a.mainWindow.AddOverlay(modeGtkWidget)
467}
468
469func (a *App) installCrashReportNotifier(ctx context.Context) {
470 if a.deps == nil {
471 return
472 }
473
474 a.deps.OnCrashReportsDetected = func(paths []string) {
475 a.showCrashReportToast(ctx, paths)
476 }
477}
478
479func (a *App) initFocusAndBorderOverlay() {
480 // Initialize focus manager for geometric navigation.
481 a.focusMgr = focus.NewManager(a.panesUC)
482
483 // Initialize border manager for mode indicators.
484 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
485
486 // Attach border overlay to main window (visible for all tabs).
487 if a.borderMgr == nil || a.mainWindow == nil {
488 return
489 }
490 borderWidget := a.borderMgr.Widget()
491 if borderWidget == nil {
492 return
493 }
494 gtkWidget := borderWidget.GtkWidget()
495 if gtkWidget == nil {
496 return
497 }
498 a.mainWindow.AddOverlay(gtkWidget)
499}
500
501func (a *App) initAccentPicker(ctx context.Context) {
502 log := logging.FromContext(ctx)
503
504 if a.widgetFactory == nil || a.mainWindow == nil {
505 log.Debug().Msg("widget factory or main window not available, skipping accent picker")
506 return
507 }
508
509 // Create accent picker component
510 a.accentPicker = component.NewAccentPicker(a.widgetFactory)
511 if a.accentPicker == nil {
512 log.Warn().Msg("failed to create accent picker")
513 return
514 }
515
516 // Add to main window overlay
517 pickerWidget := a.accentPicker.Widget()
518 if pickerWidget != nil {
519 gtkWidget := pickerWidget.GtkWidget()
520 if gtkWidget != nil {
521 a.mainWindow.AddOverlay(gtkWidget)
522 }
523 }
524
525 // Create focus provider (tracks which input has focus)
526 a.accentFocusProvider = textinput.NewFocusProvider()
527
528 // Create the use case with glib.IdleAdd for thread-safe GTK calls
529 a.insertAccentUC = usecase.NewInsertAccentUseCase(
530 a.accentFocusProvider,
531 a.accentPicker,
532 func(fn func()) {
533 cb := glib.SourceFunc(func(_ uintptr) bool {
534 fn()
535 return false // Don't repeat
536 })
537 glib.IdleAdd(&cb, 0)
538 },
539 )
540
541 log.Debug().Msg("accent picker initialized")
542}
543
544func (a *App) initDownloadHandler(ctx context.Context) {
545 log := logging.FromContext(ctx)
546
547 if a.deps == nil || a.deps.WebContext == nil {
548 log.Debug().Msg("WebContext not available, skipping download handler")
549 return
550 }
551
552 // Determine download path from config, fallback to XDG.
553 downloadPath := ""
554 if a.deps.Config != nil {
555 downloadPath = a.deps.Config.Downloads.Path
556 }
557 if downloadPath == "" && a.deps.XDG != nil {
558 var err error
559 downloadPath, err = a.deps.XDG.DownloadDir()
560 if err != nil {
561 log.Warn().Err(err).Msg("failed to get XDG download dir")
562 }
563 }
564 if downloadPath == "" {
565 // Final fallback to ~/Downloads, with /tmp as last resort.
566 home, err := os.UserHomeDir()
567 if err != nil {
568 log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
569 downloadPath = "/tmp"
570 } else {
571 downloadPath = filepath.Join(home, "Downloads")
572 }
573 }
574
575 // Create download event adapter to show toasts.
576 eventAdapter := &downloadEventAdapter{app: a}
577
578 // Create use case for preparing download destinations with file deduplication.
579 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New())
580
581 // Create and wire the download handler.
582 handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC)
583 a.deps.WebContext.SetDownloadHandler(ctx, handler)
584
585 log.Info().Str("path", downloadPath).Msg("download handler initialized")
586}
587
588// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
589type downloadEventAdapter struct {
590 app *App
591}
592
593func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
594 // Must schedule on GTK main thread.
595 cb := glib.SourceFunc(func(_ uintptr) bool {
596 if d.app.appToaster == nil {
597 return false
598 }
599
600 switch event.Type {
601 case port.DownloadEventStarted:
602 d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
603 case port.DownloadEventFinished:
604 d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
605 case port.DownloadEventFailed:
606 d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
607 }
608 return false
609 })
610 glib.IdleAdd(&cb, 0)
611}
612
613func (a *App) initKeyboardHandler(ctx context.Context) {
614 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
615 return
616 }
617
618 // Create keyboard handler and wire to dispatcher.
619 a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
620 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
621 if action == input.ActionClosePane {
622 if a.closeAndReleaseActiveFloatingPane(ctx) {
623 return nil
624 }
625 }
626 return a.kbDispatcher.Dispatch(ctx, action)
627 })
628 a.keyboardHandler.SetOnEscape(func(ctx context.Context) bool {
629 return a.handleGlobalEscape(ctx)
630 })
631 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
632 a.handleModeChange(ctx, from, to)
633 })
634 a.keyboardHandler.SetShouldBypassInput(func(modifiers input.Modifier) bool {
635 // Bypass keyboard handler when modals are visible
636 if a.sessionManager != nil && a.sessionManager.IsVisible() {
637 return true
638 }
639 if a.tabPicker != nil && a.tabPicker.IsVisible() {
640 return true
641 }
642 wsView := a.activeWorkspaceView()
643 if wsView == nil {
644 return false
645 }
646 if wsView.IsOmniboxVisible() {
647 // Let Alt-modified keys through for pane navigation
648 return modifiers&input.ModAlt == 0
649 }
650 return false
651 })
652 // Wire accent handler for dead keys support
653 if a.insertAccentUC != nil {
654 a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
655 }
656 a.keyboardHandler.AttachTo(a.mainWindow.Window())
657
658 // Create global shortcut handler for Alt+1-9 tab switching.
659 // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
660 // to intercept shortcuts even when WebView has focus.
661 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
662 ctx,
663 a.mainWindow.Window(),
664 a.deps.Config,
665 func(ctx context.Context, action input.Action) error {
666 return a.kbDispatcher.Dispatch(ctx, action)
667 },
668 )
669}
670
671func (a *App) initOmniboxConfig(ctx context.Context) {
672 if a.deps == nil || a.deps.Config == nil {
673 return
674 }
675
676 log := logging.FromContext(ctx)
677
678 // Convert config shortcuts to use case type
679 shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))
680 for key, shortcut := range a.deps.Config.SearchShortcuts {
681 shortcuts[key] = usecase.SearchShortcut{
682 URL: shortcut.URL,
683 Description: shortcut.Description,
684 }
685 }
686 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
687
688 // Create autocomplete use case for inline suggestions
689 autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC)
690
691 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
692 a.omniboxCfg = component.OmniboxConfig{
693 HistoryUC: a.deps.HistoryUC,
694 FavoritesUC: a.deps.FavoritesUC,
695 FaviconAdapter: a.faviconAdapter,
696 CopyURLUC: a.deps.CopyURLUC,
697 ShortcutsUC: shortcutsUC,
698 AutocompleteUC: autocompleteUC,
699 DefaultSearch: a.deps.Config.DefaultSearchEngine,
700 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
701 UIScale: a.deps.Config.DefaultUIScale,
702 OnNavigate: func(url string) {
703 if err := a.navigateFromOmnibox(ctx, url); err != nil {
704 log.Error().Err(err).Str("url", url).Msg("navigation failed")
705 }
706 },
707 OnFocusIn: func(entry *gtk.SearchEntry) {
708 // Set omnibox entry as the focused input for accent picker
709 if a.accentFocusProvider != nil && entry != nil {
710 a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
711 }
712 },
713 OnFocusOut: func() {
714 // When omnibox loses focus, set WebView as the focused input
715 if a.accentFocusProvider != nil {
716 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
717 }
718 },
719 }
720 a.navCoord.SetOmniboxProvider(a)
721 log.Debug().Msg("omnibox config stored, provider set")
722}
723
724func (a *App) initFindBarConfig() {
725 // Store find bar config (find bar is created per-pane via WorkspaceView).
726 a.findBarCfg = component.FindBarConfig{
727 GetFindController: func(paneID entity.PaneID) port.FindController {
728 if a.contentCoord == nil {
729 return nil
730 }
731 wv := a.contentCoord.GetWebView(paneID)
732 if wv == nil {
733 return nil
734 }
735 return wv.GetFindController()
736 },
737 OnFocusIn: func(entry *gtk.SearchEntry) {
738 // Set find bar entry as the focused input for accent picker
739 if a.accentFocusProvider != nil && entry != nil {
740 a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
741 }
742 },
743 OnFocusOut: func() {
744 // When find bar loses focus, set WebView as the focused input
745 if a.accentFocusProvider != nil {
746 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
747 }
748 },
749 }
750}
751
752func (a *App) initSessionManager(ctx context.Context) {
753 log := logging.FromContext(ctx)
754
755 if a.deps == nil {
756 log.Debug().Msg("deps not available, skipping session manager")
757 return
758 }
759
760 // Session manager can work without a repo - it just shows an empty list
761 var listSessionsUC *usecase.ListSessionsUseCase
762 var deleteSessionUC *usecase.DeleteSessionUseCase
763 if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
764 listSessionsUC = usecase.NewListSessionsUseCase(
765 a.deps.SessionRepo,
766 a.deps.SessionStateRepo,
767 a.deps.Config.Logging.LogDir,
768 )
769 deleteSessionUC = usecase.NewDeleteSessionUseCase(
770 a.deps.SessionStateRepo,
771 a.deps.SessionRepo,
772 a.deps.Config.Logging.LogDir,
773 )
774 }
775
776 // Create session spawner for restoration
777 spawner := desktop.NewSessionSpawner(ctx)
778
779 // Create session manager component
780 a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
781 ListSessionsUC: listSessionsUC,
782 DeleteSessionUC: deleteSessionUC,
783 CurrentSession: a.deps.CurrentSessionID,
784 UIScale: a.deps.Config.DefaultUIScale,
785 OnClose: func() {
786 log.Debug().Msg("session manager closed")
787 },
788 OnOpen: func(sessionID entity.SessionID) {
789 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
790 if err := spawner.SpawnWithSession(sessionID); err != nil {
791 log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
792 }
793 },
794 OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
795 if a.appToaster != nil {
796 a.appToaster.Show(ctx, message, level)
797 }
798 },
799 })
800
801 if a.sessionManager == nil {
802 log.Warn().Msg("failed to create session manager")
803 return
804 }
805
806 // Add session manager to main window overlay
807 if a.mainWindow != nil {
808 widget := a.sessionManager.Widget()
809 if widget != nil {
810 a.mainWindow.AddOverlay(widget)
811 }
812 }
813
814 // Wire session manager action to keyboard dispatcher
815 a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
816 a.ToggleSessionManager(ctx)
817 return nil
818 })
819
820 log.Debug().Msg("session manager initialized")
821}
822
823// ToggleSessionManager shows or hides the session manager.
824func (a *App) ToggleSessionManager(ctx context.Context) {
825 if a.sessionManager == nil {
826 return
827 }
828 a.sessionManager.Toggle(ctx)
829}
830
831func (a *App) attachTabPickerToActivePane() {
832 if a.tabPicker == nil || a.widgetFactory == nil {
833 return
834 }
835 wsView := a.activeWorkspaceView()
836 if wsView == nil {
837 return
838 }
839
840 activeTab := a.tabs.ActiveTab()
841 if activeTab == nil || activeTab.Workspace == nil {
842 return
843 }
844 activePaneID := activeTab.Workspace.ActivePaneID
845 if activePaneID == "" {
846 return
847 }
848 pv := wsView.GetPaneView(activePaneID)
849 if pv == nil {
850 return
851 }
852
853 if a.tabPickerWidget == nil {
854 a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
855 if a.tabPickerWidget == nil {
856 return
857 }
858 }
859
860 // If currently attached to a different pane overlay, detach.
861 if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
862 for _, view := range a.workspaceViews {
863 if view == nil {
864 continue
865 }
866 if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
867 if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
868 oldPV.RemoveOverlayWidget(a.tabPickerWidget)
869 } else if parent != nil {
870 a.tabPickerWidget.Unparent()
871 }
872 break
873 }
874 }
875 }
876
877 // Ensure the widget can be reparented.
878 if parent := a.tabPickerWidget.GetParent(); parent != nil {
879 a.tabPickerWidget.Unparent()
880 }
881
882 a.tabPicker.SetParentOverlay(pv.Overlay())
883 pv.AddOverlayWidget(a.tabPickerWidget)
884 a.tabPickerPaneID = activePaneID
885}
886
887func (a *App) HandleMovePaneToTab(ctx context.Context) error {
888 if a.movePaneToTabUC == nil {
889 return nil
890 }
891 if a.tabPicker == nil {
892 return nil
893 }
894
895 sourceTab := a.tabs.ActiveTab()
896 if sourceTab == nil || sourceTab.Workspace == nil {
897 return nil
898 }
899
900 // If there is only 1 tab, auto-create a new tab and move there.
901 if a.tabs.Count() <= 1 {
902 return a.MoveActivePaneToTab(ctx, "")
903 }
904
905 items := make([]component.TabPickerItem, 0, a.tabs.Count())
906 for _, tab := range a.tabs.Tabs {
907 if tab == nil {
908 continue
909 }
910 if tab.ID == sourceTab.ID {
911 continue
912 }
913 items = append(items, component.TabPickerItem{
914 TabID: tab.ID,
915 Title: tab.Title(),
916 IsNew: false,
917 Index: tab.Position,
918 })
919 }
920 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
921
922 a.attachTabPickerToActivePane()
923 a.tabPicker.Show(ctx, items)
924 return nil
925}
926
927func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
928 if a.movePaneToTabUC == nil {
929 return nil
930 }
931 active := a.tabs.ActiveTab()
932 if active == nil {
933 return nil
934 }
935
936 nextPos := active.Position + 1
937 if nextPos >= a.tabs.Count() {
938 // Create a new tab on the right.
939 return a.MoveActivePaneToTab(ctx, "")
940 }
941 next := a.tabs.TabAt(nextPos)
942 if next == nil {
943 return nil
944 }
945 return a.MoveActivePaneToTab(ctx, next.ID)
946}
947
948func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
949 in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
950 if in == nil {
951 return nil
952 }
953
954 out, err := a.movePaneToTabUC.Execute(*in)
955 if err != nil {
956 return err
957 }
958 if out == nil || out.TargetTab == nil {
959 return nil
960 }
961
962 a.applyMovePaneToTabUI(ctx, out, sourceTab)
963 a.switchToTargetTabIfConfigured(ctx, out)
964 a.updateTabBarVisibility(ctx)
965 a.MarkDirty()
966 return nil
967}
968
969func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
970 if a.movePaneToTabUC == nil {
971 return nil, nil
972 }
973 activeTab := a.tabs.ActiveTab()
974 if activeTab == nil || activeTab.Workspace == nil {
975 return nil, nil
976 }
977 sourcePaneID := activeTab.Workspace.ActivePaneID
978 if sourcePaneID == "" {
979 return nil, nil
980 }
981
982 in := &usecase.MovePaneToTabInput{
983 TabList: a.tabs,
984 SourceTabID: activeTab.ID,
985 SourcePaneID: sourcePaneID,
986 TargetTabID: targetTabID,
987 }
988 return in, activeTab
989}
990
991func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
992 if out == nil || out.TargetTab == nil {
993 return
994 }
995 sourceTabID := entity.TabID("")
996 if sourceTab != nil {
997 sourceTabID = sourceTab.ID
998 }
999
1000 if out.SourceTabClosed {
1001 a.removeSourceTabUI(sourceTabID)
1002 }
1003 if out.NewTabCreated {
1004 a.ensureTargetTabUI(ctx, out.TargetTab)
1005 }
1006
1007 if !out.SourceTabClosed {
1008 a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
1009 }
1010 a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
1011}
1012
1013func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
1014 a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
1015 delete(a.workspaceViews, sourceTabID)
1016 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1017 a.mainWindow.TabBar().RemoveTab(sourceTabID)
1018 }
1019}
1020
1021func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
1022 if tab == nil {
1023 return
1024 }
1025 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1026 a.mainWindow.TabBar().AddTab(tab)
1027 }
1028 a.createWorkspaceViewWithoutAttach(ctx, tab)
1029}
1030
1031func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
1032 wsView := a.workspaceViews[tabID]
1033 if wsView == nil {
1034 return
1035 }
1036 _ = wsView.Rebuild(ctx)
1037 a.reattachFloatingSessions(tabID, wsView)
1038 a.syncFloatingFocus()
1039
1040 if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
1041 return
1042 }
1043 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1044 if a.wsCoord != nil {
1045 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
1046 }
1047}
1048
1049func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.WorkspaceView) {
1050 if wsView == nil {
1051 return
1052 }
1053
1054 for key, session := range a.floatingSessions {
1055 if key.tabID != tabID || session == nil || session.pane == nil {
1056 continue
1057 }
1058
1059 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
1060 if session.widget != nil {
1061 wsView.AddWorkspaceOverlayWidget(session.widget)
1062 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
1063 setFloatingWidgetShown(session.widget, session.pane.IsVisible())
1064 }
1065 if session.paneView != nil {
1066 wsView.RegisterPaneView(session.paneID, session.paneView)
1067 if session.focusWidget == nil {
1068 session.focusWidget = session.paneView.WebViewWidget()
1069 }
1070 }
1071 }
1072}
1073
1074func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
1075 if out == nil || out.TargetTab == nil {
1076 return
1077 }
1078
1079 switchToTarget := config.Get().Workspace.SwitchToTabOnMove
1080 if out.SourceTabClosed {
1081 switchToTarget = true
1082 }
1083 if !switchToTarget {
1084 return
1085 }
1086
1087 a.tabs.SetActive(out.TargetTab.ID)
1088 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1089 a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
1090 }
1091 a.switchWorkspaceView(ctx, out.TargetTab.ID)
1092}
1093
1094func (a *App) updateTabBarVisibility(ctx context.Context) {
1095 if a.tabCoord != nil {
1096 a.tabCoord.UpdateBarVisibility(ctx)
1097 }
1098}
1099
1100func (a *App) initTabPicker(ctx context.Context) {
1101 log := logging.FromContext(ctx)
1102
1103 if a.deps == nil || a.deps.Config == nil {
1104 log.Debug().Msg("deps/config not available, skipping tab picker")
1105 return
1106 }
1107
1108 a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
1109 UIScale: a.deps.Config.DefaultUIScale,
1110 OnClose: func() {
1111 log.Debug().Msg("tab picker closed")
1112 },
1113 OnSelect: func(item component.TabPickerItem) {
1114 cb := glib.SourceFunc(func(_ uintptr) bool {
1115 var targetID entity.TabID
1116 if !item.IsNew {
1117 targetID = item.TabID
1118 }
1119 if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
1120 log.Warn().Err(err).Msg("move pane to tab failed")
1121 }
1122 return false
1123 })
1124 glib.IdleAdd(&cb, 0)
1125 },
1126 })
1127
1128 if a.tabPicker == nil {
1129 log.Warn().Msg("failed to create tab picker")
1130 return
1131 }
1132
1133 log.Debug().Msg("tab picker initialized")
1134}
1135
1136func (a *App) initSnapshotService(ctx context.Context) {
1137 log := logging.FromContext(ctx)
1138
1139 if a.deps == nil || a.deps.SnapshotUC == nil {
1140 log.Debug().Msg("snapshot use case not available, skipping snapshot service")
1141 return
1142 }
1143
1144 intervalMs := 5000 // default
1145 if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
1146 intervalMs = a.deps.Config.Session.SnapshotIntervalMs
1147 }
1148
1149 a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
1150 a.snapshotService.Start(ctx)
1151
1152 // Set up callback for main.go to notify when session is persisted
1153 a.deps.OnSessionPersisted = func() {
1154 a.snapshotService.SetReady()
1155 }
1156
1157 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
1158}
1159
1160func (a *App) initUpdateCoordinator(ctx context.Context) {
1161 log := logging.FromContext(ctx)
1162
1163 if a.deps == nil || a.deps.CheckUpdateUC == nil {
1164 log.Debug().Msg("update use cases not available, skipping update coordinator")
1165 return
1166 }
1167
1168 a.updateCoord = coordinator.NewUpdateCoordinator(
1169 a.deps.CheckUpdateUC,
1170 a.deps.ApplyUpdateUC,
1171 a.appToaster,
1172 a.deps.Config,
1173 )
1174
1175 // Start async update check
1176 a.updateCoord.CheckOnStartup(ctx)
1177
1178 log.Debug().Msg("update coordinator initialized")
1179}
1180
1181// GetTabList implements port.TabListProvider.
1182func (a *App) GetTabList() *entity.TabList {
1183 return a.tabs
1184}
1185
1186// GetSessionID implements port.TabListProvider.
1187func (a *App) GetSessionID() entity.SessionID {
1188 if a.deps == nil {
1189 return ""
1190 }
1191 return a.deps.CurrentSessionID
1192}
1193
1194// MarkDirty signals that session state has changed and should be saved.
1195func (a *App) MarkDirty() {
1196 if a.snapshotService != nil {
1197 a.snapshotService.MarkDirty()
1198 }
1199}
1200
1201func (a *App) createInitialTab(ctx context.Context) {
1202 log := logging.FromContext(ctx)
1203
1204 // Check if we should restore a session
1205 if a.deps != nil && a.deps.RestoreSessionID != "" {
1206 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
1207 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
1208 // Show error toast and fall through to create default tab
1209 if a.appToaster != nil {
1210 a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1211 }
1212 } else {
1213 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
1214 // Show success toast for session restoration
1215 if a.appToaster != nil {
1216 a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1217 }
1218 return
1219 }
1220 }
1221
1222 // Create an initial tab using coordinator.
1223 initialURL := "dumb://home"
1224 if a.deps != nil && a.deps.InitialURL != "" {
1225 initialURL = a.deps.InitialURL
1226 }
1227 if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
1228 log.Error().Err(err).Msg("failed to create initial tab")
1229 }
1230}
1231
1232func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1233 log := logging.FromContext(ctx)
1234
1235 if a.deps == nil || a.deps.SessionStateRepo == nil {
1236 return fmt.Errorf("session state repo not available")
1237 }
1238
1239 // Load session state
1240 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
1241 if err != nil {
1242 return fmt.Errorf("load session state: %w", err)
1243 }
1244 if state == nil {
1245 return fmt.Errorf("session state not found")
1246 }
1247
1248 log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
1249
1250 // Build complete TabList from snapshot (state-first approach)
1251 // This reconstructs the full pane tree with new IDs
1252 restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
1253 if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
1254 return fmt.Errorf("failed to build tab list from snapshot")
1255 }
1256
1257 // Replace tabs in-place to preserve references held by TabCoordinator
1258 a.tabs.ReplaceFrom(restoredTabs)
1259
1260 // Create UI for each restored tab and add to tab bar
1261 // Don't attach to content area yet - only the active tab should be attached
1262 tabBar := a.mainWindow.TabBar()
1263 for _, tab := range a.tabs.Tabs {
1264 a.createWorkspaceViewWithoutAttach(ctx, tab)
1265 if tabBar != nil {
1266 tabBar.AddTab(tab)
1267 }
1268 log.Debug().
1269 Str("tab_id", string(tab.ID)).
1270 Str("name", tab.Name).
1271 Int("panes", tab.Workspace.PaneCount()).
1272 Msg("restored tab with workspace")
1273 }
1274
1275 // Switch to active tab - this attaches the active workspace view to content area
1276 activeTab := a.tabs.ActiveTab()
1277 if activeTab != nil {
1278 a.switchWorkspaceView(ctx, activeTab.ID)
1279 if tabBar != nil {
1280 tabBar.SetActive(activeTab.ID)
1281 }
1282 }
1283
1284 return nil
1285}
1286
1287func (a *App) finalizeActivation(ctx context.Context) {
1288 log := logging.FromContext(ctx)
1289
1290 // Show the window
1291 if a.mainWindow != nil {
1292 a.mainWindow.Show()
1293 }
1294 log.Info().Msg("main window displayed")
1295
1296 if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
1297 a.showCrashReportToast(ctx, a.deps.StartupCrashReports)
1298 }
1299
1300 // Defer non-critical initialization until after first navigation starts.
1301 // This keeps pool prewarm, config watcher, and filter loading from
1302 // competing with the initial page load.
1303 a.runAfterFirstLoadStarted(func() {
1304 a.prewarmWebViewPoolAsync(ctx)
1305 a.initConfigWatcher(ctx)
1306 a.initFilteringAsync(ctx)
1307 a.checkConfigMigration(ctx)
1308 })
1309}
1310
1311func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
1312 if len(paths) == 0 {
1313 return
1314 }
1315 log := logging.FromContext(ctx)
1316 log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
1317
1318 cb := glib.SourceFunc(func(_ uintptr) bool {
1319 if a.appToaster != nil {
1320 msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1321 a.appToaster.Show(ctx, msg, component.ToastWarning,
1322 component.WithDuration(crashReportToastDurationMs),
1323 component.WithPosition(component.ToastPositionBottomRight),
1324 )
1325 }
1326 return false
1327 })
1328 glib.IdleAdd(&cb, 0)
1329}
1330
1331// runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1332// This keeps the GTK main loop free to process the initial load_uri() quickly,
1333// reducing the gap between webview_attached and load_started.
1334func (a *App) runAfterFirstLoadStarted(fn func()) {
1335 a.deferredInitFn = fn
1336}
1337
1338// triggerDeferredInit is called from ContentCoordinator on first load_started.
1339// It runs deferred initialization at LOW priority so navigation continues unblocked.
1340func (a *App) triggerDeferredInit(ctx context.Context) {
1341 a.deferredInitOnce.Do(func() {
1342 if a.deferredInitFn == nil {
1343 return
1344 }
1345 log := logging.FromContext(ctx)
1346 log.Debug().Msg("triggering deferred initialization")
1347
1348 fn := a.deferredInitFn
1349 cb := glib.SourceFunc(func(_ uintptr) bool {
1350 fn()
1351 return false
1352 })
1353 // Use LOW priority so navigation processing continues first
1354 glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
1355 })
1356}
1357
1358// onShutdown is called when the GTK application is shutting down.
1359func (a *App) onShutdown(ctx context.Context) {
1360 log := logging.FromContext(ctx)
1361 log.Debug().Msg("GTK application shutting down")
1362
1363 // Save final session state before shutdown
1364 if a.snapshotService != nil {
1365 if err := a.snapshotService.Stop(ctx); err != nil {
1366 log.Warn().Err(err).Msg("failed to save final session state")
1367 }
1368 }
1369
1370 // Apply staged update if available (before cleanup)
1371 if a.updateCoord != nil {
1372 if err := a.updateCoord.FinalizeOnExit(ctx); err != nil {
1373 log.Warn().Err(err).Msg("failed to apply staged update")
1374 }
1375 }
1376
1377 // Cancel context to signal all goroutines
1378 a.cancel(errors.New("application shutdown"))
1379
1380 // Cleanup resources
1381 if a.faviconAdapter != nil {
1382 a.faviconAdapter.Close()
1383 }
1384 tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions))
1385 for key := range a.floatingSessions {
1386 tabIDSet[key.tabID] = struct{}{}
1387 }
1388 tabIDs := make([]entity.TabID, 0, len(tabIDSet))
1389 for tabID := range tabIDSet {
1390 tabIDs = append(tabIDs, tabID)
1391 }
1392 for _, tabID := range tabIDs {
1393 a.releaseFloatingSessionsForTab(ctx, tabID)
1394 }
1395 if a.deps.Pool != nil {
1396 a.deps.Pool.Close(ctx)
1397 }
1398 // Close idle inhibitor to release D-Bus connection
1399 if a.deps.IdleInhibitor != nil {
1400 if err := a.deps.IdleInhibitor.Close(); err != nil {
1401 log.Warn().Err(err).Msg("failed to close idle inhibitor")
1402 }
1403 }
1404
1405 log.Info().Msg("application shutdown complete")
1406}
1407
1408// initCoordinators initializes all coordinators and wires their callbacks.
1409func (a *App) initCoordinators(ctx context.Context) {
1410 log := logging.FromContext(ctx)
1411 log.Debug().Msg("initializing coordinators")
1412
1413 // Helper to get active workspace and view
1414 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
1415 return a.activeWorkspace(), a.activeWorkspaceView()
1416 }
1417
1418 // Create FaviconAdapter with service and WebKit FaviconDatabase
1419 var faviconDB *webkit.FaviconDatabase
1420 if a.deps.WebContext != nil {
1421 faviconDB = a.deps.WebContext.FaviconDatabase()
1422 }
1423 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
1424
1425 // 1. Content Coordinator (no dependencies on other coordinators)
1426 a.contentCoord = coordinator.NewContentCoordinator(
1427 ctx,
1428 a.pool,
1429 a.widgetFactory,
1430 a.faviconAdapter,
1431 getActiveWS,
1432 a.deps.ZoomUC,
1433 a.deps.PermissionUC,
1434 )
1435
1436 // Set idle inhibitor for fullscreen video playback
1437 if a.deps.IdleInhibitor != nil {
1438 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
1439 }
1440
1441 // Wire deferred init trigger - runs after first navigation starts
1442 a.contentCoord.SetOnFirstLoadStarted(func() {
1443 a.triggerDeferredInit(ctx)
1444 })
1445
1446 // 2. Tab Coordinator
1447 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1448 TabsUC: a.tabsUC,
1449 Tabs: a.tabs,
1450 MainWindow: a.mainWindow,
1451 Config: a.deps.Config,
1452 })
1453 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
1454 a.createWorkspaceView(ctx, tab)
1455 })
1456 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
1457 a.switchWorkspaceView(ctx, tab.ID)
1458 })
1459 a.tabCoord.SetOnQuit(a.Quit)
1460 a.tabCoord.SetOnStateChanged(a.MarkDirty)
1461 // Wire popup tab WebView attachment
1462 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1463 a.attachPopupToTab(ctx, tabID, pane, wv)
1464 })
1465
1466 // Wire tab bar click handling to coordinator
1467 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1468 a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1469 if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1470 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
1471 }
1472 })
1473 }
1474
1475 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1476 a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1477 if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1478 return
1479 }
1480 if entering {
1481 // Hide tab bar when entering fullscreen video
1482 a.mainWindow.TabBar().SetVisible(false)
1483 } else {
1484 // Restore tab bar visibility based on normal logic
1485 a.tabCoord.UpdateBarVisibility(ctx)
1486 }
1487 })
1488
1489 // 3. Workspace Coordinator
1490 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1491 PanesUC: a.panesUC,
1492 FocusMgr: a.focusMgr,
1493 StackedPaneMgr: a.stackedPaneMgr,
1494 WidgetFactory: a.widgetFactory,
1495 ContentCoord: a.contentCoord,
1496 GetActiveWS: getActiveWS,
1497 GenerateID: a.generateID,
1498 })
1499 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
1500 return a.tabCoord.Close(ctx)
1501 })
1502 a.wsCoord.SetOnStateChanged(a.MarkDirty)
1503
1504 // Wire popup handling
1505 a.webViewFactory = webkit.NewWebViewFactory(
1506 a.deps.WebContext,
1507 a.settings,
1508 a.pool,
1509 a.injector,
1510 a.router,
1511 )
1512 // Set theme background color on factory to eliminate white flash
1513 if a.deps.Theme != nil {
1514 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
1515 a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
1516 }
1517 a.contentCoord.SetPopupConfig(
1518 a.webViewFactory,
1519 &a.deps.Config.Workspace.Popups,
1520 a.generateID,
1521 )
1522 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1523 return a.wsCoord.InsertPopup(ctx, input)
1524 })
1525 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
1526 return a.wsCoord.ClosePaneByID(ctx, paneID)
1527 })
1528 // Wire tabbed popup behavior to create new tabs
1529 a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1530 // Create a new tab with the popup pane
1531 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
1532 if err != nil {
1533 return err
1534 }
1535 log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
1536 return nil
1537 })
1538
1539 // Move pane use case (cross-tab)
1540 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1541
1542 // 4. Navigation Coordinator
1543 a.navCoord = coordinator.NewNavigationCoordinator(
1544 ctx,
1545 a.deps.NavigateUC,
1546 a.contentCoord,
1547 )
1548 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
1549 a.navCoord.ClearPaneHistory(paneID)
1550 })
1551
1552 // Wire title updates to history persistence
1553 a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
1554 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
1555 })
1556
1557 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
1558 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
1559 a.navCoord.RecordHistory(ctx, paneID, url)
1560 })
1561
1562 // Wire window title updates when active pane's title changes
1563 a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1564 a.updateWindowTitle(title)
1565 })
1566
1567 // Wire pane URI updates for session snapshots (searches all tabs)
1568 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
1569 a.updatePaneURIInAllTabs(paneID, url)
1570 a.updateFloatingSessionURI(paneID, url)
1571 // Mark dirty so snapshot captures the new URI
1572 a.MarkDirty()
1573 })
1574
1575 // Hide loading skeleton once the WebView paints
1576 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
1577 // The WebView can be shown while its pane is in a background tab, so scan
1578 // all WorkspaceViews rather than only the active one.
1579 for _, wsView := range a.workspaceViews {
1580 if wsView == nil {
1581 continue
1582 }
1583 if pv := wsView.GetPaneView(paneID); pv != nil {
1584 pv.HideLoadingSkeleton()
1585 }
1586 }
1587 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1588 a.firstWebViewShownOnce.Do(func() {
1589 a.deps.OnFirstWebViewShown(ctx)
1590 })
1591 }
1592
1593 // Set the WebView as the focused input for accent picker (if this is the active pane)
1594 ws := a.activeWorkspace()
1595 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1596 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1597 }
1598 })
1599
1600 // 5. Keyboard Dispatcher
1601 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
1602 ctx,
1603 a.tabCoord,
1604 a.wsCoord,
1605 a.navCoord,
1606 a.deps.ZoomUC,
1607 a.deps.CopyURLUC,
1608 )
1609 a.wireKeyboardActions()
1610
1611 log.Debug().Msg("coordinators initialized")
1612}
1613
1614func (a *App) wireKeyboardActions() {
1615 a.kbDispatcher.SetOnQuit(a.Quit)
1616 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
1617 a.ToggleFindBar(ctx)
1618 return nil
1619 })
1620 a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
1621 a.FindNext(ctx)
1622 return nil
1623 })
1624 a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
1625 a.FindPrevious(ctx)
1626 return nil
1627 })
1628 a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
1629 a.CloseFindBar(ctx)
1630 return nil
1631 })
1632 a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1633 return a.HandleMovePaneToTab(ctx)
1634 })
1635 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1636 return a.HandleMovePaneToNextTab(ctx)
1637 })
1638 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
1639 return a.ToggleFloatingPane(ctx)
1640 })
1641 a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error {
1642 return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL)
1643 })
1644
1645 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
1646 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
1647 return a.kbDispatcher.Dispatch(ctx, action)
1648 })
1649}
1650
1651func (a *App) wireWebRTCPermissionIndicator() {
1652 if a.webrtcIndicator == nil || a.contentCoord == nil {
1653 return
1654 }
1655 ctx := context.Background()
1656 if a.deps != nil && a.deps.Ctx != nil {
1657 ctx = a.deps.Ctx
1658 }
1659 log := logging.FromContext(ctx)
1660
1661 a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {
1662 a.webrtcIndicator.SetOrigin(origin)
1663
1664 switch state {
1665 case coordinator.PermissionActivityRequesting:
1666 a.webrtcIndicator.MarkRequesting(permTypes)
1667 case coordinator.PermissionActivityAllowed:
1668 a.webrtcIndicator.MarkAllowed(permTypes)
1669 case coordinator.PermissionActivityBlocked:
1670 a.webrtcIndicator.MarkBlocked(permTypes)
1671 }
1672
1673 for _, permType := range permTypes {
1674 a.syncWebRTCPermissionLockState(ctx, origin, permType)
1675 }
1676 })
1677
1678 a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
1679 if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1680 return
1681 }
1682
1683 if hasStored {
1684 // Already locked: reset to prompt (will re-ask on next request).
1685 if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil {
1686 log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision")
1687 return
1688 }
1689 } else {
1690 // Not locked: flip the state.
1691 // Allowed/requesting → deny future requests.
1692 // Blocked → allow future requests.
1693 decision := entity.PermissionDenied
1694 if state == string(coordinator.PermissionActivityBlocked) {
1695 decision = entity.PermissionGranted
1696 }
1697 if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
1698 log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision")
1699 return
1700 }
1701 }
1702
1703 a.syncWebRTCPermissionLockState(ctx, origin, permType)
1704 })
1705
1706 // Reset indicator when the active pane navigates away from the current origin.
1707 a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) {
1708 newOrigin, err := urlutil.ExtractOrigin(uri)
1709 if err != nil {
1710 // Internal pages (dumb://, about:) — clear the indicator.
1711 a.webrtcIndicator.Reset()
1712 return
1713 }
1714
1715 currentOrigin := a.webrtcIndicator.Origin()
1716 if currentOrigin != "" && currentOrigin != newOrigin {
1717 a.webrtcIndicator.Reset()
1718 }
1719 })
1720}
1721
1722func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) {
1723 if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1724 return
1725 }
1726
1727 record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType)
1728 if err != nil {
1729 logging.FromContext(ctx).Warn().
1730 Err(err).
1731 Str("origin", origin).
1732 Str("type", string(permType)).
1733 Msg("failed to read manual permission decision")
1734 return
1735 }
1736
1737 if record == nil {
1738 a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
1739 return
1740 }
1741
1742 a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true)
1743}
1744
1745// generateID generates a unique ID for tabs and panes.
1746// Uses monotonically increasing counter to avoid ID collisions.
1747func (a *App) generateID() string {
1748 a.idMu.Lock()
1749 defer a.idMu.Unlock()
1750 a.idCounter++
1751 return fmt.Sprintf("p%d", a.idCounter)
1752}
1753
1754// Tabs returns the tab list.
1755func (a *App) Tabs() *entity.TabList {
1756 return a.tabs
1757}
1758
1759// MainWindow returns the main window.
1760func (a *App) MainWindow() *window.MainWindow {
1761 return a.mainWindow
1762}
1763
1764// Quit requests the application to quit.
1765func (a *App) Quit() {
1766 if a.gtkApp != nil {
1767 a.gtkApp.Quit()
1768 }
1769}
1770
1771// RunWithArgs is a convenience function that creates and runs an App.
1772func RunWithArgs(ctx context.Context, deps *Dependencies) int {
1773 app, err := New(deps)
1774 if err != nil {
1775 log := logging.FromContext(ctx)
1776 log.Error().Err(err).Msg("failed to create application")
1777 return 1
1778 }
1779 return app.Run(ctx, os.Args)
1780}
1781
1782// updateWindowTitle updates the window title with the given page title.
1783// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
1784func (a *App) updateWindowTitle(pageTitle string) {
1785 if a.mainWindow == nil {
1786 return
1787 }
1788
1789 title := "Dumber"
1790 if pageTitle != "" {
1791 title = pageTitle + " - Dumber"
1792 }
1793 a.mainWindow.SetTitle(title)
1794}
1795
1796// updateWindowTitleFromActivePane updates the window title based on the current active pane.
1797func (a *App) updateWindowTitleFromActivePane() {
1798 ws := a.activeWorkspace()
1799 if ws == nil || a.contentCoord == nil {
1800 a.updateWindowTitle("")
1801 return
1802 }
1803 title := a.contentCoord.GetTitle(ws.ActivePaneID)
1804 a.updateWindowTitle(title)
1805}
1806
1807// handleModeChange is called when the input mode changes.
1808func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
1809 log := logging.FromContext(ctx)
1810 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
1811
1812 if from == input.ModeResize && to != input.ModeResize {
1813 a.clearResizeModeBorder()
1814 }
1815 if to == input.ModeResize {
1816 a.applyResizeModeBorder(ctx, a.activeWorkspace())
1817 }
1818
1819 // Update global border overlay visibility based on mode.
1820 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
1821 if a.borderMgr != nil {
1822 a.borderMgr.OnModeChange(ctx, from, to)
1823 }
1824
1825 // Show/hide mode indicator toaster based on config.
1826 a.updateModeIndicatorToaster(ctx, to)
1827}
1828
1829// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1830func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1831 if a.modeToaster == nil {
1832 return
1833 }
1834
1835 // Check if mode indicator toaster is enabled in config.
1836 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1837 a.modeToaster.Hide()
1838 return
1839 }
1840
1841 if mode == input.ModeNormal {
1842 a.modeToaster.Hide()
1843 return
1844 }
1845
1846 // Show persistent toaster at bottom-left with mode display name.
1847 // Mode class is applied atomically with Show() to avoid visual flicker.
1848 modeClass := getModeToastClass(mode)
1849 a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1850 component.WithDuration(0), // Persistent until mode exits.
1851 component.WithPosition(component.ToastPositionBottomLeft),
1852 component.WithModeClass(modeClass),
1853 )
1854}
1855
1856// getModeToastClass returns the CSS class for the given mode's toast styling.
1857func getModeToastClass(mode input.Mode) string {
1858 switch mode {
1859 case input.ModePane:
1860 return "toast-pane-mode"
1861 case input.ModeTab:
1862 return "toast-tab-mode"
1863 case input.ModeSession:
1864 return "toast-session-mode"
1865 case input.ModeResize:
1866 return "toast-resize-mode"
1867 default:
1868 return ""
1869 }
1870}
1871
1872func (a *App) clearResizeModeBorder() {
1873 if a.resizeModeBorderTarget != nil {
1874 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1875 a.resizeModeBorderTarget = nil
1876 }
1877}
1878
1879func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
1880 wsView := a.activeWorkspaceView()
1881 if wsView == nil || ws == nil {
1882 a.clearResizeModeBorder()
1883 return
1884 }
1885
1886 paneID := ws.ActivePaneID
1887 if paneID == "" {
1888 a.clearResizeModeBorder()
1889 return
1890 }
1891
1892 // Important: wrap the whole stack container when the active pane is in a stack.
1893 target := wsView.GetStackContainerWidget(paneID)
1894 if target == nil {
1895 a.clearResizeModeBorder()
1896 return
1897 }
1898
1899 if !target.HasCssClass("resize-mode-active") {
1900 target.AddCssClass("resize-mode-active")
1901 }
1902
1903 if a.resizeModeBorderTarget != target {
1904 if a.resizeModeBorderTarget != nil {
1905 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1906 } else {
1907 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
1908 }
1909 a.resizeModeBorderTarget = target
1910 }
1911}
1912
1913// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
1914func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
1915 a.createWorkspaceViewWithoutAttach(ctx, tab)
1916
1917 // Attach to content area
1918 wsView := a.workspaceViews[tab.ID]
1919 if wsView != nil && a.mainWindow != nil {
1920 widget := wsView.Widget()
1921 if widget != nil {
1922 gtkWidget := widget.GtkWidget()
1923 if gtkWidget != nil {
1924 a.mainWindow.SetContent(gtkWidget)
1925 }
1926 }
1927 }
1928}
1929
1930// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
1931// Used during session restoration where we create all views first, then attach only the active one.
1932func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
1933 log := logging.FromContext(ctx)
1934
1935 if a.widgetFactory == nil {
1936 log.Error().Msg("widget factory not initialized")
1937 return
1938 }
1939
1940 // Create workspace view
1941 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
1942 if wsView == nil {
1943 log.Error().Msg("failed to create workspace view")
1944 return
1945 }
1946 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
1947
1948 // Set the workspace
1949 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
1950 log.Error().Err(err).Msg("failed to set workspace in view")
1951 return
1952 }
1953
1954 // Ensure WebViews are attached to panes
1955 if a.contentCoord != nil {
1956 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1957 }
1958
1959 // Note: Mode borders for tab/pane/session are attached to MainWindow.
1960 // Resize mode border is attached to the active pane's stack container.
1961
1962 // Set omnibox config for this workspace view
1963 wsView.SetOmniboxConfig(a.omniboxCfg)
1964 // Set find bar config for this workspace view
1965 wsView.SetFindBarConfig(a.findBarCfg)
1966 // Set auto-open omnibox on new pane
1967 wsView.SetAutoOpenOnNewPane(config.Get().Omnibox.AutoOpenOnNewPane)
1968
1969 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1970 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
1971 ws := a.activeWorkspace()
1972 if ws != nil {
1973 ws.ActivePaneID = paneID
1974 }
1975 a.applyResizeModeBorder(ctx, ws)
1976 }
1977 })
1978
1979 wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
1980 if a.wsCoord != nil {
1981 _ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
1982 }
1983 })
1984
1985 // Store in map
1986 a.workspaceViews[tab.ID] = wsView
1987 a.reattachFloatingSessions(tab.ID, wsView)
1988 a.syncFloatingFocus()
1989
1990 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
1991}
1992
1993// activeWorkspace returns the workspace of the active tab.
1994func (a *App) activeWorkspace() *entity.Workspace {
1995 activeTab := a.tabs.ActiveTab()
1996 if activeTab == nil {
1997 return nil
1998 }
1999 return activeTab.Workspace
2000}
2001
2002// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
2003// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
2004func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
2005 for _, tab := range a.tabs.Tabs {
2006 if tab.Workspace == nil {
2007 continue
2008 }
2009 paneNode := tab.Workspace.FindPane(paneID)
2010 if paneNode != nil && paneNode.Pane != nil {
2011 paneNode.Pane.URI = url
2012 return // Pane IDs are unique, no need to continue
2013 }
2014 }
2015}
2016
2017// activeWorkspaceView returns the workspace view for the active tab.
2018func (a *App) activeWorkspaceView() *component.WorkspaceView {
2019 activeTab := a.tabs.ActiveTab()
2020 if activeTab == nil {
2021 return nil
2022 }
2023 return a.workspaceViews[activeTab.ID]
2024}
2025
2026// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
2027// Used by the accent picker to insert accented characters into web content.
2028func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2029 if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
2030 return nil
2031 }
2032
2033 wv := a.contentCoord.ActiveWebView(context.Background())
2034 if wv == nil {
2035 return nil
2036 }
2037
2038 // Get the underlying webkit.WebView for the text input target
2039 return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
2040}
2041
2042// attachPopupToTab attaches a popup WebView to a newly created tab.
2043// This is called when a popup uses tabbed behavior.
2044func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
2045 log := logging.FromContext(ctx)
2046
2047 wsView := a.workspaceViews[tabID]
2048 if wsView == nil {
2049 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
2050 return
2051 }
2052
2053 // Register WebView with content coordinator
2054 if a.contentCoord != nil {
2055 // Track the WebView
2056 a.contentCoord.RegisterPopupWebView(pane.ID, wv)
2057
2058 // Wrap and attach widget
2059 widget := a.contentCoord.WrapWidget(ctx, wv)
2060 if widget != nil {
2061 paneView := wsView.GetPaneView(pane.ID)
2062 if paneView != nil {
2063 paneView.SetWebViewWidget(widget)
2064 } else {
2065 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
2066 }
2067 }
2068 }
2069 log.Debug().
2070 Str("tab_id", string(tabID)).
2071 Str("pane_id", string(pane.ID)).
2072 Msg("popup webview attached to tab")
2073}
2074
2075// switchWorkspaceView swaps the displayed workspace view for a tab.
2076func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
2077 log := logging.FromContext(ctx)
2078
2079 wsView, exists := a.workspaceViews[tabID]
2080 if !exists {
2081 log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
2082 return
2083 }
2084
2085 // Get the workspace view's root widget
2086 widget := wsView.Widget()
2087 if widget == nil {
2088 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
2089 return
2090 }
2091
2092 gtkWidget := widget.GtkWidget()
2093 if gtkWidget == nil {
2094 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
2095 return
2096 }
2097
2098 // Swap content (MainWindow.SetContent now properly removes old content)
2099 if a.mainWindow != nil {
2100 a.mainWindow.SetContent(gtkWidget)
2101 }
2102
2103 // Update window title with the new active pane's title
2104 a.updateWindowTitleFromActivePane()
2105 a.syncFloatingFocus()
2106
2107 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
2108}
2109
2110func normalizeFloatingSessionID(sessionID string) string {
2111 sessionID = strings.TrimSpace(sessionID)
2112 if sessionID == "" {
2113 return floatingSessionIDDefault
2114 }
2115 return sessionID
2116}
2117
2118func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
2119 return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
2120}
2121
2122func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
2123 return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
2124}
2125
2126func decorateFloatingOverlay(overlay layout.OverlayWidget) {
2127 if overlay == nil {
2128 return
2129 }
2130
2131 overlay.SetHexpand(false)
2132 overlay.SetVexpand(false)
2133 overlay.AddCssClass("floating-pane-container")
2134 overlay.AddCssClass("pane-border")
2135 overlay.AddCssClass("pane-active")
2136}
2137
2138// showFloatingWidget makes a floating pane widget interactive and visible.
2139// Visibility is controlled by CSS class to animate opacity while keeping the
2140// widget mapped so WebKit can redraw without a hard pop.
2141func showFloatingWidget(w layout.Widget) {
2142 if w == nil {
2143 return
2144 }
2145 w.AddCssClass(floatingPaneVisibleClass)
2146 w.SetCanTarget(true)
2147 w.SetCanFocus(true)
2148}
2149
2150// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
2151// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
2152// widget mapped and WebKit retains its rendered surface in GPU memory.
2153func hideFloatingWidget(w layout.Widget) {
2154 if w == nil {
2155 return
2156 }
2157 w.RemoveCssClass(floatingPaneVisibleClass)
2158 w.SetCanTarget(false)
2159 w.SetCanFocus(false)
2160}
2161
2162// setFloatingWidgetShown applies show or hide based on the visible flag.
2163func setFloatingWidgetShown(w layout.Widget, visible bool) {
2164 if visible {
2165 showFloatingWidget(w)
2166 } else {
2167 hideFloatingWidget(w)
2168 }
2169}
2170
2171func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
2172 if workspaceOverlay == nil || floatingOverlay == nil {
2173 return
2174 }
2175
2176 workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
2177 workspaceOverlay.SetClipOverlay(floatingOverlay, false)
2178}
2179
2180func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
2181 if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
2182 return 0, 0, 0, 0, false
2183 }
2184
2185 width = desiredWidth
2186 height = desiredHeight
2187 if width > overlayWidth {
2188 width = overlayWidth
2189 }
2190 if height > overlayHeight {
2191 height = overlayHeight
2192 }
2193
2194 x = (overlayWidth - width) / 2
2195 y = (overlayHeight - height) / 2
2196 if x < 0 {
2197 x = 0
2198 }
2199 if y < 0 {
2200 y = 0
2201 }
2202
2203 return x, y, width, height, true
2204}
2205
2206type gtkAllocationLayout [4]int32
2207
2208const gtkAllocationLayoutSize = 16
2209
2210var (
2211 _ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
2212 _ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
2213)
2214
2215// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
2216// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
2217// 32-bit signed integers {x, y, width, height} in native endianness.
2218// If GTK struct layout changes, revisit offsets and writes below.
2219//
2220//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
2221func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
2222 if allocationPtr == nil {
2223 return false
2224 }
2225
2226 const (
2227 gtkAllocationOffsetX = uintptr(0)
2228 gtkAllocationOffsetY = uintptr(4)
2229 gtkAllocationOffsetWidth = uintptr(8)
2230 gtkAllocationOffsetHeight = uintptr(12)
2231 )
2232
2233 // GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
2234 base := unsafe.Pointer(allocationPtr)
2235 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetX)) = int32(x)
2236 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetY)) = int32(y)
2237 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetWidth)) = int32(width)
2238 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetHeight)) = int32(height)
2239 return true
2240}
2241
2242func (a *App) floatingAllocationForWidget(
2243 tabID entity.TabID,
2244 widgetPtr uintptr,
2245 overlayWidth, overlayHeight int,
2246) (x, y, width, height int, ok bool) {
2247 for key, session := range a.floatingSessions {
2248 if key.tabID != tabID || session == nil || session.widget == nil {
2249 continue
2250 }
2251
2252 gtkWidget := session.widget.GtkWidget()
2253 if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
2254 continue
2255 }
2256
2257 desiredWidth := session.appliedWidth
2258 desiredHeight := session.appliedHeight
2259 if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
2260 session.pane.Resize()
2261 desiredWidth, desiredHeight = session.pane.Dimensions()
2262 }
2263
2264 return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
2265 }
2266
2267 return 0, 0, 0, 0, false
2268}
2269
2270func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
2271 if workspaceOverlay == nil {
2272 return
2273 }
2274
2275 workspaceWidget := workspaceOverlay.GtkWidget()
2276 if workspaceWidget == nil {
2277 return
2278 }
2279
2280 gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
2281 if gtkOverlay == nil {
2282 return
2283 }
2284
2285 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
2286 overlayWidth := workspaceOverlay.GetAllocatedWidth()
2287 overlayHeight := workspaceOverlay.GetAllocatedHeight()
2288 x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
2289 if !ok {
2290 return false
2291 }
2292 return writeOverlayAllocation(allocationPtr, x, y, width, height)
2293 }
2294
2295 gtkOverlay.ConnectGetChildPosition(&cb)
2296}
2297
2298func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
2299 if a.deps != nil && a.deps.Config != nil {
2300 return a.deps.Config.Workspace.FloatingPane
2301 }
2302 return config.Get().Workspace.FloatingPane
2303}
2304
2305func (a *App) ensureFloatingSession(
2306 ctx context.Context,
2307 tabID entity.TabID,
2308 sessionID string,
2309 wsView *component.WorkspaceView,
2310) (*floatingWorkspaceSession, error) {
2311 key := floatingSessionMapKey(tabID, sessionID)
2312 if session, ok := a.floatingSessions[key]; ok && session != nil {
2313 if wsView == nil {
2314 return session, nil
2315 }
2316 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
2317 if session.widget != nil {
2318 wsView.AddWorkspaceOverlayWidget(session.widget)
2319 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
2320 }
2321 if session.paneView != nil {
2322 wsView.RegisterPaneView(session.paneID, session.paneView)
2323 if session.focusWidget == nil {
2324 session.focusWidget = session.paneView.WebViewWidget()
2325 }
2326 }
2327 return session, nil
2328 }
2329
2330 if wsView == nil {
2331 return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
2332 }
2333 if a.contentCoord == nil {
2334 return nil, fmt.Errorf("content coordinator not initialized")
2335 }
2336 if a.widgetFactory == nil {
2337 return nil, fmt.Errorf("widget factory not initialized")
2338 }
2339
2340 paneID := floatingPaneIDForSession(tabID, sessionID)
2341 wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
2342 if err != nil {
2343 return nil, fmt.Errorf("ensure floating webview: %w", err)
2344 }
2345
2346 webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
2347 if webViewWidget == nil {
2348 return nil, fmt.Errorf("wrap floating webview widget")
2349 }
2350 webViewWidget.SetHexpand(true)
2351 webViewWidget.SetVexpand(true)
2352
2353 // Wrap the WebView in a PaneView so it gets progress bar, loading
2354 // skeleton, and link status overlays — the same widget hierarchy as
2355 // regular workspace panes. This allows the content coordinator's
2356 // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
2357 // and update the floating pane automatically.
2358 pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)
2359 pv.SetActive(true)
2360 // Hide loading skeleton — floating panes have their own themed container.
2361 pv.HideLoadingSkeleton()
2362 wsView.RegisterPaneView(paneID, pv)
2363
2364 // Use the PaneView's overlay directly as the floating overlay.
2365 // This avoids nesting two overlays which causes GTK4 allocation
2366 // issues (inner overlay expanding to fill the parent's allocation).
2367 pvOverlay := pv.Overlay()
2368 decorateFloatingOverlay(pvOverlay)
2369 pvOverlay.SetHalign(gtk.AlignCenterValue)
2370 pvOverlay.SetValign(gtk.AlignCenterValue)
2371 hideFloatingWidget(pvOverlay)
2372 wsView.AddWorkspaceOverlayWidget(pvOverlay)
2373 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
2374
2375 floatingCfg := a.currentFloatingConfig()
2376 floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
2377 WidthPct: floatingCfg.WidthPct,
2378 HeightPct: floatingCfg.HeightPct,
2379 FallbackWidth: floatingPaneFallbackWidth,
2380 FallbackHeight: floatingPaneFallbackHeight,
2381 OnNavigate: func(navCtx context.Context, url string) error {
2382 return wv.LoadURI(navCtx, url)
2383 },
2384 })
2385
2386 session := &floatingWorkspaceSession{
2387 paneID: paneID,
2388 pane: floatingPane,
2389 paneView: pv,
2390 webView: wv,
2391 overlay: pvOverlay,
2392 widget: pvOverlay,
2393 focusWidget: webViewWidget,
2394 }
2395 a.floatingSessions[key] = session
2396 return session, nil
2397}
2398
2399func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
2400 sessions := make(map[floatingSessionKey]*floatingWorkspaceSession)
2401 for key, session := range a.floatingSessions {
2402 if key.tabID != tabID || session == nil {
2403 continue
2404 }
2405 sessions[key] = session
2406 }
2407 return sessions
2408}
2409
2410func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
2411 sessions := a.floatingSessionsForTab(tabID)
2412 if len(sessions) == 0 {
2413 return
2414 }
2415
2416 for key, session := range sessions {
2417 a.releaseFloatingSession(ctx, key, session)
2418 }
2419 a.syncFloatingFocus()
2420}
2421
2422func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
2423 activeTab := a.tabs.ActiveTab()
2424 if activeTab == nil {
2425 return floatingSessionKey{}, nil, false
2426 }
2427 for key, session := range a.floatingSessions {
2428 if key.tabID != activeTab.ID || session == nil || session.pane == nil {
2429 continue
2430 }
2431 if session.pane.IsVisible() {
2432 return key, session, true
2433 }
2434 }
2435 return floatingSessionKey{tabID: activeTab.ID}, nil, false
2436}
2437
2438func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
2439 key, session, ok := a.activeFloatingSessionEntry()
2440 if !ok {
2441 return nil, key.tabID
2442 }
2443 return session, key.tabID
2444}
2445
2446func (a *App) releaseFloatingSession(ctx context.Context, key floatingSessionKey, session *floatingWorkspaceSession) {
2447 if session == nil {
2448 return
2449 }
2450
2451 a.stopFloatingResizeWatcher(session)
2452 if session.pane != nil {
2453 session.pane.Hide(ctx)
2454 }
2455 a.hideFloatingOmnibox(ctx, session)
2456 if wsView := a.workspaceViews[key.tabID]; wsView != nil {
2457 if session.widget != nil {
2458 wsView.RemoveWorkspaceOverlayWidget(session.widget)
2459 }
2460 if session.paneID != "" {
2461 wsView.UnregisterPaneView(session.paneID)
2462 }
2463 }
2464 if a.contentCoord != nil && session.paneID != "" {
2465 a.contentCoord.ReleaseWebView(ctx, session.paneID)
2466 }
2467 delete(a.floatingSessions, key)
2468
2469 session.appliedWidth = 0
2470 session.appliedHeight = 0
2471 session.focusWidget = nil
2472 session.paneView = nil
2473 session.widget = nil
2474 session.overlay = nil
2475 session.webView = nil
2476 session.pane = nil
2477 // Session is fully released/zeroed; caller must drop this reference and not reuse it.
2478}
2479
2480func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
2481 if paneID == "" {
2482 return
2483 }
2484
2485 for _, session := range a.floatingSessions {
2486 if session == nil || session.pane == nil {
2487 continue
2488 }
2489 if session.paneID != paneID {
2490 continue
2491 }
2492 session.pane.RecordLoadedURL(url)
2493 return
2494 }
2495}
2496
2497func (a *App) syncFloatingFocus() {
2498 for _, wsView := range a.workspaceViews {
2499 if wsView != nil {
2500 wsView.SetHoverFocusLocked(false)
2501 }
2502 }
2503
2504 if a.contentCoord == nil {
2505 return
2506 }
2507
2508 activeTab := a.tabs.ActiveTab()
2509 if activeTab == nil {
2510 a.contentCoord.ClearActivePaneOverride()
2511 return
2512 }
2513
2514 if session, _ := a.activeFloatingSession(); session != nil {
2515 if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
2516 wsView.SetHoverFocusLocked(true)
2517 }
2518 a.startFloatingResizeWatcher(session)
2519 a.contentCoord.SetActivePaneOverride(session.paneID)
2520 return
2521 }
2522
2523 a.contentCoord.ClearActivePaneOverride()
2524}
2525
2526func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2527 if session == nil || session.overlay == nil || a.widgetFactory == nil {
2528 return
2529 }
2530
2531 if session.omnibox == nil {
2532 cfg := a.omniboxCfg
2533 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
2534 if a.appToaster != nil {
2535 a.appToaster.Show(toastCtx, message, level)
2536 }
2537 }
2538
2539 omnibox := component.NewOmnibox(ctx, cfg)
2540 if omnibox == nil {
2541 return
2542 }
2543
2544 omnibox.SetParentOverlay(session.overlay)
2545 omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
2546 if omniboxWidget == nil {
2547 return
2548 }
2549
2550 session.overlay.AddOverlay(omniboxWidget)
2551 session.overlay.SetClipOverlay(omniboxWidget, false)
2552 session.overlay.SetMeasureOverlay(omniboxWidget, false)
2553
2554 session.omnibox = omnibox
2555 session.omniboxWidget = omniboxWidget
2556 }
2557
2558 session.pane.SetOmniboxVisible(true)
2559 session.omnibox.Show(ctx, "")
2560}
2561
2562func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2563 if session == nil || session.pane == nil {
2564 return
2565 }
2566
2567 session.pane.SetOmniboxVisible(false)
2568 if session.omnibox == nil {
2569 return
2570 }
2571
2572 session.omnibox.Hide(ctx)
2573 if session.overlay != nil && session.omniboxWidget != nil {
2574 if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
2575 session.overlay.RemoveOverlay(session.omniboxWidget)
2576 } else if parent != nil {
2577 session.omniboxWidget.Unparent()
2578 }
2579 }
2580 session.omnibox = nil
2581 session.omniboxWidget = nil
2582}
2583
2584func floatingFocusWidget(session *floatingWorkspaceSession) layout.Widget {
2585 if session == nil {
2586 return nil
2587 }
2588 if session.focusWidget != nil {
2589 return session.focusWidget
2590 }
2591 if session.paneView != nil {
2592 return session.paneView.WebViewWidget()
2593 }
2594 return nil
2595}
2596
2597func focusFloatingSessionContent(session *floatingWorkspaceSession) {
2598 if session == nil || session.pane == nil {
2599 return
2600 }
2601 if !session.pane.IsVisible() || session.pane.IsOmniboxVisible() {
2602 return
2603 }
2604 w := floatingFocusWidget(session)
2605 if w == nil {
2606 return
2607 }
2608 w.GrabFocus()
2609}
2610
2611func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
2612 if session == nil || session.pane == nil {
2613 return
2614 }
2615 session.pane.Resize()
2616 if session.widget == nil {
2617 return
2618 }
2619 width, height := session.pane.Dimensions()
2620 if session.appliedWidth == width && session.appliedHeight == height {
2621 return
2622 }
2623 session.widget.SetSizeRequest(width, height)
2624 session.appliedWidth = width
2625 session.appliedHeight = height
2626}
2627
2628func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
2629 if session == nil || session.pane == nil {
2630 return false
2631 }
2632 if !session.pane.IsVisible() {
2633 session.resizeWatcherActive = false
2634 session.resizeTickID = 0
2635 return false
2636 }
2637
2638 a.resizeFloatingWidget(session)
2639 return true
2640}
2641
2642func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
2643 if paneID == "" {
2644 return nil
2645 }
2646 for _, session := range a.floatingSessions {
2647 if session == nil || session.paneID != paneID {
2648 continue
2649 }
2650 return session
2651 }
2652 return nil
2653}
2654
2655func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
2656 if session == nil || session.overlay == nil || session.resizeWatcherActive {
2657 return
2658 }
2659
2660 overlayWidget := session.overlay.GtkWidget()
2661 if overlayWidget == nil {
2662 return
2663 }
2664
2665 session.resizeWatcherActive = true
2666 paneID := session.paneID
2667 tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
2668 liveSession := a.floatingSessionByPaneID(paneID)
2669 if liveSession == nil {
2670 session.resizeWatcherActive = false
2671 session.resizeTickID = 0
2672 return false
2673 }
2674
2675 keepRunning := a.handleFloatingViewportTick(liveSession)
2676 if !keepRunning {
2677 liveSession.resizeWatcherActive = false
2678 liveSession.resizeTickID = 0
2679 }
2680 return keepRunning
2681 })
2682 session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)
2683}
2684
2685func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
2686 if session == nil {
2687 return
2688 }
2689
2690 if session.resizeTickID != 0 && session.overlay != nil {
2691 if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
2692 overlayWidget.RemoveTickCallback(session.resizeTickID)
2693 }
2694 }
2695 session.resizeTickID = 0
2696 session.resizeWatcherActive = false
2697}
2698
2699func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
2700 if session == nil || session.pane == nil {
2701 return
2702 }
2703
2704 a.stopFloatingResizeWatcher(session)
2705 session.pane.Hide(ctx)
2706 a.hideFloatingOmnibox(ctx, session)
2707 hideFloatingWidget(session.widget)
2708 // Force a fresh size request on next show so WebKit gets a new
2709 // allocation cycle and repaints content after rapid toggles.
2710 session.appliedWidth = 0
2711 session.appliedHeight = 0
2712}
2713
2714func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
2715 for key, session := range a.floatingSessions {
2716 if key.tabID != tabID || session == nil || session == except || session.pane == nil {
2717 continue
2718 }
2719 if !session.pane.IsVisible() {
2720 continue
2721 }
2722 a.hideFloatingSession(ctx, session)
2723 }
2724}
2725
2726func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
2727 session, _ := a.activeFloatingSession()
2728 if session == nil {
2729 return false
2730 }
2731 a.hideFloatingSession(ctx, session)
2732 a.syncFloatingFocus()
2733 return true
2734}
2735
2736func (a *App) handleGlobalEscape(ctx context.Context) bool {
2737 return a.closeActiveFloatingPane(ctx)
2738}
2739
2740func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
2741 key, session, ok := a.activeFloatingSessionEntry()
2742 if !ok {
2743 return false
2744 }
2745 a.releaseFloatingSession(ctx, key, session)
2746 a.syncFloatingFocus()
2747 return true
2748}
2749
2750// ToggleFloatingPane toggles the active workspace floating pane visibility.
2751func (a *App) ToggleFloatingPane(ctx context.Context) error {
2752 activeTab := a.tabs.ActiveTab()
2753 if activeTab == nil {
2754 return nil
2755 }
2756 if a.closeActiveFloatingPane(ctx) {
2757 return nil
2758 }
2759 return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
2760}
2761
2762// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
2763func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
2764 return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
2765}
2766
2767// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
2768func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
2769 return a.openFloatingPaneSession(ctx, sessionID, url)
2770}
2771
2772func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
2773 activeTab := a.tabs.ActiveTab()
2774 if activeTab == nil {
2775 return nil
2776 }
2777
2778 wsView := a.workspaceViews[activeTab.ID]
2779 session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
2780 if err != nil {
2781 return err
2782 }
2783 hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2784 isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
2785
2786 if hasURL && isProfileSession && session.pane.IsVisible() {
2787 a.hideFloatingSession(ctx, session)
2788 a.syncFloatingFocus()
2789 return nil
2790 }
2791
2792 a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
2793
2794 if hasURL {
2795 if isProfileSession && session.pane.SessionStarted() {
2796 session.pane.Show()
2797 } else {
2798 if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2799 return err
2800 }
2801 }
2802 } else {
2803 if err := session.pane.ShowToggle(ctx); err != nil {
2804 return err
2805 }
2806 }
2807
2808 a.resizeFloatingWidget(session)
2809 setFloatingWidgetShown(session.widget, session.pane.IsVisible())
2810 if session.pane.IsOmniboxVisible() {
2811 a.showFloatingOmnibox(ctx, session)
2812 } else {
2813 a.hideFloatingOmnibox(ctx, session)
2814 focusFloatingSessionContent(session)
2815 }
2816 a.startFloatingResizeWatcher(session)
2817 a.syncFloatingFocus()
2818
2819 return nil
2820}
2821
2822func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
2823 session, _ := a.activeFloatingSession()
2824 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
2825 return session.pane.Navigate(ctx, url)
2826 }
2827 if a.omniboxNavigateFn != nil {
2828 a.omniboxNavigateFn(url)
2829 return nil
2830 }
2831 if a.navCoord == nil {
2832 return fmt.Errorf("navigation coordinator not initialized")
2833 }
2834 return a.navCoord.Navigate(ctx, url)
2835}
2836
2837// ToggleOmnibox implements OmniboxProvider.
2838// Toggles the omnibox visibility in the active workspace view.
2839func (a *App) ToggleOmnibox(ctx context.Context) {
2840 log := logging.FromContext(ctx)
2841 if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() {
2842 wsView := a.activeWorkspaceView()
2843 if wsView != nil && wsView.IsOmniboxVisible() {
2844 wsView.HideOmnibox()
2845 }
2846
2847 if session.omnibox != nil && session.pane.IsOmniboxVisible() {
2848 a.hideFloatingOmnibox(ctx, session)
2849 } else {
2850 a.showFloatingOmnibox(ctx, session)
2851 }
2852 a.syncFloatingFocus()
2853 return
2854 }
2855
2856 wsView := a.activeWorkspaceView()
2857 if wsView == nil {
2858 log.Warn().Msg("no active workspace view for omnibox toggle")
2859 return
2860 }
2861
2862 if wsView.IsOmniboxVisible() {
2863 wsView.HideOmnibox()
2864 } else {
2865 wsView.ShowOmnibox(ctx, "")
2866 }
2867}
2868
2869// ToggleFindBar shows or hides the find bar in the active workspace view.
2870func (a *App) ToggleFindBar(ctx context.Context) {
2871 log := logging.FromContext(ctx)
2872
2873 wsView := a.activeWorkspaceView()
2874 if wsView == nil {
2875 log.Warn().Msg("no active workspace view for find bar toggle")
2876 return
2877 }
2878
2879 if wsView.IsFindBarVisible() {
2880 wsView.HideFindBar()
2881 } else {
2882 wsView.ShowFindBar(ctx)
2883 }
2884}
2885
2886// FindNext selects the next match in the active find bar.
2887func (a *App) FindNext(ctx context.Context) {
2888 wsView := a.activeWorkspaceView()
2889 if wsView == nil {
2890 return
2891 }
2892
2893 if !wsView.IsFindBarVisible() {
2894 wsView.ShowFindBar(ctx)
2895 }
2896 wsView.FindNext()
2897}
2898
2899// FindPrevious selects the previous match in the active find bar.
2900func (a *App) FindPrevious(ctx context.Context) {
2901 wsView := a.activeWorkspaceView()
2902 if wsView == nil {
2903 return
2904 }
2905
2906 if !wsView.IsFindBarVisible() {
2907 wsView.ShowFindBar(ctx)
2908 }
2909 wsView.FindPrevious()
2910}
2911
2912// CloseFindBar hides the find bar if visible.
2913func (a *App) CloseFindBar(ctx context.Context) {
2914 wsView := a.activeWorkspaceView()
2915 if wsView == nil {
2916 return
2917 }
2918 wsView.HideFindBar()
2919}
2920
2921// UpdateOmniboxZoom implements OmniboxProvider.
2922// Updates the zoom indicator on the current omnibox if visible.
2923func (a *App) UpdateOmniboxZoom(factor float64) {
2924 wsView := a.activeWorkspaceView()
2925 if wsView == nil {
2926 return
2927 }
2928
2929 omnibox := wsView.GetOmnibox()
2930 if omnibox != nil {
2931 omnibox.UpdateZoomIndicator(factor)
2932 }
2933}
2934
2935// SetOmniboxOnNavigate implements OmniboxProvider.
2936// This is called to set the navigation callback on new omniboxes.
2937// Since omniboxes are created per-pane, we store the config with the callback.
2938func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
2939 a.omniboxNavigateFn = fn
2940}
2941
2942// initFilteringAsync starts background filter loading with toast feedback.
2943func (a *App) initConfigWatcher(ctx context.Context) {
2944 log := logging.FromContext(ctx)
2945
2946 if a.configManager == nil {
2947 log.Debug().Msg("no config manager available, skipping watcher")
2948 return
2949 }
2950
2951 // Start viper watcher
2952 if err := a.configManager.Watch(); err != nil {
2953 log.Warn().Err(err).Msg("failed to start config watcher")
2954 return
2955 }
2956
2957 // Hot-reload appearance and keybindings on config change.
2958 a.configManager.OnConfigChange(func(newCfg *config.Config) {
2959 cfgCopy := newCfg
2960 cb := glib.SourceFunc(func(_ uintptr) bool {
2961 a.applyAppearanceConfig(ctx, cfgCopy)
2962 // Reload keyboard shortcuts
2963 if a.keyboardHandler != nil {
2964 a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy)
2965 }
2966 return false
2967 })
2968 glib.IdleAdd(&cb, 0)
2969 })
2970
2971 log.Debug().Msg("config watcher initialized")
2972}
2973
2974func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
2975 log := logging.FromContext(ctx)
2976 if cfg == nil {
2977 return
2978 }
2979
2980 // Update the shared config pointer in-place so existing references see changes.
2981 if a.deps != nil && a.deps.Config != nil {
2982 *a.deps.Config = *cfg
2983 }
2984
2985 // Update WebKit settings manager
2986 if a.settings != nil {
2987 a.settings.UpdateFromConfig(ctx, cfg)
2988 }
2989
2990 // Apply settings to existing webviews via coordinator
2991 if a.contentCoord != nil {
2992 a.contentCoord.ApplySettingsToAll(ctx, a.settings)
2993 }
2994
2995 // Update GTK theme and injected WebUI theme vars
2996 if a.deps != nil && a.deps.Theme != nil {
2997 var display *gdk.Display
2998 if a.mainWindow != nil && a.mainWindow.Window() != nil {
2999 display = a.mainWindow.Window().GetDisplay()
3000 }
3001 a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
3002
3003 // Update find highlight CSS for future navigations
3004 if a.injector != nil {
3005 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
3006 if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
3007 log.Warn().Err(err).Msg("failed to update find highlight CSS")
3008 }
3009 }
3010
3011 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
3012 cssText := a.deps.Theme.GetWebUIThemeCSS()
3013 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
3014 log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
3015 }
3016
3017 // Refresh injected scripts so future navigations use the latest theme.
3018 if a.contentCoord != nil && a.injector != nil {
3019 a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
3020 }
3021
3022 // Apply WebUI theme to already-loaded dumb:// pages
3023 if a.contentCoord != nil {
3024 a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
3025 }
3026 }
3027
3028 log.Info().Msg("appearance config updated")
3029}
3030
3031func (a *App) initFilteringAsync(ctx context.Context) {
3032 if a.deps.FilterManager == nil {
3033 return
3034 }
3035
3036 a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
3037 statusCopy := status // Capture for closure
3038 cb := glib.SourceFunc(func(_ uintptr) bool {
3039 a.showFilterStatus(ctx, statusCopy)
3040 return false // Don't repeat
3041 })
3042 glib.IdleAdd(&cb, 0)
3043 })
3044 a.deps.FilterManager.LoadAsync(ctx)
3045}
3046
3047// showFilterStatus displays toast notification for filter status.
3048func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
3049 log := logging.FromContext(ctx)
3050
3051 switch status.State {
3052 case filtering.StateLoading:
3053 if a.appToaster != nil {
3054 a.appToaster.Show(ctx, status.Message, component.ToastInfo)
3055 }
3056 case filtering.StateActive:
3057 // Apply filters to existing webviews that were created before filters loaded
3058 if a.contentCoord != nil && a.deps.FilterManager != nil {
3059 a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
3060 log.Debug().Msg("applied filters to all existing webviews")
3061 }
3062 if a.appToaster != nil {
3063 a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
3064 }
3065 case filtering.StateError:
3066 if a.appToaster != nil {
3067 a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
3068 }
3069 }
3070}