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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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"] | |||
| 239 | 239 | close-pane = ["x"] | |
| 240 | 240 | move-pane-to-tab = ["m"] | |
| 241 | 241 | move-pane-to-next-tab = ["M", "shift+m"] | |
| 242 | + | eject-pane-to-window = ["w"] | |
| 242 | 243 | ||
| 243 | 244 | # Consume-or-expel (niri-style) - very alpha | |
| 244 | 245 | 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 alpha | 244 | # 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 | |||
| 13 | 13 | ||
| 14 | 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 | 18 | ## Pane Mode (`Ctrl+P`) | |
| 17 | 19 | ||
| 18 | 20 | | Action | Keys | | |
| @@ -25,6 +27,7 @@ Press `Escape` or `Enter` to exit any mode. | |||
| 25 | 27 | | Close pane | `X` | | |
| 26 | 28 | | Move to tab | `M` | | |
| 27 | 29 | | Move to next tab | `Shift+M` | | |
| 30 | + | | Eject to window | `W` | | |
| 28 | 31 | | Focus right | `Shift+→`, `Shift+L` | | |
| 29 | 32 | | Focus left | `Shift+←`, `Shift+H` | | |
| 30 | 33 | | 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, | |||
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | 236 | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | |
| 237 | + | leaf.Parent = nil | |
| 237 | 238 | if stackNode.ActiveStackIndex >= len(stackNode.Children) { | |
| 238 | 239 | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 239 | 240 | } | |
| @@ -248,6 +249,7 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||
| 248 | 249 | stackNode.Children = nil | |
| 249 | 250 | stackNode.IsStacked = false | |
| 250 | 251 | stackNode.ActiveStackIndex = 0 | |
| 252 | + | remaining.Parent = nil | |
| 251 | 253 | if stackNode.Pane != nil { | |
| 252 | 254 | ws.ActivePaneID = stackNode.Pane.ID | |
| 253 | 255 | } else { | |
| @@ -280,6 +282,8 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error | |||
| 280 | 282 | } | |
| 281 | 283 | ||
| 282 | 284 | promoteSibling(ws, parent, sibling) | |
| 285 | + | leaf.Parent = nil | |
| 286 | + | parent.Children = nil | |
| 283 | 287 | ws.ActivePaneID = findFirstLeafPaneID(sibling) | |
| 284 | 288 | return nil | |
| 285 | 289 | } | |
| @@ -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) - 1 | 239 | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | ||
| 239 | } | 240 | } | ||
| @@ -248,6 +249,7 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||||
| 248 | stackNode.Children = nil | 249 | stackNode.Children = nil | ||
| 249 | stackNode.IsStacked = false | 250 | stackNode.IsStacked = false | ||
| 250 | stackNode.ActiveStackIndex = 0 | 251 | stackNode.ActiveStackIndex = 0 | ||
| 252 | + | remaining.Parent = nil | |||
| 251 | if stackNode.Pane != nil { | 253 | if stackNode.Pane != nil { | ||
| 252 | ws.ActivePaneID = stackNode.Pane.ID | 254 | 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 nil | 288 | return nil | ||
| 285 | } | 289 | } | ||
M internal/infrastructure/config/defaults.go +1 -0
| @@ -220,6 +220,7 @@ func DefaultConfig() *Config { | |||
| 220 | 220 | "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"}, | |
| 221 | 221 | "move-pane-to-tab": {Keys: []string{"m"}, Desc: "Move pane to different tab"}, | |
| 222 | 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 | 225 | "consume-or-expel-left": {Keys: []string{"["}, Desc: "Consume/expel pane left"}, | |
| 225 | 226 | "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) { | |||
| 35 | 35 | assert.InDelta(t, defaultCEFTouchpadNavigationRatio, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001) | |
| 36 | 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 | 42 | // System view shortcuts are first-class global actions. | |
| 39 | 43 | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"}) | |
| 40 | 44 | 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() { | |||
| 175 | 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 | 182 | // Apply transformed config back to viper | |
| 179 | 183 | for key, value := range rawConfig { | |
| 180 | 184 | 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 viper | 182 | // 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 ( | |||
| 14 | 14 | "gopkg.in/yaml.v3" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | 20 | // Migrator implements port.ConfigMigrator for comparing and merging config files. | |
| @@ -24,6 +25,11 @@ type Migrator struct { | |||
| 24 | 25 | 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 | 33 | // NewMigrator creates a new Migrator instance. | |
| 28 | 34 | func NewMigrator() *Migrator { | |
| 29 | 35 | v := viper.New() | |
| @@ -129,7 +135,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||
| 129 | 135 | ||
| 130 | 136 | // Find missing keys (in defaults but not in user config) | |
| 131 | 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 | 140 | // Try to match deprecated keys with missing keys (detect renames) | |
| 135 | 141 | renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys) | |
| @@ -280,10 +286,24 @@ const ( | |||
| 280 | 286 | configFormatTOML = "toml" | |
| 281 | 287 | configFormatYAML = "yaml" | |
| 282 | 288 | configFormatJSON = "json" | |
| 283 | - | databasePathKey = "database.path" | |
| 284 | - | workspaceShortcutsActionsKey = "workspace.shortcuts.actions" | |
| 289 | + | databasePathKey = "database.path" | |
| 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 | 307 | // typesAreCompatible checks if the default types for two keys are compatible. | |
| 288 | 308 | // This prevents matching int keys with string keys during rename detection. | |
| 289 | 309 | func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool { | |
| @@ -430,7 +450,7 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 430 | 450 | for key := range keysToRemove { | |
| 431 | 451 | m.deleteNestedKey(rawConfig, key) | |
| 432 | 452 | } | |
| 433 | - | m.mergeMissingWorkspaceShortcutActions(rawConfig) | |
| 453 | + | m.mergeMissingDefaultActions(rawConfig) | |
| 434 | 454 | ||
| 435 | 455 | // Create a new Viper instance with defaults for added keys | |
| 436 | 456 | userViper := viper.New() | |
| @@ -631,10 +651,12 @@ func (m *Migrator) defaultValueForKey(key string) any { | |||
| 631 | 651 | return value | |
| 632 | 652 | } | |
| 633 | 653 | ||
| 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 | + | } | |
| 638 | 660 | } | |
| 639 | 661 | } | |
| 640 | 662 | ||
| @@ -811,68 +833,66 @@ func (*Migrator) isUserDataSection(keyPath string) bool { | |||
| 811 | 833 | return false | |
| 812 | 834 | } | |
| 813 | 835 | ||
| 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 { | |
| 816 | 838 | if userKeysWithValues == nil { | |
| 817 | 839 | 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 | 842 | 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 { | |
| 838 | 846 | continue | |
| 839 | 847 | } | |
| 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 { | |
| 842 | 851 | 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 | 866 | sort.Strings(missing) | |
| 848 | 867 | return missing | |
| 849 | 868 | } | |
| 850 | 869 | ||
| 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 { | |
| 866 | 875 | continue | |
| 867 | 876 | } | |
| 868 | - | legacyActionName := strings.ReplaceAll(actionName, "-", "_") | |
| 869 | - | if _, exists := userActions[legacyActionName]; exists { | |
| 877 | + | ||
| 878 | + | userActions, ok := m.toStringAnyMap(actionsValue) | |
| 879 | + | if !ok { | |
| 870 | 880 | 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 | 898 | 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 *Config | 25 | 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 keys | 455 | // 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 value | 651 | 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 action | 657 | + | 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 false | 833 | 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 nil | 839 | 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 | continue | 846 | 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 | continue | 851 | 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 missing | 867 | 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 | - | return | 874 | + | 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 | continue | 875 | 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 | continue | 880 | 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) { | |||
| 689 | 689 | } | |
| 690 | 690 | } | |
| 691 | 691 | ||
| 692 | - | func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) { | |
| 692 | + | func TestMigrator_DetectMissingDefaultActions(t *testing.T) { | |
| 693 | 693 | m := NewMigrator() | |
| 694 | 694 | ||
| 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 | |
| 699 | 698 | } | |
| 700 | - | delete(userActions, "toggle-floating-pane") | |
| 699 | + | delete(shortcutActions, "toggle-floating-pane") | |
| 701 | 700 | ||
| 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, | |
| 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 | 719 | m := NewMigrator() | |
| 711 | 720 | ||
| 712 | 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 | 760 | actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions") | |
| 752 | 761 | actions, ok := actionsAny.(map[string]any) | |
| @@ -770,6 +779,43 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | |||
| 770 | 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 | 819 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | |
| 774 | 820 | m := NewMigrator() | |
| 775 | 821 | ||
| @@ -780,6 +826,16 @@ func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | |||
| 780 | 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 | 839 | func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) { | |
| 784 | 840 | m := NewMigrator() | |
| 785 | 841 | ||
| @@ -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.Actions | 695 | + | 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 { | |||
| 100 | 100 | panesUC *usecase.ManagePanesUseCase | |
| 101 | 101 | workspaceViews map[entity.TabID]*component.WorkspaceView | |
| 102 | 102 | 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 | |
| 105 | 106 | ||
| 106 | 107 | // Input handling | |
| 107 | 108 | keyboardHandler *input.KeyboardHandler | |
| @@ -137,7 +138,8 @@ type App struct { | |||
| 137 | 138 | windowIDMu sync.Mutex | |
| 138 | 139 | firstWebViewShownOnce sync.Once | |
| 139 | 140 | ||
| 140 | - | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| 141 | + | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| 142 | + | extractPaneToTabListUC *usecase.ExtractPaneToTabListUseCase | |
| 141 | 143 | ||
| 142 | 144 | // Accent picker for dead keys support | |
| 143 | 145 | accentFocusProvider port.FocusedInputProvider | |
| @@ -1772,7 +1774,11 @@ func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWind | |||
| 1772 | 1774 | if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil { | |
| 1773 | 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 | 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 | 2840 | if tab == nil { | |
| 2835 | 2841 | return | |
| 2836 | 2842 | } | |
| 2837 | - | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 2843 | + | if !a.createWorkspaceViewWithoutAttach(ctx, tab) { | |
| 2844 | + | return | |
| 2845 | + | } | |
| 2838 | 2846 | wsView := a.workspaceViews[tab.ID] | |
| 2839 | 2847 | if a.wsCoord != nil && wsView != nil { | |
| 2840 | 2848 | a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView) | |
| @@ -3198,8 +3206,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3198 | 3206 | // Wire tabbed popup behavior to create new tabs in the originating window. | |
| 3199 | 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 | 3210 | a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID) | |
| 3211 | + | a.extractPaneToTabListUC = usecase.NewExtractPaneToTabListUseCase(a.generateID) | |
| 3203 | 3212 | ||
| 3204 | 3213 | // 4. Navigation Coordinator | |
| 3205 | 3214 | a.navCoord = coordinator.NewNavigationCoordinator( | |
| @@ -3372,6 +3381,12 @@ func (a *App) wireKeyboardActions() { | |||
| 3372 | 3381 | } | |
| 3373 | 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 | 3390 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | |
| 3376 | 3391 | return a.ToggleFloatingPane(ctx) | |
| 3377 | 3392 | }) | |
| @@ -3778,7 +3793,9 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) { | |||
| 3778 | 3793 | ||
| 3779 | 3794 | // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area. | |
| 3780 | 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 | 3800 | target := a.browserWindowForTab(tab.ID) | |
| 3784 | 3801 | if target != nil { | |
| @@ -3800,19 +3817,23 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |||
| 3800 | 3817 | ||
| 3801 | 3818 | // createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area. | |
| 3802 | 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 | 3825 | log := logging.FromContext(ctx) | |
| 3805 | 3826 | ||
| 3806 | 3827 | if a.widgetFactory == nil { | |
| 3807 | 3828 | log.Error().Msg("widget factory not initialized") | |
| 3808 | - | return | |
| 3829 | + | return false | |
| 3809 | 3830 | } | |
| 3810 | 3831 | ||
| 3811 | 3832 | // Create workspace view | |
| 3812 | 3833 | wsView := component.NewWorkspaceView(ctx, a.widgetFactory) | |
| 3813 | 3834 | if wsView == nil { | |
| 3814 | 3835 | log.Error().Msg("failed to create workspace view") | |
| 3815 | - | return | |
| 3836 | + | return false | |
| 3816 | 3837 | } | |
| 3817 | 3838 | a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget()) | |
| 3818 | 3839 | if a.contentCoord != nil { | |
| @@ -3831,7 +3852,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 3831 | 3852 | // Set the workspace | |
| 3832 | 3853 | if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil { | |
| 3833 | 3854 | log.Error().Err(err).Msg("failed to set workspace in view") | |
| 3834 | - | return | |
| 3855 | + | return false | |
| 3835 | 3856 | } | |
| 3836 | 3857 | ||
| 3837 | 3858 | // Ensure WebViews are attached to panes | |
| @@ -3883,6 +3904,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 3883 | 3904 | a.syncFloatingFocus() | |
| 3884 | 3905 | ||
| 3885 | 3906 | log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created") | |
| 3907 | + | return true | |
| 3886 | 3908 | } | |
| 3887 | 3909 | ||
| 3888 | 3910 | // activeWorkspace returns the workspace of the focused browser window's active tab. | |
| @@ -100,8 +100,9 @@ type App struct { | |||||
| 100 | panesUC *usecase.ManagePanesUseCase | 100 | panesUC *usecase.ManagePanesUseCase | ||
| 101 | workspaceViews map[entity.TabID]*component.WorkspaceView | 101 | workspaceViews map[entity.TabID]*component.WorkspaceView | ||
| 102 | windowForTab map[entity.TabID]*browserWindow | 102 | windowForTab map[entity.TabID]*browserWindow | ||
| 103 | - | widgetFactory layout.WidgetFactory | 103 | + | widgetFactory layout.WidgetFactory |
| 104 | - | stackedPaneMgr *component.StackedPaneManager | 104 | + | workspaceViewCreateOverride func(context.Context, *entity.Tab) bool |
| 105 | + | stackedPaneMgr *component.StackedPaneManager | |||
| 105 | 106 | ||||
| 106 | // Input handling | 107 | // Input handling | ||
| 107 | keyboardHandler *input.KeyboardHandler | 108 | keyboardHandler *input.KeyboardHandler | ||
| @@ -137,7 +138,8 @@ type App struct { | |||||
| 137 | windowIDMu sync.Mutex | 138 | windowIDMu sync.Mutex | ||
| 138 | firstWebViewShownOnce sync.Once | 139 | firstWebViewShownOnce sync.Once | ||
| 139 | 140 | ||||
| 140 | - | movePaneToTabUC *usecase.MovePaneToTabUseCase | 141 | + | movePaneToTabUC *usecase.MovePaneToTabUseCase |
| 142 | + | extractPaneToTabListUC *usecase.ExtractPaneToTabListUseCase | |||
| 141 | 143 | ||||
| 142 | // Accent picker for dead keys support | 144 | // Accent picker for dead keys support | ||
| 143 | accentFocusProvider port.FocusedInputProvider | 145 | 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 | return | 2841 | 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 Coordinator | 3213 | // 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 | - | return | 3829 | + | return false |
| 3809 | } | 3830 | } | ||
| 3810 | 3831 | ||||
| 3811 | // Create workspace view | 3832 | // 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 | - | return | 3836 | + | 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 workspace | 3852 | // 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 | - | return | 3855 | + | return false |
| 3835 | } | 3856 | } | ||
| 3836 | 3857 | ||||
| 3837 | // Ensure WebViews are attached to panes | 3858 | // 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) { | |||
| 226 | 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 | 262 | func TestSetWebViewWidget_FromNil(t *testing.T) { | |
| 230 | 263 | // Arrange | |
| 231 | 264 | mockFactory := mocks.NewMockWidgetFactory(t) | |
| @@ -538,7 +571,7 @@ func setupLoadingSkeletonMocks( | |||
| 538 | 571 | mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe() | |
| 539 | 572 | mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe() | |
| 540 | 573 | mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe() | |
| 541 | - | mockLoadingContainer.EXPECT().SetVisible(true).Maybe() | |
| 574 | + | mockLoadingContainer.EXPECT().SetVisible(mock.Anything).Maybe() | |
| 542 | 575 | ||
| 543 | 576 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once() | |
| 544 | 577 | mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe() | |
| @@ -565,6 +598,7 @@ func setupLoadingSkeletonMocks( | |||
| 565 | 598 | mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe() | |
| 566 | 599 | mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe() | |
| 567 | 600 | mockLoadingSpinner.EXPECT().Start().Maybe() | |
| 601 | + | mockLoadingSpinner.EXPECT().Stop().Maybe() | |
| 568 | 602 | ||
| 569 | 603 | mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once() | |
| 570 | 604 | 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 | // Arrange | 263 | // 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 ( | |||
| 12 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | 13 | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 14 | 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 | 20 | // newMinimalCoordinator returns a Coordinator with the maps required by | |
| @@ -123,6 +126,53 @@ func TestLifecycle_EnsureWebView_ErrorWhenAcquireFails(t *testing.T) { | |||
| 123 | 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 | 177 | // ReleaseWebView | |
| 128 | 178 | // --------------------------------------------------------------------------- | |
| @@ -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 by | 20 | // 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 | // ReleaseWebView | 177 | // ReleaseWebView | ||
| 128 | // --------------------------------------------------------------------------- | 178 | // --------------------------------------------------------------------------- | ||
M internal/ui/dispatcher/keyboard.go +33 -17
| @@ -32,23 +32,24 @@ type KeyboardActions struct { | |||
| 32 | 32 | ||
| 33 | 33 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | |
| 34 | 34 | 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 | |
| 52 | 53 | } | |
| 53 | 54 | ||
| 54 | 55 | // NewKeyboardDispatcher creates a new KeyboardDispatcher. | |
| @@ -114,6 +115,10 @@ func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, | |||
| 114 | 115 | 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 | 122 | func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) { | |
| 118 | 123 | d.onToggleFloating = fn | |
| 119 | 124 | } | |
| @@ -182,6 +187,9 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 182 | 187 | input.ActionMovePaneToNextTab: func(ctx context.Context) error { | |
| 183 | 188 | return d.handleMovePaneToNextTab(ctx) | |
| 184 | 189 | }, | |
| 190 | + | input.ActionEjectPaneToWindow: func(ctx context.Context) error { | |
| 191 | + | return d.handleEjectPaneToWindow(ctx) | |
| 192 | + | }, | |
| 185 | 193 | input.ActionConsumeOrExpelLeft: func(ctx context.Context) error { | |
| 186 | 194 | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft) | |
| 187 | 195 | }, | |
| @@ -333,6 +341,14 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error | |||
| 333 | 341 | 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 | 352 | func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error { | |
| 337 | 353 | if d.actions.SwitchTabIndex == nil { | |
| 338 | 354 | 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 KeyboardActions | 35 | + | actions KeyboardActions |
| 36 | - | wsCoord *coordinator.WorkspaceCoordinator | 36 | + | wsCoord *coordinator.WorkspaceCoordinator |
| 37 | - | navCoord *coordinator.NavigationCoordinator | 37 | + | navCoord *coordinator.NavigationCoordinator |
| 38 | - | zoomUC *usecase.ManageZoomUseCase | 38 | + | zoomUC *usecase.ManageZoomUseCase |
| 39 | - | copyURLUC *usecase.CopyURLUseCase | 39 | + | copyURLUC *usecase.CopyURLUseCase |
| 40 | - | actionHandlers map[input.Action]func(ctx context.Context) error | 40 | + | actionHandlers map[input.Action]func(ctx context.Context) error |
| 41 | - | onQuit func() | 41 | + | onQuit func() |
| 42 | - | onFindOpen func(ctx context.Context) error | 42 | + | onFindOpen func(ctx context.Context) error |
| 43 | - | onFindNext func(ctx context.Context) error | 43 | + | onFindNext func(ctx context.Context) error |
| 44 | - | onFindPrev func(ctx context.Context) error | 44 | + | onFindPrev func(ctx context.Context) error |
| 45 | - | onFindClose func(ctx context.Context) error | 45 | + | onFindClose func(ctx context.Context) error |
| 46 | - | activePaneID func(ctx context.Context) entity.PaneID | 46 | + | activePaneID func(ctx context.Context) entity.PaneID |
| 47 | - | onSessionOpen func(ctx context.Context, paneID entity.PaneID) error | 47 | + | onSessionOpen func(ctx context.Context, paneID entity.PaneID) error |
| 48 | - | onMovePaneToTab 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 | 49 | + | onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error |
| 50 | - | onToggleFloating func(ctx context.Context) error | 50 | + | onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error |
| 51 | - | onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error | 51 | + | 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 = fn | 115 | 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 = fn | 123 | 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 nil | 341 | 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) { | |||
| 124 | 124 | set: d.SetOnMovePaneToNextTab, | |
| 125 | 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 | 134 | 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 ( | |||
| 137 | 137 | ||
| 138 | 138 | ActionMovePaneToTab Action = "move_pane_to_tab" | |
| 139 | 139 | ActionMovePaneToNextTab Action = "move_pane_to_next_tab" | |
| 140 | + | ActionEjectPaneToWindow Action = "eject_pane_to_window" | |
| 140 | 141 | ||
| 141 | 142 | ActionConsumeOrExpelLeft Action = "consume_or_expel_left" | |
| 142 | 143 | ActionConsumeOrExpelRight Action = "consume_or_expel_right" | |
| @@ -601,6 +602,8 @@ var configActionToAction = map[string]Action{ | |||
| 601 | 602 | "move-pane-to-tab": ActionMovePaneToTab, | |
| 602 | 603 | "move_pane_to_next_tab": ActionMovePaneToNextTab, | |
| 603 | 604 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | |
| 605 | + | "eject_pane_to_window": ActionEjectPaneToWindow, | |
| 606 | + | "eject-pane-to-window": ActionEjectPaneToWindow, | |
| 604 | 607 | ||
| 605 | 608 | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 606 | 609 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| @@ -824,7 +827,7 @@ func ShouldAutoExitMode(action Action) bool { | |||
| 824 | 827 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | |
| 825 | 828 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | |
| 826 | 829 | ActionClosePane, ActionStackPane, | |
| 827 | - | ActionMovePaneToTab, ActionMovePaneToNextTab, | |
| 830 | + | ActionMovePaneToTab, ActionMovePaneToNextTab, ActionEjectPaneToWindow, | |
| 828 | 831 | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown, | |
| 829 | 832 | ActionOpenSessionManager: | |
| 830 | 833 | 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 true | 833 | return true | ||
M internal/ui/input/shortcuts_test.go +12 -0
| @@ -351,6 +351,7 @@ func TestShouldAutoExitMode(t *testing.T) { | |||
| 351 | 351 | ActionStackPane, | |
| 352 | 352 | ActionMovePaneToTab, | |
| 353 | 353 | ActionMovePaneToNextTab, | |
| 354 | + | ActionEjectPaneToWindow, | |
| 354 | 355 | } | |
| 355 | 356 | ||
| 356 | 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 | 391 | func TestMapConfigAction_ToggleFloatingPane(t *testing.T) { | |
| 380 | 392 | action := mapConfigAction("toggle_floating_pane") | |
| 381 | 393 | 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 | + | } | |||