detached af0abd1

refactor: localize multi-window shell chrome and routing

commit
af0abd1612f40f3996cf1865d4d0652305e2bb5a
author
brice · · 2026-04-15 05:02 UTC
parent
81d2160
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +1279 -379
M internal/ui/app.go +247 -314
@@ -67,6 +67,7 @@ type App struct {
6767 mainWindow *window.MainWindow
6868
6969 browserWindows map[string]*browserWindow
70+ lastFocusedWindowID string
7071 browserWindowFactory func(context.Context, string) (*browserWindow, error)
7172 tabCreationWindow *browserWindow
7273 dispatchOnMainThread func(func())
@@ -93,9 +94,8 @@ type App struct {
9394 keyboardHandler *input.KeyboardHandler
9495 globalShortcutHandler *input.GlobalShortcutHandler
9596
96- // Focus and border management
97- focusMgr *focus.Manager
98- borderMgr *focus.BorderManager
97+ // Focus tracking stays app-global.
98+ focusMgr *focus.Manager
9999
100100 resizeModeBorderTarget layout.Widget
101101
@@ -113,16 +113,6 @@ type App struct {
113113 // Web content (managed by content.Coordinator)
114114 faviconAdapter *adapter.FaviconAdapter
115115
116- // App-level toaster for system notifications (filter status, etc.)
117- appToaster *component.Toaster
118- // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
119- modeToaster *component.Toaster
120-
121- // Session management
122- sessionManager *component.SessionManager
123- tabPicker *component.TabPicker
124- tabPickerWidget layout.Widget
125- tabPickerPaneID entity.PaneID
126116 snapshotService port.SnapshotService
127117
128118 // Update management
@@ -138,8 +128,6 @@ type App struct {
138128 movePaneToTabUC *usecase.MovePaneToTabUseCase
139129
140130 // Accent picker for dead keys support
141- accentPicker *component.AccentPicker
142- insertAccentUC *usecase.InsertAccentUseCase
143131 accentFocusProvider port.FocusedInputProvider
144132
145133 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
@@ -206,12 +194,10 @@ func New(deps *Dependencies) (*App, error) {
206194 }},
207195 OnClipboardCopied: func(textLen int) {
208196 cb := glib.SourceFunc(func(_ uintptr) bool {
209- if app.appToaster != nil {
210- app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
211- component.WithDuration(component.ToastBriefDurationMs),
212- component.WithPosition(component.ToastPositionBottomRight),
213- )
214- }
197+ app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,
198+ component.WithDuration(component.ToastBriefDurationMs),
199+ component.WithPosition(component.ToastPositionBottomRight),
200+ )
215201 return false
216202 })
217203 glib.IdleAdd(&cb, 0)
@@ -285,6 +271,7 @@ func (a *App) onActivate(ctx context.Context) {
285271 // Configure pool background color early (prevents white flash), but avoid
286272 // synchronous prewarming that delays the first navigation on cold start.
287273 a.setupPoolBackgroundColor(ctx)
274+ a.initLayoutInfrastructure()
288275
289276 if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
290277 log.Error().Err(err).Msg("failed to create main window")
@@ -292,11 +279,8 @@ func (a *App) onActivate(ctx context.Context) {
292279 }
293280 logging.Trace().Mark("window_created")
294281
295- a.initLayoutInfrastructure()
296- a.initAppToasterOverlay()
297282 a.installCrashReportNotifier(ctx)
298- a.initFocusAndBorderOverlay()
299- a.initAccentPicker(ctx)
283+ a.initFocusManager()
300284 a.registerAccentHandlers(ctx)
301285 a.initDownloadHandler(ctx)
302286
@@ -306,8 +290,7 @@ func (a *App) onActivate(ctx context.Context) {
306290 a.initKeyboardHandler(ctx)
307291 a.initOmniboxConfig(ctx)
308292 a.initFindBarConfig(ctx)
309- a.initSessionManager(ctx)
310- a.initTabPicker(ctx)
293+ a.wireSessionManagerShortcut()
311294 a.initSnapshotService(ctx)
312295 a.initUpdateCoordinator(ctx)
313296 a.createInitialTab(ctx)
@@ -395,7 +378,13 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
395378
396379 func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) {
397380 if a.browserWindowFactory != nil {
398- return a.browserWindowFactory(ctx, initialURL)
381+ created, err := a.browserWindowFactory(ctx, initialURL)
382+ if err != nil {
383+ return nil, err
384+ }
385+ a.wireBrowserWindowActivationTracking(created)
386+ created.initChrome(ctx, a)
387+ return created, nil
399388 }
400389
401390 log := logging.FromContext(ctx)
@@ -408,6 +397,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
408397 initialURL: initialURL,
409398 mainWindow: mainWindow,
410399 }
400+ a.wireBrowserWindowActivationTracking(browserWindow)
411401 if a.mainWindow == nil {
412402 a.mainWindow = mainWindow
413403 }
@@ -450,6 +440,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
450440 if a.tabCoord != nil {
451441 a.wireBrowserWindowTabBar(ctx, browserWindow)
452442 }
443+ browserWindow.initChrome(ctx, a)
453444
454445 // Apply GTK CSS styling from theme manager.
455446 if a.deps == nil || a.deps.Theme == nil {
@@ -506,10 +497,82 @@ func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
506497 return nil
507498 }
508499
500+func (a *App) lastFocusedBrowserWindow() *browserWindow {
501+ if a.lastFocusedWindowID == "" || a.browserWindows == nil {
502+ return nil
503+ }
504+ return a.browserWindows[a.lastFocusedWindowID]
505+}
506+
507+func (a *App) showToastOnBrowserWindow(
508+ ctx context.Context,
509+ bw *browserWindow,
510+ message string,
511+ level component.ToastLevel,
512+ opts ...component.ToastOption,
513+) {
514+ if bw == nil || bw.appToaster == nil {
515+ return
516+ }
517+ bw.appToaster.Show(ctx, message, level, opts...)
518+}
519+
520+func (a *App) showToastOnLastFocusedBrowserWindow(
521+ ctx context.Context,
522+ message string,
523+ level component.ToastLevel,
524+ opts ...component.ToastOption,
525+) {
526+ a.showToastOnBrowserWindow(ctx, a.lastFocusedBrowserWindow(), message, level, opts...)
527+}
528+
529+func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.PaneID) *browserWindow {
530+ if paneID != "" {
531+ if bw := a.browserWindowForPane(paneID); bw != nil {
532+ return bw
533+ }
534+ }
535+ if tabID != "" {
536+ if bw := a.browserWindowForTab(tabID); bw != nil {
537+ return bw
538+ }
539+ }
540+ return a.lastFocusedBrowserWindow()
541+}
542+
543+func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {
544+ a.updateWindowTitle(title, a.browserWindowForPane(paneID))
545+}
546+
547+func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) {
548+ bw := a.browserWindowForPane(paneID)
549+ if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil {
550+ return
551+ }
552+ if entering {
553+ bw.mainWindow.TabBar().SetVisible(false)
554+ return
555+ }
556+ a.updateBrowserWindowTabBarVisibility(bw)
557+}
558+
559+func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) {
560+ if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil {
561+ return
562+ }
563+ if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab {
564+ bw.mainWindow.TabBar().SetVisible(true)
565+ return
566+ }
567+ shouldShow := bw.mainWindow.TabBar().Count() > 1
568+ bw.mainWindow.TabBar().SetVisible(shouldShow)
569+}
570+
509571 func (a *App) activateBrowserWindow(bw *browserWindow) {
510572 if bw == nil {
511573 return
512574 }
575+ a.lastFocusedWindowID = bw.id
513576 if bw.mainWindow != nil {
514577 a.mainWindow = bw.mainWindow
515578 }
@@ -521,6 +584,23 @@ func (a *App) activateBrowserWindow(bw *browserWindow) {
521584 if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil {
522585 a.tabs.SetActive(bw.activeTabID)
523586 }
587+ a.updateWindowTitleFromActivePane(bw.activeTabID)
588+}
589+
590+func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {
591+ if !active || bw == nil {
592+ return
593+ }
594+ a.activateBrowserWindow(bw)
595+}
596+
597+func (a *App) wireBrowserWindowActivationTracking(bw *browserWindow) {
598+ if bw == nil || bw.mainWindow == nil {
599+ return
600+ }
601+ bw.mainWindow.ConnectActiveNotify(func(active bool) {
602+ a.handleBrowserWindowActivationChanged(bw, active)
603+ })
524604 }
525605
526606 func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
@@ -674,36 +754,6 @@ func (a *App) initLayoutInfrastructure() {
674754 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
675755 }
676756
677-func (a *App) initAppToasterOverlay() {
678- if a.mainWindow == nil {
679- return
680- }
681-
682- // Create app-level toaster for system notifications.
683- a.appToaster = component.NewToaster(a.widgetFactory)
684- toasterWidget := a.appToaster.Widget()
685- if toasterWidget == nil {
686- return
687- }
688- gtkWidget := toasterWidget.GtkWidget()
689- if gtkWidget == nil {
690- return
691- }
692- a.mainWindow.AddOverlay(gtkWidget)
693-
694- // Create mode indicator toaster for modal mode notifications.
695- a.modeToaster = component.NewToaster(a.widgetFactory)
696- modeToasterWidget := a.modeToaster.Widget()
697- if modeToasterWidget == nil {
698- return
699- }
700- modeGtkWidget := modeToasterWidget.GtkWidget()
701- if modeGtkWidget == nil {
702- return
703- }
704- a.mainWindow.AddOverlay(modeGtkWidget)
705-}
706-
707757 func (a *App) installCrashReportNotifier(ctx context.Context) {
708758 if a.deps == nil {
709759 return
@@ -714,92 +764,49 @@ func (a *App) installCrashReportNotifier(ctx context.Context) {
714764 }
715765 }
716766
717-func (a *App) initFocusAndBorderOverlay() {
718- // Initialize focus manager for geometric navigation.
767+func (a *App) initFocusManager() {
719768 a.focusMgr = focus.NewManager(a.panesUC)
720-
721- // Initialize border manager for mode indicators.
722- a.borderMgr = focus.NewBorderManager(a.widgetFactory)
723-
724- // Attach border overlay to main window (visible for all tabs).
725- if a.borderMgr == nil || a.mainWindow == nil {
726- return
727- }
728- borderWidget := a.borderMgr.Widget()
729- if borderWidget == nil {
730- return
731- }
732- gtkWidget := borderWidget.GtkWidget()
733- if gtkWidget == nil {
734- return
735- }
736- a.mainWindow.AddOverlay(gtkWidget)
737769 }
738770
739-func (a *App) initAccentPicker(ctx context.Context) {
740- log := logging.FromContext(ctx)
741-
742- if a.widgetFactory == nil || a.mainWindow == nil {
743- log.Debug().Msg("widget factory or main window not available, skipping accent picker")
771+func (a *App) wireSessionManagerShortcut() {
772+ if a.kbDispatcher == nil {
744773 return
745774 }
746-
747- // Create accent picker component
748- a.accentPicker = component.NewAccentPicker(a.widgetFactory)
749- if a.accentPicker == nil {
750- log.Warn().Msg("failed to create accent picker")
751- return
752- }
753-
754- // Add to main window overlay
755- pickerWidget := a.accentPicker.Widget()
756- if pickerWidget != nil {
757- gtkWidget := pickerWidget.GtkWidget()
758- if gtkWidget != nil {
759- a.mainWindow.AddOverlay(gtkWidget)
775+ a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context, paneID entity.PaneID) error {
776+ bw := a.ownerOrLastFocusedBrowserWindow("", paneID)
777+ if bw == nil || bw.sessionManager == nil {
778+ return nil
760779 }
761- }
762-
763- // Use focus provider from deps (tracks which input has focus)
764- a.accentFocusProvider = a.deps.AccentFocusProvider
765-
766- // Create the use case with glib.IdleAdd for thread-safe GTK calls
767- a.insertAccentUC = usecase.NewInsertAccentUseCase(
768- a.accentFocusProvider,
769- a.accentPicker,
770- func(fn func()) {
771- cb := glib.SourceFunc(func(_ uintptr) bool {
772- fn()
773- return false // Don't repeat
774- })
775- glib.IdleAdd(&cb, 0)
776- },
777- )
778-
779- log.Debug().Msg("accent picker initialized")
780+ bw.sessionManager.Toggle(ctx)
781+ return nil
782+ })
780783 }
781784
782785 // registerAccentHandlers registers the accent key press/release message handlers
783-// with the router. Must be called after initAccentPicker so insertAccentUC is non-nil.
786+// with the router.
784787 func (a *App) registerAccentHandlers(ctx context.Context) {
785788 log := logging.FromContext(ctx)
786789
787- if a.insertAccentUC == nil {
788- log.Debug().Msg("insertAccentUC not available, skipping accent handler registration")
789- return
790- }
791790 if a.engine == nil {
792791 log.Debug().Msg("engine not available, skipping accent handler registration")
793792 return
794793 }
795794
796- if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil {
795+ if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil {
797796 log.Error().Err(err).Msg("failed to register accent handlers")
798797 } else {
799798 log.Info().Msg("registered accent key handlers")
800799 }
801800 }
802801
802+func (a *App) activeAccentHandler() input.AccentHandler {
803+ bw := a.lastFocusedBrowserWindow()
804+ if bw == nil {
805+ return nil
806+ }
807+ return bw.insertAccentUC
808+}
809+
803810 func (a *App) initDownloadHandler(ctx context.Context) {
804811 log := logging.FromContext(ctx)
805812
@@ -853,17 +860,13 @@ type downloadEventAdapter struct {
853860 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
854861 // Must schedule on GTK main thread.
855862 cb := glib.SourceFunc(func(_ uintptr) bool {
856- if d.app.appToaster == nil {
857- return false
858- }
859-
860863 switch event.Type {
861864 case port.DownloadEventStarted:
862- d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
865+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo)
863866 case port.DownloadEventFinished:
864- d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
867+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
865868 case port.DownloadEventFailed:
866- d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
869+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError)
867870 }
868871 return false
869872 })
@@ -896,6 +899,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
896899 if bw == nil || bw.mainWindow == nil || a.deps == nil || a.deps.Config == nil || a.kbDispatcher == nil {
897900 return
898901 }
902+ if bw.keyboardHandler != nil || bw.globalShortcutHandler != nil {
903+ return
904+ }
899905
900906 bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
901907 bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error {
@@ -916,10 +922,10 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
916922 a.handleModeChange(ctx, from, to)
917923 })
918924 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
919- if a.sessionManager != nil && a.sessionManager.IsVisible() {
925+ if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
920926 return input.RoutePassToWidget
921927 }
922- if a.tabPicker != nil && a.tabPicker.IsVisible() {
928+ if bw.tabPicker != nil && bw.tabPicker.IsVisible() {
923929 return input.RoutePassToWidget
924930 }
925931
@@ -941,9 +947,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
941947
942948 return input.RouteHandleShortcuts
943949 })
944- if a.insertAccentUC != nil {
945- bw.keyboardHandler.SetAccentHandler(a.insertAccentUC)
946- }
950+ bw.keyboardHandler.SetAccentHandler(a)
947951 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())
948952
949953 bw.globalShortcutHandler = input.NewGlobalShortcutHandler(
@@ -981,7 +985,8 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) {
981985 // handleAccentKeyPress handles accent key press events for GTK entry widgets
982986 // (omnibox and find bar). Returns true if the key was consumed.
983987 func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool {
984- if a.insertAccentUC == nil {
988+ handler := a.activeAccentHandler()
989+ if handler == nil {
985990 return false
986991 }
987992 if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 {
@@ -989,7 +994,7 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M
989994 }
990995 shiftHeld := state&gdk.ShiftMaskValue != 0
991996 if char := input.KeyvalToRune(keyval); char != 0 {
992- return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld)
997+ return handler.OnKeyPressed(ctx, char, shiftHeld)
993998 }
994999 return false
9951000 }
@@ -997,12 +1002,45 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M
9971002 // handleAccentKeyRelease handles accent key release events for GTK entry widgets
9981003 // (omnibox and find bar).
9991004 func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {
1000- if a.insertAccentUC == nil {
1005+ handler := a.activeAccentHandler()
1006+ if handler == nil {
10011007 return
10021008 }
10031009 if char := input.KeyvalToRune(keyval); char != 0 {
1004- a.insertAccentUC.OnKeyReleased(ctx, char)
1010+ handler.OnKeyReleased(ctx, char)
1011+ }
1012+}
1013+
1014+func (a *App) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
1015+ handler := a.activeAccentHandler()
1016+ if handler == nil {
1017+ return false
1018+ }
1019+ return handler.OnKeyPressed(ctx, char, shiftHeld)
1020+}
1021+
1022+func (a *App) OnKeyReleased(ctx context.Context, char rune) {
1023+ handler := a.activeAccentHandler()
1024+ if handler == nil {
1025+ return
10051026 }
1027+ handler.OnKeyReleased(ctx, char)
1028+}
1029+
1030+func (a *App) IsPickerVisible() bool {
1031+ handler := a.activeAccentHandler()
1032+ if handler == nil {
1033+ return false
1034+ }
1035+ return handler.IsPickerVisible()
1036+}
1037+
1038+func (a *App) Cancel(ctx context.Context) {
1039+ handler := a.activeAccentHandler()
1040+ if handler == nil {
1041+ return
1042+ }
1043+ handler.Cancel(ctx)
10061044 }
10071045
10081046 type omniboxCallbacks struct {
@@ -1168,89 +1206,18 @@ func (a *App) initFindBarConfig(ctx context.Context) {
11681206 }
11691207 }
11701208
1171-func (a *App) initSessionManager(ctx context.Context) {
1172- log := logging.FromContext(ctx)
1173-
1174- if a.deps == nil {
1175- log.Debug().Msg("deps not available, skipping session manager")
1176- return
1177- }
1178-
1179- // Session manager can work without a repo - it just shows an empty list
1180- var listSessionsUC *usecase.ListSessionsUseCase
1181- var deleteSessionUC *usecase.DeleteSessionUseCase
1182- if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
1183- listSessionsUC = usecase.NewListSessionsUseCase(
1184- a.deps.SessionRepo,
1185- a.deps.SessionStateRepo,
1186- )
1187- deleteSessionUC = usecase.NewDeleteSessionUseCase(
1188- a.deps.SessionStateRepo,
1189- a.deps.SessionRepo,
1190- )
1191- }
1192-
1193- // Use injected session spawner for restoration
1194- spawner := a.deps.SessionSpawner
1195-
1196- // Create session manager component
1197- a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
1198- ListSessionsUC: listSessionsUC,
1199- DeleteSessionUC: deleteSessionUC,
1200- CurrentSession: a.deps.CurrentSessionID,
1201- UIScale: a.deps.Config.DefaultUIScale,
1202- OnClose: func() {
1203- log.Debug().Msg("session manager closed")
1204- },
1205- OnOpen: func(sessionID entity.SessionID) {
1206- log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
1207- if spawner == nil {
1208- log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
1209- return
1210- }
1211- if err := spawner.SpawnWithSession(sessionID); err != nil {
1212- log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
1213- }
1214- },
1215- OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
1216- if a.appToaster != nil {
1217- a.appToaster.Show(ctx, message, level)
1218- }
1219- },
1220- })
1221-
1222- if a.sessionManager == nil {
1223- log.Warn().Msg("failed to create session manager")
1224- return
1225- }
1226-
1227- // Add session manager to main window overlay
1228- if a.mainWindow != nil {
1229- widget := a.sessionManager.Widget()
1230- if widget != nil {
1231- a.mainWindow.AddOverlay(widget)
1232- }
1233- }
1234-
1235- // Wire session manager action to keyboard dispatcher
1236- a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
1237- a.ToggleSessionManager(ctx)
1238- return nil
1239- })
1240-
1241- log.Debug().Msg("session manager initialized")
1242-}
1243-
12441209 // ToggleSessionManager shows or hides the session manager.
12451210 func (a *App) ToggleSessionManager(ctx context.Context) {
1246- if a.sessionManager == nil {
1211+ bw := a.lastFocusedBrowserWindow()
1212+ if bw == nil || bw.sessionManager == nil {
12471213 return
12481214 }
1249- a.sessionManager.Toggle(ctx)
1215+ bw.sessionManager.Toggle(ctx)
12501216 }
12511217
12521218 func (a *App) attachTabPickerToActivePane() {
1253- if a.tabPicker == nil || a.widgetFactory == nil {
1219+ bw := a.lastFocusedBrowserWindow()
1220+ if bw == nil || bw.tabPicker == nil || a.widgetFactory == nil {
12541221 return
12551222 }
12561223 wsView := a.activeWorkspaceView()
@@ -1271,24 +1238,24 @@ func (a *App) attachTabPickerToActivePane() {
12711238 return
12721239 }
12731240
1274- if a.tabPickerWidget == nil {
1275- a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
1276- if a.tabPickerWidget == nil {
1241+ if bw.tabPickerWidget == nil {
1242+ bw.tabPickerWidget = bw.tabPicker.WidgetAsLayout(a.widgetFactory)
1243+ if bw.tabPickerWidget == nil {
12771244 return
12781245 }
12791246 }
12801247
12811248 // If currently attached to a different pane overlay, detach.
1282- if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
1249+ if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID {
12831250 for _, view := range a.workspaceViews {
12841251 if view == nil {
12851252 continue
12861253 }
1287- if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
1288- if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
1289- oldPV.RemoveOverlayWidget(a.tabPickerWidget)
1254+ if oldPV := view.GetPaneView(bw.tabPickerPaneID); oldPV != nil {
1255+ if parent := bw.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
1256+ oldPV.RemoveOverlayWidget(bw.tabPickerWidget)
12901257 } else if parent != nil {
1291- a.tabPickerWidget.Unparent()
1258+ bw.tabPickerWidget.Unparent()
12921259 }
12931260 break
12941261 }
@@ -1296,20 +1263,21 @@ func (a *App) attachTabPickerToActivePane() {
12961263 }
12971264
12981265 // Ensure the widget can be reparented.
1299- if parent := a.tabPickerWidget.GetParent(); parent != nil {
1300- a.tabPickerWidget.Unparent()
1266+ if parent := bw.tabPickerWidget.GetParent(); parent != nil {
1267+ bw.tabPickerWidget.Unparent()
13011268 }
13021269
1303- a.tabPicker.SetParentOverlay(pv.Overlay())
1304- pv.AddOverlayWidget(a.tabPickerWidget)
1305- a.tabPickerPaneID = activePaneID
1270+ bw.tabPicker.SetParentOverlay(pv.Overlay())
1271+ pv.AddOverlayWidget(bw.tabPickerWidget)
1272+ bw.tabPickerPaneID = activePaneID
13061273 }
13071274
13081275 func (a *App) HandleMovePaneToTab(ctx context.Context) error {
13091276 if a.movePaneToTabUC == nil {
13101277 return nil
13111278 }
1312- if a.tabPicker == nil {
1279+ bw := a.lastFocusedBrowserWindow()
1280+ if bw == nil || bw.tabPicker == nil {
13131281 return nil
13141282 }
13151283
@@ -1341,7 +1309,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
13411309 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
13421310
13431311 a.attachTabPickerToActivePane()
1344- a.tabPicker.Show(ctx, items)
1312+ bw.tabPicker.Show(ctx, items)
13451313 return nil
13461314 }
13471315
@@ -1387,6 +1355,13 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
13871355 return nil
13881356 }
13891357
1358+func (a *App) moveActivePaneToTabFromBrowserWindow(ctx context.Context, bw *browserWindow, targetTabID entity.TabID) error {
1359+ if bw != nil {
1360+ a.activateBrowserWindow(bw)
1361+ }
1362+ return a.MoveActivePaneToTab(ctx, targetTabID)
1363+}
1364+
13901365 func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
13911366 if a.movePaneToTabUC == nil {
13921367 return nil, nil
@@ -1518,42 +1493,6 @@ func (a *App) updateTabBarVisibility(ctx context.Context) {
15181493 }
15191494 }
15201495
1521-func (a *App) initTabPicker(ctx context.Context) {
1522- log := logging.FromContext(ctx)
1523-
1524- if a.deps == nil || a.deps.Config == nil {
1525- log.Debug().Msg("deps/config not available, skipping tab picker")
1526- return
1527- }
1528-
1529- a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
1530- UIScale: a.deps.Config.DefaultUIScale,
1531- OnClose: func() {
1532- log.Debug().Msg("tab picker closed")
1533- },
1534- OnSelect: func(item component.TabPickerItem) {
1535- cb := glib.SourceFunc(func(_ uintptr) bool {
1536- var targetID entity.TabID
1537- if !item.IsNew {
1538- targetID = item.TabID
1539- }
1540- if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
1541- log.Warn().Err(err).Msg("move pane to tab failed")
1542- }
1543- return false
1544- })
1545- glib.IdleAdd(&cb, 0)
1546- },
1547- })
1548-
1549- if a.tabPicker == nil {
1550- log.Warn().Msg("failed to create tab picker")
1551- return
1552- }
1553-
1554- log.Debug().Msg("tab picker initialized")
1555-}
1556-
15571496 func (a *App) initSnapshotService(ctx context.Context) {
15581497 log := logging.FromContext(ctx)
15591498
@@ -1593,7 +1532,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
15931532 a.updateCoord = coordinator.NewUpdateCoordinator(
15941533 a.deps.CheckUpdateUC,
15951534 a.deps.ApplyUpdateUC,
1596- a.appToaster,
1535+ func(ctx context.Context, msg string, level component.ToastLevel) {
1536+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, level)
1537+ },
15971538 a.deps.Config.Update.EnableOnStartup,
15981539 a.deps.Config.Update.AutoDownload,
15991540 )
@@ -1632,15 +1573,11 @@ func (a *App) createInitialTab(ctx context.Context) {
16321573 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
16331574 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
16341575 // Show error toast and fall through to create default tab
1635- if a.appToaster != nil {
1636- a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1637- }
1576+ a.showToastOnLastFocusedBrowserWindow(ctx, "Session restore failed", component.ToastWarning)
16381577 } else {
16391578 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
16401579 // Show success toast for session restoration
1641- if a.appToaster != nil {
1642- a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1643- }
1580+ a.showToastOnLastFocusedBrowserWindow(ctx, "Session restored", component.ToastSuccess)
16441581 return
16451582 }
16461583 }
@@ -1739,13 +1676,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
17391676 log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
17401677
17411678 cb := glib.SourceFunc(func(_ uintptr) bool {
1742- if a.appToaster != nil {
1743- msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1744- a.appToaster.Show(ctx, msg, component.ToastWarning,
1745- component.WithDuration(crashReportToastDurationMs),
1746- component.WithPosition(component.ToastPositionBottomRight),
1747- )
1748- }
1679+ msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1680+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastWarning,
1681+ component.WithDuration(crashReportToastDurationMs),
1682+ component.WithPosition(component.ToastPositionBottomRight),
1683+ )
17491684 return false
17501685 })
17511686 glib.IdleAdd(&cb, 0)
@@ -1919,17 +1854,8 @@ func (a *App) initCoordinators(ctx context.Context) {
19191854 }
19201855
19211856 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1922- a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1923- if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1924- return
1925- }
1926- if entering {
1927- // Hide tab bar when entering fullscreen video
1928- a.mainWindow.TabBar().SetVisible(false)
1929- } else {
1930- // Restore tab bar visibility based on normal logic
1931- a.tabCoord.UpdateBarVisibility(ctx)
1932- }
1857+ a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) {
1858+ a.handlePaneFullscreenChanged(paneID, entering)
19331859 })
19341860
19351861 // 3. Workspace Coordinator
@@ -2002,8 +1928,8 @@ func (a *App) initCoordinators(ctx context.Context) {
20021928 })
20031929
20041930 // Wire window title updates when active pane's title changes
2005- a.contentCoord.SetOnWindowTitleChanged(func(title string) {
2006- a.updateWindowTitle(title, a.browserWindowForTab(a.tabs.ActiveTabID))
1931+ a.contentCoord.SetOnWindowTitleChanged(func(paneID entity.PaneID, title string) {
1932+ a.handlePaneWindowTitleChanged(paneID, title)
20071933 })
20081934
20091935 // Wire pane URI updates for session snapshots (searches all tabs)
@@ -2048,6 +1974,7 @@ func (a *App) initCoordinators(ctx context.Context) {
20481974 a.deps.ZoomUC,
20491975 a.deps.CopyURLUC,
20501976 a.deps.Config.Workspace.NewPaneURL,
1977+ a.contentCoord.ActivePaneID,
20511978 )
20521979 a.wireKeyboardActions()
20531980 for _, bw := range a.browserWindows {
@@ -2075,10 +2002,16 @@ func (a *App) wireKeyboardActions() {
20752002 a.CloseFindBar(ctx)
20762003 return nil
20772004 })
2078- a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
2005+ a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context, paneID entity.PaneID) error {
2006+ if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
2007+ a.activateBrowserWindow(bw)
2008+ }
20792009 return a.HandleMovePaneToTab(ctx)
20802010 })
2081- a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
2011+ a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context, paneID entity.PaneID) error {
2012+ if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
2013+ a.activateBrowserWindow(bw)
2014+ }
20822015 return a.HandleMovePaneToNextTab(ctx)
20832016 })
20842017 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
@@ -2304,8 +2237,12 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
23042237 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
23052238 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
23062239 var ws *entity.Workspace
2307- if tab := a.tabs.Find(tabID); tab != nil {
2308- ws = tab.Workspace
2240+ if a.tabs != nil {
2241+ if tab := a.tabs.Find(tabID); tab != nil {
2242+ ws = tab.Workspace
2243+ } else {
2244+ ws = a.activeWorkspace()
2245+ }
23092246 } else {
23102247 ws = a.activeWorkspace()
23112248 }
@@ -2314,7 +2251,7 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
23142251 return
23152252 }
23162253 title := a.contentCoord.GetTitle(ws.ActivePaneID)
2317- a.updateWindowTitle(title, a.browserWindowForTab(tabID))
2254+ a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
23182255 }
23192256
23202257 // handleModeChange is called when the input mode changes.
@@ -2331,8 +2268,8 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
23312268
23322269 // Update global border overlay visibility based on mode.
23332270 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
2334- if a.borderMgr != nil {
2335- a.borderMgr.OnModeChange(ctx, from, to)
2271+ if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil {
2272+ bw.borderMgr.OnModeChange(ctx, from, to)
23362273 }
23372274
23382275 // Show/hide mode indicator toaster based on config.
@@ -2341,25 +2278,26 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
23412278
23422279 // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
23432280 func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
2344- if a.modeToaster == nil {
2281+ bw := a.lastFocusedBrowserWindow()
2282+ if bw == nil || bw.modeToaster == nil {
23452283 return
23462284 }
23472285
23482286 // Check if mode indicator toaster is enabled in config.
23492287 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
2350- a.modeToaster.Hide()
2288+ bw.modeToaster.Hide()
23512289 return
23522290 }
23532291
23542292 if mode == input.ModeNormal {
2355- a.modeToaster.Hide()
2293+ bw.modeToaster.Hide()
23562294 return
23572295 }
23582296
23592297 // Show persistent toaster at bottom-left with mode display name.
23602298 // Mode class is applied atomically with Show() to avoid visual flicker.
23612299 modeClass := getModeToastClass(mode)
2362- a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
2300+ bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
23632301 component.WithDuration(0), // Persistent until mode exits.
23642302 component.WithPosition(component.ToastPositionBottomLeft),
23652303 component.WithModeClass(modeClass),
@@ -2512,6 +2450,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
25122450
25132451 // activeWorkspace returns the workspace of the active tab.
25142452 func (a *App) activeWorkspace() *entity.Workspace {
2453+ if a.tabs == nil {
2454+ return nil
2455+ }
25152456 activeTab := a.tabs.ActiveTab()
25162457 if activeTab == nil {
25172458 return nil
@@ -3068,9 +3009,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac
30683009 if session.omnibox == nil {
30693010 cfg := a.omniboxCfg
30703011 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
3071- if a.appToaster != nil {
3072- a.appToaster.Show(toastCtx, message, level)
3073- }
3012+ a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
30743013 }
30753014
30763015 omnibox := component.NewOmnibox(ctx, cfg)
@@ -3594,21 +3533,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) {
35943533
35953534 switch status.State {
35963535 case port.FilterStateLoading:
3597- if a.appToaster != nil {
3598- a.appToaster.Show(ctx, status.Message, component.ToastInfo)
3599- }
3536+ a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo)
36003537 case port.FilterStateActive:
36013538 // Apply filters to existing webviews that were created before filters loaded
36023539 if a.contentCoord != nil && a.deps.FilterManager != nil {
36033540 a.contentCoord.ApplyFiltersToAll(ctx)
36043541 log.Debug().Msg("applied filters to all existing webviews")
36053542 }
3606- if a.appToaster != nil {
3607- a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
3608- }
3543+ a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
36093544 case port.FilterStateError:
3610- if a.appToaster != nil {
3611- a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
3612- }
3545+ a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError)
36133546 }
36143547 }
@@ -67,6 +67,7 @@ type App struct {
67 mainWindow *window.MainWindow67 mainWindow *window.MainWindow
68 68
69 browserWindows map[string]*browserWindow69 browserWindows map[string]*browserWindow
70+ lastFocusedWindowID string
70 browserWindowFactory func(context.Context, string) (*browserWindow, error)71 browserWindowFactory func(context.Context, string) (*browserWindow, error)
71 tabCreationWindow *browserWindow72 tabCreationWindow *browserWindow
72 dispatchOnMainThread func(func())73 dispatchOnMainThread func(func())
@@ -93,9 +94,8 @@ type App struct {
93 keyboardHandler *input.KeyboardHandler94 keyboardHandler *input.KeyboardHandler
94 globalShortcutHandler *input.GlobalShortcutHandler95 globalShortcutHandler *input.GlobalShortcutHandler
95 96
96- // Focus and border management97+ // Focus tracking stays app-global.
97- focusMgr *focus.Manager98+ focusMgr *focus.Manager
98- borderMgr *focus.BorderManager
99 99
100 resizeModeBorderTarget layout.Widget100 resizeModeBorderTarget layout.Widget
101 101
@@ -113,16 +113,6 @@ type App struct {
113 // Web content (managed by content.Coordinator)113 // Web content (managed by content.Coordinator)
114 faviconAdapter *adapter.FaviconAdapter114 faviconAdapter *adapter.FaviconAdapter
115 115
116- // App-level toaster for system notifications (filter status, etc.)
117- appToaster *component.Toaster
118- // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
119- modeToaster *component.Toaster
120-
121- // Session management
122- sessionManager *component.SessionManager
123- tabPicker *component.TabPicker
124- tabPickerWidget layout.Widget
125- tabPickerPaneID entity.PaneID
126 snapshotService port.SnapshotService116 snapshotService port.SnapshotService
127 117
128 // Update management118 // Update management
@@ -138,8 +128,6 @@ type App struct {
138 movePaneToTabUC *usecase.MovePaneToTabUseCase128 movePaneToTabUC *usecase.MovePaneToTabUseCase
139 129
140 // Accent picker for dead keys support130 // Accent picker for dead keys support
141- accentPicker *component.AccentPicker
142- insertAccentUC *usecase.InsertAccentUseCase
143 accentFocusProvider port.FocusedInputProvider131 accentFocusProvider port.FocusedInputProvider
144 132
145 // Deferred initialization - runs after first load_started to avoid blocking initial navigation133 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
@@ -206,12 +194,10 @@ func New(deps *Dependencies) (*App, error) {
206 }},194 }},
207 OnClipboardCopied: func(textLen int) {195 OnClipboardCopied: func(textLen int) {
208 cb := glib.SourceFunc(func(_ uintptr) bool {196 cb := glib.SourceFunc(func(_ uintptr) bool {
209- if app.appToaster != nil {197+ app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,
210- app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,198+ component.WithDuration(component.ToastBriefDurationMs),
211- component.WithDuration(component.ToastBriefDurationMs),199+ component.WithPosition(component.ToastPositionBottomRight),
212- component.WithPosition(component.ToastPositionBottomRight),200+ )
213- )
214- }
215 return false201 return false
216 })202 })
217 glib.IdleAdd(&cb, 0)203 glib.IdleAdd(&cb, 0)
@@ -285,6 +271,7 @@ func (a *App) onActivate(ctx context.Context) {
285 // Configure pool background color early (prevents white flash), but avoid271 // Configure pool background color early (prevents white flash), but avoid
286 // synchronous prewarming that delays the first navigation on cold start.272 // synchronous prewarming that delays the first navigation on cold start.
287 a.setupPoolBackgroundColor(ctx)273 a.setupPoolBackgroundColor(ctx)
274+ a.initLayoutInfrastructure()
288 275
289 if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {276 if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
290 log.Error().Err(err).Msg("failed to create main window")277 log.Error().Err(err).Msg("failed to create main window")
@@ -292,11 +279,8 @@ func (a *App) onActivate(ctx context.Context) {
292 }279 }
293 logging.Trace().Mark("window_created")280 logging.Trace().Mark("window_created")
294 281
295- a.initLayoutInfrastructure()
296- a.initAppToasterOverlay()
297 a.installCrashReportNotifier(ctx)282 a.installCrashReportNotifier(ctx)
298- a.initFocusAndBorderOverlay()283+ a.initFocusManager()
299- a.initAccentPicker(ctx)
300 a.registerAccentHandlers(ctx)284 a.registerAccentHandlers(ctx)
301 a.initDownloadHandler(ctx)285 a.initDownloadHandler(ctx)
302 286
@@ -306,8 +290,7 @@ func (a *App) onActivate(ctx context.Context) {
306 a.initKeyboardHandler(ctx)290 a.initKeyboardHandler(ctx)
307 a.initOmniboxConfig(ctx)291 a.initOmniboxConfig(ctx)
308 a.initFindBarConfig(ctx)292 a.initFindBarConfig(ctx)
309- a.initSessionManager(ctx)293+ a.wireSessionManagerShortcut()
310- a.initTabPicker(ctx)
311 a.initSnapshotService(ctx)294 a.initSnapshotService(ctx)
312 a.initUpdateCoordinator(ctx)295 a.initUpdateCoordinator(ctx)
313 a.createInitialTab(ctx)296 a.createInitialTab(ctx)
@@ -395,7 +378,13 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
395 378
396 func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) {379 func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) {
397 if a.browserWindowFactory != nil {380 if a.browserWindowFactory != nil {
398- return a.browserWindowFactory(ctx, initialURL)381+ created, err := a.browserWindowFactory(ctx, initialURL)
382+ if err != nil {
383+ return nil, err
384+ }
385+ a.wireBrowserWindowActivationTracking(created)
386+ created.initChrome(ctx, a)
387+ return created, nil
399 }388 }
400 389
401 log := logging.FromContext(ctx)390 log := logging.FromContext(ctx)
@@ -408,6 +397,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
408 initialURL: initialURL,397 initialURL: initialURL,
409 mainWindow: mainWindow,398 mainWindow: mainWindow,
410 }399 }
400+ a.wireBrowserWindowActivationTracking(browserWindow)
411 if a.mainWindow == nil {401 if a.mainWindow == nil {
412 a.mainWindow = mainWindow402 a.mainWindow = mainWindow
413 }403 }
@@ -450,6 +440,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
450 if a.tabCoord != nil {440 if a.tabCoord != nil {
451 a.wireBrowserWindowTabBar(ctx, browserWindow)441 a.wireBrowserWindowTabBar(ctx, browserWindow)
452 }442 }
443+ browserWindow.initChrome(ctx, a)
453 444
454 // Apply GTK CSS styling from theme manager.445 // Apply GTK CSS styling from theme manager.
455 if a.deps == nil || a.deps.Theme == nil {446 if a.deps == nil || a.deps.Theme == nil {
@@ -506,10 +497,82 @@ func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
506 return nil497 return nil
507 }498 }
508 499
500+func (a *App) lastFocusedBrowserWindow() *browserWindow {
501+ if a.lastFocusedWindowID == "" || a.browserWindows == nil {
502+ return nil
503+ }
504+ return a.browserWindows[a.lastFocusedWindowID]
505+}
506+
507+func (a *App) showToastOnBrowserWindow(
508+ ctx context.Context,
509+ bw *browserWindow,
510+ message string,
511+ level component.ToastLevel,
512+ opts ...component.ToastOption,
513+) {
514+ if bw == nil || bw.appToaster == nil {
515+ return
516+ }
517+ bw.appToaster.Show(ctx, message, level, opts...)
518+}
519+
520+func (a *App) showToastOnLastFocusedBrowserWindow(
521+ ctx context.Context,
522+ message string,
523+ level component.ToastLevel,
524+ opts ...component.ToastOption,
525+) {
526+ a.showToastOnBrowserWindow(ctx, a.lastFocusedBrowserWindow(), message, level, opts...)
527+}
528+
529+func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.PaneID) *browserWindow {
530+ if paneID != "" {
531+ if bw := a.browserWindowForPane(paneID); bw != nil {
532+ return bw
533+ }
534+ }
535+ if tabID != "" {
536+ if bw := a.browserWindowForTab(tabID); bw != nil {
537+ return bw
538+ }
539+ }
540+ return a.lastFocusedBrowserWindow()
541+}
542+
543+func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {
544+ a.updateWindowTitle(title, a.browserWindowForPane(paneID))
545+}
546+
547+func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) {
548+ bw := a.browserWindowForPane(paneID)
549+ if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil {
550+ return
551+ }
552+ if entering {
553+ bw.mainWindow.TabBar().SetVisible(false)
554+ return
555+ }
556+ a.updateBrowserWindowTabBarVisibility(bw)
557+}
558+
559+func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) {
560+ if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil {
561+ return
562+ }
563+ if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab {
564+ bw.mainWindow.TabBar().SetVisible(true)
565+ return
566+ }
567+ shouldShow := bw.mainWindow.TabBar().Count() > 1
568+ bw.mainWindow.TabBar().SetVisible(shouldShow)
569+}
570+
509 func (a *App) activateBrowserWindow(bw *browserWindow) {571 func (a *App) activateBrowserWindow(bw *browserWindow) {
510 if bw == nil {572 if bw == nil {
511 return573 return
512 }574 }
575+ a.lastFocusedWindowID = bw.id
513 if bw.mainWindow != nil {576 if bw.mainWindow != nil {
514 a.mainWindow = bw.mainWindow577 a.mainWindow = bw.mainWindow
515 }578 }
@@ -521,6 +584,23 @@ func (a *App) activateBrowserWindow(bw *browserWindow) {
521 if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil {584 if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil {
522 a.tabs.SetActive(bw.activeTabID)585 a.tabs.SetActive(bw.activeTabID)
523 }586 }
587+ a.updateWindowTitleFromActivePane(bw.activeTabID)
588+}
589+
590+func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {
591+ if !active || bw == nil {
592+ return
593+ }
594+ a.activateBrowserWindow(bw)
595+}
596+
597+func (a *App) wireBrowserWindowActivationTracking(bw *browserWindow) {
598+ if bw == nil || bw.mainWindow == nil {
599+ return
600+ }
601+ bw.mainWindow.ConnectActiveNotify(func(active bool) {
602+ a.handleBrowserWindowActivationChanged(bw, active)
603+ })
524 }604 }
525 605
526 func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {606 func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
@@ -674,36 +754,6 @@ func (a *App) initLayoutInfrastructure() {
674 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)754 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
675 }755 }
676 756
677-func (a *App) initAppToasterOverlay() {
678- if a.mainWindow == nil {
679- return
680- }
681-
682- // Create app-level toaster for system notifications.
683- a.appToaster = component.NewToaster(a.widgetFactory)
684- toasterWidget := a.appToaster.Widget()
685- if toasterWidget == nil {
686- return
687- }
688- gtkWidget := toasterWidget.GtkWidget()
689- if gtkWidget == nil {
690- return
691- }
692- a.mainWindow.AddOverlay(gtkWidget)
693-
694- // Create mode indicator toaster for modal mode notifications.
695- a.modeToaster = component.NewToaster(a.widgetFactory)
696- modeToasterWidget := a.modeToaster.Widget()
697- if modeToasterWidget == nil {
698- return
699- }
700- modeGtkWidget := modeToasterWidget.GtkWidget()
701- if modeGtkWidget == nil {
702- return
703- }
704- a.mainWindow.AddOverlay(modeGtkWidget)
705-}
706-
707 func (a *App) installCrashReportNotifier(ctx context.Context) {757 func (a *App) installCrashReportNotifier(ctx context.Context) {
708 if a.deps == nil {758 if a.deps == nil {
709 return759 return
@@ -714,92 +764,49 @@ func (a *App) installCrashReportNotifier(ctx context.Context) {
714 }764 }
715 }765 }
716 766
717-func (a *App) initFocusAndBorderOverlay() {767+func (a *App) initFocusManager() {
718- // Initialize focus manager for geometric navigation.
719 a.focusMgr = focus.NewManager(a.panesUC)768 a.focusMgr = focus.NewManager(a.panesUC)
720-
721- // Initialize border manager for mode indicators.
722- a.borderMgr = focus.NewBorderManager(a.widgetFactory)
723-
724- // Attach border overlay to main window (visible for all tabs).
725- if a.borderMgr == nil || a.mainWindow == nil {
726- return
727- }
728- borderWidget := a.borderMgr.Widget()
729- if borderWidget == nil {
730- return
731- }
732- gtkWidget := borderWidget.GtkWidget()
733- if gtkWidget == nil {
734- return
735- }
736- a.mainWindow.AddOverlay(gtkWidget)
737 }769 }
738 770
739-func (a *App) initAccentPicker(ctx context.Context) {771+func (a *App) wireSessionManagerShortcut() {
740- log := logging.FromContext(ctx)772+ if a.kbDispatcher == nil {
741-
742- if a.widgetFactory == nil || a.mainWindow == nil {
743- log.Debug().Msg("widget factory or main window not available, skipping accent picker")
744 return773 return
745 }774 }
746-775+ a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context, paneID entity.PaneID) error {
747- // Create accent picker component776+ bw := a.ownerOrLastFocusedBrowserWindow("", paneID)
748- a.accentPicker = component.NewAccentPicker(a.widgetFactory)777+ if bw == nil || bw.sessionManager == nil {
749- if a.accentPicker == nil {778+ return nil
750- log.Warn().Msg("failed to create accent picker")
751- return
752- }
753-
754- // Add to main window overlay
755- pickerWidget := a.accentPicker.Widget()
756- if pickerWidget != nil {
757- gtkWidget := pickerWidget.GtkWidget()
758- if gtkWidget != nil {
759- a.mainWindow.AddOverlay(gtkWidget)
760 }779 }
761- }780+ bw.sessionManager.Toggle(ctx)
762-781+ return nil
763- // Use focus provider from deps (tracks which input has focus)782+ })
764- a.accentFocusProvider = a.deps.AccentFocusProvider
765-
766- // Create the use case with glib.IdleAdd for thread-safe GTK calls
767- a.insertAccentUC = usecase.NewInsertAccentUseCase(
768- a.accentFocusProvider,
769- a.accentPicker,
770- func(fn func()) {
771- cb := glib.SourceFunc(func(_ uintptr) bool {
772- fn()
773- return false // Don't repeat
774- })
775- glib.IdleAdd(&cb, 0)
776- },
777- )
778-
779- log.Debug().Msg("accent picker initialized")
780 }783 }
781 784
782 // registerAccentHandlers registers the accent key press/release message handlers785 // registerAccentHandlers registers the accent key press/release message handlers
783-// with the router. Must be called after initAccentPicker so insertAccentUC is non-nil.786+// with the router.
784 func (a *App) registerAccentHandlers(ctx context.Context) {787 func (a *App) registerAccentHandlers(ctx context.Context) {
785 log := logging.FromContext(ctx)788 log := logging.FromContext(ctx)
786 789
787- if a.insertAccentUC == nil {
788- log.Debug().Msg("insertAccentUC not available, skipping accent handler registration")
789- return
790- }
791 if a.engine == nil {790 if a.engine == nil {
792 log.Debug().Msg("engine not available, skipping accent handler registration")791 log.Debug().Msg("engine not available, skipping accent handler registration")
793 return792 return
794 }793 }
795 794
796- if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil {795+ if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil {
797 log.Error().Err(err).Msg("failed to register accent handlers")796 log.Error().Err(err).Msg("failed to register accent handlers")
798 } else {797 } else {
799 log.Info().Msg("registered accent key handlers")798 log.Info().Msg("registered accent key handlers")
800 }799 }
801 }800 }
802 801
802+func (a *App) activeAccentHandler() input.AccentHandler {
803+ bw := a.lastFocusedBrowserWindow()
804+ if bw == nil {
805+ return nil
806+ }
807+ return bw.insertAccentUC
808+}
809+
803 func (a *App) initDownloadHandler(ctx context.Context) {810 func (a *App) initDownloadHandler(ctx context.Context) {
804 log := logging.FromContext(ctx)811 log := logging.FromContext(ctx)
805 812
@@ -853,17 +860,13 @@ type downloadEventAdapter struct {
853 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {860 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
854 // Must schedule on GTK main thread.861 // Must schedule on GTK main thread.
855 cb := glib.SourceFunc(func(_ uintptr) bool {862 cb := glib.SourceFunc(func(_ uintptr) bool {
856- if d.app.appToaster == nil {
857- return false
858- }
859-
860 switch event.Type {863 switch event.Type {
861 case port.DownloadEventStarted:864 case port.DownloadEventStarted:
862- d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)865+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo)
863 case port.DownloadEventFinished:866 case port.DownloadEventFinished:
864- d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)867+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
865 case port.DownloadEventFailed:868 case port.DownloadEventFailed:
866- d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)869+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError)
867 }870 }
868 return false871 return false
869 })872 })
@@ -896,6 +899,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
896 if bw == nil || bw.mainWindow == nil || a.deps == nil || a.deps.Config == nil || a.kbDispatcher == nil {899 if bw == nil || bw.mainWindow == nil || a.deps == nil || a.deps.Config == nil || a.kbDispatcher == nil {
897 return900 return
898 }901 }
902+ if bw.keyboardHandler != nil || bw.globalShortcutHandler != nil {
903+ return
904+ }
899 905
900 bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)906 bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
901 bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error {907 bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error {
@@ -916,10 +922,10 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
916 a.handleModeChange(ctx, from, to)922 a.handleModeChange(ctx, from, to)
917 })923 })
918 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {924 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
919- if a.sessionManager != nil && a.sessionManager.IsVisible() {925+ if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
920 return input.RoutePassToWidget926 return input.RoutePassToWidget
921 }927 }
922- if a.tabPicker != nil && a.tabPicker.IsVisible() {928+ if bw.tabPicker != nil && bw.tabPicker.IsVisible() {
923 return input.RoutePassToWidget929 return input.RoutePassToWidget
924 }930 }
925 931
@@ -941,9 +947,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
941 947
942 return input.RouteHandleShortcuts948 return input.RouteHandleShortcuts
943 })949 })
944- if a.insertAccentUC != nil {950+ bw.keyboardHandler.SetAccentHandler(a)
945- bw.keyboardHandler.SetAccentHandler(a.insertAccentUC)
946- }
947 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())951 bw.keyboardHandler.AttachTo(bw.mainWindow.Window())
948 952
949 bw.globalShortcutHandler = input.NewGlobalShortcutHandler(953 bw.globalShortcutHandler = input.NewGlobalShortcutHandler(
@@ -981,7 +985,8 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) {
981 // handleAccentKeyPress handles accent key press events for GTK entry widgets985 // handleAccentKeyPress handles accent key press events for GTK entry widgets
982 // (omnibox and find bar). Returns true if the key was consumed.986 // (omnibox and find bar). Returns true if the key was consumed.
983 func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool {987 func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool {
984- if a.insertAccentUC == nil {988+ handler := a.activeAccentHandler()
989+ if handler == nil {
985 return false990 return false
986 }991 }
987 if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 {992 if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 {
@@ -989,7 +994,7 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M
989 }994 }
990 shiftHeld := state&gdk.ShiftMaskValue != 0995 shiftHeld := state&gdk.ShiftMaskValue != 0
991 if char := input.KeyvalToRune(keyval); char != 0 {996 if char := input.KeyvalToRune(keyval); char != 0 {
992- return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld)997+ return handler.OnKeyPressed(ctx, char, shiftHeld)
993 }998 }
994 return false999 return false
995 }1000 }
@@ -997,12 +1002,45 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M
997 // handleAccentKeyRelease handles accent key release events for GTK entry widgets1002 // handleAccentKeyRelease handles accent key release events for GTK entry widgets
998 // (omnibox and find bar).1003 // (omnibox and find bar).
999 func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {1004 func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {
1000- if a.insertAccentUC == nil {1005+ handler := a.activeAccentHandler()
1006+ if handler == nil {
1001 return1007 return
1002 }1008 }
1003 if char := input.KeyvalToRune(keyval); char != 0 {1009 if char := input.KeyvalToRune(keyval); char != 0 {
1004- a.insertAccentUC.OnKeyReleased(ctx, char)1010+ handler.OnKeyReleased(ctx, char)
1011+ }
1012+}
1013+
1014+func (a *App) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
1015+ handler := a.activeAccentHandler()
1016+ if handler == nil {
1017+ return false
1018+ }
1019+ return handler.OnKeyPressed(ctx, char, shiftHeld)
1020+}
1021+
1022+func (a *App) OnKeyReleased(ctx context.Context, char rune) {
1023+ handler := a.activeAccentHandler()
1024+ if handler == nil {
1025+ return
1005 }1026 }
1027+ handler.OnKeyReleased(ctx, char)
1028+}
1029+
1030+func (a *App) IsPickerVisible() bool {
1031+ handler := a.activeAccentHandler()
1032+ if handler == nil {
1033+ return false
1034+ }
1035+ return handler.IsPickerVisible()
1036+}
1037+
1038+func (a *App) Cancel(ctx context.Context) {
1039+ handler := a.activeAccentHandler()
1040+ if handler == nil {
1041+ return
1042+ }
1043+ handler.Cancel(ctx)
1006 }1044 }
1007 1045
1008 type omniboxCallbacks struct {1046 type omniboxCallbacks struct {
@@ -1168,89 +1206,18 @@ func (a *App) initFindBarConfig(ctx context.Context) {
1168 }1206 }
1169 }1207 }
1170 1208
1171-func (a *App) initSessionManager(ctx context.Context) {
1172- log := logging.FromContext(ctx)
1173-
1174- if a.deps == nil {
1175- log.Debug().Msg("deps not available, skipping session manager")
1176- return
1177- }
1178-
1179- // Session manager can work without a repo - it just shows an empty list
1180- var listSessionsUC *usecase.ListSessionsUseCase
1181- var deleteSessionUC *usecase.DeleteSessionUseCase
1182- if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
1183- listSessionsUC = usecase.NewListSessionsUseCase(
1184- a.deps.SessionRepo,
1185- a.deps.SessionStateRepo,
1186- )
1187- deleteSessionUC = usecase.NewDeleteSessionUseCase(
1188- a.deps.SessionStateRepo,
1189- a.deps.SessionRepo,
1190- )
1191- }
1192-
1193- // Use injected session spawner for restoration
1194- spawner := a.deps.SessionSpawner
1195-
1196- // Create session manager component
1197- a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
1198- ListSessionsUC: listSessionsUC,
1199- DeleteSessionUC: deleteSessionUC,
1200- CurrentSession: a.deps.CurrentSessionID,
1201- UIScale: a.deps.Config.DefaultUIScale,
1202- OnClose: func() {
1203- log.Debug().Msg("session manager closed")
1204- },
1205- OnOpen: func(sessionID entity.SessionID) {
1206- log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
1207- if spawner == nil {
1208- log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
1209- return
1210- }
1211- if err := spawner.SpawnWithSession(sessionID); err != nil {
1212- log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
1213- }
1214- },
1215- OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
1216- if a.appToaster != nil {
1217- a.appToaster.Show(ctx, message, level)
1218- }
1219- },
1220- })
1221-
1222- if a.sessionManager == nil {
1223- log.Warn().Msg("failed to create session manager")
1224- return
1225- }
1226-
1227- // Add session manager to main window overlay
1228- if a.mainWindow != nil {
1229- widget := a.sessionManager.Widget()
1230- if widget != nil {
1231- a.mainWindow.AddOverlay(widget)
1232- }
1233- }
1234-
1235- // Wire session manager action to keyboard dispatcher
1236- a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
1237- a.ToggleSessionManager(ctx)
1238- return nil
1239- })
1240-
1241- log.Debug().Msg("session manager initialized")
1242-}
1243-
1244 // ToggleSessionManager shows or hides the session manager.1209 // ToggleSessionManager shows or hides the session manager.
1245 func (a *App) ToggleSessionManager(ctx context.Context) {1210 func (a *App) ToggleSessionManager(ctx context.Context) {
1246- if a.sessionManager == nil {1211+ bw := a.lastFocusedBrowserWindow()
1212+ if bw == nil || bw.sessionManager == nil {
1247 return1213 return
1248 }1214 }
1249- a.sessionManager.Toggle(ctx)1215+ bw.sessionManager.Toggle(ctx)
1250 }1216 }
1251 1217
1252 func (a *App) attachTabPickerToActivePane() {1218 func (a *App) attachTabPickerToActivePane() {
1253- if a.tabPicker == nil || a.widgetFactory == nil {1219+ bw := a.lastFocusedBrowserWindow()
1220+ if bw == nil || bw.tabPicker == nil || a.widgetFactory == nil {
1254 return1221 return
1255 }1222 }
1256 wsView := a.activeWorkspaceView()1223 wsView := a.activeWorkspaceView()
@@ -1271,24 +1238,24 @@ func (a *App) attachTabPickerToActivePane() {
1271 return1238 return
1272 }1239 }
1273 1240
1274- if a.tabPickerWidget == nil {1241+ if bw.tabPickerWidget == nil {
1275- a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)1242+ bw.tabPickerWidget = bw.tabPicker.WidgetAsLayout(a.widgetFactory)
1276- if a.tabPickerWidget == nil {1243+ if bw.tabPickerWidget == nil {
1277 return1244 return
1278 }1245 }
1279 }1246 }
1280 1247
1281 // If currently attached to a different pane overlay, detach.1248 // If currently attached to a different pane overlay, detach.
1282- if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {1249+ if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID {
1283 for _, view := range a.workspaceViews {1250 for _, view := range a.workspaceViews {
1284 if view == nil {1251 if view == nil {
1285 continue1252 continue
1286 }1253 }
1287- if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {1254+ if oldPV := view.GetPaneView(bw.tabPickerPaneID); oldPV != nil {
1288- if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {1255+ if parent := bw.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
1289- oldPV.RemoveOverlayWidget(a.tabPickerWidget)1256+ oldPV.RemoveOverlayWidget(bw.tabPickerWidget)
1290 } else if parent != nil {1257 } else if parent != nil {
1291- a.tabPickerWidget.Unparent()1258+ bw.tabPickerWidget.Unparent()
1292 }1259 }
1293 break1260 break
1294 }1261 }
@@ -1296,20 +1263,21 @@ func (a *App) attachTabPickerToActivePane() {
1296 }1263 }
1297 1264
1298 // Ensure the widget can be reparented.1265 // Ensure the widget can be reparented.
1299- if parent := a.tabPickerWidget.GetParent(); parent != nil {1266+ if parent := bw.tabPickerWidget.GetParent(); parent != nil {
1300- a.tabPickerWidget.Unparent()1267+ bw.tabPickerWidget.Unparent()
1301 }1268 }
1302 1269
1303- a.tabPicker.SetParentOverlay(pv.Overlay())1270+ bw.tabPicker.SetParentOverlay(pv.Overlay())
1304- pv.AddOverlayWidget(a.tabPickerWidget)1271+ pv.AddOverlayWidget(bw.tabPickerWidget)
1305- a.tabPickerPaneID = activePaneID1272+ bw.tabPickerPaneID = activePaneID
1306 }1273 }
1307 1274
1308 func (a *App) HandleMovePaneToTab(ctx context.Context) error {1275 func (a *App) HandleMovePaneToTab(ctx context.Context) error {
1309 if a.movePaneToTabUC == nil {1276 if a.movePaneToTabUC == nil {
1310 return nil1277 return nil
1311 }1278 }
1312- if a.tabPicker == nil {1279+ bw := a.lastFocusedBrowserWindow()
1280+ if bw == nil || bw.tabPicker == nil {
1313 return nil1281 return nil
1314 }1282 }
1315 1283
@@ -1341,7 +1309,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
1341 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})1309 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
1342 1310
1343 a.attachTabPickerToActivePane()1311 a.attachTabPickerToActivePane()
1344- a.tabPicker.Show(ctx, items)1312+ bw.tabPicker.Show(ctx, items)
1345 return nil1313 return nil
1346 }1314 }
1347 1315
@@ -1387,6 +1355,13 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
1387 return nil1355 return nil
1388 }1356 }
1389 1357
1358+func (a *App) moveActivePaneToTabFromBrowserWindow(ctx context.Context, bw *browserWindow, targetTabID entity.TabID) error {
1359+ if bw != nil {
1360+ a.activateBrowserWindow(bw)
1361+ }
1362+ return a.MoveActivePaneToTab(ctx, targetTabID)
1363+}
1364+
1390 func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {1365 func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
1391 if a.movePaneToTabUC == nil {1366 if a.movePaneToTabUC == nil {
1392 return nil, nil1367 return nil, nil
@@ -1518,42 +1493,6 @@ func (a *App) updateTabBarVisibility(ctx context.Context) {
1518 }1493 }
1519 }1494 }
1520 1495
1521-func (a *App) initTabPicker(ctx context.Context) {
1522- log := logging.FromContext(ctx)
1523-
1524- if a.deps == nil || a.deps.Config == nil {
1525- log.Debug().Msg("deps/config not available, skipping tab picker")
1526- return
1527- }
1528-
1529- a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
1530- UIScale: a.deps.Config.DefaultUIScale,
1531- OnClose: func() {
1532- log.Debug().Msg("tab picker closed")
1533- },
1534- OnSelect: func(item component.TabPickerItem) {
1535- cb := glib.SourceFunc(func(_ uintptr) bool {
1536- var targetID entity.TabID
1537- if !item.IsNew {
1538- targetID = item.TabID
1539- }
1540- if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
1541- log.Warn().Err(err).Msg("move pane to tab failed")
1542- }
1543- return false
1544- })
1545- glib.IdleAdd(&cb, 0)
1546- },
1547- })
1548-
1549- if a.tabPicker == nil {
1550- log.Warn().Msg("failed to create tab picker")
1551- return
1552- }
1553-
1554- log.Debug().Msg("tab picker initialized")
1555-}
1556-
1557 func (a *App) initSnapshotService(ctx context.Context) {1496 func (a *App) initSnapshotService(ctx context.Context) {
1558 log := logging.FromContext(ctx)1497 log := logging.FromContext(ctx)
1559 1498
@@ -1593,7 +1532,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
1593 a.updateCoord = coordinator.NewUpdateCoordinator(1532 a.updateCoord = coordinator.NewUpdateCoordinator(
1594 a.deps.CheckUpdateUC,1533 a.deps.CheckUpdateUC,
1595 a.deps.ApplyUpdateUC,1534 a.deps.ApplyUpdateUC,
1596- a.appToaster,1535+ func(ctx context.Context, msg string, level component.ToastLevel) {
1536+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, level)
1537+ },
1597 a.deps.Config.Update.EnableOnStartup,1538 a.deps.Config.Update.EnableOnStartup,
1598 a.deps.Config.Update.AutoDownload,1539 a.deps.Config.Update.AutoDownload,
1599 )1540 )
@@ -1632,15 +1573,11 @@ func (a *App) createInitialTab(ctx context.Context) {
1632 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {1573 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
1633 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")1574 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
1634 // Show error toast and fall through to create default tab1575 // Show error toast and fall through to create default tab
1635- if a.appToaster != nil {1576+ a.showToastOnLastFocusedBrowserWindow(ctx, "Session restore failed", component.ToastWarning)
1636- a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1637- }
1638 } else {1577 } else {
1639 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")1578 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
1640 // Show success toast for session restoration1579 // Show success toast for session restoration
1641- if a.appToaster != nil {1580+ a.showToastOnLastFocusedBrowserWindow(ctx, "Session restored", component.ToastSuccess)
1642- a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1643- }
1644 return1581 return
1645 }1582 }
1646 }1583 }
@@ -1739,13 +1676,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
1739 log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")1676 log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
1740 1677
1741 cb := glib.SourceFunc(func(_ uintptr) bool {1678 cb := glib.SourceFunc(func(_ uintptr) bool {
1742- if a.appToaster != nil {1679+ msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1743- msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))1680+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastWarning,
1744- a.appToaster.Show(ctx, msg, component.ToastWarning,1681+ component.WithDuration(crashReportToastDurationMs),
1745- component.WithDuration(crashReportToastDurationMs),1682+ component.WithPosition(component.ToastPositionBottomRight),
1746- component.WithPosition(component.ToastPositionBottomRight),1683+ )
1747- )
1748- }
1749 return false1684 return false
1750 })1685 })
1751 glib.IdleAdd(&cb, 0)1686 glib.IdleAdd(&cb, 0)
@@ -1919,17 +1854,8 @@ func (a *App) initCoordinators(ctx context.Context) {
1919 }1854 }
1920 1855
1921 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)1856 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1922- a.contentCoord.SetOnFullscreenChanged(func(entering bool) {1857+ a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) {
1923- if a.mainWindow == nil || a.mainWindow.TabBar() == nil {1858+ a.handlePaneFullscreenChanged(paneID, entering)
1924- return
1925- }
1926- if entering {
1927- // Hide tab bar when entering fullscreen video
1928- a.mainWindow.TabBar().SetVisible(false)
1929- } else {
1930- // Restore tab bar visibility based on normal logic
1931- a.tabCoord.UpdateBarVisibility(ctx)
1932- }
1933 })1859 })
1934 1860
1935 // 3. Workspace Coordinator1861 // 3. Workspace Coordinator
@@ -2002,8 +1928,8 @@ func (a *App) initCoordinators(ctx context.Context) {
2002 })1928 })
2003 1929
2004 // Wire window title updates when active pane's title changes1930 // Wire window title updates when active pane's title changes
2005- a.contentCoord.SetOnWindowTitleChanged(func(title string) {1931+ a.contentCoord.SetOnWindowTitleChanged(func(paneID entity.PaneID, title string) {
2006- a.updateWindowTitle(title, a.browserWindowForTab(a.tabs.ActiveTabID))1932+ a.handlePaneWindowTitleChanged(paneID, title)
2007 })1933 })
2008 1934
2009 // Wire pane URI updates for session snapshots (searches all tabs)1935 // Wire pane URI updates for session snapshots (searches all tabs)
@@ -2048,6 +1974,7 @@ func (a *App) initCoordinators(ctx context.Context) {
2048 a.deps.ZoomUC,1974 a.deps.ZoomUC,
2049 a.deps.CopyURLUC,1975 a.deps.CopyURLUC,
2050 a.deps.Config.Workspace.NewPaneURL,1976 a.deps.Config.Workspace.NewPaneURL,
1977+ a.contentCoord.ActivePaneID,
2051 )1978 )
2052 a.wireKeyboardActions()1979 a.wireKeyboardActions()
2053 for _, bw := range a.browserWindows {1980 for _, bw := range a.browserWindows {
@@ -2075,10 +2002,16 @@ func (a *App) wireKeyboardActions() {
2075 a.CloseFindBar(ctx)2002 a.CloseFindBar(ctx)
2076 return nil2003 return nil
2077 })2004 })
2078- a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {2005+ a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context, paneID entity.PaneID) error {
2006+ if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
2007+ a.activateBrowserWindow(bw)
2008+ }
2079 return a.HandleMovePaneToTab(ctx)2009 return a.HandleMovePaneToTab(ctx)
2080 })2010 })
2081- a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {2011+ a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context, paneID entity.PaneID) error {
2012+ if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
2013+ a.activateBrowserWindow(bw)
2014+ }
2082 return a.HandleMovePaneToNextTab(ctx)2015 return a.HandleMovePaneToNextTab(ctx)
2083 })2016 })
2084 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {2017 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
@@ -2304,8 +2237,12 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
2304 // updateWindowTitleFromActivePane updates the window title based on the current active pane.2237 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
2305 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {2238 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
2306 var ws *entity.Workspace2239 var ws *entity.Workspace
2307- if tab := a.tabs.Find(tabID); tab != nil {2240+ if a.tabs != nil {
2308- ws = tab.Workspace2241+ if tab := a.tabs.Find(tabID); tab != nil {
2242+ ws = tab.Workspace
2243+ } else {
2244+ ws = a.activeWorkspace()
2245+ }
2309 } else {2246 } else {
2310 ws = a.activeWorkspace()2247 ws = a.activeWorkspace()
2311 }2248 }
@@ -2314,7 +2251,7 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
2314 return2251 return
2315 }2252 }
2316 title := a.contentCoord.GetTitle(ws.ActivePaneID)2253 title := a.contentCoord.GetTitle(ws.ActivePaneID)
2317- a.updateWindowTitle(title, a.browserWindowForTab(tabID))2254+ a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
2318 }2255 }
2319 2256
2320 // handleModeChange is called when the input mode changes.2257 // handleModeChange is called when the input mode changes.
@@ -2331,8 +2268,8 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
2331 2268
2332 // Update global border overlay visibility based on mode.2269 // Update global border overlay visibility based on mode.
2333 // Note: resize mode border is handled per-pane (stack container), not via global overlay.2270 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
2334- if a.borderMgr != nil {2271+ if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil {
2335- a.borderMgr.OnModeChange(ctx, from, to)2272+ bw.borderMgr.OnModeChange(ctx, from, to)
2336 }2273 }
2337 2274
2338 // Show/hide mode indicator toaster based on config.2275 // Show/hide mode indicator toaster based on config.
@@ -2341,25 +2278,26 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
2341 2278
2342 // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.2279 // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
2343 func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {2280 func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
2344- if a.modeToaster == nil {2281+ bw := a.lastFocusedBrowserWindow()
2282+ if bw == nil || bw.modeToaster == nil {
2345 return2283 return
2346 }2284 }
2347 2285
2348 // Check if mode indicator toaster is enabled in config.2286 // Check if mode indicator toaster is enabled in config.
2349 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {2287 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
2350- a.modeToaster.Hide()2288+ bw.modeToaster.Hide()
2351 return2289 return
2352 }2290 }
2353 2291
2354 if mode == input.ModeNormal {2292 if mode == input.ModeNormal {
2355- a.modeToaster.Hide()2293+ bw.modeToaster.Hide()
2356 return2294 return
2357 }2295 }
2358 2296
2359 // Show persistent toaster at bottom-left with mode display name.2297 // Show persistent toaster at bottom-left with mode display name.
2360 // Mode class is applied atomically with Show() to avoid visual flicker.2298 // Mode class is applied atomically with Show() to avoid visual flicker.
2361 modeClass := getModeToastClass(mode)2299 modeClass := getModeToastClass(mode)
2362- a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,2300+ bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
2363 component.WithDuration(0), // Persistent until mode exits.2301 component.WithDuration(0), // Persistent until mode exits.
2364 component.WithPosition(component.ToastPositionBottomLeft),2302 component.WithPosition(component.ToastPositionBottomLeft),
2365 component.WithModeClass(modeClass),2303 component.WithModeClass(modeClass),
@@ -2512,6 +2450,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
2512 2450
2513 // activeWorkspace returns the workspace of the active tab.2451 // activeWorkspace returns the workspace of the active tab.
2514 func (a *App) activeWorkspace() *entity.Workspace {2452 func (a *App) activeWorkspace() *entity.Workspace {
2453+ if a.tabs == nil {
2454+ return nil
2455+ }
2515 activeTab := a.tabs.ActiveTab()2456 activeTab := a.tabs.ActiveTab()
2516 if activeTab == nil {2457 if activeTab == nil {
2517 return nil2458 return nil
@@ -3068,9 +3009,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac
3068 if session.omnibox == nil {3009 if session.omnibox == nil {
3069 cfg := a.omniboxCfg3010 cfg := a.omniboxCfg
3070 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {3011 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
3071- if a.appToaster != nil {3012+ a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
3072- a.appToaster.Show(toastCtx, message, level)
3073- }
3074 }3013 }
3075 3014
3076 omnibox := component.NewOmnibox(ctx, cfg)3015 omnibox := component.NewOmnibox(ctx, cfg)
@@ -3594,21 +3533,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) {
3594 3533
3595 switch status.State {3534 switch status.State {
3596 case port.FilterStateLoading:3535 case port.FilterStateLoading:
3597- if a.appToaster != nil {3536+ a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo)
3598- a.appToaster.Show(ctx, status.Message, component.ToastInfo)
3599- }
3600 case port.FilterStateActive:3537 case port.FilterStateActive:
3601 // Apply filters to existing webviews that were created before filters loaded3538 // Apply filters to existing webviews that were created before filters loaded
3602 if a.contentCoord != nil && a.deps.FilterManager != nil {3539 if a.contentCoord != nil && a.deps.FilterManager != nil {
3603 a.contentCoord.ApplyFiltersToAll(ctx)3540 a.contentCoord.ApplyFiltersToAll(ctx)
3604 log.Debug().Msg("applied filters to all existing webviews")3541 log.Debug().Msg("applied filters to all existing webviews")
3605 }3542 }
3606- if a.appToaster != nil {3543+ a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
3607- a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
3608- }
3609 case port.FilterStateError:3544 case port.FilterStateError:
3610- if a.appToaster != nil {3545+ a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError)
3611- a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
3612- }
3613 }3546 }
3614 }3547 }
M internal/ui/app_browser_launch_test.go +515 -0
@@ -8,11 +8,17 @@ import (
88 "unsafe"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1112 "github.com/bnema/dumber/internal/application/usecase"
1213 "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
1315 "github.com/bnema/dumber/internal/ui/component"
1416 "github.com/bnema/dumber/internal/ui/coordinator"
17+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
18+ "github.com/bnema/dumber/internal/ui/dispatcher"
1519 "github.com/bnema/dumber/internal/ui/input"
20+ "github.com/bnema/dumber/internal/ui/layout"
21+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
1622 "github.com/bnema/dumber/internal/ui/window"
1723 "github.com/bnema/puregotk/v4/gio"
1824 "github.com/bnema/puregotk/v4/gtk"
@@ -94,6 +100,86 @@ func windowTabBarActiveID(t *testing.T, mw *window.MainWindow) entity.TabID {
94100 return mw.TabBar().ActiveTabID()
95101 }
96102
103+func windowTitle(t *testing.T, mw *window.MainWindow) string {
104+ t.Helper()
105+ if mw == nil || mw.Window() == nil {
106+ return ""
107+ }
108+ return mw.Window().GetTitle()
109+}
110+
111+func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
112+ t.Helper()
113+ if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
114+ return false
115+ }
116+ return mw.TabBar().Box().GetVisible()
117+}
118+
119+func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
120+ t.Helper()
121+
122+ factory := layoutmocks.NewMockWidgetFactory(t)
123+ box := layoutmocks.NewMockBoxWidget(t)
124+ label := layoutmocks.NewMockLabelWidget(t)
125+
126+ factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(box).Once()
127+ factory.EXPECT().NewLabel("").Return(label).Once()
128+ box.EXPECT().AddCssClass("toast").Once()
129+ box.EXPECT().AddCssClass("toast-info").Once()
130+ box.EXPECT().SetHalign(gtk.AlignStartValue).Twice()
131+ box.EXPECT().SetValign(gtk.AlignStartValue).Twice()
132+ box.EXPECT().SetHexpand(false).Once()
133+ box.EXPECT().SetVexpand(false).Once()
134+ box.EXPECT().SetCanTarget(false).Once()
135+ box.EXPECT().SetCanFocus(false).Once()
136+ box.EXPECT().SetVisible(false).Once()
137+ box.EXPECT().Append(label).Once()
138+ label.EXPECT().SetCanTarget(false).Once()
139+ label.EXPECT().SetCanFocus(false).Once()
140+
141+ return component.NewToaster(factory), box, label
142+}
143+
144+func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
145+ ctx := context.Background()
146+ toaster, box, label := newTestShellToaster(t)
147+ box.EXPECT().SetVisible(true).Once()
148+ label.EXPECT().SetText("Ad blocker loading").Once()
149+
150+ bw := &browserWindow{id: "window-1", appToaster: toaster}
151+ app := &App{
152+ browserWindows: map[string]*browserWindow{bw.id: bw},
153+ lastFocusedWindowID: bw.id,
154+ }
155+
156+ app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
157+}
158+
159+func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
160+ ctx := context.Background()
161+ toaster, box, label := newTestShellToaster(t)
162+ box.EXPECT().SetVisible(true).Once()
163+ label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()
164+
165+ migrator := portmocks.NewMockConfigMigrator(t)
166+ migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
167+
168+ bw := &browserWindow{id: "window-1", appToaster: toaster}
169+ app := &App{
170+ deps: &Dependencies{
171+ Config: &config.Config{
172+ Update: config.UpdateConfig{NotifyOnNewSettings: true},
173+ },
174+ ConfigMigrator: migrator,
175+ },
176+ browserWindows: map[string]*browserWindow{bw.id: bw},
177+ lastFocusedWindowID: bw.id,
178+ }
179+
180+ app.checkConfigMigration(ctx)
181+}
182+
97183 func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
98184 relay := &testBrowserLaunchRelay{closer: &testCloser{}}
99185 deps := &Dependencies{}
@@ -317,3 +403,432 @@ func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
317403 t.Fatalf("browserWindowForPane = %p, want %p", got, second)
318404 }
319405 }
406+
407+func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
408+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
409+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
410+ tabs := entity.NewTabList()
411+ tabs.Add(tab1)
412+ tabs.Add(tab2)
413+ first := &browserWindow{id: "window-1"}
414+ second := &browserWindow{id: "window-2"}
415+ app := &App{
416+ tabs: tabs,
417+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
418+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
419+ lastFocusedWindowID: first.id,
420+ }
421+
422+ got := app.ownerOrLastFocusedBrowserWindow("", entity.PaneID("pane-2"))
423+
424+ if got != second {
425+ t.Fatalf("ownerOrLastFocusedBrowserWindow = %p, want %p", got, second)
426+ }
427+}
428+
429+func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
430+ EnsureAdwaitaInitialized()
431+ appID := AppID
432+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
433+ if gtkApp == nil {
434+ t.Fatal("gtk application creation failed")
435+ }
436+ defer gtkApp.Unref()
437+
438+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
439+ if err != nil {
440+ t.Fatalf("first window creation failed: %v", err)
441+ }
442+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
443+ if err != nil {
444+ t.Fatalf("second window creation failed: %v", err)
445+ }
446+
447+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
448+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
449+ tabs := entity.NewTabList()
450+ tabs.Add(tab1)
451+ tabs.Add(tab2)
452+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
453+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
454+ app := &App{
455+ tabs: tabs,
456+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
457+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
458+ lastFocusedWindowID: first.id,
459+ }
460+
461+ app.handlePaneWindowTitleChanged(entity.PaneID("pane-2"), "Pane Two")
462+
463+ if got := windowTitle(t, firstMainWindow); got != "Dumber" {
464+ t.Fatalf("first window title = %q, want %q", got, "Dumber")
465+ }
466+ if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
467+ t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
468+ }
469+}
470+
471+func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
472+ EnsureAdwaitaInitialized()
473+ appID := AppID
474+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
475+ if gtkApp == nil {
476+ t.Fatal("gtk application creation failed")
477+ }
478+ defer gtkApp.Unref()
479+
480+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
481+ if err != nil {
482+ t.Fatalf("first window creation failed: %v", err)
483+ }
484+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
485+ if err != nil {
486+ t.Fatalf("second window creation failed: %v", err)
487+ }
488+
489+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
490+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
491+ tabs := entity.NewTabList()
492+ tabs.Add(tab1)
493+ tabs.Add(tab2)
494+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID}
495+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID}
496+ contentCoord := &contentcoord.Coordinator{}
497+ contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
498+ reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
499+ app := &App{
500+ tabs: tabs,
501+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
502+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
503+ contentCoord: contentCoord,
504+ }
505+
506+ app.handlePaneWindowTitleChanged(tab2.Workspace.ActivePaneID, "Pane Two")
507+ secondMainWindow.SetTitle("Dumber")
508+
509+ app.activateBrowserWindow(second)
510+
511+ if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
512+ t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
513+ }
514+ if got := windowTitle(t, firstMainWindow); got != "Dumber" {
515+ t.Fatalf("first window title = %q, want %q", got, "Dumber")
516+ }
517+}
518+
519+func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
520+ EnsureAdwaitaInitialized()
521+ appID := AppID
522+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
523+ if gtkApp == nil {
524+ t.Fatal("gtk application creation failed")
525+ }
526+ defer gtkApp.Unref()
527+
528+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
529+ if err != nil {
530+ t.Fatalf("first window creation failed: %v", err)
531+ }
532+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
533+ if err != nil {
534+ t.Fatalf("second window creation failed: %v", err)
535+ }
536+
537+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
538+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
539+ tabs := entity.NewTabList()
540+ tabs.Add(tab1)
541+ tabs.Add(tab2)
542+ secondMainWindow.TabBar().AddTab(tab1)
543+ secondMainWindow.TabBar().AddTab(tab2)
544+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
545+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
546+ app := &App{
547+ tabs: tabs,
548+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
549+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
550+ lastFocusedWindowID: first.id,
551+ }
552+
553+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
554+
555+ if windowTabBarVisible(t, firstMainWindow) != true {
556+ t.Fatalf("first window tab bar visibility changed unexpectedly")
557+ }
558+ if got := windowTabBarVisible(t, secondMainWindow); got {
559+ t.Fatalf("second window tab bar visible = %v, want false", got)
560+ }
561+
562+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
563+
564+ if got := windowTabBarVisible(t, secondMainWindow); !got {
565+ t.Fatalf("second window tab bar visible = %v, want true", got)
566+ }
567+}
568+
569+func TestApp_HandlePaneFullscreenChangedIgnoresUnresolvedPane(t *testing.T) {
570+ EnsureAdwaitaInitialized()
571+ appID := AppID
572+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
573+ if gtkApp == nil {
574+ t.Fatal("gtk application creation failed")
575+ }
576+ defer gtkApp.Unref()
577+
578+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
579+ if err != nil {
580+ t.Fatalf("first window creation failed: %v", err)
581+ }
582+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
583+ if err != nil {
584+ t.Fatalf("second window creation failed: %v", err)
585+ }
586+ firstMainWindow.TabBar().SetVisible(true)
587+ secondMainWindow.TabBar().SetVisible(true)
588+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
589+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
590+ app := &App{
591+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
592+ lastFocusedWindowID: first.id,
593+ }
594+
595+ app.handlePaneFullscreenChanged(entity.PaneID("missing-pane"), true)
596+
597+ if got := windowTabBarVisible(t, firstMainWindow); !got {
598+ t.Fatalf("first window tab bar visible = %v, want true", got)
599+ }
600+ if got := windowTabBarVisible(t, secondMainWindow); !got {
601+ t.Fatalf("second window tab bar visible = %v, want true", got)
602+ }
603+}
604+
605+func TestApp_HandleAccentKeyPressDelegatesToOwningWindow(t *testing.T) {
606+ first := newTestAccentUseCase(t, true)
607+ second := newTestAccentUseCase(t, false)
608+ firstWindow := &browserWindow{id: "window-1", insertAccentUC: first}
609+ secondWindow := &browserWindow{id: "window-2", insertAccentUC: second}
610+ app := &App{
611+ browserWindows: map[string]*browserWindow{firstWindow.id: firstWindow, secondWindow.id: secondWindow},
612+ lastFocusedWindowID: secondWindow.id,
613+ }
614+
615+ if got := app.handleAccentKeyPress(context.Background(), uint('e'), 0); got {
616+ t.Fatalf("handleAccentKeyPress returned %v, want false from active shell handler", got)
617+ }
618+ if got := first.IsPickerVisible(); !got {
619+ t.Fatalf("first shell accent handler should remain visible")
620+ }
621+ if got := second.IsPickerVisible(); got {
622+ t.Fatalf("second shell accent handler should remain hidden")
623+ }
624+}
625+
626+func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.T) {
627+ tabs := entity.NewTabList()
628+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
629+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
630+ tabs.Add(tab1)
631+ tabs.Add(tab2)
632+ first := &browserWindow{id: "window-1", activeTabID: tab1.ID}
633+ second := &browserWindow{id: "window-2", activeTabID: tab2.ID}
634+ app := &App{
635+ tabs: tabs,
636+ movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
637+ deps: &Dependencies{Config: &config.Config{}},
638+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
639+ lastFocusedWindowID: second.id,
640+ }
641+
642+ if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, ""); err != nil {
643+ t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
644+ }
645+
646+ if tabs.Find(tab1.ID) != nil {
647+ t.Fatalf("tab-1 should have been moved from owning window")
648+ }
649+ if tabs.Find(tab2.ID) == nil {
650+ t.Fatalf("tab-2 should remain when owning window is preserved")
651+ }
652+}
653+
654+func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
655+ EnsureAdwaitaInitialized()
656+ appID := AppID
657+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
658+ if gtkApp == nil {
659+ t.Fatal("gtk application creation failed")
660+ }
661+ defer gtkApp.Unref()
662+
663+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
664+ if err != nil {
665+ t.Fatalf("window creation failed: %v", err)
666+ }
667+
668+ sentinelKeyboardHandler := &input.KeyboardHandler{}
669+ sentinelShortcutHandler := &input.GlobalShortcutHandler{}
670+ bw := &browserWindow{
671+ id: "window-1",
672+ mainWindow: mainWindow,
673+ keyboardHandler: sentinelKeyboardHandler,
674+ globalShortcutHandler: sentinelShortcutHandler,
675+ }
676+ app := &App{
677+ deps: &Dependencies{Config: &config.Config{}},
678+ mainWindow: mainWindow,
679+ browserWindows: map[string]*browserWindow{bw.id: bw},
680+ kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, nil, "", func(context.Context) entity.PaneID { return "" }),
681+ }
682+
683+ app.initKeyboardHandler(context.Background())
684+
685+ if bw.keyboardHandler != sentinelKeyboardHandler {
686+ t.Fatalf("keyboardHandler was reattached")
687+ }
688+ if bw.globalShortcutHandler != sentinelShortcutHandler {
689+ t.Fatalf("globalShortcutHandler was reattached")
690+ }
691+}
692+
693+func newTestAccentUseCase(t *testing.T, pickerVisible bool) *usecase.InsertAccentUseCase {
694+ t.Helper()
695+ uc := usecase.NewInsertAccentUseCase(&testFocusedInputProvider{}, nil, nil)
696+ if uc == nil {
697+ t.Fatal("failed to create accent use case")
698+ }
699+ setAccentUseCaseBoolField(t, uc, "pickerVisible", pickerVisible)
700+ return uc
701+}
702+
703+func setAccentUseCaseBoolField(t *testing.T, uc *usecase.InsertAccentUseCase, field string, value bool) {
704+ t.Helper()
705+ rv := reflect.ValueOf(uc).Elem()
706+ fv := rv.FieldByName(field)
707+ if !fv.IsValid() {
708+ t.Fatalf("InsertAccentUseCase missing %s field", field)
709+ }
710+ reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().SetBool(value)
711+}
712+
713+type testFocusedInputProvider struct{}
714+
715+func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
716+func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}
717+
718+func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
719+ EnsureAdwaitaInitialized()
720+ appID := AppID
721+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
722+ if gtkApp == nil {
723+ t.Fatal("gtk application creation failed")
724+ }
725+ defer gtkApp.Unref()
726+
727+ mainWindow := &window.MainWindow{}
728+ tabBar := component.NewTabBar()
729+ if tabBar == nil {
730+ t.Fatal("tab bar creation failed")
731+ }
732+ tabBar.AddTab(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))))
733+ tabBar.SetVisible(false)
734+ setWindowTabBar(t, mainWindow, tabBar)
735+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
736+ app := &App{
737+ deps: &Dependencies{Config: &config.Config{}},
738+ }
739+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = false
740+
741+ app.updateBrowserWindowTabBarVisibility(bw)
742+
743+ if got := windowTabBarVisible(t, mainWindow); !got {
744+ t.Fatalf("tab bar visible = %v, want true", got)
745+ }
746+}
747+
748+func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *testing.T) {
749+ for i := 0; i < 20; i++ {
750+ removed := &browserWindow{id: "window-z", mainWindow: &window.MainWindow{}}
751+ nilWindow := &browserWindow{id: "window-a"}
752+ firstValid := &browserWindow{id: "window-b", mainWindow: &window.MainWindow{}}
753+ secondValid := &browserWindow{id: "window-c", mainWindow: &window.MainWindow{}}
754+ app := &App{
755+ mainWindow: removed.mainWindow,
756+ browserWindows: map[string]*browserWindow{
757+ removed.id: removed,
758+ nilWindow.id: nilWindow,
759+ firstValid.id: firstValid,
760+ secondValid.id: secondValid,
761+ },
762+ lastFocusedWindowID: removed.id,
763+ }
764+
765+ app.removeBrowserWindow(removed.id)
766+
767+ if app.mainWindow != firstValid.mainWindow {
768+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow)
769+ }
770+ if app.lastFocusedWindowID != firstValid.id {
771+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id)
772+ }
773+ }
774+}
775+
776+func TestApp_BrowserWindowActivationHookUpdatesLastFocusedWindowID(t *testing.T) {
777+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
778+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
779+ app := &App{
780+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
781+ lastFocusedWindowID: first.id,
782+ }
783+
784+ app.handleBrowserWindowActivationChanged(second, true)
785+
786+ if app.lastFocusedWindowID != second.id {
787+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
788+ }
789+}
790+
791+func TestApp_ActivateBrowserWindowTracksLastFocusedWindow(t *testing.T) {
792+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
793+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
794+ app := &App{
795+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
796+ }
797+
798+ app.activateBrowserWindow(second)
799+
800+ if app.lastFocusedWindowID != second.id {
801+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
802+ }
803+}
804+
805+func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
806+ first := &browserWindow{id: "window-1"}
807+ second := &browserWindow{id: "window-2"}
808+ app := &App{
809+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
810+ lastFocusedWindowID: second.id,
811+ }
812+
813+ got := app.lastFocusedBrowserWindow()
814+
815+ if got != second {
816+ t.Fatalf("lastFocusedBrowserWindow = %p, want %p", got, second)
817+ }
818+}
819+
820+func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
821+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
822+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
823+ app := &App{
824+ mainWindow: second.mainWindow,
825+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
826+ lastFocusedWindowID: second.id,
827+ }
828+
829+ app.removeBrowserWindow(second.id)
830+
831+ if app.lastFocusedWindowID != first.id {
832+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
833+ }
834+}
@@ -8,11 +8,17 @@ import (
8 "unsafe"8 "unsafe"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
13 "github.com/bnema/dumber/internal/ui/component"15 "github.com/bnema/dumber/internal/ui/component"
14 "github.com/bnema/dumber/internal/ui/coordinator"16 "github.com/bnema/dumber/internal/ui/coordinator"
17+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
18+ "github.com/bnema/dumber/internal/ui/dispatcher"
15 "github.com/bnema/dumber/internal/ui/input"19 "github.com/bnema/dumber/internal/ui/input"
20+ "github.com/bnema/dumber/internal/ui/layout"
21+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
16 "github.com/bnema/dumber/internal/ui/window"22 "github.com/bnema/dumber/internal/ui/window"
17 "github.com/bnema/puregotk/v4/gio"23 "github.com/bnema/puregotk/v4/gio"
18 "github.com/bnema/puregotk/v4/gtk"24 "github.com/bnema/puregotk/v4/gtk"
@@ -94,6 +100,86 @@ func windowTabBarActiveID(t *testing.T, mw *window.MainWindow) entity.TabID {
94 return mw.TabBar().ActiveTabID()100 return mw.TabBar().ActiveTabID()
95 }101 }
96 102
103+func windowTitle(t *testing.T, mw *window.MainWindow) string {
104+ t.Helper()
105+ if mw == nil || mw.Window() == nil {
106+ return ""
107+ }
108+ return mw.Window().GetTitle()
109+}
110+
111+func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
112+ t.Helper()
113+ if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
114+ return false
115+ }
116+ return mw.TabBar().Box().GetVisible()
117+}
118+
119+func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
120+ t.Helper()
121+
122+ factory := layoutmocks.NewMockWidgetFactory(t)
123+ box := layoutmocks.NewMockBoxWidget(t)
124+ label := layoutmocks.NewMockLabelWidget(t)
125+
126+ factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(box).Once()
127+ factory.EXPECT().NewLabel("").Return(label).Once()
128+ box.EXPECT().AddCssClass("toast").Once()
129+ box.EXPECT().AddCssClass("toast-info").Once()
130+ box.EXPECT().SetHalign(gtk.AlignStartValue).Twice()
131+ box.EXPECT().SetValign(gtk.AlignStartValue).Twice()
132+ box.EXPECT().SetHexpand(false).Once()
133+ box.EXPECT().SetVexpand(false).Once()
134+ box.EXPECT().SetCanTarget(false).Once()
135+ box.EXPECT().SetCanFocus(false).Once()
136+ box.EXPECT().SetVisible(false).Once()
137+ box.EXPECT().Append(label).Once()
138+ label.EXPECT().SetCanTarget(false).Once()
139+ label.EXPECT().SetCanFocus(false).Once()
140+
141+ return component.NewToaster(factory), box, label
142+}
143+
144+func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
145+ ctx := context.Background()
146+ toaster, box, label := newTestShellToaster(t)
147+ box.EXPECT().SetVisible(true).Once()
148+ label.EXPECT().SetText("Ad blocker loading").Once()
149+
150+ bw := &browserWindow{id: "window-1", appToaster: toaster}
151+ app := &App{
152+ browserWindows: map[string]*browserWindow{bw.id: bw},
153+ lastFocusedWindowID: bw.id,
154+ }
155+
156+ app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
157+}
158+
159+func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
160+ ctx := context.Background()
161+ toaster, box, label := newTestShellToaster(t)
162+ box.EXPECT().SetVisible(true).Once()
163+ label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()
164+
165+ migrator := portmocks.NewMockConfigMigrator(t)
166+ migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
167+
168+ bw := &browserWindow{id: "window-1", appToaster: toaster}
169+ app := &App{
170+ deps: &Dependencies{
171+ Config: &config.Config{
172+ Update: config.UpdateConfig{NotifyOnNewSettings: true},
173+ },
174+ ConfigMigrator: migrator,
175+ },
176+ browserWindows: map[string]*browserWindow{bw.id: bw},
177+ lastFocusedWindowID: bw.id,
178+ }
179+
180+ app.checkConfigMigration(ctx)
181+}
182+
97 func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {183 func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
98 relay := &testBrowserLaunchRelay{closer: &testCloser{}}184 relay := &testBrowserLaunchRelay{closer: &testCloser{}}
99 deps := &Dependencies{}185 deps := &Dependencies{}
@@ -317,3 +403,432 @@ func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
317 t.Fatalf("browserWindowForPane = %p, want %p", got, second)403 t.Fatalf("browserWindowForPane = %p, want %p", got, second)
318 }404 }
319 }405 }
406+
407+func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
408+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
409+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
410+ tabs := entity.NewTabList()
411+ tabs.Add(tab1)
412+ tabs.Add(tab2)
413+ first := &browserWindow{id: "window-1"}
414+ second := &browserWindow{id: "window-2"}
415+ app := &App{
416+ tabs: tabs,
417+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
418+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
419+ lastFocusedWindowID: first.id,
420+ }
421+
422+ got := app.ownerOrLastFocusedBrowserWindow("", entity.PaneID("pane-2"))
423+
424+ if got != second {
425+ t.Fatalf("ownerOrLastFocusedBrowserWindow = %p, want %p", got, second)
426+ }
427+}
428+
429+func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
430+ EnsureAdwaitaInitialized()
431+ appID := AppID
432+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
433+ if gtkApp == nil {
434+ t.Fatal("gtk application creation failed")
435+ }
436+ defer gtkApp.Unref()
437+
438+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
439+ if err != nil {
440+ t.Fatalf("first window creation failed: %v", err)
441+ }
442+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
443+ if err != nil {
444+ t.Fatalf("second window creation failed: %v", err)
445+ }
446+
447+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
448+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
449+ tabs := entity.NewTabList()
450+ tabs.Add(tab1)
451+ tabs.Add(tab2)
452+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
453+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
454+ app := &App{
455+ tabs: tabs,
456+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
457+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
458+ lastFocusedWindowID: first.id,
459+ }
460+
461+ app.handlePaneWindowTitleChanged(entity.PaneID("pane-2"), "Pane Two")
462+
463+ if got := windowTitle(t, firstMainWindow); got != "Dumber" {
464+ t.Fatalf("first window title = %q, want %q", got, "Dumber")
465+ }
466+ if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
467+ t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
468+ }
469+}
470+
471+func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
472+ EnsureAdwaitaInitialized()
473+ appID := AppID
474+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
475+ if gtkApp == nil {
476+ t.Fatal("gtk application creation failed")
477+ }
478+ defer gtkApp.Unref()
479+
480+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
481+ if err != nil {
482+ t.Fatalf("first window creation failed: %v", err)
483+ }
484+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
485+ if err != nil {
486+ t.Fatalf("second window creation failed: %v", err)
487+ }
488+
489+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
490+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
491+ tabs := entity.NewTabList()
492+ tabs.Add(tab1)
493+ tabs.Add(tab2)
494+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID}
495+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID}
496+ contentCoord := &contentcoord.Coordinator{}
497+ contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
498+ reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
499+ app := &App{
500+ tabs: tabs,
501+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
502+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
503+ contentCoord: contentCoord,
504+ }
505+
506+ app.handlePaneWindowTitleChanged(tab2.Workspace.ActivePaneID, "Pane Two")
507+ secondMainWindow.SetTitle("Dumber")
508+
509+ app.activateBrowserWindow(second)
510+
511+ if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
512+ t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
513+ }
514+ if got := windowTitle(t, firstMainWindow); got != "Dumber" {
515+ t.Fatalf("first window title = %q, want %q", got, "Dumber")
516+ }
517+}
518+
519+func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
520+ EnsureAdwaitaInitialized()
521+ appID := AppID
522+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
523+ if gtkApp == nil {
524+ t.Fatal("gtk application creation failed")
525+ }
526+ defer gtkApp.Unref()
527+
528+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
529+ if err != nil {
530+ t.Fatalf("first window creation failed: %v", err)
531+ }
532+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
533+ if err != nil {
534+ t.Fatalf("second window creation failed: %v", err)
535+ }
536+
537+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
538+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
539+ tabs := entity.NewTabList()
540+ tabs.Add(tab1)
541+ tabs.Add(tab2)
542+ secondMainWindow.TabBar().AddTab(tab1)
543+ secondMainWindow.TabBar().AddTab(tab2)
544+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
545+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
546+ app := &App{
547+ tabs: tabs,
548+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
549+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
550+ lastFocusedWindowID: first.id,
551+ }
552+
553+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
554+
555+ if windowTabBarVisible(t, firstMainWindow) != true {
556+ t.Fatalf("first window tab bar visibility changed unexpectedly")
557+ }
558+ if got := windowTabBarVisible(t, secondMainWindow); got {
559+ t.Fatalf("second window tab bar visible = %v, want false", got)
560+ }
561+
562+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
563+
564+ if got := windowTabBarVisible(t, secondMainWindow); !got {
565+ t.Fatalf("second window tab bar visible = %v, want true", got)
566+ }
567+}
568+
569+func TestApp_HandlePaneFullscreenChangedIgnoresUnresolvedPane(t *testing.T) {
570+ EnsureAdwaitaInitialized()
571+ appID := AppID
572+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
573+ if gtkApp == nil {
574+ t.Fatal("gtk application creation failed")
575+ }
576+ defer gtkApp.Unref()
577+
578+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
579+ if err != nil {
580+ t.Fatalf("first window creation failed: %v", err)
581+ }
582+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
583+ if err != nil {
584+ t.Fatalf("second window creation failed: %v", err)
585+ }
586+ firstMainWindow.TabBar().SetVisible(true)
587+ secondMainWindow.TabBar().SetVisible(true)
588+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
589+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
590+ app := &App{
591+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
592+ lastFocusedWindowID: first.id,
593+ }
594+
595+ app.handlePaneFullscreenChanged(entity.PaneID("missing-pane"), true)
596+
597+ if got := windowTabBarVisible(t, firstMainWindow); !got {
598+ t.Fatalf("first window tab bar visible = %v, want true", got)
599+ }
600+ if got := windowTabBarVisible(t, secondMainWindow); !got {
601+ t.Fatalf("second window tab bar visible = %v, want true", got)
602+ }
603+}
604+
605+func TestApp_HandleAccentKeyPressDelegatesToOwningWindow(t *testing.T) {
606+ first := newTestAccentUseCase(t, true)
607+ second := newTestAccentUseCase(t, false)
608+ firstWindow := &browserWindow{id: "window-1", insertAccentUC: first}
609+ secondWindow := &browserWindow{id: "window-2", insertAccentUC: second}
610+ app := &App{
611+ browserWindows: map[string]*browserWindow{firstWindow.id: firstWindow, secondWindow.id: secondWindow},
612+ lastFocusedWindowID: secondWindow.id,
613+ }
614+
615+ if got := app.handleAccentKeyPress(context.Background(), uint('e'), 0); got {
616+ t.Fatalf("handleAccentKeyPress returned %v, want false from active shell handler", got)
617+ }
618+ if got := first.IsPickerVisible(); !got {
619+ t.Fatalf("first shell accent handler should remain visible")
620+ }
621+ if got := second.IsPickerVisible(); got {
622+ t.Fatalf("second shell accent handler should remain hidden")
623+ }
624+}
625+
626+func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.T) {
627+ tabs := entity.NewTabList()
628+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
629+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
630+ tabs.Add(tab1)
631+ tabs.Add(tab2)
632+ first := &browserWindow{id: "window-1", activeTabID: tab1.ID}
633+ second := &browserWindow{id: "window-2", activeTabID: tab2.ID}
634+ app := &App{
635+ tabs: tabs,
636+ movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
637+ deps: &Dependencies{Config: &config.Config{}},
638+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
639+ lastFocusedWindowID: second.id,
640+ }
641+
642+ if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, ""); err != nil {
643+ t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
644+ }
645+
646+ if tabs.Find(tab1.ID) != nil {
647+ t.Fatalf("tab-1 should have been moved from owning window")
648+ }
649+ if tabs.Find(tab2.ID) == nil {
650+ t.Fatalf("tab-2 should remain when owning window is preserved")
651+ }
652+}
653+
654+func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
655+ EnsureAdwaitaInitialized()
656+ appID := AppID
657+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
658+ if gtkApp == nil {
659+ t.Fatal("gtk application creation failed")
660+ }
661+ defer gtkApp.Unref()
662+
663+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
664+ if err != nil {
665+ t.Fatalf("window creation failed: %v", err)
666+ }
667+
668+ sentinelKeyboardHandler := &input.KeyboardHandler{}
669+ sentinelShortcutHandler := &input.GlobalShortcutHandler{}
670+ bw := &browserWindow{
671+ id: "window-1",
672+ mainWindow: mainWindow,
673+ keyboardHandler: sentinelKeyboardHandler,
674+ globalShortcutHandler: sentinelShortcutHandler,
675+ }
676+ app := &App{
677+ deps: &Dependencies{Config: &config.Config{}},
678+ mainWindow: mainWindow,
679+ browserWindows: map[string]*browserWindow{bw.id: bw},
680+ kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, nil, "", func(context.Context) entity.PaneID { return "" }),
681+ }
682+
683+ app.initKeyboardHandler(context.Background())
684+
685+ if bw.keyboardHandler != sentinelKeyboardHandler {
686+ t.Fatalf("keyboardHandler was reattached")
687+ }
688+ if bw.globalShortcutHandler != sentinelShortcutHandler {
689+ t.Fatalf("globalShortcutHandler was reattached")
690+ }
691+}
692+
693+func newTestAccentUseCase(t *testing.T, pickerVisible bool) *usecase.InsertAccentUseCase {
694+ t.Helper()
695+ uc := usecase.NewInsertAccentUseCase(&testFocusedInputProvider{}, nil, nil)
696+ if uc == nil {
697+ t.Fatal("failed to create accent use case")
698+ }
699+ setAccentUseCaseBoolField(t, uc, "pickerVisible", pickerVisible)
700+ return uc
701+}
702+
703+func setAccentUseCaseBoolField(t *testing.T, uc *usecase.InsertAccentUseCase, field string, value bool) {
704+ t.Helper()
705+ rv := reflect.ValueOf(uc).Elem()
706+ fv := rv.FieldByName(field)
707+ if !fv.IsValid() {
708+ t.Fatalf("InsertAccentUseCase missing %s field", field)
709+ }
710+ reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().SetBool(value)
711+}
712+
713+type testFocusedInputProvider struct{}
714+
715+func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
716+func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}
717+
718+func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
719+ EnsureAdwaitaInitialized()
720+ appID := AppID
721+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
722+ if gtkApp == nil {
723+ t.Fatal("gtk application creation failed")
724+ }
725+ defer gtkApp.Unref()
726+
727+ mainWindow := &window.MainWindow{}
728+ tabBar := component.NewTabBar()
729+ if tabBar == nil {
730+ t.Fatal("tab bar creation failed")
731+ }
732+ tabBar.AddTab(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))))
733+ tabBar.SetVisible(false)
734+ setWindowTabBar(t, mainWindow, tabBar)
735+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
736+ app := &App{
737+ deps: &Dependencies{Config: &config.Config{}},
738+ }
739+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = false
740+
741+ app.updateBrowserWindowTabBarVisibility(bw)
742+
743+ if got := windowTabBarVisible(t, mainWindow); !got {
744+ t.Fatalf("tab bar visible = %v, want true", got)
745+ }
746+}
747+
748+func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *testing.T) {
749+ for i := 0; i < 20; i++ {
750+ removed := &browserWindow{id: "window-z", mainWindow: &window.MainWindow{}}
751+ nilWindow := &browserWindow{id: "window-a"}
752+ firstValid := &browserWindow{id: "window-b", mainWindow: &window.MainWindow{}}
753+ secondValid := &browserWindow{id: "window-c", mainWindow: &window.MainWindow{}}
754+ app := &App{
755+ mainWindow: removed.mainWindow,
756+ browserWindows: map[string]*browserWindow{
757+ removed.id: removed,
758+ nilWindow.id: nilWindow,
759+ firstValid.id: firstValid,
760+ secondValid.id: secondValid,
761+ },
762+ lastFocusedWindowID: removed.id,
763+ }
764+
765+ app.removeBrowserWindow(removed.id)
766+
767+ if app.mainWindow != firstValid.mainWindow {
768+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow)
769+ }
770+ if app.lastFocusedWindowID != firstValid.id {
771+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id)
772+ }
773+ }
774+}
775+
776+func TestApp_BrowserWindowActivationHookUpdatesLastFocusedWindowID(t *testing.T) {
777+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
778+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
779+ app := &App{
780+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
781+ lastFocusedWindowID: first.id,
782+ }
783+
784+ app.handleBrowserWindowActivationChanged(second, true)
785+
786+ if app.lastFocusedWindowID != second.id {
787+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
788+ }
789+}
790+
791+func TestApp_ActivateBrowserWindowTracksLastFocusedWindow(t *testing.T) {
792+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
793+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
794+ app := &App{
795+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
796+ }
797+
798+ app.activateBrowserWindow(second)
799+
800+ if app.lastFocusedWindowID != second.id {
801+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
802+ }
803+}
804+
805+func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
806+ first := &browserWindow{id: "window-1"}
807+ second := &browserWindow{id: "window-2"}
808+ app := &App{
809+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
810+ lastFocusedWindowID: second.id,
811+ }
812+
813+ got := app.lastFocusedBrowserWindow()
814+
815+ if got != second {
816+ t.Fatalf("lastFocusedBrowserWindow = %p, want %p", got, second)
817+ }
818+}
819+
820+func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
821+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
822+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
823+ app := &App{
824+ mainWindow: second.mainWindow,
825+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
826+ lastFocusedWindowID: second.id,
827+ }
828+
829+ app.removeBrowserWindow(second.id)
830+
831+ if app.lastFocusedWindowID != first.id {
832+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
833+ }
834+}
M internal/ui/browser_window.go +247 -9
@@ -2,12 +2,18 @@ package ui
22
33 import (
44 "context"
5+ "sort"
56
67 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
79 "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
811 "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/focus"
913 "github.com/bnema/dumber/internal/ui/input"
14+ "github.com/bnema/dumber/internal/ui/layout"
1015 "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/bnema/puregotk/v4/glib"
1117 )
1218
1319 type browserWindow struct {
@@ -15,12 +21,215 @@ type browserWindow struct {
1521 initialURL string
1622 activeTabID entity.TabID
1723 mainWindow *window.MainWindow
24+ appToaster *component.Toaster
25+ modeToaster *component.Toaster
26+ borderMgr *focus.BorderManager
27+ sessionManager *component.SessionManager
28+ tabPicker *component.TabPicker
29+ tabPickerWidget layout.Widget
30+ tabPickerPaneID entity.PaneID
31+ insertAccentUC *usecase.InsertAccentUseCase
32+ accentPicker *component.AccentPicker
1833 keyboardHandler *input.KeyboardHandler
1934 globalShortcutHandler *input.GlobalShortcutHandler
2035 permissionDialog port.PermissionDialogPresenter
2136 webrtcIndicator *component.WebRTCPermissionIndicator
2237 }
2338
39+func (bw *browserWindow) clearShellState() {
40+ if bw == nil {
41+ return
42+ }
43+ bw.appToaster = nil
44+ bw.modeToaster = nil
45+ bw.borderMgr = nil
46+ bw.sessionManager = nil
47+ bw.tabPicker = nil
48+ bw.tabPickerWidget = nil
49+ bw.tabPickerPaneID = ""
50+ bw.insertAccentUC = nil
51+ bw.accentPicker = nil
52+ bw.keyboardHandler = nil
53+ bw.globalShortcutHandler = nil
54+ bw.permissionDialog = nil
55+ bw.webrtcIndicator = nil
56+}
57+
58+func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
59+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
60+ return
61+ }
62+
63+ bw.initToasterOverlay(a)
64+ bw.initBorderOverlay(a)
65+ bw.initAccentPicker(ctx, a)
66+ bw.initSessionManager(ctx, a)
67+ bw.initTabPicker(ctx, a)
68+}
69+
70+func (bw *browserWindow) initToasterOverlay(a *App) {
71+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
72+ return
73+ }
74+
75+ bw.appToaster = component.NewToaster(a.widgetFactory)
76+ if bw.appToaster != nil {
77+ if widget := bw.appToaster.Widget(); widget != nil {
78+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
79+ bw.mainWindow.AddOverlay(gtkWidget)
80+ }
81+ }
82+ }
83+
84+ bw.modeToaster = component.NewToaster(a.widgetFactory)
85+ if bw.modeToaster != nil {
86+ if widget := bw.modeToaster.Widget(); widget != nil {
87+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
88+ bw.mainWindow.AddOverlay(gtkWidget)
89+ }
90+ }
91+ }
92+}
93+
94+func (bw *browserWindow) initBorderOverlay(a *App) {
95+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
96+ return
97+ }
98+
99+ bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
100+ if bw.borderMgr == nil {
101+ return
102+ }
103+ if widget := bw.borderMgr.Widget(); widget != nil {
104+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
105+ bw.mainWindow.AddOverlay(gtkWidget)
106+ }
107+ }
108+}
109+
110+func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
111+ log := logging.FromContext(ctx)
112+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil {
113+ log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker")
114+ return
115+ }
116+
117+ bw.accentPicker = component.NewAccentPicker(a.widgetFactory)
118+ if bw.accentPicker == nil {
119+ log.Warn().Msg("failed to create accent picker")
120+ return
121+ }
122+ if widget := bw.accentPicker.Widget(); widget != nil {
123+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
124+ bw.mainWindow.AddOverlay(gtkWidget)
125+ }
126+ }
127+
128+ a.accentFocusProvider = a.deps.AccentFocusProvider
129+ bw.insertAccentUC = usecase.NewInsertAccentUseCase(
130+ a.accentFocusProvider,
131+ bw.accentPicker,
132+ func(fn func()) {
133+ cb := glib.SourceFunc(func(_ uintptr) bool {
134+ fn()
135+ return false
136+ })
137+ glib.IdleAdd(&cb, 0)
138+ },
139+ )
140+
141+ log.Debug().Msg("accent picker initialized")
142+}
143+
144+func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) {
145+ log := logging.FromContext(ctx)
146+ if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil || bw.mainWindow == nil {
147+ log.Debug().Msg("deps or main window not available, skipping session manager")
148+ return
149+ }
150+
151+ var listSessionsUC *usecase.ListSessionsUseCase
152+ var deleteSessionUC *usecase.DeleteSessionUseCase
153+ if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
154+ listSessionsUC = usecase.NewListSessionsUseCase(
155+ a.deps.SessionRepo,
156+ a.deps.SessionStateRepo,
157+ )
158+ deleteSessionUC = usecase.NewDeleteSessionUseCase(
159+ a.deps.SessionStateRepo,
160+ a.deps.SessionRepo,
161+ )
162+ }
163+
164+ bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
165+ ListSessionsUC: listSessionsUC,
166+ DeleteSessionUC: deleteSessionUC,
167+ CurrentSession: a.deps.CurrentSessionID,
168+ UIScale: a.deps.Config.DefaultUIScale,
169+ OnClose: func() {
170+ log.Debug().Msg("session manager closed")
171+ },
172+ OnOpen: func(sessionID entity.SessionID) {
173+ log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
174+ if spawner := a.deps.SessionSpawner; spawner == nil {
175+ log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
176+ return
177+ } else if err := spawner.SpawnWithSession(sessionID); err != nil {
178+ log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
179+ }
180+ },
181+ OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
182+ if bw.appToaster != nil {
183+ bw.appToaster.Show(ctx, message, level)
184+ }
185+ },
186+ })
187+
188+ if bw.sessionManager == nil {
189+ log.Warn().Msg("failed to create session manager")
190+ return
191+ }
192+ if widget := bw.sessionManager.Widget(); widget != nil {
193+ bw.mainWindow.AddOverlay(widget)
194+ }
195+ log.Debug().Msg("session manager initialized")
196+}
197+
198+func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
199+ log := logging.FromContext(ctx)
200+ if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil {
201+ log.Debug().Msg("deps/config not available, skipping tab picker")
202+ return
203+ }
204+
205+ bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
206+ UIScale: a.deps.Config.DefaultUIScale,
207+ OnClose: func() {
208+ log.Debug().Msg("tab picker closed")
209+ },
210+ OnSelect: func(item component.TabPickerItem) {
211+ cb := glib.SourceFunc(func(_ uintptr) bool {
212+ var targetID entity.TabID
213+ if !item.IsNew {
214+ targetID = item.TabID
215+ }
216+ if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil {
217+ log.Warn().Err(err).Msg("move pane to tab failed")
218+ }
219+ return false
220+ })
221+ glib.IdleAdd(&cb, 0)
222+ },
223+ })
224+
225+ if bw.tabPicker == nil {
226+ log.Warn().Msg("failed to create tab picker")
227+ return
228+ }
229+
230+ log.Debug().Msg("tab picker initialized")
231+}
232+
24233 func (a *App) registerBrowserWindow(bw *browserWindow) {
25234 if bw == nil {
26235 return
@@ -35,28 +244,57 @@ func (a *App) removeBrowserWindow(id string) {
35244 if id == "" || a.browserWindows == nil {
36245 return
37246 }
247+ removed := a.browserWindows[id]
248+ wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
38249 for tabID, bw := range a.windowForTab {
39250 if bw != nil && bw.id == id {
40251 delete(a.windowForTab, tabID)
41252 }
42253 }
43- if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow {
44- delete(a.browserWindows, id)
45- for _, remaining := range a.browserWindows {
46- if remaining != nil && remaining.mainWindow != nil {
47- a.activateBrowserWindow(remaining)
48- return
49- }
254+ if removed != nil {
255+ removed.clearShellState()
256+ }
257+ delete(a.browserWindows, id)
258+ fallback := a.deterministicBrowserWindowFallback()
259+ if a.lastFocusedWindowID == id {
260+ if fallback != nil {
261+ a.lastFocusedWindowID = fallback.id
262+ } else {
263+ a.lastFocusedWindowID = ""
264+ }
265+ }
266+ if wasMainWindow {
267+ a.clearResizeModeBorder()
268+ if fallback != nil {
269+ a.activateBrowserWindow(fallback)
270+ return
50271 }
272+ a.lastFocusedWindowID = ""
51273 a.mainWindow = nil
52274 a.keyboardHandler = nil
53275 a.globalShortcutHandler = nil
54276 if a.tabCoord != nil {
55277 a.tabCoord.SetMainWindow(nil)
56278 }
57- return
58279 }
59- delete(a.browserWindows, id)
280+}
281+
282+func (a *App) deterministicBrowserWindowFallback() *browserWindow {
283+ if a.browserWindows == nil {
284+ return nil
285+ }
286+ ids := make([]string, 0, len(a.browserWindows))
287+ for id, bw := range a.browserWindows {
288+ if id == "" || bw == nil || bw.mainWindow == nil {
289+ continue
290+ }
291+ ids = append(ids, id)
292+ }
293+ sort.Strings(ids)
294+ if len(ids) == 0 {
295+ return nil
296+ }
297+ return a.browserWindows[ids[0]]
60298 }
61299
62300 func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
@@ -2,12 +2,18 @@ package ui
2 2
3 import (3 import (
4 "context"4 "context"
5+ "sort"
5 6
6 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
7 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
8 "github.com/bnema/dumber/internal/ui/component"11 "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/focus"
9 "github.com/bnema/dumber/internal/ui/input"13 "github.com/bnema/dumber/internal/ui/input"
14+ "github.com/bnema/dumber/internal/ui/layout"
10 "github.com/bnema/dumber/internal/ui/window"15 "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/bnema/puregotk/v4/glib"
11 )17 )
12 18
13 type browserWindow struct {19 type browserWindow struct {
@@ -15,12 +21,215 @@ type browserWindow struct {
15 initialURL string21 initialURL string
16 activeTabID entity.TabID22 activeTabID entity.TabID
17 mainWindow *window.MainWindow23 mainWindow *window.MainWindow
24+ appToaster *component.Toaster
25+ modeToaster *component.Toaster
26+ borderMgr *focus.BorderManager
27+ sessionManager *component.SessionManager
28+ tabPicker *component.TabPicker
29+ tabPickerWidget layout.Widget
30+ tabPickerPaneID entity.PaneID
31+ insertAccentUC *usecase.InsertAccentUseCase
32+ accentPicker *component.AccentPicker
18 keyboardHandler *input.KeyboardHandler33 keyboardHandler *input.KeyboardHandler
19 globalShortcutHandler *input.GlobalShortcutHandler34 globalShortcutHandler *input.GlobalShortcutHandler
20 permissionDialog port.PermissionDialogPresenter35 permissionDialog port.PermissionDialogPresenter
21 webrtcIndicator *component.WebRTCPermissionIndicator36 webrtcIndicator *component.WebRTCPermissionIndicator
22 }37 }
23 38
39+func (bw *browserWindow) clearShellState() {
40+ if bw == nil {
41+ return
42+ }
43+ bw.appToaster = nil
44+ bw.modeToaster = nil
45+ bw.borderMgr = nil
46+ bw.sessionManager = nil
47+ bw.tabPicker = nil
48+ bw.tabPickerWidget = nil
49+ bw.tabPickerPaneID = ""
50+ bw.insertAccentUC = nil
51+ bw.accentPicker = nil
52+ bw.keyboardHandler = nil
53+ bw.globalShortcutHandler = nil
54+ bw.permissionDialog = nil
55+ bw.webrtcIndicator = nil
56+}
57+
58+func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
59+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
60+ return
61+ }
62+
63+ bw.initToasterOverlay(a)
64+ bw.initBorderOverlay(a)
65+ bw.initAccentPicker(ctx, a)
66+ bw.initSessionManager(ctx, a)
67+ bw.initTabPicker(ctx, a)
68+}
69+
70+func (bw *browserWindow) initToasterOverlay(a *App) {
71+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
72+ return
73+ }
74+
75+ bw.appToaster = component.NewToaster(a.widgetFactory)
76+ if bw.appToaster != nil {
77+ if widget := bw.appToaster.Widget(); widget != nil {
78+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
79+ bw.mainWindow.AddOverlay(gtkWidget)
80+ }
81+ }
82+ }
83+
84+ bw.modeToaster = component.NewToaster(a.widgetFactory)
85+ if bw.modeToaster != nil {
86+ if widget := bw.modeToaster.Widget(); widget != nil {
87+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
88+ bw.mainWindow.AddOverlay(gtkWidget)
89+ }
90+ }
91+ }
92+}
93+
94+func (bw *browserWindow) initBorderOverlay(a *App) {
95+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
96+ return
97+ }
98+
99+ bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
100+ if bw.borderMgr == nil {
101+ return
102+ }
103+ if widget := bw.borderMgr.Widget(); widget != nil {
104+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
105+ bw.mainWindow.AddOverlay(gtkWidget)
106+ }
107+ }
108+}
109+
110+func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
111+ log := logging.FromContext(ctx)
112+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil {
113+ log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker")
114+ return
115+ }
116+
117+ bw.accentPicker = component.NewAccentPicker(a.widgetFactory)
118+ if bw.accentPicker == nil {
119+ log.Warn().Msg("failed to create accent picker")
120+ return
121+ }
122+ if widget := bw.accentPicker.Widget(); widget != nil {
123+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
124+ bw.mainWindow.AddOverlay(gtkWidget)
125+ }
126+ }
127+
128+ a.accentFocusProvider = a.deps.AccentFocusProvider
129+ bw.insertAccentUC = usecase.NewInsertAccentUseCase(
130+ a.accentFocusProvider,
131+ bw.accentPicker,
132+ func(fn func()) {
133+ cb := glib.SourceFunc(func(_ uintptr) bool {
134+ fn()
135+ return false
136+ })
137+ glib.IdleAdd(&cb, 0)
138+ },
139+ )
140+
141+ log.Debug().Msg("accent picker initialized")
142+}
143+
144+func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) {
145+ log := logging.FromContext(ctx)
146+ if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil || bw.mainWindow == nil {
147+ log.Debug().Msg("deps or main window not available, skipping session manager")
148+ return
149+ }
150+
151+ var listSessionsUC *usecase.ListSessionsUseCase
152+ var deleteSessionUC *usecase.DeleteSessionUseCase
153+ if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
154+ listSessionsUC = usecase.NewListSessionsUseCase(
155+ a.deps.SessionRepo,
156+ a.deps.SessionStateRepo,
157+ )
158+ deleteSessionUC = usecase.NewDeleteSessionUseCase(
159+ a.deps.SessionStateRepo,
160+ a.deps.SessionRepo,
161+ )
162+ }
163+
164+ bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
165+ ListSessionsUC: listSessionsUC,
166+ DeleteSessionUC: deleteSessionUC,
167+ CurrentSession: a.deps.CurrentSessionID,
168+ UIScale: a.deps.Config.DefaultUIScale,
169+ OnClose: func() {
170+ log.Debug().Msg("session manager closed")
171+ },
172+ OnOpen: func(sessionID entity.SessionID) {
173+ log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
174+ if spawner := a.deps.SessionSpawner; spawner == nil {
175+ log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
176+ return
177+ } else if err := spawner.SpawnWithSession(sessionID); err != nil {
178+ log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
179+ }
180+ },
181+ OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
182+ if bw.appToaster != nil {
183+ bw.appToaster.Show(ctx, message, level)
184+ }
185+ },
186+ })
187+
188+ if bw.sessionManager == nil {
189+ log.Warn().Msg("failed to create session manager")
190+ return
191+ }
192+ if widget := bw.sessionManager.Widget(); widget != nil {
193+ bw.mainWindow.AddOverlay(widget)
194+ }
195+ log.Debug().Msg("session manager initialized")
196+}
197+
198+func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
199+ log := logging.FromContext(ctx)
200+ if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil {
201+ log.Debug().Msg("deps/config not available, skipping tab picker")
202+ return
203+ }
204+
205+ bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
206+ UIScale: a.deps.Config.DefaultUIScale,
207+ OnClose: func() {
208+ log.Debug().Msg("tab picker closed")
209+ },
210+ OnSelect: func(item component.TabPickerItem) {
211+ cb := glib.SourceFunc(func(_ uintptr) bool {
212+ var targetID entity.TabID
213+ if !item.IsNew {
214+ targetID = item.TabID
215+ }
216+ if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil {
217+ log.Warn().Err(err).Msg("move pane to tab failed")
218+ }
219+ return false
220+ })
221+ glib.IdleAdd(&cb, 0)
222+ },
223+ })
224+
225+ if bw.tabPicker == nil {
226+ log.Warn().Msg("failed to create tab picker")
227+ return
228+ }
229+
230+ log.Debug().Msg("tab picker initialized")
231+}
232+
24 func (a *App) registerBrowserWindow(bw *browserWindow) {233 func (a *App) registerBrowserWindow(bw *browserWindow) {
25 if bw == nil {234 if bw == nil {
26 return235 return
@@ -35,28 +244,57 @@ func (a *App) removeBrowserWindow(id string) {
35 if id == "" || a.browserWindows == nil {244 if id == "" || a.browserWindows == nil {
36 return245 return
37 }246 }
247+ removed := a.browserWindows[id]
248+ wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
38 for tabID, bw := range a.windowForTab {249 for tabID, bw := range a.windowForTab {
39 if bw != nil && bw.id == id {250 if bw != nil && bw.id == id {
40 delete(a.windowForTab, tabID)251 delete(a.windowForTab, tabID)
41 }252 }
42 }253 }
43- if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow {254+ if removed != nil {
44- delete(a.browserWindows, id)255+ removed.clearShellState()
45- for _, remaining := range a.browserWindows {256+ }
46- if remaining != nil && remaining.mainWindow != nil {257+ delete(a.browserWindows, id)
47- a.activateBrowserWindow(remaining)258+ fallback := a.deterministicBrowserWindowFallback()
48- return259+ if a.lastFocusedWindowID == id {
49- }260+ if fallback != nil {
261+ a.lastFocusedWindowID = fallback.id
262+ } else {
263+ a.lastFocusedWindowID = ""
264+ }
265+ }
266+ if wasMainWindow {
267+ a.clearResizeModeBorder()
268+ if fallback != nil {
269+ a.activateBrowserWindow(fallback)
270+ return
50 }271 }
272+ a.lastFocusedWindowID = ""
51 a.mainWindow = nil273 a.mainWindow = nil
52 a.keyboardHandler = nil274 a.keyboardHandler = nil
53 a.globalShortcutHandler = nil275 a.globalShortcutHandler = nil
54 if a.tabCoord != nil {276 if a.tabCoord != nil {
55 a.tabCoord.SetMainWindow(nil)277 a.tabCoord.SetMainWindow(nil)
56 }278 }
57- return
58 }279 }
59- delete(a.browserWindows, id)280+}
281+
282+func (a *App) deterministicBrowserWindowFallback() *browserWindow {
283+ if a.browserWindows == nil {
284+ return nil
285+ }
286+ ids := make([]string, 0, len(a.browserWindows))
287+ for id, bw := range a.browserWindows {
288+ if id == "" || bw == nil || bw.mainWindow == nil {
289+ continue
290+ }
291+ ids = append(ids, id)
292+ }
293+ sort.Strings(ids)
294+ if len(ids) == 0 {
295+ return nil
296+ }
297+ return a.browserWindows[ids[0]]
60 }298 }
61 299
62 func (a *App) OpenFreshWindow(ctx context.Context, url string) error {300 func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
M internal/ui/browser_window_test.go +130 -0
@@ -3,11 +3,64 @@ package ui
33 import (
44 "context"
55 "errors"
6+ "reflect"
67 "testing"
8+ "unsafe"
79
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/ui/component"
13+ "github.com/bnema/dumber/internal/ui/focus"
14+ "github.com/bnema/dumber/internal/ui/layout"
815 "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/bnema/puregotk/v4/gtk"
917 )
1018
19+func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
20+ mainWindow := &window.MainWindow{}
21+ removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
22+ remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
23+ app := &App{
24+ mainWindow: mainWindow,
25+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
26+ lastFocusedWindowID: remaining.id,
27+ }
28+
29+ setShellField(t, removed, "appToaster", &component.Toaster{})
30+ setShellField(t, removed, "modeToaster", &component.Toaster{})
31+ setShellField(t, removed, "borderMgr", &focus.BorderManager{})
32+ setShellField(t, removed, "sessionManager", &component.SessionManager{})
33+ setShellField(t, removed, "tabPicker", &component.TabPicker{})
34+ setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
35+ setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
36+ setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
37+ setShellField(t, removed, "accentPicker", &component.AccentPicker{})
38+ setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
39+ setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
40+
41+ app.removeBrowserWindow(removed.id)
42+
43+ for _, name := range []string{
44+ "appToaster",
45+ "modeToaster",
46+ "borderMgr",
47+ "sessionManager",
48+ "tabPicker",
49+ "tabPickerWidget",
50+ "tabPickerPaneID",
51+ "insertAccentUC",
52+ "accentPicker",
53+ "keyboardHandler",
54+ "globalShortcutHandler",
55+ "permissionDialog",
56+ "webrtcIndicator",
57+ } {
58+ if !fieldIsZero(t, removed, name) {
59+ t.Fatalf("browserWindow.%s was not cleared", name)
60+ }
61+ }
62+}
63+
1164 func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
1265 app := &App{browserWindows: make(map[string]*browserWindow)}
1366 first := &browserWindow{id: "window-1"}
@@ -77,6 +130,25 @@ func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
77130 }
78131 }
79132
133+func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {
134+ mainWindow := &window.MainWindow{}
135+ otherWindow := &window.MainWindow{}
136+ removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
137+ remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
138+ resizeTarget := &testLayoutWidget{}
139+ app := &App{
140+ mainWindow: mainWindow,
141+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
142+ resizeModeBorderTarget: resizeTarget,
143+ }
144+
145+ app.removeBrowserWindow(removed.id)
146+
147+ if app.resizeModeBorderTarget != nil {
148+ t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)
149+ }
150+}
151+
80152 func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
81153 app := &App{browserWindows: make(map[string]*browserWindow)}
82154
@@ -128,3 +200,61 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
128200 t.Fatalf("browserWindows length = %d, want 0", got)
129201 }
130202 }
203+
204+func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
205+ t.Helper()
206+ rv := reflect.ValueOf(bw).Elem()
207+ field := rv.FieldByName(name)
208+ if !field.IsValid() {
209+ t.Fatalf("browserWindow missing field %s", name)
210+ }
211+ reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
212+}
213+
214+func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
215+ t.Helper()
216+ rv := reflect.ValueOf(bw).Elem()
217+ field := rv.FieldByName(name)
218+ if !field.IsValid() {
219+ t.Fatalf("browserWindow missing field %s", name)
220+ }
221+ return field.IsZero()
222+}
223+
224+type testLayoutWidget struct{}
225+
226+func (*testLayoutWidget) Show() {}
227+func (*testLayoutWidget) Hide() {}
228+func (*testLayoutWidget) SetVisible(bool) {}
229+func (*testLayoutWidget) IsVisible() bool { return false }
230+func (*testLayoutWidget) SetOpacity(float64) {}
231+func (*testLayoutWidget) GrabFocus() bool { return false }
232+func (*testLayoutWidget) HasFocus() bool { return false }
233+func (*testLayoutWidget) SetCanFocus(bool) {}
234+func (*testLayoutWidget) SetFocusable(bool) {}
235+func (*testLayoutWidget) SetFocusOnClick(bool) {}
236+func (*testLayoutWidget) SetCanTarget(bool) {}
237+func (*testLayoutWidget) SetHexpand(bool) {}
238+func (*testLayoutWidget) SetVexpand(bool) {}
239+func (*testLayoutWidget) GetHexpand() bool { return false }
240+func (*testLayoutWidget) GetVexpand() bool { return false }
241+func (*testLayoutWidget) SetHalign(gtk.Align) {}
242+func (*testLayoutWidget) SetValign(gtk.Align) {}
243+func (*testLayoutWidget) SetSizeRequest(int, int) {}
244+func (*testLayoutWidget) GetAllocatedWidth() int { return 0 }
245+func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
246+func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
247+ return 0, 0, false
248+}
249+func (*testLayoutWidget) AddCssClass(string) {}
250+func (*testLayoutWidget) RemoveCssClass(string) {}
251+func (*testLayoutWidget) HasCssClass(string) bool { return false }
252+func (*testLayoutWidget) Unparent() {}
253+func (*testLayoutWidget) GetParent() layout.Widget { return nil }
254+func (*testLayoutWidget) GtkWidget() *gtk.Widget { return nil }
255+func (*testLayoutWidget) AddController(*gtk.EventController) {}
256+
257+type testPermissionDialogPresenter struct{}
258+
259+func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
260+}
@@ -3,11 +3,64 @@ package ui
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "reflect"
6 "testing"7 "testing"
8+ "unsafe"
7 9
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/ui/component"
13+ "github.com/bnema/dumber/internal/ui/focus"
14+ "github.com/bnema/dumber/internal/ui/layout"
8 "github.com/bnema/dumber/internal/ui/window"15 "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/bnema/puregotk/v4/gtk"
9 )17 )
10 18
19+func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
20+ mainWindow := &window.MainWindow{}
21+ removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
22+ remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
23+ app := &App{
24+ mainWindow: mainWindow,
25+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
26+ lastFocusedWindowID: remaining.id,
27+ }
28+
29+ setShellField(t, removed, "appToaster", &component.Toaster{})
30+ setShellField(t, removed, "modeToaster", &component.Toaster{})
31+ setShellField(t, removed, "borderMgr", &focus.BorderManager{})
32+ setShellField(t, removed, "sessionManager", &component.SessionManager{})
33+ setShellField(t, removed, "tabPicker", &component.TabPicker{})
34+ setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
35+ setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
36+ setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
37+ setShellField(t, removed, "accentPicker", &component.AccentPicker{})
38+ setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
39+ setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
40+
41+ app.removeBrowserWindow(removed.id)
42+
43+ for _, name := range []string{
44+ "appToaster",
45+ "modeToaster",
46+ "borderMgr",
47+ "sessionManager",
48+ "tabPicker",
49+ "tabPickerWidget",
50+ "tabPickerPaneID",
51+ "insertAccentUC",
52+ "accentPicker",
53+ "keyboardHandler",
54+ "globalShortcutHandler",
55+ "permissionDialog",
56+ "webrtcIndicator",
57+ } {
58+ if !fieldIsZero(t, removed, name) {
59+ t.Fatalf("browserWindow.%s was not cleared", name)
60+ }
61+ }
62+}
63+
11 func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {64 func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
12 app := &App{browserWindows: make(map[string]*browserWindow)}65 app := &App{browserWindows: make(map[string]*browserWindow)}
13 first := &browserWindow{id: "window-1"}66 first := &browserWindow{id: "window-1"}
@@ -77,6 +130,25 @@ func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
77 }130 }
78 }131 }
79 132
133+func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {
134+ mainWindow := &window.MainWindow{}
135+ otherWindow := &window.MainWindow{}
136+ removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
137+ remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
138+ resizeTarget := &testLayoutWidget{}
139+ app := &App{
140+ mainWindow: mainWindow,
141+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
142+ resizeModeBorderTarget: resizeTarget,
143+ }
144+
145+ app.removeBrowserWindow(removed.id)
146+
147+ if app.resizeModeBorderTarget != nil {
148+ t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)
149+ }
150+}
151+
80 func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {152 func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
81 app := &App{browserWindows: make(map[string]*browserWindow)}153 app := &App{browserWindows: make(map[string]*browserWindow)}
82 154
@@ -128,3 +200,61 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
128 t.Fatalf("browserWindows length = %d, want 0", got)200 t.Fatalf("browserWindows length = %d, want 0", got)
129 }201 }
130 }202 }
203+
204+func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
205+ t.Helper()
206+ rv := reflect.ValueOf(bw).Elem()
207+ field := rv.FieldByName(name)
208+ if !field.IsValid() {
209+ t.Fatalf("browserWindow missing field %s", name)
210+ }
211+ reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
212+}
213+
214+func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
215+ t.Helper()
216+ rv := reflect.ValueOf(bw).Elem()
217+ field := rv.FieldByName(name)
218+ if !field.IsValid() {
219+ t.Fatalf("browserWindow missing field %s", name)
220+ }
221+ return field.IsZero()
222+}
223+
224+type testLayoutWidget struct{}
225+
226+func (*testLayoutWidget) Show() {}
227+func (*testLayoutWidget) Hide() {}
228+func (*testLayoutWidget) SetVisible(bool) {}
229+func (*testLayoutWidget) IsVisible() bool { return false }
230+func (*testLayoutWidget) SetOpacity(float64) {}
231+func (*testLayoutWidget) GrabFocus() bool { return false }
232+func (*testLayoutWidget) HasFocus() bool { return false }
233+func (*testLayoutWidget) SetCanFocus(bool) {}
234+func (*testLayoutWidget) SetFocusable(bool) {}
235+func (*testLayoutWidget) SetFocusOnClick(bool) {}
236+func (*testLayoutWidget) SetCanTarget(bool) {}
237+func (*testLayoutWidget) SetHexpand(bool) {}
238+func (*testLayoutWidget) SetVexpand(bool) {}
239+func (*testLayoutWidget) GetHexpand() bool { return false }
240+func (*testLayoutWidget) GetVexpand() bool { return false }
241+func (*testLayoutWidget) SetHalign(gtk.Align) {}
242+func (*testLayoutWidget) SetValign(gtk.Align) {}
243+func (*testLayoutWidget) SetSizeRequest(int, int) {}
244+func (*testLayoutWidget) GetAllocatedWidth() int { return 0 }
245+func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
246+func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
247+ return 0, 0, false
248+}
249+func (*testLayoutWidget) AddCssClass(string) {}
250+func (*testLayoutWidget) RemoveCssClass(string) {}
251+func (*testLayoutWidget) HasCssClass(string) bool { return false }
252+func (*testLayoutWidget) Unparent() {}
253+func (*testLayoutWidget) GetParent() layout.Widget { return nil }
254+func (*testLayoutWidget) GtkWidget() *gtk.Widget { return nil }
255+func (*testLayoutWidget) AddController(*gtk.EventController) {}
256+
257+type testPermissionDialogPresenter struct{}
258+
259+func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
260+}
M internal/ui/config_migration.go +2 -2
@@ -32,8 +32,8 @@ func (a *App) checkConfigMigration(ctx context.Context) {
3232 Msg("config migration available")
3333
3434 // Show toast notification if enabled
35- if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil {
35+ if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings {
3636 msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
37- a.appToaster.Show(ctx, msg, component.ToastInfo)
37+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastInfo)
3838 }
3939 }
@@ -32,8 +32,8 @@ func (a *App) checkConfigMigration(ctx context.Context) {
32 Msg("config migration available")32 Msg("config migration available")
33 33
34 // Show toast notification if enabled34 // Show toast notification if enabled
35- if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil {35+ if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings {
36 msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))36 msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
37- a.appToaster.Show(ctx, msg, component.ToastInfo)37+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastInfo)
38 }38 }
39 }39 }
M internal/ui/coordinator/content/callbacks.go +2 -2
@@ -146,7 +146,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
146146 }
147147 }
148148 if c.onFullscreenChanged != nil {
149- c.onFullscreenChanged(true)
149+ c.onFullscreenChanged(paneID, true)
150150 }
151151 return false // Allow fullscreen
152152 }
@@ -158,7 +158,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
158158 }
159159 }
160160 if c.onFullscreenChanged != nil {
161- c.onFullscreenChanged(false)
161+ c.onFullscreenChanged(paneID, false)
162162 }
163163 return false // Allow leaving fullscreen
164164 }
@@ -146,7 +146,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
146 }146 }
147 }147 }
148 if c.onFullscreenChanged != nil {148 if c.onFullscreenChanged != nil {
149- c.onFullscreenChanged(true)149+ c.onFullscreenChanged(paneID, true)
150 }150 }
151 return false // Allow fullscreen151 return false // Allow fullscreen
152 }152 }
@@ -158,7 +158,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
158 }158 }
159 }159 }
160 if c.onFullscreenChanged != nil {160 if c.onFullscreenChanged != nil {
161- c.onFullscreenChanged(false)161+ c.onFullscreenChanged(paneID, false)
162 }162 }
163 return false // Allow leaving fullscreen163 return false // Allow leaving fullscreen
164 }164 }
M internal/ui/coordinator/content/coordinator.go +4 -4
@@ -56,7 +56,7 @@ type Coordinator struct {
5656 onPaneURIUpdated func(paneID entity.PaneID, url string)
5757
5858 // Callback when active pane title changes (for window title updates)
59- onWindowTitleChanged func(title string)
59+ onWindowTitleChanged func(paneID entity.PaneID, title string)
6060
6161 // Callback when media permission activity changes (requesting/allowed/blocked).
6262 onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)
@@ -103,7 +103,7 @@ type Coordinator struct {
103103 idleInhibitor port.IdleInhibitor
104104
105105 // Callback when fullscreen state changes (for hiding/showing tab bar)
106- onFullscreenChanged func(entering bool)
106+ onFullscreenChanged func(paneID entity.PaneID, entering bool)
107107
108108 // Callback when WebView gains focus (for accent picker text input targeting)
109109 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
@@ -203,7 +203,7 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri
203203 }
204204
205205 // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
206-func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) {
206+func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {
207207 c.onWindowTitleChanged = fn
208208 }
209209
@@ -223,7 +223,7 @@ func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
223223 }
224224
225225 // SetOnFullscreenChanged sets the callback for fullscreen state changes.
226-func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) {
226+func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) {
227227 c.onFullscreenChanged = fn
228228 }
229229
@@ -56,7 +56,7 @@ type Coordinator struct {
56 onPaneURIUpdated func(paneID entity.PaneID, url string)56 onPaneURIUpdated func(paneID entity.PaneID, url string)
57 57
58 // Callback when active pane title changes (for window title updates)58 // Callback when active pane title changes (for window title updates)
59- onWindowTitleChanged func(title string)59+ onWindowTitleChanged func(paneID entity.PaneID, title string)
60 60
61 // Callback when media permission activity changes (requesting/allowed/blocked).61 // Callback when media permission activity changes (requesting/allowed/blocked).
62 onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)62 onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)
@@ -103,7 +103,7 @@ type Coordinator struct {
103 idleInhibitor port.IdleInhibitor103 idleInhibitor port.IdleInhibitor
104 104
105 // Callback when fullscreen state changes (for hiding/showing tab bar)105 // Callback when fullscreen state changes (for hiding/showing tab bar)
106- onFullscreenChanged func(entering bool)106+ onFullscreenChanged func(paneID entity.PaneID, entering bool)
107 107
108 // Callback when WebView gains focus (for accent picker text input targeting)108 // Callback when WebView gains focus (for accent picker text input targeting)
109 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)109 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
@@ -203,7 +203,7 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri
203 }203 }
204 204
205 // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).205 // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
206-func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) {206+func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {
207 c.onWindowTitleChanged = fn207 c.onWindowTitleChanged = fn
208 }208 }
209 209
@@ -223,7 +223,7 @@ func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
223 }223 }
224 224
225 // SetOnFullscreenChanged sets the callback for fullscreen state changes.225 // SetOnFullscreenChanged sets the callback for fullscreen state changes.
226-func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) {226+func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) {
227 c.onFullscreenChanged = fn227 c.onFullscreenChanged = fn
228 }228 }
229 229
M internal/ui/coordinator/content/title.go +17 -25
@@ -29,30 +29,22 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID,
2929 c.paneTitles[paneID] = title
3030 c.titleMu.Unlock()
3131
32- // Update domain model and check if this is the active pane
33- isActivePaneTitle := false
34- if c.getActiveWS == nil {
35- return
36- }
37- ws, wsView := c.getActiveWS()
38- if ws != nil {
39- paneNode := ws.FindPane(paneID)
40- if paneNode != nil && paneNode.Pane != nil {
41- paneNode.Pane.Title = title
42- }
43- // Check if this pane is the active one
44- if ws.ActivePaneID == paneID {
45- isActivePaneTitle = true
32+ // Update domain model and stacked title if available.
33+ if c.getActiveWS != nil {
34+ ws, wsView := c.getActiveWS()
35+ if ws != nil {
36+ paneNode := ws.FindPane(paneID)
37+ if paneNode != nil && paneNode.Pane != nil {
38+ paneNode.Pane.Title = title
39+ }
4640 }
47- }
4841
49- // Update StackedView title bar if this pane is in a stack
50- if wsView != nil {
51- tr := wsView.TreeRenderer()
52- if tr != nil {
53- stackedView := tr.GetStackedViewForPane(string(paneID))
54- if stackedView != nil {
55- c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
42+ if wsView != nil {
43+ tr := wsView.TreeRenderer()
44+ if tr != nil {
45+ if stackedView := tr.GetStackedViewForPane(string(paneID)); stackedView != nil {
46+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
47+ }
5648 }
5749 }
5850 }
@@ -67,9 +59,9 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID,
6759 }
6860 }
6961
70- // Notify window title update if this is the active pane
71- if isActivePaneTitle && c.onWindowTitleChanged != nil {
72- c.onWindowTitleChanged(title)
62+ // Notify window title update for every pane title change.
63+ if c.onWindowTitleChanged != nil {
64+ c.onWindowTitleChanged(paneID, title)
7365 }
7466
7567 log.Debug().
@@ -29,30 +29,22 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID,
29 c.paneTitles[paneID] = title29 c.paneTitles[paneID] = title
30 c.titleMu.Unlock()30 c.titleMu.Unlock()
31 31
32- // Update domain model and check if this is the active pane32+ // Update domain model and stacked title if available.
33- isActivePaneTitle := false33+ if c.getActiveWS != nil {
34- if c.getActiveWS == nil {34+ ws, wsView := c.getActiveWS()
35- return35+ if ws != nil {
36- }36+ paneNode := ws.FindPane(paneID)
37- ws, wsView := c.getActiveWS()37+ if paneNode != nil && paneNode.Pane != nil {
38- if ws != nil {38+ paneNode.Pane.Title = title
39- paneNode := ws.FindPane(paneID)39+ }
40- if paneNode != nil && paneNode.Pane != nil {
41- paneNode.Pane.Title = title
42- }
43- // Check if this pane is the active one
44- if ws.ActivePaneID == paneID {
45- isActivePaneTitle = true
46 }40 }
47- }
48 41
49- // Update StackedView title bar if this pane is in a stack42+ if wsView != nil {
50- if wsView != nil {43+ tr := wsView.TreeRenderer()
51- tr := wsView.TreeRenderer()44+ if tr != nil {
52- if tr != nil {45+ if stackedView := tr.GetStackedViewForPane(string(paneID)); stackedView != nil {
53- stackedView := tr.GetStackedViewForPane(string(paneID))46+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
54- if stackedView != nil {47+ }
55- c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
56 }48 }
57 }49 }
58 }50 }
@@ -67,9 +59,9 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID,
67 }59 }
68 }60 }
69 61
70- // Notify window title update if this is the active pane62+ // Notify window title update for every pane title change.
71- if isActivePaneTitle && c.onWindowTitleChanged != nil {63+ if c.onWindowTitleChanged != nil {
72- c.onWindowTitleChanged(title)64+ c.onWindowTitleChanged(paneID, title)
73 }65 }
74 66
75 log.Debug().67 log.Debug().
M internal/ui/coordinator/content/title_test.go +30 -0
@@ -8,8 +8,38 @@ import (
88 "github.com/stretchr/testify/assert"
99
1010 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/ui/component"
1112 )
1213
14+func TestOnTitleChanged_EmitsCallbackForBackgroundPane(t *testing.T) {
15+ t.Parallel()
16+
17+ pane1 := entity.PaneID("pane-1")
18+ pane2 := entity.PaneID("pane-2")
19+ root := &entity.PaneNode{ID: "root"}
20+ leaf1 := &entity.PaneNode{ID: string(pane1), Pane: entity.NewPane(pane1), Parent: root}
21+ leaf2 := &entity.PaneNode{ID: string(pane2), Pane: entity.NewPane(pane2), Parent: root}
22+ root.Children = []*entity.PaneNode{leaf1, leaf2}
23+
24+ ws := &entity.Workspace{Root: root, ActivePaneID: pane1}
25+ var gotPaneID entity.PaneID
26+ var gotTitle string
27+ c := &Coordinator{
28+ paneTitles: make(map[entity.PaneID]string),
29+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return ws, nil },
30+ onWindowTitleChanged: func(paneID entity.PaneID, title string) {
31+ gotPaneID = paneID
32+ gotTitle = title
33+ },
34+ }
35+
36+ c.onTitleChanged(context.Background(), pane2, "Pane Two")
37+
38+ assert.Equal(t, pane2, gotPaneID)
39+ assert.Equal(t, "Pane Two", gotTitle)
40+ assert.Equal(t, "Pane Two", c.GetTitle(pane2))
41+}
42+
1343 // TestGetTitle tests the GetTitle method for title state retrieval.
1444
1545 func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) {
@@ -8,8 +8,38 @@ import (
8 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
9 9
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/ui/component"
11 )12 )
12 13
14+func TestOnTitleChanged_EmitsCallbackForBackgroundPane(t *testing.T) {
15+ t.Parallel()
16+
17+ pane1 := entity.PaneID("pane-1")
18+ pane2 := entity.PaneID("pane-2")
19+ root := &entity.PaneNode{ID: "root"}
20+ leaf1 := &entity.PaneNode{ID: string(pane1), Pane: entity.NewPane(pane1), Parent: root}
21+ leaf2 := &entity.PaneNode{ID: string(pane2), Pane: entity.NewPane(pane2), Parent: root}
22+ root.Children = []*entity.PaneNode{leaf1, leaf2}
23+
24+ ws := &entity.Workspace{Root: root, ActivePaneID: pane1}
25+ var gotPaneID entity.PaneID
26+ var gotTitle string
27+ c := &Coordinator{
28+ paneTitles: make(map[entity.PaneID]string),
29+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return ws, nil },
30+ onWindowTitleChanged: func(paneID entity.PaneID, title string) {
31+ gotPaneID = paneID
32+ gotTitle = title
33+ },
34+ }
35+
36+ c.onTitleChanged(context.Background(), pane2, "Pane Two")
37+
38+ assert.Equal(t, pane2, gotPaneID)
39+ assert.Equal(t, "Pane Two", gotTitle)
40+ assert.Equal(t, "Pane Two", c.GetTitle(pane2))
41+}
42+
13 // TestGetTitle tests the GetTitle method for title state retrieval.43 // TestGetTitle tests the GetTitle method for title state retrieval.
14 44
15 func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) {45 func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) {
M internal/ui/coordinator/update.go +9 -5
@@ -17,7 +17,7 @@ import (
1717 type UpdateCoordinator struct {
1818 checkUC *usecase.CheckUpdateUseCase
1919 applyUC *usecase.ApplyUpdateUseCase
20- toaster *component.Toaster
20+ toastFn func(ctx context.Context, msg string, level component.ToastLevel)
2121 enableOnStartup bool
2222 autoDownload bool
2323 mu sync.RWMutex // Protects status and lastInfo
@@ -31,14 +31,14 @@ type UpdateCoordinator struct {
3131 func NewUpdateCoordinator(
3232 checkUC *usecase.CheckUpdateUseCase,
3333 applyUC *usecase.ApplyUpdateUseCase,
34- toaster *component.Toaster,
34+ showToast func(ctx context.Context, msg string, level component.ToastLevel),
3535 enableOnStartup bool,
3636 autoDownload bool,
3737 ) *UpdateCoordinator {
3838 return &UpdateCoordinator{
3939 checkUC: checkUC,
4040 applyUC: applyUC,
41- toaster: toaster,
41+ toastFn: showToast,
4242 enableOnStartup: enableOnStartup,
4343 autoDownload: autoDownload,
4444 status: entity.UpdateStatusUnknown,
@@ -104,7 +104,9 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *
104104 }
105105
106106 cb := glib.SourceFunc(func(_ uintptr) bool {
107- c.toaster.Show(ctx, msg, component.ToastInfo)
107+ if c.toastFn != nil {
108+ c.toastFn(ctx, msg, component.ToastInfo)
109+ }
108110 return false
109111 })
110112 glib.IdleAdd(&cb, 0)
@@ -145,7 +147,9 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin
145147
146148 func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {
147149 cb := glib.SourceFunc(func(_ uintptr) bool {
148- c.toaster.Show(ctx, msg, level)
150+ if c.toastFn != nil {
151+ c.toastFn(ctx, msg, level)
152+ }
149153 return false
150154 })
151155 glib.IdleAdd(&cb, 0)
@@ -17,7 +17,7 @@ import (
17 type UpdateCoordinator struct {17 type UpdateCoordinator struct {
18 checkUC *usecase.CheckUpdateUseCase18 checkUC *usecase.CheckUpdateUseCase
19 applyUC *usecase.ApplyUpdateUseCase19 applyUC *usecase.ApplyUpdateUseCase
20- toaster *component.Toaster20+ toastFn func(ctx context.Context, msg string, level component.ToastLevel)
21 enableOnStartup bool21 enableOnStartup bool
22 autoDownload bool22 autoDownload bool
23 mu sync.RWMutex // Protects status and lastInfo23 mu sync.RWMutex // Protects status and lastInfo
@@ -31,14 +31,14 @@ type UpdateCoordinator struct {
31 func NewUpdateCoordinator(31 func NewUpdateCoordinator(
32 checkUC *usecase.CheckUpdateUseCase,32 checkUC *usecase.CheckUpdateUseCase,
33 applyUC *usecase.ApplyUpdateUseCase,33 applyUC *usecase.ApplyUpdateUseCase,
34- toaster *component.Toaster,34+ showToast func(ctx context.Context, msg string, level component.ToastLevel),
35 enableOnStartup bool,35 enableOnStartup bool,
36 autoDownload bool,36 autoDownload bool,
37 ) *UpdateCoordinator {37 ) *UpdateCoordinator {
38 return &UpdateCoordinator{38 return &UpdateCoordinator{
39 checkUC: checkUC,39 checkUC: checkUC,
40 applyUC: applyUC,40 applyUC: applyUC,
41- toaster: toaster,41+ toastFn: showToast,
42 enableOnStartup: enableOnStartup,42 enableOnStartup: enableOnStartup,
43 autoDownload: autoDownload,43 autoDownload: autoDownload,
44 status: entity.UpdateStatusUnknown,44 status: entity.UpdateStatusUnknown,
@@ -104,7 +104,9 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *
104 }104 }
105 105
106 cb := glib.SourceFunc(func(_ uintptr) bool {106 cb := glib.SourceFunc(func(_ uintptr) bool {
107- c.toaster.Show(ctx, msg, component.ToastInfo)107+ if c.toastFn != nil {
108+ c.toastFn(ctx, msg, component.ToastInfo)
109+ }
108 return false110 return false
109 })111 })
110 glib.IdleAdd(&cb, 0)112 glib.IdleAdd(&cb, 0)
@@ -145,7 +147,9 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin
145 147
146 func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {148 func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {
147 cb := glib.SourceFunc(func(_ uintptr) bool {149 cb := glib.SourceFunc(func(_ uintptr) bool {
148- c.toaster.Show(ctx, msg, level)150+ if c.toastFn != nil {
151+ c.toastFn(ctx, msg, level)
152+ }
149 return false153 return false
150 })154 })
151 glib.IdleAdd(&cb, 0)155 glib.IdleAdd(&cb, 0)
M internal/ui/dispatcher/keyboard.go +18 -15
@@ -28,9 +28,10 @@ type KeyboardDispatcher struct {
2828 onFindNext func(ctx context.Context) error
2929 onFindPrev func(ctx context.Context) error
3030 onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error
32- onMovePaneToTab func(ctx context.Context) error
33- onMovePaneToNext func(ctx context.Context) error
31+ activePaneID func(ctx context.Context) entity.PaneID
32+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
33+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
34+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
3435 onToggleFloating func(ctx context.Context) error
3536 onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
3637 }
@@ -44,17 +45,19 @@ func NewKeyboardDispatcher(
4445 zoomUC *usecase.ManageZoomUseCase,
4546 copyURLUC *usecase.CopyURLUseCase,
4647 newPaneURL string,
48+ activePaneID func(context.Context) entity.PaneID,
4749 ) *KeyboardDispatcher {
4850 log := logging.FromContext(ctx)
4951 log.Debug().Msg("creating keyboard dispatcher")
5052
5153 dispatcher := &KeyboardDispatcher{
52- tabCoord: tabCoord,
53- wsCoord: wsCoord,
54- navCoord: navCoord,
55- zoomUC: zoomUC,
56- copyURLUC: copyURLUC,
57- newPaneURL: newPaneURL,
54+ tabCoord: tabCoord,
55+ wsCoord: wsCoord,
56+ navCoord: navCoord,
57+ zoomUC: zoomUC,
58+ copyURLUC: copyURLUC,
59+ newPaneURL: newPaneURL,
60+ activePaneID: activePaneID,
5861 }
5962 dispatcher.initActionHandlers()
6063 return dispatcher
@@ -86,15 +89,15 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error)
8689 }
8790
8891 // SetOnSessionOpen sets the callback for opening the session manager.
89-func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) {
92+func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) {
9093 d.onSessionOpen = fn
9194 }
9295
93-func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
96+func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
9497 d.onMovePaneToTab = fn
9598 }
9699
97-func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
100+func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
98101 d.onMovePaneToNext = fn
99102 }
100103
@@ -289,7 +292,7 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
289292
290293 func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
291294 if d.onSessionOpen != nil {
292- return d.onSessionOpen(ctx)
295+ return d.onSessionOpen(ctx, d.activePaneID(ctx))
293296 }
294297 logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
295298 return nil
@@ -297,7 +300,7 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
297300
298301 func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
299302 if d.onMovePaneToTab != nil {
300- return d.onMovePaneToTab(ctx)
303+ return d.onMovePaneToTab(ctx, d.activePaneID(ctx))
301304 }
302305 logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
303306 return nil
@@ -305,7 +308,7 @@ func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
305308
306309 func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
307310 if d.onMovePaneToNext != nil {
308- return d.onMovePaneToNext(ctx)
311+ return d.onMovePaneToNext(ctx, d.activePaneID(ctx))
309312 }
310313 logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
311314 return nil
@@ -28,9 +28,10 @@ type KeyboardDispatcher struct {
28 onFindNext func(ctx context.Context) error28 onFindNext func(ctx context.Context) error
29 onFindPrev func(ctx context.Context) error29 onFindPrev func(ctx context.Context) error
30 onFindClose func(ctx context.Context) error30 onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error31+ activePaneID func(ctx context.Context) entity.PaneID
32- onMovePaneToTab func(ctx context.Context) error32+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
33- onMovePaneToNext func(ctx context.Context) error33+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
34+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
34 onToggleFloating func(ctx context.Context) error35 onToggleFloating func(ctx context.Context) error
35 onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error36 onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
36 }37 }
@@ -44,17 +45,19 @@ func NewKeyboardDispatcher(
44 zoomUC *usecase.ManageZoomUseCase,45 zoomUC *usecase.ManageZoomUseCase,
45 copyURLUC *usecase.CopyURLUseCase,46 copyURLUC *usecase.CopyURLUseCase,
46 newPaneURL string,47 newPaneURL string,
48+ activePaneID func(context.Context) entity.PaneID,
47 ) *KeyboardDispatcher {49 ) *KeyboardDispatcher {
48 log := logging.FromContext(ctx)50 log := logging.FromContext(ctx)
49 log.Debug().Msg("creating keyboard dispatcher")51 log.Debug().Msg("creating keyboard dispatcher")
50 52
51 dispatcher := &KeyboardDispatcher{53 dispatcher := &KeyboardDispatcher{
52- tabCoord: tabCoord,54+ tabCoord: tabCoord,
53- wsCoord: wsCoord,55+ wsCoord: wsCoord,
54- navCoord: navCoord,56+ navCoord: navCoord,
55- zoomUC: zoomUC,57+ zoomUC: zoomUC,
56- copyURLUC: copyURLUC,58+ copyURLUC: copyURLUC,
57- newPaneURL: newPaneURL,59+ newPaneURL: newPaneURL,
60+ activePaneID: activePaneID,
58 }61 }
59 dispatcher.initActionHandlers()62 dispatcher.initActionHandlers()
60 return dispatcher63 return dispatcher
@@ -86,15 +89,15 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error)
86 }89 }
87 90
88 // SetOnSessionOpen sets the callback for opening the session manager.91 // SetOnSessionOpen sets the callback for opening the session manager.
89-func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) {92+func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) {
90 d.onSessionOpen = fn93 d.onSessionOpen = fn
91 }94 }
92 95
93-func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {96+func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
94 d.onMovePaneToTab = fn97 d.onMovePaneToTab = fn
95 }98 }
96 99
97-func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {100+func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
98 d.onMovePaneToNext = fn101 d.onMovePaneToNext = fn
99 }102 }
100 103
@@ -289,7 +292,7 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
289 292
290 func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {293 func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
291 if d.onSessionOpen != nil {294 if d.onSessionOpen != nil {
292- return d.onSessionOpen(ctx)295+ return d.onSessionOpen(ctx, d.activePaneID(ctx))
293 }296 }
294 logging.FromContext(ctx).Debug().Msg("session open action (no handler)")297 logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
295 return nil298 return nil
@@ -297,7 +300,7 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
297 300
298 func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {301 func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
299 if d.onMovePaneToTab != nil {302 if d.onMovePaneToTab != nil {
300- return d.onMovePaneToTab(ctx)303+ return d.onMovePaneToTab(ctx, d.activePaneID(ctx))
301 }304 }
302 logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")305 logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
303 return nil306 return nil
@@ -305,7 +308,7 @@ func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
305 308
306 func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {309 func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
307 if d.onMovePaneToNext != nil {310 if d.onMovePaneToNext != nil {
308- return d.onMovePaneToNext(ctx)311+ return d.onMovePaneToNext(ctx, d.activePaneID(ctx))
309 }312 }
310 logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")313 logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
311 return nil314 return nil
M internal/ui/dispatcher/keyboard_test.go +46 -3
@@ -35,7 +35,7 @@ func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *tes
3535 _, err := tabCoord.Create(ctx, "https://initial.example")
3636 require.NoError(t, err)
3737
38- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example")
38+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" })
3939
4040 err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)
4141 require.NoError(t, err)
@@ -76,7 +76,7 @@ func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *t
7676 _, err = tabCoord.Create(ctx, "https://second.example")
7777 require.NoError(t, err)
7878
79- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")
79+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
8080
8181 err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
8282 require.NoError(t, err)
@@ -106,10 +106,53 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout
106106 _, err := tabCoord.Create(ctx, "https://first.example")
107107 require.NoError(t, err)
108108
109- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")
109+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
110110
111111 err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
112112 require.Error(t, err)
113113 require.Len(t, tabs.Tabs, 1)
114114 assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
115115 }
116+
117+func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
118+ ctx := context.Background()
119+ activePaneID := entity.PaneID("pane-1")
120+ d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID {
121+ return activePaneID
122+ })
123+
124+ tests := []struct {
125+ name string
126+ set func(func(context.Context, entity.PaneID) error)
127+ invoke func(context.Context) error
128+ }{
129+ {
130+ name: "session open",
131+ set: d.SetOnSessionOpen,
132+ invoke: d.handleSessionOpen,
133+ },
134+ {
135+ name: "move pane to tab",
136+ set: d.SetOnMovePaneToTab,
137+ invoke: d.handleMovePaneToTab,
138+ },
139+ {
140+ name: "move pane to next tab",
141+ set: d.SetOnMovePaneToNextTab,
142+ invoke: d.handleMovePaneToNextTab,
143+ },
144+ }
145+
146+ for _, tc := range tests {
147+ t.Run(tc.name, func(t *testing.T) {
148+ var gotPaneID entity.PaneID
149+ tc.set(func(_ context.Context, paneID entity.PaneID) error {
150+ gotPaneID = paneID
151+ return nil
152+ })
153+
154+ require.NoError(t, tc.invoke(ctx))
155+ assert.Equal(t, activePaneID, gotPaneID)
156+ })
157+ }
158+}
@@ -35,7 +35,7 @@ func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *tes
35 _, err := tabCoord.Create(ctx, "https://initial.example")35 _, err := tabCoord.Create(ctx, "https://initial.example")
36 require.NoError(t, err)36 require.NoError(t, err)
37 37
38- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example")38+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" })
39 39
40 err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)40 err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)
41 require.NoError(t, err)41 require.NoError(t, err)
@@ -76,7 +76,7 @@ func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *t
76 _, err = tabCoord.Create(ctx, "https://second.example")76 _, err = tabCoord.Create(ctx, "https://second.example")
77 require.NoError(t, err)77 require.NoError(t, err)
78 78
79- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")79+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
80 80
81 err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)81 err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
82 require.NoError(t, err)82 require.NoError(t, err)
@@ -106,10 +106,53 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout
106 _, err := tabCoord.Create(ctx, "https://first.example")106 _, err := tabCoord.Create(ctx, "https://first.example")
107 require.NoError(t, err)107 require.NoError(t, err)
108 108
109- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")109+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
110 110
111 err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)111 err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
112 require.Error(t, err)112 require.Error(t, err)
113 require.Len(t, tabs.Tabs, 1)113 require.Len(t, tabs.Tabs, 1)
114 assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)114 assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
115 }115 }
116+
117+func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
118+ ctx := context.Background()
119+ activePaneID := entity.PaneID("pane-1")
120+ d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID {
121+ return activePaneID
122+ })
123+
124+ tests := []struct {
125+ name string
126+ set func(func(context.Context, entity.PaneID) error)
127+ invoke func(context.Context) error
128+ }{
129+ {
130+ name: "session open",
131+ set: d.SetOnSessionOpen,
132+ invoke: d.handleSessionOpen,
133+ },
134+ {
135+ name: "move pane to tab",
136+ set: d.SetOnMovePaneToTab,
137+ invoke: d.handleMovePaneToTab,
138+ },
139+ {
140+ name: "move pane to next tab",
141+ set: d.SetOnMovePaneToNextTab,
142+ invoke: d.handleMovePaneToNextTab,
143+ },
144+ }
145+
146+ for _, tc := range tests {
147+ t.Run(tc.name, func(t *testing.T) {
148+ var gotPaneID entity.PaneID
149+ tc.set(func(_ context.Context, paneID entity.PaneID) error {
150+ gotPaneID = paneID
151+ return nil
152+ })
153+
154+ require.NoError(t, tc.invoke(ctx))
155+ assert.Equal(t, activePaneID, gotPaneID)
156+ })
157+ }
158+}
M internal/ui/window/main_window.go +12 -0
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/dumber/internal/logging"
88 "github.com/bnema/dumber/internal/ui/component"
9+ "github.com/bnema/puregotk/v4/gobject"
910 "github.com/bnema/puregotk/v4/gtk"
1011 "github.com/rs/zerolog"
1112 )
@@ -155,6 +156,17 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow {
155156 return mw.window
156157 }
157158
159+// ConnectActiveNotify wires activation state changes for the top-level window.
160+func (mw *MainWindow) ConnectActiveNotify(callback func(active bool)) uint {
161+ if mw == nil || mw.window == nil || callback == nil {
162+ return 0
163+ }
164+ cb := func(_ gobject.Object, _ *gobject.ParamSpec) {
165+ callback(mw.window.IsActive())
166+ }
167+ return mw.window.ConnectNotifyWithDetail("is-active", &cb)
168+}
169+
158170 // SetTitle sets the window title.
159171 func (mw *MainWindow) SetTitle(title string) {
160172 if mw.window == nil {
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bnema/dumber/internal/logging"7 "github.com/bnema/dumber/internal/logging"
8 "github.com/bnema/dumber/internal/ui/component"8 "github.com/bnema/dumber/internal/ui/component"
9+ "github.com/bnema/puregotk/v4/gobject"
9 "github.com/bnema/puregotk/v4/gtk"10 "github.com/bnema/puregotk/v4/gtk"
10 "github.com/rs/zerolog"11 "github.com/rs/zerolog"
11 )12 )
@@ -155,6 +156,17 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow {
155 return mw.window156 return mw.window
156 }157 }
157 158
159+// ConnectActiveNotify wires activation state changes for the top-level window.
160+func (mw *MainWindow) ConnectActiveNotify(callback func(active bool)) uint {
161+ if mw == nil || mw.window == nil || callback == nil {
162+ return 0
163+ }
164+ cb := func(_ gobject.Object, _ *gobject.ParamSpec) {
165+ callback(mw.window.IsActive())
166+ }
167+ return mw.window.ConnectNotifyWithDetail("is-active", &cb)
168+}
169+
158 // SetTitle sets the window title.170 // SetTitle sets the window title.
159 func (mw *MainWindow) SetTitle(title string) {171 func (mw *MainWindow) SetTitle(title string) {
160 if mw.window == nil {172 if mw.window == nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close