detached f2d203b

fix: vertical splits from stacked panes now split around entire stack

commit
f2d203b7e3c0b3d384e8284885c6821f57cfdb7d
author
brice · · 2026-01-01 10:34 UTC
parent
c2f6690
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +393 -113
M internal/application/usecase/manage_panes.go +6 -7
@@ -112,15 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
112112 return nil, fmt.Errorf("target pane is required")
113113 }
114114
115- // If target is inside a stack and splitting left/right, split around the entire stack
115+ // If target is inside a stack, split around the entire stack
116116 // (i.e., create the new pane as a sibling to the stack, not inside it)
117+ // This applies to all directions - we don't want to nest split containers inside stacks.
117118 targetNode := input.TargetPane
118- if input.Direction == SplitLeft || input.Direction == SplitRight {
119- if targetNode.Parent != nil && targetNode.Parent.IsStacked {
120- // Promote to the stack container so split happens outside the stack
121- targetNode = targetNode.Parent
122- log.Debug().Msg("target is inside a stack, splitting around stack container")
123- }
119+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
120+ // Promote to the stack container so split happens outside the stack
121+ targetNode = targetNode.Parent
122+ log.Debug().Msg("target is inside a stack, splitting around stack container")
124123 }
125124
126125 // Create new pane
@@ -112,15 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
112 return nil, fmt.Errorf("target pane is required")112 return nil, fmt.Errorf("target pane is required")
113 }113 }
114 114
115- // If target is inside a stack and splitting left/right, split around the entire stack115+ // If target is inside a stack, split around the entire stack
116 // (i.e., create the new pane as a sibling to the stack, not inside it)116 // (i.e., create the new pane as a sibling to the stack, not inside it)
117+ // This applies to all directions - we don't want to nest split containers inside stacks.
117 targetNode := input.TargetPane118 targetNode := input.TargetPane
118- if input.Direction == SplitLeft || input.Direction == SplitRight {119+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
119- if targetNode.Parent != nil && targetNode.Parent.IsStacked {120+ // Promote to the stack container so split happens outside the stack
120- // Promote to the stack container so split happens outside the stack121+ targetNode = targetNode.Parent
121- targetNode = targetNode.Parent122+ log.Debug().Msg("target is inside a stack, splitting around stack container")
122- log.Debug().Msg("target is inside a stack, splitting around stack container")
123- }
124 }123 }
125 124
126 // Create new pane125 // Create new pane
M internal/ui/component/pane_view.go +12 -0
@@ -244,6 +244,18 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
244244 }
245245 }
246246
247+// CancelPendingHover cancels any pending hover focus timer.
248+// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
249+func (pv *PaneView) CancelPendingHover() {
250+ pv.mu.RLock()
251+ handler := pv.hoverHandler
252+ pv.mu.RUnlock()
253+
254+ if handler != nil {
255+ handler.Cancel()
256+ }
257+}
258+
247259 // Widget returns the underlying overlay widget for embedding in containers.
248260 func (pv *PaneView) Widget() layout.Widget {
249261 return pv.overlay
@@ -244,6 +244,18 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
244 }244 }
245 }245 }
246 246
247+// CancelPendingHover cancels any pending hover focus timer.
248+// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
249+func (pv *PaneView) CancelPendingHover() {
250+ pv.mu.RLock()
251+ handler := pv.hoverHandler
252+ pv.mu.RUnlock()
253+
254+ if handler != nil {
255+ handler.Cancel()
256+ }
257+}
258+
247 // Widget returns the underlying overlay widget for embedding in containers.259 // Widget returns the underlying overlay widget for embedding in containers.
248 func (pv *PaneView) Widget() layout.Widget {260 func (pv *PaneView) Widget() layout.Widget {
249 return pv.overlay261 return pv.overlay
M internal/ui/component/workspace_view.go +42 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "sync"
9+ "time"
910
1011 "github.com/bnema/dumber/internal/domain/entity"
1112 "github.com/bnema/dumber/internal/logging"
@@ -50,6 +51,10 @@ type WorkspaceView struct {
5051 onPaneFocused func(paneID entity.PaneID)
5152 onSplitRatioDragged func(nodeID string, ratio float64)
5253
54+ // Hover suppression for keyboard navigation (Issue #89)
55+ // Prevents hover focus from overriding keyboard-initiated focus changes
56+ hoverSuppressedUntil time.Time
57+
5358 mu sync.RWMutex
5459 }
5560
@@ -86,6 +91,11 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
8691
8792 // Set up hover callback for focus-follows-mouse
8893 pv.SetOnHover(func(paneID entity.PaneID) {
94+ // Skip if hover is suppressed (keyboard navigation just happened)
95+ if a.wv.IsHoverSuppressed() {
96+ return
97+ }
98+
8999 // Skip if this pane is already active
90100 if a.wv.GetActivePaneID() == paneID {
91101 return
@@ -755,3 +765,35 @@ func (wv *WorkspaceView) GetActivePaneView() *PaneView {
755765 defer wv.mu.RUnlock()
756766 return wv.paneViews[wv.getActivePaneIDInternal()]
757767 }
768+
769+// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
770+const KeyboardFocusSuppressDuration = 300 * time.Millisecond
771+
772+// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
773+// This prevents the race condition where a pending hover timer overrides
774+// an explicit keyboard focus change (Issue #89).
775+func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
776+ wv.mu.Lock()
777+ wv.hoverSuppressedUntil = time.Now().Add(duration)
778+ wv.mu.Unlock()
779+}
780+
781+// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
782+func (wv *WorkspaceView) IsHoverSuppressed() bool {
783+ wv.mu.RLock()
784+ defer wv.mu.RUnlock()
785+ return time.Now().Before(wv.hoverSuppressedUntil)
786+}
787+
788+// CancelAllPendingHovers cancels any pending hover timers on all pane views.
789+// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
790+func (wv *WorkspaceView) CancelAllPendingHovers() {
791+ wv.mu.RLock()
792+ defer wv.mu.RUnlock()
793+
794+ for _, pv := range wv.paneViews {
795+ if pv != nil {
796+ pv.CancelPendingHover()
797+ }
798+ }
799+}
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "sync"8 "sync"
9+ "time"
9 10
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
@@ -50,6 +51,10 @@ type WorkspaceView struct {
50 onPaneFocused func(paneID entity.PaneID)51 onPaneFocused func(paneID entity.PaneID)
51 onSplitRatioDragged func(nodeID string, ratio float64)52 onSplitRatioDragged func(nodeID string, ratio float64)
52 53
54+ // Hover suppression for keyboard navigation (Issue #89)
55+ // Prevents hover focus from overriding keyboard-initiated focus changes
56+ hoverSuppressedUntil time.Time
57+
53 mu sync.RWMutex58 mu sync.RWMutex
54 }59 }
55 60
@@ -86,6 +91,11 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
86 91
87 // Set up hover callback for focus-follows-mouse92 // Set up hover callback for focus-follows-mouse
88 pv.SetOnHover(func(paneID entity.PaneID) {93 pv.SetOnHover(func(paneID entity.PaneID) {
94+ // Skip if hover is suppressed (keyboard navigation just happened)
95+ if a.wv.IsHoverSuppressed() {
96+ return
97+ }
98+
89 // Skip if this pane is already active99 // Skip if this pane is already active
90 if a.wv.GetActivePaneID() == paneID {100 if a.wv.GetActivePaneID() == paneID {
91 return101 return
@@ -755,3 +765,35 @@ func (wv *WorkspaceView) GetActivePaneView() *PaneView {
755 defer wv.mu.RUnlock()765 defer wv.mu.RUnlock()
756 return wv.paneViews[wv.getActivePaneIDInternal()]766 return wv.paneViews[wv.getActivePaneIDInternal()]
757 }767 }
768+
769+// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
770+const KeyboardFocusSuppressDuration = 300 * time.Millisecond
771+
772+// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
773+// This prevents the race condition where a pending hover timer overrides
774+// an explicit keyboard focus change (Issue #89).
775+func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
776+ wv.mu.Lock()
777+ wv.hoverSuppressedUntil = time.Now().Add(duration)
778+ wv.mu.Unlock()
779+}
780+
781+// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
782+func (wv *WorkspaceView) IsHoverSuppressed() bool {
783+ wv.mu.RLock()
784+ defer wv.mu.RUnlock()
785+ return time.Now().Before(wv.hoverSuppressedUntil)
786+}
787+
788+// CancelAllPendingHovers cancels any pending hover timers on all pane views.
789+// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
790+func (wv *WorkspaceView) CancelAllPendingHovers() {
791+ wv.mu.RLock()
792+ defer wv.mu.RUnlock()
793+
794+ for _, pv := range wv.paneViews {
795+ if pv != nil {
796+ pv.CancelPendingHover()
797+ }
798+ }
799+}
M internal/ui/component/workspace_view_test.go +14 -0
@@ -129,6 +129,7 @@ func setupStackedLeafMocks(
129129 mockFavicon := mocks.NewMockImageWidget(t)
130130 mockLabel := mocks.NewMockLabelWidget(t)
131131 mockButton := mocks.NewMockButtonWidget(t)
132+ mockCloseButton := mocks.NewMockButtonWidget(t)
132133
133134 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()
134135 mockStackBox.EXPECT().SetHexpand(true).Once()
@@ -150,13 +151,26 @@ func setupStackedLeafMocks(
150151 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
151152 mockTitleBar.EXPECT().Append(mockLabel).Once()
152153
154+ // Close button (uses SetIconName directly instead of child image)
155+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
156+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
157+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
158+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
159+ mockCloseButton.EXPECT().SetVexpand(false).Once()
160+ mockCloseButton.EXPECT().SetHexpand(false).Once()
161+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
162+
163+ // Title bar button
153164 mockFactory.EXPECT().NewButton().Return(mockButton).Once()
154165 mockButton.EXPECT().SetChild(mockTitleBar).Once()
155166 mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
156167 mockButton.EXPECT().SetFocusOnClick(false).Once()
157168 mockButton.EXPECT().SetVexpand(false).Once()
158169 mockButton.EXPECT().SetHexpand(true).Once()
170+
171+ // Click handlers
159172 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
173+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
160174
161175 mockStackBox.EXPECT().Append(mockButton).Once()
162176 mockStackBox.EXPECT().Append(container).Once()
@@ -129,6 +129,7 @@ func setupStackedLeafMocks(
129 mockFavicon := mocks.NewMockImageWidget(t)129 mockFavicon := mocks.NewMockImageWidget(t)
130 mockLabel := mocks.NewMockLabelWidget(t)130 mockLabel := mocks.NewMockLabelWidget(t)
131 mockButton := mocks.NewMockButtonWidget(t)131 mockButton := mocks.NewMockButtonWidget(t)
132+ mockCloseButton := mocks.NewMockButtonWidget(t)
132 133
133 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()134 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()
134 mockStackBox.EXPECT().SetHexpand(true).Once()135 mockStackBox.EXPECT().SetHexpand(true).Once()
@@ -150,13 +151,26 @@ func setupStackedLeafMocks(
150 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()151 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
151 mockTitleBar.EXPECT().Append(mockLabel).Once()152 mockTitleBar.EXPECT().Append(mockLabel).Once()
152 153
154+ // Close button (uses SetIconName directly instead of child image)
155+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
156+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
157+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
158+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
159+ mockCloseButton.EXPECT().SetVexpand(false).Once()
160+ mockCloseButton.EXPECT().SetHexpand(false).Once()
161+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
162+
163+ // Title bar button
153 mockFactory.EXPECT().NewButton().Return(mockButton).Once()164 mockFactory.EXPECT().NewButton().Return(mockButton).Once()
154 mockButton.EXPECT().SetChild(mockTitleBar).Once()165 mockButton.EXPECT().SetChild(mockTitleBar).Once()
155 mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()166 mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
156 mockButton.EXPECT().SetFocusOnClick(false).Once()167 mockButton.EXPECT().SetFocusOnClick(false).Once()
157 mockButton.EXPECT().SetVexpand(false).Once()168 mockButton.EXPECT().SetVexpand(false).Once()
158 mockButton.EXPECT().SetHexpand(true).Once()169 mockButton.EXPECT().SetHexpand(true).Once()
170+
171+ // Click handlers
159 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()172 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
173+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
160 174
161 mockStackBox.EXPECT().Append(mockButton).Once()175 mockStackBox.EXPECT().Append(mockButton).Once()
162 mockStackBox.EXPECT().Append(container).Once()176 mockStackBox.EXPECT().Append(container).Once()
M internal/ui/coordinator/workspace.go +45 -16
@@ -179,12 +179,14 @@ func (c *WorkspaceCoordinator) prepareSplit(
179179 return nil, false
180180 }
181181
182- isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
183- activePane.Parent != nil && activePane.Parent.IsStacked
182+ // Check if active pane is inside a stack
183+ isInStack := activePane.Parent != nil && activePane.Parent.IsStacked
184+ // isStackSplit is only true for horizontal splits - these use a special code path
185+ isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack
184186
185187 var existingWidget layout.Widget
186188 if wsView != nil {
187- existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit)
189+ existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack)
188190 }
189191
190192 return &splitContext{
@@ -199,27 +201,29 @@ func (c *WorkspaceCoordinator) prepareSplit(
199201 func (c *WorkspaceCoordinator) resolveSplitWidget(
200202 wsView *component.WorkspaceView,
201203 activePane *entity.PaneNode,
202- isStackSplit bool,
204+ isInStack bool,
203205 ) layout.Widget {
204206 if wsView == nil {
205207 return nil
206208 }
207209
208- if !isStackSplit {
209- return wsView.GetRootWidget()
210- }
210+ // When splitting from inside a stack (any direction), we need the stack widget
211+ // because the domain model promotes the split to happen around the stack container
212+ if isInStack {
213+ tr := wsView.TreeRenderer()
214+ if tr == nil {
215+ return nil
216+ }
211217
212- tr := wsView.TreeRenderer()
213- if tr == nil {
214- return nil
215- }
218+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
219+ if stackedView == nil {
220+ return nil
221+ }
216222
217- stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
218- if stackedView == nil {
219- return nil
223+ return stackedView.Widget()
220224 }
221225
222- return stackedView.Widget()
226+ return wsView.GetRootWidget()
223227 }
224228
225229 func (c *WorkspaceCoordinator) applySplitToView(
@@ -1161,6 +1165,10 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.
11611165 return nil
11621166 }
11631167
1168+ // Cancel pending hover timers and suppress future hover focus (Issue #89)
1169+ wsView.CancelAllPendingHovers()
1170+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1171+
11641172 // Update the workspace view's active pane
11651173 if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
11661174 log.Warn().Err(err).Msg("failed to update active pane in view")
@@ -1277,7 +1285,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12771285 stackCtx.wsView.ShowOmnibox(ctx, "")
12781286 }
12791287
1280- // Set up title bar click callback
1288+ // Set up title bar click and close callbacks
12811289 tr := stackCtx.wsView.TreeRenderer()
12821290 if tr != nil {
12831291 stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
@@ -1286,6 +1294,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12861294 stackedView.SetOnActivate(func(index int) {
12871295 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
12881296 })
1297+ stackedView.SetOnClosePane(func(paneID string) {
1298+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1299+ })
12891300 }
12901301 }
12911302
@@ -1531,6 +1542,10 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
15311542 // Update workspace view (also updates domain model)
15321543 _, wsView := c.getActiveWS()
15331544 if wsView != nil {
1545+ // Cancel pending hover timers and suppress future hover focus (Issue #89)
1546+ wsView.CancelAllPendingHovers()
1547+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1548+
15341549 if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
15351550 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
15361551 }
@@ -1543,6 +1558,17 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
15431558 Msg("switched active pane via title bar click")
15441559 }
15451560
1561+// onStackedPaneClose handles close button clicks on stacked pane title bars.
1562+func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) {
1563+ log := logging.FromContext(ctx)
1564+
1565+ log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button")
1566+
1567+ if err := c.ClosePaneByID(ctx, paneID); err != nil {
1568+ log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane")
1569+ }
1570+}
1571+
15461572 // --- Popup Insertion ---
15471573
15481574 // SetOnCreatePopupTab sets the callback for creating popup tabs.
@@ -1836,6 +1862,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
18361862 stackedView.SetOnActivate(func(index int) {
18371863 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
18381864 })
1865+ stackedView.SetOnClosePane(func(paneID string) {
1866+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1867+ })
18391868 }
18401869 }
18411870 return nil
@@ -179,12 +179,14 @@ func (c *WorkspaceCoordinator) prepareSplit(
179 return nil, false179 return nil, false
180 }180 }
181 181
182- isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&182+ // Check if active pane is inside a stack
183- activePane.Parent != nil && activePane.Parent.IsStacked183+ isInStack := activePane.Parent != nil && activePane.Parent.IsStacked
184+ // isStackSplit is only true for horizontal splits - these use a special code path
185+ isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack
184 186
185 var existingWidget layout.Widget187 var existingWidget layout.Widget
186 if wsView != nil {188 if wsView != nil {
187- existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit)189+ existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack)
188 }190 }
189 191
190 return &splitContext{192 return &splitContext{
@@ -199,27 +201,29 @@ func (c *WorkspaceCoordinator) prepareSplit(
199 func (c *WorkspaceCoordinator) resolveSplitWidget(201 func (c *WorkspaceCoordinator) resolveSplitWidget(
200 wsView *component.WorkspaceView,202 wsView *component.WorkspaceView,
201 activePane *entity.PaneNode,203 activePane *entity.PaneNode,
202- isStackSplit bool,204+ isInStack bool,
203 ) layout.Widget {205 ) layout.Widget {
204 if wsView == nil {206 if wsView == nil {
205 return nil207 return nil
206 }208 }
207 209
208- if !isStackSplit {210+ // When splitting from inside a stack (any direction), we need the stack widget
209- return wsView.GetRootWidget()211+ // because the domain model promotes the split to happen around the stack container
210- }212+ if isInStack {
213+ tr := wsView.TreeRenderer()
214+ if tr == nil {
215+ return nil
216+ }
211 217
212- tr := wsView.TreeRenderer()218+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
213- if tr == nil {219+ if stackedView == nil {
214- return nil220+ return nil
215- }221+ }
216 222
217- stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))223+ return stackedView.Widget()
218- if stackedView == nil {
219- return nil
220 }224 }
221 225
222- return stackedView.Widget()226+ return wsView.GetRootWidget()
223 }227 }
224 228
225 func (c *WorkspaceCoordinator) applySplitToView(229 func (c *WorkspaceCoordinator) applySplitToView(
@@ -1161,6 +1165,10 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.
1161 return nil1165 return nil
1162 }1166 }
1163 1167
1168+ // Cancel pending hover timers and suppress future hover focus (Issue #89)
1169+ wsView.CancelAllPendingHovers()
1170+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1171+
1164 // Update the workspace view's active pane1172 // Update the workspace view's active pane
1165 if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {1173 if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
1166 log.Warn().Err(err).Msg("failed to update active pane in view")1174 log.Warn().Err(err).Msg("failed to update active pane in view")
@@ -1277,7 +1285,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1277 stackCtx.wsView.ShowOmnibox(ctx, "")1285 stackCtx.wsView.ShowOmnibox(ctx, "")
1278 }1286 }
1279 1287
1280- // Set up title bar click callback1288+ // Set up title bar click and close callbacks
1281 tr := stackCtx.wsView.TreeRenderer()1289 tr := stackCtx.wsView.TreeRenderer()
1282 if tr != nil {1290 if tr != nil {
1283 stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))1291 stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
@@ -1286,6 +1294,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1286 stackedView.SetOnActivate(func(index int) {1294 stackedView.SetOnActivate(func(index int) {
1287 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)1295 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1288 })1296 })
1297+ stackedView.SetOnClosePane(func(paneID string) {
1298+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1299+ })
1289 }1300 }
1290 }1301 }
1291 1302
@@ -1531,6 +1542,10 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
1531 // Update workspace view (also updates domain model)1542 // Update workspace view (also updates domain model)
1532 _, wsView := c.getActiveWS()1543 _, wsView := c.getActiveWS()
1533 if wsView != nil {1544 if wsView != nil {
1545+ // Cancel pending hover timers and suppress future hover focus (Issue #89)
1546+ wsView.CancelAllPendingHovers()
1547+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1548+
1534 if err := wsView.SetActivePaneID(clickedPaneID); err != nil {1549 if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1535 log.Warn().Err(err).Msg("failed to set active pane in workspace view")1550 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1536 }1551 }
@@ -1543,6 +1558,17 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
1543 Msg("switched active pane via title bar click")1558 Msg("switched active pane via title bar click")
1544 }1559 }
1545 1560
1561+// onStackedPaneClose handles close button clicks on stacked pane title bars.
1562+func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) {
1563+ log := logging.FromContext(ctx)
1564+
1565+ log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button")
1566+
1567+ if err := c.ClosePaneByID(ctx, paneID); err != nil {
1568+ log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane")
1569+ }
1570+}
1571+
1546 // --- Popup Insertion ---1572 // --- Popup Insertion ---
1547 1573
1548 // SetOnCreatePopupTab sets the callback for creating popup tabs.1574 // SetOnCreatePopupTab sets the callback for creating popup tabs.
@@ -1836,6 +1862,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
1836 stackedView.SetOnActivate(func(index int) {1862 stackedView.SetOnActivate(func(index int) {
1837 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)1863 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1838 })1864 })
1865+ stackedView.SetOnClosePane(func(paneID string) {
1866+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1867+ })
1839 }1868 }
1840 }1869 }
1841 return nil1870 return nil
M internal/ui/focus/manager.go +41 -5
@@ -38,16 +38,24 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
3838 // For panes in stacks, uses the stack container's geometry since individual
3939 // collapsed panes have no allocated size.
4040 // Only visible panes are included - for stacked panes, only the active one is visible.
41-func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {
41+func (m *Manager) CollectPaneRects(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect {
42+ log := logging.FromContext(ctx)
4243 var rects []entity.PaneRect
4344
4445 container := provider.ContainerWidget()
4546
46- for _, paneID := range provider.GetPaneIDs() {
47+ allPaneIDs := provider.GetPaneIDs()
48+ log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting")
49+
50+ for _, paneID := range allPaneIDs {
4751 paneWidget := provider.GetPaneWidget(paneID)
4852
4953 // Skip panes that aren't visible (includes inactive stacked panes)
5054 if paneWidget == nil || !paneWidget.IsVisible() {
55+ log.Debug().
56+ Str("pane_id", string(paneID)).
57+ Bool("widget_nil", paneWidget == nil).
58+ Msg("CollectPaneRects: skipping invisible pane")
5159 continue
5260 }
5361
@@ -77,6 +85,14 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR
7785 continue
7886 }
7987
88+ log.Debug().
89+ Str("pane_id", string(paneID)).
90+ Int("x", int(x)).
91+ Int("y", int(y)).
92+ Int("w", w).
93+ Int("h", h).
94+ Msg("CollectPaneRects: added visible pane")
95+
8096 rects = append(rects, entity.PaneRect{
8197 PaneID: paneID,
8298 X: int(x),
@@ -86,6 +102,8 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR
86102 })
87103 }
88104
105+ log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done")
106+
89107 return rects
90108 }
91109
@@ -128,7 +146,7 @@ func (m *Manager) NavigateGeometric(
128146 }
129147
130148 // Collect geometry from visible panes
131- rects := m.CollectPaneRects(provider)
149+ rects := m.CollectPaneRects(ctx, provider)
132150 if len(rects) == 0 {
133151 return nil, nil
134152 }
@@ -149,8 +167,26 @@ func (m *Manager) NavigateGeometric(
149167 // Update workspace
150168 ws.ActivePaneID = output.TargetPaneID
151169
152- // Return target node
153- return ws.FindPane(output.TargetPaneID), nil
170+ // Find target node and update stack index if target is in a stack
171+ targetNode := ws.FindPane(output.TargetPaneID)
172+ if targetNode != nil && targetNode.Parent != nil && targetNode.Parent.IsStacked {
173+ // Update the stack's ActiveStackIndex to match the target pane
174+ oldIndex := targetNode.Parent.ActiveStackIndex
175+ for i, child := range targetNode.Parent.Children {
176+ if child == targetNode {
177+ targetNode.Parent.ActiveStackIndex = i
178+ log.Debug().
179+ Str("target_pane_id", string(output.TargetPaneID)).
180+ Int("old_stack_index", oldIndex).
181+ Int("new_stack_index", i).
182+ Int("num_children", len(targetNode.Parent.Children)).
183+ Msg("updated stack index for geometric nav into stack")
184+ break
185+ }
186+ }
187+ }
188+
189+ return targetNode, nil
154190 }
155191
156192 // navigateWithinStack tries to navigate within a stack.
@@ -38,16 +38,24 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
38 // For panes in stacks, uses the stack container's geometry since individual38 // For panes in stacks, uses the stack container's geometry since individual
39 // collapsed panes have no allocated size.39 // collapsed panes have no allocated size.
40 // Only visible panes are included - for stacked panes, only the active one is visible.40 // Only visible panes are included - for stacked panes, only the active one is visible.
41-func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {41+func (m *Manager) CollectPaneRects(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect {
42+ log := logging.FromContext(ctx)
42 var rects []entity.PaneRect43 var rects []entity.PaneRect
43 44
44 container := provider.ContainerWidget()45 container := provider.ContainerWidget()
45 46
46- for _, paneID := range provider.GetPaneIDs() {47+ allPaneIDs := provider.GetPaneIDs()
48+ log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting")
49+
50+ for _, paneID := range allPaneIDs {
47 paneWidget := provider.GetPaneWidget(paneID)51 paneWidget := provider.GetPaneWidget(paneID)
48 52
49 // Skip panes that aren't visible (includes inactive stacked panes)53 // Skip panes that aren't visible (includes inactive stacked panes)
50 if paneWidget == nil || !paneWidget.IsVisible() {54 if paneWidget == nil || !paneWidget.IsVisible() {
55+ log.Debug().
56+ Str("pane_id", string(paneID)).
57+ Bool("widget_nil", paneWidget == nil).
58+ Msg("CollectPaneRects: skipping invisible pane")
51 continue59 continue
52 }60 }
53 61
@@ -77,6 +85,14 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR
77 continue85 continue
78 }86 }
79 87
88+ log.Debug().
89+ Str("pane_id", string(paneID)).
90+ Int("x", int(x)).
91+ Int("y", int(y)).
92+ Int("w", w).
93+ Int("h", h).
94+ Msg("CollectPaneRects: added visible pane")
95+
80 rects = append(rects, entity.PaneRect{96 rects = append(rects, entity.PaneRect{
81 PaneID: paneID,97 PaneID: paneID,
82 X: int(x),98 X: int(x),
@@ -86,6 +102,8 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR
86 })102 })
87 }103 }
88 104
105+ log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done")
106+
89 return rects107 return rects
90 }108 }
91 109
@@ -128,7 +146,7 @@ func (m *Manager) NavigateGeometric(
128 }146 }
129 147
130 // Collect geometry from visible panes148 // Collect geometry from visible panes
131- rects := m.CollectPaneRects(provider)149+ rects := m.CollectPaneRects(ctx, provider)
132 if len(rects) == 0 {150 if len(rects) == 0 {
133 return nil, nil151 return nil, nil
134 }152 }
@@ -149,8 +167,26 @@ func (m *Manager) NavigateGeometric(
149 // Update workspace167 // Update workspace
150 ws.ActivePaneID = output.TargetPaneID168 ws.ActivePaneID = output.TargetPaneID
151 169
152- // Return target node170+ // Find target node and update stack index if target is in a stack
153- return ws.FindPane(output.TargetPaneID), nil171+ targetNode := ws.FindPane(output.TargetPaneID)
172+ if targetNode != nil && targetNode.Parent != nil && targetNode.Parent.IsStacked {
173+ // Update the stack's ActiveStackIndex to match the target pane
174+ oldIndex := targetNode.Parent.ActiveStackIndex
175+ for i, child := range targetNode.Parent.Children {
176+ if child == targetNode {
177+ targetNode.Parent.ActiveStackIndex = i
178+ log.Debug().
179+ Str("target_pane_id", string(output.TargetPaneID)).
180+ Int("old_stack_index", oldIndex).
181+ Int("new_stack_index", i).
182+ Int("num_children", len(targetNode.Parent.Children)).
183+ Msg("updated stack index for geometric nav into stack")
184+ break
185+ }
186+ }
187+ }
188+
189+ return targetNode, nil
154 }190 }
155 191
156 // navigateWithinStack tries to navigate within a stack.192 // navigateWithinStack tries to navigate within a stack.
M internal/ui/input/hover.go +6 -0
@@ -115,6 +115,12 @@ func (h *HoverHandler) handleEnter() {
115115
116116 // handleLeave cancels any pending focus switch.
117117 func (h *HoverHandler) handleLeave() {
118+ h.Cancel()
119+}
120+
121+// Cancel cancels any pending focus switch.
122+// This can be called externally to cancel hover when keyboard navigation occurs.
123+func (h *HoverHandler) Cancel() {
118124 h.timerMu.Lock()
119125 defer h.timerMu.Unlock()
120126
@@ -115,6 +115,12 @@ func (h *HoverHandler) handleEnter() {
115 115
116 // handleLeave cancels any pending focus switch.116 // handleLeave cancels any pending focus switch.
117 func (h *HoverHandler) handleLeave() {117 func (h *HoverHandler) handleLeave() {
118+ h.Cancel()
119+}
120+
121+// Cancel cancels any pending focus switch.
122+// This can be called externally to cancel hover when keyboard navigation occurs.
123+func (h *HoverHandler) Cancel() {
118 h.timerMu.Lock()124 h.timerMu.Lock()
119 defer h.timerMu.Unlock()125 defer h.timerMu.Unlock()
120 126
M internal/ui/layout/gtk_widgets.go +3 -2
@@ -519,8 +519,9 @@ func (btn *gtkButton) GetParent() Widget {
519519 return &gtkWidget{inner: parent}
520520 }
521521
522-func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) }
523-func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() }
522+func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) }
523+func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() }
524+func (btn *gtkButton) SetIconName(name string) { btn.inner.SetIconName(name) }
524525
525526 func (btn *gtkButton) SetChild(child Widget) {
526527 if child == nil {
@@ -519,8 +519,9 @@ func (btn *gtkButton) GetParent() Widget {
519 return &gtkWidget{inner: parent}519 return &gtkWidget{inner: parent}
520 }520 }
521 521
522-func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) }522+func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) }
523-func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() }523+func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() }
524+func (btn *gtkButton) SetIconName(name string) { btn.inner.SetIconName(name) }
524 525
525 func (btn *gtkButton) SetChild(child Widget) {526 func (btn *gtkButton) SetChild(child Widget) {
526 if child == nil {527 if child == nil {
M internal/ui/layout/mocks/mocks.go +40 -0
@@ -8951,6 +8951,46 @@ func (_c *MockButtonWidget_SetHexpand_Call) RunAndReturn(run func(expand bool))
89518951 return _c
89528952 }
89538953
8954+// SetIconName provides a mock function for the type MockButtonWidget
8955+func (_mock *MockButtonWidget) SetIconName(iconName string) {
8956+ _mock.Called(iconName)
8957+ return
8958+}
8959+
8960+// MockButtonWidget_SetIconName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetIconName'
8961+type MockButtonWidget_SetIconName_Call struct {
8962+ *mock.Call
8963+}
8964+
8965+// SetIconName is a helper method to define mock.On call
8966+// - iconName string
8967+func (_e *MockButtonWidget_Expecter) SetIconName(iconName interface{}) *MockButtonWidget_SetIconName_Call {
8968+ return &MockButtonWidget_SetIconName_Call{Call: _e.mock.On("SetIconName", iconName)}
8969+}
8970+
8971+func (_c *MockButtonWidget_SetIconName_Call) Run(run func(iconName string)) *MockButtonWidget_SetIconName_Call {
8972+ _c.Call.Run(func(args mock.Arguments) {
8973+ var arg0 string
8974+ if args[0] != nil {
8975+ arg0 = args[0].(string)
8976+ }
8977+ run(
8978+ arg0,
8979+ )
8980+ })
8981+ return _c
8982+}
8983+
8984+func (_c *MockButtonWidget_SetIconName_Call) Return() *MockButtonWidget_SetIconName_Call {
8985+ _c.Call.Return()
8986+ return _c
8987+}
8988+
8989+func (_c *MockButtonWidget_SetIconName_Call) RunAndReturn(run func(iconName string)) *MockButtonWidget_SetIconName_Call {
8990+ _c.Run(run)
8991+ return _c
8992+}
8993+
89548994 // SetLabel provides a mock function for the type MockButtonWidget
89558995 func (_mock *MockButtonWidget) SetLabel(label string) {
89568996 _mock.Called(label)
@@ -8951,6 +8951,46 @@ func (_c *MockButtonWidget_SetHexpand_Call) RunAndReturn(run func(expand bool))
8951 return _c8951 return _c
8952 }8952 }
8953 8953
8954+// SetIconName provides a mock function for the type MockButtonWidget
8955+func (_mock *MockButtonWidget) SetIconName(iconName string) {
8956+ _mock.Called(iconName)
8957+ return
8958+}
8959+
8960+// MockButtonWidget_SetIconName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetIconName'
8961+type MockButtonWidget_SetIconName_Call struct {
8962+ *mock.Call
8963+}
8964+
8965+// SetIconName is a helper method to define mock.On call
8966+// - iconName string
8967+func (_e *MockButtonWidget_Expecter) SetIconName(iconName interface{}) *MockButtonWidget_SetIconName_Call {
8968+ return &MockButtonWidget_SetIconName_Call{Call: _e.mock.On("SetIconName", iconName)}
8969+}
8970+
8971+func (_c *MockButtonWidget_SetIconName_Call) Run(run func(iconName string)) *MockButtonWidget_SetIconName_Call {
8972+ _c.Call.Run(func(args mock.Arguments) {
8973+ var arg0 string
8974+ if args[0] != nil {
8975+ arg0 = args[0].(string)
8976+ }
8977+ run(
8978+ arg0,
8979+ )
8980+ })
8981+ return _c
8982+}
8983+
8984+func (_c *MockButtonWidget_SetIconName_Call) Return() *MockButtonWidget_SetIconName_Call {
8985+ _c.Call.Return()
8986+ return _c
8987+}
8988+
8989+func (_c *MockButtonWidget_SetIconName_Call) RunAndReturn(run func(iconName string)) *MockButtonWidget_SetIconName_Call {
8990+ _c.Run(run)
8991+ return _c
8992+}
8993+
8954 // SetLabel provides a mock function for the type MockButtonWidget8994 // SetLabel provides a mock function for the type MockButtonWidget
8955 func (_mock *MockButtonWidget) SetLabel(label string) {8995 func (_mock *MockButtonWidget) SetLabel(label string) {
8956 _mock.Called(label)8996 _mock.Called(label)
M internal/ui/layout/stacked.go +120 -83
@@ -17,7 +17,10 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds")
1717 // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.
1818 var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")
1919
20-const stackedTitleMaxWidthChars = 30
20+const (
21+ stackedTitleMaxWidthChars = 30
22+ stackedPaneCloseIcon = "window-close-symbolic"
23+)
2124
2225 // stackedPane represents a single pane within a stacked container.
2326 type stackedPane struct {
@@ -38,7 +41,8 @@ type StackedView struct {
3841 panes []*stackedPane
3942 activeIndex int
4043
41- onActivate func(index int) // called when a pane is activated via title bar click
44+ onActivate func(index int) // called when a pane is activated via title bar click
45+ onClosePane func(paneID string) // called when a pane's close button is clicked
4246
4347 mu sync.RWMutex
4448 }
@@ -57,20 +61,18 @@ func NewStackedView(factory WidgetFactory) *StackedView {
5761 }
5862 }
5963
60-// AddPane adds a new pane to the stack.
61-// The new pane becomes active (visible).
62-func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
63- log := logging.FromContext(ctx)
64- sv.mu.Lock()
65- defer sv.mu.Unlock()
66-
67- log.Debug().
68- Str("title", title).
69- Bool("container_nil", container == nil).
70- Int("current_count", len(sv.panes)).
71- Msg("StackedView.AddPane called")
64+// titleBarComponents holds the widgets created for a pane's title bar.
65+type titleBarComponents struct {
66+ titleBar BoxWidget
67+ titleButton ButtonWidget
68+ closeBtn ButtonWidget
69+ favicon ImageWidget
70+ label LabelWidget
71+}
7272
73- // Create title bar - must not expand vertically
73+// createTitleBar creates the title bar widgets for a pane.
74+func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents {
75+ // Create title bar container - must not expand vertically
7476 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
7577 titleBar.AddCssClass("stacked-pane-titlebar")
7678 titleBar.SetVexpand(false)
@@ -93,6 +95,15 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
9395 label.SetXalign(0.0)
9496 titleBar.Append(label)
9597
98+ // Create close button using GTK's native icon button support
99+ closeBtn := sv.factory.NewButton()
100+ closeBtn.SetIconName(stackedPaneCloseIcon)
101+ closeBtn.AddCssClass("stacked-pane-close-button")
102+ closeBtn.SetFocusOnClick(false)
103+ closeBtn.SetVexpand(false)
104+ closeBtn.SetHexpand(false)
105+ titleBar.Append(closeBtn)
106+
96107 // Make title bar clickable - ensure it doesn't expand vertically
97108 titleButton := sv.factory.NewButton()
98109 titleButton.SetChild(titleBar)
@@ -101,35 +112,52 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
101112 titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically
102113 titleButton.SetHexpand(true) // But fill horizontal space
103114
115+ return titleBarComponents{
116+ titleBar: titleBar,
117+ titleButton: titleButton,
118+ closeBtn: closeBtn,
119+ favicon: favicon,
120+ label: label,
121+ }
122+}
123+
124+// AddPane adds a new pane to the stack.
125+// The new pane becomes active (visible).
126+func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
127+ log := logging.FromContext(ctx)
128+ sv.mu.Lock()
129+ defer sv.mu.Unlock()
130+
131+ log.Debug().
132+ Str("title", title).
133+ Bool("container_nil", container == nil).
134+ Int("current_count", len(sv.panes)).
135+ Msg("StackedView.AddPane called")
136+
137+ // Create title bar components
138+ tb := sv.createTitleBar(title, faviconIconName)
139+
104140 pane := &stackedPane{
105141 paneID: paneID,
106- titleBar: titleBar,
142+ titleBar: tb.titleBar,
107143 container: container,
108144 title: title,
109- favicon: favicon,
110- label: label,
145+ favicon: tb.favicon,
146+ label: tb.label,
111147 isActive: false,
112148 }
113149
114150 index := len(sv.panes)
115151 sv.panes = append(sv.panes, pane)
116152
117- // Connect click handler
118- titleButton.ConnectClicked(func() {
119- sv.mu.RLock()
120- callback := sv.onActivate
121- sv.mu.RUnlock()
122-
123- if callback != nil {
124- callback(index)
125- }
126- })
153+ // Connect click handlers using paneID (not index, to handle removals)
154+ sv.connectTitleBarHandlers(tb, paneID)
127155
128156 // Add to container
129157 log.Debug().
130158 Int("index", index).
131159 Msg("StackedView: appending titleButton and container to box")
132- sv.box.Append(titleButton)
160+ sv.box.Append(tb.titleButton)
133161 if container != nil {
134162 sv.box.Append(container)
135163 }
@@ -145,6 +173,33 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
145173 return index
146174 }
147175
176+// connectTitleBarHandlers connects the click handlers for a title bar.
177+// Uses paneID lookup instead of captured index to handle pane removals correctly.
178+func (sv *StackedView) connectTitleBarHandlers(tb titleBarComponents, paneID string) {
179+ // Connect title bar click handler for activation
180+ tb.titleButton.ConnectClicked(func() {
181+ sv.mu.RLock()
182+ callback := sv.onActivate
183+ currentIndex := sv.findPaneIndexInternal(paneID)
184+ sv.mu.RUnlock()
185+
186+ if callback != nil && currentIndex >= 0 {
187+ callback(currentIndex)
188+ }
189+ })
190+
191+ // Connect close button click handler
192+ tb.closeBtn.ConnectClicked(func() {
193+ sv.mu.RLock()
194+ onClose := sv.onClosePane
195+ sv.mu.RUnlock()
196+
197+ if onClose != nil {
198+ onClose(paneID)
199+ }
200+ })
201+}
202+
148203 // InsertPaneAfter inserts a new pane after the specified index position.
149204 // Use afterIndex=-1 to insert at the beginning.
150205 // The new pane becomes active (visible).
@@ -172,44 +227,16 @@ func (sv *StackedView) InsertPaneAfter(
172227 Int("current_count", len(sv.panes)).
173228 Msg("StackedView.InsertPaneAfter called")
174229
175- // Create title bar - must not expand vertically
176- titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
177- titleBar.AddCssClass("stacked-pane-titlebar")
178- titleBar.SetVexpand(false)
179-
180- // Create favicon image
181- favicon := sv.factory.NewImage()
182- if faviconIconName != "" {
183- favicon.SetFromIconName(faviconIconName)
184- } else {
185- favicon.SetFromIconName("web-browser-symbolic")
186- }
187- favicon.SetPixelSize(16)
188- titleBar.Append(favicon)
189-
190- // Create title label
191- label := sv.factory.NewLabel(title)
192- label.SetEllipsize(EllipsizeEnd)
193- label.SetMaxWidthChars(stackedTitleMaxWidthChars)
194- label.SetHexpand(true)
195- label.SetXalign(0.0)
196- titleBar.Append(label)
197-
198- // Make title bar clickable - ensure it doesn't expand vertically
199- titleButton := sv.factory.NewButton()
200- titleButton.SetChild(titleBar)
201- titleButton.AddCssClass("stacked-pane-title-button")
202- titleButton.SetFocusOnClick(false)
203- titleButton.SetVexpand(false)
204- titleButton.SetHexpand(true)
230+ // Create title bar components
231+ tb := sv.createTitleBar(title, faviconIconName)
205232
206233 pane := &stackedPane{
207234 paneID: paneID,
208- titleBar: titleBar,
235+ titleBar: tb.titleBar,
209236 container: container,
210237 title: title,
211- favicon: favicon,
212- label: label,
238+ favicon: tb.favicon,
239+ label: tb.label,
213240 isActive: false,
214241 }
215242
@@ -218,19 +245,25 @@ func (sv *StackedView) InsertPaneAfter(
218245 copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
219246 sv.panes[insertIndex] = pane
220247
221- // Connect click handler - use captured index
222- capturedIndex := insertIndex
223- titleButton.ConnectClicked(func() {
224- sv.mu.RLock()
225- callback := sv.onActivate
226- sv.mu.RUnlock()
227-
228- if callback != nil {
229- callback(capturedIndex)
230- }
231- })
248+ // Connect click handlers using paneID (not index, to handle removals)
249+ sv.connectTitleBarHandlers(tb, paneID)
232250
233251 // Insert widgets at correct position in GTK box
252+ sv.insertTitleBarWidgets(tb.titleButton, container, insertIndex)
253+
254+ // Set this pane as active
255+ sv.setActiveInternal(ctx, insertIndex)
256+
257+ log.Debug().
258+ Int("insert_index", insertIndex).
259+ Int("new_count", len(sv.panes)).
260+ Msg("StackedView.InsertPaneAfter completed")
261+
262+ return insertIndex
263+}
264+
265+// insertTitleBarWidgets inserts the title bar button and container at the correct position.
266+func (sv *StackedView) insertTitleBarWidgets(titleButton ButtonWidget, container Widget, insertIndex int) {
234267 if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
235268 // Insert after the previous pane's container
236269 prevPane := sv.panes[insertIndex-1]
@@ -262,16 +295,6 @@ func (sv *StackedView) InsertPaneAfter(
262295 }
263296 sv.box.Prepend(titleButton)
264297 }
265-
266- // Set this pane as active
267- sv.setActiveInternal(ctx, insertIndex)
268-
269- log.Debug().
270- Int("insert_index", insertIndex).
271- Int("new_count", len(sv.panes)).
272- Msg("StackedView.InsertPaneAfter completed")
273-
274- return insertIndex
275298 }
276299
277300 // RemovePane removes a pane from the stack by index.
@@ -429,6 +452,12 @@ func (sv *StackedView) FindPaneIndex(paneID string) int {
429452 sv.mu.RLock()
430453 defer sv.mu.RUnlock()
431454
455+ return sv.findPaneIndexInternal(paneID)
456+}
457+
458+// findPaneIndexInternal returns the index of a pane by ID.
459+// Caller must hold at least a read lock on sv.mu.
460+func (sv *StackedView) findPaneIndexInternal(paneID string) int {
432461 for i, pane := range sv.panes {
433462 if pane.paneID == paneID {
434463 return i
@@ -445,6 +474,14 @@ func (sv *StackedView) SetOnActivate(fn func(index int)) {
445474 sv.onActivate = fn
446475 }
447476
477+// SetOnClosePane sets the callback for when a pane's close button is clicked.
478+func (sv *StackedView) SetOnClosePane(fn func(paneID string)) {
479+ sv.mu.Lock()
480+ defer sv.mu.Unlock()
481+
482+ sv.onClosePane = fn
483+}
484+
448485 // UpdateTitle updates the title of a pane at the given index.
449486 func (sv *StackedView) UpdateTitle(index int, title string) error {
450487 sv.mu.Lock()
@@ -17,7 +17,10 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds")
17 // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.17 // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.
18 var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")18 var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")
19 19
20-const stackedTitleMaxWidthChars = 3020+const (
21+ stackedTitleMaxWidthChars = 30
22+ stackedPaneCloseIcon = "window-close-symbolic"
23+)
21 24
22 // stackedPane represents a single pane within a stacked container.25 // stackedPane represents a single pane within a stacked container.
23 type stackedPane struct {26 type stackedPane struct {
@@ -38,7 +41,8 @@ type StackedView struct {
38 panes []*stackedPane41 panes []*stackedPane
39 activeIndex int42 activeIndex int
40 43
41- onActivate func(index int) // called when a pane is activated via title bar click44+ onActivate func(index int) // called when a pane is activated via title bar click
45+ onClosePane func(paneID string) // called when a pane's close button is clicked
42 46
43 mu sync.RWMutex47 mu sync.RWMutex
44 }48 }
@@ -57,20 +61,18 @@ func NewStackedView(factory WidgetFactory) *StackedView {
57 }61 }
58 }62 }
59 63
60-// AddPane adds a new pane to the stack.64+// titleBarComponents holds the widgets created for a pane's title bar.
61-// The new pane becomes active (visible).65+type titleBarComponents struct {
62-func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {66+ titleBar BoxWidget
63- log := logging.FromContext(ctx)67+ titleButton ButtonWidget
64- sv.mu.Lock()68+ closeBtn ButtonWidget
65- defer sv.mu.Unlock()69+ favicon ImageWidget
66-70+ label LabelWidget
67- log.Debug().71+}
68- Str("title", title).
69- Bool("container_nil", container == nil).
70- Int("current_count", len(sv.panes)).
71- Msg("StackedView.AddPane called")
72 72
73- // Create title bar - must not expand vertically73+// createTitleBar creates the title bar widgets for a pane.
74+func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents {
75+ // Create title bar container - must not expand vertically
74 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)76 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
75 titleBar.AddCssClass("stacked-pane-titlebar")77 titleBar.AddCssClass("stacked-pane-titlebar")
76 titleBar.SetVexpand(false)78 titleBar.SetVexpand(false)
@@ -93,6 +95,15 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
93 label.SetXalign(0.0)95 label.SetXalign(0.0)
94 titleBar.Append(label)96 titleBar.Append(label)
95 97
98+ // Create close button using GTK's native icon button support
99+ closeBtn := sv.factory.NewButton()
100+ closeBtn.SetIconName(stackedPaneCloseIcon)
101+ closeBtn.AddCssClass("stacked-pane-close-button")
102+ closeBtn.SetFocusOnClick(false)
103+ closeBtn.SetVexpand(false)
104+ closeBtn.SetHexpand(false)
105+ titleBar.Append(closeBtn)
106+
96 // Make title bar clickable - ensure it doesn't expand vertically107 // Make title bar clickable - ensure it doesn't expand vertically
97 titleButton := sv.factory.NewButton()108 titleButton := sv.factory.NewButton()
98 titleButton.SetChild(titleBar)109 titleButton.SetChild(titleBar)
@@ -101,35 +112,52 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
101 titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically112 titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically
102 titleButton.SetHexpand(true) // But fill horizontal space113 titleButton.SetHexpand(true) // But fill horizontal space
103 114
115+ return titleBarComponents{
116+ titleBar: titleBar,
117+ titleButton: titleButton,
118+ closeBtn: closeBtn,
119+ favicon: favicon,
120+ label: label,
121+ }
122+}
123+
124+// AddPane adds a new pane to the stack.
125+// The new pane becomes active (visible).
126+func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
127+ log := logging.FromContext(ctx)
128+ sv.mu.Lock()
129+ defer sv.mu.Unlock()
130+
131+ log.Debug().
132+ Str("title", title).
133+ Bool("container_nil", container == nil).
134+ Int("current_count", len(sv.panes)).
135+ Msg("StackedView.AddPane called")
136+
137+ // Create title bar components
138+ tb := sv.createTitleBar(title, faviconIconName)
139+
104 pane := &stackedPane{140 pane := &stackedPane{
105 paneID: paneID,141 paneID: paneID,
106- titleBar: titleBar,142+ titleBar: tb.titleBar,
107 container: container,143 container: container,
108 title: title,144 title: title,
109- favicon: favicon,145+ favicon: tb.favicon,
110- label: label,146+ label: tb.label,
111 isActive: false,147 isActive: false,
112 }148 }
113 149
114 index := len(sv.panes)150 index := len(sv.panes)
115 sv.panes = append(sv.panes, pane)151 sv.panes = append(sv.panes, pane)
116 152
117- // Connect click handler153+ // Connect click handlers using paneID (not index, to handle removals)
118- titleButton.ConnectClicked(func() {154+ sv.connectTitleBarHandlers(tb, paneID)
119- sv.mu.RLock()
120- callback := sv.onActivate
121- sv.mu.RUnlock()
122-
123- if callback != nil {
124- callback(index)
125- }
126- })
127 155
128 // Add to container156 // Add to container
129 log.Debug().157 log.Debug().
130 Int("index", index).158 Int("index", index).
131 Msg("StackedView: appending titleButton and container to box")159 Msg("StackedView: appending titleButton and container to box")
132- sv.box.Append(titleButton)160+ sv.box.Append(tb.titleButton)
133 if container != nil {161 if container != nil {
134 sv.box.Append(container)162 sv.box.Append(container)
135 }163 }
@@ -145,6 +173,33 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
145 return index173 return index
146 }174 }
147 175
176+// connectTitleBarHandlers connects the click handlers for a title bar.
177+// Uses paneID lookup instead of captured index to handle pane removals correctly.
178+func (sv *StackedView) connectTitleBarHandlers(tb titleBarComponents, paneID string) {
179+ // Connect title bar click handler for activation
180+ tb.titleButton.ConnectClicked(func() {
181+ sv.mu.RLock()
182+ callback := sv.onActivate
183+ currentIndex := sv.findPaneIndexInternal(paneID)
184+ sv.mu.RUnlock()
185+
186+ if callback != nil && currentIndex >= 0 {
187+ callback(currentIndex)
188+ }
189+ })
190+
191+ // Connect close button click handler
192+ tb.closeBtn.ConnectClicked(func() {
193+ sv.mu.RLock()
194+ onClose := sv.onClosePane
195+ sv.mu.RUnlock()
196+
197+ if onClose != nil {
198+ onClose(paneID)
199+ }
200+ })
201+}
202+
148 // InsertPaneAfter inserts a new pane after the specified index position.203 // InsertPaneAfter inserts a new pane after the specified index position.
149 // Use afterIndex=-1 to insert at the beginning.204 // Use afterIndex=-1 to insert at the beginning.
150 // The new pane becomes active (visible).205 // The new pane becomes active (visible).
@@ -172,44 +227,16 @@ func (sv *StackedView) InsertPaneAfter(
172 Int("current_count", len(sv.panes)).227 Int("current_count", len(sv.panes)).
173 Msg("StackedView.InsertPaneAfter called")228 Msg("StackedView.InsertPaneAfter called")
174 229
175- // Create title bar - must not expand vertically230+ // Create title bar components
176- titleBar := sv.factory.NewBox(OrientationHorizontal, 4)231+ tb := sv.createTitleBar(title, faviconIconName)
177- titleBar.AddCssClass("stacked-pane-titlebar")
178- titleBar.SetVexpand(false)
179-
180- // Create favicon image
181- favicon := sv.factory.NewImage()
182- if faviconIconName != "" {
183- favicon.SetFromIconName(faviconIconName)
184- } else {
185- favicon.SetFromIconName("web-browser-symbolic")
186- }
187- favicon.SetPixelSize(16)
188- titleBar.Append(favicon)
189-
190- // Create title label
191- label := sv.factory.NewLabel(title)
192- label.SetEllipsize(EllipsizeEnd)
193- label.SetMaxWidthChars(stackedTitleMaxWidthChars)
194- label.SetHexpand(true)
195- label.SetXalign(0.0)
196- titleBar.Append(label)
197-
198- // Make title bar clickable - ensure it doesn't expand vertically
199- titleButton := sv.factory.NewButton()
200- titleButton.SetChild(titleBar)
201- titleButton.AddCssClass("stacked-pane-title-button")
202- titleButton.SetFocusOnClick(false)
203- titleButton.SetVexpand(false)
204- titleButton.SetHexpand(true)
205 232
206 pane := &stackedPane{233 pane := &stackedPane{
207 paneID: paneID,234 paneID: paneID,
208- titleBar: titleBar,235+ titleBar: tb.titleBar,
209 container: container,236 container: container,
210 title: title,237 title: title,
211- favicon: favicon,238+ favicon: tb.favicon,
212- label: label,239+ label: tb.label,
213 isActive: false,240 isActive: false,
214 }241 }
215 242
@@ -218,19 +245,25 @@ func (sv *StackedView) InsertPaneAfter(
218 copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])245 copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
219 sv.panes[insertIndex] = pane246 sv.panes[insertIndex] = pane
220 247
221- // Connect click handler - use captured index248+ // Connect click handlers using paneID (not index, to handle removals)
222- capturedIndex := insertIndex249+ sv.connectTitleBarHandlers(tb, paneID)
223- titleButton.ConnectClicked(func() {
224- sv.mu.RLock()
225- callback := sv.onActivate
226- sv.mu.RUnlock()
227-
228- if callback != nil {
229- callback(capturedIndex)
230- }
231- })
232 250
233 // Insert widgets at correct position in GTK box251 // Insert widgets at correct position in GTK box
252+ sv.insertTitleBarWidgets(tb.titleButton, container, insertIndex)
253+
254+ // Set this pane as active
255+ sv.setActiveInternal(ctx, insertIndex)
256+
257+ log.Debug().
258+ Int("insert_index", insertIndex).
259+ Int("new_count", len(sv.panes)).
260+ Msg("StackedView.InsertPaneAfter completed")
261+
262+ return insertIndex
263+}
264+
265+// insertTitleBarWidgets inserts the title bar button and container at the correct position.
266+func (sv *StackedView) insertTitleBarWidgets(titleButton ButtonWidget, container Widget, insertIndex int) {
234 if insertIndex > 0 && sv.panes[insertIndex-1] != nil {267 if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
235 // Insert after the previous pane's container268 // Insert after the previous pane's container
236 prevPane := sv.panes[insertIndex-1]269 prevPane := sv.panes[insertIndex-1]
@@ -262,16 +295,6 @@ func (sv *StackedView) InsertPaneAfter(
262 }295 }
263 sv.box.Prepend(titleButton)296 sv.box.Prepend(titleButton)
264 }297 }
265-
266- // Set this pane as active
267- sv.setActiveInternal(ctx, insertIndex)
268-
269- log.Debug().
270- Int("insert_index", insertIndex).
271- Int("new_count", len(sv.panes)).
272- Msg("StackedView.InsertPaneAfter completed")
273-
274- return insertIndex
275 }298 }
276 299
277 // RemovePane removes a pane from the stack by index.300 // RemovePane removes a pane from the stack by index.
@@ -429,6 +452,12 @@ func (sv *StackedView) FindPaneIndex(paneID string) int {
429 sv.mu.RLock()452 sv.mu.RLock()
430 defer sv.mu.RUnlock()453 defer sv.mu.RUnlock()
431 454
455+ return sv.findPaneIndexInternal(paneID)
456+}
457+
458+// findPaneIndexInternal returns the index of a pane by ID.
459+// Caller must hold at least a read lock on sv.mu.
460+func (sv *StackedView) findPaneIndexInternal(paneID string) int {
432 for i, pane := range sv.panes {461 for i, pane := range sv.panes {
433 if pane.paneID == paneID {462 if pane.paneID == paneID {
434 return i463 return i
@@ -445,6 +474,14 @@ func (sv *StackedView) SetOnActivate(fn func(index int)) {
445 sv.onActivate = fn474 sv.onActivate = fn
446 }475 }
447 476
477+// SetOnClosePane sets the callback for when a pane's close button is clicked.
478+func (sv *StackedView) SetOnClosePane(fn func(paneID string)) {
479+ sv.mu.Lock()
480+ defer sv.mu.Unlock()
481+
482+ sv.onClosePane = fn
483+}
484+
448 // UpdateTitle updates the title of a pane at the given index.485 // UpdateTitle updates the title of a pane at the given index.
449 func (sv *StackedView) UpdateTitle(index int, title string) error {486 func (sv *StackedView) UpdateTitle(index int, title string) error {
450 sv.mu.Lock()487 sv.mu.Lock()
M internal/ui/layout/stacked_test.go +26 -0
@@ -32,6 +32,7 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
3232 mockFavicon := mocks.NewMockImageWidget(t)
3333 mockLabel := mocks.NewMockLabelWidget(t)
3434 mockButton := mocks.NewMockButtonWidget(t)
35+ mockCloseButton := mocks.NewMockButtonWidget(t)
3536 mockContainer := mocks.NewMockWidget(t)
3637
3738 // Title bar creation
@@ -53,6 +54,15 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
5354 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
5455 mockTitleBar.EXPECT().Append(mockLabel).Once()
5556
57+ // Close button (uses SetIconName directly instead of child image)
58+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
59+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
60+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
61+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
62+ mockCloseButton.EXPECT().SetVexpand(false).Once()
63+ mockCloseButton.EXPECT().SetHexpand(false).Once()
64+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
65+
5666 // Button wrapping title bar
5767 mockFactory.EXPECT().NewButton().Return(mockButton).Once()
5868 mockButton.EXPECT().SetChild(mockTitleBar).Once()
@@ -60,7 +70,10 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
6070 mockButton.EXPECT().SetFocusOnClick(false).Once()
6171 mockButton.EXPECT().SetVexpand(false).Once()
6272 mockButton.EXPECT().SetHexpand(true).Once()
73+
74+ // Click handlers (title bar activation and close button)
6375 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
76+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
6477
6578 // Adding to main box
6679 mockBox.EXPECT().Append(mockButton).Once()
@@ -559,6 +572,7 @@ func setupInsertPaneMocks(
559572 mockFavicon := mocks.NewMockImageWidget(t)
560573 mockLabel := mocks.NewMockLabelWidget(t)
561574 mockButton := mocks.NewMockButtonWidget(t)
575+ mockCloseButton := mocks.NewMockButtonWidget(t)
562576 mockContainer := mocks.NewMockWidget(t)
563577
564578 // Title bar creation
@@ -580,6 +594,15 @@ func setupInsertPaneMocks(
580594 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
581595 mockTitleBar.EXPECT().Append(mockLabel).Once()
582596
597+ // Close button (uses SetIconName directly instead of child image)
598+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
599+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
600+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
601+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
602+ mockCloseButton.EXPECT().SetVexpand(false).Once()
603+ mockCloseButton.EXPECT().SetHexpand(false).Once()
604+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
605+
583606 // Button wrapping title bar
584607 mockFactory.EXPECT().NewButton().Return(mockButton).Once()
585608 mockButton.EXPECT().SetChild(mockTitleBar).Once()
@@ -587,7 +610,10 @@ func setupInsertPaneMocks(
587610 mockButton.EXPECT().SetFocusOnClick(false).Once()
588611 mockButton.EXPECT().SetVexpand(false).Once()
589612 mockButton.EXPECT().SetHexpand(true).Once()
613+
614+ // Click handlers (title bar activation and close button)
590615 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
616+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
591617
592618 // Position-aware insertion using InsertChildAfter
593619 if siblingContainer != nil {
@@ -32,6 +32,7 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
32 mockFavicon := mocks.NewMockImageWidget(t)32 mockFavicon := mocks.NewMockImageWidget(t)
33 mockLabel := mocks.NewMockLabelWidget(t)33 mockLabel := mocks.NewMockLabelWidget(t)
34 mockButton := mocks.NewMockButtonWidget(t)34 mockButton := mocks.NewMockButtonWidget(t)
35+ mockCloseButton := mocks.NewMockButtonWidget(t)
35 mockContainer := mocks.NewMockWidget(t)36 mockContainer := mocks.NewMockWidget(t)
36 37
37 // Title bar creation38 // Title bar creation
@@ -53,6 +54,15 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
53 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()54 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
54 mockTitleBar.EXPECT().Append(mockLabel).Once()55 mockTitleBar.EXPECT().Append(mockLabel).Once()
55 56
57+ // Close button (uses SetIconName directly instead of child image)
58+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
59+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
60+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
61+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
62+ mockCloseButton.EXPECT().SetVexpand(false).Once()
63+ mockCloseButton.EXPECT().SetHexpand(false).Once()
64+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
65+
56 // Button wrapping title bar66 // Button wrapping title bar
57 mockFactory.EXPECT().NewButton().Return(mockButton).Once()67 mockFactory.EXPECT().NewButton().Return(mockButton).Once()
58 mockButton.EXPECT().SetChild(mockTitleBar).Once()68 mockButton.EXPECT().SetChild(mockTitleBar).Once()
@@ -60,7 +70,10 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
60 mockButton.EXPECT().SetFocusOnClick(false).Once()70 mockButton.EXPECT().SetFocusOnClick(false).Once()
61 mockButton.EXPECT().SetVexpand(false).Once()71 mockButton.EXPECT().SetVexpand(false).Once()
62 mockButton.EXPECT().SetHexpand(true).Once()72 mockButton.EXPECT().SetHexpand(true).Once()
73+
74+ // Click handlers (title bar activation and close button)
63 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()75 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
76+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
64 77
65 // Adding to main box78 // Adding to main box
66 mockBox.EXPECT().Append(mockButton).Once()79 mockBox.EXPECT().Append(mockButton).Once()
@@ -559,6 +572,7 @@ func setupInsertPaneMocks(
559 mockFavicon := mocks.NewMockImageWidget(t)572 mockFavicon := mocks.NewMockImageWidget(t)
560 mockLabel := mocks.NewMockLabelWidget(t)573 mockLabel := mocks.NewMockLabelWidget(t)
561 mockButton := mocks.NewMockButtonWidget(t)574 mockButton := mocks.NewMockButtonWidget(t)
575+ mockCloseButton := mocks.NewMockButtonWidget(t)
562 mockContainer := mocks.NewMockWidget(t)576 mockContainer := mocks.NewMockWidget(t)
563 577
564 // Title bar creation578 // Title bar creation
@@ -580,6 +594,15 @@ func setupInsertPaneMocks(
580 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()594 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
581 mockTitleBar.EXPECT().Append(mockLabel).Once()595 mockTitleBar.EXPECT().Append(mockLabel).Once()
582 596
597+ // Close button (uses SetIconName directly instead of child image)
598+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
599+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
600+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
601+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
602+ mockCloseButton.EXPECT().SetVexpand(false).Once()
603+ mockCloseButton.EXPECT().SetHexpand(false).Once()
604+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
605+
583 // Button wrapping title bar606 // Button wrapping title bar
584 mockFactory.EXPECT().NewButton().Return(mockButton).Once()607 mockFactory.EXPECT().NewButton().Return(mockButton).Once()
585 mockButton.EXPECT().SetChild(mockTitleBar).Once()608 mockButton.EXPECT().SetChild(mockTitleBar).Once()
@@ -587,7 +610,10 @@ func setupInsertPaneMocks(
587 mockButton.EXPECT().SetFocusOnClick(false).Once()610 mockButton.EXPECT().SetFocusOnClick(false).Once()
588 mockButton.EXPECT().SetVexpand(false).Once()611 mockButton.EXPECT().SetVexpand(false).Once()
589 mockButton.EXPECT().SetHexpand(true).Once()612 mockButton.EXPECT().SetHexpand(true).Once()
613+
614+ // Click handlers (title bar activation and close button)
590 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()615 mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
616+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
591 617
592 // Position-aware insertion using InsertChildAfter618 // Position-aware insertion using InsertChildAfter
593 if siblingContainer != nil {619 if siblingContainer != nil {
M internal/ui/layout/widgets.go +1 -0
@@ -179,6 +179,7 @@ type ButtonWidget interface {
179179 GetLabel() string
180180 SetChild(child Widget)
181181 GetChild() Widget
182+ SetIconName(iconName string)
182183
183184 // Connect click handler, returns signal ID for disconnection
184185 ConnectClicked(callback func()) uint32
@@ -179,6 +179,7 @@ type ButtonWidget interface {
179 GetLabel() string179 GetLabel() string
180 SetChild(child Widget)180 SetChild(child Widget)
181 GetChild() Widget181 GetChild() Widget
182+ SetIconName(iconName string)
182 183
183 // Connect click handler, returns signal ID for disconnection184 // Connect click handler, returns signal ID for disconnection
184 ConnectClicked(callback func()) uint32185 ConnectClicked(callback func()) uint32
M internal/ui/theme/css.go +37 -0
@@ -623,6 +623,43 @@ button.stacked-pane-title-button:focus {
623623 button.stacked-pane-title-button:hover .stacked-pane-titlebar label {
624624 color: var(--accent);
625625 }
626+
627+/* Close button in stacked pane title bar */
628+.stacked-pane-close-button {
629+ background: transparent;
630+ border: none;
631+ border-radius: 4px;
632+ padding: 4px;
633+ margin: 0 2px;
634+ min-width: 20px;
635+ min-height: 20px;
636+ opacity: 0.4;
637+ transition: opacity 100ms ease-in-out, background-color 100ms ease-in-out;
638+}
639+
640+.stacked-pane-close-button > image {
641+ margin: 0;
642+ padding: 0;
643+}
644+
645+.stacked-pane-close-button:hover {
646+ opacity: 1.0;
647+ background: alpha(var(--destructive), 0.2);
648+}
649+
650+.stacked-pane-close-button:hover > image {
651+ color: var(--destructive);
652+}
653+
654+/* Show close button more prominently when hovering title bar */
655+button.stacked-pane-title-button:hover .stacked-pane-close-button {
656+ opacity: 0.7;
657+}
658+
659+.stacked-pane-close-button:focus {
660+ outline: none;
661+ box-shadow: none;
662+}
626663 `
627664 }
628665
@@ -623,6 +623,43 @@ button.stacked-pane-title-button:focus {
623 button.stacked-pane-title-button:hover .stacked-pane-titlebar label {623 button.stacked-pane-title-button:hover .stacked-pane-titlebar label {
624 color: var(--accent);624 color: var(--accent);
625 }625 }
626+
627+/* Close button in stacked pane title bar */
628+.stacked-pane-close-button {
629+ background: transparent;
630+ border: none;
631+ border-radius: 4px;
632+ padding: 4px;
633+ margin: 0 2px;
634+ min-width: 20px;
635+ min-height: 20px;
636+ opacity: 0.4;
637+ transition: opacity 100ms ease-in-out, background-color 100ms ease-in-out;
638+}
639+
640+.stacked-pane-close-button > image {
641+ margin: 0;
642+ padding: 0;
643+}
644+
645+.stacked-pane-close-button:hover {
646+ opacity: 1.0;
647+ background: alpha(var(--destructive), 0.2);
648+}
649+
650+.stacked-pane-close-button:hover > image {
651+ color: var(--destructive);
652+}
653+
654+/* Show close button more prominently when hovering title bar */
655+button.stacked-pane-title-button:hover .stacked-pane-close-button {
656+ opacity: 0.7;
657+}
658+
659+.stacked-pane-close-button:focus {
660+ outline: none;
661+ box-shadow: none;
662+}
626 `663 `
627 }664 }
628 665

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