v0.23.2 bfd5eb4

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	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}

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