fix(layout): harden pane tree operations
- commit
- 07742d2bd01923696c65c4944950f4083b04b8f7
- author
- brice · · 2026-07-04 07:29 UTC
- committer
- brice · · 2026-07-04 07:41 UTC
- parent
- af961c3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +87 -32
M internal/usecase/layout/layout_test.go +41 -0
| @@ -73,6 +73,20 @@ func TestSolveStackFitAndOverflow(t *testing.T) { | |||
| 73 | 73 | require.False(t, ok) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | func TestStackNewEmptyTreeReturnsError(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 78 | + | tr := &Tree{} | |
| 79 | + | require.ErrorIs(t, tr.StackNew("missing", "new", domain.Rect{Width: 20, Height: 2}), ErrNotFound) | |
| 80 | + | require.Nil(t, tr.Root) | |
| 81 | + | require.Empty(t, tr.Focus) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestNilTreeCloneIsSafe(t *testing.T) { | |
| 85 | + | t.Parallel() | |
| 86 | + | var tr *Tree | |
| 87 | + | require.Nil(t, tr.clone()) | |
| 88 | + | } | |
| 89 | + | ||
| 76 | 90 | func TestTooSmallRefusalsDoNotMutate(t *testing.T) { | |
| 77 | 91 | t.Parallel() | |
| 78 | 92 | tests := []struct { | |
| @@ -218,6 +232,17 @@ func TestCloseRefocusPrefersSibling(t *testing.T) { | |||
| 218 | 232 | require.Equal(t, PaneID("b"), tr.Focus) | |
| 219 | 233 | } | |
| 220 | 234 | ||
| 235 | + | func TestCloseNotFoundDoesNotMutate(t *testing.T) { | |
| 236 | + | t.Parallel() | |
| 237 | + | tr := &Tree{Focus: "b", Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 238 | + | NewLeaf("a"), | |
| 239 | + | {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}, | |
| 240 | + | }}} | |
| 241 | + | before := tr.clone() | |
| 242 | + | require.ErrorIs(t, tr.Close("missing"), ErrNotFound) | |
| 243 | + | require.Equal(t, before, tr) | |
| 244 | + | } | |
| 245 | + | ||
| 221 | 246 | func TestSolveRejectsStackWithMissingExpanded(t *testing.T) { | |
| 222 | 247 | t.Parallel() | |
| 223 | 248 | _, ok := Solve(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "missing"}, domain.Rect{Width: 20, Height: 4}) | |
| @@ -342,6 +367,22 @@ func TestFocusDirEnteringStackExpandsMember(t *testing.T) { | |||
| 342 | 367 | } | |
| 343 | 368 | } | |
| 344 | 369 | ||
| 370 | + | func TestFocusDirStackLocalSkipsNonLeafChildren(t *testing.T) { | |
| 371 | + | t.Parallel() | |
| 372 | + | tr := &Tree{ | |
| 373 | + | Root: &Node{Kind: Stack, Children: []*Node{ | |
| 374 | + | NewLeaf("a"), | |
| 375 | + | {Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("nested")}}, | |
| 376 | + | NewLeaf("b"), | |
| 377 | + | }, Expanded: "a"}, | |
| 378 | + | Focus: "a", | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | require.NoError(t, tr.FocusDir(Down, domain.Rect{Width: 80, Height: 6})) | |
| 382 | + | require.Equal(t, PaneID("b"), tr.Focus) | |
| 383 | + | require.Equal(t, PaneID("b"), tr.Root.Expanded) | |
| 384 | + | } | |
| 385 | + | ||
| 345 | 386 | func contents(ps []Placement) map[PaneID]domain.Rect { | |
| 346 | 387 | out := make(map[PaneID]domain.Rect, len(ps)) | |
| 347 | 388 | for _, p := range ps { | |
| @@ -73,6 +73,20 @@ func TestSolveStackFitAndOverflow(t *testing.T) { | |||||
| 73 | require.False(t, ok) | 73 | require.False(t, ok) | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | func TestStackNewEmptyTreeReturnsError(t *testing.T) { | |||
| 77 | + | t.Parallel() | |||
| 78 | + | tr := &Tree{} | |||
| 79 | + | require.ErrorIs(t, tr.StackNew("missing", "new", domain.Rect{Width: 20, Height: 2}), ErrNotFound) | |||
| 80 | + | require.Nil(t, tr.Root) | |||
| 81 | + | require.Empty(t, tr.Focus) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestNilTreeCloneIsSafe(t *testing.T) { | |||
| 85 | + | t.Parallel() | |||
| 86 | + | var tr *Tree | |||
| 87 | + | require.Nil(t, tr.clone()) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 76 | func TestTooSmallRefusalsDoNotMutate(t *testing.T) { | 90 | func TestTooSmallRefusalsDoNotMutate(t *testing.T) { | ||
| 77 | t.Parallel() | 91 | t.Parallel() | ||
| 78 | tests := []struct { | 92 | tests := []struct { | ||
| @@ -218,6 +232,17 @@ func TestCloseRefocusPrefersSibling(t *testing.T) { | |||||
| 218 | require.Equal(t, PaneID("b"), tr.Focus) | 232 | require.Equal(t, PaneID("b"), tr.Focus) | ||
| 219 | } | 233 | } | ||
| 220 | 234 | ||||
| 235 | + | func TestCloseNotFoundDoesNotMutate(t *testing.T) { | |||
| 236 | + | t.Parallel() | |||
| 237 | + | tr := &Tree{Focus: "b", Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 238 | + | NewLeaf("a"), | |||
| 239 | + | {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}, | |||
| 240 | + | }}} | |||
| 241 | + | before := tr.clone() | |||
| 242 | + | require.ErrorIs(t, tr.Close("missing"), ErrNotFound) | |||
| 243 | + | require.Equal(t, before, tr) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 221 | func TestSolveRejectsStackWithMissingExpanded(t *testing.T) { | 246 | func TestSolveRejectsStackWithMissingExpanded(t *testing.T) { | ||
| 222 | t.Parallel() | 247 | t.Parallel() | ||
| 223 | _, ok := Solve(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "missing"}, domain.Rect{Width: 20, Height: 4}) | 248 | _, ok := Solve(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "missing"}, domain.Rect{Width: 20, Height: 4}) | ||
| @@ -342,6 +367,22 @@ func TestFocusDirEnteringStackExpandsMember(t *testing.T) { | |||||
| 342 | } | 367 | } | ||
| 343 | } | 368 | } | ||
| 344 | 369 | ||||
| 370 | + | func TestFocusDirStackLocalSkipsNonLeafChildren(t *testing.T) { | |||
| 371 | + | t.Parallel() | |||
| 372 | + | tr := &Tree{ | |||
| 373 | + | Root: &Node{Kind: Stack, Children: []*Node{ | |||
| 374 | + | NewLeaf("a"), | |||
| 375 | + | {Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("nested")}}, | |||
| 376 | + | NewLeaf("b"), | |||
| 377 | + | }, Expanded: "a"}, | |||
| 378 | + | Focus: "a", | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | require.NoError(t, tr.FocusDir(Down, domain.Rect{Width: 80, Height: 6})) | |||
| 382 | + | require.Equal(t, PaneID("b"), tr.Focus) | |||
| 383 | + | require.Equal(t, PaneID("b"), tr.Root.Expanded) | |||
| 384 | + | } | |||
| 385 | + | ||||
| 345 | func contents(ps []Placement) map[PaneID]domain.Rect { | 386 | func contents(ps []Placement) map[PaneID]domain.Rect { | ||
| 346 | out := make(map[PaneID]domain.Rect, len(ps)) | 387 | out := make(map[PaneID]domain.Rect, len(ps)) | ||
| 347 | for _, p := range ps { | 388 | for _, p := range ps { | ||
M internal/usecase/layout/ops.go +43 -32
| @@ -68,7 +68,7 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID | |||
| 68 | 68 | // StackNew puts newID in target's stack, creating one if target is not stacked. | |
| 69 | 69 | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | |
| 70 | 70 | candidate := t.clone() | |
| 71 | - | if !stackNew(candidate.Root, target, newID) { | |
| 71 | + | if candidate == nil || !stackNew(candidate.Root, target, newID) { | |
| 72 | 72 | return ErrNotFound | |
| 73 | 73 | } | |
| 74 | 74 | candidate.Focus = newID | |
| @@ -80,6 +80,9 @@ func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | |||
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | func stackNew(n *Node, target, newID PaneID) bool { | |
| 83 | + | if n == nil { | |
| 84 | + | return false | |
| 85 | + | } | |
| 83 | 86 | if n.Kind == Stack { | |
| 84 | 87 | for _, child := range n.Children { | |
| 85 | 88 | if child.Kind == Leaf && child.Leaf == target { | |
| @@ -184,33 +187,36 @@ func splitDirectLeavesContain(n *Node, target PaneID) bool { | |||
| 184 | 187 | ||
| 185 | 188 | // Close removes target and dissolves single-child containers. | |
| 186 | 189 | func (t *Tree) Close(target PaneID) error { | |
| 187 | - | if t.Root == nil || !containsLeaf(t.Root, target) { | |
| 190 | + | candidate := t.clone() | |
| 191 | + | if candidate == nil || candidate.Root == nil || !containsLeaf(candidate.Root, target) { | |
| 188 | 192 | return ErrNotFound | |
| 189 | 193 | } | |
| 190 | - | if t.Root.Kind == Leaf && t.Root.Leaf == target { | |
| 191 | - | t.Root = nil | |
| 192 | - | t.Focus = "" | |
| 194 | + | if candidate.Root.Kind == Leaf && candidate.Root.Leaf == target { | |
| 195 | + | candidate.Root = nil | |
| 196 | + | candidate.Focus = "" | |
| 197 | + | *t = *candidate | |
| 193 | 198 | return nil | |
| 194 | 199 | } | |
| 195 | - | refocus := closeRefocusCandidate(t.Root, target) | |
| 196 | - | root, removed := closeNode(t.Root, target) | |
| 200 | + | refocus := closeRefocusCandidate(candidate.Root, target) | |
| 201 | + | root, removed := closeNode(candidate.Root, target) | |
| 197 | 202 | if !removed { | |
| 198 | 203 | return ErrNotFound | |
| 199 | 204 | } | |
| 200 | - | t.Root = root | |
| 201 | - | if t.Focus == target { | |
| 202 | - | if refocus != "" && containsLeaf(t.Root, refocus) { | |
| 203 | - | t.Focus = refocus | |
| 205 | + | candidate.Root = root | |
| 206 | + | if candidate.Focus == target { | |
| 207 | + | if refocus != "" && containsLeaf(candidate.Root, refocus) { | |
| 208 | + | candidate.Focus = refocus | |
| 204 | 209 | } else { | |
| 205 | - | ids := leafIDs(t.Root) | |
| 210 | + | ids := leafIDs(candidate.Root) | |
| 206 | 211 | if len(ids) > 0 { | |
| 207 | - | t.Focus = ids[0] | |
| 212 | + | candidate.Focus = ids[0] | |
| 208 | 213 | } else { | |
| 209 | - | t.Focus = "" | |
| 214 | + | candidate.Focus = "" | |
| 210 | 215 | } | |
| 211 | 216 | } | |
| 212 | - | setExpanded(t.Root, t.Focus) | |
| 217 | + | setExpanded(candidate.Root, candidate.Focus) | |
| 213 | 218 | } | |
| 219 | + | *t = *candidate | |
| 214 | 220 | return nil | |
| 215 | 221 | } | |
| 216 | 222 | ||
| @@ -412,24 +418,11 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |||
| 412 | 418 | ||
| 413 | 419 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | |
| 414 | 420 | if n.Kind == Stack { | |
| 415 | - | idx := -1 | |
| 416 | - | for i, child := range n.Children { | |
| 417 | - | if child.Kind == Leaf && child.Leaf == id { | |
| 418 | - | idx = i | |
| 419 | - | break | |
| 420 | - | } | |
| 421 | - | } | |
| 421 | + | idx := stackDirectLeafIndex(n, id) | |
| 422 | 422 | if idx >= 0 { | |
| 423 | - | next := idx | |
| 424 | - | if dir == Up && idx > 0 { | |
| 425 | - | next = idx - 1 | |
| 426 | - | } | |
| 427 | - | if dir == Down && idx < len(n.Children)-1 { | |
| 428 | - | next = idx + 1 | |
| 429 | - | } | |
| 430 | - | if next != idx { | |
| 431 | - | n.Expanded = n.Children[next].Leaf | |
| 432 | - | *focus = n.Children[next].Leaf | |
| 423 | + | if next := adjacentStackLeaf(n, idx, dir); next != "" { | |
| 424 | + | n.Expanded = next | |
| 425 | + | *focus = next | |
| 433 | 426 | return true | |
| 434 | 427 | } | |
| 435 | 428 | return false | |
| @@ -443,6 +436,24 @@ func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | |||
| 443 | 436 | return false | |
| 444 | 437 | } | |
| 445 | 438 | ||
| 439 | + | func adjacentStackLeaf(n *Node, idx int, dir Direction) PaneID { | |
| 440 | + | step := 0 | |
| 441 | + | switch dir { | |
| 442 | + | case Up: | |
| 443 | + | step = -1 | |
| 444 | + | case Down: | |
| 445 | + | step = 1 | |
| 446 | + | default: | |
| 447 | + | return "" | |
| 448 | + | } | |
| 449 | + | for i := idx + step; i >= 0 && i < len(n.Children); i += step { | |
| 450 | + | if n.Children[i].Kind == Leaf { | |
| 451 | + | return n.Children[i].Leaf | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | return "" | |
| 455 | + | } | |
| 456 | + | ||
| 446 | 457 | func focusRect(p Placement) domain.Rect { | |
| 447 | 458 | if p.TitleBar.Height == 0 { | |
| 448 | 459 | return p.Content | |
| @@ -68,7 +68,7 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID | |||||
| 68 | // StackNew puts newID in target's stack, creating one if target is not stacked. | 68 | // StackNew puts newID in target's stack, creating one if target is not stacked. | ||
| 69 | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | 69 | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | ||
| 70 | candidate := t.clone() | 70 | candidate := t.clone() | ||
| 71 | - | if !stackNew(candidate.Root, target, newID) { | 71 | + | if candidate == nil || !stackNew(candidate.Root, target, newID) { |
| 72 | return ErrNotFound | 72 | return ErrNotFound | ||
| 73 | } | 73 | } | ||
| 74 | candidate.Focus = newID | 74 | candidate.Focus = newID | ||
| @@ -80,6 +80,9 @@ func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | |||||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | func stackNew(n *Node, target, newID PaneID) bool { | 82 | func stackNew(n *Node, target, newID PaneID) bool { | ||
| 83 | + | if n == nil { | |||
| 84 | + | return false | |||
| 85 | + | } | |||
| 83 | if n.Kind == Stack { | 86 | if n.Kind == Stack { | ||
| 84 | for _, child := range n.Children { | 87 | for _, child := range n.Children { | ||
| 85 | if child.Kind == Leaf && child.Leaf == target { | 88 | if child.Kind == Leaf && child.Leaf == target { | ||
| @@ -184,33 +187,36 @@ func splitDirectLeavesContain(n *Node, target PaneID) bool { | |||||
| 184 | 187 | ||||
| 185 | // Close removes target and dissolves single-child containers. | 188 | // Close removes target and dissolves single-child containers. | ||
| 186 | func (t *Tree) Close(target PaneID) error { | 189 | func (t *Tree) Close(target PaneID) error { | ||
| 187 | - | if t.Root == nil || !containsLeaf(t.Root, target) { | 190 | + | candidate := t.clone() |
| 191 | + | if candidate == nil || candidate.Root == nil || !containsLeaf(candidate.Root, target) { | |||
| 188 | return ErrNotFound | 192 | return ErrNotFound | ||
| 189 | } | 193 | } | ||
| 190 | - | if t.Root.Kind == Leaf && t.Root.Leaf == target { | 194 | + | if candidate.Root.Kind == Leaf && candidate.Root.Leaf == target { |
| 191 | - | t.Root = nil | 195 | + | candidate.Root = nil |
| 192 | - | t.Focus = "" | 196 | + | candidate.Focus = "" |
| 197 | + | *t = *candidate | |||
| 193 | return nil | 198 | return nil | ||
| 194 | } | 199 | } | ||
| 195 | - | refocus := closeRefocusCandidate(t.Root, target) | 200 | + | refocus := closeRefocusCandidate(candidate.Root, target) |
| 196 | - | root, removed := closeNode(t.Root, target) | 201 | + | root, removed := closeNode(candidate.Root, target) |
| 197 | if !removed { | 202 | if !removed { | ||
| 198 | return ErrNotFound | 203 | return ErrNotFound | ||
| 199 | } | 204 | } | ||
| 200 | - | t.Root = root | 205 | + | candidate.Root = root |
| 201 | - | if t.Focus == target { | 206 | + | if candidate.Focus == target { |
| 202 | - | if refocus != "" && containsLeaf(t.Root, refocus) { | 207 | + | if refocus != "" && containsLeaf(candidate.Root, refocus) { |
| 203 | - | t.Focus = refocus | 208 | + | candidate.Focus = refocus |
| 204 | } else { | 209 | } else { | ||
| 205 | - | ids := leafIDs(t.Root) | 210 | + | ids := leafIDs(candidate.Root) |
| 206 | if len(ids) > 0 { | 211 | if len(ids) > 0 { | ||
| 207 | - | t.Focus = ids[0] | 212 | + | candidate.Focus = ids[0] |
| 208 | } else { | 213 | } else { | ||
| 209 | - | t.Focus = "" | 214 | + | candidate.Focus = "" |
| 210 | } | 215 | } | ||
| 211 | } | 216 | } | ||
| 212 | - | setExpanded(t.Root, t.Focus) | 217 | + | setExpanded(candidate.Root, candidate.Focus) |
| 213 | } | 218 | } | ||
| 219 | + | *t = *candidate | |||
| 214 | return nil | 220 | return nil | ||
| 215 | } | 221 | } | ||
| 216 | 222 | ||||
| @@ -412,24 +418,11 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |||||
| 412 | 418 | ||||
| 413 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | 419 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | ||
| 414 | if n.Kind == Stack { | 420 | if n.Kind == Stack { | ||
| 415 | - | idx := -1 | 421 | + | idx := stackDirectLeafIndex(n, id) |
| 416 | - | for i, child := range n.Children { | |||
| 417 | - | if child.Kind == Leaf && child.Leaf == id { | |||
| 418 | - | idx = i | |||
| 419 | - | break | |||
| 420 | - | } | |||
| 421 | - | } | |||
| 422 | if idx >= 0 { | 422 | if idx >= 0 { | ||
| 423 | - | next := idx | 423 | + | if next := adjacentStackLeaf(n, idx, dir); next != "" { |
| 424 | - | if dir == Up && idx > 0 { | 424 | + | n.Expanded = next |
| 425 | - | next = idx - 1 | 425 | + | *focus = next |
| 426 | - | } | |||
| 427 | - | if dir == Down && idx < len(n.Children)-1 { | |||
| 428 | - | next = idx + 1 | |||
| 429 | - | } | |||
| 430 | - | if next != idx { | |||
| 431 | - | n.Expanded = n.Children[next].Leaf | |||
| 432 | - | *focus = n.Children[next].Leaf | |||
| 433 | return true | 426 | return true | ||
| 434 | } | 427 | } | ||
| 435 | return false | 428 | return false | ||
| @@ -443,6 +436,24 @@ func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | |||||
| 443 | return false | 436 | return false | ||
| 444 | } | 437 | } | ||
| 445 | 438 | ||||
| 439 | + | func adjacentStackLeaf(n *Node, idx int, dir Direction) PaneID { | |||
| 440 | + | step := 0 | |||
| 441 | + | switch dir { | |||
| 442 | + | case Up: | |||
| 443 | + | step = -1 | |||
| 444 | + | case Down: | |||
| 445 | + | step = 1 | |||
| 446 | + | default: | |||
| 447 | + | return "" | |||
| 448 | + | } | |||
| 449 | + | for i := idx + step; i >= 0 && i < len(n.Children); i += step { | |||
| 450 | + | if n.Children[i].Kind == Leaf { | |||
| 451 | + | return n.Children[i].Leaf | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | return "" | |||
| 455 | + | } | |||
| 456 | + | ||||
| 446 | func focusRect(p Placement) domain.Rect { | 457 | func focusRect(p Placement) domain.Rect { | ||
| 447 | if p.TitleBar.Height == 0 { | 458 | if p.TitleBar.Height == 0 { | ||
| 448 | return p.Content | 459 | return p.Content | ||
M internal/usecase/layout/tree.go +3 -0
| @@ -89,5 +89,8 @@ func (n *Node) clone() *Node { | |||
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 91 | func (t *Tree) clone() *Tree { | |
| 92 | + | if t == nil { | |
| 93 | + | return nil | |
| 94 | + | } | |
| 92 | 95 | return &Tree{Root: t.Root.clone(), Focus: t.Focus} | |
| 93 | 96 | } | |
| @@ -89,5 +89,8 @@ func (n *Node) clone() *Node { | |||||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | func (t *Tree) clone() *Tree { | 91 | func (t *Tree) clone() *Tree { | ||
| 92 | + | if t == nil { | |||
| 93 | + | return nil | |||
| 94 | + | } | |||
| 92 | return &Tree{Root: t.Root.clone(), Focus: t.Focus} | 95 | return &Tree{Root: t.Root.clone(), Focus: t.Focus} | ||
| 93 | } | 96 | } | ||