detached 5a0e14c

feat(ui): eject panes to new windows

commit
5a0e14c0e79681ac641cd1f773a212b97c6272c3
author
brice · · 2026-06-05 03:29 UTC
committer
brice · · 2026-06-05 04:04 UTC
parent
8336cfb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +1233 -91
M docs/config/index.md +1 -0
@@ -239,6 +239,7 @@ stack-pane = ["s"]
239239 close-pane = ["x"]
240240 move-pane-to-tab = ["m"]
241241 move-pane-to-next-tab = ["M", "shift+m"]
242+eject-pane-to-window = ["w"]
242243
243244 # Consume-or-expel (niri-style) - very alpha
244245 consume-or-expel-left = ["["]
@@ -239,6 +239,7 @@ stack-pane = ["s"]
239 close-pane = ["x"]239 close-pane = ["x"]
240 move-pane-to-tab = ["m"]240 move-pane-to-tab = ["m"]
241 move-pane-to-next-tab = ["M", "shift+m"]241 move-pane-to-next-tab = ["M", "shift+m"]
242+eject-pane-to-window = ["w"]
242 243
243 # Consume-or-expel (niri-style) - very alpha244 # Consume-or-expel (niri-style) - very alpha
244 consume-or-expel-left = ["["]245 consume-or-expel-left = ["["]
M docs/reference/keybindings.md +3 -0
@@ -13,6 +13,8 @@ Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, t
1313
1414 Press `Escape` or `Enter` to exit any mode.
1515
16+Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.
17+
1618 ## Pane Mode (`Ctrl+P`)
1719
1820 | Action | Keys |
@@ -25,6 +27,7 @@ Press `Escape` or `Enter` to exit any mode.
2527 | Close pane | `X` |
2628 | Move to tab | `M` |
2729 | Move to next tab | `Shift+M` |
30+| Eject to window | `W` |
2831 | Focus right | `Shift+→`, `Shift+L` |
2932 | Focus left | `Shift+←`, `Shift+H` |
3033 | Focus up | `Shift+↑`, `Shift+K` |
@@ -13,6 +13,8 @@ Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, t
13 13
14 Press `Escape` or `Enter` to exit any mode.14 Press `Escape` or `Enter` to exit any mode.
15 15
16+Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.
17+
16 ## Pane Mode (`Ctrl+P`)18 ## Pane Mode (`Ctrl+P`)
17 19
18 | Action | Keys |20 | Action | Keys |
@@ -25,6 +27,7 @@ Press `Escape` or `Enter` to exit any mode.
25 | Close pane | `X` |27 | Close pane | `X` |
26 | Move to tab | `M` |28 | Move to tab | `M` |
27 | Move to next tab | `Shift+M` |29 | Move to next tab | `Shift+M` |
30+| Eject to window | `W` |
28 | Focus right | `Shift+→`, `Shift+L` |31 | Focus right | `Shift+→`, `Shift+L` |
29 | Focus left | `Shift+←`, `Shift+H` |32 | Focus left | `Shift+←`, `Shift+H` |
30 | Focus up | `Shift+↑`, `Shift+K` |33 | Focus up | `Shift+↑`, `Shift+K` |
A internal/application/usecase/extract_pane_to_tab_list.go +86 -0
@@ -0,0 +1,86 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// ExtractPaneToTabListUseCase moves a pane from a source tab list into a new
10+// tab in a target tab list. It is pure domain manipulation and has no
11+// knowledge of browser windows or UI objects.
12+type ExtractPaneToTabListUseCase struct {
13+ idGenerator IDGenerator
14+}
15+
16+func NewExtractPaneToTabListUseCase(idGenerator IDGenerator) *ExtractPaneToTabListUseCase {
17+ return &ExtractPaneToTabListUseCase{idGenerator: idGenerator}
18+}
19+
20+type ExtractPaneToTabListInput struct {
21+ SourceTabs *entity.TabList
22+ SourceTabID entity.TabID
23+ SourcePaneID entity.PaneID
24+ TargetTabs *entity.TabList
25+}
26+
27+type ExtractPaneToTabListOutput struct {
28+ NewTab *entity.Tab
29+ MovedPaneNode *entity.PaneNode
30+ SourceTabClosed bool
31+}
32+
33+func (uc *ExtractPaneToTabListUseCase) Execute(input ExtractPaneToTabListInput) (*ExtractPaneToTabListOutput, error) {
34+ if err := validateExtractPaneToTabListInput(uc, input); err != nil {
35+ return nil, err
36+ }
37+
38+ sourceTab, err := findSourceTab(input.SourceTabs, input.SourceTabID)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID)
44+ if err != nil {
45+ return nil, err
46+ }
47+
48+ if err := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); err != nil {
49+ return nil, err
50+ }
51+
52+ sourceTabClosed := closeSourceTabIfEmpty(input.SourceTabs, sourceTab)
53+
54+ tabID := entity.TabID(uc.idGenerator())
55+ workspaceID := entity.WorkspaceID(uc.idGenerator())
56+ newTab := entity.NewTab(tabID, workspaceID, movedPane)
57+ input.TargetTabs.Add(newTab)
58+
59+ return &ExtractPaneToTabListOutput{
60+ NewTab: newTab,
61+ MovedPaneNode: newTab.Workspace.Root,
62+ SourceTabClosed: sourceTabClosed,
63+ }, nil
64+}
65+
66+func validateExtractPaneToTabListInput(uc *ExtractPaneToTabListUseCase, input ExtractPaneToTabListInput) error {
67+ if uc == nil {
68+ return fmt.Errorf("extract pane to tab list use case is nil")
69+ }
70+ if input.SourceTabs == nil {
71+ return fmt.Errorf("source tabs are required")
72+ }
73+ if input.TargetTabs == nil {
74+ return fmt.Errorf("target tabs are required")
75+ }
76+ if input.SourceTabID == "" {
77+ return fmt.Errorf("source tab id is required")
78+ }
79+ if input.SourcePaneID == "" {
80+ return fmt.Errorf("source pane id is required")
81+ }
82+ if uc.idGenerator == nil {
83+ return fmt.Errorf("id generator is required")
84+ }
85+ return nil
86+}
@@ -0,0 +1,86 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// ExtractPaneToTabListUseCase moves a pane from a source tab list into a new
10+// tab in a target tab list. It is pure domain manipulation and has no
11+// knowledge of browser windows or UI objects.
12+type ExtractPaneToTabListUseCase struct {
13+ idGenerator IDGenerator
14+}
15+
16+func NewExtractPaneToTabListUseCase(idGenerator IDGenerator) *ExtractPaneToTabListUseCase {
17+ return &ExtractPaneToTabListUseCase{idGenerator: idGenerator}
18+}
19+
20+type ExtractPaneToTabListInput struct {
21+ SourceTabs *entity.TabList
22+ SourceTabID entity.TabID
23+ SourcePaneID entity.PaneID
24+ TargetTabs *entity.TabList
25+}
26+
27+type ExtractPaneToTabListOutput struct {
28+ NewTab *entity.Tab
29+ MovedPaneNode *entity.PaneNode
30+ SourceTabClosed bool
31+}
32+
33+func (uc *ExtractPaneToTabListUseCase) Execute(input ExtractPaneToTabListInput) (*ExtractPaneToTabListOutput, error) {
34+ if err := validateExtractPaneToTabListInput(uc, input); err != nil {
35+ return nil, err
36+ }
37+
38+ sourceTab, err := findSourceTab(input.SourceTabs, input.SourceTabID)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID)
44+ if err != nil {
45+ return nil, err
46+ }
47+
48+ if err := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); err != nil {
49+ return nil, err
50+ }
51+
52+ sourceTabClosed := closeSourceTabIfEmpty(input.SourceTabs, sourceTab)
53+
54+ tabID := entity.TabID(uc.idGenerator())
55+ workspaceID := entity.WorkspaceID(uc.idGenerator())
56+ newTab := entity.NewTab(tabID, workspaceID, movedPane)
57+ input.TargetTabs.Add(newTab)
58+
59+ return &ExtractPaneToTabListOutput{
60+ NewTab: newTab,
61+ MovedPaneNode: newTab.Workspace.Root,
62+ SourceTabClosed: sourceTabClosed,
63+ }, nil
64+}
65+
66+func validateExtractPaneToTabListInput(uc *ExtractPaneToTabListUseCase, input ExtractPaneToTabListInput) error {
67+ if uc == nil {
68+ return fmt.Errorf("extract pane to tab list use case is nil")
69+ }
70+ if input.SourceTabs == nil {
71+ return fmt.Errorf("source tabs are required")
72+ }
73+ if input.TargetTabs == nil {
74+ return fmt.Errorf("target tabs are required")
75+ }
76+ if input.SourceTabID == "" {
77+ return fmt.Errorf("source tab id is required")
78+ }
79+ if input.SourcePaneID == "" {
80+ return fmt.Errorf("source pane id is required")
81+ }
82+ if uc.idGenerator == nil {
83+ return fmt.Errorf("id generator is required")
84+ }
85+ return nil
86+}
A internal/application/usecase/extract_pane_to_tab_list_test.go +225 -0
@@ -0,0 +1,225 @@
1+package usecase
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestExtractPaneToTabList_FromThreePaneStack(t *testing.T) {
11+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
12+ sourceTabs := entity.NewTabList()
13+ targetTabs := entity.NewTabList()
14+
15+ paneA := entity.NewPane(entity.PaneID("pA"))
16+ paneB := entity.NewPane(entity.PaneID("pB"))
17+ paneC := entity.NewPane(entity.PaneID("pC"))
18+ sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC)
19+ sourceTab.Workspace.ActivePaneID = paneB.ID
20+ sourceTabs.Add(sourceTab)
21+
22+ out, err := uc.Execute(ExtractPaneToTabListInput{
23+ SourceTabs: sourceTabs,
24+ SourceTabID: sourceTab.ID,
25+ SourcePaneID: paneB.ID,
26+ TargetTabs: targetTabs,
27+ })
28+ require.NoError(t, err)
29+ require.False(t, out.SourceTabClosed)
30+ require.NotNil(t, out.NewTab)
31+ require.NotNil(t, out.MovedPaneNode)
32+
33+ require.True(t, sourceTab.Workspace.Root.IsStacked)
34+ require.Len(t, sourceTab.Workspace.Root.Children, 2)
35+ require.Equal(t, paneA.ID, sourceTab.Workspace.Root.Children[0].Pane.ID)
36+ require.Equal(t, paneC.ID, sourceTab.Workspace.Root.Children[1].Pane.ID)
37+ require.Contains(t, []entity.PaneID{paneA.ID, paneC.ID}, sourceTab.Workspace.ActivePaneID)
38+ require.Same(t, sourceTab.Workspace.Root, sourceTab.Workspace.Root.Children[0].Parent)
39+ require.Same(t, sourceTab.Workspace.Root, sourceTab.Workspace.Root.Children[1].Parent)
40+
41+ require.Equal(t, 1, targetTabs.Count())
42+ require.Same(t, out.NewTab, targetTabs.TabAt(0))
43+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
44+ require.Nil(t, out.NewTab.Workspace.Root.Parent)
45+ require.Nil(t, out.MovedPaneNode.Parent)
46+}
47+
48+func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) {
49+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
50+ sourceTabs := entity.NewTabList()
51+ targetTabs := entity.NewTabList()
52+
53+ paneA := entity.NewPane(entity.PaneID("pA"))
54+ paneB := entity.NewPane(entity.PaneID("pB"))
55+ sourceTab := newStackedTab("tA", "wA", paneA, paneB)
56+ sourceTab.Workspace.ActivePaneID = paneB.ID
57+ sourceTabs.Add(sourceTab)
58+
59+ out, err := uc.Execute(ExtractPaneToTabListInput{
60+ SourceTabs: sourceTabs,
61+ SourceTabID: sourceTab.ID,
62+ SourcePaneID: paneB.ID,
63+ TargetTabs: targetTabs,
64+ })
65+ require.NoError(t, err)
66+ require.False(t, out.SourceTabClosed)
67+
68+ require.NotNil(t, sourceTab.Workspace.Root)
69+ require.True(t, sourceTab.Workspace.Root.IsLeaf())
70+ require.Same(t, paneA, sourceTab.Workspace.Root.Pane)
71+ require.Nil(t, sourceTab.Workspace.Root.Parent)
72+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
73+ require.Nil(t, out.NewTab.Workspace.Root.Parent)
74+ require.Nil(t, out.MovedPaneNode.Parent)
75+}
76+
77+func TestExtractPaneToTabList_LastPaneClosesSourceTab(t *testing.T) {
78+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
79+ sourceTabs := entity.NewTabList()
80+ targetTabs := entity.NewTabList()
81+
82+ pane := entity.NewPane(entity.PaneID("pA"))
83+ sourceTab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
84+ sourceTabs.Add(sourceTab)
85+
86+ out, err := uc.Execute(ExtractPaneToTabListInput{
87+ SourceTabs: sourceTabs,
88+ SourceTabID: sourceTab.ID,
89+ SourcePaneID: pane.ID,
90+ TargetTabs: targetTabs,
91+ })
92+ require.NoError(t, err)
93+ require.True(t, out.SourceTabClosed)
94+ require.Nil(t, sourceTabs.Find(sourceTab.ID))
95+ require.Equal(t, 0, sourceTabs.Count())
96+ require.Equal(t, 1, targetTabs.Count())
97+ require.Same(t, pane, out.NewTab.Workspace.Root.Pane)
98+}
99+
100+func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) {
101+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
102+ sourceTabs := entity.NewTabList()
103+ targetTabs := entity.NewTabList()
104+
105+ paneA := entity.NewPane(entity.PaneID("pA"))
106+ paneB := entity.NewPane(entity.PaneID("pB"))
107+ sourceTab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
108+ removedSplit := &entity.PaneNode{ID: "split", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
109+ movedLeaf := &entity.PaneNode{ID: "pA", Pane: paneA, Parent: removedSplit}
110+ otherLeaf := &entity.PaneNode{ID: "pB", Pane: paneB, Parent: removedSplit}
111+ removedSplit.Children = []*entity.PaneNode{movedLeaf, otherLeaf}
112+ sourceTab.Workspace.Root = removedSplit
113+ sourceTab.Workspace.ActivePaneID = paneA.ID
114+ sourceTabs.Add(sourceTab)
115+
116+ out, err := uc.Execute(ExtractPaneToTabListInput{
117+ SourceTabs: sourceTabs,
118+ SourceTabID: sourceTab.ID,
119+ SourcePaneID: paneA.ID,
120+ TargetTabs: targetTabs,
121+ })
122+ require.NoError(t, err)
123+ require.False(t, out.SourceTabClosed)
124+
125+ require.Same(t, otherLeaf, sourceTab.Workspace.Root)
126+ require.Nil(t, sourceTab.Workspace.Root.Parent)
127+ require.Nil(t, out.MovedPaneNode.Parent)
128+ require.Empty(t, removedSplit.Children)
129+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
130+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
131+}
132+
133+func TestExtractPaneToTabList_InvalidSourceTabID(t *testing.T) {
134+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
135+ sourceTabs := entity.NewTabList()
136+ sourceTabs.Add(entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a")))
137+
138+ _, err := uc.Execute(ExtractPaneToTabListInput{
139+ SourceTabs: sourceTabs,
140+ SourceTabID: "missing-tab",
141+ SourcePaneID: "pane-a",
142+ TargetTabs: entity.NewTabList(),
143+ })
144+ require.Error(t, err)
145+}
146+
147+func TestExtractPaneToTabList_InvalidSourcePaneID(t *testing.T) {
148+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
149+ sourceTabs := entity.NewTabList()
150+ sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
151+ sourceTabs.Add(sourceTab)
152+
153+ _, err := uc.Execute(ExtractPaneToTabListInput{
154+ SourceTabs: sourceTabs,
155+ SourceTabID: sourceTab.ID,
156+ SourcePaneID: "missing-pane",
157+ TargetTabs: entity.NewTabList(),
158+ })
159+ require.Error(t, err)
160+}
161+
162+func TestExtractPaneToTabList_NilInputs(t *testing.T) {
163+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
164+
165+ _, err := uc.Execute(ExtractPaneToTabListInput{
166+ SourceTabID: "source-tab",
167+ SourcePaneID: "pane-a",
168+ TargetTabs: entity.NewTabList(),
169+ })
170+ require.Error(t, err)
171+
172+ _, err = uc.Execute(ExtractPaneToTabListInput{
173+ SourceTabs: entity.NewTabList(),
174+ SourceTabID: "source-tab",
175+ SourcePaneID: "pane-a",
176+ })
177+ require.Error(t, err)
178+}
179+
180+func TestExtractPaneToTabList_ParentPointerTreeIntegrity(t *testing.T) {
181+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
182+ sourceTabs := entity.NewTabList()
183+ targetTabs := entity.NewTabList()
184+
185+ paneA := entity.NewPane(entity.PaneID("pA"))
186+ paneB := entity.NewPane(entity.PaneID("pB"))
187+ paneC := entity.NewPane(entity.PaneID("pC"))
188+ sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC)
189+ sourceTabs.Add(sourceTab)
190+
191+ out, err := uc.Execute(ExtractPaneToTabListInput{
192+ SourceTabs: sourceTabs,
193+ SourceTabID: sourceTab.ID,
194+ SourcePaneID: paneB.ID,
195+ TargetTabs: targetTabs,
196+ })
197+ require.NoError(t, err)
198+
199+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
200+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
201+ require.Nil(t, out.MovedPaneNode.Parent)
202+}
203+
204+func newStackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, panes ...*entity.Pane) *entity.Tab {
205+ tab := entity.NewTab(tabID, workspaceID, panes[0])
206+ stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
207+ for _, pane := range panes {
208+ child := &entity.PaneNode{ID: string(pane.ID), Pane: pane, Parent: stack}
209+ stack.Children = append(stack.Children, child)
210+ }
211+ tab.Workspace.Root = stack
212+ tab.Workspace.ActivePaneID = panes[0].ID
213+ return tab
214+}
215+
216+func assertParentPointers(t *testing.T, node, wantParent *entity.PaneNode) {
217+ t.Helper()
218+ if node == nil {
219+ return
220+ }
221+ require.Same(t, wantParent, node.Parent)
222+ for _, child := range node.Children {
223+ assertParentPointers(t, child, node)
224+ }
225+}
@@ -0,0 +1,225 @@
1+package usecase
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestExtractPaneToTabList_FromThreePaneStack(t *testing.T) {
11+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
12+ sourceTabs := entity.NewTabList()
13+ targetTabs := entity.NewTabList()
14+
15+ paneA := entity.NewPane(entity.PaneID("pA"))
16+ paneB := entity.NewPane(entity.PaneID("pB"))
17+ paneC := entity.NewPane(entity.PaneID("pC"))
18+ sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC)
19+ sourceTab.Workspace.ActivePaneID = paneB.ID
20+ sourceTabs.Add(sourceTab)
21+
22+ out, err := uc.Execute(ExtractPaneToTabListInput{
23+ SourceTabs: sourceTabs,
24+ SourceTabID: sourceTab.ID,
25+ SourcePaneID: paneB.ID,
26+ TargetTabs: targetTabs,
27+ })
28+ require.NoError(t, err)
29+ require.False(t, out.SourceTabClosed)
30+ require.NotNil(t, out.NewTab)
31+ require.NotNil(t, out.MovedPaneNode)
32+
33+ require.True(t, sourceTab.Workspace.Root.IsStacked)
34+ require.Len(t, sourceTab.Workspace.Root.Children, 2)
35+ require.Equal(t, paneA.ID, sourceTab.Workspace.Root.Children[0].Pane.ID)
36+ require.Equal(t, paneC.ID, sourceTab.Workspace.Root.Children[1].Pane.ID)
37+ require.Contains(t, []entity.PaneID{paneA.ID, paneC.ID}, sourceTab.Workspace.ActivePaneID)
38+ require.Same(t, sourceTab.Workspace.Root, sourceTab.Workspace.Root.Children[0].Parent)
39+ require.Same(t, sourceTab.Workspace.Root, sourceTab.Workspace.Root.Children[1].Parent)
40+
41+ require.Equal(t, 1, targetTabs.Count())
42+ require.Same(t, out.NewTab, targetTabs.TabAt(0))
43+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
44+ require.Nil(t, out.NewTab.Workspace.Root.Parent)
45+ require.Nil(t, out.MovedPaneNode.Parent)
46+}
47+
48+func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) {
49+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
50+ sourceTabs := entity.NewTabList()
51+ targetTabs := entity.NewTabList()
52+
53+ paneA := entity.NewPane(entity.PaneID("pA"))
54+ paneB := entity.NewPane(entity.PaneID("pB"))
55+ sourceTab := newStackedTab("tA", "wA", paneA, paneB)
56+ sourceTab.Workspace.ActivePaneID = paneB.ID
57+ sourceTabs.Add(sourceTab)
58+
59+ out, err := uc.Execute(ExtractPaneToTabListInput{
60+ SourceTabs: sourceTabs,
61+ SourceTabID: sourceTab.ID,
62+ SourcePaneID: paneB.ID,
63+ TargetTabs: targetTabs,
64+ })
65+ require.NoError(t, err)
66+ require.False(t, out.SourceTabClosed)
67+
68+ require.NotNil(t, sourceTab.Workspace.Root)
69+ require.True(t, sourceTab.Workspace.Root.IsLeaf())
70+ require.Same(t, paneA, sourceTab.Workspace.Root.Pane)
71+ require.Nil(t, sourceTab.Workspace.Root.Parent)
72+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
73+ require.Nil(t, out.NewTab.Workspace.Root.Parent)
74+ require.Nil(t, out.MovedPaneNode.Parent)
75+}
76+
77+func TestExtractPaneToTabList_LastPaneClosesSourceTab(t *testing.T) {
78+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
79+ sourceTabs := entity.NewTabList()
80+ targetTabs := entity.NewTabList()
81+
82+ pane := entity.NewPane(entity.PaneID("pA"))
83+ sourceTab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
84+ sourceTabs.Add(sourceTab)
85+
86+ out, err := uc.Execute(ExtractPaneToTabListInput{
87+ SourceTabs: sourceTabs,
88+ SourceTabID: sourceTab.ID,
89+ SourcePaneID: pane.ID,
90+ TargetTabs: targetTabs,
91+ })
92+ require.NoError(t, err)
93+ require.True(t, out.SourceTabClosed)
94+ require.Nil(t, sourceTabs.Find(sourceTab.ID))
95+ require.Equal(t, 0, sourceTabs.Count())
96+ require.Equal(t, 1, targetTabs.Count())
97+ require.Same(t, pane, out.NewTab.Workspace.Root.Pane)
98+}
99+
100+func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) {
101+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
102+ sourceTabs := entity.NewTabList()
103+ targetTabs := entity.NewTabList()
104+
105+ paneA := entity.NewPane(entity.PaneID("pA"))
106+ paneB := entity.NewPane(entity.PaneID("pB"))
107+ sourceTab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
108+ removedSplit := &entity.PaneNode{ID: "split", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
109+ movedLeaf := &entity.PaneNode{ID: "pA", Pane: paneA, Parent: removedSplit}
110+ otherLeaf := &entity.PaneNode{ID: "pB", Pane: paneB, Parent: removedSplit}
111+ removedSplit.Children = []*entity.PaneNode{movedLeaf, otherLeaf}
112+ sourceTab.Workspace.Root = removedSplit
113+ sourceTab.Workspace.ActivePaneID = paneA.ID
114+ sourceTabs.Add(sourceTab)
115+
116+ out, err := uc.Execute(ExtractPaneToTabListInput{
117+ SourceTabs: sourceTabs,
118+ SourceTabID: sourceTab.ID,
119+ SourcePaneID: paneA.ID,
120+ TargetTabs: targetTabs,
121+ })
122+ require.NoError(t, err)
123+ require.False(t, out.SourceTabClosed)
124+
125+ require.Same(t, otherLeaf, sourceTab.Workspace.Root)
126+ require.Nil(t, sourceTab.Workspace.Root.Parent)
127+ require.Nil(t, out.MovedPaneNode.Parent)
128+ require.Empty(t, removedSplit.Children)
129+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
130+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
131+}
132+
133+func TestExtractPaneToTabList_InvalidSourceTabID(t *testing.T) {
134+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
135+ sourceTabs := entity.NewTabList()
136+ sourceTabs.Add(entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a")))
137+
138+ _, err := uc.Execute(ExtractPaneToTabListInput{
139+ SourceTabs: sourceTabs,
140+ SourceTabID: "missing-tab",
141+ SourcePaneID: "pane-a",
142+ TargetTabs: entity.NewTabList(),
143+ })
144+ require.Error(t, err)
145+}
146+
147+func TestExtractPaneToTabList_InvalidSourcePaneID(t *testing.T) {
148+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
149+ sourceTabs := entity.NewTabList()
150+ sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
151+ sourceTabs.Add(sourceTab)
152+
153+ _, err := uc.Execute(ExtractPaneToTabListInput{
154+ SourceTabs: sourceTabs,
155+ SourceTabID: sourceTab.ID,
156+ SourcePaneID: "missing-pane",
157+ TargetTabs: entity.NewTabList(),
158+ })
159+ require.Error(t, err)
160+}
161+
162+func TestExtractPaneToTabList_NilInputs(t *testing.T) {
163+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
164+
165+ _, err := uc.Execute(ExtractPaneToTabListInput{
166+ SourceTabID: "source-tab",
167+ SourcePaneID: "pane-a",
168+ TargetTabs: entity.NewTabList(),
169+ })
170+ require.Error(t, err)
171+
172+ _, err = uc.Execute(ExtractPaneToTabListInput{
173+ SourceTabs: entity.NewTabList(),
174+ SourceTabID: "source-tab",
175+ SourcePaneID: "pane-a",
176+ })
177+ require.Error(t, err)
178+}
179+
180+func TestExtractPaneToTabList_ParentPointerTreeIntegrity(t *testing.T) {
181+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
182+ sourceTabs := entity.NewTabList()
183+ targetTabs := entity.NewTabList()
184+
185+ paneA := entity.NewPane(entity.PaneID("pA"))
186+ paneB := entity.NewPane(entity.PaneID("pB"))
187+ paneC := entity.NewPane(entity.PaneID("pC"))
188+ sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC)
189+ sourceTabs.Add(sourceTab)
190+
191+ out, err := uc.Execute(ExtractPaneToTabListInput{
192+ SourceTabs: sourceTabs,
193+ SourceTabID: sourceTab.ID,
194+ SourcePaneID: paneB.ID,
195+ TargetTabs: targetTabs,
196+ })
197+ require.NoError(t, err)
198+
199+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
200+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
201+ require.Nil(t, out.MovedPaneNode.Parent)
202+}
203+
204+func newStackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, panes ...*entity.Pane) *entity.Tab {
205+ tab := entity.NewTab(tabID, workspaceID, panes[0])
206+ stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
207+ for _, pane := range panes {
208+ child := &entity.PaneNode{ID: string(pane.ID), Pane: pane, Parent: stack}
209+ stack.Children = append(stack.Children, child)
210+ }
211+ tab.Workspace.Root = stack
212+ tab.Workspace.ActivePaneID = panes[0].ID
213+ return tab
214+}
215+
216+func assertParentPointers(t *testing.T, node, wantParent *entity.PaneNode) {
217+ t.Helper()
218+ if node == nil {
219+ return
220+ }
221+ require.Same(t, wantParent, node.Parent)
222+ for _, child := range node.Children {
223+ assertParentPointers(t, child, node)
224+ }
225+}
M internal/application/usecase/move_pane_to_tab.go +4 -0
@@ -234,6 +234,7 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
234234 }
235235
236236 stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
237+ leaf.Parent = nil
237238 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
238239 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
239240 }
@@ -248,6 +249,7 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
248249 stackNode.Children = nil
249250 stackNode.IsStacked = false
250251 stackNode.ActiveStackIndex = 0
252+ remaining.Parent = nil
251253 if stackNode.Pane != nil {
252254 ws.ActivePaneID = stackNode.Pane.ID
253255 } else {
@@ -280,6 +282,8 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error
280282 }
281283
282284 promoteSibling(ws, parent, sibling)
285+ leaf.Parent = nil
286+ parent.Children = nil
283287 ws.ActivePaneID = findFirstLeafPaneID(sibling)
284288 return nil
285289 }
@@ -234,6 +234,7 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
234 }234 }
235 235
236 stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)236 stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
237+ leaf.Parent = nil
237 if stackNode.ActiveStackIndex >= len(stackNode.Children) {238 if stackNode.ActiveStackIndex >= len(stackNode.Children) {
238 stackNode.ActiveStackIndex = len(stackNode.Children) - 1239 stackNode.ActiveStackIndex = len(stackNode.Children) - 1
239 }240 }
@@ -248,6 +249,7 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
248 stackNode.Children = nil249 stackNode.Children = nil
249 stackNode.IsStacked = false250 stackNode.IsStacked = false
250 stackNode.ActiveStackIndex = 0251 stackNode.ActiveStackIndex = 0
252+ remaining.Parent = nil
251 if stackNode.Pane != nil {253 if stackNode.Pane != nil {
252 ws.ActivePaneID = stackNode.Pane.ID254 ws.ActivePaneID = stackNode.Pane.ID
253 } else {255 } else {
@@ -280,6 +282,8 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error
280 }282 }
281 283
282 promoteSibling(ws, parent, sibling)284 promoteSibling(ws, parent, sibling)
285+ leaf.Parent = nil
286+ parent.Children = nil
283 ws.ActivePaneID = findFirstLeafPaneID(sibling)287 ws.ActivePaneID = findFirstLeafPaneID(sibling)
284 return nil288 return nil
285 }289 }
M internal/infrastructure/config/defaults.go +1 -0
@@ -220,6 +220,7 @@ func DefaultConfig() *Config {
220220 "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"},
221221 "move-pane-to-tab": {Keys: []string{"m"}, Desc: "Move pane to different tab"},
222222 "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}, Desc: "Move pane to next tab"},
223+ "eject-pane-to-window": {Keys: []string{"w"}, Desc: "Eject active pane to a new window"},
223224
224225 "consume-or-expel-left": {Keys: []string{"["}, Desc: "Consume/expel pane left"},
225226 "consume-or-expel-right": {Keys: []string{"]"}, Desc: "Consume/expel pane right"},
@@ -220,6 +220,7 @@ func DefaultConfig() *Config {
220 "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"},220 "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"},
221 "move-pane-to-tab": {Keys: []string{"m"}, Desc: "Move pane to different tab"},221 "move-pane-to-tab": {Keys: []string{"m"}, Desc: "Move pane to different tab"},
222 "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}, Desc: "Move pane to next tab"},222 "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}, Desc: "Move pane to next tab"},
223+ "eject-pane-to-window": {Keys: []string{"w"}, Desc: "Eject active pane to a new window"},
223 224
224 "consume-or-expel-left": {Keys: []string{"["}, Desc: "Consume/expel pane left"},225 "consume-or-expel-left": {Keys: []string{"["}, Desc: "Consume/expel pane left"},
225 "consume-or-expel-right": {Keys: []string{"]"}, Desc: "Consume/expel pane right"},226 "consume-or-expel-right": {Keys: []string{"]"}, Desc: "Consume/expel pane right"},
M internal/infrastructure/config/defaults_test.go +4 -0
@@ -35,6 +35,10 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
3535 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
3636 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
3737
38+ // Pane mode actions.
39+ requireActionBinding(t, cfg.Workspace.PaneMode.Actions, "eject-pane-to-window", []string{"w"})
40+ assert.Equal(t, "Eject active pane to a new window", cfg.Workspace.PaneMode.Actions["eject-pane-to-window"].Desc)
41+
3842 // System view shortcuts are first-class global actions.
3943 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
4044 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})
@@ -35,6 +35,10 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
35 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)35 assert.InDelta(t, defaultCEFTouchpadNavigationRatio, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
36 assert.True(t, cfg.Engine.WebKit.ITPEnabled)36 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
37 37
38+ // Pane mode actions.
39+ requireActionBinding(t, cfg.Workspace.PaneMode.Actions, "eject-pane-to-window", []string{"w"})
40+ assert.Equal(t, "Eject active pane to a new window", cfg.Workspace.PaneMode.Actions["eject-pane-to-window"].Desc)
41+
38 // System view shortcuts are first-class global actions.42 // System view shortcuts are first-class global actions.
39 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})43 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
40 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})44 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})
M internal/infrastructure/config/loader.go +4 -0
@@ -175,6 +175,10 @@ func (m *Manager) transformLegacyConfig() {
175175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176176 }
177177
178+ // Merge newly added default actions into in-memory config so existing persisted
179+ // action maps keep working before the user writes a migrated config to disk.
180+ configMigrator.mergeMissingDefaultActions(rawConfig)
181+
178182 // Apply transformed config back to viper
179183 for key, value := range rawConfig {
180184 m.viper.Set(key, value)
@@ -175,6 +175,10 @@ func (m *Manager) transformLegacyConfig() {
175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176 }176 }
177 177
178+ // Merge newly added default actions into in-memory config so existing persisted
179+ // action maps keep working before the user writes a migrated config to disk.
180+ configMigrator.mergeMissingDefaultActions(rawConfig)
181+
178 // Apply transformed config back to viper182 // Apply transformed config back to viper
179 for key, value := range rawConfig {183 for key, value := range rawConfig {
180 m.viper.Set(key, value)184 m.viper.Set(key, value)
A internal/infrastructure/config/loader_actions_test.go +46 -0
@@ -0,0 +1,46 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {
13+ home := t.TempDir()
14+ t.Setenv("HOME", home)
15+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
16+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
17+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
18+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
19+
20+ configFile, err := GetConfigFile()
21+ require.NoError(t, err)
22+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
23+ require.NoError(t, os.WriteFile(configFile, []byte(`
24+[workspace.pane_mode]
25+activation_shortcut = "ctrl+p"
26+timeout_ms = 3000
27+
28+[workspace.pane_mode.actions.stack-pane]
29+keys = ["s"]
30+desc = "Custom stack pane"
31+`), 0o644))
32+
33+ mgr, err := NewManager()
34+ require.NoError(t, err)
35+ require.NoError(t, mgr.Load())
36+
37+ cfg := mgr.Get()
38+ require.NotNil(t, cfg)
39+
40+ eject, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
41+ require.True(t, ok)
42+ assert.Equal(t, []string{"w"}, eject.Keys)
43+
44+ stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
45+ assert.Equal(t, "Custom stack pane", stack.Desc)
46+}
@@ -0,0 +1,46 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {
13+ home := t.TempDir()
14+ t.Setenv("HOME", home)
15+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
16+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
17+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
18+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
19+
20+ configFile, err := GetConfigFile()
21+ require.NoError(t, err)
22+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
23+ require.NoError(t, os.WriteFile(configFile, []byte(`
24+[workspace.pane_mode]
25+activation_shortcut = "ctrl+p"
26+timeout_ms = 3000
27+
28+[workspace.pane_mode.actions.stack-pane]
29+keys = ["s"]
30+desc = "Custom stack pane"
31+`), 0o644))
32+
33+ mgr, err := NewManager()
34+ require.NoError(t, err)
35+ require.NoError(t, mgr.Load())
36+
37+ cfg := mgr.Get()
38+ require.NotNil(t, cfg)
39+
40+ eject, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
41+ require.True(t, ok)
42+ assert.Equal(t, []string{"w"}, eject.Keys)
43+
44+ stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
45+ assert.Equal(t, "Custom stack pane", stack.Desc)
46+}
M internal/infrastructure/config/migrate.go +70 -50
@@ -14,6 +14,7 @@ import (
1414 "gopkg.in/yaml.v3"
1515
1616 "github.com/bnema/dumber/internal/application/port"
17+ "github.com/bnema/dumber/internal/domain/entity"
1718 )
1819
1920 // Migrator implements port.ConfigMigrator for comparing and merging config files.
@@ -24,6 +25,11 @@ type Migrator struct {
2425 defaultConfig *Config
2526 }
2627
28+// configMigrator reuses the default-action metadata needed during config load.
29+// It is immutable after initialization; per-command migration still constructs
30+// explicit Migrator instances when callers need independent state.
31+var configMigrator = NewMigrator()
32+
2733 // NewMigrator creates a new Migrator instance.
2834 func NewMigrator() *Migrator {
2935 v := viper.New()
@@ -129,7 +135,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
129135
130136 // Find missing keys (in defaults but not in user config)
131137 missingKeys := excludeKeys(m.findMissingKeys(defaultKeys, userKeySet), handledLegacyMissingKeys)
132- missingKeys = appendUniqueKeys(missingKeys, m.detectMissingWorkspaceShortcutActions(userKeysWithValues))
138+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
133139
134140 // Try to match deprecated keys with missing keys (detect renames)
135141 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
@@ -280,10 +286,24 @@ const (
280286 configFormatTOML = "toml"
281287 configFormatYAML = "yaml"
282288 configFormatJSON = "json"
283- databasePathKey = "database.path"
284- workspaceShortcutsActionsKey = "workspace.shortcuts.actions"
289+ databasePathKey = "database.path"
285290 )
286291
292+type defaultActionMap struct {
293+ key string
294+ actions map[string]entity.ActionBinding
295+}
296+
297+func (m *Migrator) defaultActionMaps() []defaultActionMap {
298+ return []defaultActionMap{
299+ {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
300+ {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
301+ {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
302+ {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
303+ {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
304+ }
305+}
306+
287307 // typesAreCompatible checks if the default types for two keys are compatible.
288308 // This prevents matching int keys with string keys during rename detection.
289309 func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {
@@ -430,7 +450,7 @@ func (m *Migrator) Migrate() ([]string, error) {
430450 for key := range keysToRemove {
431451 m.deleteNestedKey(rawConfig, key)
432452 }
433- m.mergeMissingWorkspaceShortcutActions(rawConfig)
453+ m.mergeMissingDefaultActions(rawConfig)
434454
435455 // Create a new Viper instance with defaults for added keys
436456 userViper := viper.New()
@@ -631,10 +651,12 @@ func (m *Migrator) defaultValueForKey(key string) any {
631651 return value
632652 }
633653
634- if strings.HasPrefix(key, workspaceShortcutsActionsKey+".") {
635- actionName := strings.TrimPrefix(key, workspaceShortcutsActionsKey+".")
636- if action, ok := m.defaultConfig.Workspace.Shortcuts.Actions[actionName]; ok {
637- return action
654+ for _, actionMap := range m.defaultActionMaps() {
655+ if strings.HasPrefix(key, actionMap.key+".") {
656+ actionName := strings.TrimPrefix(key, actionMap.key+".")
657+ if action, ok := actionMap.actions[actionName]; ok {
658+ return action
659+ }
638660 }
639661 }
640662
@@ -811,68 +833,66 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
811833 return false
812834 }
813835
814-// detectMissingWorkspaceShortcutActions finds default global shortcut actions not present in user config.
815-func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[string]any) []string {
836+// detectMissingDefaultActions finds default shortcut/modal actions not present in user config.
837+func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {
816838 if userKeysWithValues == nil {
817839 return nil
818840 }
819841
820- userActionsValue, ok := userKeysWithValues[workspaceShortcutsActionsKey]
821- if !ok {
822- return nil
823- }
824-
825- userActions, ok := m.toStringAnyMap(userActionsValue)
826- if !ok {
827- return nil
828- }
829-
830- defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
831- if len(defaultActions) == 0 {
832- return nil
833- }
834-
835842 missing := make([]string, 0)
836- for actionName := range defaultActions {
837- if _, exists := userActions[actionName]; exists {
843+ for _, actionMap := range m.defaultActionMaps() {
844+ userActionsValue, ok := userKeysWithValues[actionMap.key]
845+ if !ok {
838846 continue
839847 }
840- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
841- if _, exists := userActions[legacyActionName]; exists {
848+
849+ userActions, ok := m.toStringAnyMap(userActionsValue)
850+ if !ok || len(actionMap.actions) == 0 {
842851 continue
843852 }
844- missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)
853+
854+ for actionName := range actionMap.actions {
855+ if _, exists := userActions[actionName]; exists {
856+ continue
857+ }
858+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
859+ if _, exists := userActions[legacyActionName]; exists {
860+ continue
861+ }
862+ missing = append(missing, actionMap.key+"."+actionName)
863+ }
845864 }
846865
847866 sort.Strings(missing)
848867 return missing
849868 }
850869
851-// mergeMissingWorkspaceShortcutActions injects new default shortcut actions while preserving user overrides.
852-func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any) {
853- actionsValue := m.getNestedValue(rawConfig, workspaceShortcutsActionsKey)
854- if actionsValue == nil {
855- return
856- }
857-
858- userActions, ok := m.toStringAnyMap(actionsValue)
859- if !ok {
860- return
861- }
862-
863- defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
864- for actionName, defaultAction := range defaultActions {
865- if _, exists := userActions[actionName]; exists {
870+// mergeMissingDefaultActions injects new default shortcut/modal actions while preserving user overrides.
871+func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
872+ for _, actionMap := range m.defaultActionMaps() {
873+ actionsValue := m.getNestedValue(rawConfig, actionMap.key)
874+ if actionsValue == nil {
866875 continue
867876 }
868- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
869- if _, exists := userActions[legacyActionName]; exists {
877+
878+ userActions, ok := m.toStringAnyMap(actionsValue)
879+ if !ok {
870880 continue
871881 }
872- userActions[actionName] = defaultAction
873- }
874882
875- m.setNestedValue(rawConfig, workspaceShortcutsActionsKey, userActions)
883+ for actionName, defaultAction := range actionMap.actions {
884+ if _, exists := userActions[actionName]; exists {
885+ continue
886+ }
887+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
888+ if _, exists := userActions[legacyActionName]; exists {
889+ continue
890+ }
891+ userActions[actionName] = defaultAction
892+ }
893+
894+ m.setNestedValue(rawConfig, actionMap.key, userActions)
895+ }
876896 }
877897
878898 func appendUniqueKeys(base, extra []string) []string {
@@ -14,6 +14,7 @@ import (
14 "gopkg.in/yaml.v3"14 "gopkg.in/yaml.v3"
15 15
16 "github.com/bnema/dumber/internal/application/port"16 "github.com/bnema/dumber/internal/application/port"
17+ "github.com/bnema/dumber/internal/domain/entity"
17 )18 )
18 19
19 // Migrator implements port.ConfigMigrator for comparing and merging config files.20 // Migrator implements port.ConfigMigrator for comparing and merging config files.
@@ -24,6 +25,11 @@ type Migrator struct {
24 defaultConfig *Config25 defaultConfig *Config
25 }26 }
26 27
28+// configMigrator reuses the default-action metadata needed during config load.
29+// It is immutable after initialization; per-command migration still constructs
30+// explicit Migrator instances when callers need independent state.
31+var configMigrator = NewMigrator()
32+
27 // NewMigrator creates a new Migrator instance.33 // NewMigrator creates a new Migrator instance.
28 func NewMigrator() *Migrator {34 func NewMigrator() *Migrator {
29 v := viper.New()35 v := viper.New()
@@ -129,7 +135,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
129 135
130 // Find missing keys (in defaults but not in user config)136 // Find missing keys (in defaults but not in user config)
131 missingKeys := excludeKeys(m.findMissingKeys(defaultKeys, userKeySet), handledLegacyMissingKeys)137 missingKeys := excludeKeys(m.findMissingKeys(defaultKeys, userKeySet), handledLegacyMissingKeys)
132- missingKeys = appendUniqueKeys(missingKeys, m.detectMissingWorkspaceShortcutActions(userKeysWithValues))138+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
133 139
134 // Try to match deprecated keys with missing keys (detect renames)140 // Try to match deprecated keys with missing keys (detect renames)
135 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)141 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
@@ -280,10 +286,24 @@ const (
280 configFormatTOML = "toml"286 configFormatTOML = "toml"
281 configFormatYAML = "yaml"287 configFormatYAML = "yaml"
282 configFormatJSON = "json"288 configFormatJSON = "json"
283- databasePathKey = "database.path"289+ databasePathKey = "database.path"
284- workspaceShortcutsActionsKey = "workspace.shortcuts.actions"
285 )290 )
286 291
292+type defaultActionMap struct {
293+ key string
294+ actions map[string]entity.ActionBinding
295+}
296+
297+func (m *Migrator) defaultActionMaps() []defaultActionMap {
298+ return []defaultActionMap{
299+ {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
300+ {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
301+ {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
302+ {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
303+ {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
304+ }
305+}
306+
287 // typesAreCompatible checks if the default types for two keys are compatible.307 // typesAreCompatible checks if the default types for two keys are compatible.
288 // This prevents matching int keys with string keys during rename detection.308 // This prevents matching int keys with string keys during rename detection.
289 func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {309 func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {
@@ -430,7 +450,7 @@ func (m *Migrator) Migrate() ([]string, error) {
430 for key := range keysToRemove {450 for key := range keysToRemove {
431 m.deleteNestedKey(rawConfig, key)451 m.deleteNestedKey(rawConfig, key)
432 }452 }
433- m.mergeMissingWorkspaceShortcutActions(rawConfig)453+ m.mergeMissingDefaultActions(rawConfig)
434 454
435 // Create a new Viper instance with defaults for added keys455 // Create a new Viper instance with defaults for added keys
436 userViper := viper.New()456 userViper := viper.New()
@@ -631,10 +651,12 @@ func (m *Migrator) defaultValueForKey(key string) any {
631 return value651 return value
632 }652 }
633 653
634- if strings.HasPrefix(key, workspaceShortcutsActionsKey+".") {654+ for _, actionMap := range m.defaultActionMaps() {
635- actionName := strings.TrimPrefix(key, workspaceShortcutsActionsKey+".")655+ if strings.HasPrefix(key, actionMap.key+".") {
636- if action, ok := m.defaultConfig.Workspace.Shortcuts.Actions[actionName]; ok {656+ actionName := strings.TrimPrefix(key, actionMap.key+".")
637- return action657+ if action, ok := actionMap.actions[actionName]; ok {
658+ return action
659+ }
638 }660 }
639 }661 }
640 662
@@ -811,68 +833,66 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
811 return false833 return false
812 }834 }
813 835
814-// detectMissingWorkspaceShortcutActions finds default global shortcut actions not present in user config.836+// detectMissingDefaultActions finds default shortcut/modal actions not present in user config.
815-func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[string]any) []string {837+func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {
816 if userKeysWithValues == nil {838 if userKeysWithValues == nil {
817 return nil839 return nil
818 }840 }
819 841
820- userActionsValue, ok := userKeysWithValues[workspaceShortcutsActionsKey]
821- if !ok {
822- return nil
823- }
824-
825- userActions, ok := m.toStringAnyMap(userActionsValue)
826- if !ok {
827- return nil
828- }
829-
830- defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
831- if len(defaultActions) == 0 {
832- return nil
833- }
834-
835 missing := make([]string, 0)842 missing := make([]string, 0)
836- for actionName := range defaultActions {843+ for _, actionMap := range m.defaultActionMaps() {
837- if _, exists := userActions[actionName]; exists {844+ userActionsValue, ok := userKeysWithValues[actionMap.key]
845+ if !ok {
838 continue846 continue
839 }847 }
840- legacyActionName := strings.ReplaceAll(actionName, "-", "_")848+
841- if _, exists := userActions[legacyActionName]; exists {849+ userActions, ok := m.toStringAnyMap(userActionsValue)
850+ if !ok || len(actionMap.actions) == 0 {
842 continue851 continue
843 }852 }
844- missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)853+
854+ for actionName := range actionMap.actions {
855+ if _, exists := userActions[actionName]; exists {
856+ continue
857+ }
858+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
859+ if _, exists := userActions[legacyActionName]; exists {
860+ continue
861+ }
862+ missing = append(missing, actionMap.key+"."+actionName)
863+ }
845 }864 }
846 865
847 sort.Strings(missing)866 sort.Strings(missing)
848 return missing867 return missing
849 }868 }
850 869
851-// mergeMissingWorkspaceShortcutActions injects new default shortcut actions while preserving user overrides.870+// mergeMissingDefaultActions injects new default shortcut/modal actions while preserving user overrides.
852-func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any) {871+func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
853- actionsValue := m.getNestedValue(rawConfig, workspaceShortcutsActionsKey)872+ for _, actionMap := range m.defaultActionMaps() {
854- if actionsValue == nil {873+ actionsValue := m.getNestedValue(rawConfig, actionMap.key)
855- return874+ if actionsValue == nil {
856- }
857-
858- userActions, ok := m.toStringAnyMap(actionsValue)
859- if !ok {
860- return
861- }
862-
863- defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
864- for actionName, defaultAction := range defaultActions {
865- if _, exists := userActions[actionName]; exists {
866 continue875 continue
867 }876 }
868- legacyActionName := strings.ReplaceAll(actionName, "-", "_")877+
869- if _, exists := userActions[legacyActionName]; exists {878+ userActions, ok := m.toStringAnyMap(actionsValue)
879+ if !ok {
870 continue880 continue
871 }881 }
872- userActions[actionName] = defaultAction
873- }
874 882
875- m.setNestedValue(rawConfig, workspaceShortcutsActionsKey, userActions)883+ for actionName, defaultAction := range actionMap.actions {
884+ if _, exists := userActions[actionName]; exists {
885+ continue
886+ }
887+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
888+ if _, exists := userActions[legacyActionName]; exists {
889+ continue
890+ }
891+ userActions[actionName] = defaultAction
892+ }
893+
894+ m.setNestedValue(rawConfig, actionMap.key, userActions)
895+ }
876 }896 }
877 897
878 func appendUniqueKeys(base, extra []string) []string {898 func appendUniqueKeys(base, extra []string) []string {
M internal/infrastructure/config/migrate_test.go +67 -11
@@ -689,24 +689,33 @@ func TestMigrator_GetConfigType(t *testing.T) {
689689 }
690690 }
691691
692-func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
692+func TestMigrator_DetectMissingDefaultActions(t *testing.T) {
693693 m := NewMigrator()
694694
695- defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
696- userActions := make(map[string]any, len(defaultActions))
697- for actionName, value := range defaultActions {
698- userActions[actionName] = value
695+ shortcutActions := make(map[string]any, len(m.defaultConfig.Workspace.Shortcuts.Actions))
696+ for actionName, value := range m.defaultConfig.Workspace.Shortcuts.Actions {
697+ shortcutActions[actionName] = value
699698 }
700- delete(userActions, "toggle-floating-pane")
699+ delete(shortcutActions, "toggle-floating-pane")
701700
702- missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
703- "workspace.shortcuts.actions": userActions,
701+ paneActions := make(map[string]any, len(m.defaultConfig.Workspace.PaneMode.Actions))
702+ for actionName, value := range m.defaultConfig.Workspace.PaneMode.Actions {
703+ paneActions[actionName] = value
704+ }
705+ delete(paneActions, "eject-pane-to-window")
706+
707+ missing := m.detectMissingDefaultActions(map[string]any{
708+ "workspace.shortcuts.actions": shortcutActions,
709+ "workspace.pane_mode.actions": paneActions,
704710 })
705711
706- assert.Equal(t, []string{"workspace.shortcuts.actions.toggle-floating-pane"}, missing)
712+ assert.Equal(t, []string{
713+ "workspace.pane_mode.actions.eject-pane-to-window",
714+ "workspace.shortcuts.actions.toggle-floating-pane",
715+ }, missing)
707716 }
708717
709-func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
718+func TestMigrator_MergeMissingDefaultActions(t *testing.T) {
710719 m := NewMigrator()
711720
712721 rawConfig := map[string]any{
@@ -746,7 +755,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
746755 },
747756 }
748757
749- m.mergeMissingWorkspaceShortcutActions(rawConfig)
758+ m.mergeMissingDefaultActions(rawConfig)
750759
751760 actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
752761 actions, ok := actionsAny.(map[string]any)
@@ -770,6 +779,43 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
770779 assert.NotContains(t, actions, "consume-or-expel-down")
771780 }
772781
782+func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) {
783+ m := NewMigrator()
784+
785+ rawConfig := map[string]any{
786+ "workspace": map[string]any{
787+ "pane_mode": map[string]any{
788+ "actions": map[string]any{
789+ "stack-pane": map[string]any{
790+ "keys": []string{"s"},
791+ "desc": "Stack pane with sibling",
792+ },
793+ "move-pane-to-tab": map[string]any{
794+ "keys": []string{"m"},
795+ "desc": "Custom move pane",
796+ },
797+ },
798+ },
799+ },
800+ }
801+
802+ m.mergeMissingDefaultActions(rawConfig)
803+
804+ actionsAny := m.getNestedValue(rawConfig, "workspace.pane_mode.actions")
805+ actions, ok := actionsAny.(map[string]any)
806+ require.True(t, ok)
807+
808+ ejectAny, hasEject := actions["eject-pane-to-window"]
809+ assert.True(t, hasEject)
810+ assert.NotNil(t, ejectAny)
811+
812+ moveAny, hasMove := actions["move-pane-to-tab"]
813+ require.True(t, hasMove)
814+ move, ok := moveAny.(map[string]any)
815+ require.True(t, ok)
816+ assert.Equal(t, "Custom move pane", move["desc"])
817+}
818+
773819 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
774820 m := NewMigrator()
775821
@@ -780,6 +826,16 @@ func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
780826 assert.Equal(t, []string{"alt+f"}, action.Keys)
781827 }
782828
829+func TestMigrator_DefaultValueForKey_PaneModeAction(t *testing.T) {
830+ m := NewMigrator()
831+
832+ value := m.defaultValueForKey("workspace.pane_mode.actions.eject-pane-to-window")
833+
834+ action, ok := value.(ActionBinding)
835+ require.True(t, ok)
836+ assert.Equal(t, []string{"w"}, action.Keys)
837+}
838+
783839 func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
784840 m := NewMigrator()
785841
@@ -689,24 +689,33 @@ func TestMigrator_GetConfigType(t *testing.T) {
689 }689 }
690 }690 }
691 691
692-func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {692+func TestMigrator_DetectMissingDefaultActions(t *testing.T) {
693 m := NewMigrator()693 m := NewMigrator()
694 694
695- defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions695+ shortcutActions := make(map[string]any, len(m.defaultConfig.Workspace.Shortcuts.Actions))
696- userActions := make(map[string]any, len(defaultActions))696+ for actionName, value := range m.defaultConfig.Workspace.Shortcuts.Actions {
697- for actionName, value := range defaultActions {697+ shortcutActions[actionName] = value
698- userActions[actionName] = value
699 }698 }
700- delete(userActions, "toggle-floating-pane")699+ delete(shortcutActions, "toggle-floating-pane")
701 700
702- missing := m.detectMissingWorkspaceShortcutActions(map[string]any{701+ paneActions := make(map[string]any, len(m.defaultConfig.Workspace.PaneMode.Actions))
703- "workspace.shortcuts.actions": userActions,702+ for actionName, value := range m.defaultConfig.Workspace.PaneMode.Actions {
703+ paneActions[actionName] = value
704+ }
705+ delete(paneActions, "eject-pane-to-window")
706+
707+ missing := m.detectMissingDefaultActions(map[string]any{
708+ "workspace.shortcuts.actions": shortcutActions,
709+ "workspace.pane_mode.actions": paneActions,
704 })710 })
705 711
706- assert.Equal(t, []string{"workspace.shortcuts.actions.toggle-floating-pane"}, missing)712+ assert.Equal(t, []string{
713+ "workspace.pane_mode.actions.eject-pane-to-window",
714+ "workspace.shortcuts.actions.toggle-floating-pane",
715+ }, missing)
707 }716 }
708 717
709-func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {718+func TestMigrator_MergeMissingDefaultActions(t *testing.T) {
710 m := NewMigrator()719 m := NewMigrator()
711 720
712 rawConfig := map[string]any{721 rawConfig := map[string]any{
@@ -746,7 +755,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
746 },755 },
747 }756 }
748 757
749- m.mergeMissingWorkspaceShortcutActions(rawConfig)758+ m.mergeMissingDefaultActions(rawConfig)
750 759
751 actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")760 actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
752 actions, ok := actionsAny.(map[string]any)761 actions, ok := actionsAny.(map[string]any)
@@ -770,6 +779,43 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
770 assert.NotContains(t, actions, "consume-or-expel-down")779 assert.NotContains(t, actions, "consume-or-expel-down")
771 }780 }
772 781
782+func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) {
783+ m := NewMigrator()
784+
785+ rawConfig := map[string]any{
786+ "workspace": map[string]any{
787+ "pane_mode": map[string]any{
788+ "actions": map[string]any{
789+ "stack-pane": map[string]any{
790+ "keys": []string{"s"},
791+ "desc": "Stack pane with sibling",
792+ },
793+ "move-pane-to-tab": map[string]any{
794+ "keys": []string{"m"},
795+ "desc": "Custom move pane",
796+ },
797+ },
798+ },
799+ },
800+ }
801+
802+ m.mergeMissingDefaultActions(rawConfig)
803+
804+ actionsAny := m.getNestedValue(rawConfig, "workspace.pane_mode.actions")
805+ actions, ok := actionsAny.(map[string]any)
806+ require.True(t, ok)
807+
808+ ejectAny, hasEject := actions["eject-pane-to-window"]
809+ assert.True(t, hasEject)
810+ assert.NotNil(t, ejectAny)
811+
812+ moveAny, hasMove := actions["move-pane-to-tab"]
813+ require.True(t, hasMove)
814+ move, ok := moveAny.(map[string]any)
815+ require.True(t, ok)
816+ assert.Equal(t, "Custom move pane", move["desc"])
817+}
818+
773 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {819 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
774 m := NewMigrator()820 m := NewMigrator()
775 821
@@ -780,6 +826,16 @@ func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
780 assert.Equal(t, []string{"alt+f"}, action.Keys)826 assert.Equal(t, []string{"alt+f"}, action.Keys)
781 }827 }
782 828
829+func TestMigrator_DefaultValueForKey_PaneModeAction(t *testing.T) {
830+ m := NewMigrator()
831+
832+ value := m.defaultValueForKey("workspace.pane_mode.actions.eject-pane-to-window")
833+
834+ action, ok := value.(ActionBinding)
835+ require.True(t, ok)
836+ assert.Equal(t, []string{"w"}, action.Keys)
837+}
838+
783 func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {839 func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
784 m := NewMigrator()840 m := NewMigrator()
785 841
M internal/ui/app.go +33 -11
@@ -100,8 +100,9 @@ type App struct {
100100 panesUC *usecase.ManagePanesUseCase
101101 workspaceViews map[entity.TabID]*component.WorkspaceView
102102 windowForTab map[entity.TabID]*browserWindow
103- widgetFactory layout.WidgetFactory
104- stackedPaneMgr *component.StackedPaneManager
103+ widgetFactory layout.WidgetFactory
104+ workspaceViewCreateOverride func(context.Context, *entity.Tab) bool
105+ stackedPaneMgr *component.StackedPaneManager
105106
106107 // Input handling
107108 keyboardHandler *input.KeyboardHandler
@@ -137,7 +138,8 @@ type App struct {
137138 windowIDMu sync.Mutex
138139 firstWebViewShownOnce sync.Once
139140
140- movePaneToTabUC *usecase.MovePaneToTabUseCase
141+ movePaneToTabUC *usecase.MovePaneToTabUseCase
142+ extractPaneToTabListUC *usecase.ExtractPaneToTabListUseCase
141143
142144 // Accent picker for dead keys support
143145 accentFocusProvider port.FocusedInputProvider
@@ -1772,7 +1774,11 @@ func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWind
17721774 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
17731775 tabBar.AddTab(tab)
17741776 }
1775- a.createWorkspaceViewWithoutAttach(ctx, tab)
1777+ if a.workspaceViews[tab.ID] == nil {
1778+ if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
1779+ return
1780+ }
1781+ }
17761782 }
17771783
17781784 func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
@@ -2834,7 +2840,9 @@ func (a *App) buildRestoredTabUI(ctx context.Context, bw *browserWindow, tabBar
28342840 if tab == nil {
28352841 return
28362842 }
2837- a.createWorkspaceViewWithoutAttach(ctx, tab)
2843+ if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
2844+ return
2845+ }
28382846 wsView := a.workspaceViews[tab.ID]
28392847 if a.wsCoord != nil && wsView != nil {
28402848 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
@@ -3198,8 +3206,9 @@ func (a *App) initCoordinators(ctx context.Context) {
31983206 // Wire tabbed popup behavior to create new tabs in the originating window.
31993207 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
32003208
3201- // Move pane use case (cross-tab)
3209+ // Move pane use cases (cross-tab/cross-window)
32023210 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
3211+ a.extractPaneToTabListUC = usecase.NewExtractPaneToTabListUseCase(a.generateID)
32033212
32043213 // 4. Navigation Coordinator
32053214 a.navCoord = coordinator.NewNavigationCoordinator(
@@ -3372,6 +3381,12 @@ func (a *App) wireKeyboardActions() {
33723381 }
33733382 return a.HandleMovePaneToNextTab(ctx)
33743383 })
3384+ a.kbDispatcher.SetOnEjectPaneToWindow(func(ctx context.Context, paneID entity.PaneID) error {
3385+ if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
3386+ a.activateBrowserWindow(bw)
3387+ }
3388+ return a.EjectActivePaneToWindow(ctx, paneID)
3389+ })
33753390 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
33763391 return a.ToggleFloatingPane(ctx)
33773392 })
@@ -3778,7 +3793,9 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
37783793
37793794 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
37803795 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
3781- a.createWorkspaceViewWithoutAttach(ctx, tab)
3796+ if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
3797+ return
3798+ }
37823799
37833800 target := a.browserWindowForTab(tab.ID)
37843801 if target != nil {
@@ -3800,19 +3817,23 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
38003817
38013818 // createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
38023819 // Used during session restoration where we create all views first, then attach only the active one.
3803-func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
3820+func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) bool {
3821+ if a.workspaceViewCreateOverride != nil {
3822+ return a.workspaceViewCreateOverride(ctx, tab)
3823+ }
3824+
38043825 log := logging.FromContext(ctx)
38053826
38063827 if a.widgetFactory == nil {
38073828 log.Error().Msg("widget factory not initialized")
3808- return
3829+ return false
38093830 }
38103831
38113832 // Create workspace view
38123833 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
38133834 if wsView == nil {
38143835 log.Error().Msg("failed to create workspace view")
3815- return
3836+ return false
38163837 }
38173838 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
38183839 if a.contentCoord != nil {
@@ -3831,7 +3852,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
38313852 // Set the workspace
38323853 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
38333854 log.Error().Err(err).Msg("failed to set workspace in view")
3834- return
3855+ return false
38353856 }
38363857
38373858 // Ensure WebViews are attached to panes
@@ -3883,6 +3904,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
38833904 a.syncFloatingFocus()
38843905
38853906 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
3907+ return true
38863908 }
38873909
38883910 // activeWorkspace returns the workspace of the focused browser window's active tab.
@@ -100,8 +100,9 @@ type App struct {
100 panesUC *usecase.ManagePanesUseCase100 panesUC *usecase.ManagePanesUseCase
101 workspaceViews map[entity.TabID]*component.WorkspaceView101 workspaceViews map[entity.TabID]*component.WorkspaceView
102 windowForTab map[entity.TabID]*browserWindow102 windowForTab map[entity.TabID]*browserWindow
103- widgetFactory layout.WidgetFactory103+ widgetFactory layout.WidgetFactory
104- stackedPaneMgr *component.StackedPaneManager104+ workspaceViewCreateOverride func(context.Context, *entity.Tab) bool
105+ stackedPaneMgr *component.StackedPaneManager
105 106
106 // Input handling107 // Input handling
107 keyboardHandler *input.KeyboardHandler108 keyboardHandler *input.KeyboardHandler
@@ -137,7 +138,8 @@ type App struct {
137 windowIDMu sync.Mutex138 windowIDMu sync.Mutex
138 firstWebViewShownOnce sync.Once139 firstWebViewShownOnce sync.Once
139 140
140- movePaneToTabUC *usecase.MovePaneToTabUseCase141+ movePaneToTabUC *usecase.MovePaneToTabUseCase
142+ extractPaneToTabListUC *usecase.ExtractPaneToTabListUseCase
141 143
142 // Accent picker for dead keys support144 // Accent picker for dead keys support
143 accentFocusProvider port.FocusedInputProvider145 accentFocusProvider port.FocusedInputProvider
@@ -1772,7 +1774,11 @@ func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWind
1772 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {1774 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
1773 tabBar.AddTab(tab)1775 tabBar.AddTab(tab)
1774 }1776 }
1775- a.createWorkspaceViewWithoutAttach(ctx, tab)1777+ if a.workspaceViews[tab.ID] == nil {
1778+ if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
1779+ return
1780+ }
1781+ }
1776 }1782 }
1777 1783
1778 func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {1784 func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
@@ -2834,7 +2840,9 @@ func (a *App) buildRestoredTabUI(ctx context.Context, bw *browserWindow, tabBar
2834 if tab == nil {2840 if tab == nil {
2835 return2841 return
2836 }2842 }
2837- a.createWorkspaceViewWithoutAttach(ctx, tab)2843+ if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
2844+ return
2845+ }
2838 wsView := a.workspaceViews[tab.ID]2846 wsView := a.workspaceViews[tab.ID]
2839 if a.wsCoord != nil && wsView != nil {2847 if a.wsCoord != nil && wsView != nil {
2840 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)2848 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
@@ -3198,8 +3206,9 @@ func (a *App) initCoordinators(ctx context.Context) {
3198 // Wire tabbed popup behavior to create new tabs in the originating window.3206 // Wire tabbed popup behavior to create new tabs in the originating window.
3199 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)3207 a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
3200 3208
3201- // Move pane use case (cross-tab)3209+ // Move pane use cases (cross-tab/cross-window)
3202 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)3210 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
3211+ a.extractPaneToTabListUC = usecase.NewExtractPaneToTabListUseCase(a.generateID)
3203 3212
3204 // 4. Navigation Coordinator3213 // 4. Navigation Coordinator
3205 a.navCoord = coordinator.NewNavigationCoordinator(3214 a.navCoord = coordinator.NewNavigationCoordinator(
@@ -3372,6 +3381,12 @@ func (a *App) wireKeyboardActions() {
3372 }3381 }
3373 return a.HandleMovePaneToNextTab(ctx)3382 return a.HandleMovePaneToNextTab(ctx)
3374 })3383 })
3384+ a.kbDispatcher.SetOnEjectPaneToWindow(func(ctx context.Context, paneID entity.PaneID) error {
3385+ if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
3386+ a.activateBrowserWindow(bw)
3387+ }
3388+ return a.EjectActivePaneToWindow(ctx, paneID)
3389+ })
3375 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {3390 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
3376 return a.ToggleFloatingPane(ctx)3391 return a.ToggleFloatingPane(ctx)
3377 })3392 })
@@ -3778,7 +3793,9 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
3778 3793
3779 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.3794 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
3780 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {3795 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
3781- a.createWorkspaceViewWithoutAttach(ctx, tab)3796+ if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
3797+ return
3798+ }
3782 3799
3783 target := a.browserWindowForTab(tab.ID)3800 target := a.browserWindowForTab(tab.ID)
3784 if target != nil {3801 if target != nil {
@@ -3800,19 +3817,23 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
3800 3817
3801 // createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.3818 // createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
3802 // Used during session restoration where we create all views first, then attach only the active one.3819 // Used during session restoration where we create all views first, then attach only the active one.
3803-func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {3820+func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) bool {
3821+ if a.workspaceViewCreateOverride != nil {
3822+ return a.workspaceViewCreateOverride(ctx, tab)
3823+ }
3824+
3804 log := logging.FromContext(ctx)3825 log := logging.FromContext(ctx)
3805 3826
3806 if a.widgetFactory == nil {3827 if a.widgetFactory == nil {
3807 log.Error().Msg("widget factory not initialized")3828 log.Error().Msg("widget factory not initialized")
3808- return3829+ return false
3809 }3830 }
3810 3831
3811 // Create workspace view3832 // Create workspace view
3812 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)3833 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
3813 if wsView == nil {3834 if wsView == nil {
3814 log.Error().Msg("failed to create workspace view")3835 log.Error().Msg("failed to create workspace view")
3815- return3836+ return false
3816 }3837 }
3817 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())3838 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
3818 if a.contentCoord != nil {3839 if a.contentCoord != nil {
@@ -3831,7 +3852,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
3831 // Set the workspace3852 // Set the workspace
3832 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {3853 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
3833 log.Error().Err(err).Msg("failed to set workspace in view")3854 log.Error().Err(err).Msg("failed to set workspace in view")
3834- return3855+ return false
3835 }3856 }
3836 3857
3837 // Ensure WebViews are attached to panes3858 // Ensure WebViews are attached to panes
@@ -3883,6 +3904,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
3883 a.syncFloatingFocus()3904 a.syncFloatingFocus()
3884 3905
3885 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")3906 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
3907+ return true
3886 }3908 }
3887 3909
3888 // activeWorkspace returns the workspace of the focused browser window's active tab.3910 // activeWorkspace returns the workspace of the focused browser window's active tab.
A internal/ui/app_eject_pane_to_window_test.go +295 -0
@@ -0,0 +1,295 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
13+ app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab-a", "source-ws-a", "pane-a", "pane-b", "pane-c"))
14+ sourceTab.Workspace.ActivePaneID = "pane-b"
15+
16+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
17+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
18+ }
19+
20+ if got := len(app.browserWindows); got != 2 {
21+ t.Fatalf("browserWindows length = %d, want 2", got)
22+ }
23+ if app.browserWindows[sourceWindow.id] == nil {
24+ t.Fatal("source window should remain registered")
25+ }
26+ targetWindow := app.browserWindows["target-window"]
27+ if targetWindow == nil {
28+ t.Fatal("target window was not registered")
29+ }
30+ if got := targetWindow.tabs.Count(); got != 1 {
31+ t.Fatalf("target tab count = %d, want 1", got)
32+ }
33+ newTab := targetWindow.tabs.Tabs[0]
34+ if got := app.windowForTab[newTab.ID]; got != targetWindow {
35+ t.Fatalf("windowForTab[%s] = %p, want target %p", newTab.ID, got, targetWindow)
36+ }
37+ if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-b") {
38+ t.Fatalf("target active pane = %q, want pane-b", got)
39+ }
40+ if got := sourceTab.Workspace.FindPane("pane-b"); got != nil {
41+ t.Fatal("source workspace still contains moved pane")
42+ }
43+ if got := app.lastFocusedWindowID; got != targetWindow.id {
44+ t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id)
45+ }
46+}
47+
48+func TestApp_EjectActivePaneToWindowRetainsSourceWindowOnRemainingActiveTab(t *testing.T) {
49+ sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
50+ otherTab := entity.NewTab("other-tab", "other-ws", entity.NewPane("pane-b"))
51+ app, sourceWindow, _ := newEjectTestApp(t, sourceTab)
52+ sourceWindow.tabs.Add(otherTab)
53+ app.setBrowserWindowForTab(otherTab.ID, sourceWindow)
54+ sourceWindow.tabs.SetActive(sourceTab.ID)
55+
56+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
57+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
58+ }
59+
60+ if app.browserWindows[sourceWindow.id] == nil {
61+ t.Fatal("source window should remain registered when another tab remains")
62+ }
63+ if got := sourceWindow.tabs.ActiveTabID; got != otherTab.ID {
64+ t.Fatalf("source active tab = %q, want %q", got, otherTab.ID)
65+ }
66+ if got := app.windowForTab[sourceTab.ID]; got != nil {
67+ t.Fatalf("closed source tab ownership remained: %p", got)
68+ }
69+ if got := app.windowForTab[otherTab.ID]; got != sourceWindow {
70+ t.Fatalf("remaining tab owner = %p, want source window %p", got, sourceWindow)
71+ }
72+}
73+
74+func TestApp_EjectActivePaneToWindowRemovesEmptySourceWindow(t *testing.T) {
75+ sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
76+ app, sourceWindow, _ := newEjectTestApp(t, sourceTab)
77+
78+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
79+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
80+ }
81+
82+ if app.browserWindows[sourceWindow.id] != nil {
83+ t.Fatal("empty source window should be removed")
84+ }
85+ if got := len(app.browserWindows); got != 1 {
86+ t.Fatalf("browserWindows length = %d, want replacement target only", got)
87+ }
88+ if got := app.windowForTab[sourceTab.ID]; got != nil {
89+ t.Fatalf("source tab ownership remained after source tab closed: %p", got)
90+ }
91+ for _, bw := range app.browserWindows {
92+ if bw.tabs == nil || bw.tabs.Count() == 0 {
93+ t.Fatalf("registered zero-tab window remains: %#v", bw)
94+ }
95+ }
96+}
97+
98+func TestApp_EjectActivePaneToWindowSnapshotRetainsSourceWindowWhenTabsRemain(t *testing.T) {
99+ app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b", "pane-c"))
100+ sourceTab.Workspace.ActivePaneID = "pane-b"
101+
102+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
103+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
104+ }
105+
106+ windows, activeWindowIndex := app.GetWindowSnapshotState()
107+ if got := len(windows); got != 2 {
108+ t.Fatalf("snapshot window count = %d, want 2", got)
109+ }
110+ if got := activeWindowIndex; got != 1 {
111+ t.Fatalf("active window index = %d, want target index 1", got)
112+ }
113+ if got := windows[0].WindowID; got != entity.WindowID(sourceWindow.id) {
114+ t.Fatalf("first snapshot window = %q, want source %q", got, sourceWindow.id)
115+ }
116+ if got := windows[0].Tabs.Count(); got != 1 {
117+ t.Fatalf("source snapshot tab count = %d, want 1", got)
118+ }
119+ if windows[0].Tabs.Find(sourceTab.ID) == nil {
120+ t.Fatalf("source snapshot missing retained tab %q", sourceTab.ID)
121+ }
122+ assertTargetSnapshotContainsPane(t, windows[1], "pane-b")
123+}
124+
125+func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) {
126+ sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
127+ app, _, _ := newEjectTestApp(t, sourceTab)
128+
129+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
130+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
131+ }
132+
133+ windows, activeWindowIndex := app.GetWindowSnapshotState()
134+ if got := len(windows); got != 1 {
135+ t.Fatalf("snapshot window count = %d, want replacement target only", got)
136+ }
137+ if got := activeWindowIndex; got != 0 {
138+ t.Fatalf("active window index = %d, want target index 0", got)
139+ }
140+ assertTargetSnapshotContainsPane(t, windows[0], "pane-a")
141+}
142+
143+func TestApp_EjectActivePaneToWindowTargetUICreationFailureRestoresState(t *testing.T) {
144+ app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
145+ app.workspaceViewCreateOverride = nil
146+ sourceTab.Workspace.ActivePaneID = "pane-b"
147+
148+ err := app.EjectActivePaneToWindow(context.Background(), "")
149+ if err == nil {
150+ t.Fatal("EjectActivePaneToWindow returned nil error, want target UI creation error")
151+ }
152+
153+ if got := len(app.browserWindows); got != 1 {
154+ t.Fatalf("browserWindows length = %d, want restored source only", got)
155+ }
156+ if app.browserWindows[sourceWindow.id] != sourceWindow {
157+ t.Fatal("source window should remain registered after rollback")
158+ }
159+ if app.browserWindows["target-window"] != nil {
160+ t.Fatal("target window should be removed after rollback")
161+ }
162+ if got := sourceWindow.tabs.Count(); got != 1 {
163+ t.Fatalf("source tab count = %d, want restored source tab", got)
164+ }
165+ restoredTab := sourceWindow.tabs.Find(sourceTab.ID)
166+ if restoredTab == nil {
167+ t.Fatal("source tab missing after rollback")
168+ }
169+ if restoredTab.Workspace.FindPane("pane-a") == nil || restoredTab.Workspace.FindPane("pane-b") == nil {
170+ t.Fatal("source workspace panes were not restored after rollback")
171+ }
172+ if got := app.windowForTab[sourceTab.ID]; got != sourceWindow {
173+ t.Fatalf("source tab owner = %p, want source window %p", got, sourceWindow)
174+ }
175+ if got := app.windowForTabCount(); got != 1 {
176+ t.Fatalf("windowForTab length = %d, want restored source only", got)
177+ }
178+}
179+
180+func TestApp_EjectActivePaneToWindowUsecaseFailureRollsBackTargetWindow(t *testing.T) {
181+ app, _, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
182+ sourceTab.Workspace.ActivePaneID = "missing-pane"
183+
184+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
185+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
186+ }
187+
188+ if got := len(app.browserWindows); got != 1 {
189+ t.Fatalf("browserWindows length = %d, want unchanged source only", got)
190+ }
191+ if app.browserWindows["target-window"] != nil {
192+ t.Fatal("target window should not be registered after failure/no-op")
193+ }
194+ if got := app.windowForTabCount(); got != 1 {
195+ t.Fatalf("windowForTab length = %d, want unchanged source only", got)
196+ }
197+}
198+
199+func TestApp_EjectActivePaneToWindowNoopsWhenRequestedPaneIsNotActiveWorkspacePane(t *testing.T) {
200+ app, _, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
201+ sourceTab.Workspace.ActivePaneID = "pane-a"
202+
203+ if err := app.EjectActivePaneToWindow(context.Background(), "floating-pane:source-tab:default"); err != nil {
204+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
205+ }
206+
207+ if got := len(app.browserWindows); got != 1 {
208+ t.Fatalf("browserWindows length = %d, want unchanged source only", got)
209+ }
210+ if sourceTab.Workspace.FindPane("pane-a") == nil || sourceTab.Workspace.FindPane("pane-b") == nil {
211+ t.Fatal("source workspace changed after mismatched requested pane no-op")
212+ }
213+}
214+
215+func TestApp_EjectActivePaneToWindowNoopsForInternalSystemPane(t *testing.T) {
216+ pane := entity.NewPane("pane-a")
217+ pane.URI = "dumb://history"
218+ app, _, sourceTab := newEjectTestApp(t, entity.NewTab("source-tab", "source-ws", pane))
219+
220+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
221+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
222+ }
223+
224+ if got := len(app.browserWindows); got != 1 {
225+ t.Fatalf("browserWindows length = %d, want unchanged source only", got)
226+ }
227+ if sourceTab.Workspace.FindPane("pane-a") == nil {
228+ t.Fatal("source system pane was moved")
229+ }
230+}
231+
232+func newEjectTestApp(t *testing.T, sourceTab *entity.Tab) (*App, *browserWindow, *entity.Tab) {
233+ t.Helper()
234+ sourceTabs := entity.NewTabList()
235+ sourceTabs.Add(sourceTab)
236+ sourceWindow := &browserWindow{id: "source-window", tabs: sourceTabs}
237+ ids := []string{"new-tab", "new-workspace"}
238+ idIndex := 0
239+ app := &App{
240+ tabs: entity.NewTabList(),
241+ browserWindows: map[string]*browserWindow{sourceWindow.id: sourceWindow},
242+ browserWindowOrder: []string{sourceWindow.id},
243+ lastFocusedWindowID: sourceWindow.id,
244+ windowForTab: map[entity.TabID]*browserWindow{sourceTab.ID: sourceWindow},
245+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
246+ extractPaneToTabListUC: usecase.NewExtractPaneToTabListUseCase(func() string {
247+ if idIndex >= len(ids) {
248+ return "extra-id"
249+ }
250+ id := ids[idIndex]
251+ idIndex++
252+ return id
253+ }),
254+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
255+ return &browserWindow{id: "target-window", tabs: entity.NewTabList()}, nil
256+ },
257+ }
258+ app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
259+ if tab == nil {
260+ return false
261+ }
262+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
263+ return true
264+ }
265+ app.tabs.Add(sourceTab)
266+ return app, sourceWindow, sourceTab
267+}
268+
269+func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...entity.PaneID) *entity.Tab {
270+ children := make([]*entity.PaneNode, 0, len(paneIDs))
271+ stack := &entity.PaneNode{ID: "stack", IsStacked: true}
272+ for _, paneID := range paneIDs {
273+ child := &entity.PaneNode{ID: string(paneID) + "-node", Pane: entity.NewPane(paneID), Parent: stack}
274+ children = append(children, child)
275+ }
276+ stack.Children = children
277+ workspace := &entity.Workspace{ID: workspaceID, Root: stack, ActivePaneID: paneIDs[0]}
278+ return &entity.Tab{ID: tabID, Workspace: workspace}
279+}
280+
281+func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {
282+ t.Helper()
283+ if got := window.WindowID; got != entity.WindowID("target-window") {
284+ t.Fatalf("target snapshot window ID = %q, want target-window", got)
285+ }
286+ if got := window.Tabs.Count(); got != 1 {
287+ t.Fatalf("target snapshot tab count = %d, want 1", got)
288+ }
289+ newTab := window.Tabs.Tabs[0]
290+ if newTab.Workspace == nil || newTab.Workspace.FindPane(paneID) == nil {
291+ t.Fatalf("target snapshot missing moved pane %q", paneID)
292+ }
293+}
294+
295+func (a *App) windowForTabCount() int { return len(a.windowForTab) }
@@ -0,0 +1,295 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
13+ app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab-a", "source-ws-a", "pane-a", "pane-b", "pane-c"))
14+ sourceTab.Workspace.ActivePaneID = "pane-b"
15+
16+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
17+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
18+ }
19+
20+ if got := len(app.browserWindows); got != 2 {
21+ t.Fatalf("browserWindows length = %d, want 2", got)
22+ }
23+ if app.browserWindows[sourceWindow.id] == nil {
24+ t.Fatal("source window should remain registered")
25+ }
26+ targetWindow := app.browserWindows["target-window"]
27+ if targetWindow == nil {
28+ t.Fatal("target window was not registered")
29+ }
30+ if got := targetWindow.tabs.Count(); got != 1 {
31+ t.Fatalf("target tab count = %d, want 1", got)
32+ }
33+ newTab := targetWindow.tabs.Tabs[0]
34+ if got := app.windowForTab[newTab.ID]; got != targetWindow {
35+ t.Fatalf("windowForTab[%s] = %p, want target %p", newTab.ID, got, targetWindow)
36+ }
37+ if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-b") {
38+ t.Fatalf("target active pane = %q, want pane-b", got)
39+ }
40+ if got := sourceTab.Workspace.FindPane("pane-b"); got != nil {
41+ t.Fatal("source workspace still contains moved pane")
42+ }
43+ if got := app.lastFocusedWindowID; got != targetWindow.id {
44+ t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id)
45+ }
46+}
47+
48+func TestApp_EjectActivePaneToWindowRetainsSourceWindowOnRemainingActiveTab(t *testing.T) {
49+ sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
50+ otherTab := entity.NewTab("other-tab", "other-ws", entity.NewPane("pane-b"))
51+ app, sourceWindow, _ := newEjectTestApp(t, sourceTab)
52+ sourceWindow.tabs.Add(otherTab)
53+ app.setBrowserWindowForTab(otherTab.ID, sourceWindow)
54+ sourceWindow.tabs.SetActive(sourceTab.ID)
55+
56+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
57+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
58+ }
59+
60+ if app.browserWindows[sourceWindow.id] == nil {
61+ t.Fatal("source window should remain registered when another tab remains")
62+ }
63+ if got := sourceWindow.tabs.ActiveTabID; got != otherTab.ID {
64+ t.Fatalf("source active tab = %q, want %q", got, otherTab.ID)
65+ }
66+ if got := app.windowForTab[sourceTab.ID]; got != nil {
67+ t.Fatalf("closed source tab ownership remained: %p", got)
68+ }
69+ if got := app.windowForTab[otherTab.ID]; got != sourceWindow {
70+ t.Fatalf("remaining tab owner = %p, want source window %p", got, sourceWindow)
71+ }
72+}
73+
74+func TestApp_EjectActivePaneToWindowRemovesEmptySourceWindow(t *testing.T) {
75+ sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
76+ app, sourceWindow, _ := newEjectTestApp(t, sourceTab)
77+
78+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
79+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
80+ }
81+
82+ if app.browserWindows[sourceWindow.id] != nil {
83+ t.Fatal("empty source window should be removed")
84+ }
85+ if got := len(app.browserWindows); got != 1 {
86+ t.Fatalf("browserWindows length = %d, want replacement target only", got)
87+ }
88+ if got := app.windowForTab[sourceTab.ID]; got != nil {
89+ t.Fatalf("source tab ownership remained after source tab closed: %p", got)
90+ }
91+ for _, bw := range app.browserWindows {
92+ if bw.tabs == nil || bw.tabs.Count() == 0 {
93+ t.Fatalf("registered zero-tab window remains: %#v", bw)
94+ }
95+ }
96+}
97+
98+func TestApp_EjectActivePaneToWindowSnapshotRetainsSourceWindowWhenTabsRemain(t *testing.T) {
99+ app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b", "pane-c"))
100+ sourceTab.Workspace.ActivePaneID = "pane-b"
101+
102+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
103+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
104+ }
105+
106+ windows, activeWindowIndex := app.GetWindowSnapshotState()
107+ if got := len(windows); got != 2 {
108+ t.Fatalf("snapshot window count = %d, want 2", got)
109+ }
110+ if got := activeWindowIndex; got != 1 {
111+ t.Fatalf("active window index = %d, want target index 1", got)
112+ }
113+ if got := windows[0].WindowID; got != entity.WindowID(sourceWindow.id) {
114+ t.Fatalf("first snapshot window = %q, want source %q", got, sourceWindow.id)
115+ }
116+ if got := windows[0].Tabs.Count(); got != 1 {
117+ t.Fatalf("source snapshot tab count = %d, want 1", got)
118+ }
119+ if windows[0].Tabs.Find(sourceTab.ID) == nil {
120+ t.Fatalf("source snapshot missing retained tab %q", sourceTab.ID)
121+ }
122+ assertTargetSnapshotContainsPane(t, windows[1], "pane-b")
123+}
124+
125+func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) {
126+ sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
127+ app, _, _ := newEjectTestApp(t, sourceTab)
128+
129+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
130+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
131+ }
132+
133+ windows, activeWindowIndex := app.GetWindowSnapshotState()
134+ if got := len(windows); got != 1 {
135+ t.Fatalf("snapshot window count = %d, want replacement target only", got)
136+ }
137+ if got := activeWindowIndex; got != 0 {
138+ t.Fatalf("active window index = %d, want target index 0", got)
139+ }
140+ assertTargetSnapshotContainsPane(t, windows[0], "pane-a")
141+}
142+
143+func TestApp_EjectActivePaneToWindowTargetUICreationFailureRestoresState(t *testing.T) {
144+ app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
145+ app.workspaceViewCreateOverride = nil
146+ sourceTab.Workspace.ActivePaneID = "pane-b"
147+
148+ err := app.EjectActivePaneToWindow(context.Background(), "")
149+ if err == nil {
150+ t.Fatal("EjectActivePaneToWindow returned nil error, want target UI creation error")
151+ }
152+
153+ if got := len(app.browserWindows); got != 1 {
154+ t.Fatalf("browserWindows length = %d, want restored source only", got)
155+ }
156+ if app.browserWindows[sourceWindow.id] != sourceWindow {
157+ t.Fatal("source window should remain registered after rollback")
158+ }
159+ if app.browserWindows["target-window"] != nil {
160+ t.Fatal("target window should be removed after rollback")
161+ }
162+ if got := sourceWindow.tabs.Count(); got != 1 {
163+ t.Fatalf("source tab count = %d, want restored source tab", got)
164+ }
165+ restoredTab := sourceWindow.tabs.Find(sourceTab.ID)
166+ if restoredTab == nil {
167+ t.Fatal("source tab missing after rollback")
168+ }
169+ if restoredTab.Workspace.FindPane("pane-a") == nil || restoredTab.Workspace.FindPane("pane-b") == nil {
170+ t.Fatal("source workspace panes were not restored after rollback")
171+ }
172+ if got := app.windowForTab[sourceTab.ID]; got != sourceWindow {
173+ t.Fatalf("source tab owner = %p, want source window %p", got, sourceWindow)
174+ }
175+ if got := app.windowForTabCount(); got != 1 {
176+ t.Fatalf("windowForTab length = %d, want restored source only", got)
177+ }
178+}
179+
180+func TestApp_EjectActivePaneToWindowUsecaseFailureRollsBackTargetWindow(t *testing.T) {
181+ app, _, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
182+ sourceTab.Workspace.ActivePaneID = "missing-pane"
183+
184+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
185+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
186+ }
187+
188+ if got := len(app.browserWindows); got != 1 {
189+ t.Fatalf("browserWindows length = %d, want unchanged source only", got)
190+ }
191+ if app.browserWindows["target-window"] != nil {
192+ t.Fatal("target window should not be registered after failure/no-op")
193+ }
194+ if got := app.windowForTabCount(); got != 1 {
195+ t.Fatalf("windowForTab length = %d, want unchanged source only", got)
196+ }
197+}
198+
199+func TestApp_EjectActivePaneToWindowNoopsWhenRequestedPaneIsNotActiveWorkspacePane(t *testing.T) {
200+ app, _, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
201+ sourceTab.Workspace.ActivePaneID = "pane-a"
202+
203+ if err := app.EjectActivePaneToWindow(context.Background(), "floating-pane:source-tab:default"); err != nil {
204+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
205+ }
206+
207+ if got := len(app.browserWindows); got != 1 {
208+ t.Fatalf("browserWindows length = %d, want unchanged source only", got)
209+ }
210+ if sourceTab.Workspace.FindPane("pane-a") == nil || sourceTab.Workspace.FindPane("pane-b") == nil {
211+ t.Fatal("source workspace changed after mismatched requested pane no-op")
212+ }
213+}
214+
215+func TestApp_EjectActivePaneToWindowNoopsForInternalSystemPane(t *testing.T) {
216+ pane := entity.NewPane("pane-a")
217+ pane.URI = "dumb://history"
218+ app, _, sourceTab := newEjectTestApp(t, entity.NewTab("source-tab", "source-ws", pane))
219+
220+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
221+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
222+ }
223+
224+ if got := len(app.browserWindows); got != 1 {
225+ t.Fatalf("browserWindows length = %d, want unchanged source only", got)
226+ }
227+ if sourceTab.Workspace.FindPane("pane-a") == nil {
228+ t.Fatal("source system pane was moved")
229+ }
230+}
231+
232+func newEjectTestApp(t *testing.T, sourceTab *entity.Tab) (*App, *browserWindow, *entity.Tab) {
233+ t.Helper()
234+ sourceTabs := entity.NewTabList()
235+ sourceTabs.Add(sourceTab)
236+ sourceWindow := &browserWindow{id: "source-window", tabs: sourceTabs}
237+ ids := []string{"new-tab", "new-workspace"}
238+ idIndex := 0
239+ app := &App{
240+ tabs: entity.NewTabList(),
241+ browserWindows: map[string]*browserWindow{sourceWindow.id: sourceWindow},
242+ browserWindowOrder: []string{sourceWindow.id},
243+ lastFocusedWindowID: sourceWindow.id,
244+ windowForTab: map[entity.TabID]*browserWindow{sourceTab.ID: sourceWindow},
245+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
246+ extractPaneToTabListUC: usecase.NewExtractPaneToTabListUseCase(func() string {
247+ if idIndex >= len(ids) {
248+ return "extra-id"
249+ }
250+ id := ids[idIndex]
251+ idIndex++
252+ return id
253+ }),
254+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
255+ return &browserWindow{id: "target-window", tabs: entity.NewTabList()}, nil
256+ },
257+ }
258+ app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
259+ if tab == nil {
260+ return false
261+ }
262+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
263+ return true
264+ }
265+ app.tabs.Add(sourceTab)
266+ return app, sourceWindow, sourceTab
267+}
268+
269+func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...entity.PaneID) *entity.Tab {
270+ children := make([]*entity.PaneNode, 0, len(paneIDs))
271+ stack := &entity.PaneNode{ID: "stack", IsStacked: true}
272+ for _, paneID := range paneIDs {
273+ child := &entity.PaneNode{ID: string(paneID) + "-node", Pane: entity.NewPane(paneID), Parent: stack}
274+ children = append(children, child)
275+ }
276+ stack.Children = children
277+ workspace := &entity.Workspace{ID: workspaceID, Root: stack, ActivePaneID: paneIDs[0]}
278+ return &entity.Tab{ID: tabID, Workspace: workspace}
279+}
280+
281+func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {
282+ t.Helper()
283+ if got := window.WindowID; got != entity.WindowID("target-window") {
284+ t.Fatalf("target snapshot window ID = %q, want target-window", got)
285+ }
286+ if got := window.Tabs.Count(); got != 1 {
287+ t.Fatalf("target snapshot tab count = %d, want 1", got)
288+ }
289+ newTab := window.Tabs.Tabs[0]
290+ if newTab.Workspace == nil || newTab.Workspace.FindPane(paneID) == nil {
291+ t.Fatalf("target snapshot missing moved pane %q", paneID)
292+ }
293+}
294+
295+func (a *App) windowForTabCount() int { return len(a.windowForTab) }
M internal/ui/component/pane_view_test.go +35 -1
@@ -226,6 +226,39 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
226226 assert.Equal(t, mockNewWebView, pv.WebViewWidget())
227227 }
228228
229+func TestSetWebViewWidget_ReparentsWidgetWithExistingParent(t *testing.T) {
230+ // Arrange
231+ mockFactory := mocks.NewMockWidgetFactory(t)
232+ mockOverlay := mocks.NewMockOverlayWidget(t)
233+ mockBorderBox := mocks.NewMockBoxWidget(t)
234+ mockOldWebView := mocks.NewMockWidget(t)
235+ mockNewWebView := mocks.NewMockWidget(t)
236+ mockParent := mocks.NewMockWidget(t)
237+
238+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
239+
240+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockOldWebView)
241+
242+ mockOverlay.EXPECT().SetChild(nil).Once()
243+ mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
244+ mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
245+ mockNewWebView.EXPECT().GetAllocatedWidth().Return(0).Twice()
246+ mockNewWebView.EXPECT().GetAllocatedHeight().Return(0).Twice()
247+ mockNewWebView.EXPECT().GetParent().Return(mockParent).Once()
248+ mockNewWebView.EXPECT().IsVisible().Return(true).Once()
249+ mockNewWebView.EXPECT().Unparent().Once()
250+ mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
251+ mockNewWebView.EXPECT().SetVisible(true).Once()
252+
253+ // Loading skeleton is hidden immediately for a reparented, already-visible widget.
254+
255+ // Act
256+ pv.SetWebViewWidget(mockNewWebView)
257+
258+ // Assert
259+ assert.Equal(t, mockNewWebView, pv.WebViewWidget())
260+}
261+
229262 func TestSetWebViewWidget_FromNil(t *testing.T) {
230263 // Arrange
231264 mockFactory := mocks.NewMockWidgetFactory(t)
@@ -538,7 +571,7 @@ func setupLoadingSkeletonMocks(
538571 mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe()
539572 mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe()
540573 mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe()
541- mockLoadingContainer.EXPECT().SetVisible(true).Maybe()
574+ mockLoadingContainer.EXPECT().SetVisible(mock.Anything).Maybe()
542575
543576 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once()
544577 mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
@@ -565,6 +598,7 @@ func setupLoadingSkeletonMocks(
565598 mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()
566599 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
567600 mockLoadingSpinner.EXPECT().Start().Maybe()
601+ mockLoadingSpinner.EXPECT().Stop().Maybe()
568602
569603 mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once()
570604 mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe()
@@ -226,6 +226,39 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
226 assert.Equal(t, mockNewWebView, pv.WebViewWidget())226 assert.Equal(t, mockNewWebView, pv.WebViewWidget())
227 }227 }
228 228
229+func TestSetWebViewWidget_ReparentsWidgetWithExistingParent(t *testing.T) {
230+ // Arrange
231+ mockFactory := mocks.NewMockWidgetFactory(t)
232+ mockOverlay := mocks.NewMockOverlayWidget(t)
233+ mockBorderBox := mocks.NewMockBoxWidget(t)
234+ mockOldWebView := mocks.NewMockWidget(t)
235+ mockNewWebView := mocks.NewMockWidget(t)
236+ mockParent := mocks.NewMockWidget(t)
237+
238+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
239+
240+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockOldWebView)
241+
242+ mockOverlay.EXPECT().SetChild(nil).Once()
243+ mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
244+ mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
245+ mockNewWebView.EXPECT().GetAllocatedWidth().Return(0).Twice()
246+ mockNewWebView.EXPECT().GetAllocatedHeight().Return(0).Twice()
247+ mockNewWebView.EXPECT().GetParent().Return(mockParent).Once()
248+ mockNewWebView.EXPECT().IsVisible().Return(true).Once()
249+ mockNewWebView.EXPECT().Unparent().Once()
250+ mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
251+ mockNewWebView.EXPECT().SetVisible(true).Once()
252+
253+ // Loading skeleton is hidden immediately for a reparented, already-visible widget.
254+
255+ // Act
256+ pv.SetWebViewWidget(mockNewWebView)
257+
258+ // Assert
259+ assert.Equal(t, mockNewWebView, pv.WebViewWidget())
260+}
261+
229 func TestSetWebViewWidget_FromNil(t *testing.T) {262 func TestSetWebViewWidget_FromNil(t *testing.T) {
230 // Arrange263 // Arrange
231 mockFactory := mocks.NewMockWidgetFactory(t)264 mockFactory := mocks.NewMockWidgetFactory(t)
@@ -538,7 +571,7 @@ func setupLoadingSkeletonMocks(
538 mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe()571 mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe()
539 mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe()572 mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe()
540 mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe()573 mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe()
541- mockLoadingContainer.EXPECT().SetVisible(true).Maybe()574+ mockLoadingContainer.EXPECT().SetVisible(mock.Anything).Maybe()
542 575
543 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once()576 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once()
544 mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe()577 mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
@@ -565,6 +598,7 @@ func setupLoadingSkeletonMocks(
565 mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()598 mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()
566 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()599 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
567 mockLoadingSpinner.EXPECT().Start().Maybe()600 mockLoadingSpinner.EXPECT().Start().Maybe()
601+ mockLoadingSpinner.EXPECT().Stop().Maybe()
568 602
569 mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once()603 mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once()
570 mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe()604 mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe()
M internal/ui/coordinator/content/lifecycle_test.go +50 -0
@@ -12,6 +12,9 @@ import (
1212 "github.com/bnema/dumber/internal/application/port"
1313 "github.com/bnema/dumber/internal/application/port/mocks"
1414 "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/bnema/dumber/internal/ui/component"
16+ "github.com/bnema/dumber/internal/ui/layout"
17+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
1518 )
1619
1720 // newMinimalCoordinator returns a Coordinator with the maps required by
@@ -123,6 +126,53 @@ func TestLifecycle_EnsureWebView_ErrorWhenAcquireFails(t *testing.T) {
123126 assert.Equal(t, acquireErr, err)
124127 }
125128
129+func TestLifecycle_AttachToWorkspace_ReusesRegisteredWebViewByPaneID(t *testing.T) {
130+ t.Parallel()
131+
132+ paneID := entity.PaneID("pane-1")
133+ wv := mocks.NewMockWebView(t)
134+ wv.EXPECT().IsDestroyed().Return(false).Once()
135+ wv.EXPECT().ID().Return(port.WebViewID(104)).Once()
136+ wv.EXPECT().URI().Return("").Maybe()
137+
138+ pool := mocks.NewMockWebViewPool(t)
139+ widgetFactory := layoutmocks.NewMockWidgetFactory(t)
140+ ctx, wsView := newAttachWorkspaceView(t, widgetFactory)
141+ workspace := &entity.Workspace{
142+ ID: "workspace-1",
143+ Root: &entity.PaneNode{ID: "node-1", Pane: entity.NewPane(paneID)},
144+ ActivePaneID: paneID,
145+ }
146+
147+ c := newMinimalCoordinator()
148+ c.pool = pool
149+ c.widgetFactory = widgetFactory
150+ c.webViews[paneID] = wv
151+
152+ c.AttachToWorkspace(ctx, workspace, wsView)
153+
154+ assert.Same(t, wv, c.webViews[paneID])
155+}
156+
157+func newAttachWorkspaceView(t *testing.T, factory *layoutmocks.MockWidgetFactory) (context.Context, *component.WorkspaceView) {
158+ t.Helper()
159+ ctx := context.Background()
160+ container := layoutmocks.NewMockBoxWidget(t)
161+ overlay := layoutmocks.NewMockOverlayWidget(t)
162+
163+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(container).Once()
164+ container.EXPECT().SetHexpand(true).Once()
165+ container.EXPECT().SetVexpand(true).Once()
166+ container.EXPECT().SetVisible(true).Once()
167+ factory.EXPECT().NewOverlay().Return(overlay).Once()
168+ overlay.EXPECT().SetHexpand(true).Once()
169+ overlay.EXPECT().SetVexpand(true).Once()
170+ overlay.EXPECT().SetChild(container).Once()
171+ overlay.EXPECT().SetVisible(true).Once()
172+
173+ return ctx, component.NewWorkspaceView(ctx, factory)
174+}
175+
126176 // ---------------------------------------------------------------------------
127177 // ReleaseWebView
128178 // ---------------------------------------------------------------------------
@@ -12,6 +12,9 @@ import (
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/application/port/mocks"13 "github.com/bnema/dumber/internal/application/port/mocks"
14 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/bnema/dumber/internal/ui/component"
16+ "github.com/bnema/dumber/internal/ui/layout"
17+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
15 )18 )
16 19
17 // newMinimalCoordinator returns a Coordinator with the maps required by20 // newMinimalCoordinator returns a Coordinator with the maps required by
@@ -123,6 +126,53 @@ func TestLifecycle_EnsureWebView_ErrorWhenAcquireFails(t *testing.T) {
123 assert.Equal(t, acquireErr, err)126 assert.Equal(t, acquireErr, err)
124 }127 }
125 128
129+func TestLifecycle_AttachToWorkspace_ReusesRegisteredWebViewByPaneID(t *testing.T) {
130+ t.Parallel()
131+
132+ paneID := entity.PaneID("pane-1")
133+ wv := mocks.NewMockWebView(t)
134+ wv.EXPECT().IsDestroyed().Return(false).Once()
135+ wv.EXPECT().ID().Return(port.WebViewID(104)).Once()
136+ wv.EXPECT().URI().Return("").Maybe()
137+
138+ pool := mocks.NewMockWebViewPool(t)
139+ widgetFactory := layoutmocks.NewMockWidgetFactory(t)
140+ ctx, wsView := newAttachWorkspaceView(t, widgetFactory)
141+ workspace := &entity.Workspace{
142+ ID: "workspace-1",
143+ Root: &entity.PaneNode{ID: "node-1", Pane: entity.NewPane(paneID)},
144+ ActivePaneID: paneID,
145+ }
146+
147+ c := newMinimalCoordinator()
148+ c.pool = pool
149+ c.widgetFactory = widgetFactory
150+ c.webViews[paneID] = wv
151+
152+ c.AttachToWorkspace(ctx, workspace, wsView)
153+
154+ assert.Same(t, wv, c.webViews[paneID])
155+}
156+
157+func newAttachWorkspaceView(t *testing.T, factory *layoutmocks.MockWidgetFactory) (context.Context, *component.WorkspaceView) {
158+ t.Helper()
159+ ctx := context.Background()
160+ container := layoutmocks.NewMockBoxWidget(t)
161+ overlay := layoutmocks.NewMockOverlayWidget(t)
162+
163+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(container).Once()
164+ container.EXPECT().SetHexpand(true).Once()
165+ container.EXPECT().SetVexpand(true).Once()
166+ container.EXPECT().SetVisible(true).Once()
167+ factory.EXPECT().NewOverlay().Return(overlay).Once()
168+ overlay.EXPECT().SetHexpand(true).Once()
169+ overlay.EXPECT().SetVexpand(true).Once()
170+ overlay.EXPECT().SetChild(container).Once()
171+ overlay.EXPECT().SetVisible(true).Once()
172+
173+ return ctx, component.NewWorkspaceView(ctx, factory)
174+}
175+
126 // ---------------------------------------------------------------------------176 // ---------------------------------------------------------------------------
127 // ReleaseWebView177 // ReleaseWebView
128 // ---------------------------------------------------------------------------178 // ---------------------------------------------------------------------------
M internal/ui/dispatcher/keyboard.go +33 -17
@@ -32,23 +32,24 @@ type KeyboardActions struct {
3232
3333 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
3434 type KeyboardDispatcher struct {
35- actions KeyboardActions
36- wsCoord *coordinator.WorkspaceCoordinator
37- navCoord *coordinator.NavigationCoordinator
38- zoomUC *usecase.ManageZoomUseCase
39- copyURLUC *usecase.CopyURLUseCase
40- actionHandlers map[input.Action]func(ctx context.Context) error
41- onQuit func()
42- onFindOpen func(ctx context.Context) error
43- onFindNext func(ctx context.Context) error
44- onFindPrev func(ctx context.Context) error
45- onFindClose func(ctx context.Context) error
46- activePaneID func(ctx context.Context) entity.PaneID
47- onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
48- onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
49- onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
50- onToggleFloating func(ctx context.Context) error
51- onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
35+ actions KeyboardActions
36+ wsCoord *coordinator.WorkspaceCoordinator
37+ navCoord *coordinator.NavigationCoordinator
38+ zoomUC *usecase.ManageZoomUseCase
39+ copyURLUC *usecase.CopyURLUseCase
40+ actionHandlers map[input.Action]func(ctx context.Context) error
41+ onQuit func()
42+ onFindOpen func(ctx context.Context) error
43+ onFindNext func(ctx context.Context) error
44+ onFindPrev func(ctx context.Context) error
45+ onFindClose func(ctx context.Context) error
46+ activePaneID func(ctx context.Context) entity.PaneID
47+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
48+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
49+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
50+ onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
51+ onToggleFloating func(ctx context.Context) error
52+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
5253 }
5354
5455 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -114,6 +115,10 @@ func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context,
114115 d.onMovePaneToNext = fn
115116 }
116117
118+func (d *KeyboardDispatcher) SetOnEjectPaneToWindow(fn func(ctx context.Context, paneID entity.PaneID) error) {
119+ d.onEjectPaneToWindow = fn
120+}
121+
117122 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
118123 d.onToggleFloating = fn
119124 }
@@ -182,6 +187,9 @@ func (d *KeyboardDispatcher) initActionHandlers() {
182187 input.ActionMovePaneToNextTab: func(ctx context.Context) error {
183188 return d.handleMovePaneToNextTab(ctx)
184189 },
190+ input.ActionEjectPaneToWindow: func(ctx context.Context) error {
191+ return d.handleEjectPaneToWindow(ctx)
192+ },
185193 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
186194 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
187195 },
@@ -333,6 +341,14 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error
333341 return nil
334342 }
335343
344+func (d *KeyboardDispatcher) handleEjectPaneToWindow(ctx context.Context) error {
345+ if d.onEjectPaneToWindow != nil {
346+ return d.onEjectPaneToWindow(ctx, d.activePaneID(ctx))
347+ }
348+ logging.FromContext(ctx).Debug().Msg("eject pane to window action (no handler)")
349+ return nil
350+}
351+
336352 func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
337353 if d.actions.SwitchTabIndex == nil {
338354 logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
@@ -32,23 +32,24 @@ type KeyboardActions struct {
32 32
33 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.33 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
34 type KeyboardDispatcher struct {34 type KeyboardDispatcher struct {
35- actions KeyboardActions35+ actions KeyboardActions
36- wsCoord *coordinator.WorkspaceCoordinator36+ wsCoord *coordinator.WorkspaceCoordinator
37- navCoord *coordinator.NavigationCoordinator37+ navCoord *coordinator.NavigationCoordinator
38- zoomUC *usecase.ManageZoomUseCase38+ zoomUC *usecase.ManageZoomUseCase
39- copyURLUC *usecase.CopyURLUseCase39+ copyURLUC *usecase.CopyURLUseCase
40- actionHandlers map[input.Action]func(ctx context.Context) error40+ actionHandlers map[input.Action]func(ctx context.Context) error
41- onQuit func()41+ onQuit func()
42- onFindOpen func(ctx context.Context) error42+ onFindOpen func(ctx context.Context) error
43- onFindNext func(ctx context.Context) error43+ onFindNext func(ctx context.Context) error
44- onFindPrev func(ctx context.Context) error44+ onFindPrev func(ctx context.Context) error
45- onFindClose func(ctx context.Context) error45+ onFindClose func(ctx context.Context) error
46- activePaneID func(ctx context.Context) entity.PaneID46+ activePaneID func(ctx context.Context) entity.PaneID
47- onSessionOpen func(ctx context.Context, paneID entity.PaneID) error47+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
48- onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error48+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
49- onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error49+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
50- onToggleFloating func(ctx context.Context) error50+ onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
51- onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error51+ onToggleFloating func(ctx context.Context) error
52+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
52 }53 }
53 54
54 // NewKeyboardDispatcher creates a new KeyboardDispatcher.55 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -114,6 +115,10 @@ func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context,
114 d.onMovePaneToNext = fn115 d.onMovePaneToNext = fn
115 }116 }
116 117
118+func (d *KeyboardDispatcher) SetOnEjectPaneToWindow(fn func(ctx context.Context, paneID entity.PaneID) error) {
119+ d.onEjectPaneToWindow = fn
120+}
121+
117 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {122 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
118 d.onToggleFloating = fn123 d.onToggleFloating = fn
119 }124 }
@@ -182,6 +187,9 @@ func (d *KeyboardDispatcher) initActionHandlers() {
182 input.ActionMovePaneToNextTab: func(ctx context.Context) error {187 input.ActionMovePaneToNextTab: func(ctx context.Context) error {
183 return d.handleMovePaneToNextTab(ctx)188 return d.handleMovePaneToNextTab(ctx)
184 },189 },
190+ input.ActionEjectPaneToWindow: func(ctx context.Context) error {
191+ return d.handleEjectPaneToWindow(ctx)
192+ },
185 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {193 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
186 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)194 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
187 },195 },
@@ -333,6 +341,14 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error
333 return nil341 return nil
334 }342 }
335 343
344+func (d *KeyboardDispatcher) handleEjectPaneToWindow(ctx context.Context) error {
345+ if d.onEjectPaneToWindow != nil {
346+ return d.onEjectPaneToWindow(ctx, d.activePaneID(ctx))
347+ }
348+ logging.FromContext(ctx).Debug().Msg("eject pane to window action (no handler)")
349+ return nil
350+}
351+
336 func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {352 func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
337 if d.actions.SwitchTabIndex == nil {353 if d.actions.SwitchTabIndex == nil {
338 logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")354 logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
M internal/ui/dispatcher/keyboard_test.go +5 -0
@@ -124,6 +124,11 @@ func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
124124 set: d.SetOnMovePaneToNextTab,
125125 invoke: d.handleMovePaneToNextTab,
126126 },
127+ {
128+ name: "eject pane to window",
129+ set: d.SetOnEjectPaneToWindow,
130+ invoke: d.handleEjectPaneToWindow,
131+ },
127132 }
128133
129134 for _, tc := range tests {
@@ -124,6 +124,11 @@ func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
124 set: d.SetOnMovePaneToNextTab,124 set: d.SetOnMovePaneToNextTab,
125 invoke: d.handleMovePaneToNextTab,125 invoke: d.handleMovePaneToNextTab,
126 },126 },
127+ {
128+ name: "eject pane to window",
129+ set: d.SetOnEjectPaneToWindow,
130+ invoke: d.handleEjectPaneToWindow,
131+ },
127 }132 }
128 133
129 for _, tc := range tests {134 for _, tc := range tests {
M internal/ui/input/shortcuts.go +4 -1
@@ -137,6 +137,7 @@ const (
137137
138138 ActionMovePaneToTab Action = "move_pane_to_tab"
139139 ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
140+ ActionEjectPaneToWindow Action = "eject_pane_to_window"
140141
141142 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
142143 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
@@ -601,6 +602,8 @@ var configActionToAction = map[string]Action{
601602 "move-pane-to-tab": ActionMovePaneToTab,
602603 "move_pane_to_next_tab": ActionMovePaneToNextTab,
603604 "move-pane-to-next-tab": ActionMovePaneToNextTab,
605+ "eject_pane_to_window": ActionEjectPaneToWindow,
606+ "eject-pane-to-window": ActionEjectPaneToWindow,
604607
605608 "consume_or_expel_left": ActionConsumeOrExpelLeft,
606609 "consume-or-expel-left": ActionConsumeOrExpelLeft,
@@ -824,7 +827,7 @@ func ShouldAutoExitMode(action Action) bool {
824827 case ActionNewTab, ActionCloseTab, ActionRenameTab,
825828 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
826829 ActionClosePane, ActionStackPane,
827- ActionMovePaneToTab, ActionMovePaneToNextTab,
830+ ActionMovePaneToTab, ActionMovePaneToNextTab, ActionEjectPaneToWindow,
828831 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
829832 ActionOpenSessionManager:
830833 return true
@@ -137,6 +137,7 @@ const (
137 137
138 ActionMovePaneToTab Action = "move_pane_to_tab"138 ActionMovePaneToTab Action = "move_pane_to_tab"
139 ActionMovePaneToNextTab Action = "move_pane_to_next_tab"139 ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
140+ ActionEjectPaneToWindow Action = "eject_pane_to_window"
140 141
141 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"142 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
142 ActionConsumeOrExpelRight Action = "consume_or_expel_right"143 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
@@ -601,6 +602,8 @@ var configActionToAction = map[string]Action{
601 "move-pane-to-tab": ActionMovePaneToTab,602 "move-pane-to-tab": ActionMovePaneToTab,
602 "move_pane_to_next_tab": ActionMovePaneToNextTab,603 "move_pane_to_next_tab": ActionMovePaneToNextTab,
603 "move-pane-to-next-tab": ActionMovePaneToNextTab,604 "move-pane-to-next-tab": ActionMovePaneToNextTab,
605+ "eject_pane_to_window": ActionEjectPaneToWindow,
606+ "eject-pane-to-window": ActionEjectPaneToWindow,
604 607
605 "consume_or_expel_left": ActionConsumeOrExpelLeft,608 "consume_or_expel_left": ActionConsumeOrExpelLeft,
606 "consume-or-expel-left": ActionConsumeOrExpelLeft,609 "consume-or-expel-left": ActionConsumeOrExpelLeft,
@@ -824,7 +827,7 @@ func ShouldAutoExitMode(action Action) bool {
824 case ActionNewTab, ActionCloseTab, ActionRenameTab,827 case ActionNewTab, ActionCloseTab, ActionRenameTab,
825 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,828 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
826 ActionClosePane, ActionStackPane,829 ActionClosePane, ActionStackPane,
827- ActionMovePaneToTab, ActionMovePaneToNextTab,830+ ActionMovePaneToTab, ActionMovePaneToNextTab, ActionEjectPaneToWindow,
828 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,831 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
829 ActionOpenSessionManager:832 ActionOpenSessionManager:
830 return true833 return true
M internal/ui/input/shortcuts_test.go +12 -0
@@ -351,6 +351,7 @@ func TestShouldAutoExitMode(t *testing.T) {
351351 ActionStackPane,
352352 ActionMovePaneToTab,
353353 ActionMovePaneToNextTab,
354+ ActionEjectPaneToWindow,
354355 }
355356
356357 stayActions := []Action{
@@ -376,6 +377,17 @@ func TestShouldAutoExitMode(t *testing.T) {
376377 }
377378 }
378379
380+func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
381+ tests := []string{"eject_pane_to_window", "eject-pane-to-window"}
382+ for _, name := range tests {
383+ t.Run(name, func(t *testing.T) {
384+ if got := mapConfigAction(name); got != ActionEjectPaneToWindow {
385+ t.Fatalf("mapConfigAction(%s) = %s, want %s", name, got, ActionEjectPaneToWindow)
386+ }
387+ })
388+ }
389+}
390+
379391 func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
380392 action := mapConfigAction("toggle_floating_pane")
381393 if action != ActionToggleFloatingPane {
@@ -351,6 +351,7 @@ func TestShouldAutoExitMode(t *testing.T) {
351 ActionStackPane,351 ActionStackPane,
352 ActionMovePaneToTab,352 ActionMovePaneToTab,
353 ActionMovePaneToNextTab,353 ActionMovePaneToNextTab,
354+ ActionEjectPaneToWindow,
354 }355 }
355 356
356 stayActions := []Action{357 stayActions := []Action{
@@ -376,6 +377,17 @@ func TestShouldAutoExitMode(t *testing.T) {
376 }377 }
377 }378 }
378 379
380+func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
381+ tests := []string{"eject_pane_to_window", "eject-pane-to-window"}
382+ for _, name := range tests {
383+ t.Run(name, func(t *testing.T) {
384+ if got := mapConfigAction(name); got != ActionEjectPaneToWindow {
385+ t.Fatalf("mapConfigAction(%s) = %s, want %s", name, got, ActionEjectPaneToWindow)
386+ }
387+ })
388+ }
389+}
390+
379 func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {391 func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
380 action := mapConfigAction("toggle_floating_pane")392 action := mapConfigAction("toggle_floating_pane")
381 if action != ActionToggleFloatingPane {393 if action != ActionToggleFloatingPane {
A internal/ui/pane_window_eject.go +255 -0
@@ -0,0 +1,255 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/component"
11+)
12+
13+func (a *App) createEmptyBrowserWindow(ctx context.Context) (*browserWindow, error) {
14+ created, err := a.createBrowserWindow(ctx, "")
15+ if err != nil {
16+ return nil, err
17+ }
18+ a.registerBrowserWindow(created)
19+ return created, nil
20+}
21+
22+func (a *App) cleanupEjectTargetWindow(bw *browserWindow) {
23+ if bw == nil {
24+ return
25+ }
26+ if bw.tabs != nil {
27+ for _, tab := range bw.tabs.Tabs {
28+ if tab == nil {
29+ continue
30+ }
31+ delete(a.windowForTab, tab.ID)
32+ delete(a.workspaceViews, tab.ID)
33+ if a.tabs != nil {
34+ a.tabs.Remove(tab.ID)
35+ }
36+ }
37+ }
38+ a.removeBrowserWindow(bw.id)
39+ if bw.mainWindow != nil {
40+ bw.mainWindow.Destroy()
41+ }
42+}
43+
44+type ejectRollbackSnapshot struct {
45+ sourceTabs *entity.TabList
46+ sourceSnapshot *entity.TabList
47+ targetTabs *entity.TabList
48+ targetSnapshot *entity.TabList
49+ globalSnapshot *entity.TabList
50+ windowForTab map[entity.TabID]*browserWindow
51+ workspaceViews map[entity.TabID]*component.WorkspaceView
52+}
53+
54+func (a *App) captureEjectRollbackSnapshot(sourceTabs, targetTabs *entity.TabList) ejectRollbackSnapshot {
55+ return ejectRollbackSnapshot{
56+ sourceTabs: sourceTabs,
57+ sourceSnapshot: sourceTabs.Snapshot(),
58+ targetTabs: targetTabs,
59+ targetSnapshot: targetTabs.Snapshot(),
60+ globalSnapshot: a.tabs.Snapshot(),
61+ windowForTab: cloneWindowForTabMap(a.windowForTab),
62+ workspaceViews: cloneWorkspaceViewsMap(a.workspaceViews),
63+ }
64+}
65+
66+func (a *App) restoreEjectSnapshots(snapshot ejectRollbackSnapshot) {
67+ if snapshot.sourceTabs != nil {
68+ snapshot.sourceTabs.ReplaceFrom(snapshot.sourceSnapshot)
69+ }
70+ if snapshot.targetTabs != nil {
71+ snapshot.targetTabs.ReplaceFrom(snapshot.targetSnapshot)
72+ }
73+ if a.tabs != nil {
74+ a.tabs.ReplaceFrom(snapshot.globalSnapshot)
75+ }
76+ a.windowForTab = snapshot.windowForTab
77+ a.workspaceViews = snapshot.workspaceViews
78+}
79+
80+func (a *App) rollbackEjectTargetUIFailure(targetWindow *browserWindow, snapshot ejectRollbackSnapshot) error {
81+ a.restoreEjectSnapshots(snapshot)
82+ a.cleanupEjectTargetWindow(targetWindow)
83+ return fmt.Errorf("failed to create target workspace view")
84+}
85+
86+func cloneWindowForTabMap(source map[entity.TabID]*browserWindow) map[entity.TabID]*browserWindow {
87+ cloned := make(map[entity.TabID]*browserWindow, len(source))
88+ for tabID, bw := range source {
89+ cloned[tabID] = bw
90+ }
91+ return cloned
92+}
93+
94+func cloneWorkspaceViewsMap(source map[entity.TabID]*component.WorkspaceView) map[entity.TabID]*component.WorkspaceView {
95+ cloned := make(map[entity.TabID]*component.WorkspaceView, len(source))
96+ for tabID, wsView := range source {
97+ cloned[tabID] = wsView
98+ }
99+ return cloned
100+}
101+
102+func (a *App) activePaneEjectSource(requestedPaneID entity.PaneID) (*browserWindow, *entity.TabList, *entity.Tab, entity.PaneID) {
103+ sourceWindow := a.lastFocusedBrowserWindow()
104+ sourceTabs := a.tabListForBrowserWindow(sourceWindow)
105+ sourceTab := a.activeTabForBrowserWindow(sourceWindow)
106+ if sourceWindow == nil || sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil {
107+ return nil, nil, nil, ""
108+ }
109+ sourcePaneID := sourceTab.Workspace.ActivePaneID
110+ if requestedPaneID != "" && requestedPaneID != sourcePaneID {
111+ return nil, nil, nil, ""
112+ }
113+ sourceNode := sourceTab.Workspace.FindPane(sourcePaneID)
114+ if sourcePaneID == "" || sourceNode == nil || sourceNode.Pane == nil || !isEjectableWorkspacePane(sourceNode.Pane) {
115+ return nil, nil, nil, ""
116+ }
117+ return sourceWindow, sourceTabs, sourceTab, sourcePaneID
118+}
119+
120+func isEjectableWorkspacePane(pane *entity.Pane) bool {
121+ if pane == nil {
122+ return false
123+ }
124+ return pane.WindowType == entity.WindowMain && !strings.HasPrefix(pane.URI, "dumb://")
125+}
126+
127+func (a *App) EjectActivePaneToWindow(ctx context.Context, requestedPaneID entity.PaneID) error {
128+ if a.extractPaneToTabListUC == nil {
129+ return nil
130+ }
131+
132+ sourceWindow, sourceTabs, sourceTab, sourcePaneID := a.activePaneEjectSource(requestedPaneID)
133+ if sourceWindow == nil {
134+ return nil
135+ }
136+
137+ targetWindow, err := a.createEmptyBrowserWindow(ctx)
138+ if err != nil {
139+ return err
140+ }
141+ targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
142+ if targetTabs == nil {
143+ a.cleanupEjectTargetWindow(targetWindow)
144+ return fmt.Errorf("target tab list is required")
145+ }
146+
147+ rollbackSnapshot := a.captureEjectRollbackSnapshot(sourceTabs, targetTabs)
148+
149+ out, err := a.extractPaneToTabListUC.Execute(usecase.ExtractPaneToTabListInput{
150+ SourceTabs: sourceTabs,
151+ SourceTabID: sourceTab.ID,
152+ SourcePaneID: sourcePaneID,
153+ TargetTabs: targetTabs,
154+ })
155+ if err != nil {
156+ a.cleanupEjectTargetWindow(targetWindow)
157+ return err
158+ }
159+ if out == nil || out.NewTab == nil {
160+ a.cleanupEjectTargetWindow(targetWindow)
161+ return nil
162+ }
163+
164+ a.setBrowserWindowForTab(out.NewTab.ID, targetWindow)
165+ a.syncDerivedGlobalTabMirror(out.NewTab)
166+ if a.tabs != nil {
167+ a.tabs.SetActive(out.NewTab.ID)
168+ }
169+ a.ensureTargetTabUI(ctx, out.NewTab, targetWindow)
170+ if a.workspaceViews[out.NewTab.ID] == nil {
171+ return a.rollbackEjectTargetUIFailure(targetWindow, rollbackSnapshot)
172+ }
173+
174+ if out.SourceTabClosed {
175+ a.removeSourceTabUI(sourceTab.ID, sourceWindow)
176+ delete(a.windowForTab, sourceTab.ID)
177+ a.switchSourceWindowToActiveTab(ctx, sourceWindow, sourceTabs)
178+ } else {
179+ a.rebuildAndAttachWorkspace(ctx, sourceTab.ID, sourceTab)
180+ }
181+
182+ a.rebuildAndAttachWorkspace(ctx, out.NewTab.ID, out.NewTab)
183+ if a.contentCoord != nil && out.NewTab.Workspace != nil {
184+ a.contentCoord.SyncWebViewViewport(ctx, out.NewTab.Workspace.ActivePaneID, "eject-pane-to-window")
185+ }
186+
187+ a.updateEjectWindowChrome(ctx, sourceWindow, targetWindow, targetTabs, out.NewTab.ID)
188+
189+ if out.SourceTabClosed && sourceTabs.Count() == 0 {
190+ a.removeEmptySourceBrowserWindow(sourceWindow)
191+ }
192+
193+ targetWindows := a.orderedBrowserWindowsForSnapshot()
194+ a.replaceGlobalTabsFromRuntimeWindows(targetWindows, targetWindow)
195+
196+ a.activateBrowserWindow(targetWindow)
197+ a.switchWorkspaceView(ctx, out.NewTab.ID)
198+ if targetWindow.mainWindow != nil {
199+ targetWindow.mainWindow.Show()
200+ }
201+ a.MarkDirty()
202+ return nil
203+}
204+
205+func (a *App) updateEjectWindowChrome(
206+ ctx context.Context,
207+ sourceWindow, targetWindow *browserWindow,
208+ targetTabs *entity.TabList,
209+ newTabID entity.TabID,
210+) {
211+ targetTabs.SetActive(newTabID)
212+ if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
213+ tabBar.SetActive(newTabID)
214+ }
215+ if a.tabCoord != nil {
216+ a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(sourceWindow))
217+ a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(targetWindow))
218+ }
219+ a.updateBrowserWindowTabBarVisibility(sourceWindow)
220+ a.updateBrowserWindowTabBarVisibility(targetWindow)
221+}
222+
223+func (a *App) switchSourceWindowToActiveTab(ctx context.Context, bw *browserWindow, tabs *entity.TabList) {
224+ if bw == nil || tabs == nil || tabs.Count() == 0 {
225+ return
226+ }
227+ activeTab := tabs.ActiveTab()
228+ if activeTab == nil {
229+ return
230+ }
231+ if tabBar := a.tabBarForBrowserWindow(bw); tabBar != nil {
232+ tabBar.SetActive(activeTab.ID)
233+ }
234+ a.switchWorkspaceView(ctx, activeTab.ID)
235+}
236+
237+func (a *App) removeEmptySourceBrowserWindow(bw *browserWindow) {
238+ if bw == nil || a.tabCountForBrowserWindow(bw) != 0 {
239+ return
240+ }
241+ a.removeBrowserWindow(bw.id)
242+ if bw.mainWindow != nil {
243+ bw.mainWindow.Destroy()
244+ }
245+}
246+
247+func (a *App) orderedBrowserWindowsForSnapshot() []*browserWindow {
248+ windows := make([]*browserWindow, 0, len(a.browserWindows))
249+ for _, id := range a.windowOrder() {
250+ if bw := a.browserWindows[id]; bw != nil {
251+ windows = append(windows, bw)
252+ }
253+ }
254+ return windows
255+}
@@ -0,0 +1,255 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/component"
11+)
12+
13+func (a *App) createEmptyBrowserWindow(ctx context.Context) (*browserWindow, error) {
14+ created, err := a.createBrowserWindow(ctx, "")
15+ if err != nil {
16+ return nil, err
17+ }
18+ a.registerBrowserWindow(created)
19+ return created, nil
20+}
21+
22+func (a *App) cleanupEjectTargetWindow(bw *browserWindow) {
23+ if bw == nil {
24+ return
25+ }
26+ if bw.tabs != nil {
27+ for _, tab := range bw.tabs.Tabs {
28+ if tab == nil {
29+ continue
30+ }
31+ delete(a.windowForTab, tab.ID)
32+ delete(a.workspaceViews, tab.ID)
33+ if a.tabs != nil {
34+ a.tabs.Remove(tab.ID)
35+ }
36+ }
37+ }
38+ a.removeBrowserWindow(bw.id)
39+ if bw.mainWindow != nil {
40+ bw.mainWindow.Destroy()
41+ }
42+}
43+
44+type ejectRollbackSnapshot struct {
45+ sourceTabs *entity.TabList
46+ sourceSnapshot *entity.TabList
47+ targetTabs *entity.TabList
48+ targetSnapshot *entity.TabList
49+ globalSnapshot *entity.TabList
50+ windowForTab map[entity.TabID]*browserWindow
51+ workspaceViews map[entity.TabID]*component.WorkspaceView
52+}
53+
54+func (a *App) captureEjectRollbackSnapshot(sourceTabs, targetTabs *entity.TabList) ejectRollbackSnapshot {
55+ return ejectRollbackSnapshot{
56+ sourceTabs: sourceTabs,
57+ sourceSnapshot: sourceTabs.Snapshot(),
58+ targetTabs: targetTabs,
59+ targetSnapshot: targetTabs.Snapshot(),
60+ globalSnapshot: a.tabs.Snapshot(),
61+ windowForTab: cloneWindowForTabMap(a.windowForTab),
62+ workspaceViews: cloneWorkspaceViewsMap(a.workspaceViews),
63+ }
64+}
65+
66+func (a *App) restoreEjectSnapshots(snapshot ejectRollbackSnapshot) {
67+ if snapshot.sourceTabs != nil {
68+ snapshot.sourceTabs.ReplaceFrom(snapshot.sourceSnapshot)
69+ }
70+ if snapshot.targetTabs != nil {
71+ snapshot.targetTabs.ReplaceFrom(snapshot.targetSnapshot)
72+ }
73+ if a.tabs != nil {
74+ a.tabs.ReplaceFrom(snapshot.globalSnapshot)
75+ }
76+ a.windowForTab = snapshot.windowForTab
77+ a.workspaceViews = snapshot.workspaceViews
78+}
79+
80+func (a *App) rollbackEjectTargetUIFailure(targetWindow *browserWindow, snapshot ejectRollbackSnapshot) error {
81+ a.restoreEjectSnapshots(snapshot)
82+ a.cleanupEjectTargetWindow(targetWindow)
83+ return fmt.Errorf("failed to create target workspace view")
84+}
85+
86+func cloneWindowForTabMap(source map[entity.TabID]*browserWindow) map[entity.TabID]*browserWindow {
87+ cloned := make(map[entity.TabID]*browserWindow, len(source))
88+ for tabID, bw := range source {
89+ cloned[tabID] = bw
90+ }
91+ return cloned
92+}
93+
94+func cloneWorkspaceViewsMap(source map[entity.TabID]*component.WorkspaceView) map[entity.TabID]*component.WorkspaceView {
95+ cloned := make(map[entity.TabID]*component.WorkspaceView, len(source))
96+ for tabID, wsView := range source {
97+ cloned[tabID] = wsView
98+ }
99+ return cloned
100+}
101+
102+func (a *App) activePaneEjectSource(requestedPaneID entity.PaneID) (*browserWindow, *entity.TabList, *entity.Tab, entity.PaneID) {
103+ sourceWindow := a.lastFocusedBrowserWindow()
104+ sourceTabs := a.tabListForBrowserWindow(sourceWindow)
105+ sourceTab := a.activeTabForBrowserWindow(sourceWindow)
106+ if sourceWindow == nil || sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil {
107+ return nil, nil, nil, ""
108+ }
109+ sourcePaneID := sourceTab.Workspace.ActivePaneID
110+ if requestedPaneID != "" && requestedPaneID != sourcePaneID {
111+ return nil, nil, nil, ""
112+ }
113+ sourceNode := sourceTab.Workspace.FindPane(sourcePaneID)
114+ if sourcePaneID == "" || sourceNode == nil || sourceNode.Pane == nil || !isEjectableWorkspacePane(sourceNode.Pane) {
115+ return nil, nil, nil, ""
116+ }
117+ return sourceWindow, sourceTabs, sourceTab, sourcePaneID
118+}
119+
120+func isEjectableWorkspacePane(pane *entity.Pane) bool {
121+ if pane == nil {
122+ return false
123+ }
124+ return pane.WindowType == entity.WindowMain && !strings.HasPrefix(pane.URI, "dumb://")
125+}
126+
127+func (a *App) EjectActivePaneToWindow(ctx context.Context, requestedPaneID entity.PaneID) error {
128+ if a.extractPaneToTabListUC == nil {
129+ return nil
130+ }
131+
132+ sourceWindow, sourceTabs, sourceTab, sourcePaneID := a.activePaneEjectSource(requestedPaneID)
133+ if sourceWindow == nil {
134+ return nil
135+ }
136+
137+ targetWindow, err := a.createEmptyBrowserWindow(ctx)
138+ if err != nil {
139+ return err
140+ }
141+ targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
142+ if targetTabs == nil {
143+ a.cleanupEjectTargetWindow(targetWindow)
144+ return fmt.Errorf("target tab list is required")
145+ }
146+
147+ rollbackSnapshot := a.captureEjectRollbackSnapshot(sourceTabs, targetTabs)
148+
149+ out, err := a.extractPaneToTabListUC.Execute(usecase.ExtractPaneToTabListInput{
150+ SourceTabs: sourceTabs,
151+ SourceTabID: sourceTab.ID,
152+ SourcePaneID: sourcePaneID,
153+ TargetTabs: targetTabs,
154+ })
155+ if err != nil {
156+ a.cleanupEjectTargetWindow(targetWindow)
157+ return err
158+ }
159+ if out == nil || out.NewTab == nil {
160+ a.cleanupEjectTargetWindow(targetWindow)
161+ return nil
162+ }
163+
164+ a.setBrowserWindowForTab(out.NewTab.ID, targetWindow)
165+ a.syncDerivedGlobalTabMirror(out.NewTab)
166+ if a.tabs != nil {
167+ a.tabs.SetActive(out.NewTab.ID)
168+ }
169+ a.ensureTargetTabUI(ctx, out.NewTab, targetWindow)
170+ if a.workspaceViews[out.NewTab.ID] == nil {
171+ return a.rollbackEjectTargetUIFailure(targetWindow, rollbackSnapshot)
172+ }
173+
174+ if out.SourceTabClosed {
175+ a.removeSourceTabUI(sourceTab.ID, sourceWindow)
176+ delete(a.windowForTab, sourceTab.ID)
177+ a.switchSourceWindowToActiveTab(ctx, sourceWindow, sourceTabs)
178+ } else {
179+ a.rebuildAndAttachWorkspace(ctx, sourceTab.ID, sourceTab)
180+ }
181+
182+ a.rebuildAndAttachWorkspace(ctx, out.NewTab.ID, out.NewTab)
183+ if a.contentCoord != nil && out.NewTab.Workspace != nil {
184+ a.contentCoord.SyncWebViewViewport(ctx, out.NewTab.Workspace.ActivePaneID, "eject-pane-to-window")
185+ }
186+
187+ a.updateEjectWindowChrome(ctx, sourceWindow, targetWindow, targetTabs, out.NewTab.ID)
188+
189+ if out.SourceTabClosed && sourceTabs.Count() == 0 {
190+ a.removeEmptySourceBrowserWindow(sourceWindow)
191+ }
192+
193+ targetWindows := a.orderedBrowserWindowsForSnapshot()
194+ a.replaceGlobalTabsFromRuntimeWindows(targetWindows, targetWindow)
195+
196+ a.activateBrowserWindow(targetWindow)
197+ a.switchWorkspaceView(ctx, out.NewTab.ID)
198+ if targetWindow.mainWindow != nil {
199+ targetWindow.mainWindow.Show()
200+ }
201+ a.MarkDirty()
202+ return nil
203+}
204+
205+func (a *App) updateEjectWindowChrome(
206+ ctx context.Context,
207+ sourceWindow, targetWindow *browserWindow,
208+ targetTabs *entity.TabList,
209+ newTabID entity.TabID,
210+) {
211+ targetTabs.SetActive(newTabID)
212+ if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
213+ tabBar.SetActive(newTabID)
214+ }
215+ if a.tabCoord != nil {
216+ a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(sourceWindow))
217+ a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(targetWindow))
218+ }
219+ a.updateBrowserWindowTabBarVisibility(sourceWindow)
220+ a.updateBrowserWindowTabBarVisibility(targetWindow)
221+}
222+
223+func (a *App) switchSourceWindowToActiveTab(ctx context.Context, bw *browserWindow, tabs *entity.TabList) {
224+ if bw == nil || tabs == nil || tabs.Count() == 0 {
225+ return
226+ }
227+ activeTab := tabs.ActiveTab()
228+ if activeTab == nil {
229+ return
230+ }
231+ if tabBar := a.tabBarForBrowserWindow(bw); tabBar != nil {
232+ tabBar.SetActive(activeTab.ID)
233+ }
234+ a.switchWorkspaceView(ctx, activeTab.ID)
235+}
236+
237+func (a *App) removeEmptySourceBrowserWindow(bw *browserWindow) {
238+ if bw == nil || a.tabCountForBrowserWindow(bw) != 0 {
239+ return
240+ }
241+ a.removeBrowserWindow(bw.id)
242+ if bw.mainWindow != nil {
243+ bw.mainWindow.Destroy()
244+ }
245+}
246+
247+func (a *App) orderedBrowserWindowsForSnapshot() []*browserWindow {
248+ windows := make([]*browserWindow, 0, len(a.browserWindows))
249+ for _, id := range a.windowOrder() {
250+ if bw := a.browserWindows[id]; bw != nil {
251+ windows = append(windows, bw)
252+ }
253+ }
254+ return windows
255+}

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