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 if detachErr := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); detachErr != nil {
50 return nil, detachErr
51 }
52
53 sourceTabClosed := closeSourceTabIfEmpty(input.TabList, sourceTab)
54
55 targetTab, newTabCreated, err := uc.resolveTargetTab(input.TabList, input.TargetTabID, movedPane)
56 if err != nil {
57 return nil, err
58 }
59 if targetTab == nil || targetTab.Workspace == nil {
60 return nil, fmt.Errorf("target tab/workspace is nil")
61 }
62
63 if newTabCreated {
64 return &MovePaneToTabOutput{
65 TargetTab: targetTab,
66 MovedPaneNode: targetTab.Workspace.Root,
67 SourceTabClosed: sourceTabClosed,
68 NewTabCreated: true,
69 }, nil
70 }
71
72 movedNode, err := uc.insertIntoTargetWorkspace(targetTab.Workspace, movedPane)
73 if err != nil {
74 return nil, err
75 }
76
77 return &MovePaneToTabOutput{
78 TargetTab: targetTab,
79 MovedPaneNode: movedNode,
80 SourceTabClosed: sourceTabClosed,
81 NewTabCreated: false,
82 }, nil
83}
84
85func validateMovePaneToTabInput(uc *MovePaneToTabUseCase, input MovePaneToTabInput) error {
86 if uc == nil {
87 return fmt.Errorf("move pane to tab use case is nil")
88 }
89 if input.TabList == nil {
90 return fmt.Errorf("tab list is required")
91 }
92 if input.SourceTabID == "" {
93 return fmt.Errorf("source tab id is required")
94 }
95 if input.SourcePaneID == "" {
96 return fmt.Errorf("source pane id is required")
97 }
98 if input.TargetTabID == input.SourceTabID {
99 return fmt.Errorf("cannot move pane to same tab")
100 }
101 return nil
102}
103
104func findSourceTab(tl *entity.TabList, id entity.TabID) (*entity.Tab, error) {
105 sourceTab := tl.Find(id)
106 if sourceTab == nil {
107 return nil, fmt.Errorf("source tab not found: %s", id)
108 }
109 if sourceTab.Workspace == nil {
110 return nil, fmt.Errorf("source workspace is nil")
111 }
112 return sourceTab, nil
113}
114
115func findSourcePane(ws *entity.Workspace, paneID entity.PaneID) (*entity.Pane, *entity.PaneNode, error) {
116 if ws == nil {
117 return nil, nil, fmt.Errorf("workspace is required")
118 }
119 sourceNode := ws.FindPane(paneID)
120 if sourceNode == nil || sourceNode.Pane == nil {
121 return nil, nil, fmt.Errorf("source pane not found: %s", paneID)
122 }
123 return sourceNode.Pane, sourceNode, nil
124}
125
126func closeSourceTabIfEmpty(tl *entity.TabList, sourceTab *entity.Tab) bool {
127 if tl == nil || sourceTab == nil || sourceTab.Workspace == nil {
128 return false
129 }
130 if sourceTab.Workspace.PaneCount() != 0 {
131 return false
132 }
133 return tl.Remove(sourceTab.ID)
134}
135
136func (uc *MovePaneToTabUseCase) resolveTargetTab(
137 tl *entity.TabList,
138 targetID entity.TabID,
139 movedPane *entity.Pane,
140) (*entity.Tab, bool, error) {
141 if tl == nil {
142 return nil, false, fmt.Errorf("tab list is required")
143 }
144 if movedPane == nil {
145 return nil, false, fmt.Errorf("moved pane is required")
146 }
147
148 if targetID != "" {
149 if targetTab := tl.Find(targetID); targetTab != nil {
150 return targetTab, false, nil
151 }
152 // Treat missing as "create new".
153 }
154
155 if uc.idGenerator == nil {
156 return nil, false, fmt.Errorf("id generator is required to create new tab")
157 }
158 tabID := entity.TabID(uc.idGenerator())
159 wsID := entity.WorkspaceID(uc.idGenerator())
160 targetTab := entity.NewTab(tabID, wsID, movedPane)
161 tl.Add(targetTab)
162 return targetTab, true, nil
163}
164
165func (uc *MovePaneToTabUseCase) insertIntoTargetWorkspace(ws *entity.Workspace, movedPane *entity.Pane) (*entity.PaneNode, error) {
166 if ws == nil {
167 return nil, fmt.Errorf("workspace is required")
168 }
169 if movedPane == nil {
170 return nil, fmt.Errorf("moved pane is required")
171 }
172
173 movedNode := &entity.PaneNode{ID: string(movedPane.ID), Pane: movedPane}
174
175 if ws.Root == nil {
176 ws.Root = movedNode
177 ws.ActivePaneID = movedPane.ID
178 return movedNode, nil
179 }
180
181 targetActive := ws.ActivePane()
182 if targetActive == nil || targetActive.Pane == nil {
183 return nil, fmt.Errorf("target tab has no active pane")
184 }
185
186 if err := insertPaneRightOfActive(ws, targetActive, movedNode, uc.idGenerator); err != nil {
187 return nil, err
188 }
189 ws.ActivePaneID = movedPane.ID
190 return movedNode, nil
191}
192
193func detachPaneFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
194 if ws == nil {
195 return fmt.Errorf("workspace is required")
196 }
197 if leaf == nil || leaf.Pane == nil {
198 return fmt.Errorf("pane node is required")
199 }
200
201 detachedFromStack, err := detachFromStackIfNeeded(ws, leaf)
202 if err != nil {
203 return err
204 }
205 if detachedFromStack {
206 return nil
207 }
208
209 if !leaf.IsLeaf() {
210 return fmt.Errorf("can only move leaf panes")
211 }
212 return detachLeafFromWorkspace(ws, leaf)
213}
214
215func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, error) {
216 if ws == nil || leaf == nil {
217 return false, nil
218 }
219 if leaf.Parent == nil || !leaf.Parent.IsStacked {
220 return false, nil
221 }
222
223 stackNode := leaf.Parent
224
225 idx := -1
226 for i, child := range stackNode.Children {
227 if child == leaf {
228 idx = i
229 break
230 }
231 }
232 if idx < 0 {
233 return false, fmt.Errorf("pane not found in stack")
234 }
235
236 stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
237 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
238 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
239 }
240 if stackNode.ActiveStackIndex < 0 {
241 stackNode.ActiveStackIndex = 0
242 }
243
244 // If only one pane remains in stack, dissolve it into a leaf node.
245 if len(stackNode.Children) == 1 {
246 remaining := stackNode.Children[0]
247 stackNode.Pane = remaining.Pane
248 stackNode.Children = nil
249 stackNode.IsStacked = false
250 stackNode.ActiveStackIndex = 0
251 if stackNode.Pane != nil {
252 ws.ActivePaneID = stackNode.Pane.ID
253 } else {
254 ws.ActivePaneID = ""
255 }
256 return true, nil
257 }
258
259 // Otherwise, set workspace active pane to the stack's current active.
260 if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil {
261 ws.ActivePaneID = stackNode.ActivePane().Pane.ID
262 }
263 return true, nil
264}
265
266func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
267 parent := leaf.Parent
268 if parent == nil {
269 ws.Root = nil
270 ws.ActivePaneID = ""
271 return nil
272 }
273 if !parent.IsSplit() {
274 return fmt.Errorf("pane parent is not a split")
275 }
276
277 sibling := findSibling(parent, leaf)
278 if sibling == nil {
279 return fmt.Errorf("no sibling found")
280 }
281
282 promoteSibling(ws, parent, sibling)
283 ws.ActivePaneID = findFirstLeafPaneID(sibling)
284 return nil
285}
286
287func findSibling(parent, leaf *entity.PaneNode) *entity.PaneNode {
288 if parent == nil {
289 return nil
290 }
291 for _, child := range parent.Children {
292 if child != leaf {
293 return child
294 }
295 }
296 return nil
297}
298
299func promoteSibling(ws *entity.Workspace, parent, sibling *entity.PaneNode) {
300 if ws == nil || sibling == nil {
301 return
302 }
303
304 grandparent := parent.Parent
305 if grandparent == nil {
306 ws.Root = sibling
307 sibling.Parent = nil
308 return
309 }
310 for i, child := range grandparent.Children {
311 if child == parent {
312 grandparent.Children[i] = sibling
313 break
314 }
315 }
316 sibling.Parent = grandparent
317}
318
319func findFirstLeafPaneID(node *entity.PaneNode) entity.PaneID {
320 if node == nil {
321 return ""
322 }
323 var active entity.PaneID
324 node.Walk(func(n *entity.PaneNode) bool {
325 if n.IsLeaf() && n.Pane != nil {
326 active = n.Pane.ID
327 return false
328 }
329 return true
330 })
331 return active
332}
333
334func insertPaneRightOfActive(ws *entity.Workspace, activeNode, newLeaf *entity.PaneNode, idGen IDGenerator) error {
335 if ws == nil {
336 return fmt.Errorf("workspace is required")
337 }
338 if activeNode == nil {
339 return fmt.Errorf("active pane is required")
340 }
341 if newLeaf == nil || newLeaf.Pane == nil {
342 return fmt.Errorf("new pane node is required")
343 }
344 if idGen == nil {
345 return fmt.Errorf("id generator is required")
346 }
347
348 targetNode := activeNode
349 // Split around stacks when inserting left/right.
350 if targetNode.Parent != nil && targetNode.Parent.IsStacked {
351 targetNode = targetNode.Parent
352 }
353
354 parentID := idGen()
355 splitParent := &entity.PaneNode{
356 ID: parentID,
357 SplitDir: entity.SplitHorizontal,
358 SplitRatio: 0.5,
359 Children: make([]*entity.PaneNode, 2),
360 }
361
362 splitParent.Children[0] = targetNode
363 splitParent.Children[1] = newLeaf
364
365 newLeaf.Parent = splitParent
366 oldParent := targetNode.Parent
367 targetNode.Parent = splitParent
368
369 if oldParent == nil {
370 ws.Root = splitParent
371 } else {
372 for i, child := range oldParent.Children {
373 if child == targetNode {
374 oldParent.Children[i] = splitParent
375 break
376 }
377 }
378 splitParent.Parent = oldParent
379 }
380
381 return nil
382}