detached 94df97b

feat(panes): implement move pane to tab use case and configuration

commit
94df97b0577ed5b4630c08f9542e716fdaf317c2
author
brice · · 2025-12-27 15:49 UTC
parent
4c2047b
signature
unsigned
7 files · +649 -23
A internal/application/usecase/move_pane_to_tab.go +382 -0
@@ -0,0 +1,382 @@
1+package usecase
2+
3+import (
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.
12+type MovePaneToTabUseCase struct {
13+ idGenerator IDGenerator
14+}
15+
16+func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase {
17+ return &MovePaneToTabUseCase{idGenerator: idGenerator}
18+}
19+
20+type 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+
27+type MovePaneToTabOutput struct {
28+ TargetTab *entity.Tab
29+ MovedPaneNode *entity.PaneNode
30+ SourceTabClosed bool
31+ NewTabCreated bool
32+}
33+
34+func (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+
85+func 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+
104+func 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+
115+func 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+
126+func 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+
136+func (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+
165+func (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+
193+func 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+
215+func 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+
266+func 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+
287+func 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+
299+func 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+
319+func 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+
334+func 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+}
@@ -0,0 +1,382 @@
1+package usecase
2+
3+import (
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.
12+type MovePaneToTabUseCase struct {
13+ idGenerator IDGenerator
14+}
15+
16+func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase {
17+ return &MovePaneToTabUseCase{idGenerator: idGenerator}
18+}
19+
20+type 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+
27+type MovePaneToTabOutput struct {
28+ TargetTab *entity.Tab
29+ MovedPaneNode *entity.PaneNode
30+ SourceTabClosed bool
31+ NewTabCreated bool
32+}
33+
34+func (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+
85+func 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+
104+func 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+
115+func 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+
126+func 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+
136+func (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+
165+func (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+
193+func 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+
215+func 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+
266+func 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+
287+func 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+
299+func 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+
319+func 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+
334+func 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+}
A internal/application/usecase/move_pane_to_tab_test.go +200 -0
@@ -0,0 +1,200 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestMovePaneToTab_MoveToExistingTab(t *testing.T) {
12+ id := newTestIDGen()
13+ uc := NewMovePaneToTabUseCase(id)
14+
15+ tabs := entity.NewTabList()
16+
17+ paneA := entity.NewPane(entity.PaneID("pA"))
18+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
19+ tabs.Add(tabA)
20+
21+ paneB := entity.NewPane(entity.PaneID("pB"))
22+ tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
23+ tabs.Add(tabB)
24+ // Ensure tabB active pane is paneB.
25+ tabB.Workspace.ActivePaneID = paneB.ID
26+
27+ out, err := uc.Execute(MovePaneToTabInput{
28+ TabList: tabs,
29+ SourceTabID: tabA.ID,
30+ SourcePaneID: paneA.ID,
31+ TargetTabID: tabB.ID,
32+ })
33+ require.NoError(t, err)
34+ require.True(t, out.SourceTabClosed)
35+ require.False(t, out.NewTabCreated)
36+ require.Equal(t, tabB.ID, out.TargetTab.ID)
37+ require.NotNil(t, out.MovedPaneNode)
38+
39+ require.Nil(t, tabs.Find(tabA.ID))
40+ // Inserted as split in tabB.
41+ require.NotNil(t, tabB.Workspace.Root)
42+ require.True(t, tabB.Workspace.Root.IsSplit())
43+ require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID)
44+ require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID)
45+ // Active pane becomes moved pane.
46+ require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID)
47+}
48+
49+func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) {
50+ id := newTestIDGen()
51+ uc := NewMovePaneToTabUseCase(id)
52+
53+ tabs := entity.NewTabList()
54+ paneA := entity.NewPane(entity.PaneID("pA"))
55+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
56+ tabs.Add(tabA)
57+
58+ out, err := uc.Execute(MovePaneToTabInput{
59+ TabList: tabs,
60+ SourceTabID: tabA.ID,
61+ SourcePaneID: paneA.ID,
62+ TargetTabID: "",
63+ })
64+ require.NoError(t, err)
65+ require.True(t, out.NewTabCreated)
66+ require.True(t, out.SourceTabClosed)
67+ require.Equal(t, 1, tabs.Count())
68+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID)
69+ require.NotNil(t, out.TargetTab.Workspace.Root)
70+ require.True(t, out.TargetTab.Workspace.Root.IsLeaf())
71+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID)
72+}
73+
74+func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) {
75+ uc := NewMovePaneToTabUseCase(newTestIDGen())
76+ abs := entity.NewTabList()
77+ pane := entity.NewPane(entity.PaneID("pA"))
78+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
79+ abs.Add(tab)
80+
81+ _, err := uc.Execute(MovePaneToTabInput{
82+ TabList: abs,
83+ SourceTabID: tab.ID,
84+ SourcePaneID: pane.ID,
85+ TargetTabID: tab.ID,
86+ })
87+ require.Error(t, err)
88+}
89+
90+func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) {
91+ uc := NewMovePaneToTabUseCase(newTestIDGen())
92+ tabs := entity.NewTabList()
93+ pane := entity.NewPane(entity.PaneID("pA"))
94+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
95+ tabs.Add(tab)
96+
97+ _, err := uc.Execute(MovePaneToTabInput{
98+ TabList: tabs,
99+ SourceTabID: tab.ID,
100+ SourcePaneID: entity.PaneID("missing"),
101+ TargetTabID: entity.TabID("tB"),
102+ })
103+ require.Error(t, err)
104+}
105+
106+func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) {
107+ id := newTestIDGen()
108+ uc := NewMovePaneToTabUseCase(id)
109+
110+ tabs := entity.NewTabList()
111+
112+ // Source workspace: split with two panes; move one, leaving one.
113+ paneLeft := entity.NewPane(entity.PaneID("pL"))
114+ paneRight := entity.NewPane(entity.PaneID("pR"))
115+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft)
116+ // Build a split root manually.
117+ source.Workspace.Root = &entity.PaneNode{
118+ ID: "root",
119+ SplitDir: entity.SplitHorizontal,
120+ SplitRatio: 0.5,
121+ Children: []*entity.PaneNode{
122+ {ID: string(paneLeft.ID), Pane: paneLeft},
123+ {ID: string(paneRight.ID), Pane: paneRight},
124+ },
125+ }
126+ source.Workspace.Root.Children[0].Parent = source.Workspace.Root
127+ source.Workspace.Root.Children[1].Parent = source.Workspace.Root
128+ source.Workspace.ActivePaneID = paneRight.ID
129+ tabs.Add(source)
130+
131+ targetPane := entity.NewPane(entity.PaneID("pT"))
132+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane)
133+ tabs.Add(target)
134+
135+ out, err := uc.Execute(MovePaneToTabInput{
136+ TabList: tabs,
137+ SourceTabID: source.ID,
138+ SourcePaneID: paneRight.ID,
139+ TargetTabID: target.ID,
140+ })
141+ require.NoError(t, err)
142+ require.False(t, out.SourceTabClosed)
143+ // Source should now be single leaf.
144+ require.Equal(t, 1, source.Workspace.PaneCount())
145+ require.NotNil(t, source.Workspace.Root)
146+ require.True(t, source.Workspace.Root.IsLeaf())
147+ require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID)
148+}
149+
150+func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) {
151+ id := newTestIDGen()
152+ uc := NewMovePaneToTabUseCase(id)
153+
154+ tabs := entity.NewTabList()
155+
156+ paneA := entity.NewPane(entity.PaneID("pA"))
157+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
158+ tabs.Add(source)
159+
160+ // Target workspace: stacked container with two panes; active is second.
161+ pane1 := entity.NewPane(entity.PaneID("p1"))
162+ pane2 := entity.NewPane(entity.PaneID("p2"))
163+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1)
164+ stack := &entity.PaneNode{
165+ ID: "stack",
166+ IsStacked: true,
167+ ActiveStackIndex: 1,
168+ Children: []*entity.PaneNode{
169+ {ID: string(pane1.ID), Pane: pane1},
170+ {ID: string(pane2.ID), Pane: pane2},
171+ },
172+ }
173+ stack.Children[0].Parent = stack
174+ stack.Children[1].Parent = stack
175+ target.Workspace.Root = stack
176+ target.Workspace.ActivePaneID = pane2.ID
177+ tabs.Add(target)
178+
179+ out, err := uc.Execute(MovePaneToTabInput{
180+ TabList: tabs,
181+ SourceTabID: source.ID,
182+ SourcePaneID: paneA.ID,
183+ TargetTabID: target.ID,
184+ })
185+ require.NoError(t, err)
186+ require.NotNil(t, out.TargetTab.Workspace.Root)
187+ require.True(t, out.TargetTab.Workspace.Root.IsSplit())
188+ // Left child should be the stack container.
189+ require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked)
190+ // Right child is moved pane.
191+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID)
192+}
193+
194+func newTestIDGen() func() string {
195+ counter := 0
196+ return func() string {
197+ counter++
198+ return fmt.Sprintf("id%d", counter)
199+ }
200+}
@@ -0,0 +1,200 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestMovePaneToTab_MoveToExistingTab(t *testing.T) {
12+ id := newTestIDGen()
13+ uc := NewMovePaneToTabUseCase(id)
14+
15+ tabs := entity.NewTabList()
16+
17+ paneA := entity.NewPane(entity.PaneID("pA"))
18+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
19+ tabs.Add(tabA)
20+
21+ paneB := entity.NewPane(entity.PaneID("pB"))
22+ tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
23+ tabs.Add(tabB)
24+ // Ensure tabB active pane is paneB.
25+ tabB.Workspace.ActivePaneID = paneB.ID
26+
27+ out, err := uc.Execute(MovePaneToTabInput{
28+ TabList: tabs,
29+ SourceTabID: tabA.ID,
30+ SourcePaneID: paneA.ID,
31+ TargetTabID: tabB.ID,
32+ })
33+ require.NoError(t, err)
34+ require.True(t, out.SourceTabClosed)
35+ require.False(t, out.NewTabCreated)
36+ require.Equal(t, tabB.ID, out.TargetTab.ID)
37+ require.NotNil(t, out.MovedPaneNode)
38+
39+ require.Nil(t, tabs.Find(tabA.ID))
40+ // Inserted as split in tabB.
41+ require.NotNil(t, tabB.Workspace.Root)
42+ require.True(t, tabB.Workspace.Root.IsSplit())
43+ require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID)
44+ require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID)
45+ // Active pane becomes moved pane.
46+ require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID)
47+}
48+
49+func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) {
50+ id := newTestIDGen()
51+ uc := NewMovePaneToTabUseCase(id)
52+
53+ tabs := entity.NewTabList()
54+ paneA := entity.NewPane(entity.PaneID("pA"))
55+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
56+ tabs.Add(tabA)
57+
58+ out, err := uc.Execute(MovePaneToTabInput{
59+ TabList: tabs,
60+ SourceTabID: tabA.ID,
61+ SourcePaneID: paneA.ID,
62+ TargetTabID: "",
63+ })
64+ require.NoError(t, err)
65+ require.True(t, out.NewTabCreated)
66+ require.True(t, out.SourceTabClosed)
67+ require.Equal(t, 1, tabs.Count())
68+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID)
69+ require.NotNil(t, out.TargetTab.Workspace.Root)
70+ require.True(t, out.TargetTab.Workspace.Root.IsLeaf())
71+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID)
72+}
73+
74+func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) {
75+ uc := NewMovePaneToTabUseCase(newTestIDGen())
76+ abs := entity.NewTabList()
77+ pane := entity.NewPane(entity.PaneID("pA"))
78+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
79+ abs.Add(tab)
80+
81+ _, err := uc.Execute(MovePaneToTabInput{
82+ TabList: abs,
83+ SourceTabID: tab.ID,
84+ SourcePaneID: pane.ID,
85+ TargetTabID: tab.ID,
86+ })
87+ require.Error(t, err)
88+}
89+
90+func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) {
91+ uc := NewMovePaneToTabUseCase(newTestIDGen())
92+ tabs := entity.NewTabList()
93+ pane := entity.NewPane(entity.PaneID("pA"))
94+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
95+ tabs.Add(tab)
96+
97+ _, err := uc.Execute(MovePaneToTabInput{
98+ TabList: tabs,
99+ SourceTabID: tab.ID,
100+ SourcePaneID: entity.PaneID("missing"),
101+ TargetTabID: entity.TabID("tB"),
102+ })
103+ require.Error(t, err)
104+}
105+
106+func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) {
107+ id := newTestIDGen()
108+ uc := NewMovePaneToTabUseCase(id)
109+
110+ tabs := entity.NewTabList()
111+
112+ // Source workspace: split with two panes; move one, leaving one.
113+ paneLeft := entity.NewPane(entity.PaneID("pL"))
114+ paneRight := entity.NewPane(entity.PaneID("pR"))
115+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft)
116+ // Build a split root manually.
117+ source.Workspace.Root = &entity.PaneNode{
118+ ID: "root",
119+ SplitDir: entity.SplitHorizontal,
120+ SplitRatio: 0.5,
121+ Children: []*entity.PaneNode{
122+ {ID: string(paneLeft.ID), Pane: paneLeft},
123+ {ID: string(paneRight.ID), Pane: paneRight},
124+ },
125+ }
126+ source.Workspace.Root.Children[0].Parent = source.Workspace.Root
127+ source.Workspace.Root.Children[1].Parent = source.Workspace.Root
128+ source.Workspace.ActivePaneID = paneRight.ID
129+ tabs.Add(source)
130+
131+ targetPane := entity.NewPane(entity.PaneID("pT"))
132+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane)
133+ tabs.Add(target)
134+
135+ out, err := uc.Execute(MovePaneToTabInput{
136+ TabList: tabs,
137+ SourceTabID: source.ID,
138+ SourcePaneID: paneRight.ID,
139+ TargetTabID: target.ID,
140+ })
141+ require.NoError(t, err)
142+ require.False(t, out.SourceTabClosed)
143+ // Source should now be single leaf.
144+ require.Equal(t, 1, source.Workspace.PaneCount())
145+ require.NotNil(t, source.Workspace.Root)
146+ require.True(t, source.Workspace.Root.IsLeaf())
147+ require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID)
148+}
149+
150+func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) {
151+ id := newTestIDGen()
152+ uc := NewMovePaneToTabUseCase(id)
153+
154+ tabs := entity.NewTabList()
155+
156+ paneA := entity.NewPane(entity.PaneID("pA"))
157+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
158+ tabs.Add(source)
159+
160+ // Target workspace: stacked container with two panes; active is second.
161+ pane1 := entity.NewPane(entity.PaneID("p1"))
162+ pane2 := entity.NewPane(entity.PaneID("p2"))
163+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1)
164+ stack := &entity.PaneNode{
165+ ID: "stack",
166+ IsStacked: true,
167+ ActiveStackIndex: 1,
168+ Children: []*entity.PaneNode{
169+ {ID: string(pane1.ID), Pane: pane1},
170+ {ID: string(pane2.ID), Pane: pane2},
171+ },
172+ }
173+ stack.Children[0].Parent = stack
174+ stack.Children[1].Parent = stack
175+ target.Workspace.Root = stack
176+ target.Workspace.ActivePaneID = pane2.ID
177+ tabs.Add(target)
178+
179+ out, err := uc.Execute(MovePaneToTabInput{
180+ TabList: tabs,
181+ SourceTabID: source.ID,
182+ SourcePaneID: paneA.ID,
183+ TargetTabID: target.ID,
184+ })
185+ require.NoError(t, err)
186+ require.NotNil(t, out.TargetTab.Workspace.Root)
187+ require.True(t, out.TargetTab.Workspace.Root.IsSplit())
188+ // Left child should be the stack container.
189+ require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked)
190+ // Right child is moved pane.
191+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID)
192+}
193+
194+func newTestIDGen() func() string {
195+ counter := 0
196+ return func() string {
197+ counter++
198+ return fmt.Sprintf("id%d", counter)
199+ }
200+}
M internal/domain/entity/tab.go +9 -4
@@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool {
7979 tl.Tabs[j].Position = j
8080 }
8181 // Update active tab if needed
82- if tl.ActiveTabID == id && len(tl.Tabs) > 0 {
83- if i < len(tl.Tabs) {
84- tl.ActiveTabID = tl.Tabs[i].ID
82+ if tl.ActiveTabID == id {
83+ if len(tl.Tabs) > 0 {
84+ if i < len(tl.Tabs) {
85+ tl.ActiveTabID = tl.Tabs[i].ID
86+ } else {
87+ tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
88+ }
8589 } else {
86- tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
90+ tl.ActiveTabID = ""
91+ tl.PreviousActiveTabID = ""
8792 }
8893 }
8994 return true
@@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool {
79 tl.Tabs[j].Position = j79 tl.Tabs[j].Position = j
80 }80 }
81 // Update active tab if needed81 // Update active tab if needed
82- if tl.ActiveTabID == id && len(tl.Tabs) > 0 {82+ if tl.ActiveTabID == id {
83- if i < len(tl.Tabs) {83+ if len(tl.Tabs) > 0 {
84- tl.ActiveTabID = tl.Tabs[i].ID84+ if i < len(tl.Tabs) {
85+ tl.ActiveTabID = tl.Tabs[i].ID
86+ } else {
87+ tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
88+ }
85 } else {89 } else {
86- tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID90+ tl.ActiveTabID = ""
91+ tl.PreviousActiveTabID = ""
87 }92 }
88 }93 }
89 return true94 return true
M internal/infrastructure/config/defaults.go +10 -7
@@ -155,17 +155,20 @@ func DefaultConfig() *Config {
155155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
156156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
157157 Workspace: WorkspaceConfig{
158- NewPaneURL: defaultNewPaneURL,
158+ NewPaneURL: defaultNewPaneURL,
159+ SwitchToTabOnMove: true,
159160 PaneMode: PaneModeConfig{
160161 ActivationShortcut: defaultPaneActivationShortcut,
161162 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
162163 Actions: map[string][]string{
163- "split-right": {"arrowright", "r"},
164- "split-left": {"arrowleft", "l"},
165- "split-up": {"arrowup", "u"},
166- "split-down": {"arrowdown", "d"},
167- "stack-pane": {"s"},
168- "close-pane": {"x"},
164+ "split-right": {"arrowright", "r"},
165+ "split-left": {"arrowleft", "l"},
166+ "split-up": {"arrowup", "u"},
167+ "split-down": {"arrowdown", "d"},
168+ "stack-pane": {"s"},
169+ "close-pane": {"x"},
170+ "move-pane-to-tab": {"m"},
171+ "move-pane-to-next-tab": {"M", "shift+m"},
169172
170173 "consume-or-expel-left": {"bracketleft", "["},
171174 "consume-or-expel-right": {"bracketright", "]"},
@@ -155,17 +155,20 @@ func DefaultConfig() *Config {
155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
157 Workspace: WorkspaceConfig{157 Workspace: WorkspaceConfig{
158- NewPaneURL: defaultNewPaneURL,158+ NewPaneURL: defaultNewPaneURL,
159+ SwitchToTabOnMove: true,
159 PaneMode: PaneModeConfig{160 PaneMode: PaneModeConfig{
160 ActivationShortcut: defaultPaneActivationShortcut,161 ActivationShortcut: defaultPaneActivationShortcut,
161 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,162 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
162 Actions: map[string][]string{163 Actions: map[string][]string{
163- "split-right": {"arrowright", "r"},164+ "split-right": {"arrowright", "r"},
164- "split-left": {"arrowleft", "l"},165+ "split-left": {"arrowleft", "l"},
165- "split-up": {"arrowup", "u"},166+ "split-up": {"arrowup", "u"},
166- "split-down": {"arrowdown", "d"},167+ "split-down": {"arrowdown", "d"},
167- "stack-pane": {"s"},168+ "stack-pane": {"s"},
168- "close-pane": {"x"},169+ "close-pane": {"x"},
170+ "move-pane-to-tab": {"m"},
171+ "move-pane-to-next-tab": {"M", "shift+m"},
169 172
170 "consume-or-expel-left": {"bracketleft", "["},173 "consume-or-expel-left": {"bracketleft", "["},
171 "consume-or-expel-right": {"bracketright", "]"},174 "consume-or-expel-right": {"bracketright", "]"},
M internal/infrastructure/config/loader.go +1 -0
@@ -401,6 +401,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
401401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
402402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
403403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
404+ m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
404405 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
405406 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
406407 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
@@ -401,6 +401,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
404+ m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
404 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))405 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
405 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)406 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
406 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)407 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
M internal/infrastructure/config/schema.go +3 -0
@@ -313,6 +313,9 @@ type WorkspaceConfig struct {
313313 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
314314 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
315315 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
316+ // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
317+ // Default: true
318+ SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
316319 // Popups configures default popup placement rules.
317320 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
318321 // Styling configures workspace visual appearance.
@@ -313,6 +313,9 @@ type WorkspaceConfig struct {
313 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`313 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
314 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.314 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
315 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line315 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
316+ // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
317+ // Default: true
318+ SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
316 // Popups configures default popup placement rules.319 // Popups configures default popup placement rules.
317 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`320 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
318 // Styling configures workspace visual appearance.321 // Styling configures workspace visual appearance.
M internal/ui/dispatcher/keyboard.go +44 -12
@@ -17,18 +17,20 @@ import (
1717
1818 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
1919 type KeyboardDispatcher struct {
20- tabCoord *coordinator.TabCoordinator
21- wsCoord *coordinator.WorkspaceCoordinator
22- navCoord *coordinator.NavigationCoordinator
23- zoomUC *usecase.ManageZoomUseCase
24- copyURLUC *usecase.CopyURLUseCase
25- actionHandlers map[input.Action]func(ctx context.Context) error
26- onQuit func()
27- onFindOpen func(ctx context.Context) error
28- onFindNext func(ctx context.Context) error
29- onFindPrev func(ctx context.Context) error
30- onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error
20+ tabCoord *coordinator.TabCoordinator
21+ wsCoord *coordinator.WorkspaceCoordinator
22+ navCoord *coordinator.NavigationCoordinator
23+ zoomUC *usecase.ManageZoomUseCase
24+ copyURLUC *usecase.CopyURLUseCase
25+ actionHandlers map[input.Action]func(ctx context.Context) error
26+ onQuit func()
27+ onFindOpen func(ctx context.Context) error
28+ onFindNext func(ctx context.Context) error
29+ onFindPrev func(ctx context.Context) error
30+ onFindClose func(ctx context.Context) error
31+ onSessionOpen func(ctx context.Context) error
32+ onMovePaneToTab func(ctx context.Context) error
33+ onMovePaneToNext func(ctx context.Context) error
3234 }
3335
3436 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error
8486 d.onSessionOpen = fn
8587 }
8688
89+func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
90+ d.onMovePaneToTab = fn
91+}
92+
93+func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
94+ d.onMovePaneToNext = fn
95+}
96+
8797 func (d *KeyboardDispatcher) initActionHandlers() {
8898 const (
8999 firstTabIndex = 0
@@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
128138 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
129139 input.ActionClosePane: d.wsCoord.ClosePane,
130140 input.ActionStackPane: d.wsCoord.StackPane,
141+ input.ActionMovePaneToTab: func(ctx context.Context) error {
142+ return d.handleMovePaneToTab(ctx)
143+ },
144+ input.ActionMovePaneToNextTab: func(ctx context.Context) error {
145+ return d.handleMovePaneToNextTab(ctx)
146+ },
131147 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
132148 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
133149 },
@@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
239255 return nil
240256 }
241257
258+func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
259+ if d.onMovePaneToTab != nil {
260+ return d.onMovePaneToTab(ctx)
261+ }
262+ logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
263+ return nil
264+}
265+
266+func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
267+ if d.onMovePaneToNext != nil {
268+ return d.onMovePaneToNext(ctx)
269+ }
270+ logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
271+ return nil
272+}
273+
242274 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
243275 if ctx == nil {
244276 return fmt.Errorf("missing context")
@@ -17,18 +17,20 @@ import (
17 17
18 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.18 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
19 type KeyboardDispatcher struct {19 type KeyboardDispatcher struct {
20- tabCoord *coordinator.TabCoordinator20+ tabCoord *coordinator.TabCoordinator
21- wsCoord *coordinator.WorkspaceCoordinator21+ wsCoord *coordinator.WorkspaceCoordinator
22- navCoord *coordinator.NavigationCoordinator22+ navCoord *coordinator.NavigationCoordinator
23- zoomUC *usecase.ManageZoomUseCase23+ zoomUC *usecase.ManageZoomUseCase
24- copyURLUC *usecase.CopyURLUseCase24+ copyURLUC *usecase.CopyURLUseCase
25- actionHandlers map[input.Action]func(ctx context.Context) error25+ actionHandlers map[input.Action]func(ctx context.Context) error
26- onQuit func()26+ onQuit func()
27- onFindOpen func(ctx context.Context) error27+ onFindOpen func(ctx context.Context) error
28- onFindNext func(ctx context.Context) error28+ onFindNext func(ctx context.Context) error
29- onFindPrev func(ctx context.Context) error29+ onFindPrev func(ctx context.Context) error
30- onFindClose func(ctx context.Context) error30+ onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error31+ onSessionOpen func(ctx context.Context) error
32+ onMovePaneToTab func(ctx context.Context) error
33+ onMovePaneToNext func(ctx context.Context) error
32 }34 }
33 35
34 // NewKeyboardDispatcher creates a new KeyboardDispatcher.36 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error
84 d.onSessionOpen = fn86 d.onSessionOpen = fn
85 }87 }
86 88
89+func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
90+ d.onMovePaneToTab = fn
91+}
92+
93+func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
94+ d.onMovePaneToNext = fn
95+}
96+
87 func (d *KeyboardDispatcher) initActionHandlers() {97 func (d *KeyboardDispatcher) initActionHandlers() {
88 const (98 const (
89 firstTabIndex = 099 firstTabIndex = 0
@@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
128 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },138 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
129 input.ActionClosePane: d.wsCoord.ClosePane,139 input.ActionClosePane: d.wsCoord.ClosePane,
130 input.ActionStackPane: d.wsCoord.StackPane,140 input.ActionStackPane: d.wsCoord.StackPane,
141+ input.ActionMovePaneToTab: func(ctx context.Context) error {
142+ return d.handleMovePaneToTab(ctx)
143+ },
144+ input.ActionMovePaneToNextTab: func(ctx context.Context) error {
145+ return d.handleMovePaneToNextTab(ctx)
146+ },
131 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {147 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
132 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)148 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
133 },149 },
@@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
239 return nil255 return nil
240 }256 }
241 257
258+func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
259+ if d.onMovePaneToTab != nil {
260+ return d.onMovePaneToTab(ctx)
261+ }
262+ logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
263+ return nil
264+}
265+
266+func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
267+ if d.onMovePaneToNext != nil {
268+ return d.onMovePaneToNext(ctx)
269+ }
270+ logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
271+ return nil
272+}
273+
242 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {274 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
243 if ctx == nil {275 if ctx == nil {
244 return fmt.Errorf("missing context")276 return fmt.Errorf("missing context")

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