refactor: move focus state mutations from UI to application layer
- commit
- e755f2d8be670a08d27de7dcbe49769cfe585c75
- author
- brice · · 2026-01-01 12:52 UTC
- parent
- 6e4ac00
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +60 -23
M internal/application/usecase/manage_panes.go +48 -0
| @@ -618,6 +618,54 @@ type GeometricNavigationOutput struct { | |||
| 618 | 618 | Found bool | |
| 619 | 619 | } | |
| 620 | 620 | ||
| 621 | + | // ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack. | |
| 622 | + | // This is the canonical way to change focus to a pane, ensuring stack state is kept in sync. | |
| 623 | + | func (uc *ManagePanesUseCase) ApplyFocusChange( | |
| 624 | + | ctx context.Context, | |
| 625 | + | ws *entity.Workspace, | |
| 626 | + | targetPaneID entity.PaneID, | |
| 627 | + | ) (*entity.PaneNode, error) { | |
| 628 | + | log := logging.FromContext(ctx) | |
| 629 | + | if uc == nil { | |
| 630 | + | return nil, fmt.Errorf("manage panes use case is nil") | |
| 631 | + | } | |
| 632 | + | if ws == nil { | |
| 633 | + | return nil, fmt.Errorf("workspace is required") | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | targetNode := ws.FindPane(targetPaneID) | |
| 637 | + | if targetNode == nil { | |
| 638 | + | return nil, fmt.Errorf("pane not found: %s", targetPaneID) | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | oldActive := ws.ActivePaneID | |
| 642 | + | ws.ActivePaneID = targetPaneID | |
| 643 | + | ||
| 644 | + | // Update stack index if target is in a stack | |
| 645 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 646 | + | oldIndex := targetNode.Parent.ActiveStackIndex | |
| 647 | + | for i, child := range targetNode.Parent.Children { | |
| 648 | + | if child == targetNode { | |
| 649 | + | targetNode.Parent.ActiveStackIndex = i | |
| 650 | + | log.Debug(). | |
| 651 | + | Str("target_pane_id", string(targetPaneID)). | |
| 652 | + | Int("old_stack_index", oldIndex). | |
| 653 | + | Int("new_stack_index", i). | |
| 654 | + | Int("num_children", len(targetNode.Parent.Children)). | |
| 655 | + | Msg("updated stack index for focus change") | |
| 656 | + | break | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | log.Info(). | |
| 662 | + | Str("from", string(oldActive)). | |
| 663 | + | Str("to", string(targetPaneID)). | |
| 664 | + | Msg("focus changed") | |
| 665 | + | ||
| 666 | + | return targetNode, nil | |
| 667 | + | } | |
| 668 | + | ||
| 621 | 669 | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | |
| 622 | 670 | // Algorithm: | |
| 623 | 671 | // 1. Get center of active pane | |
| @@ -618,6 +618,54 @@ type GeometricNavigationOutput struct { | |||||
| 618 | Found bool | 618 | Found bool | ||
| 619 | } | 619 | } | ||
| 620 | 620 | ||||
| 621 | + | // ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack. | |||
| 622 | + | // This is the canonical way to change focus to a pane, ensuring stack state is kept in sync. | |||
| 623 | + | func (uc *ManagePanesUseCase) ApplyFocusChange( | |||
| 624 | + | ctx context.Context, | |||
| 625 | + | ws *entity.Workspace, | |||
| 626 | + | targetPaneID entity.PaneID, | |||
| 627 | + | ) (*entity.PaneNode, error) { | |||
| 628 | + | log := logging.FromContext(ctx) | |||
| 629 | + | if uc == nil { | |||
| 630 | + | return nil, fmt.Errorf("manage panes use case is nil") | |||
| 631 | + | } | |||
| 632 | + | if ws == nil { | |||
| 633 | + | return nil, fmt.Errorf("workspace is required") | |||
| 634 | + | } | |||
| 635 | + | ||||
| 636 | + | targetNode := ws.FindPane(targetPaneID) | |||
| 637 | + | if targetNode == nil { | |||
| 638 | + | return nil, fmt.Errorf("pane not found: %s", targetPaneID) | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | oldActive := ws.ActivePaneID | |||
| 642 | + | ws.ActivePaneID = targetPaneID | |||
| 643 | + | ||||
| 644 | + | // Update stack index if target is in a stack | |||
| 645 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |||
| 646 | + | oldIndex := targetNode.Parent.ActiveStackIndex | |||
| 647 | + | for i, child := range targetNode.Parent.Children { | |||
| 648 | + | if child == targetNode { | |||
| 649 | + | targetNode.Parent.ActiveStackIndex = i | |||
| 650 | + | log.Debug(). | |||
| 651 | + | Str("target_pane_id", string(targetPaneID)). | |||
| 652 | + | Int("old_stack_index", oldIndex). | |||
| 653 | + | Int("new_stack_index", i). | |||
| 654 | + | Int("num_children", len(targetNode.Parent.Children)). | |||
| 655 | + | Msg("updated stack index for focus change") | |||
| 656 | + | break | |||
| 657 | + | } | |||
| 658 | + | } | |||
| 659 | + | } | |||
| 660 | + | ||||
| 661 | + | log.Info(). | |||
| 662 | + | Str("from", string(oldActive)). | |||
| 663 | + | Str("to", string(targetPaneID)). | |||
| 664 | + | Msg("focus changed") | |||
| 665 | + | ||||
| 666 | + | return targetNode, nil | |||
| 667 | + | } | |||
| 668 | + | ||||
| 621 | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | 669 | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | ||
| 622 | // Algorithm: | 670 | // Algorithm: | ||
| 623 | // 1. Get center of active pane | 671 | // 1. Get center of active pane | ||
M internal/ui/focus/manager.go +12 -23
| @@ -137,8 +137,12 @@ func (m *Manager) NavigateGeometric( | |||
| 137 | 137 | stackNode := activeNode.Parent | |
| 138 | 138 | canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction) | |
| 139 | 139 | if canNavigate { | |
| 140 | - | ws.ActivePaneID = newPaneID | |
| 141 | - | return ws.FindPane(newPaneID), nil | |
| 140 | + | // Apply focus change through use case (handles stack index updates) | |
| 141 | + | targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, newPaneID) | |
| 142 | + | if err != nil { | |
| 143 | + | return nil, err | |
| 144 | + | } | |
| 145 | + | return targetNode, nil | |
| 142 | 146 | } | |
| 143 | 147 | // At stack boundary - fall through to geometric navigation | |
| 144 | 148 | log.Debug().Msg("at stack boundary, using geometric navigation") | |
| @@ -164,26 +168,10 @@ func (m *Manager) NavigateGeometric( | |||
| 164 | 168 | return nil, nil | |
| 165 | 169 | } | |
| 166 | 170 | ||
| 167 | - | // Update workspace | |
| 168 | - | ws.ActivePaneID = output.TargetPaneID | |
| 169 | - | ||
| 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 | - | } | |
| 171 | + | // Apply focus change through use case (handles stack index updates) | |
| 172 | + | targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, output.TargetPaneID) | |
| 173 | + | if err != nil { | |
| 174 | + | return nil, err | |
| 187 | 175 | } | |
| 188 | 176 | ||
| 189 | 177 | return targetNode, nil | |
| @@ -192,6 +180,8 @@ func (m *Manager) NavigateGeometric( | |||
| 192 | 180 | // navigateWithinStack tries to navigate within a stack. | |
| 193 | 181 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | |
| 194 | 182 | // boundary and navigation should escape the stack. | |
| 183 | + | // Note: This method does not modify domain state; the caller should use | |
| 184 | + | // ApplyFocusChange to apply the focus change. | |
| 195 | 185 | func (m *Manager) navigateWithinStack( | |
| 196 | 186 | stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection, | |
| 197 | 187 | ) (bool, entity.PaneID) { | |
| @@ -209,7 +199,6 @@ func (m *Manager) navigateWithinStack( | |||
| 209 | 199 | return false, "" | |
| 210 | 200 | } | |
| 211 | 201 | ||
| 212 | - | stackNode.ActiveStackIndex = newIdx | |
| 213 | 202 | return m.getPaneIDFromStackChild(stackNode.Children[newIdx]) | |
| 214 | 203 | } | |
| 215 | 204 | ||
| @@ -137,8 +137,12 @@ func (m *Manager) NavigateGeometric( | |||||
| 137 | stackNode := activeNode.Parent | 137 | stackNode := activeNode.Parent | ||
| 138 | canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction) | 138 | canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction) | ||
| 139 | if canNavigate { | 139 | if canNavigate { | ||
| 140 | - | ws.ActivePaneID = newPaneID | 140 | + | // Apply focus change through use case (handles stack index updates) |
| 141 | - | return ws.FindPane(newPaneID), nil | 141 | + | targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, newPaneID) |
| 142 | + | if err != nil { | |||
| 143 | + | return nil, err | |||
| 144 | + | } | |||
| 145 | + | return targetNode, nil | |||
| 142 | } | 146 | } | ||
| 143 | // At stack boundary - fall through to geometric navigation | 147 | // At stack boundary - fall through to geometric navigation | ||
| 144 | log.Debug().Msg("at stack boundary, using geometric navigation") | 148 | log.Debug().Msg("at stack boundary, using geometric navigation") | ||
| @@ -164,26 +168,10 @@ func (m *Manager) NavigateGeometric( | |||||
| 164 | return nil, nil | 168 | return nil, nil | ||
| 165 | } | 169 | } | ||
| 166 | 170 | ||||
| 167 | - | // Update workspace | 171 | + | // Apply focus change through use case (handles stack index updates) |
| 168 | - | ws.ActivePaneID = output.TargetPaneID | 172 | + | targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, output.TargetPaneID) |
| 169 | - | 173 | + | if err != nil { | |
| 170 | - | // Find target node and update stack index if target is in a stack | 174 | + | return nil, err |
| 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 | } | 175 | } | ||
| 188 | 176 | ||||
| 189 | return targetNode, nil | 177 | return targetNode, nil | ||
| @@ -192,6 +180,8 @@ func (m *Manager) NavigateGeometric( | |||||
| 192 | // navigateWithinStack tries to navigate within a stack. | 180 | // navigateWithinStack tries to navigate within a stack. | ||
| 193 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | 181 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | ||
| 194 | // boundary and navigation should escape the stack. | 182 | // boundary and navigation should escape the stack. | ||
| 183 | + | // Note: This method does not modify domain state; the caller should use | |||
| 184 | + | // ApplyFocusChange to apply the focus change. | |||
| 195 | func (m *Manager) navigateWithinStack( | 185 | func (m *Manager) navigateWithinStack( | ||
| 196 | stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection, | 186 | stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection, | ||
| 197 | ) (bool, entity.PaneID) { | 187 | ) (bool, entity.PaneID) { | ||
| @@ -209,7 +199,6 @@ func (m *Manager) navigateWithinStack( | |||||
| 209 | return false, "" | 199 | return false, "" | ||
| 210 | } | 200 | } | ||
| 211 | 201 | ||||
| 212 | - | stackNode.ActiveStackIndex = newIdx | |||
| 213 | return m.getPaneIDFromStackChild(stackNode.Children[newIdx]) | 202 | return m.getPaneIDFromStackChild(stackNode.Children[newIdx]) | ||
| 214 | } | 203 | } | ||
| 215 | 204 | ||||