detached 0b6a0cf

refactor(ui): extract workspace close context helper

commit
0b6a0cf138920848bda8a4661e0bd3494516db3c
author
brice · · 2026-06-25 12:43 UTC
parent
dbc9709
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +150 -84
M internal/ui/coordinator/workspace.go +0 -84
@@ -75,16 +75,6 @@ type stackPaneContext struct {
7575 originalTitle string
7676 }
7777
78-type incrementalCloseContext struct {
79- parentNode *entity.PaneNode
80- siblingNode *entity.PaneNode
81- grandparentNode *entity.PaneNode
82- parentWidget layout.Widget
83- siblingIsStartChild bool
84- parentIsStartInGrand bool
85- precheckReason string
86-}
87-
8878 const (
8979 defaultResizeStepPercent = 0.05
9080 defaultResizeMinPanePercent = 0.1
@@ -1261,80 +1251,6 @@ func (c *WorkspaceCoordinator) captureIncrementalCloseContext(
12611251 return closeCtx
12621252 }
12631253
1264-func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) {
1265- var closeCtx incrementalCloseContext
1266- if closingPane == nil {
1267- return closeCtx, errors.New("closing pane missing")
1268- }
1269-
1270- parentNode := closingPane.Parent
1271- if parentNode == nil {
1272- return closeCtx, errors.New("closing pane has no parent")
1273- }
1274-
1275- closeCtx.parentNode = parentNode
1276- closeCtx.grandparentNode = parentNode.Parent
1277-
1278- if parentNode.IsStacked {
1279- return closeCtx, nil
1280- }
1281-
1282- if !parentNode.IsSplit() {
1283- return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID)
1284- }
1285-
1286- if len(parentNode.Children) != 2 {
1287- return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children))
1288- }
1289-
1290- leftChild := parentNode.Left()
1291- rightChild := parentNode.Right()
1292- if leftChild == closingPane && rightChild == nil {
1293- return closeCtx, errors.New("sibling missing")
1294- }
1295- if rightChild == closingPane && leftChild == nil {
1296- return closeCtx, errors.New("sibling missing")
1297- }
1298- if leftChild == nil || rightChild == nil {
1299- return closeCtx, errors.New("split parent has nil child")
1300- }
1301-
1302- switch {
1303- case leftChild == closingPane:
1304- closeCtx.siblingNode = rightChild
1305- closeCtx.siblingIsStartChild = false
1306- case rightChild == closingPane:
1307- closeCtx.siblingNode = leftChild
1308- closeCtx.siblingIsStartChild = true
1309- default:
1310- return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID)
1311- }
1312-
1313- if closeCtx.siblingNode == nil {
1314- return closeCtx, errors.New("sibling missing")
1315- }
1316-
1317- if closeCtx.grandparentNode != nil {
1318- switch {
1319- case closeCtx.grandparentNode.Left() == parentNode:
1320- closeCtx.parentIsStartInGrand = true
1321- case closeCtx.grandparentNode.Right() == parentNode:
1322- closeCtx.parentIsStartInGrand = false
1323- default:
1324- return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID)
1325- }
1326- }
1327-
1328- return closeCtx, nil
1329-}
1330-
1331-func paneNodeID(node *entity.PaneNode) string {
1332- if node == nil {
1333- return nilString
1334- }
1335- return node.ID
1336-}
1337-
13381254 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
13391255 func (c *WorkspaceCoordinator) doIncrementalStackClose(
13401256 ctx context.Context,
@@ -75,16 +75,6 @@ type stackPaneContext struct {
75 originalTitle string75 originalTitle string
76 }76 }
77 77
78-type incrementalCloseContext struct {
79- parentNode *entity.PaneNode
80- siblingNode *entity.PaneNode
81- grandparentNode *entity.PaneNode
82- parentWidget layout.Widget
83- siblingIsStartChild bool
84- parentIsStartInGrand bool
85- precheckReason string
86-}
87-
88 const (78 const (
89 defaultResizeStepPercent = 0.0579 defaultResizeStepPercent = 0.05
90 defaultResizeMinPanePercent = 0.180 defaultResizeMinPanePercent = 0.1
@@ -1261,80 +1251,6 @@ func (c *WorkspaceCoordinator) captureIncrementalCloseContext(
1261 return closeCtx1251 return closeCtx
1262 }1252 }
1263 1253
1264-func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) {
1265- var closeCtx incrementalCloseContext
1266- if closingPane == nil {
1267- return closeCtx, errors.New("closing pane missing")
1268- }
1269-
1270- parentNode := closingPane.Parent
1271- if parentNode == nil {
1272- return closeCtx, errors.New("closing pane has no parent")
1273- }
1274-
1275- closeCtx.parentNode = parentNode
1276- closeCtx.grandparentNode = parentNode.Parent
1277-
1278- if parentNode.IsStacked {
1279- return closeCtx, nil
1280- }
1281-
1282- if !parentNode.IsSplit() {
1283- return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID)
1284- }
1285-
1286- if len(parentNode.Children) != 2 {
1287- return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children))
1288- }
1289-
1290- leftChild := parentNode.Left()
1291- rightChild := parentNode.Right()
1292- if leftChild == closingPane && rightChild == nil {
1293- return closeCtx, errors.New("sibling missing")
1294- }
1295- if rightChild == closingPane && leftChild == nil {
1296- return closeCtx, errors.New("sibling missing")
1297- }
1298- if leftChild == nil || rightChild == nil {
1299- return closeCtx, errors.New("split parent has nil child")
1300- }
1301-
1302- switch {
1303- case leftChild == closingPane:
1304- closeCtx.siblingNode = rightChild
1305- closeCtx.siblingIsStartChild = false
1306- case rightChild == closingPane:
1307- closeCtx.siblingNode = leftChild
1308- closeCtx.siblingIsStartChild = true
1309- default:
1310- return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID)
1311- }
1312-
1313- if closeCtx.siblingNode == nil {
1314- return closeCtx, errors.New("sibling missing")
1315- }
1316-
1317- if closeCtx.grandparentNode != nil {
1318- switch {
1319- case closeCtx.grandparentNode.Left() == parentNode:
1320- closeCtx.parentIsStartInGrand = true
1321- case closeCtx.grandparentNode.Right() == parentNode:
1322- closeCtx.parentIsStartInGrand = false
1323- default:
1324- return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID)
1325- }
1326- }
1327-
1328- return closeCtx, nil
1329-}
1330-
1331-func paneNodeID(node *entity.PaneNode) string {
1332- if node == nil {
1333- return nilString
1334- }
1335- return node.ID
1336-}
1337-
1338 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.1254 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
1339 func (c *WorkspaceCoordinator) doIncrementalStackClose(1255 func (c *WorkspaceCoordinator) doIncrementalStackClose(
1340 ctx context.Context,1256 ctx context.Context,
A internal/ui/coordinator/workspace_close_context.go +93 -0
@@ -0,0 +1,93 @@
1+package coordinator
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+type incrementalCloseContext struct {
12+ parentNode *entity.PaneNode
13+ siblingNode *entity.PaneNode
14+ grandparentNode *entity.PaneNode
15+ parentWidget layout.Widget
16+ siblingIsStartChild bool
17+ parentIsStartInGrand bool
18+ precheckReason string
19+}
20+
21+func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) {
22+ var closeCtx incrementalCloseContext
23+ if closingPane == nil {
24+ return closeCtx, errors.New("closing pane missing")
25+ }
26+
27+ parentNode := closingPane.Parent
28+ if parentNode == nil {
29+ return closeCtx, errors.New("closing pane has no parent")
30+ }
31+
32+ closeCtx.parentNode = parentNode
33+ closeCtx.grandparentNode = parentNode.Parent
34+
35+ if parentNode.IsStacked {
36+ return closeCtx, nil
37+ }
38+
39+ if !parentNode.IsSplit() {
40+ return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID)
41+ }
42+
43+ if len(parentNode.Children) != 2 {
44+ return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children))
45+ }
46+
47+ leftChild := parentNode.Left()
48+ rightChild := parentNode.Right()
49+ if leftChild == closingPane && rightChild == nil {
50+ return closeCtx, errors.New("sibling missing")
51+ }
52+ if rightChild == closingPane && leftChild == nil {
53+ return closeCtx, errors.New("sibling missing")
54+ }
55+ if leftChild == nil || rightChild == nil {
56+ return closeCtx, errors.New("split parent has nil child")
57+ }
58+
59+ switch {
60+ case leftChild == closingPane:
61+ closeCtx.siblingNode = rightChild
62+ closeCtx.siblingIsStartChild = false
63+ case rightChild == closingPane:
64+ closeCtx.siblingNode = leftChild
65+ closeCtx.siblingIsStartChild = true
66+ default:
67+ return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID)
68+ }
69+
70+ if closeCtx.siblingNode == nil {
71+ return closeCtx, errors.New("sibling missing")
72+ }
73+
74+ if closeCtx.grandparentNode != nil {
75+ switch {
76+ case closeCtx.grandparentNode.Left() == parentNode:
77+ closeCtx.parentIsStartInGrand = true
78+ case closeCtx.grandparentNode.Right() == parentNode:
79+ closeCtx.parentIsStartInGrand = false
80+ default:
81+ return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID)
82+ }
83+ }
84+
85+ return closeCtx, nil
86+}
87+
88+func paneNodeID(node *entity.PaneNode) string {
89+ if node == nil {
90+ return nilString
91+ }
92+ return node.ID
93+}
@@ -0,0 +1,93 @@
1+package coordinator
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+type incrementalCloseContext struct {
12+ parentNode *entity.PaneNode
13+ siblingNode *entity.PaneNode
14+ grandparentNode *entity.PaneNode
15+ parentWidget layout.Widget
16+ siblingIsStartChild bool
17+ parentIsStartInGrand bool
18+ precheckReason string
19+}
20+
21+func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) {
22+ var closeCtx incrementalCloseContext
23+ if closingPane == nil {
24+ return closeCtx, errors.New("closing pane missing")
25+ }
26+
27+ parentNode := closingPane.Parent
28+ if parentNode == nil {
29+ return closeCtx, errors.New("closing pane has no parent")
30+ }
31+
32+ closeCtx.parentNode = parentNode
33+ closeCtx.grandparentNode = parentNode.Parent
34+
35+ if parentNode.IsStacked {
36+ return closeCtx, nil
37+ }
38+
39+ if !parentNode.IsSplit() {
40+ return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID)
41+ }
42+
43+ if len(parentNode.Children) != 2 {
44+ return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children))
45+ }
46+
47+ leftChild := parentNode.Left()
48+ rightChild := parentNode.Right()
49+ if leftChild == closingPane && rightChild == nil {
50+ return closeCtx, errors.New("sibling missing")
51+ }
52+ if rightChild == closingPane && leftChild == nil {
53+ return closeCtx, errors.New("sibling missing")
54+ }
55+ if leftChild == nil || rightChild == nil {
56+ return closeCtx, errors.New("split parent has nil child")
57+ }
58+
59+ switch {
60+ case leftChild == closingPane:
61+ closeCtx.siblingNode = rightChild
62+ closeCtx.siblingIsStartChild = false
63+ case rightChild == closingPane:
64+ closeCtx.siblingNode = leftChild
65+ closeCtx.siblingIsStartChild = true
66+ default:
67+ return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID)
68+ }
69+
70+ if closeCtx.siblingNode == nil {
71+ return closeCtx, errors.New("sibling missing")
72+ }
73+
74+ if closeCtx.grandparentNode != nil {
75+ switch {
76+ case closeCtx.grandparentNode.Left() == parentNode:
77+ closeCtx.parentIsStartInGrand = true
78+ case closeCtx.grandparentNode.Right() == parentNode:
79+ closeCtx.parentIsStartInGrand = false
80+ default:
81+ return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID)
82+ }
83+ }
84+
85+ return closeCtx, nil
86+}
87+
88+func paneNodeID(node *entity.PaneNode) string {
89+ if node == nil {
90+ return nilString
91+ }
92+ return node.ID
93+}
M internal/ui/coordinator/workspace_close_test.go +57 -0
@@ -59,6 +59,47 @@ func TestDeriveIncrementalCloseTreeContext_ValidSplit(t *testing.T) {
5959 }
6060 }
6161
62+func TestDeriveIncrementalCloseTreeContext_InvalidPreconditions(t *testing.T) {
63+ tests := []struct {
64+ name string
65+ closingPane *entity.PaneNode
66+ wantText string
67+ }{
68+ {
69+ name: "nil closing pane",
70+ closingPane: nil,
71+ wantText: "closing pane missing",
72+ },
73+ {
74+ name: "closing pane has no parent",
75+ closingPane: testLeafNode("closing"),
76+ wantText: "closing pane has no parent",
77+ },
78+ {
79+ name: "parent is not split",
80+ closingPane: func() *entity.PaneNode {
81+ closing := testLeafNode("closing")
82+ parent := &entity.PaneNode{ID: "parent", Children: []*entity.PaneNode{closing}}
83+ closing.Parent = parent
84+ return closing
85+ }(),
86+ wantText: "parent node is not split",
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ _, err := deriveIncrementalCloseTreeContext(tt.closingPane)
93+ if err == nil {
94+ t.Fatalf("expected error")
95+ }
96+ if !strings.Contains(err.Error(), tt.wantText) {
97+ t.Fatalf("expected %q error, got: %v", tt.wantText, err)
98+ }
99+ })
100+ }
101+}
102+
62103 func TestDeriveIncrementalCloseTreeContext_MissingSibling(t *testing.T) {
63104 closing := testLeafNode("closing")
64105 parent := testSplitNode("parent", closing, nil)
@@ -92,6 +133,22 @@ func TestDeriveIncrementalCloseTreeContext_ParentDoesNotContainClosingPane(t *te
92133 }
93134 }
94135
136+func TestDeriveIncrementalCloseTreeContext_ParentMissingFromGrandparent(t *testing.T) {
137+ closing := testLeafNode("closing")
138+ sibling := testLeafNode("sibling")
139+ parent := testSplitNode("parent", closing, sibling)
140+ grand := testSplitNode("grand", testLeafNode("other-left"), testLeafNode("other-right"))
141+ parent.Parent = grand
142+
143+ _, err := deriveIncrementalCloseTreeContext(closing)
144+ if err == nil {
145+ t.Fatalf("expected invariant error")
146+ }
147+ if !strings.Contains(err.Error(), "parent not found under grandparent") {
148+ t.Fatalf("unexpected error: %v", err)
149+ }
150+}
151+
95152 func TestCaptureIncrementalCloseContext_MissingSiblingSetsPrecheckReason(t *testing.T) {
96153 closing := testLeafNode("closing")
97154 testSplitNode("parent", closing, nil)
@@ -59,6 +59,47 @@ func TestDeriveIncrementalCloseTreeContext_ValidSplit(t *testing.T) {
59 }59 }
60 }60 }
61 61
62+func TestDeriveIncrementalCloseTreeContext_InvalidPreconditions(t *testing.T) {
63+ tests := []struct {
64+ name string
65+ closingPane *entity.PaneNode
66+ wantText string
67+ }{
68+ {
69+ name: "nil closing pane",
70+ closingPane: nil,
71+ wantText: "closing pane missing",
72+ },
73+ {
74+ name: "closing pane has no parent",
75+ closingPane: testLeafNode("closing"),
76+ wantText: "closing pane has no parent",
77+ },
78+ {
79+ name: "parent is not split",
80+ closingPane: func() *entity.PaneNode {
81+ closing := testLeafNode("closing")
82+ parent := &entity.PaneNode{ID: "parent", Children: []*entity.PaneNode{closing}}
83+ closing.Parent = parent
84+ return closing
85+ }(),
86+ wantText: "parent node is not split",
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ _, err := deriveIncrementalCloseTreeContext(tt.closingPane)
93+ if err == nil {
94+ t.Fatalf("expected error")
95+ }
96+ if !strings.Contains(err.Error(), tt.wantText) {
97+ t.Fatalf("expected %q error, got: %v", tt.wantText, err)
98+ }
99+ })
100+ }
101+}
102+
62 func TestDeriveIncrementalCloseTreeContext_MissingSibling(t *testing.T) {103 func TestDeriveIncrementalCloseTreeContext_MissingSibling(t *testing.T) {
63 closing := testLeafNode("closing")104 closing := testLeafNode("closing")
64 parent := testSplitNode("parent", closing, nil)105 parent := testSplitNode("parent", closing, nil)
@@ -92,6 +133,22 @@ func TestDeriveIncrementalCloseTreeContext_ParentDoesNotContainClosingPane(t *te
92 }133 }
93 }134 }
94 135
136+func TestDeriveIncrementalCloseTreeContext_ParentMissingFromGrandparent(t *testing.T) {
137+ closing := testLeafNode("closing")
138+ sibling := testLeafNode("sibling")
139+ parent := testSplitNode("parent", closing, sibling)
140+ grand := testSplitNode("grand", testLeafNode("other-left"), testLeafNode("other-right"))
141+ parent.Parent = grand
142+
143+ _, err := deriveIncrementalCloseTreeContext(closing)
144+ if err == nil {
145+ t.Fatalf("expected invariant error")
146+ }
147+ if !strings.Contains(err.Error(), "parent not found under grandparent") {
148+ t.Fatalf("unexpected error: %v", err)
149+ }
150+}
151+
95 func TestCaptureIncrementalCloseContext_MissingSiblingSetsPrecheckReason(t *testing.T) {152 func TestCaptureIncrementalCloseContext_MissingSiblingSetsPrecheckReason(t *testing.T) {
96 closing := testLeafNode("closing")153 closing := testLeafNode("closing")
97 testSplitNode("parent", closing, nil)154 testSplitNode("parent", closing, nil)

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close