detached cb3ee8d

feat(ui): add TabPicker and wire move pane functionality with skeleton fix

commit
cb3ee8d90f0b1311743d7187b40b35f6593fd53f
author
brice · · 2025-12-27 15:49 UTC
parent
94df97b
signature
unsigned
6 files · +888 -3
M internal/ui/app.go +295 -2
@@ -90,6 +90,9 @@ type App struct {
9090
9191 // Session management
9292 sessionManager *component.SessionManager
93+ tabPicker *component.TabPicker
94+ tabPickerWidget layout.Widget
95+ tabPickerPaneID entity.PaneID
9396 snapshotService *snapshot.Service
9497
9598 // Update management
@@ -99,6 +102,8 @@ type App struct {
99102 idCounter uint64
100103 idMu sync.Mutex
101104
105+ movePaneToTabUC *usecase.MovePaneToTabUseCase
106+
102107 // lifecycle
103108 cancel context.CancelCauseFunc
104109 }
@@ -197,6 +202,7 @@ func (a *App) onActivate(ctx context.Context) {
197202 a.initOmniboxConfig(ctx)
198203 a.initFindBarConfig()
199204 a.initSessionManager(ctx)
205+ a.initTabPicker(ctx)
200206 a.initSnapshotService(ctx)
201207 a.initUpdateCoordinator(ctx)
202208 a.createInitialTab(ctx)
@@ -333,10 +339,13 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
333339 a.handleModeChange(ctx, from, to)
334340 })
335341 a.keyboardHandler.SetShouldBypassInput(func() bool {
336- // Bypass keyboard handler when session manager is visible
342+ // Bypass keyboard handler when modals are visible
337343 if a.sessionManager != nil && a.sessionManager.IsVisible() {
338344 return true
339345 }
346+ if a.tabPicker != nil && a.tabPicker.IsVisible() {
347+ return true
348+ }
340349 wsView := a.activeWorkspaceView()
341350 if wsView == nil {
342351 return false
@@ -480,6 +489,276 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
480489 a.sessionManager.Toggle(ctx)
481490 }
482491
492+func (a *App) attachTabPickerToActivePane() {
493+ if a.tabPicker == nil || a.widgetFactory == nil {
494+ return
495+ }
496+ wsView := a.activeWorkspaceView()
497+ if wsView == nil {
498+ return
499+ }
500+
501+ activeTab := a.tabs.ActiveTab()
502+ if activeTab == nil || activeTab.Workspace == nil {
503+ return
504+ }
505+ activePaneID := activeTab.Workspace.ActivePaneID
506+ if activePaneID == "" {
507+ return
508+ }
509+ pv := wsView.GetPaneView(activePaneID)
510+ if pv == nil {
511+ return
512+ }
513+
514+ if a.tabPickerWidget == nil {
515+ a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
516+ if a.tabPickerWidget == nil {
517+ return
518+ }
519+ }
520+
521+ // If currently attached to a different pane overlay, detach.
522+ if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
523+ for _, view := range a.workspaceViews {
524+ if view == nil {
525+ continue
526+ }
527+ if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
528+ oldPV.RemoveOverlayWidget(a.tabPickerWidget)
529+ break
530+ }
531+ }
532+ }
533+
534+ // Ensure the widget can be reparented.
535+ if parent := a.tabPickerWidget.GetParent(); parent != nil {
536+ a.tabPickerWidget.Unparent()
537+ }
538+
539+ a.tabPicker.SetParentOverlay(pv.Overlay())
540+ pv.AddOverlayWidget(a.tabPickerWidget)
541+ a.tabPickerPaneID = activePaneID
542+}
543+
544+func (a *App) HandleMovePaneToTab(ctx context.Context) error {
545+ if a.movePaneToTabUC == nil {
546+ return nil
547+ }
548+ if a.tabPicker == nil {
549+ return nil
550+ }
551+
552+ sourceTab := a.tabs.ActiveTab()
553+ if sourceTab == nil || sourceTab.Workspace == nil {
554+ return nil
555+ }
556+
557+ // If there is only 1 tab, auto-create a new tab and move there.
558+ if a.tabs.Count() <= 1 {
559+ return a.MoveActivePaneToTab(ctx, "")
560+ }
561+
562+ items := make([]component.TabPickerItem, 0, a.tabs.Count())
563+ for _, tab := range a.tabs.Tabs {
564+ if tab == nil {
565+ continue
566+ }
567+ if tab.ID == sourceTab.ID {
568+ continue
569+ }
570+ items = append(items, component.TabPickerItem{
571+ TabID: tab.ID,
572+ Title: tab.Title(),
573+ IsNew: false,
574+ Index: tab.Position,
575+ })
576+ }
577+ items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
578+
579+ a.attachTabPickerToActivePane()
580+ a.tabPicker.Show(ctx, items)
581+ return nil
582+}
583+
584+func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
585+ if a.movePaneToTabUC == nil {
586+ return nil
587+ }
588+ active := a.tabs.ActiveTab()
589+ if active == nil {
590+ return nil
591+ }
592+
593+ nextPos := active.Position + 1
594+ if nextPos >= a.tabs.Count() {
595+ // Create a new tab on the right.
596+ return a.MoveActivePaneToTab(ctx, "")
597+ }
598+ next := a.tabs.TabAt(nextPos)
599+ if next == nil {
600+ return nil
601+ }
602+ return a.MoveActivePaneToTab(ctx, next.ID)
603+}
604+
605+func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
606+ in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
607+ if in == nil {
608+ return nil
609+ }
610+
611+ out, err := a.movePaneToTabUC.Execute(*in)
612+ if err != nil {
613+ return err
614+ }
615+ if out == nil || out.TargetTab == nil {
616+ return nil
617+ }
618+
619+ a.applyMovePaneToTabUI(ctx, out, sourceTab)
620+ a.switchToTargetTabIfConfigured(ctx, out)
621+ a.updateTabBarVisibility(ctx)
622+ a.MarkDirty()
623+ return nil
624+}
625+
626+func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
627+ if a.movePaneToTabUC == nil {
628+ return nil, nil
629+ }
630+ activeTab := a.tabs.ActiveTab()
631+ if activeTab == nil || activeTab.Workspace == nil {
632+ return nil, nil
633+ }
634+ sourcePaneID := activeTab.Workspace.ActivePaneID
635+ if sourcePaneID == "" {
636+ return nil, nil
637+ }
638+
639+ in := &usecase.MovePaneToTabInput{
640+ TabList: a.tabs,
641+ SourceTabID: activeTab.ID,
642+ SourcePaneID: sourcePaneID,
643+ TargetTabID: targetTabID,
644+ }
645+ return in, activeTab
646+}
647+
648+func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
649+ if out == nil || out.TargetTab == nil {
650+ return
651+ }
652+ sourceTabID := entity.TabID("")
653+ if sourceTab != nil {
654+ sourceTabID = sourceTab.ID
655+ }
656+
657+ if out.SourceTabClosed {
658+ a.removeSourceTabUI(sourceTabID)
659+ }
660+ if out.NewTabCreated {
661+ a.ensureTargetTabUI(ctx, out.TargetTab)
662+ }
663+
664+ if !out.SourceTabClosed {
665+ a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
666+ }
667+ a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
668+}
669+
670+func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
671+ delete(a.workspaceViews, sourceTabID)
672+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
673+ a.mainWindow.TabBar().RemoveTab(sourceTabID)
674+ }
675+}
676+
677+func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
678+ if tab == nil {
679+ return
680+ }
681+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
682+ a.mainWindow.TabBar().AddTab(tab)
683+ }
684+ a.createWorkspaceViewWithoutAttach(ctx, tab)
685+}
686+
687+func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
688+ wsView := a.workspaceViews[tabID]
689+ if wsView == nil {
690+ return
691+ }
692+ _ = wsView.Rebuild(ctx)
693+
694+ if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
695+ return
696+ }
697+ a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
698+}
699+
700+func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
701+ if out == nil || out.TargetTab == nil {
702+ return
703+ }
704+
705+ switchToTarget := config.Get().Workspace.SwitchToTabOnMove
706+ if out.SourceTabClosed {
707+ switchToTarget = true
708+ }
709+ if !switchToTarget {
710+ return
711+ }
712+
713+ a.tabs.SetActive(out.TargetTab.ID)
714+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
715+ a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
716+ }
717+ a.switchWorkspaceView(ctx, out.TargetTab.ID)
718+}
719+
720+func (a *App) updateTabBarVisibility(ctx context.Context) {
721+ if a.tabCoord != nil {
722+ a.tabCoord.UpdateBarVisibility(ctx)
723+ }
724+}
725+
726+func (a *App) initTabPicker(ctx context.Context) {
727+ log := logging.FromContext(ctx)
728+
729+ if a.deps == nil || a.deps.Config == nil {
730+ log.Debug().Msg("deps/config not available, skipping tab picker")
731+ return
732+ }
733+
734+ a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
735+ UIScale: a.deps.Config.DefaultUIScale,
736+ OnClose: func() {
737+ log.Debug().Msg("tab picker closed")
738+ },
739+ OnSelect: func(item component.TabPickerItem) {
740+ cb := glib.SourceFunc(func(_ uintptr) bool {
741+ var targetID entity.TabID
742+ if !item.IsNew {
743+ targetID = item.TabID
744+ }
745+ if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
746+ log.Warn().Err(err).Msg("move pane to tab failed")
747+ }
748+ return false
749+ })
750+ glib.IdleAdd(&cb, 0)
751+ },
752+ })
753+
754+ if a.tabPicker == nil {
755+ log.Warn().Msg("failed to create tab picker")
756+ return
757+ }
758+
759+ log.Debug().Msg("tab picker initialized")
760+}
761+
483762 func (a *App) initSnapshotService(ctx context.Context) {
484763 log := logging.FromContext(ctx)
485764
@@ -798,6 +1077,9 @@ func (a *App) initCoordinators(ctx context.Context) {
7981077 return nil
7991078 })
8001079
1080+ // Move pane use case (cross-tab)
1081+ a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1082+
8011083 // 4. Navigation Coordinator
8021084 a.navCoord = coordinator.NewNavigationCoordinator(
8031085 ctx,
@@ -829,7 +1111,12 @@ func (a *App) initCoordinators(ctx context.Context) {
8291111
8301112 // Hide loading skeleton once the WebView paints
8311113 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
832- if wsView := a.activeWorkspaceView(); wsView != nil {
1114+ // The WebView can be shown while its pane is in a background tab, so scan
1115+ // all WorkspaceViews rather than only the active one.
1116+ for _, wsView := range a.workspaceViews {
1117+ if wsView == nil {
1118+ continue
1119+ }
8331120 if pv := wsView.GetPaneView(paneID); pv != nil {
8341121 pv.HideLoadingSkeleton()
8351122 }
@@ -862,6 +1149,12 @@ func (a *App) initCoordinators(ctx context.Context) {
8621149 a.CloseFindBar(ctx)
8631150 return nil
8641151 })
1152+ a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1153+ return a.HandleMovePaneToTab(ctx)
1154+ })
1155+ a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1156+ return a.HandleMovePaneToNextTab(ctx)
1157+ })
8651158
8661159 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
8671160 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
@@ -90,6 +90,9 @@ type App struct {
90 90
91 // Session management91 // Session management
92 sessionManager *component.SessionManager92 sessionManager *component.SessionManager
93+ tabPicker *component.TabPicker
94+ tabPickerWidget layout.Widget
95+ tabPickerPaneID entity.PaneID
93 snapshotService *snapshot.Service96 snapshotService *snapshot.Service
94 97
95 // Update management98 // Update management
@@ -99,6 +102,8 @@ type App struct {
99 idCounter uint64102 idCounter uint64
100 idMu sync.Mutex103 idMu sync.Mutex
101 104
105+ movePaneToTabUC *usecase.MovePaneToTabUseCase
106+
102 // lifecycle107 // lifecycle
103 cancel context.CancelCauseFunc108 cancel context.CancelCauseFunc
104 }109 }
@@ -197,6 +202,7 @@ func (a *App) onActivate(ctx context.Context) {
197 a.initOmniboxConfig(ctx)202 a.initOmniboxConfig(ctx)
198 a.initFindBarConfig()203 a.initFindBarConfig()
199 a.initSessionManager(ctx)204 a.initSessionManager(ctx)
205+ a.initTabPicker(ctx)
200 a.initSnapshotService(ctx)206 a.initSnapshotService(ctx)
201 a.initUpdateCoordinator(ctx)207 a.initUpdateCoordinator(ctx)
202 a.createInitialTab(ctx)208 a.createInitialTab(ctx)
@@ -333,10 +339,13 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
333 a.handleModeChange(ctx, from, to)339 a.handleModeChange(ctx, from, to)
334 })340 })
335 a.keyboardHandler.SetShouldBypassInput(func() bool {341 a.keyboardHandler.SetShouldBypassInput(func() bool {
336- // Bypass keyboard handler when session manager is visible342+ // Bypass keyboard handler when modals are visible
337 if a.sessionManager != nil && a.sessionManager.IsVisible() {343 if a.sessionManager != nil && a.sessionManager.IsVisible() {
338 return true344 return true
339 }345 }
346+ if a.tabPicker != nil && a.tabPicker.IsVisible() {
347+ return true
348+ }
340 wsView := a.activeWorkspaceView()349 wsView := a.activeWorkspaceView()
341 if wsView == nil {350 if wsView == nil {
342 return false351 return false
@@ -480,6 +489,276 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
480 a.sessionManager.Toggle(ctx)489 a.sessionManager.Toggle(ctx)
481 }490 }
482 491
492+func (a *App) attachTabPickerToActivePane() {
493+ if a.tabPicker == nil || a.widgetFactory == nil {
494+ return
495+ }
496+ wsView := a.activeWorkspaceView()
497+ if wsView == nil {
498+ return
499+ }
500+
501+ activeTab := a.tabs.ActiveTab()
502+ if activeTab == nil || activeTab.Workspace == nil {
503+ return
504+ }
505+ activePaneID := activeTab.Workspace.ActivePaneID
506+ if activePaneID == "" {
507+ return
508+ }
509+ pv := wsView.GetPaneView(activePaneID)
510+ if pv == nil {
511+ return
512+ }
513+
514+ if a.tabPickerWidget == nil {
515+ a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
516+ if a.tabPickerWidget == nil {
517+ return
518+ }
519+ }
520+
521+ // If currently attached to a different pane overlay, detach.
522+ if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
523+ for _, view := range a.workspaceViews {
524+ if view == nil {
525+ continue
526+ }
527+ if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
528+ oldPV.RemoveOverlayWidget(a.tabPickerWidget)
529+ break
530+ }
531+ }
532+ }
533+
534+ // Ensure the widget can be reparented.
535+ if parent := a.tabPickerWidget.GetParent(); parent != nil {
536+ a.tabPickerWidget.Unparent()
537+ }
538+
539+ a.tabPicker.SetParentOverlay(pv.Overlay())
540+ pv.AddOverlayWidget(a.tabPickerWidget)
541+ a.tabPickerPaneID = activePaneID
542+}
543+
544+func (a *App) HandleMovePaneToTab(ctx context.Context) error {
545+ if a.movePaneToTabUC == nil {
546+ return nil
547+ }
548+ if a.tabPicker == nil {
549+ return nil
550+ }
551+
552+ sourceTab := a.tabs.ActiveTab()
553+ if sourceTab == nil || sourceTab.Workspace == nil {
554+ return nil
555+ }
556+
557+ // If there is only 1 tab, auto-create a new tab and move there.
558+ if a.tabs.Count() <= 1 {
559+ return a.MoveActivePaneToTab(ctx, "")
560+ }
561+
562+ items := make([]component.TabPickerItem, 0, a.tabs.Count())
563+ for _, tab := range a.tabs.Tabs {
564+ if tab == nil {
565+ continue
566+ }
567+ if tab.ID == sourceTab.ID {
568+ continue
569+ }
570+ items = append(items, component.TabPickerItem{
571+ TabID: tab.ID,
572+ Title: tab.Title(),
573+ IsNew: false,
574+ Index: tab.Position,
575+ })
576+ }
577+ items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
578+
579+ a.attachTabPickerToActivePane()
580+ a.tabPicker.Show(ctx, items)
581+ return nil
582+}
583+
584+func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
585+ if a.movePaneToTabUC == nil {
586+ return nil
587+ }
588+ active := a.tabs.ActiveTab()
589+ if active == nil {
590+ return nil
591+ }
592+
593+ nextPos := active.Position + 1
594+ if nextPos >= a.tabs.Count() {
595+ // Create a new tab on the right.
596+ return a.MoveActivePaneToTab(ctx, "")
597+ }
598+ next := a.tabs.TabAt(nextPos)
599+ if next == nil {
600+ return nil
601+ }
602+ return a.MoveActivePaneToTab(ctx, next.ID)
603+}
604+
605+func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
606+ in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
607+ if in == nil {
608+ return nil
609+ }
610+
611+ out, err := a.movePaneToTabUC.Execute(*in)
612+ if err != nil {
613+ return err
614+ }
615+ if out == nil || out.TargetTab == nil {
616+ return nil
617+ }
618+
619+ a.applyMovePaneToTabUI(ctx, out, sourceTab)
620+ a.switchToTargetTabIfConfigured(ctx, out)
621+ a.updateTabBarVisibility(ctx)
622+ a.MarkDirty()
623+ return nil
624+}
625+
626+func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
627+ if a.movePaneToTabUC == nil {
628+ return nil, nil
629+ }
630+ activeTab := a.tabs.ActiveTab()
631+ if activeTab == nil || activeTab.Workspace == nil {
632+ return nil, nil
633+ }
634+ sourcePaneID := activeTab.Workspace.ActivePaneID
635+ if sourcePaneID == "" {
636+ return nil, nil
637+ }
638+
639+ in := &usecase.MovePaneToTabInput{
640+ TabList: a.tabs,
641+ SourceTabID: activeTab.ID,
642+ SourcePaneID: sourcePaneID,
643+ TargetTabID: targetTabID,
644+ }
645+ return in, activeTab
646+}
647+
648+func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
649+ if out == nil || out.TargetTab == nil {
650+ return
651+ }
652+ sourceTabID := entity.TabID("")
653+ if sourceTab != nil {
654+ sourceTabID = sourceTab.ID
655+ }
656+
657+ if out.SourceTabClosed {
658+ a.removeSourceTabUI(sourceTabID)
659+ }
660+ if out.NewTabCreated {
661+ a.ensureTargetTabUI(ctx, out.TargetTab)
662+ }
663+
664+ if !out.SourceTabClosed {
665+ a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
666+ }
667+ a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
668+}
669+
670+func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
671+ delete(a.workspaceViews, sourceTabID)
672+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
673+ a.mainWindow.TabBar().RemoveTab(sourceTabID)
674+ }
675+}
676+
677+func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
678+ if tab == nil {
679+ return
680+ }
681+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
682+ a.mainWindow.TabBar().AddTab(tab)
683+ }
684+ a.createWorkspaceViewWithoutAttach(ctx, tab)
685+}
686+
687+func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
688+ wsView := a.workspaceViews[tabID]
689+ if wsView == nil {
690+ return
691+ }
692+ _ = wsView.Rebuild(ctx)
693+
694+ if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
695+ return
696+ }
697+ a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
698+}
699+
700+func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
701+ if out == nil || out.TargetTab == nil {
702+ return
703+ }
704+
705+ switchToTarget := config.Get().Workspace.SwitchToTabOnMove
706+ if out.SourceTabClosed {
707+ switchToTarget = true
708+ }
709+ if !switchToTarget {
710+ return
711+ }
712+
713+ a.tabs.SetActive(out.TargetTab.ID)
714+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
715+ a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
716+ }
717+ a.switchWorkspaceView(ctx, out.TargetTab.ID)
718+}
719+
720+func (a *App) updateTabBarVisibility(ctx context.Context) {
721+ if a.tabCoord != nil {
722+ a.tabCoord.UpdateBarVisibility(ctx)
723+ }
724+}
725+
726+func (a *App) initTabPicker(ctx context.Context) {
727+ log := logging.FromContext(ctx)
728+
729+ if a.deps == nil || a.deps.Config == nil {
730+ log.Debug().Msg("deps/config not available, skipping tab picker")
731+ return
732+ }
733+
734+ a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
735+ UIScale: a.deps.Config.DefaultUIScale,
736+ OnClose: func() {
737+ log.Debug().Msg("tab picker closed")
738+ },
739+ OnSelect: func(item component.TabPickerItem) {
740+ cb := glib.SourceFunc(func(_ uintptr) bool {
741+ var targetID entity.TabID
742+ if !item.IsNew {
743+ targetID = item.TabID
744+ }
745+ if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
746+ log.Warn().Err(err).Msg("move pane to tab failed")
747+ }
748+ return false
749+ })
750+ glib.IdleAdd(&cb, 0)
751+ },
752+ })
753+
754+ if a.tabPicker == nil {
755+ log.Warn().Msg("failed to create tab picker")
756+ return
757+ }
758+
759+ log.Debug().Msg("tab picker initialized")
760+}
761+
483 func (a *App) initSnapshotService(ctx context.Context) {762 func (a *App) initSnapshotService(ctx context.Context) {
484 log := logging.FromContext(ctx)763 log := logging.FromContext(ctx)
485 764
@@ -798,6 +1077,9 @@ func (a *App) initCoordinators(ctx context.Context) {
798 return nil1077 return nil
799 })1078 })
800 1079
1080+ // Move pane use case (cross-tab)
1081+ a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1082+
801 // 4. Navigation Coordinator1083 // 4. Navigation Coordinator
802 a.navCoord = coordinator.NewNavigationCoordinator(1084 a.navCoord = coordinator.NewNavigationCoordinator(
803 ctx,1085 ctx,
@@ -829,7 +1111,12 @@ func (a *App) initCoordinators(ctx context.Context) {
829 1111
830 // Hide loading skeleton once the WebView paints1112 // Hide loading skeleton once the WebView paints
831 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {1113 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
832- if wsView := a.activeWorkspaceView(); wsView != nil {1114+ // The WebView can be shown while its pane is in a background tab, so scan
1115+ // all WorkspaceViews rather than only the active one.
1116+ for _, wsView := range a.workspaceViews {
1117+ if wsView == nil {
1118+ continue
1119+ }
833 if pv := wsView.GetPaneView(paneID); pv != nil {1120 if pv := wsView.GetPaneView(paneID); pv != nil {
834 pv.HideLoadingSkeleton()1121 pv.HideLoadingSkeleton()
835 }1122 }
@@ -862,6 +1149,12 @@ func (a *App) initCoordinators(ctx context.Context) {
862 a.CloseFindBar(ctx)1149 a.CloseFindBar(ctx)
863 return nil1150 return nil
864 })1151 })
1152+ a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1153+ return a.HandleMovePaneToTab(ctx)
1154+ })
1155+ a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1156+ return a.HandleMovePaneToNextTab(ctx)
1157+ })
865 1158
866 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)1159 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
867 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {1160 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
M internal/ui/component/modal_sizing.go +15 -0
@@ -65,6 +65,21 @@ var SessionManagerListDefaults = ListDisplayDefaults{
6565 MaxResults: 50,
6666 }
6767
68+// TabPickerSizeDefaults provides default sizing for tab picker modal.
69+var TabPickerSizeDefaults = ModalSizeConfig{
70+ WidthPct: 0.6,
71+ MaxWidth: 600,
72+ TopMarginPct: 0.15,
73+ FallbackWidth: 600,
74+ FallbackHeight: 600,
75+}
76+
77+// TabPickerListDefaults provides display limits for tab picker modal.
78+var TabPickerListDefaults = ListDisplayDefaults{
79+ MaxVisibleRows: 8,
80+ MaxResults: 20,
81+}
82+
6883 // CalculateModalDimensions computes width and top margin based on parent overlay.
6984 // Returns calculated width and top margin in pixels.
7085 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
@@ -65,6 +65,21 @@ var SessionManagerListDefaults = ListDisplayDefaults{
65 MaxResults: 50,65 MaxResults: 50,
66 }66 }
67 67
68+// TabPickerSizeDefaults provides default sizing for tab picker modal.
69+var TabPickerSizeDefaults = ModalSizeConfig{
70+ WidthPct: 0.6,
71+ MaxWidth: 600,
72+ TopMarginPct: 0.15,
73+ FallbackWidth: 600,
74+ FallbackHeight: 600,
75+}
76+
77+// TabPickerListDefaults provides display limits for tab picker modal.
78+var TabPickerListDefaults = ListDisplayDefaults{
79+ MaxVisibleRows: 8,
80+ MaxResults: 20,
81+}
82+
68 // CalculateModalDimensions computes width and top margin based on parent overlay.83 // CalculateModalDimensions computes width and top margin based on parent overlay.
69 // Returns calculated width and top margin in pixels.84 // Returns calculated width and top margin in pixels.
70 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {85 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
M internal/ui/component/pane_view.go +10 -1
@@ -144,13 +144,22 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
144144 pv.webViewWidget = widget
145145
146146 if widget != nil {
147+ // If this widget already had a parent, we're reparenting an existing WebView
148+ // (e.g. after rebuilding/moving panes). In that case the WebView has likely
149+ // already painted, so the loading skeleton should be hidden immediately.
150+ hadParent := widget.GetParent() != nil
151+
147152 // Unparent widget from any previous parent (critical for rebuild scenarios)
148153 // In GTK4, a widget can only have one parent at a time
149- if widget.GetParent() != nil {
154+ if hadParent {
150155 widget.Unparent()
151156 }
152157 widget.SetVisible(true)
153158 pv.overlay.SetChild(widget)
159+
160+ if hadParent && pv.loading != nil {
161+ pv.loading.SetVisible(false)
162+ }
154163 }
155164 }
156165
@@ -144,13 +144,22 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
144 pv.webViewWidget = widget144 pv.webViewWidget = widget
145 145
146 if widget != nil {146 if widget != nil {
147+ // If this widget already had a parent, we're reparenting an existing WebView
148+ // (e.g. after rebuilding/moving panes). In that case the WebView has likely
149+ // already painted, so the loading skeleton should be hidden immediately.
150+ hadParent := widget.GetParent() != nil
151+
147 // Unparent widget from any previous parent (critical for rebuild scenarios)152 // Unparent widget from any previous parent (critical for rebuild scenarios)
148 // In GTK4, a widget can only have one parent at a time153 // In GTK4, a widget can only have one parent at a time
149- if widget.GetParent() != nil {154+ if hadParent {
150 widget.Unparent()155 widget.Unparent()
151 }156 }
152 widget.SetVisible(true)157 widget.SetVisible(true)
153 pv.overlay.SetChild(widget)158 pv.overlay.SetChild(widget)
159+
160+ if hadParent && pv.loading != nil {
161+ pv.loading.SetVisible(false)
162+ }
154 }163 }
155 }164 }
156 165
A internal/ui/component/tab_picker.go +482 -0
@@ -0,0 +1,482 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/glib"
11+ "github.com/jwijenbergh/puregotk/v4/gtk"
12+)
13+
14+type TabPickerItem struct {
15+ TabID entity.TabID
16+ Title string
17+ IsNew bool
18+ Index int // 0-based index in tab list, -1 for + New
19+}
20+
21+// TabPicker is a modal overlay for selecting a tab destination.
22+//
23+// It is intentionally similar to SessionManager/Omnibox patterns.
24+type TabPicker struct {
25+ // GTK widgets
26+ outerBox *gtk.Box
27+ mainBox *gtk.Box
28+ headerBox *gtk.Box
29+ titleLabel *gtk.Label
30+ scrolledWindow *gtk.ScrolledWindow
31+ listBox *gtk.ListBox
32+ footerLabel *gtk.Label
33+
34+ parentOverlay layout.OverlayWidget
35+
36+ mu sync.RWMutex
37+ visible bool
38+ items []TabPickerItem
39+ selectedIndex int
40+ uiScale float64
41+
42+ onClose func()
43+ onSelect func(item TabPickerItem)
44+
45+ retainedCallbacks []interface{}
46+ ctx context.Context
47+}
48+
49+type TabPickerConfig struct {
50+ UIScale float64
51+ OnClose func()
52+ OnSelect func(item TabPickerItem)
53+}
54+
55+func NewTabPicker(ctx context.Context, cfg TabPickerConfig) *TabPicker {
56+ uiScale := cfg.UIScale
57+ if uiScale <= 0 {
58+ uiScale = 1.0
59+ }
60+
61+ tp := &TabPicker{
62+ ctx: ctx,
63+ onClose: cfg.OnClose,
64+ onSelect: cfg.OnSelect,
65+ selectedIndex: 0,
66+ uiScale: uiScale,
67+ }
68+
69+ if err := tp.createWidgets(); err != nil {
70+ return nil
71+ }
72+ tp.attachKeyController()
73+ return tp
74+}
75+
76+func (tp *TabPicker) SetParentOverlay(overlay layout.OverlayWidget) {
77+ tp.mu.Lock()
78+ defer tp.mu.Unlock()
79+ tp.parentOverlay = overlay
80+}
81+
82+func (tp *TabPicker) Widget() *gtk.Widget {
83+ if tp.outerBox == nil {
84+ return nil
85+ }
86+ return &tp.outerBox.Widget
87+}
88+
89+func (tp *TabPicker) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
90+ if tp.outerBox == nil {
91+ return nil
92+ }
93+ return factory.WrapWidget(&tp.outerBox.Widget)
94+}
95+
96+func (tp *TabPicker) IsVisible() bool {
97+ tp.mu.RLock()
98+ defer tp.mu.RUnlock()
99+ return tp.visible
100+}
101+
102+func (tp *TabPicker) Show(ctx context.Context, items []TabPickerItem) {
103+ tp.mu.Lock()
104+ if tp.visible {
105+ tp.mu.Unlock()
106+ return
107+ }
108+ tp.visible = true
109+ tp.items = items
110+ tp.selectedIndex = 0
111+ tp.mu.Unlock()
112+
113+ tp.populateList()
114+ tp.resizeAndCenter()
115+ if tp.outerBox != nil {
116+ tp.outerBox.SetVisible(true)
117+ }
118+ if tp.listBox != nil {
119+ tp.listBox.GrabFocus()
120+ }
121+}
122+
123+func (tp *TabPicker) Hide(ctx context.Context) {
124+ tp.mu.Lock()
125+ if !tp.visible {
126+ tp.mu.Unlock()
127+ return
128+ }
129+ tp.visible = false
130+ tp.mu.Unlock()
131+
132+ if tp.outerBox != nil {
133+ tp.outerBox.SetVisible(false)
134+ }
135+ if tp.listBox != nil {
136+ tp.listBox.RemoveAll()
137+ }
138+
139+ if tp.onClose != nil {
140+ tp.onClose()
141+ }
142+}
143+
144+func (tp *TabPicker) Toggle(ctx context.Context, items []TabPickerItem) {
145+ if tp.IsVisible() {
146+ tp.Hide(ctx)
147+ } else {
148+ tp.Show(ctx, items)
149+ }
150+}
151+
152+func (tp *TabPicker) createWidgets() error {
153+ if err := tp.createOuter(); err != nil {
154+ return err
155+ }
156+ if err := tp.createMain(); err != nil {
157+ return err
158+ }
159+ if err := tp.createHeader(); err != nil {
160+ return err
161+ }
162+ if err := tp.createList(); err != nil {
163+ return err
164+ }
165+ if err := tp.createFooter(); err != nil {
166+ return err
167+ }
168+ tp.assemble()
169+ return nil
170+}
171+
172+func (tp *TabPicker) createOuter() error {
173+ tp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
174+ if tp.outerBox == nil {
175+ return errNilWidget("tabPickerOuterBox")
176+ }
177+ tp.outerBox.AddCssClass("tab-picker-outer")
178+ tp.outerBox.SetHalign(gtk.AlignCenterValue)
179+ tp.outerBox.SetValign(gtk.AlignStartValue)
180+ tp.outerBox.SetVisible(false)
181+ return nil
182+}
183+
184+func (tp *TabPicker) createMain() error {
185+ tp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
186+ if tp.mainBox == nil {
187+ return errNilWidget("tabPickerMainBox")
188+ }
189+ tp.mainBox.AddCssClass("tab-picker-container")
190+ return nil
191+}
192+
193+func (tp *TabPicker) createHeader() error {
194+ tp.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
195+ if tp.headerBox == nil {
196+ return errNilWidget("tabPickerHeaderBox")
197+ }
198+ tp.headerBox.AddCssClass("tab-picker-header")
199+
200+ title := "Move Pane To Tab"
201+ tp.titleLabel = gtk.NewLabel(&title)
202+ if tp.titleLabel == nil {
203+ return errNilWidget("tabPickerTitleLabel")
204+ }
205+ tp.titleLabel.AddCssClass("tab-picker-title")
206+ tp.titleLabel.SetHalign(gtk.AlignStartValue)
207+ tp.titleLabel.SetHexpand(true)
208+ tp.headerBox.Append(&tp.titleLabel.Widget)
209+
210+ shortcutText := "Ctrl+P m"
211+ shortcutLabel := gtk.NewLabel(&shortcutText)
212+ if shortcutLabel != nil {
213+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
214+ tp.headerBox.Append(&shortcutLabel.Widget)
215+ }
216+ return nil
217+}
218+
219+func (tp *TabPicker) createList() error {
220+ tp.scrolledWindow = gtk.NewScrolledWindow()
221+ if tp.scrolledWindow == nil {
222+ return errNilWidget("tabPickerScrolledWindow")
223+ }
224+ tp.scrolledWindow.AddCssClass("tab-picker-scrolled")
225+ tp.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
226+ tp.scrolledWindow.SetPropagateNaturalHeight(false)
227+
228+ tp.listBox = gtk.NewListBox()
229+ if tp.listBox == nil {
230+ return errNilWidget("tabPickerListBox")
231+ }
232+ tp.listBox.AddCssClass("tab-picker-list")
233+ tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234+
235+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
236+ if rowPtr == 0 {
237+ return
238+ }
239+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
240+ if row == nil {
241+ return
242+ }
243+ idx := row.GetIndex()
244+ tp.mu.Lock()
245+ tp.selectedIndex = idx
246+ tp.mu.Unlock()
247+ }
248+ tp.retainedCallbacks = append(tp.retainedCallbacks, rowSelectedCb)
249+ tp.listBox.ConnectRowSelected(&rowSelectedCb)
250+
251+ tp.scrolledWindow.SetChild(&tp.listBox.Widget)
252+ return nil
253+}
254+
255+func (tp *TabPicker) createFooter() error {
256+ footerText := "↑↓/jk navigate Enter confirm 1-9 pick tab n new tab Esc close"
257+ tp.footerLabel = gtk.NewLabel(&footerText)
258+ if tp.footerLabel == nil {
259+ return errNilWidget("tabPickerFooterLabel")
260+ }
261+ tp.footerLabel.AddCssClass("tab-picker-footer")
262+ tp.footerLabel.SetHalign(gtk.AlignCenterValue)
263+ return nil
264+}
265+
266+func (tp *TabPicker) assemble() {
267+ if tp.outerBox == nil || tp.mainBox == nil {
268+ return
269+ }
270+ if tp.headerBox != nil {
271+ tp.mainBox.Append(&tp.headerBox.Widget)
272+ }
273+ if tp.scrolledWindow != nil {
274+ tp.mainBox.Append(&tp.scrolledWindow.Widget)
275+ }
276+ if tp.footerLabel != nil {
277+ tp.mainBox.Append(&tp.footerLabel.Widget)
278+ }
279+ tp.outerBox.Append(&tp.mainBox.Widget)
280+}
281+
282+func (tp *TabPicker) resizeAndCenter() {
283+ if tp.outerBox == nil || tp.mainBox == nil {
284+ return
285+ }
286+
287+ width, marginTop := CalculateModalDimensions(tp.parentOverlay, TabPickerSizeDefaults)
288+ tp.mainBox.SetSizeRequest(width, -1)
289+ tp.outerBox.SetMarginTop(marginTop)
290+
291+ // Height based on rows
292+ tp.mu.RLock()
293+ count := len(tp.items)
294+ tp.mu.RUnlock()
295+
296+ if tp.scrolledWindow != nil {
297+ rowH := ScaleValue(DefaultRowHeights.Standard, tp.uiScale)
298+ maxH := TabPickerListDefaults.MaxVisibleRows * rowH
299+ h := count * rowH
300+ if h > maxH {
301+ h = maxH
302+ }
303+ if h < rowH {
304+ h = rowH
305+ }
306+ cb := glib.SourceFunc(func(_ uintptr) bool {
307+ SetScrolledWindowHeight(tp.scrolledWindow, h)
308+ tp.outerBox.QueueResize()
309+ return false
310+ })
311+ glib.IdleAdd(&cb, 0)
312+ }
313+}
314+
315+func (tp *TabPicker) populateList() {
316+ if tp.listBox == nil {
317+ return
318+ }
319+ p := tp.listBox
320+ p.RemoveAll()
321+
322+ tp.mu.RLock()
323+ items := append([]TabPickerItem(nil), tp.items...)
324+ tp.mu.RUnlock()
325+
326+ for _, it := range items {
327+ row := gtk.NewListBoxRow()
328+ if row == nil {
329+ continue
330+ }
331+ row.AddCssClass("tab-picker-row")
332+
333+ const rowSpacing = 8
334+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
335+ if hbox == nil {
336+ continue
337+ }
338+ hbox.SetHexpand(true)
339+
340+ labelText := it.Title
341+ if it.IsNew {
342+ labelText = "+ New Tab"
343+ }
344+
345+ label := gtk.NewLabel(&labelText)
346+ if label != nil {
347+ label.AddCssClass("tab-picker-row-title")
348+ label.SetHalign(gtk.AlignStartValue)
349+ label.SetHexpand(true)
350+ hbox.Append(&label.Widget)
351+ }
352+
353+ row.SetChild(&hbox.Widget)
354+ p.Append(&row.Widget)
355+ }
356+
357+ // select first row
358+ if row := p.GetRowAtIndex(0); row != nil {
359+ p.SelectRow(row)
360+ }
361+}
362+
363+func (tp *TabPicker) attachKeyController() {
364+ controller := gtk.NewEventControllerKey()
365+ if controller == nil {
366+ return
367+ }
368+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
369+
370+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
371+ return tp.handleKeyPress(keyval)
372+ }
373+ tp.retainedCallbacks = append(tp.retainedCallbacks, keyPressedCb)
374+ controller.ConnectKeyPressed(&keyPressedCb)
375+ tp.outerBox.AddController(&controller.EventController)
376+}
377+
378+func (tp *TabPicker) handleKeyPress(keyval uint) bool {
379+ switch keyval {
380+ case uint(gdk.KEY_Escape):
381+ tp.Hide(tp.ctx)
382+ return true
383+ case uint(gdk.KEY_Up), uint(gdk.KEY_k):
384+ tp.selectRelative(-1)
385+ return true
386+ case uint(gdk.KEY_Down), uint(gdk.KEY_j):
387+ tp.selectRelative(1)
388+ return true
389+ case uint(gdk.KEY_n):
390+ tp.chooseNewTab()
391+ return true
392+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
393+ tp.confirmSelection()
394+ return true
395+ case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
396+ uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
397+ idx := int(keyval - uint(gdk.KEY_1))
398+ tp.selectIndex(idx)
399+ tp.confirmSelection()
400+ return true
401+ default:
402+ return false
403+ }
404+}
405+
406+func (tp *TabPicker) selectRelative(delta int) {
407+ tp.mu.Lock()
408+ defer tp.mu.Unlock()
409+
410+ count := len(tp.items)
411+ if count == 0 {
412+ return
413+ }
414+
415+ i := tp.selectedIndex + delta
416+ if i < 0 {
417+ i = count - 1
418+ } else if i >= count {
419+ i = 0
420+ }
421+ tp.selectedIndex = i
422+ if tp.listBox != nil {
423+ if row := tp.listBox.GetRowAtIndex(i); row != nil {
424+ tp.listBox.SelectRow(row)
425+ row.GrabFocus()
426+ }
427+ }
428+}
429+
430+func (tp *TabPicker) selectIndex(index int) {
431+ tp.mu.Lock()
432+ defer tp.mu.Unlock()
433+
434+ count := len(tp.items)
435+ if count == 0 {
436+ return
437+ }
438+ if index < 0 {
439+ index = 0
440+ }
441+ if index >= count {
442+ index = count - 1
443+ }
444+ tp.selectedIndex = index
445+ if tp.listBox != nil {
446+ if row := tp.listBox.GetRowAtIndex(index); row != nil {
447+ tp.listBox.SelectRow(row)
448+ row.GrabFocus()
449+ }
450+ }
451+}
452+
453+func (tp *TabPicker) chooseNewTab() {
454+ tp.mu.RLock()
455+ items := append([]TabPickerItem(nil), tp.items...)
456+ tp.mu.RUnlock()
457+
458+ for i := range items {
459+ if items[i].IsNew {
460+ tp.selectIndex(i)
461+ tp.confirmSelection()
462+ return
463+ }
464+ }
465+}
466+
467+func (tp *TabPicker) confirmSelection() {
468+ tp.mu.RLock()
469+ idx := tp.selectedIndex
470+ items := append([]TabPickerItem(nil), tp.items...)
471+ tp.mu.RUnlock()
472+
473+ if idx < 0 || idx >= len(items) {
474+ return
475+ }
476+ item := items[idx]
477+
478+ tp.Hide(tp.ctx)
479+ if tp.onSelect != nil {
480+ tp.onSelect(item)
481+ }
482+}
@@ -0,0 +1,482 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/glib"
11+ "github.com/jwijenbergh/puregotk/v4/gtk"
12+)
13+
14+type TabPickerItem struct {
15+ TabID entity.TabID
16+ Title string
17+ IsNew bool
18+ Index int // 0-based index in tab list, -1 for + New
19+}
20+
21+// TabPicker is a modal overlay for selecting a tab destination.
22+//
23+// It is intentionally similar to SessionManager/Omnibox patterns.
24+type TabPicker struct {
25+ // GTK widgets
26+ outerBox *gtk.Box
27+ mainBox *gtk.Box
28+ headerBox *gtk.Box
29+ titleLabel *gtk.Label
30+ scrolledWindow *gtk.ScrolledWindow
31+ listBox *gtk.ListBox
32+ footerLabel *gtk.Label
33+
34+ parentOverlay layout.OverlayWidget
35+
36+ mu sync.RWMutex
37+ visible bool
38+ items []TabPickerItem
39+ selectedIndex int
40+ uiScale float64
41+
42+ onClose func()
43+ onSelect func(item TabPickerItem)
44+
45+ retainedCallbacks []interface{}
46+ ctx context.Context
47+}
48+
49+type TabPickerConfig struct {
50+ UIScale float64
51+ OnClose func()
52+ OnSelect func(item TabPickerItem)
53+}
54+
55+func NewTabPicker(ctx context.Context, cfg TabPickerConfig) *TabPicker {
56+ uiScale := cfg.UIScale
57+ if uiScale <= 0 {
58+ uiScale = 1.0
59+ }
60+
61+ tp := &TabPicker{
62+ ctx: ctx,
63+ onClose: cfg.OnClose,
64+ onSelect: cfg.OnSelect,
65+ selectedIndex: 0,
66+ uiScale: uiScale,
67+ }
68+
69+ if err := tp.createWidgets(); err != nil {
70+ return nil
71+ }
72+ tp.attachKeyController()
73+ return tp
74+}
75+
76+func (tp *TabPicker) SetParentOverlay(overlay layout.OverlayWidget) {
77+ tp.mu.Lock()
78+ defer tp.mu.Unlock()
79+ tp.parentOverlay = overlay
80+}
81+
82+func (tp *TabPicker) Widget() *gtk.Widget {
83+ if tp.outerBox == nil {
84+ return nil
85+ }
86+ return &tp.outerBox.Widget
87+}
88+
89+func (tp *TabPicker) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
90+ if tp.outerBox == nil {
91+ return nil
92+ }
93+ return factory.WrapWidget(&tp.outerBox.Widget)
94+}
95+
96+func (tp *TabPicker) IsVisible() bool {
97+ tp.mu.RLock()
98+ defer tp.mu.RUnlock()
99+ return tp.visible
100+}
101+
102+func (tp *TabPicker) Show(ctx context.Context, items []TabPickerItem) {
103+ tp.mu.Lock()
104+ if tp.visible {
105+ tp.mu.Unlock()
106+ return
107+ }
108+ tp.visible = true
109+ tp.items = items
110+ tp.selectedIndex = 0
111+ tp.mu.Unlock()
112+
113+ tp.populateList()
114+ tp.resizeAndCenter()
115+ if tp.outerBox != nil {
116+ tp.outerBox.SetVisible(true)
117+ }
118+ if tp.listBox != nil {
119+ tp.listBox.GrabFocus()
120+ }
121+}
122+
123+func (tp *TabPicker) Hide(ctx context.Context) {
124+ tp.mu.Lock()
125+ if !tp.visible {
126+ tp.mu.Unlock()
127+ return
128+ }
129+ tp.visible = false
130+ tp.mu.Unlock()
131+
132+ if tp.outerBox != nil {
133+ tp.outerBox.SetVisible(false)
134+ }
135+ if tp.listBox != nil {
136+ tp.listBox.RemoveAll()
137+ }
138+
139+ if tp.onClose != nil {
140+ tp.onClose()
141+ }
142+}
143+
144+func (tp *TabPicker) Toggle(ctx context.Context, items []TabPickerItem) {
145+ if tp.IsVisible() {
146+ tp.Hide(ctx)
147+ } else {
148+ tp.Show(ctx, items)
149+ }
150+}
151+
152+func (tp *TabPicker) createWidgets() error {
153+ if err := tp.createOuter(); err != nil {
154+ return err
155+ }
156+ if err := tp.createMain(); err != nil {
157+ return err
158+ }
159+ if err := tp.createHeader(); err != nil {
160+ return err
161+ }
162+ if err := tp.createList(); err != nil {
163+ return err
164+ }
165+ if err := tp.createFooter(); err != nil {
166+ return err
167+ }
168+ tp.assemble()
169+ return nil
170+}
171+
172+func (tp *TabPicker) createOuter() error {
173+ tp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
174+ if tp.outerBox == nil {
175+ return errNilWidget("tabPickerOuterBox")
176+ }
177+ tp.outerBox.AddCssClass("tab-picker-outer")
178+ tp.outerBox.SetHalign(gtk.AlignCenterValue)
179+ tp.outerBox.SetValign(gtk.AlignStartValue)
180+ tp.outerBox.SetVisible(false)
181+ return nil
182+}
183+
184+func (tp *TabPicker) createMain() error {
185+ tp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
186+ if tp.mainBox == nil {
187+ return errNilWidget("tabPickerMainBox")
188+ }
189+ tp.mainBox.AddCssClass("tab-picker-container")
190+ return nil
191+}
192+
193+func (tp *TabPicker) createHeader() error {
194+ tp.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
195+ if tp.headerBox == nil {
196+ return errNilWidget("tabPickerHeaderBox")
197+ }
198+ tp.headerBox.AddCssClass("tab-picker-header")
199+
200+ title := "Move Pane To Tab"
201+ tp.titleLabel = gtk.NewLabel(&title)
202+ if tp.titleLabel == nil {
203+ return errNilWidget("tabPickerTitleLabel")
204+ }
205+ tp.titleLabel.AddCssClass("tab-picker-title")
206+ tp.titleLabel.SetHalign(gtk.AlignStartValue)
207+ tp.titleLabel.SetHexpand(true)
208+ tp.headerBox.Append(&tp.titleLabel.Widget)
209+
210+ shortcutText := "Ctrl+P m"
211+ shortcutLabel := gtk.NewLabel(&shortcutText)
212+ if shortcutLabel != nil {
213+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
214+ tp.headerBox.Append(&shortcutLabel.Widget)
215+ }
216+ return nil
217+}
218+
219+func (tp *TabPicker) createList() error {
220+ tp.scrolledWindow = gtk.NewScrolledWindow()
221+ if tp.scrolledWindow == nil {
222+ return errNilWidget("tabPickerScrolledWindow")
223+ }
224+ tp.scrolledWindow.AddCssClass("tab-picker-scrolled")
225+ tp.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
226+ tp.scrolledWindow.SetPropagateNaturalHeight(false)
227+
228+ tp.listBox = gtk.NewListBox()
229+ if tp.listBox == nil {
230+ return errNilWidget("tabPickerListBox")
231+ }
232+ tp.listBox.AddCssClass("tab-picker-list")
233+ tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234+
235+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
236+ if rowPtr == 0 {
237+ return
238+ }
239+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
240+ if row == nil {
241+ return
242+ }
243+ idx := row.GetIndex()
244+ tp.mu.Lock()
245+ tp.selectedIndex = idx
246+ tp.mu.Unlock()
247+ }
248+ tp.retainedCallbacks = append(tp.retainedCallbacks, rowSelectedCb)
249+ tp.listBox.ConnectRowSelected(&rowSelectedCb)
250+
251+ tp.scrolledWindow.SetChild(&tp.listBox.Widget)
252+ return nil
253+}
254+
255+func (tp *TabPicker) createFooter() error {
256+ footerText := "↑↓/jk navigate Enter confirm 1-9 pick tab n new tab Esc close"
257+ tp.footerLabel = gtk.NewLabel(&footerText)
258+ if tp.footerLabel == nil {
259+ return errNilWidget("tabPickerFooterLabel")
260+ }
261+ tp.footerLabel.AddCssClass("tab-picker-footer")
262+ tp.footerLabel.SetHalign(gtk.AlignCenterValue)
263+ return nil
264+}
265+
266+func (tp *TabPicker) assemble() {
267+ if tp.outerBox == nil || tp.mainBox == nil {
268+ return
269+ }
270+ if tp.headerBox != nil {
271+ tp.mainBox.Append(&tp.headerBox.Widget)
272+ }
273+ if tp.scrolledWindow != nil {
274+ tp.mainBox.Append(&tp.scrolledWindow.Widget)
275+ }
276+ if tp.footerLabel != nil {
277+ tp.mainBox.Append(&tp.footerLabel.Widget)
278+ }
279+ tp.outerBox.Append(&tp.mainBox.Widget)
280+}
281+
282+func (tp *TabPicker) resizeAndCenter() {
283+ if tp.outerBox == nil || tp.mainBox == nil {
284+ return
285+ }
286+
287+ width, marginTop := CalculateModalDimensions(tp.parentOverlay, TabPickerSizeDefaults)
288+ tp.mainBox.SetSizeRequest(width, -1)
289+ tp.outerBox.SetMarginTop(marginTop)
290+
291+ // Height based on rows
292+ tp.mu.RLock()
293+ count := len(tp.items)
294+ tp.mu.RUnlock()
295+
296+ if tp.scrolledWindow != nil {
297+ rowH := ScaleValue(DefaultRowHeights.Standard, tp.uiScale)
298+ maxH := TabPickerListDefaults.MaxVisibleRows * rowH
299+ h := count * rowH
300+ if h > maxH {
301+ h = maxH
302+ }
303+ if h < rowH {
304+ h = rowH
305+ }
306+ cb := glib.SourceFunc(func(_ uintptr) bool {
307+ SetScrolledWindowHeight(tp.scrolledWindow, h)
308+ tp.outerBox.QueueResize()
309+ return false
310+ })
311+ glib.IdleAdd(&cb, 0)
312+ }
313+}
314+
315+func (tp *TabPicker) populateList() {
316+ if tp.listBox == nil {
317+ return
318+ }
319+ p := tp.listBox
320+ p.RemoveAll()
321+
322+ tp.mu.RLock()
323+ items := append([]TabPickerItem(nil), tp.items...)
324+ tp.mu.RUnlock()
325+
326+ for _, it := range items {
327+ row := gtk.NewListBoxRow()
328+ if row == nil {
329+ continue
330+ }
331+ row.AddCssClass("tab-picker-row")
332+
333+ const rowSpacing = 8
334+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
335+ if hbox == nil {
336+ continue
337+ }
338+ hbox.SetHexpand(true)
339+
340+ labelText := it.Title
341+ if it.IsNew {
342+ labelText = "+ New Tab"
343+ }
344+
345+ label := gtk.NewLabel(&labelText)
346+ if label != nil {
347+ label.AddCssClass("tab-picker-row-title")
348+ label.SetHalign(gtk.AlignStartValue)
349+ label.SetHexpand(true)
350+ hbox.Append(&label.Widget)
351+ }
352+
353+ row.SetChild(&hbox.Widget)
354+ p.Append(&row.Widget)
355+ }
356+
357+ // select first row
358+ if row := p.GetRowAtIndex(0); row != nil {
359+ p.SelectRow(row)
360+ }
361+}
362+
363+func (tp *TabPicker) attachKeyController() {
364+ controller := gtk.NewEventControllerKey()
365+ if controller == nil {
366+ return
367+ }
368+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
369+
370+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
371+ return tp.handleKeyPress(keyval)
372+ }
373+ tp.retainedCallbacks = append(tp.retainedCallbacks, keyPressedCb)
374+ controller.ConnectKeyPressed(&keyPressedCb)
375+ tp.outerBox.AddController(&controller.EventController)
376+}
377+
378+func (tp *TabPicker) handleKeyPress(keyval uint) bool {
379+ switch keyval {
380+ case uint(gdk.KEY_Escape):
381+ tp.Hide(tp.ctx)
382+ return true
383+ case uint(gdk.KEY_Up), uint(gdk.KEY_k):
384+ tp.selectRelative(-1)
385+ return true
386+ case uint(gdk.KEY_Down), uint(gdk.KEY_j):
387+ tp.selectRelative(1)
388+ return true
389+ case uint(gdk.KEY_n):
390+ tp.chooseNewTab()
391+ return true
392+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
393+ tp.confirmSelection()
394+ return true
395+ case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
396+ uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
397+ idx := int(keyval - uint(gdk.KEY_1))
398+ tp.selectIndex(idx)
399+ tp.confirmSelection()
400+ return true
401+ default:
402+ return false
403+ }
404+}
405+
406+func (tp *TabPicker) selectRelative(delta int) {
407+ tp.mu.Lock()
408+ defer tp.mu.Unlock()
409+
410+ count := len(tp.items)
411+ if count == 0 {
412+ return
413+ }
414+
415+ i := tp.selectedIndex + delta
416+ if i < 0 {
417+ i = count - 1
418+ } else if i >= count {
419+ i = 0
420+ }
421+ tp.selectedIndex = i
422+ if tp.listBox != nil {
423+ if row := tp.listBox.GetRowAtIndex(i); row != nil {
424+ tp.listBox.SelectRow(row)
425+ row.GrabFocus()
426+ }
427+ }
428+}
429+
430+func (tp *TabPicker) selectIndex(index int) {
431+ tp.mu.Lock()
432+ defer tp.mu.Unlock()
433+
434+ count := len(tp.items)
435+ if count == 0 {
436+ return
437+ }
438+ if index < 0 {
439+ index = 0
440+ }
441+ if index >= count {
442+ index = count - 1
443+ }
444+ tp.selectedIndex = index
445+ if tp.listBox != nil {
446+ if row := tp.listBox.GetRowAtIndex(index); row != nil {
447+ tp.listBox.SelectRow(row)
448+ row.GrabFocus()
449+ }
450+ }
451+}
452+
453+func (tp *TabPicker) chooseNewTab() {
454+ tp.mu.RLock()
455+ items := append([]TabPickerItem(nil), tp.items...)
456+ tp.mu.RUnlock()
457+
458+ for i := range items {
459+ if items[i].IsNew {
460+ tp.selectIndex(i)
461+ tp.confirmSelection()
462+ return
463+ }
464+ }
465+}
466+
467+func (tp *TabPicker) confirmSelection() {
468+ tp.mu.RLock()
469+ idx := tp.selectedIndex
470+ items := append([]TabPickerItem(nil), tp.items...)
471+ tp.mu.RUnlock()
472+
473+ if idx < 0 || idx >= len(items) {
474+ return
475+ }
476+ item := items[idx]
477+
478+ tp.Hide(tp.ctx)
479+ if tp.onSelect != nil {
480+ tp.onSelect(item)
481+ }
482+}
M internal/ui/theme/css.go +5 -0
@@ -114,6 +114,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
114114
115115 // Session manager styling
116116 sb.WriteString(generateSessionManagerCSS(p))
117+ sb.WriteString("\n")
118+
119+ // Tab picker styling
120+ sb.WriteString(generateTabPickerCSS(p))
117121
118122 return sb.String()
119123 }
@@ -596,6 +600,7 @@ progressbar.osd progress {
596600 // generateSessionManagerCSS creates session manager modal styles.
597601 // Uses em units for scalable UI, matches omnibox styling patterns.
598602 func generateSessionManagerCSS(p Palette) string {
603+ _ = p
599604 return `/* ===== Session Manager Styling ===== */
600605
601606 /* Session manager outer container - for positioning in overlay */
@@ -114,6 +114,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
114 114
115 // Session manager styling115 // Session manager styling
116 sb.WriteString(generateSessionManagerCSS(p))116 sb.WriteString(generateSessionManagerCSS(p))
117+ sb.WriteString("\n")
118+
119+ // Tab picker styling
120+ sb.WriteString(generateTabPickerCSS(p))
117 121
118 return sb.String()122 return sb.String()
119 }123 }
@@ -596,6 +600,7 @@ progressbar.osd progress {
596 // generateSessionManagerCSS creates session manager modal styles.600 // generateSessionManagerCSS creates session manager modal styles.
597 // Uses em units for scalable UI, matches omnibox styling patterns.601 // Uses em units for scalable UI, matches omnibox styling patterns.
598 func generateSessionManagerCSS(p Palette) string {602 func generateSessionManagerCSS(p Palette) string {
603+ _ = p
599 return `/* ===== Session Manager Styling ===== */604 return `/* ===== Session Manager Styling ===== */
600 605
601 /* Session manager outer container - for positioning in overlay */606 /* Session manager outer container - for positioning in overlay */
A internal/ui/theme/tab_picker_css.go +81 -0
@@ -0,0 +1,81 @@
1+package theme
2+
3+// generateTabPickerCSS creates tab picker modal styles.
4+// Uses similar styling conventions as Omnibox/SessionManager.
5+func generateTabPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Tab Picker Styling ===== */
8+
9+.tab-picker-outer {
10+ /* Positioning handled in Go */
11+}
12+
13+.tab-picker-container {
14+ background-color: var(--surface-variant);
15+ border: 0.0625em solid var(--border);
16+ border-radius: 0.1875em;
17+ padding: 0;
18+ min-width: 28em;
19+}
20+
21+.tab-picker-header {
22+ background-color: shade(var(--surface-variant), 1.1);
23+ border-bottom: 0.0625em solid var(--border);
24+ padding: 0.5em 0.75em;
25+}
26+
27+.tab-picker-title {
28+ font-size: 0.9375em;
29+ font-weight: 600;
30+ color: var(--text);
31+}
32+
33+.tab-picker-scrolled {
34+ background-color: shade(var(--surface-variant), 0.95);
35+}
36+
37+.tab-picker-list {
38+ background-color: transparent;
39+}
40+
41+.tab-picker-row {
42+ padding: 0.5em 0.75em;
43+ margin: 0;
44+ border-radius: 0;
45+ border-left: 0.1875em solid transparent;
46+ border-bottom: 0.0625em solid alpha(var(--border), 0.5);
47+ transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
48+ min-height: 2.75em;
49+}
50+
51+.tab-picker-row:last-child {
52+ border-bottom: none;
53+}
54+
55+.tab-picker-row:hover {
56+ background-color: alpha(var(--accent), 0.12);
57+ border-left: 0.1875em solid var(--accent);
58+}
59+
60+.tab-picker-row:selected,
61+row:selected .tab-picker-row {
62+ background-color: alpha(var(--accent), 0.2);
63+ border-left: 0.1875em solid var(--accent);
64+}
65+
66+.tab-picker-row-title {
67+ color: var(--text);
68+ font-size: 0.875em;
69+ font-weight: 400;
70+}
71+
72+.tab-picker-footer {
73+ background-color: shade(var(--surface-variant), 0.9);
74+ border-top: 0.0625em solid var(--border);
75+ padding: 0.375em 0.75em;
76+ font-size: 0.6875em;
77+ color: var(--muted);
78+ font-family: var(--font-mono);
79+}
80+`
81+}
@@ -0,0 +1,81 @@
1+package theme
2+
3+// generateTabPickerCSS creates tab picker modal styles.
4+// Uses similar styling conventions as Omnibox/SessionManager.
5+func generateTabPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Tab Picker Styling ===== */
8+
9+.tab-picker-outer {
10+ /* Positioning handled in Go */
11+}
12+
13+.tab-picker-container {
14+ background-color: var(--surface-variant);
15+ border: 0.0625em solid var(--border);
16+ border-radius: 0.1875em;
17+ padding: 0;
18+ min-width: 28em;
19+}
20+
21+.tab-picker-header {
22+ background-color: shade(var(--surface-variant), 1.1);
23+ border-bottom: 0.0625em solid var(--border);
24+ padding: 0.5em 0.75em;
25+}
26+
27+.tab-picker-title {
28+ font-size: 0.9375em;
29+ font-weight: 600;
30+ color: var(--text);
31+}
32+
33+.tab-picker-scrolled {
34+ background-color: shade(var(--surface-variant), 0.95);
35+}
36+
37+.tab-picker-list {
38+ background-color: transparent;
39+}
40+
41+.tab-picker-row {
42+ padding: 0.5em 0.75em;
43+ margin: 0;
44+ border-radius: 0;
45+ border-left: 0.1875em solid transparent;
46+ border-bottom: 0.0625em solid alpha(var(--border), 0.5);
47+ transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
48+ min-height: 2.75em;
49+}
50+
51+.tab-picker-row:last-child {
52+ border-bottom: none;
53+}
54+
55+.tab-picker-row:hover {
56+ background-color: alpha(var(--accent), 0.12);
57+ border-left: 0.1875em solid var(--accent);
58+}
59+
60+.tab-picker-row:selected,
61+row:selected .tab-picker-row {
62+ background-color: alpha(var(--accent), 0.2);
63+ border-left: 0.1875em solid var(--accent);
64+}
65+
66+.tab-picker-row-title {
67+ color: var(--text);
68+ font-size: 0.875em;
69+ font-weight: 400;
70+}
71+
72+.tab-picker-footer {
73+ background-color: shade(var(--surface-variant), 0.9);
74+ border-top: 0.0625em solid var(--border);
75+ padding: 0.375em 0.75em;
76+ font-size: 0.6875em;
77+ color: var(--muted);
78+ font-family: var(--font-mono);
79+}
80+`
81+}

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