feat(panes): implement move pane to tab use case and configuration
- commit
- 94df97b0577ed5b4630c08f9542e716fdaf317c2
- author
- brice · · 2025-12-27 15:49 UTC
- parent
- 4c2047b
- signature
- unsigned
7 files · +649 -23
A internal/application/usecase/move_pane_to_tab.go +382 -0
| @@ -0,0 +1,382 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // MovePaneToTabUseCase moves a pane from one tab's workspace to another. | |
| 10 | + | // | |
| 11 | + | // It is pure domain manipulation: it depends only on entities and an ID generator. | |
| 12 | + | type MovePaneToTabUseCase struct { | |
| 13 | + | idGenerator IDGenerator | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase { | |
| 17 | + | return &MovePaneToTabUseCase{idGenerator: idGenerator} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type MovePaneToTabInput struct { | |
| 21 | + | TabList *entity.TabList | |
| 22 | + | SourceTabID entity.TabID | |
| 23 | + | SourcePaneID entity.PaneID | |
| 24 | + | TargetTabID entity.TabID // empty means create new tab | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type MovePaneToTabOutput struct { | |
| 28 | + | TargetTab *entity.Tab | |
| 29 | + | MovedPaneNode *entity.PaneNode | |
| 30 | + | SourceTabClosed bool | |
| 31 | + | NewTabCreated bool | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (uc *MovePaneToTabUseCase) Execute(input MovePaneToTabInput) (*MovePaneToTabOutput, error) { | |
| 35 | + | if err := validateMovePaneToTabInput(uc, input); err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | sourceTab, err := findSourceTab(input.TabList, input.SourceTabID) | |
| 40 | + | if err != nil { | |
| 41 | + | return nil, err | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID) | |
| 45 | + | if err != nil { | |
| 46 | + | return nil, err | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | if detachErr := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); detachErr != nil { | |
| 50 | + | return nil, detachErr | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | sourceTabClosed := closeSourceTabIfEmpty(input.TabList, sourceTab) | |
| 54 | + | ||
| 55 | + | targetTab, newTabCreated, err := uc.resolveTargetTab(input.TabList, input.TargetTabID, movedPane) | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | if targetTab == nil || targetTab.Workspace == nil { | |
| 60 | + | return nil, fmt.Errorf("target tab/workspace is nil") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | if newTabCreated { | |
| 64 | + | return &MovePaneToTabOutput{ | |
| 65 | + | TargetTab: targetTab, | |
| 66 | + | MovedPaneNode: targetTab.Workspace.Root, | |
| 67 | + | SourceTabClosed: sourceTabClosed, | |
| 68 | + | NewTabCreated: true, | |
| 69 | + | }, nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | movedNode, err := uc.insertIntoTargetWorkspace(targetTab.Workspace, movedPane) | |
| 73 | + | if err != nil { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | return &MovePaneToTabOutput{ | |
| 78 | + | TargetTab: targetTab, | |
| 79 | + | MovedPaneNode: movedNode, | |
| 80 | + | SourceTabClosed: sourceTabClosed, | |
| 81 | + | NewTabCreated: false, | |
| 82 | + | }, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func validateMovePaneToTabInput(uc *MovePaneToTabUseCase, input MovePaneToTabInput) error { | |
| 86 | + | if uc == nil { | |
| 87 | + | return fmt.Errorf("move pane to tab use case is nil") | |
| 88 | + | } | |
| 89 | + | if input.TabList == nil { | |
| 90 | + | return fmt.Errorf("tab list is required") | |
| 91 | + | } | |
| 92 | + | if input.SourceTabID == "" { | |
| 93 | + | return fmt.Errorf("source tab id is required") | |
| 94 | + | } | |
| 95 | + | if input.SourcePaneID == "" { | |
| 96 | + | return fmt.Errorf("source pane id is required") | |
| 97 | + | } | |
| 98 | + | if input.TargetTabID == input.SourceTabID { | |
| 99 | + | return fmt.Errorf("cannot move pane to same tab") | |
| 100 | + | } | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func findSourceTab(tl *entity.TabList, id entity.TabID) (*entity.Tab, error) { | |
| 105 | + | sourceTab := tl.Find(id) | |
| 106 | + | if sourceTab == nil { | |
| 107 | + | return nil, fmt.Errorf("source tab not found: %s", id) | |
| 108 | + | } | |
| 109 | + | if sourceTab.Workspace == nil { | |
| 110 | + | return nil, fmt.Errorf("source workspace is nil") | |
| 111 | + | } | |
| 112 | + | return sourceTab, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func findSourcePane(ws *entity.Workspace, paneID entity.PaneID) (*entity.Pane, *entity.PaneNode, error) { | |
| 116 | + | if ws == nil { | |
| 117 | + | return nil, nil, fmt.Errorf("workspace is required") | |
| 118 | + | } | |
| 119 | + | sourceNode := ws.FindPane(paneID) | |
| 120 | + | if sourceNode == nil || sourceNode.Pane == nil { | |
| 121 | + | return nil, nil, fmt.Errorf("source pane not found: %s", paneID) | |
| 122 | + | } | |
| 123 | + | return sourceNode.Pane, sourceNode, nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func closeSourceTabIfEmpty(tl *entity.TabList, sourceTab *entity.Tab) bool { | |
| 127 | + | if tl == nil || sourceTab == nil || sourceTab.Workspace == nil { | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | if sourceTab.Workspace.PaneCount() != 0 { | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | return tl.Remove(sourceTab.ID) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (uc *MovePaneToTabUseCase) resolveTargetTab( | |
| 137 | + | tl *entity.TabList, | |
| 138 | + | targetID entity.TabID, | |
| 139 | + | movedPane *entity.Pane, | |
| 140 | + | ) (*entity.Tab, bool, error) { | |
| 141 | + | if tl == nil { | |
| 142 | + | return nil, false, fmt.Errorf("tab list is required") | |
| 143 | + | } | |
| 144 | + | if movedPane == nil { | |
| 145 | + | return nil, false, fmt.Errorf("moved pane is required") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | if targetID != "" { | |
| 149 | + | if targetTab := tl.Find(targetID); targetTab != nil { | |
| 150 | + | return targetTab, false, nil | |
| 151 | + | } | |
| 152 | + | // Treat missing as "create new". | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | if uc.idGenerator == nil { | |
| 156 | + | return nil, false, fmt.Errorf("id generator is required to create new tab") | |
| 157 | + | } | |
| 158 | + | tabID := entity.TabID(uc.idGenerator()) | |
| 159 | + | wsID := entity.WorkspaceID(uc.idGenerator()) | |
| 160 | + | targetTab := entity.NewTab(tabID, wsID, movedPane) | |
| 161 | + | tl.Add(targetTab) | |
| 162 | + | return targetTab, true, nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (uc *MovePaneToTabUseCase) insertIntoTargetWorkspace(ws *entity.Workspace, movedPane *entity.Pane) (*entity.PaneNode, error) { | |
| 166 | + | if ws == nil { | |
| 167 | + | return nil, fmt.Errorf("workspace is required") | |
| 168 | + | } | |
| 169 | + | if movedPane == nil { | |
| 170 | + | return nil, fmt.Errorf("moved pane is required") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | movedNode := &entity.PaneNode{ID: string(movedPane.ID), Pane: movedPane} | |
| 174 | + | ||
| 175 | + | if ws.Root == nil { | |
| 176 | + | ws.Root = movedNode | |
| 177 | + | ws.ActivePaneID = movedPane.ID | |
| 178 | + | return movedNode, nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | targetActive := ws.ActivePane() | |
| 182 | + | if targetActive == nil || targetActive.Pane == nil { | |
| 183 | + | return nil, fmt.Errorf("target tab has no active pane") | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | if err := insertPaneRightOfActive(ws, targetActive, movedNode, uc.idGenerator); err != nil { | |
| 187 | + | return nil, err | |
| 188 | + | } | |
| 189 | + | ws.ActivePaneID = movedPane.ID | |
| 190 | + | return movedNode, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func detachPaneFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |
| 194 | + | if ws == nil { | |
| 195 | + | return fmt.Errorf("workspace is required") | |
| 196 | + | } | |
| 197 | + | if leaf == nil || leaf.Pane == nil { | |
| 198 | + | return fmt.Errorf("pane node is required") | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | detachedFromStack, err := detachFromStackIfNeeded(ws, leaf) | |
| 202 | + | if err != nil { | |
| 203 | + | return err | |
| 204 | + | } | |
| 205 | + | if detachedFromStack { | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | if !leaf.IsLeaf() { | |
| 210 | + | return fmt.Errorf("can only move leaf panes") | |
| 211 | + | } | |
| 212 | + | return detachLeafFromWorkspace(ws, leaf) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, error) { | |
| 216 | + | if ws == nil || leaf == nil { | |
| 217 | + | return false, nil | |
| 218 | + | } | |
| 219 | + | if leaf.Parent == nil || !leaf.Parent.IsStacked { | |
| 220 | + | return false, nil | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | stackNode := leaf.Parent | |
| 224 | + | ||
| 225 | + | idx := -1 | |
| 226 | + | for i, child := range stackNode.Children { | |
| 227 | + | if child == leaf { | |
| 228 | + | idx = i | |
| 229 | + | break | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | if idx < 0 { | |
| 233 | + | return false, fmt.Errorf("pane not found in stack") | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | |
| 237 | + | if stackNode.ActiveStackIndex >= len(stackNode.Children) { | |
| 238 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 239 | + | } | |
| 240 | + | if stackNode.ActiveStackIndex < 0 { | |
| 241 | + | stackNode.ActiveStackIndex = 0 | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // If only one pane remains in stack, dissolve it into a leaf node. | |
| 245 | + | if len(stackNode.Children) == 1 { | |
| 246 | + | remaining := stackNode.Children[0] | |
| 247 | + | stackNode.Pane = remaining.Pane | |
| 248 | + | stackNode.Children = nil | |
| 249 | + | stackNode.IsStacked = false | |
| 250 | + | stackNode.ActiveStackIndex = 0 | |
| 251 | + | if stackNode.Pane != nil { | |
| 252 | + | ws.ActivePaneID = stackNode.Pane.ID | |
| 253 | + | } else { | |
| 254 | + | ws.ActivePaneID = "" | |
| 255 | + | } | |
| 256 | + | return true, nil | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Otherwise, set workspace active pane to the stack's current active. | |
| 260 | + | if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil { | |
| 261 | + | ws.ActivePaneID = stackNode.ActivePane().Pane.ID | |
| 262 | + | } | |
| 263 | + | return true, nil | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |
| 267 | + | parent := leaf.Parent | |
| 268 | + | if parent == nil { | |
| 269 | + | ws.Root = nil | |
| 270 | + | ws.ActivePaneID = "" | |
| 271 | + | return nil | |
| 272 | + | } | |
| 273 | + | if !parent.IsSplit() { | |
| 274 | + | return fmt.Errorf("pane parent is not a split") | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | sibling := findSibling(parent, leaf) | |
| 278 | + | if sibling == nil { | |
| 279 | + | return fmt.Errorf("no sibling found") | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | promoteSibling(ws, parent, sibling) | |
| 283 | + | ws.ActivePaneID = findFirstLeafPaneID(sibling) | |
| 284 | + | return nil | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func findSibling(parent, leaf *entity.PaneNode) *entity.PaneNode { | |
| 288 | + | if parent == nil { | |
| 289 | + | return nil | |
| 290 | + | } | |
| 291 | + | for _, child := range parent.Children { | |
| 292 | + | if child != leaf { | |
| 293 | + | return child | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func promoteSibling(ws *entity.Workspace, parent, sibling *entity.PaneNode) { | |
| 300 | + | if ws == nil || sibling == nil { | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | grandparent := parent.Parent | |
| 305 | + | if grandparent == nil { | |
| 306 | + | ws.Root = sibling | |
| 307 | + | sibling.Parent = nil | |
| 308 | + | return | |
| 309 | + | } | |
| 310 | + | for i, child := range grandparent.Children { | |
| 311 | + | if child == parent { | |
| 312 | + | grandparent.Children[i] = sibling | |
| 313 | + | break | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | sibling.Parent = grandparent | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func findFirstLeafPaneID(node *entity.PaneNode) entity.PaneID { | |
| 320 | + | if node == nil { | |
| 321 | + | return "" | |
| 322 | + | } | |
| 323 | + | var active entity.PaneID | |
| 324 | + | node.Walk(func(n *entity.PaneNode) bool { | |
| 325 | + | if n.IsLeaf() && n.Pane != nil { | |
| 326 | + | active = n.Pane.ID | |
| 327 | + | return false | |
| 328 | + | } | |
| 329 | + | return true | |
| 330 | + | }) | |
| 331 | + | return active | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func insertPaneRightOfActive(ws *entity.Workspace, activeNode, newLeaf *entity.PaneNode, idGen IDGenerator) error { | |
| 335 | + | if ws == nil { | |
| 336 | + | return fmt.Errorf("workspace is required") | |
| 337 | + | } | |
| 338 | + | if activeNode == nil { | |
| 339 | + | return fmt.Errorf("active pane is required") | |
| 340 | + | } | |
| 341 | + | if newLeaf == nil || newLeaf.Pane == nil { | |
| 342 | + | return fmt.Errorf("new pane node is required") | |
| 343 | + | } | |
| 344 | + | if idGen == nil { | |
| 345 | + | return fmt.Errorf("id generator is required") | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | targetNode := activeNode | |
| 349 | + | // Split around stacks when inserting left/right. | |
| 350 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 351 | + | targetNode = targetNode.Parent | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | parentID := idGen() | |
| 355 | + | splitParent := &entity.PaneNode{ | |
| 356 | + | ID: parentID, | |
| 357 | + | SplitDir: entity.SplitHorizontal, | |
| 358 | + | SplitRatio: 0.5, | |
| 359 | + | Children: make([]*entity.PaneNode, 2), | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | splitParent.Children[0] = targetNode | |
| 363 | + | splitParent.Children[1] = newLeaf | |
| 364 | + | ||
| 365 | + | newLeaf.Parent = splitParent | |
| 366 | + | oldParent := targetNode.Parent | |
| 367 | + | targetNode.Parent = splitParent | |
| 368 | + | ||
| 369 | + | if oldParent == nil { | |
| 370 | + | ws.Root = splitParent | |
| 371 | + | } else { | |
| 372 | + | for i, child := range oldParent.Children { | |
| 373 | + | if child == targetNode { | |
| 374 | + | oldParent.Children[i] = splitParent | |
| 375 | + | break | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | splitParent.Parent = oldParent | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | return nil | |
| 382 | + | } | |
| @@ -0,0 +1,382 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // MovePaneToTabUseCase moves a pane from one tab's workspace to another. | |||
| 10 | + | // | |||
| 11 | + | // It is pure domain manipulation: it depends only on entities and an ID generator. | |||
| 12 | + | type MovePaneToTabUseCase struct { | |||
| 13 | + | idGenerator IDGenerator | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase { | |||
| 17 | + | return &MovePaneToTabUseCase{idGenerator: idGenerator} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type MovePaneToTabInput struct { | |||
| 21 | + | TabList *entity.TabList | |||
| 22 | + | SourceTabID entity.TabID | |||
| 23 | + | SourcePaneID entity.PaneID | |||
| 24 | + | TargetTabID entity.TabID // empty means create new tab | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type MovePaneToTabOutput struct { | |||
| 28 | + | TargetTab *entity.Tab | |||
| 29 | + | MovedPaneNode *entity.PaneNode | |||
| 30 | + | SourceTabClosed bool | |||
| 31 | + | NewTabCreated bool | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (uc *MovePaneToTabUseCase) Execute(input MovePaneToTabInput) (*MovePaneToTabOutput, error) { | |||
| 35 | + | if err := validateMovePaneToTabInput(uc, input); err != nil { | |||
| 36 | + | return nil, err | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | sourceTab, err := findSourceTab(input.TabList, input.SourceTabID) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return nil, err | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID) | |||
| 45 | + | if err != nil { | |||
| 46 | + | return nil, err | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | if detachErr := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); detachErr != nil { | |||
| 50 | + | return nil, detachErr | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | sourceTabClosed := closeSourceTabIfEmpty(input.TabList, sourceTab) | |||
| 54 | + | ||||
| 55 | + | targetTab, newTabCreated, err := uc.resolveTargetTab(input.TabList, input.TargetTabID, movedPane) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | if targetTab == nil || targetTab.Workspace == nil { | |||
| 60 | + | return nil, fmt.Errorf("target tab/workspace is nil") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | if newTabCreated { | |||
| 64 | + | return &MovePaneToTabOutput{ | |||
| 65 | + | TargetTab: targetTab, | |||
| 66 | + | MovedPaneNode: targetTab.Workspace.Root, | |||
| 67 | + | SourceTabClosed: sourceTabClosed, | |||
| 68 | + | NewTabCreated: true, | |||
| 69 | + | }, nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | movedNode, err := uc.insertIntoTargetWorkspace(targetTab.Workspace, movedPane) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | return &MovePaneToTabOutput{ | |||
| 78 | + | TargetTab: targetTab, | |||
| 79 | + | MovedPaneNode: movedNode, | |||
| 80 | + | SourceTabClosed: sourceTabClosed, | |||
| 81 | + | NewTabCreated: false, | |||
| 82 | + | }, nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func validateMovePaneToTabInput(uc *MovePaneToTabUseCase, input MovePaneToTabInput) error { | |||
| 86 | + | if uc == nil { | |||
| 87 | + | return fmt.Errorf("move pane to tab use case is nil") | |||
| 88 | + | } | |||
| 89 | + | if input.TabList == nil { | |||
| 90 | + | return fmt.Errorf("tab list is required") | |||
| 91 | + | } | |||
| 92 | + | if input.SourceTabID == "" { | |||
| 93 | + | return fmt.Errorf("source tab id is required") | |||
| 94 | + | } | |||
| 95 | + | if input.SourcePaneID == "" { | |||
| 96 | + | return fmt.Errorf("source pane id is required") | |||
| 97 | + | } | |||
| 98 | + | if input.TargetTabID == input.SourceTabID { | |||
| 99 | + | return fmt.Errorf("cannot move pane to same tab") | |||
| 100 | + | } | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func findSourceTab(tl *entity.TabList, id entity.TabID) (*entity.Tab, error) { | |||
| 105 | + | sourceTab := tl.Find(id) | |||
| 106 | + | if sourceTab == nil { | |||
| 107 | + | return nil, fmt.Errorf("source tab not found: %s", id) | |||
| 108 | + | } | |||
| 109 | + | if sourceTab.Workspace == nil { | |||
| 110 | + | return nil, fmt.Errorf("source workspace is nil") | |||
| 111 | + | } | |||
| 112 | + | return sourceTab, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func findSourcePane(ws *entity.Workspace, paneID entity.PaneID) (*entity.Pane, *entity.PaneNode, error) { | |||
| 116 | + | if ws == nil { | |||
| 117 | + | return nil, nil, fmt.Errorf("workspace is required") | |||
| 118 | + | } | |||
| 119 | + | sourceNode := ws.FindPane(paneID) | |||
| 120 | + | if sourceNode == nil || sourceNode.Pane == nil { | |||
| 121 | + | return nil, nil, fmt.Errorf("source pane not found: %s", paneID) | |||
| 122 | + | } | |||
| 123 | + | return sourceNode.Pane, sourceNode, nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func closeSourceTabIfEmpty(tl *entity.TabList, sourceTab *entity.Tab) bool { | |||
| 127 | + | if tl == nil || sourceTab == nil || sourceTab.Workspace == nil { | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | if sourceTab.Workspace.PaneCount() != 0 { | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | return tl.Remove(sourceTab.ID) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (uc *MovePaneToTabUseCase) resolveTargetTab( | |||
| 137 | + | tl *entity.TabList, | |||
| 138 | + | targetID entity.TabID, | |||
| 139 | + | movedPane *entity.Pane, | |||
| 140 | + | ) (*entity.Tab, bool, error) { | |||
| 141 | + | if tl == nil { | |||
| 142 | + | return nil, false, fmt.Errorf("tab list is required") | |||
| 143 | + | } | |||
| 144 | + | if movedPane == nil { | |||
| 145 | + | return nil, false, fmt.Errorf("moved pane is required") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | if targetID != "" { | |||
| 149 | + | if targetTab := tl.Find(targetID); targetTab != nil { | |||
| 150 | + | return targetTab, false, nil | |||
| 151 | + | } | |||
| 152 | + | // Treat missing as "create new". | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | if uc.idGenerator == nil { | |||
| 156 | + | return nil, false, fmt.Errorf("id generator is required to create new tab") | |||
| 157 | + | } | |||
| 158 | + | tabID := entity.TabID(uc.idGenerator()) | |||
| 159 | + | wsID := entity.WorkspaceID(uc.idGenerator()) | |||
| 160 | + | targetTab := entity.NewTab(tabID, wsID, movedPane) | |||
| 161 | + | tl.Add(targetTab) | |||
| 162 | + | return targetTab, true, nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (uc *MovePaneToTabUseCase) insertIntoTargetWorkspace(ws *entity.Workspace, movedPane *entity.Pane) (*entity.PaneNode, error) { | |||
| 166 | + | if ws == nil { | |||
| 167 | + | return nil, fmt.Errorf("workspace is required") | |||
| 168 | + | } | |||
| 169 | + | if movedPane == nil { | |||
| 170 | + | return nil, fmt.Errorf("moved pane is required") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | movedNode := &entity.PaneNode{ID: string(movedPane.ID), Pane: movedPane} | |||
| 174 | + | ||||
| 175 | + | if ws.Root == nil { | |||
| 176 | + | ws.Root = movedNode | |||
| 177 | + | ws.ActivePaneID = movedPane.ID | |||
| 178 | + | return movedNode, nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | targetActive := ws.ActivePane() | |||
| 182 | + | if targetActive == nil || targetActive.Pane == nil { | |||
| 183 | + | return nil, fmt.Errorf("target tab has no active pane") | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | if err := insertPaneRightOfActive(ws, targetActive, movedNode, uc.idGenerator); err != nil { | |||
| 187 | + | return nil, err | |||
| 188 | + | } | |||
| 189 | + | ws.ActivePaneID = movedPane.ID | |||
| 190 | + | return movedNode, nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func detachPaneFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |||
| 194 | + | if ws == nil { | |||
| 195 | + | return fmt.Errorf("workspace is required") | |||
| 196 | + | } | |||
| 197 | + | if leaf == nil || leaf.Pane == nil { | |||
| 198 | + | return fmt.Errorf("pane node is required") | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | detachedFromStack, err := detachFromStackIfNeeded(ws, leaf) | |||
| 202 | + | if err != nil { | |||
| 203 | + | return err | |||
| 204 | + | } | |||
| 205 | + | if detachedFromStack { | |||
| 206 | + | return nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | if !leaf.IsLeaf() { | |||
| 210 | + | return fmt.Errorf("can only move leaf panes") | |||
| 211 | + | } | |||
| 212 | + | return detachLeafFromWorkspace(ws, leaf) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, error) { | |||
| 216 | + | if ws == nil || leaf == nil { | |||
| 217 | + | return false, nil | |||
| 218 | + | } | |||
| 219 | + | if leaf.Parent == nil || !leaf.Parent.IsStacked { | |||
| 220 | + | return false, nil | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | stackNode := leaf.Parent | |||
| 224 | + | ||||
| 225 | + | idx := -1 | |||
| 226 | + | for i, child := range stackNode.Children { | |||
| 227 | + | if child == leaf { | |||
| 228 | + | idx = i | |||
| 229 | + | break | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | if idx < 0 { | |||
| 233 | + | return false, fmt.Errorf("pane not found in stack") | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | |||
| 237 | + | if stackNode.ActiveStackIndex >= len(stackNode.Children) { | |||
| 238 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |||
| 239 | + | } | |||
| 240 | + | if stackNode.ActiveStackIndex < 0 { | |||
| 241 | + | stackNode.ActiveStackIndex = 0 | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // If only one pane remains in stack, dissolve it into a leaf node. | |||
| 245 | + | if len(stackNode.Children) == 1 { | |||
| 246 | + | remaining := stackNode.Children[0] | |||
| 247 | + | stackNode.Pane = remaining.Pane | |||
| 248 | + | stackNode.Children = nil | |||
| 249 | + | stackNode.IsStacked = false | |||
| 250 | + | stackNode.ActiveStackIndex = 0 | |||
| 251 | + | if stackNode.Pane != nil { | |||
| 252 | + | ws.ActivePaneID = stackNode.Pane.ID | |||
| 253 | + | } else { | |||
| 254 | + | ws.ActivePaneID = "" | |||
| 255 | + | } | |||
| 256 | + | return true, nil | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // Otherwise, set workspace active pane to the stack's current active. | |||
| 260 | + | if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil { | |||
| 261 | + | ws.ActivePaneID = stackNode.ActivePane().Pane.ID | |||
| 262 | + | } | |||
| 263 | + | return true, nil | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |||
| 267 | + | parent := leaf.Parent | |||
| 268 | + | if parent == nil { | |||
| 269 | + | ws.Root = nil | |||
| 270 | + | ws.ActivePaneID = "" | |||
| 271 | + | return nil | |||
| 272 | + | } | |||
| 273 | + | if !parent.IsSplit() { | |||
| 274 | + | return fmt.Errorf("pane parent is not a split") | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | sibling := findSibling(parent, leaf) | |||
| 278 | + | if sibling == nil { | |||
| 279 | + | return fmt.Errorf("no sibling found") | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | promoteSibling(ws, parent, sibling) | |||
| 283 | + | ws.ActivePaneID = findFirstLeafPaneID(sibling) | |||
| 284 | + | return nil | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func findSibling(parent, leaf *entity.PaneNode) *entity.PaneNode { | |||
| 288 | + | if parent == nil { | |||
| 289 | + | return nil | |||
| 290 | + | } | |||
| 291 | + | for _, child := range parent.Children { | |||
| 292 | + | if child != leaf { | |||
| 293 | + | return child | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | return nil | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func promoteSibling(ws *entity.Workspace, parent, sibling *entity.PaneNode) { | |||
| 300 | + | if ws == nil || sibling == nil { | |||
| 301 | + | return | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | grandparent := parent.Parent | |||
| 305 | + | if grandparent == nil { | |||
| 306 | + | ws.Root = sibling | |||
| 307 | + | sibling.Parent = nil | |||
| 308 | + | return | |||
| 309 | + | } | |||
| 310 | + | for i, child := range grandparent.Children { | |||
| 311 | + | if child == parent { | |||
| 312 | + | grandparent.Children[i] = sibling | |||
| 313 | + | break | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | sibling.Parent = grandparent | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func findFirstLeafPaneID(node *entity.PaneNode) entity.PaneID { | |||
| 320 | + | if node == nil { | |||
| 321 | + | return "" | |||
| 322 | + | } | |||
| 323 | + | var active entity.PaneID | |||
| 324 | + | node.Walk(func(n *entity.PaneNode) bool { | |||
| 325 | + | if n.IsLeaf() && n.Pane != nil { | |||
| 326 | + | active = n.Pane.ID | |||
| 327 | + | return false | |||
| 328 | + | } | |||
| 329 | + | return true | |||
| 330 | + | }) | |||
| 331 | + | return active | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | func insertPaneRightOfActive(ws *entity.Workspace, activeNode, newLeaf *entity.PaneNode, idGen IDGenerator) error { | |||
| 335 | + | if ws == nil { | |||
| 336 | + | return fmt.Errorf("workspace is required") | |||
| 337 | + | } | |||
| 338 | + | if activeNode == nil { | |||
| 339 | + | return fmt.Errorf("active pane is required") | |||
| 340 | + | } | |||
| 341 | + | if newLeaf == nil || newLeaf.Pane == nil { | |||
| 342 | + | return fmt.Errorf("new pane node is required") | |||
| 343 | + | } | |||
| 344 | + | if idGen == nil { | |||
| 345 | + | return fmt.Errorf("id generator is required") | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | targetNode := activeNode | |||
| 349 | + | // Split around stacks when inserting left/right. | |||
| 350 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |||
| 351 | + | targetNode = targetNode.Parent | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | parentID := idGen() | |||
| 355 | + | splitParent := &entity.PaneNode{ | |||
| 356 | + | ID: parentID, | |||
| 357 | + | SplitDir: entity.SplitHorizontal, | |||
| 358 | + | SplitRatio: 0.5, | |||
| 359 | + | Children: make([]*entity.PaneNode, 2), | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | splitParent.Children[0] = targetNode | |||
| 363 | + | splitParent.Children[1] = newLeaf | |||
| 364 | + | ||||
| 365 | + | newLeaf.Parent = splitParent | |||
| 366 | + | oldParent := targetNode.Parent | |||
| 367 | + | targetNode.Parent = splitParent | |||
| 368 | + | ||||
| 369 | + | if oldParent == nil { | |||
| 370 | + | ws.Root = splitParent | |||
| 371 | + | } else { | |||
| 372 | + | for i, child := range oldParent.Children { | |||
| 373 | + | if child == targetNode { | |||
| 374 | + | oldParent.Children[i] = splitParent | |||
| 375 | + | break | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | splitParent.Parent = oldParent | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | return nil | |||
| 382 | + | } | |||
A internal/application/usecase/move_pane_to_tab_test.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestMovePaneToTab_MoveToExistingTab(t *testing.T) { | |
| 12 | + | id := newTestIDGen() | |
| 13 | + | uc := NewMovePaneToTabUseCase(id) | |
| 14 | + | ||
| 15 | + | tabs := entity.NewTabList() | |
| 16 | + | ||
| 17 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 18 | + | tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |
| 19 | + | tabs.Add(tabA) | |
| 20 | + | ||
| 21 | + | paneB := entity.NewPane(entity.PaneID("pB")) | |
| 22 | + | tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB) | |
| 23 | + | tabs.Add(tabB) | |
| 24 | + | // Ensure tabB active pane is paneB. | |
| 25 | + | tabB.Workspace.ActivePaneID = paneB.ID | |
| 26 | + | ||
| 27 | + | out, err := uc.Execute(MovePaneToTabInput{ | |
| 28 | + | TabList: tabs, | |
| 29 | + | SourceTabID: tabA.ID, | |
| 30 | + | SourcePaneID: paneA.ID, | |
| 31 | + | TargetTabID: tabB.ID, | |
| 32 | + | }) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.True(t, out.SourceTabClosed) | |
| 35 | + | require.False(t, out.NewTabCreated) | |
| 36 | + | require.Equal(t, tabB.ID, out.TargetTab.ID) | |
| 37 | + | require.NotNil(t, out.MovedPaneNode) | |
| 38 | + | ||
| 39 | + | require.Nil(t, tabs.Find(tabA.ID)) | |
| 40 | + | // Inserted as split in tabB. | |
| 41 | + | require.NotNil(t, tabB.Workspace.Root) | |
| 42 | + | require.True(t, tabB.Workspace.Root.IsSplit()) | |
| 43 | + | require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID) | |
| 44 | + | require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID) | |
| 45 | + | // Active pane becomes moved pane. | |
| 46 | + | require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) { | |
| 50 | + | id := newTestIDGen() | |
| 51 | + | uc := NewMovePaneToTabUseCase(id) | |
| 52 | + | ||
| 53 | + | tabs := entity.NewTabList() | |
| 54 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 55 | + | tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |
| 56 | + | tabs.Add(tabA) | |
| 57 | + | ||
| 58 | + | out, err := uc.Execute(MovePaneToTabInput{ | |
| 59 | + | TabList: tabs, | |
| 60 | + | SourceTabID: tabA.ID, | |
| 61 | + | SourcePaneID: paneA.ID, | |
| 62 | + | TargetTabID: "", | |
| 63 | + | }) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | require.True(t, out.NewTabCreated) | |
| 66 | + | require.True(t, out.SourceTabClosed) | |
| 67 | + | require.Equal(t, 1, tabs.Count()) | |
| 68 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID) | |
| 69 | + | require.NotNil(t, out.TargetTab.Workspace.Root) | |
| 70 | + | require.True(t, out.TargetTab.Workspace.Root.IsLeaf()) | |
| 71 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) { | |
| 75 | + | uc := NewMovePaneToTabUseCase(newTestIDGen()) | |
| 76 | + | abs := entity.NewTabList() | |
| 77 | + | pane := entity.NewPane(entity.PaneID("pA")) | |
| 78 | + | tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane) | |
| 79 | + | abs.Add(tab) | |
| 80 | + | ||
| 81 | + | _, err := uc.Execute(MovePaneToTabInput{ | |
| 82 | + | TabList: abs, | |
| 83 | + | SourceTabID: tab.ID, | |
| 84 | + | SourcePaneID: pane.ID, | |
| 85 | + | TargetTabID: tab.ID, | |
| 86 | + | }) | |
| 87 | + | require.Error(t, err) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) { | |
| 91 | + | uc := NewMovePaneToTabUseCase(newTestIDGen()) | |
| 92 | + | tabs := entity.NewTabList() | |
| 93 | + | pane := entity.NewPane(entity.PaneID("pA")) | |
| 94 | + | tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane) | |
| 95 | + | tabs.Add(tab) | |
| 96 | + | ||
| 97 | + | _, err := uc.Execute(MovePaneToTabInput{ | |
| 98 | + | TabList: tabs, | |
| 99 | + | SourceTabID: tab.ID, | |
| 100 | + | SourcePaneID: entity.PaneID("missing"), | |
| 101 | + | TargetTabID: entity.TabID("tB"), | |
| 102 | + | }) | |
| 103 | + | require.Error(t, err) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) { | |
| 107 | + | id := newTestIDGen() | |
| 108 | + | uc := NewMovePaneToTabUseCase(id) | |
| 109 | + | ||
| 110 | + | tabs := entity.NewTabList() | |
| 111 | + | ||
| 112 | + | // Source workspace: split with two panes; move one, leaving one. | |
| 113 | + | paneLeft := entity.NewPane(entity.PaneID("pL")) | |
| 114 | + | paneRight := entity.NewPane(entity.PaneID("pR")) | |
| 115 | + | source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft) | |
| 116 | + | // Build a split root manually. | |
| 117 | + | source.Workspace.Root = &entity.PaneNode{ | |
| 118 | + | ID: "root", | |
| 119 | + | SplitDir: entity.SplitHorizontal, | |
| 120 | + | SplitRatio: 0.5, | |
| 121 | + | Children: []*entity.PaneNode{ | |
| 122 | + | {ID: string(paneLeft.ID), Pane: paneLeft}, | |
| 123 | + | {ID: string(paneRight.ID), Pane: paneRight}, | |
| 124 | + | }, | |
| 125 | + | } | |
| 126 | + | source.Workspace.Root.Children[0].Parent = source.Workspace.Root | |
| 127 | + | source.Workspace.Root.Children[1].Parent = source.Workspace.Root | |
| 128 | + | source.Workspace.ActivePaneID = paneRight.ID | |
| 129 | + | tabs.Add(source) | |
| 130 | + | ||
| 131 | + | targetPane := entity.NewPane(entity.PaneID("pT")) | |
| 132 | + | target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane) | |
| 133 | + | tabs.Add(target) | |
| 134 | + | ||
| 135 | + | out, err := uc.Execute(MovePaneToTabInput{ | |
| 136 | + | TabList: tabs, | |
| 137 | + | SourceTabID: source.ID, | |
| 138 | + | SourcePaneID: paneRight.ID, | |
| 139 | + | TargetTabID: target.ID, | |
| 140 | + | }) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | require.False(t, out.SourceTabClosed) | |
| 143 | + | // Source should now be single leaf. | |
| 144 | + | require.Equal(t, 1, source.Workspace.PaneCount()) | |
| 145 | + | require.NotNil(t, source.Workspace.Root) | |
| 146 | + | require.True(t, source.Workspace.Root.IsLeaf()) | |
| 147 | + | require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) { | |
| 151 | + | id := newTestIDGen() | |
| 152 | + | uc := NewMovePaneToTabUseCase(id) | |
| 153 | + | ||
| 154 | + | tabs := entity.NewTabList() | |
| 155 | + | ||
| 156 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 157 | + | source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |
| 158 | + | tabs.Add(source) | |
| 159 | + | ||
| 160 | + | // Target workspace: stacked container with two panes; active is second. | |
| 161 | + | pane1 := entity.NewPane(entity.PaneID("p1")) | |
| 162 | + | pane2 := entity.NewPane(entity.PaneID("p2")) | |
| 163 | + | target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1) | |
| 164 | + | stack := &entity.PaneNode{ | |
| 165 | + | ID: "stack", | |
| 166 | + | IsStacked: true, | |
| 167 | + | ActiveStackIndex: 1, | |
| 168 | + | Children: []*entity.PaneNode{ | |
| 169 | + | {ID: string(pane1.ID), Pane: pane1}, | |
| 170 | + | {ID: string(pane2.ID), Pane: pane2}, | |
| 171 | + | }, | |
| 172 | + | } | |
| 173 | + | stack.Children[0].Parent = stack | |
| 174 | + | stack.Children[1].Parent = stack | |
| 175 | + | target.Workspace.Root = stack | |
| 176 | + | target.Workspace.ActivePaneID = pane2.ID | |
| 177 | + | tabs.Add(target) | |
| 178 | + | ||
| 179 | + | out, err := uc.Execute(MovePaneToTabInput{ | |
| 180 | + | TabList: tabs, | |
| 181 | + | SourceTabID: source.ID, | |
| 182 | + | SourcePaneID: paneA.ID, | |
| 183 | + | TargetTabID: target.ID, | |
| 184 | + | }) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | require.NotNil(t, out.TargetTab.Workspace.Root) | |
| 187 | + | require.True(t, out.TargetTab.Workspace.Root.IsSplit()) | |
| 188 | + | // Left child should be the stack container. | |
| 189 | + | require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked) | |
| 190 | + | // Right child is moved pane. | |
| 191 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func newTestIDGen() func() string { | |
| 195 | + | counter := 0 | |
| 196 | + | return func() string { | |
| 197 | + | counter++ | |
| 198 | + | return fmt.Sprintf("id%d", counter) | |
| 199 | + | } | |
| 200 | + | } | |
| @@ -0,0 +1,200 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestMovePaneToTab_MoveToExistingTab(t *testing.T) { | |||
| 12 | + | id := newTestIDGen() | |||
| 13 | + | uc := NewMovePaneToTabUseCase(id) | |||
| 14 | + | ||||
| 15 | + | tabs := entity.NewTabList() | |||
| 16 | + | ||||
| 17 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |||
| 18 | + | tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |||
| 19 | + | tabs.Add(tabA) | |||
| 20 | + | ||||
| 21 | + | paneB := entity.NewPane(entity.PaneID("pB")) | |||
| 22 | + | tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB) | |||
| 23 | + | tabs.Add(tabB) | |||
| 24 | + | // Ensure tabB active pane is paneB. | |||
| 25 | + | tabB.Workspace.ActivePaneID = paneB.ID | |||
| 26 | + | ||||
| 27 | + | out, err := uc.Execute(MovePaneToTabInput{ | |||
| 28 | + | TabList: tabs, | |||
| 29 | + | SourceTabID: tabA.ID, | |||
| 30 | + | SourcePaneID: paneA.ID, | |||
| 31 | + | TargetTabID: tabB.ID, | |||
| 32 | + | }) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.True(t, out.SourceTabClosed) | |||
| 35 | + | require.False(t, out.NewTabCreated) | |||
| 36 | + | require.Equal(t, tabB.ID, out.TargetTab.ID) | |||
| 37 | + | require.NotNil(t, out.MovedPaneNode) | |||
| 38 | + | ||||
| 39 | + | require.Nil(t, tabs.Find(tabA.ID)) | |||
| 40 | + | // Inserted as split in tabB. | |||
| 41 | + | require.NotNil(t, tabB.Workspace.Root) | |||
| 42 | + | require.True(t, tabB.Workspace.Root.IsSplit()) | |||
| 43 | + | require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID) | |||
| 44 | + | require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID) | |||
| 45 | + | // Active pane becomes moved pane. | |||
| 46 | + | require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) { | |||
| 50 | + | id := newTestIDGen() | |||
| 51 | + | uc := NewMovePaneToTabUseCase(id) | |||
| 52 | + | ||||
| 53 | + | tabs := entity.NewTabList() | |||
| 54 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |||
| 55 | + | tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |||
| 56 | + | tabs.Add(tabA) | |||
| 57 | + | ||||
| 58 | + | out, err := uc.Execute(MovePaneToTabInput{ | |||
| 59 | + | TabList: tabs, | |||
| 60 | + | SourceTabID: tabA.ID, | |||
| 61 | + | SourcePaneID: paneA.ID, | |||
| 62 | + | TargetTabID: "", | |||
| 63 | + | }) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | require.True(t, out.NewTabCreated) | |||
| 66 | + | require.True(t, out.SourceTabClosed) | |||
| 67 | + | require.Equal(t, 1, tabs.Count()) | |||
| 68 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID) | |||
| 69 | + | require.NotNil(t, out.TargetTab.Workspace.Root) | |||
| 70 | + | require.True(t, out.TargetTab.Workspace.Root.IsLeaf()) | |||
| 71 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) { | |||
| 75 | + | uc := NewMovePaneToTabUseCase(newTestIDGen()) | |||
| 76 | + | abs := entity.NewTabList() | |||
| 77 | + | pane := entity.NewPane(entity.PaneID("pA")) | |||
| 78 | + | tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane) | |||
| 79 | + | abs.Add(tab) | |||
| 80 | + | ||||
| 81 | + | _, err := uc.Execute(MovePaneToTabInput{ | |||
| 82 | + | TabList: abs, | |||
| 83 | + | SourceTabID: tab.ID, | |||
| 84 | + | SourcePaneID: pane.ID, | |||
| 85 | + | TargetTabID: tab.ID, | |||
| 86 | + | }) | |||
| 87 | + | require.Error(t, err) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) { | |||
| 91 | + | uc := NewMovePaneToTabUseCase(newTestIDGen()) | |||
| 92 | + | tabs := entity.NewTabList() | |||
| 93 | + | pane := entity.NewPane(entity.PaneID("pA")) | |||
| 94 | + | tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane) | |||
| 95 | + | tabs.Add(tab) | |||
| 96 | + | ||||
| 97 | + | _, err := uc.Execute(MovePaneToTabInput{ | |||
| 98 | + | TabList: tabs, | |||
| 99 | + | SourceTabID: tab.ID, | |||
| 100 | + | SourcePaneID: entity.PaneID("missing"), | |||
| 101 | + | TargetTabID: entity.TabID("tB"), | |||
| 102 | + | }) | |||
| 103 | + | require.Error(t, err) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) { | |||
| 107 | + | id := newTestIDGen() | |||
| 108 | + | uc := NewMovePaneToTabUseCase(id) | |||
| 109 | + | ||||
| 110 | + | tabs := entity.NewTabList() | |||
| 111 | + | ||||
| 112 | + | // Source workspace: split with two panes; move one, leaving one. | |||
| 113 | + | paneLeft := entity.NewPane(entity.PaneID("pL")) | |||
| 114 | + | paneRight := entity.NewPane(entity.PaneID("pR")) | |||
| 115 | + | source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft) | |||
| 116 | + | // Build a split root manually. | |||
| 117 | + | source.Workspace.Root = &entity.PaneNode{ | |||
| 118 | + | ID: "root", | |||
| 119 | + | SplitDir: entity.SplitHorizontal, | |||
| 120 | + | SplitRatio: 0.5, | |||
| 121 | + | Children: []*entity.PaneNode{ | |||
| 122 | + | {ID: string(paneLeft.ID), Pane: paneLeft}, | |||
| 123 | + | {ID: string(paneRight.ID), Pane: paneRight}, | |||
| 124 | + | }, | |||
| 125 | + | } | |||
| 126 | + | source.Workspace.Root.Children[0].Parent = source.Workspace.Root | |||
| 127 | + | source.Workspace.Root.Children[1].Parent = source.Workspace.Root | |||
| 128 | + | source.Workspace.ActivePaneID = paneRight.ID | |||
| 129 | + | tabs.Add(source) | |||
| 130 | + | ||||
| 131 | + | targetPane := entity.NewPane(entity.PaneID("pT")) | |||
| 132 | + | target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane) | |||
| 133 | + | tabs.Add(target) | |||
| 134 | + | ||||
| 135 | + | out, err := uc.Execute(MovePaneToTabInput{ | |||
| 136 | + | TabList: tabs, | |||
| 137 | + | SourceTabID: source.ID, | |||
| 138 | + | SourcePaneID: paneRight.ID, | |||
| 139 | + | TargetTabID: target.ID, | |||
| 140 | + | }) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | require.False(t, out.SourceTabClosed) | |||
| 143 | + | // Source should now be single leaf. | |||
| 144 | + | require.Equal(t, 1, source.Workspace.PaneCount()) | |||
| 145 | + | require.NotNil(t, source.Workspace.Root) | |||
| 146 | + | require.True(t, source.Workspace.Root.IsLeaf()) | |||
| 147 | + | require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) { | |||
| 151 | + | id := newTestIDGen() | |||
| 152 | + | uc := NewMovePaneToTabUseCase(id) | |||
| 153 | + | ||||
| 154 | + | tabs := entity.NewTabList() | |||
| 155 | + | ||||
| 156 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |||
| 157 | + | source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |||
| 158 | + | tabs.Add(source) | |||
| 159 | + | ||||
| 160 | + | // Target workspace: stacked container with two panes; active is second. | |||
| 161 | + | pane1 := entity.NewPane(entity.PaneID("p1")) | |||
| 162 | + | pane2 := entity.NewPane(entity.PaneID("p2")) | |||
| 163 | + | target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1) | |||
| 164 | + | stack := &entity.PaneNode{ | |||
| 165 | + | ID: "stack", | |||
| 166 | + | IsStacked: true, | |||
| 167 | + | ActiveStackIndex: 1, | |||
| 168 | + | Children: []*entity.PaneNode{ | |||
| 169 | + | {ID: string(pane1.ID), Pane: pane1}, | |||
| 170 | + | {ID: string(pane2.ID), Pane: pane2}, | |||
| 171 | + | }, | |||
| 172 | + | } | |||
| 173 | + | stack.Children[0].Parent = stack | |||
| 174 | + | stack.Children[1].Parent = stack | |||
| 175 | + | target.Workspace.Root = stack | |||
| 176 | + | target.Workspace.ActivePaneID = pane2.ID | |||
| 177 | + | tabs.Add(target) | |||
| 178 | + | ||||
| 179 | + | out, err := uc.Execute(MovePaneToTabInput{ | |||
| 180 | + | TabList: tabs, | |||
| 181 | + | SourceTabID: source.ID, | |||
| 182 | + | SourcePaneID: paneA.ID, | |||
| 183 | + | TargetTabID: target.ID, | |||
| 184 | + | }) | |||
| 185 | + | require.NoError(t, err) | |||
| 186 | + | require.NotNil(t, out.TargetTab.Workspace.Root) | |||
| 187 | + | require.True(t, out.TargetTab.Workspace.Root.IsSplit()) | |||
| 188 | + | // Left child should be the stack container. | |||
| 189 | + | require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked) | |||
| 190 | + | // Right child is moved pane. | |||
| 191 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func newTestIDGen() func() string { | |||
| 195 | + | counter := 0 | |||
| 196 | + | return func() string { | |||
| 197 | + | counter++ | |||
| 198 | + | return fmt.Sprintf("id%d", counter) | |||
| 199 | + | } | |||
| 200 | + | } | |||
M internal/domain/entity/tab.go +9 -4
| @@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool { | |||
| 79 | 79 | tl.Tabs[j].Position = j | |
| 80 | 80 | } | |
| 81 | 81 | // Update active tab if needed | |
| 82 | - | if tl.ActiveTabID == id && len(tl.Tabs) > 0 { | |
| 83 | - | if i < len(tl.Tabs) { | |
| 84 | - | tl.ActiveTabID = tl.Tabs[i].ID | |
| 82 | + | if tl.ActiveTabID == id { | |
| 83 | + | if len(tl.Tabs) > 0 { | |
| 84 | + | if i < len(tl.Tabs) { | |
| 85 | + | tl.ActiveTabID = tl.Tabs[i].ID | |
| 86 | + | } else { | |
| 87 | + | tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID | |
| 88 | + | } | |
| 85 | 89 | } else { | |
| 86 | - | tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID | |
| 90 | + | tl.ActiveTabID = "" | |
| 91 | + | tl.PreviousActiveTabID = "" | |
| 87 | 92 | } | |
| 88 | 93 | } | |
| 89 | 94 | return true | |
| @@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool { | |||||
| 79 | tl.Tabs[j].Position = j | 79 | tl.Tabs[j].Position = j | ||
| 80 | } | 80 | } | ||
| 81 | // Update active tab if needed | 81 | // Update active tab if needed | ||
| 82 | - | if tl.ActiveTabID == id && len(tl.Tabs) > 0 { | 82 | + | if tl.ActiveTabID == id { |
| 83 | - | if i < len(tl.Tabs) { | 83 | + | if len(tl.Tabs) > 0 { |
| 84 | - | tl.ActiveTabID = tl.Tabs[i].ID | 84 | + | if i < len(tl.Tabs) { |
| 85 | + | tl.ActiveTabID = tl.Tabs[i].ID | |||
| 86 | + | } else { | |||
| 87 | + | tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID | |||
| 88 | + | } | |||
| 85 | } else { | 89 | } else { | ||
| 86 | - | tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID | 90 | + | tl.ActiveTabID = "" |
| 91 | + | tl.PreviousActiveTabID = "" | |||
| 87 | } | 92 | } | ||
| 88 | } | 93 | } | ||
| 89 | return true | 94 | return true | ||
M internal/infrastructure/config/defaults.go +10 -7
| @@ -155,17 +155,20 @@ func DefaultConfig() *Config { | |||
| 155 | 155 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | |
| 156 | 156 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | |
| 157 | 157 | Workspace: WorkspaceConfig{ | |
| 158 | - | NewPaneURL: defaultNewPaneURL, | |
| 158 | + | NewPaneURL: defaultNewPaneURL, | |
| 159 | + | SwitchToTabOnMove: true, | |
| 159 | 160 | PaneMode: PaneModeConfig{ | |
| 160 | 161 | ActivationShortcut: defaultPaneActivationShortcut, | |
| 161 | 162 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | |
| 162 | 163 | Actions: map[string][]string{ | |
| 163 | - | "split-right": {"arrowright", "r"}, | |
| 164 | - | "split-left": {"arrowleft", "l"}, | |
| 165 | - | "split-up": {"arrowup", "u"}, | |
| 166 | - | "split-down": {"arrowdown", "d"}, | |
| 167 | - | "stack-pane": {"s"}, | |
| 168 | - | "close-pane": {"x"}, | |
| 164 | + | "split-right": {"arrowright", "r"}, | |
| 165 | + | "split-left": {"arrowleft", "l"}, | |
| 166 | + | "split-up": {"arrowup", "u"}, | |
| 167 | + | "split-down": {"arrowdown", "d"}, | |
| 168 | + | "stack-pane": {"s"}, | |
| 169 | + | "close-pane": {"x"}, | |
| 170 | + | "move-pane-to-tab": {"m"}, | |
| 171 | + | "move-pane-to-next-tab": {"M", "shift+m"}, | |
| 169 | 172 | ||
| 170 | 173 | "consume-or-expel-left": {"bracketleft", "["}, | |
| 171 | 174 | "consume-or-expel-right": {"bracketright", "]"}, | |
| @@ -155,17 +155,20 @@ func DefaultConfig() *Config { | |||||
| 155 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | 155 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | ||
| 156 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | 156 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | ||
| 157 | Workspace: WorkspaceConfig{ | 157 | Workspace: WorkspaceConfig{ | ||
| 158 | - | NewPaneURL: defaultNewPaneURL, | 158 | + | NewPaneURL: defaultNewPaneURL, |
| 159 | + | SwitchToTabOnMove: true, | |||
| 159 | PaneMode: PaneModeConfig{ | 160 | PaneMode: PaneModeConfig{ | ||
| 160 | ActivationShortcut: defaultPaneActivationShortcut, | 161 | ActivationShortcut: defaultPaneActivationShortcut, | ||
| 161 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | 162 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | ||
| 162 | Actions: map[string][]string{ | 163 | Actions: map[string][]string{ | ||
| 163 | - | "split-right": {"arrowright", "r"}, | 164 | + | "split-right": {"arrowright", "r"}, |
| 164 | - | "split-left": {"arrowleft", "l"}, | 165 | + | "split-left": {"arrowleft", "l"}, |
| 165 | - | "split-up": {"arrowup", "u"}, | 166 | + | "split-up": {"arrowup", "u"}, |
| 166 | - | "split-down": {"arrowdown", "d"}, | 167 | + | "split-down": {"arrowdown", "d"}, |
| 167 | - | "stack-pane": {"s"}, | 168 | + | "stack-pane": {"s"}, |
| 168 | - | "close-pane": {"x"}, | 169 | + | "close-pane": {"x"}, |
| 170 | + | "move-pane-to-tab": {"m"}, | |||
| 171 | + | "move-pane-to-next-tab": {"M", "shift+m"}, | |||
| 169 | 172 | ||||
| 170 | "consume-or-expel-left": {"bracketleft", "["}, | 173 | "consume-or-expel-left": {"bracketleft", "["}, | ||
| 171 | "consume-or-expel-right": {"bracketright", "]"}, | 174 | "consume-or-expel-right": {"bracketright", "]"}, | ||
M internal/infrastructure/config/loader.go +1 -0
| @@ -401,6 +401,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 401 | 401 | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | |
| 402 | 402 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | |
| 403 | 403 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | |
| 404 | + | m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove) | |
| 404 | 405 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | |
| 405 | 406 | m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement) | |
| 406 | 407 | m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane) | |
| @@ -401,6 +401,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||||
| 401 | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | 401 | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | ||
| 402 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | 402 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | ||
| 403 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | 403 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | ||
| 404 | + | m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove) | |||
| 404 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | 405 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | ||
| 405 | m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement) | 406 | m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement) | ||
| 406 | m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane) | 407 | m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane) | ||
M internal/infrastructure/config/schema.go +3 -0
| @@ -313,6 +313,9 @@ type WorkspaceConfig struct { | |||
| 313 | 313 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | |
| 314 | 314 | // HideTabBarWhenSingleTab hides the tab bar when only one tab exists. | |
| 315 | 315 | HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line | |
| 316 | + | // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab. | |
| 317 | + | // Default: true | |
| 318 | + | SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line | |
| 316 | 319 | // Popups configures default popup placement rules. | |
| 317 | 320 | Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"` | |
| 318 | 321 | // Styling configures workspace visual appearance. | |
| @@ -313,6 +313,9 @@ type WorkspaceConfig struct { | |||||
| 313 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | 313 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | ||
| 314 | // HideTabBarWhenSingleTab hides the tab bar when only one tab exists. | 314 | // HideTabBarWhenSingleTab hides the tab bar when only one tab exists. | ||
| 315 | HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line | 315 | HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line | ||
| 316 | + | // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab. | |||
| 317 | + | // Default: true | |||
| 318 | + | SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line | |||
| 316 | // Popups configures default popup placement rules. | 319 | // Popups configures default popup placement rules. | ||
| 317 | Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"` | 320 | Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"` | ||
| 318 | // Styling configures workspace visual appearance. | 321 | // Styling configures workspace visual appearance. | ||
M internal/ui/dispatcher/keyboard.go +44 -12
| @@ -17,18 +17,20 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | |
| 19 | 19 | type KeyboardDispatcher struct { | |
| 20 | - | tabCoord *coordinator.TabCoordinator | |
| 21 | - | wsCoord *coordinator.WorkspaceCoordinator | |
| 22 | - | navCoord *coordinator.NavigationCoordinator | |
| 23 | - | zoomUC *usecase.ManageZoomUseCase | |
| 24 | - | copyURLUC *usecase.CopyURLUseCase | |
| 25 | - | actionHandlers map[input.Action]func(ctx context.Context) error | |
| 26 | - | onQuit func() | |
| 27 | - | onFindOpen func(ctx context.Context) error | |
| 28 | - | onFindNext func(ctx context.Context) error | |
| 29 | - | onFindPrev func(ctx context.Context) error | |
| 30 | - | onFindClose func(ctx context.Context) error | |
| 31 | - | onSessionOpen func(ctx context.Context) error | |
| 20 | + | tabCoord *coordinator.TabCoordinator | |
| 21 | + | wsCoord *coordinator.WorkspaceCoordinator | |
| 22 | + | navCoord *coordinator.NavigationCoordinator | |
| 23 | + | zoomUC *usecase.ManageZoomUseCase | |
| 24 | + | copyURLUC *usecase.CopyURLUseCase | |
| 25 | + | actionHandlers map[input.Action]func(ctx context.Context) error | |
| 26 | + | onQuit func() | |
| 27 | + | onFindOpen func(ctx context.Context) error | |
| 28 | + | onFindNext func(ctx context.Context) error | |
| 29 | + | onFindPrev func(ctx context.Context) error | |
| 30 | + | onFindClose func(ctx context.Context) error | |
| 31 | + | onSessionOpen func(ctx context.Context) error | |
| 32 | + | onMovePaneToTab func(ctx context.Context) error | |
| 33 | + | onMovePaneToNext func(ctx context.Context) error | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | // NewKeyboardDispatcher creates a new KeyboardDispatcher. | |
| @@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error | |||
| 84 | 86 | d.onSessionOpen = fn | |
| 85 | 87 | } | |
| 86 | 88 | ||
| 89 | + | func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) { | |
| 90 | + | d.onMovePaneToTab = fn | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) { | |
| 94 | + | d.onMovePaneToNext = fn | |
| 95 | + | } | |
| 96 | + | ||
| 87 | 97 | func (d *KeyboardDispatcher) initActionHandlers() { | |
| 88 | 98 | const ( | |
| 89 | 99 | firstTabIndex = 0 | |
| @@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 128 | 138 | input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, | |
| 129 | 139 | input.ActionClosePane: d.wsCoord.ClosePane, | |
| 130 | 140 | input.ActionStackPane: d.wsCoord.StackPane, | |
| 141 | + | input.ActionMovePaneToTab: func(ctx context.Context) error { | |
| 142 | + | return d.handleMovePaneToTab(ctx) | |
| 143 | + | }, | |
| 144 | + | input.ActionMovePaneToNextTab: func(ctx context.Context) error { | |
| 145 | + | return d.handleMovePaneToNextTab(ctx) | |
| 146 | + | }, | |
| 131 | 147 | input.ActionConsumeOrExpelLeft: func(ctx context.Context) error { | |
| 132 | 148 | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft) | |
| 133 | 149 | }, | |
| @@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |||
| 239 | 255 | return nil | |
| 240 | 256 | } | |
| 241 | 257 | ||
| 258 | + | func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | |
| 259 | + | if d.onMovePaneToTab != nil { | |
| 260 | + | return d.onMovePaneToTab(ctx) | |
| 261 | + | } | |
| 262 | + | logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") | |
| 263 | + | return nil | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { | |
| 267 | + | if d.onMovePaneToNext != nil { | |
| 268 | + | return d.onMovePaneToNext(ctx) | |
| 269 | + | } | |
| 270 | + | logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") | |
| 271 | + | return nil | |
| 272 | + | } | |
| 273 | + | ||
| 242 | 274 | func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error { | |
| 243 | 275 | if ctx == nil { | |
| 244 | 276 | return fmt.Errorf("missing context") | |
| @@ -17,18 +17,20 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | 18 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | ||
| 19 | type KeyboardDispatcher struct { | 19 | type KeyboardDispatcher struct { | ||
| 20 | - | tabCoord *coordinator.TabCoordinator | 20 | + | tabCoord *coordinator.TabCoordinator |
| 21 | - | wsCoord *coordinator.WorkspaceCoordinator | 21 | + | wsCoord *coordinator.WorkspaceCoordinator |
| 22 | - | navCoord *coordinator.NavigationCoordinator | 22 | + | navCoord *coordinator.NavigationCoordinator |
| 23 | - | zoomUC *usecase.ManageZoomUseCase | 23 | + | zoomUC *usecase.ManageZoomUseCase |
| 24 | - | copyURLUC *usecase.CopyURLUseCase | 24 | + | copyURLUC *usecase.CopyURLUseCase |
| 25 | - | actionHandlers map[input.Action]func(ctx context.Context) error | 25 | + | actionHandlers map[input.Action]func(ctx context.Context) error |
| 26 | - | onQuit func() | 26 | + | onQuit func() |
| 27 | - | onFindOpen func(ctx context.Context) error | 27 | + | onFindOpen func(ctx context.Context) error |
| 28 | - | onFindNext func(ctx context.Context) error | 28 | + | onFindNext func(ctx context.Context) error |
| 29 | - | onFindPrev func(ctx context.Context) error | 29 | + | onFindPrev func(ctx context.Context) error |
| 30 | - | onFindClose func(ctx context.Context) error | 30 | + | onFindClose func(ctx context.Context) error |
| 31 | - | onSessionOpen func(ctx context.Context) error | 31 | + | onSessionOpen func(ctx context.Context) error |
| 32 | + | onMovePaneToTab func(ctx context.Context) error | |||
| 33 | + | onMovePaneToNext func(ctx context.Context) error | |||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
| 34 | // NewKeyboardDispatcher creates a new KeyboardDispatcher. | 36 | // NewKeyboardDispatcher creates a new KeyboardDispatcher. | ||
| @@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error | |||||
| 84 | d.onSessionOpen = fn | 86 | d.onSessionOpen = fn | ||
| 85 | } | 87 | } | ||
| 86 | 88 | ||||
| 89 | + | func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) { | |||
| 90 | + | d.onMovePaneToTab = fn | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) { | |||
| 94 | + | d.onMovePaneToNext = fn | |||
| 95 | + | } | |||
| 96 | + | ||||
| 87 | func (d *KeyboardDispatcher) initActionHandlers() { | 97 | func (d *KeyboardDispatcher) initActionHandlers() { | ||
| 88 | const ( | 98 | const ( | ||
| 89 | firstTabIndex = 0 | 99 | firstTabIndex = 0 | ||
| @@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||||
| 128 | input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, | 138 | input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, | ||
| 129 | input.ActionClosePane: d.wsCoord.ClosePane, | 139 | input.ActionClosePane: d.wsCoord.ClosePane, | ||
| 130 | input.ActionStackPane: d.wsCoord.StackPane, | 140 | input.ActionStackPane: d.wsCoord.StackPane, | ||
| 141 | + | input.ActionMovePaneToTab: func(ctx context.Context) error { | |||
| 142 | + | return d.handleMovePaneToTab(ctx) | |||
| 143 | + | }, | |||
| 144 | + | input.ActionMovePaneToNextTab: func(ctx context.Context) error { | |||
| 145 | + | return d.handleMovePaneToNextTab(ctx) | |||
| 146 | + | }, | |||
| 131 | input.ActionConsumeOrExpelLeft: func(ctx context.Context) error { | 147 | input.ActionConsumeOrExpelLeft: func(ctx context.Context) error { | ||
| 132 | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft) | 148 | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft) | ||
| 133 | }, | 149 | }, | ||
| @@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |||||
| 239 | return nil | 255 | return nil | ||
| 240 | } | 256 | } | ||
| 241 | 257 | ||||
| 258 | + | func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | |||
| 259 | + | if d.onMovePaneToTab != nil { | |||
| 260 | + | return d.onMovePaneToTab(ctx) | |||
| 261 | + | } | |||
| 262 | + | logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") | |||
| 263 | + | return nil | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { | |||
| 267 | + | if d.onMovePaneToNext != nil { | |||
| 268 | + | return d.onMovePaneToNext(ctx) | |||
| 269 | + | } | |||
| 270 | + | logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") | |||
| 271 | + | return nil | |||
| 272 | + | } | |||
| 273 | + | ||||
| 242 | func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error { | 274 | func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error { | ||
| 243 | if ctx == nil { | 275 | if ctx == nil { | ||
| 244 | return fmt.Errorf("missing context") | 276 | return fmt.Errorf("missing context") | ||