detached e755f2d

refactor: move focus state mutations from UI to application layer

commit
e755f2d8be670a08d27de7dcbe49769cfe585c75
author
brice · · 2026-01-01 12:52 UTC
parent
6e4ac00
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
618618 Found bool
619619 }
620620
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+
621669 // NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
622670 // Algorithm:
623671 // 1. Get center of active pane
@@ -618,6 +618,54 @@ type GeometricNavigationOutput struct {
618 Found bool618 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 pane671 // 1. Get center of active pane
M internal/ui/focus/manager.go +12 -23
@@ -137,8 +137,12 @@ func (m *Manager) NavigateGeometric(
137137 stackNode := activeNode.Parent
138138 canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)
139139 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
142146 }
143147 // At stack boundary - fall through to geometric navigation
144148 log.Debug().Msg("at stack boundary, using geometric navigation")
@@ -164,26 +168,10 @@ func (m *Manager) NavigateGeometric(
164168 return nil, nil
165169 }
166170
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
187175 }
188176
189177 return targetNode, nil
@@ -192,6 +180,8 @@ func (m *Manager) NavigateGeometric(
192180 // navigateWithinStack tries to navigate within a stack.
193181 // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
194182 // 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.
195185 func (m *Manager) navigateWithinStack(
196186 stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,
197187 ) (bool, entity.PaneID) {
@@ -209,7 +199,6 @@ func (m *Manager) navigateWithinStack(
209199 return false, ""
210200 }
211201
212- stackNode.ActiveStackIndex = newIdx
213202 return m.getPaneIDFromStackChild(stackNode.Children[newIdx])
214203 }
215204
@@ -137,8 +137,12 @@ func (m *Manager) NavigateGeometric(
137 stackNode := activeNode.Parent137 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 = newPaneID140+ // Apply focus change through use case (handles stack index updates)
141- return ws.FindPane(newPaneID), nil141+ 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 navigation147 // 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, nil168 return nil, nil
165 }169 }
166 170
167- // Update workspace171+ // Apply focus change through use case (handles stack index updates)
168- ws.ActivePaneID = output.TargetPaneID172+ 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 stack174+ 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, nil177 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 a181 // 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

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