move_pane_to_tab.go RawHistoryFolder

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}

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