1package usecase
2
3import (
4 "fmt"
5
6 "github.com/bnema/dumber/internal/domain/entity"
7)
8
9// MovePaneToTabUseCase moves a pane from one tab's workspace to another.
10//
11// It is pure domain manipulation: it depends only on entities and an ID generator.
12type MovePaneToTabUseCase struct {
13 idGenerator IDGenerator
14}
15
16func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase {
17 return &MovePaneToTabUseCase{idGenerator: idGenerator}
18}
19
20type MovePaneToTabInput struct {
21 TabList *entity.TabList
22 SourceTabID entity.TabID
23 SourcePaneID entity.PaneID
24 TargetTabID entity.TabID // empty means create new tab
25}
26
27type MovePaneToTabOutput struct {
28 TargetTab *entity.Tab
29 MovedPaneNode *entity.PaneNode
30 SourceTabClosed bool
31 NewTabCreated bool
32}
33
34func (uc *MovePaneToTabUseCase) Execute(input MovePaneToTabInput) (*MovePaneToTabOutput, error) {
35 if err := validateMovePaneToTabInput(uc, input); err != nil {
36 return nil, err
37 }
38
39 sourceTab, err := findSourceTab(input.TabList, input.SourceTabID)
40 if err != nil {
41 return nil, err
42 }
43
44 movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID)
45 if err != nil {
46 return nil, err
47 }
48
49 // CORRECTNESS-02: Resolve/prepare the target BEFORE mutating source.
50 // This ensures that target resolution (idGenerator check for new tabs)
51 // and insertion validation happen while source state is still intact.
52 targetTab, newTabCreated, err := uc.resolveTargetTab(input.TabList, input.TargetTabID, movedPane)
53 if err != nil {
54 return nil, err
55 }
56 if targetTab == nil || targetTab.Workspace == nil {
57 return nil, fmt.Errorf("target tab/workspace is nil")
58 }
59
60 // Pre-validate insertion prerequisites before mutating source.
61 // Insertion into an existing non-empty workspace requires idGenerator
62 // for creating split parent nodes (for non-empty targets with Root != nil),
63 // and requires a valid active pane. Fail early if either is unavailable.
64 if !newTabCreated && targetTab.Workspace.Root != nil {
65 if uc.idGenerator == nil {
66 return nil, fmt.Errorf("id generator is required to insert pane into existing workspace")
67 }
68 activePane := targetTab.Workspace.ActivePane()
69 if activePane == nil || activePane.Pane == nil {
70 return nil, fmt.Errorf("target tab has no active pane")
71 }
72 }
73
74 // Now mutate source — pane is detached from original location.
75 if detachErr := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); detachErr != nil {
76 return nil, detachErr
77 }
78
79 sourceTabClosed := closeSourceTabIfEmpty(input.TabList, sourceTab)
80
81 if newTabCreated {
82 input.TabList.Add(targetTab)
83 return &MovePaneToTabOutput{
84 TargetTab: targetTab,
85 MovedPaneNode: targetTab.Workspace.Root,
86 SourceTabClosed: sourceTabClosed,
87 NewTabCreated: true,
88 }, nil
89 }
90
91 movedNode, err := uc.insertIntoTargetWorkspace(targetTab.Workspace, movedPane)
92 if err != nil {
93 return nil, err
94 }
95
96 return &MovePaneToTabOutput{
97 TargetTab: targetTab,
98 MovedPaneNode: movedNode,
99 SourceTabClosed: sourceTabClosed,
100 NewTabCreated: false,
101 }, nil
102}
103
104func validateMovePaneToTabInput(uc *MovePaneToTabUseCase, input MovePaneToTabInput) error {
105 if uc == nil {
106 return fmt.Errorf("move pane to tab use case is nil")
107 }
108 if input.TabList == nil {
109 return fmt.Errorf("tab list is required")
110 }
111 if input.SourceTabID == "" {
112 return fmt.Errorf("source tab id is required")
113 }
114 if input.SourcePaneID == "" {
115 return fmt.Errorf("source pane id is required")
116 }
117 if input.TargetTabID == input.SourceTabID {
118 return fmt.Errorf("cannot move pane to same tab")
119 }
120 return nil
121}
122
123func findSourceTab(tl *entity.TabList, id entity.TabID) (*entity.Tab, error) {
124 sourceTab := tl.Find(id)
125 if sourceTab == nil {
126 return nil, fmt.Errorf("source tab not found: %s", id)
127 }
128 if sourceTab.Workspace == nil {
129 return nil, fmt.Errorf("source workspace is nil")
130 }
131 return sourceTab, nil
132}
133
134func findSourcePane(ws *entity.Workspace, paneID entity.PaneID) (*entity.Pane, *entity.PaneNode, error) {
135 if ws == nil {
136 return nil, nil, fmt.Errorf("workspace is required")
137 }
138 sourceNode := ws.FindPane(paneID)
139 if sourceNode == nil || sourceNode.Pane == nil {
140 return nil, nil, fmt.Errorf("source pane not found: %s", paneID)
141 }
142 return sourceNode.Pane, sourceNode, nil
143}
144
145func closeSourceTabIfEmpty(tl *entity.TabList, sourceTab *entity.Tab) bool {
146 if tl == nil || sourceTab == nil || sourceTab.Workspace == nil {
147 return false
148 }
149 if sourceTab.Workspace.PaneCount() != 0 {
150 return false
151 }
152 return tl.Remove(sourceTab.ID)
153}
154
155func (uc *MovePaneToTabUseCase) resolveTargetTab(
156 tl *entity.TabList,
157 targetID entity.TabID,
158 movedPane *entity.Pane,
159) (*entity.Tab, bool, error) {
160 if tl == nil {
161 return nil, false, fmt.Errorf("tab list is required")
162 }
163 if movedPane == nil {
164 return nil, false, fmt.Errorf("moved pane is required")
165 }
166
167 if targetID != "" {
168 if targetTab := tl.Find(targetID); targetTab != nil {
169 return targetTab, false, nil
170 }
171 // Treat missing as "create new".
172 }
173
174 if uc.idGenerator == nil {
175 return nil, false, fmt.Errorf("id generator is required to create new tab")
176 }
177 tabID := entity.TabID(uc.idGenerator())
178 wsID := entity.WorkspaceID(uc.idGenerator())
179 targetTab := entity.NewTab(tabID, wsID, movedPane)
180 // NOTE: tab is NOT added to TabList here. Addition is deferred until
181 // after successful detach in Execute() (see newTabCreated path).
182 // This prevents leaving a stale/duplicate tab in the list if detach fails
183 // (CORRECTNESS-02).
184 return targetTab, true, nil
185}
186
187func (uc *MovePaneToTabUseCase) insertIntoTargetWorkspace(ws *entity.Workspace, movedPane *entity.Pane) (*entity.PaneNode, error) {
188 if ws == nil {
189 return nil, fmt.Errorf("workspace is required")
190 }
191 if movedPane == nil {
192 return nil, fmt.Errorf("moved pane is required")
193 }
194
195 movedNode := &entity.PaneNode{ID: string(movedPane.ID), Pane: movedPane}
196
197 if ws.Root == nil {
198 ws.Root = movedNode
199 ws.ActivePaneID = movedPane.ID
200 return movedNode, nil
201 }
202
203 targetActive := ws.ActivePane()
204 if targetActive == nil || targetActive.Pane == nil {
205 return nil, fmt.Errorf("target tab has no active pane")
206 }
207
208 if err := insertPaneRightOfActive(ws, targetActive, movedNode, uc.idGenerator); err != nil {
209 return nil, err
210 }
211 ws.ActivePaneID = movedPane.ID
212 return movedNode, nil
213}
214
215func detachPaneFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
216 if ws == nil {
217 return fmt.Errorf("workspace is required")
218 }
219 if leaf == nil || leaf.Pane == nil {
220 return fmt.Errorf("pane node is required")
221 }
222
223 detachedFromStack, err := detachFromStackIfNeeded(ws, leaf)
224 if err != nil {
225 return err
226 }
227 if detachedFromStack {
228 return nil
229 }
230
231 if !leaf.IsLeaf() {
232 return fmt.Errorf("can only move leaf panes")
233 }
234 return detachLeafFromWorkspace(ws, leaf)
235}
236
237func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, error) {
238 if ws == nil || leaf == nil {
239 return false, nil
240 }
241 if leaf.Parent == nil || !leaf.Parent.IsStacked {
242 return false, nil
243 }
244
245 if err := removeLeafFromStackPreservingActive(ws, leaf.Parent, leaf); err != nil {
246 return false, err
247 }
248 return true, nil
249}
250
251func removeLeafFromStackPreservingActive(ws *entity.Workspace, stackNode, leaf *entity.PaneNode) error {
252 if ws == nil {
253 return fmt.Errorf("workspace is required")
254 }
255 if stackNode == nil || !stackNode.IsStacked {
256 return fmt.Errorf("node is not a stack")
257 }
258 if leaf == nil || leaf.Pane == nil {
259 return fmt.Errorf("pane node is required")
260 }
261
262 previousActive := ws.ActivePaneID
263 removedPaneID := leaf.Pane.ID
264 removedIndex := -1
265 activeIndex := stackNode.ActiveStackIndex
266 for i, child := range stackNode.Children {
267 if child == leaf {
268 removedIndex = i
269 }
270 if child != nil && child.Pane != nil && child.Pane.ID == previousActive {
271 activeIndex = i
272 }
273 }
274 if removedIndex < 0 {
275 return fmt.Errorf("pane not found in stack")
276 }
277
278 stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...)
279 leaf.Parent = nil
280
281 // If only one pane remains in stack, dissolve it into a leaf node.
282 if len(stackNode.Children) == 1 {
283 remaining := stackNode.Children[0]
284 stackNode.Pane = remaining.Pane
285 stackNode.Children = nil
286 stackNode.IsStacked = false
287 stackNode.ActiveStackIndex = 0
288 remaining.Parent = nil
289 setActiveAfterPaneDetach(ws, previousActive, removedPaneID, paneIDFromNode(stackNode))
290 return nil
291 }
292
293 if activeIndex == removedIndex {
294 if removedIndex >= len(stackNode.Children) {
295 activeIndex = len(stackNode.Children) - 1
296 } else {
297 activeIndex = removedIndex
298 }
299 } else if activeIndex > removedIndex {
300 activeIndex--
301 }
302 if activeIndex < 0 {
303 activeIndex = 0
304 }
305 if activeIndex >= len(stackNode.Children) {
306 activeIndex = len(stackNode.Children) - 1
307 }
308 stackNode.ActiveStackIndex = activeIndex
309
310 fallbackID := entity.PaneID("")
311 if activeChild := stackNode.ActivePane(); activeChild != nil {
312 fallbackID = paneIDFromNode(activeChild)
313 }
314 setActiveAfterPaneDetach(ws, previousActive, removedPaneID, fallbackID)
315 return nil
316}
317
318func setActiveAfterPaneDetach(ws *entity.Workspace, previousActive, removedPaneID, fallbackID entity.PaneID) {
319 if ws == nil {
320 return
321 }
322 if previousActive != "" && previousActive != removedPaneID && ws.FindPane(previousActive) != nil {
323 ws.ActivePaneID = previousActive
324 return
325 }
326 ws.ActivePaneID = fallbackID
327}
328
329func paneIDFromNode(node *entity.PaneNode) entity.PaneID {
330 if node == nil || node.Pane == nil {
331 return ""
332 }
333 return node.Pane.ID
334}
335
336func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
337 previousActive := ws.ActivePaneID
338 removedPaneID := leaf.Pane.ID
339
340 parent := leaf.Parent
341 if parent == nil {
342 ws.Root = nil
343 ws.ActivePaneID = ""
344 return nil
345 }
346 if !parent.IsSplit() {
347 return fmt.Errorf("pane parent is not a split")
348 }
349
350 sibling := findSibling(parent, leaf)
351 if sibling == nil {
352 return fmt.Errorf("no sibling found")
353 }
354
355 promoteSibling(ws, parent, sibling)
356 leaf.Parent = nil
357 parent.Children = nil
358 setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling))
359 return nil
360}
361
362func findSibling(parent, leaf *entity.PaneNode) *entity.PaneNode {
363 if parent == nil {
364 return nil
365 }
366 for _, child := range parent.Children {
367 if child != leaf {
368 return child
369 }
370 }
371 return nil
372}
373
374func promoteSibling(ws *entity.Workspace, parent, sibling *entity.PaneNode) {
375 if ws == nil || sibling == nil {
376 return
377 }
378
379 grandparent := parent.Parent
380 if grandparent == nil {
381 ws.Root = sibling
382 sibling.Parent = nil
383 return
384 }
385 for i, child := range grandparent.Children {
386 if child == parent {
387 grandparent.Children[i] = sibling
388 break
389 }
390 }
391 sibling.Parent = grandparent
392}
393
394func findFirstLeafPaneID(node *entity.PaneNode) entity.PaneID {
395 if node == nil {
396 return ""
397 }
398 var active entity.PaneID
399 node.Walk(func(n *entity.PaneNode) bool {
400 if n.IsLeaf() && n.Pane != nil {
401 active = n.Pane.ID
402 return false
403 }
404 return true
405 })
406 return active
407}
408
409func insertPaneRightOfActive(ws *entity.Workspace, activeNode, newLeaf *entity.PaneNode, idGen IDGenerator) error {
410 if ws == nil {
411 return fmt.Errorf("workspace is required")
412 }
413 if activeNode == nil {
414 return fmt.Errorf("active pane is required")
415 }
416 if newLeaf == nil || newLeaf.Pane == nil {
417 return fmt.Errorf("new pane node is required")
418 }
419 if idGen == nil {
420 return fmt.Errorf("id generator is required")
421 }
422
423 targetNode := activeNode
424 // Split around stacks when inserting left/right.
425 if targetNode.Parent != nil && targetNode.Parent.IsStacked {
426 targetNode = targetNode.Parent
427 }
428
429 parentID := idGen()
430 splitParent := &entity.PaneNode{
431 ID: parentID,
432 SplitDir: entity.SplitHorizontal,
433 SplitRatio: 0.5,
434 Children: make([]*entity.PaneNode, 2),
435 }
436
437 splitParent.Children[0] = targetNode
438 splitParent.Children[1] = newLeaf
439
440 newLeaf.Parent = splitParent
441 oldParent := targetNode.Parent
442 targetNode.Parent = splitParent
443
444 if oldParent == nil {
445 ws.Root = splitParent
446 } else {
447 for i, child := range oldParent.Children {
448 if child == targetNode {
449 oldParent.Children[i] = splitParent
450 break
451 }
452 }
453 splitParent.Parent = oldParent
454 }
455
456 return nil
457}