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