detached 35136f8

feat(usecase): implement pane resizing logic

commit
35136f8eb60acdd928653f07b2bf877caf14ab53
author
brice · · 2025-12-26 07:03 UTC
parent
82a17d2
signature
unsigned
3 files · +231 -16
M internal/application/usecase/manage_panes.go +185 -0
@@ -2,6 +2,7 @@ package usecase
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "sort"
78
@@ -29,6 +30,26 @@ const (
2930 NavDown NavigateDirection = "down"
3031 )
3132
33+// ResizeDirection indicates the direction for pane resizing.
34+type ResizeDirection string
35+
36+const (
37+ ResizeIncreaseLeft ResizeDirection = "increase_left"
38+ ResizeIncreaseRight ResizeDirection = "increase_right"
39+ ResizeIncreaseUp ResizeDirection = "increase_up"
40+ ResizeIncreaseDown ResizeDirection = "increase_down"
41+
42+ ResizeDecreaseLeft ResizeDirection = "decrease_left"
43+ ResizeDecreaseRight ResizeDirection = "decrease_right"
44+ ResizeDecreaseUp ResizeDirection = "decrease_up"
45+ ResizeDecreaseDown ResizeDirection = "decrease_down"
46+
47+ ResizeIncrease ResizeDirection = "increase"
48+ ResizeDecrease ResizeDirection = "decrease"
49+)
50+
51+var ErrNothingToResize = errors.New("nothing to resize")
52+
3253 // ManagePanesUseCase handles pane tree operations.
3354 type ManagePanesUseCase struct {
3455 idGenerator IDGenerator
@@ -166,6 +187,170 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
166187 }, nil
167188 }
168189
190+// Resize adjusts the nearest applicable split ratio for the given direction.
191+// stepPercent is applied per keystroke (e.g. 5.0 means 5%).
192+// minPanePercent enforces a minimum size for each side of a split.
193+func (uc *ManagePanesUseCase) Resize(
194+ ctx context.Context,
195+ ws *entity.Workspace,
196+ paneNode *entity.PaneNode,
197+ dir ResizeDirection,
198+ stepPercent float64,
199+ minPanePercent float64,
200+) error {
201+ log := logging.FromContext(ctx)
202+ if uc == nil {
203+ return fmt.Errorf("manage panes use case is nil")
204+ }
205+
206+ if ws == nil {
207+ return fmt.Errorf("workspace is required")
208+ }
209+ if ws.Root == nil {
210+ return ErrNothingToResize
211+ }
212+ if paneNode == nil {
213+ return fmt.Errorf("pane node is required")
214+ }
215+
216+ target := paneNode
217+ if target.Parent != nil && target.Parent.IsStacked {
218+ target = target.Parent
219+ }
220+
221+ actualDir := dir
222+ switch dir {
223+ case ResizeIncrease:
224+ actualDir = findFirstAvailableDirection(target, []ResizeDirection{
225+ ResizeIncreaseRight,
226+ ResizeIncreaseDown,
227+ ResizeIncreaseLeft,
228+ ResizeIncreaseUp,
229+ })
230+ case ResizeDecrease:
231+ actualDir = findFirstAvailableDirection(target, []ResizeDirection{
232+ ResizeDecreaseLeft,
233+ ResizeDecreaseUp,
234+ ResizeDecreaseRight,
235+ ResizeDecreaseDown,
236+ })
237+ }
238+ if actualDir == "" {
239+ return ErrNothingToResize
240+ }
241+
242+ axis, ok := axisForResizeDirection(actualDir)
243+ if !ok {
244+ return ErrNothingToResize
245+ }
246+
247+ splitNode := findNearestSplitForAxis(target, axis)
248+ if splitNode == nil {
249+ return ErrNothingToResize
250+ }
251+
252+ // We treat resize directions as moving the split divider.
253+ // SplitRatio is the proportion allocated to the first child (left/top).
254+ // - Moving the divider right/down increases SplitRatio.
255+ // - Moving the divider left/up decreases SplitRatio.
256+ delta := deltaForDividerMove(actualDir, stepPercent)
257+
258+ minRatio := minPanePercent / 100.0
259+ maxRatio := 1.0 - minRatio
260+ oldRatio := splitNode.SplitRatio
261+ splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio)
262+
263+ log.Debug().
264+ Str("direction", string(dir)).
265+ Str("actual_direction", string(actualDir)).
266+ Float64("old_ratio", oldRatio).
267+ Float64("new_ratio", splitNode.SplitRatio).
268+ Msg("pane resized")
269+
270+ return nil
271+}
272+
273+func findFirstAvailableDirection(target *entity.PaneNode, dirs []ResizeDirection) ResizeDirection {
274+ for _, d := range dirs {
275+ axis, ok := axisForResizeDirection(d)
276+ if !ok {
277+ continue
278+ }
279+ splitNode := findNearestSplitForAxis(target, axis)
280+ if splitNode != nil {
281+ return d
282+ }
283+ }
284+ return ""
285+}
286+
287+type resizeAxis int
288+
289+const (
290+ resizeAxisNone resizeAxis = iota
291+ resizeAxisHorizontal
292+ resizeAxisVertical
293+)
294+
295+func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) {
296+ switch dir {
297+ case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight:
298+ return resizeAxisHorizontal, true
299+ case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown:
300+ return resizeAxisVertical, true
301+ default:
302+ return resizeAxisNone, false
303+ }
304+}
305+
306+func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 {
307+ delta := stepPercent / 100.0
308+ if delta < 0 {
309+ delta = -delta
310+ }
311+
312+ switch dir {
313+ case ResizeIncreaseRight, ResizeIncreaseDown:
314+ return delta
315+ case ResizeIncreaseLeft, ResizeIncreaseUp:
316+ return -delta
317+ case ResizeDecreaseRight, ResizeDecreaseDown:
318+ return -delta
319+ case ResizeDecreaseLeft, ResizeDecreaseUp:
320+ return delta
321+ default:
322+ return 0
323+ }
324+}
325+
326+// findNearestSplitForAxis walks up the tree to find the nearest split matching the axis.
327+func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode {
328+ current := node
329+ for current != nil && current.Parent != nil {
330+ parent := current.Parent
331+ if parent.IsSplit() {
332+ if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal {
333+ return parent
334+ }
335+ if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical {
336+ return parent
337+ }
338+ }
339+ current = parent
340+ }
341+ return nil
342+}
343+
344+func clampFloat64(v, minVal, maxVal float64) float64 {
345+ if v < minVal {
346+ return minVal
347+ }
348+ if v > maxVal {
349+ return maxVal
350+ }
351+ return v
352+}
353+
169354 // Close removes a pane and promotes its sibling.
170355 // Returns the sibling node that was promoted, or nil if this was the last pane.
171356 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
@@ -2,6 +2,7 @@ package usecase
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "sort"7 "sort"
7 8
@@ -29,6 +30,26 @@ const (
29 NavDown NavigateDirection = "down"30 NavDown NavigateDirection = "down"
30 )31 )
31 32
33+// ResizeDirection indicates the direction for pane resizing.
34+type ResizeDirection string
35+
36+const (
37+ ResizeIncreaseLeft ResizeDirection = "increase_left"
38+ ResizeIncreaseRight ResizeDirection = "increase_right"
39+ ResizeIncreaseUp ResizeDirection = "increase_up"
40+ ResizeIncreaseDown ResizeDirection = "increase_down"
41+
42+ ResizeDecreaseLeft ResizeDirection = "decrease_left"
43+ ResizeDecreaseRight ResizeDirection = "decrease_right"
44+ ResizeDecreaseUp ResizeDirection = "decrease_up"
45+ ResizeDecreaseDown ResizeDirection = "decrease_down"
46+
47+ ResizeIncrease ResizeDirection = "increase"
48+ ResizeDecrease ResizeDirection = "decrease"
49+)
50+
51+var ErrNothingToResize = errors.New("nothing to resize")
52+
32 // ManagePanesUseCase handles pane tree operations.53 // ManagePanesUseCase handles pane tree operations.
33 type ManagePanesUseCase struct {54 type ManagePanesUseCase struct {
34 idGenerator IDGenerator55 idGenerator IDGenerator
@@ -166,6 +187,170 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
166 }, nil187 }, nil
167 }188 }
168 189
190+// Resize adjusts the nearest applicable split ratio for the given direction.
191+// stepPercent is applied per keystroke (e.g. 5.0 means 5%).
192+// minPanePercent enforces a minimum size for each side of a split.
193+func (uc *ManagePanesUseCase) Resize(
194+ ctx context.Context,
195+ ws *entity.Workspace,
196+ paneNode *entity.PaneNode,
197+ dir ResizeDirection,
198+ stepPercent float64,
199+ minPanePercent float64,
200+) error {
201+ log := logging.FromContext(ctx)
202+ if uc == nil {
203+ return fmt.Errorf("manage panes use case is nil")
204+ }
205+
206+ if ws == nil {
207+ return fmt.Errorf("workspace is required")
208+ }
209+ if ws.Root == nil {
210+ return ErrNothingToResize
211+ }
212+ if paneNode == nil {
213+ return fmt.Errorf("pane node is required")
214+ }
215+
216+ target := paneNode
217+ if target.Parent != nil && target.Parent.IsStacked {
218+ target = target.Parent
219+ }
220+
221+ actualDir := dir
222+ switch dir {
223+ case ResizeIncrease:
224+ actualDir = findFirstAvailableDirection(target, []ResizeDirection{
225+ ResizeIncreaseRight,
226+ ResizeIncreaseDown,
227+ ResizeIncreaseLeft,
228+ ResizeIncreaseUp,
229+ })
230+ case ResizeDecrease:
231+ actualDir = findFirstAvailableDirection(target, []ResizeDirection{
232+ ResizeDecreaseLeft,
233+ ResizeDecreaseUp,
234+ ResizeDecreaseRight,
235+ ResizeDecreaseDown,
236+ })
237+ }
238+ if actualDir == "" {
239+ return ErrNothingToResize
240+ }
241+
242+ axis, ok := axisForResizeDirection(actualDir)
243+ if !ok {
244+ return ErrNothingToResize
245+ }
246+
247+ splitNode := findNearestSplitForAxis(target, axis)
248+ if splitNode == nil {
249+ return ErrNothingToResize
250+ }
251+
252+ // We treat resize directions as moving the split divider.
253+ // SplitRatio is the proportion allocated to the first child (left/top).
254+ // - Moving the divider right/down increases SplitRatio.
255+ // - Moving the divider left/up decreases SplitRatio.
256+ delta := deltaForDividerMove(actualDir, stepPercent)
257+
258+ minRatio := minPanePercent / 100.0
259+ maxRatio := 1.0 - minRatio
260+ oldRatio := splitNode.SplitRatio
261+ splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio)
262+
263+ log.Debug().
264+ Str("direction", string(dir)).
265+ Str("actual_direction", string(actualDir)).
266+ Float64("old_ratio", oldRatio).
267+ Float64("new_ratio", splitNode.SplitRatio).
268+ Msg("pane resized")
269+
270+ return nil
271+}
272+
273+func findFirstAvailableDirection(target *entity.PaneNode, dirs []ResizeDirection) ResizeDirection {
274+ for _, d := range dirs {
275+ axis, ok := axisForResizeDirection(d)
276+ if !ok {
277+ continue
278+ }
279+ splitNode := findNearestSplitForAxis(target, axis)
280+ if splitNode != nil {
281+ return d
282+ }
283+ }
284+ return ""
285+}
286+
287+type resizeAxis int
288+
289+const (
290+ resizeAxisNone resizeAxis = iota
291+ resizeAxisHorizontal
292+ resizeAxisVertical
293+)
294+
295+func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) {
296+ switch dir {
297+ case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight:
298+ return resizeAxisHorizontal, true
299+ case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown:
300+ return resizeAxisVertical, true
301+ default:
302+ return resizeAxisNone, false
303+ }
304+}
305+
306+func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 {
307+ delta := stepPercent / 100.0
308+ if delta < 0 {
309+ delta = -delta
310+ }
311+
312+ switch dir {
313+ case ResizeIncreaseRight, ResizeIncreaseDown:
314+ return delta
315+ case ResizeIncreaseLeft, ResizeIncreaseUp:
316+ return -delta
317+ case ResizeDecreaseRight, ResizeDecreaseDown:
318+ return -delta
319+ case ResizeDecreaseLeft, ResizeDecreaseUp:
320+ return delta
321+ default:
322+ return 0
323+ }
324+}
325+
326+// findNearestSplitForAxis walks up the tree to find the nearest split matching the axis.
327+func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode {
328+ current := node
329+ for current != nil && current.Parent != nil {
330+ parent := current.Parent
331+ if parent.IsSplit() {
332+ if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal {
333+ return parent
334+ }
335+ if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical {
336+ return parent
337+ }
338+ }
339+ current = parent
340+ }
341+ return nil
342+}
343+
344+func clampFloat64(v, minVal, maxVal float64) float64 {
345+ if v < minVal {
346+ return minVal
347+ }
348+ if v > maxVal {
349+ return maxVal
350+ }
351+ return v
352+}
353+
169 // Close removes a pane and promotes its sibling.354 // Close removes a pane and promotes its sibling.
170 // Returns the sibling node that was promoted, or nil if this was the last pane.355 // Returns the sibling node that was promoted, or nil if this was the last pane.
171 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {356 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
M internal/ui/layout/tree.go +45 -15
@@ -3,6 +3,7 @@ package layout
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "sync"
78
89 "github.com/bnema/dumber/internal/domain/entity"
@@ -27,10 +28,11 @@ type PaneViewFactory interface {
2728 // It maps each node to its corresponding GTK widget and maintains
2829 // a lookup table for finding widgets by node ID.
2930 type TreeRenderer struct {
30- factory WidgetFactory
31- paneViewFactory PaneViewFactory
32- logger zerolog.Logger
33- nodeToWidget map[string]Widget
31+ factory WidgetFactory
32+ paneViewFactory PaneViewFactory
33+ logger zerolog.Logger
34+ nodeToWidget map[string]Widget
35+ splitOrientation map[string]Orientation
3436 // paneToStack maps pane IDs to their containing StackedView.
3537 // Every leaf pane is wrapped in a StackedView for easy stacking later.
3638 paneToStack map[string]*StackedView
@@ -43,11 +45,12 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
4345 log.Debug().Msg("creating tree renderer")
4446
4547 return &TreeRenderer{
46- factory: factory,
47- paneViewFactory: paneViewFactory,
48- logger: log.With().Str("component", "tree-renderer").Logger(),
49- nodeToWidget: make(map[string]Widget),
50- paneToStack: make(map[string]*StackedView),
48+ factory: factory,
49+ paneViewFactory: paneViewFactory,
50+ logger: log.With().Str("component", "tree-renderer").Logger(),
51+ nodeToWidget: make(map[string]Widget),
52+ splitOrientation: make(map[string]Orientation),
53+ paneToStack: make(map[string]*StackedView),
5154 }
5255 }
5356
@@ -63,6 +66,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge
6366
6467 // Clear previous mappings
6568 tr.nodeToWidget = make(map[string]Widget)
69+ tr.splitOrientation = make(map[string]Orientation)
6670 tr.paneToStack = make(map[string]*StackedView)
6771
6872 return tr.renderNode(ctx, root)
@@ -166,6 +170,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
166170
167171 // Create split view
168172 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
173+ tr.splitOrientation[node.ID] = orientation
169174
170175 return splitView.Widget(), nil
171176 }
@@ -247,6 +252,7 @@ func (tr *TreeRenderer) Clear() {
247252 defer tr.mu.Unlock()
248253
249254 tr.nodeToWidget = make(map[string]Widget)
255+ tr.splitOrientation = make(map[string]Orientation)
250256 }
251257
252258 // RegisterWidget adds or updates a node-to-widget mapping.
@@ -258,6 +264,15 @@ func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
258264 tr.nodeToWidget[nodeID] = widget
259265 }
260266
267+// RegisterSplit registers a split widget with its orientation.
268+func (tr *TreeRenderer) RegisterSplit(nodeID string, widget Widget, orientation Orientation) {
269+ tr.mu.Lock()
270+ defer tr.mu.Unlock()
271+
272+ tr.nodeToWidget[nodeID] = widget
273+ tr.splitOrientation[nodeID] = orientation
274+}
275+
261276 // UnregisterWidget removes a node-to-widget mapping.
262277 // Use this when a node is removed or replaced during incremental operations.
263278 func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
@@ -265,6 +280,7 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
265280 defer tr.mu.Unlock()
266281
267282 delete(tr.nodeToWidget, nodeID)
283+ delete(tr.splitOrientation, nodeID)
268284 }
269285
270286 // UpdateSplitRatio updates the ratio of a split view by node ID.
@@ -272,19 +288,33 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
272288 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
273289 tr.mu.RLock()
274290 widget, ok := tr.nodeToWidget[nodeID]
291+ orientation, okOrient := tr.splitOrientation[nodeID]
275292 tr.mu.RUnlock()
276293
277294 if !ok {
278295 return ErrNodeNotFound
279296 }
280297
281- // The widget should be a PanedWidget from a SplitView
282- // We can't directly update the ratio since we only store the Widget interface
283- // The caller would need to track SplitView instances separately or
284- // use the underlying PanedWidget's SetPosition method
298+ paned, ok := widget.(PanedWidget)
299+ if !ok {
300+ return fmt.Errorf("widget is not a PanedWidget")
301+ }
302+ if !okOrient {
303+ return fmt.Errorf("split orientation not found")
304+ }
305+
306+ totalSize := 0
307+ switch orientation {
308+ case OrientationHorizontal:
309+ totalSize = paned.GetAllocatedWidth()
310+ case OrientationVertical:
311+ totalSize = paned.GetAllocatedHeight()
312+ }
313+ if totalSize <= 0 {
314+ return nil
315+ }
285316
286- // For now, we can at least verify the widget exists
287- _ = widget
317+ paned.SetPosition(int(float64(totalSize) * ratio))
288318 return nil
289319 }
290320
@@ -3,6 +3,7 @@ package layout
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "sync"7 "sync"
7 8
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
@@ -27,10 +28,11 @@ type PaneViewFactory interface {
27 // It maps each node to its corresponding GTK widget and maintains28 // It maps each node to its corresponding GTK widget and maintains
28 // a lookup table for finding widgets by node ID.29 // a lookup table for finding widgets by node ID.
29 type TreeRenderer struct {30 type TreeRenderer struct {
30- factory WidgetFactory31+ factory WidgetFactory
31- paneViewFactory PaneViewFactory32+ paneViewFactory PaneViewFactory
32- logger zerolog.Logger33+ logger zerolog.Logger
33- nodeToWidget map[string]Widget34+ nodeToWidget map[string]Widget
35+ splitOrientation map[string]Orientation
34 // paneToStack maps pane IDs to their containing StackedView.36 // paneToStack maps pane IDs to their containing StackedView.
35 // Every leaf pane is wrapped in a StackedView for easy stacking later.37 // Every leaf pane is wrapped in a StackedView for easy stacking later.
36 paneToStack map[string]*StackedView38 paneToStack map[string]*StackedView
@@ -43,11 +45,12 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
43 log.Debug().Msg("creating tree renderer")45 log.Debug().Msg("creating tree renderer")
44 46
45 return &TreeRenderer{47 return &TreeRenderer{
46- factory: factory,48+ factory: factory,
47- paneViewFactory: paneViewFactory,49+ paneViewFactory: paneViewFactory,
48- logger: log.With().Str("component", "tree-renderer").Logger(),50+ logger: log.With().Str("component", "tree-renderer").Logger(),
49- nodeToWidget: make(map[string]Widget),51+ nodeToWidget: make(map[string]Widget),
50- paneToStack: make(map[string]*StackedView),52+ splitOrientation: make(map[string]Orientation),
53+ paneToStack: make(map[string]*StackedView),
51 }54 }
52 }55 }
53 56
@@ -63,6 +66,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge
63 66
64 // Clear previous mappings67 // Clear previous mappings
65 tr.nodeToWidget = make(map[string]Widget)68 tr.nodeToWidget = make(map[string]Widget)
69+ tr.splitOrientation = make(map[string]Orientation)
66 tr.paneToStack = make(map[string]*StackedView)70 tr.paneToStack = make(map[string]*StackedView)
67 71
68 return tr.renderNode(ctx, root)72 return tr.renderNode(ctx, root)
@@ -166,6 +170,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
166 170
167 // Create split view171 // Create split view
168 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)172 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
173+ tr.splitOrientation[node.ID] = orientation
169 174
170 return splitView.Widget(), nil175 return splitView.Widget(), nil
171 }176 }
@@ -247,6 +252,7 @@ func (tr *TreeRenderer) Clear() {
247 defer tr.mu.Unlock()252 defer tr.mu.Unlock()
248 253
249 tr.nodeToWidget = make(map[string]Widget)254 tr.nodeToWidget = make(map[string]Widget)
255+ tr.splitOrientation = make(map[string]Orientation)
250 }256 }
251 257
252 // RegisterWidget adds or updates a node-to-widget mapping.258 // RegisterWidget adds or updates a node-to-widget mapping.
@@ -258,6 +264,15 @@ func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
258 tr.nodeToWidget[nodeID] = widget264 tr.nodeToWidget[nodeID] = widget
259 }265 }
260 266
267+// RegisterSplit registers a split widget with its orientation.
268+func (tr *TreeRenderer) RegisterSplit(nodeID string, widget Widget, orientation Orientation) {
269+ tr.mu.Lock()
270+ defer tr.mu.Unlock()
271+
272+ tr.nodeToWidget[nodeID] = widget
273+ tr.splitOrientation[nodeID] = orientation
274+}
275+
261 // UnregisterWidget removes a node-to-widget mapping.276 // UnregisterWidget removes a node-to-widget mapping.
262 // Use this when a node is removed or replaced during incremental operations.277 // Use this when a node is removed or replaced during incremental operations.
263 func (tr *TreeRenderer) UnregisterWidget(nodeID string) {278 func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
@@ -265,6 +280,7 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
265 defer tr.mu.Unlock()280 defer tr.mu.Unlock()
266 281
267 delete(tr.nodeToWidget, nodeID)282 delete(tr.nodeToWidget, nodeID)
283+ delete(tr.splitOrientation, nodeID)
268 }284 }
269 285
270 // UpdateSplitRatio updates the ratio of a split view by node ID.286 // UpdateSplitRatio updates the ratio of a split view by node ID.
@@ -272,19 +288,33 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
272 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {288 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
273 tr.mu.RLock()289 tr.mu.RLock()
274 widget, ok := tr.nodeToWidget[nodeID]290 widget, ok := tr.nodeToWidget[nodeID]
291+ orientation, okOrient := tr.splitOrientation[nodeID]
275 tr.mu.RUnlock()292 tr.mu.RUnlock()
276 293
277 if !ok {294 if !ok {
278 return ErrNodeNotFound295 return ErrNodeNotFound
279 }296 }
280 297
281- // The widget should be a PanedWidget from a SplitView298+ paned, ok := widget.(PanedWidget)
282- // We can't directly update the ratio since we only store the Widget interface299+ if !ok {
283- // The caller would need to track SplitView instances separately or300+ return fmt.Errorf("widget is not a PanedWidget")
284- // use the underlying PanedWidget's SetPosition method301+ }
302+ if !okOrient {
303+ return fmt.Errorf("split orientation not found")
304+ }
305+
306+ totalSize := 0
307+ switch orientation {
308+ case OrientationHorizontal:
309+ totalSize = paned.GetAllocatedWidth()
310+ case OrientationVertical:
311+ totalSize = paned.GetAllocatedHeight()
312+ }
313+ if totalSize <= 0 {
314+ return nil
315+ }
285 316
286- // For now, we can at least verify the widget exists317+ paned.SetPosition(int(float64(totalSize) * ratio))
287- _ = widget
288 return nil318 return nil
289 }319 }
290 320
M internal/ui/layout/tree_test.go +1 -1
@@ -538,7 +538,7 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
538538 mockRightStackBox.EXPECT().SetVisible(true).Once()
539539 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
540540 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
541- mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
541+ mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()
542542 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
543543 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
544544
@@ -538,7 +538,7 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
538 mockRightStackBox.EXPECT().SetVisible(true).Once()538 mockRightStackBox.EXPECT().SetVisible(true).Once()
539 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()539 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
540 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()540 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
541- mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()541+ mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()
542 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()542 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
543 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()543 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
544 544

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