detached 172cc2c

refactor(stack): use cases for domain model sync in stacked panes

commit
172cc2ccc925e754b27bdc8f55c921e8569a79f0
author
bnema · · 2026-01-07 19:02 UTC
parent
e9e5ddc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +275 -126
M internal/application/usecase/manage_panes.go +95 -1
@@ -455,6 +455,7 @@ func clampFloat64(v, minVal, maxVal float64) float64 {
455455
456456 // Close removes a pane and promotes its sibling.
457457 // Returns the sibling node that was promoted, or nil if this was the last pane.
458+// For stacked panes, removes the pane from the stack via RemoveFromStack.
458459 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
459460 log := logging.FromContext(ctx)
460461 if uc == nil {
@@ -482,7 +483,12 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
482483 return nil, nil
483484 }
484485
485- // Find sibling
486+ // Handle stacked panes differently - they can have more than 2 children
487+ if parent.IsStacked {
488+ return uc.closeStackedPane(ctx, ws, paneNode, parent)
489+ }
490+
491+ // Binary split: find sibling (the other child of parent)
486492 var sibling *entity.PaneNode
487493 for _, child := range parent.Children {
488494 if child != paneNode {
@@ -539,6 +545,94 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
539545 return sibling, nil
540546 }
541547
548+// closeStackedPane handles closing a pane within a stack.
549+// If multiple panes remain, removes the pane from the stack.
550+// If only one pane would remain, dissolves the stack.
551+func (uc *ManagePanesUseCase) closeStackedPane(
552+ ctx context.Context,
553+ ws *entity.Workspace,
554+ paneNode *entity.PaneNode,
555+ stackNode *entity.PaneNode,
556+) (*entity.PaneNode, error) {
557+ log := logging.FromContext(ctx)
558+
559+ paneID := paneNode.Pane.ID
560+
561+ // If more than 2 panes in stack, just remove this one
562+ if len(stackNode.Children) > 2 {
563+ if err := uc.RemoveFromStack(ctx, stackNode, paneID); err != nil {
564+ return nil, fmt.Errorf("failed to remove pane from stack: %w", err)
565+ }
566+
567+ // Update active pane if we closed the active one
568+ if ws.ActivePaneID == paneID {
569+ // Use the pane at the current active index
570+ if stackNode.ActiveStackIndex >= 0 && stackNode.ActiveStackIndex < len(stackNode.Children) {
571+ activeChild := stackNode.Children[stackNode.ActiveStackIndex]
572+ if activeChild.Pane != nil {
573+ ws.ActivePaneID = activeChild.Pane.ID
574+ }
575+ }
576+ }
577+
578+ log.Info().
579+ Str("pane_id", string(paneID)).
580+ Int("remaining", len(stackNode.Children)).
581+ Msg("pane removed from stack")
582+
583+ return stackNode, nil
584+ }
585+
586+ // If exactly 2 panes, remove this one and dissolve the stack
587+ if len(stackNode.Children) == 2 {
588+ // Find the remaining pane
589+ var remaining *entity.PaneNode
590+ for _, child := range stackNode.Children {
591+ if child.Pane != nil && child.Pane.ID != paneID {
592+ remaining = child
593+ break
594+ }
595+ }
596+
597+ if remaining == nil {
598+ return nil, fmt.Errorf("no remaining pane found in stack")
599+ }
600+
601+ // Dissolve the stack: promote remaining pane to stack's position
602+ grandparent := stackNode.Parent
603+
604+ if grandparent == nil {
605+ // Stack was root, remaining becomes new root
606+ ws.Root = remaining
607+ remaining.Parent = nil
608+ } else {
609+ // Replace stack with remaining in grandparent
610+ for i, child := range grandparent.Children {
611+ if child == stackNode {
612+ grandparent.Children[i] = remaining
613+ break
614+ }
615+ }
616+ remaining.Parent = grandparent
617+ }
618+
619+ // Update active pane if we closed the active one
620+ if ws.ActivePaneID == paneID && remaining.Pane != nil {
621+ ws.ActivePaneID = remaining.Pane.ID
622+ }
623+
624+ log.Info().
625+ Str("closed_pane_id", string(paneID)).
626+ Str("remaining_pane_id", remaining.ID).
627+ Msg("stack dissolved, remaining pane promoted")
628+
629+ return remaining, nil
630+ }
631+
632+ // Should not happen: stack with less than 2 children
633+ return nil, fmt.Errorf("invalid stack: has %d children", len(stackNode.Children))
634+}
635+
542636 // Focus sets the active pane in the workspace.
543637 // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
544638 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
@@ -455,6 +455,7 @@ func clampFloat64(v, minVal, maxVal float64) float64 {
455 455
456 // Close removes a pane and promotes its sibling.456 // Close removes a pane and promotes its sibling.
457 // Returns the sibling node that was promoted, or nil if this was the last pane.457 // Returns the sibling node that was promoted, or nil if this was the last pane.
458+// For stacked panes, removes the pane from the stack via RemoveFromStack.
458 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {459 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
459 log := logging.FromContext(ctx)460 log := logging.FromContext(ctx)
460 if uc == nil {461 if uc == nil {
@@ -482,7 +483,12 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
482 return nil, nil483 return nil, nil
483 }484 }
484 485
485- // Find sibling486+ // Handle stacked panes differently - they can have more than 2 children
487+ if parent.IsStacked {
488+ return uc.closeStackedPane(ctx, ws, paneNode, parent)
489+ }
490+
491+ // Binary split: find sibling (the other child of parent)
486 var sibling *entity.PaneNode492 var sibling *entity.PaneNode
487 for _, child := range parent.Children {493 for _, child := range parent.Children {
488 if child != paneNode {494 if child != paneNode {
@@ -539,6 +545,94 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
539 return sibling, nil545 return sibling, nil
540 }546 }
541 547
548+// closeStackedPane handles closing a pane within a stack.
549+// If multiple panes remain, removes the pane from the stack.
550+// If only one pane would remain, dissolves the stack.
551+func (uc *ManagePanesUseCase) closeStackedPane(
552+ ctx context.Context,
553+ ws *entity.Workspace,
554+ paneNode *entity.PaneNode,
555+ stackNode *entity.PaneNode,
556+) (*entity.PaneNode, error) {
557+ log := logging.FromContext(ctx)
558+
559+ paneID := paneNode.Pane.ID
560+
561+ // If more than 2 panes in stack, just remove this one
562+ if len(stackNode.Children) > 2 {
563+ if err := uc.RemoveFromStack(ctx, stackNode, paneID); err != nil {
564+ return nil, fmt.Errorf("failed to remove pane from stack: %w", err)
565+ }
566+
567+ // Update active pane if we closed the active one
568+ if ws.ActivePaneID == paneID {
569+ // Use the pane at the current active index
570+ if stackNode.ActiveStackIndex >= 0 && stackNode.ActiveStackIndex < len(stackNode.Children) {
571+ activeChild := stackNode.Children[stackNode.ActiveStackIndex]
572+ if activeChild.Pane != nil {
573+ ws.ActivePaneID = activeChild.Pane.ID
574+ }
575+ }
576+ }
577+
578+ log.Info().
579+ Str("pane_id", string(paneID)).
580+ Int("remaining", len(stackNode.Children)).
581+ Msg("pane removed from stack")
582+
583+ return stackNode, nil
584+ }
585+
586+ // If exactly 2 panes, remove this one and dissolve the stack
587+ if len(stackNode.Children) == 2 {
588+ // Find the remaining pane
589+ var remaining *entity.PaneNode
590+ for _, child := range stackNode.Children {
591+ if child.Pane != nil && child.Pane.ID != paneID {
592+ remaining = child
593+ break
594+ }
595+ }
596+
597+ if remaining == nil {
598+ return nil, fmt.Errorf("no remaining pane found in stack")
599+ }
600+
601+ // Dissolve the stack: promote remaining pane to stack's position
602+ grandparent := stackNode.Parent
603+
604+ if grandparent == nil {
605+ // Stack was root, remaining becomes new root
606+ ws.Root = remaining
607+ remaining.Parent = nil
608+ } else {
609+ // Replace stack with remaining in grandparent
610+ for i, child := range grandparent.Children {
611+ if child == stackNode {
612+ grandparent.Children[i] = remaining
613+ break
614+ }
615+ }
616+ remaining.Parent = grandparent
617+ }
618+
619+ // Update active pane if we closed the active one
620+ if ws.ActivePaneID == paneID && remaining.Pane != nil {
621+ ws.ActivePaneID = remaining.Pane.ID
622+ }
623+
624+ log.Info().
625+ Str("closed_pane_id", string(paneID)).
626+ Str("remaining_pane_id", remaining.ID).
627+ Msg("stack dissolved, remaining pane promoted")
628+
629+ return remaining, nil
630+ }
631+
632+ // Should not happen: stack with less than 2 children
633+ return nil, fmt.Errorf("invalid stack: has %d children", len(stackNode.Children))
634+}
635+
542 // Focus sets the active pane in the workspace.636 // Focus sets the active pane in the workspace.
543 // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.637 // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
544 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {638 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
M internal/ui/app.go +3 -0
@@ -928,6 +928,9 @@ func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID,
928928 return
929929 }
930930 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
931+ if a.wsCoord != nil {
932+ a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
933+ }
931934 }
932935
933936 func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
@@ -928,6 +928,9 @@ func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID,
928 return928 return
929 }929 }
930 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)930 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
931+ if a.wsCoord != nil {
932+ a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
933+ }
931 }934 }
932 935
933 func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {936 func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
M internal/ui/coordinator/workspace.go +177 -125
@@ -263,6 +263,7 @@ func (c *WorkspaceCoordinator) applySplitToView(
263263
264264 if needsAttach {
265265 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
266+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
266267 }
267268 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
268269 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
@@ -1055,6 +1056,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
10551056 }
10561057 c.contentCoord.ReleaseWebView(ctx, paneID)
10571058 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1059+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
10581060 }
10591061 } else {
10601062 if err := wsView.Rebuild(ctx); err != nil {
@@ -1062,6 +1064,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
10621064 }
10631065 c.contentCoord.ReleaseWebView(ctx, paneID)
10641066 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1067+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
10651068 }
10661069
10671070 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
@@ -1095,17 +1098,10 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
10951098 return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
10961099 }
10971100
1098- // Find the index of the closing pane in the stack
1099- closingIndex := -1
1100- for i, child := range stackNode.Children {
1101- if child.Pane != nil && child.Pane.ID == closingPaneID {
1102- closingIndex = i
1103- break
1104- }
1105- }
1106-
1101+ // Find the index of the closing pane in the StackedView UI
1102+ closingIndex := stackedView.FindPaneIndex(string(closingPaneID))
11071103 if closingIndex < 0 {
1108- return fmt.Errorf("pane %s not found in stack", closingPaneID)
1104+ return fmt.Errorf("pane %s not found in stacked view", closingPaneID)
11091105 }
11101106
11111107 // Remove the pane from the StackedView widget
@@ -1120,12 +1116,48 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
11201116 // Release the closing pane's webview
11211117 c.contentCoord.ReleaseWebView(ctx, closingPaneID)
11221118
1119+ // Sync remaining title bars with current titles from ContentCoordinator.
1120+ // The domain model (stackNode.Children) has already been updated by the use case,
1121+ // so we iterate the remaining children and update their titles in the UI.
1122+ c.syncStackedPaneTitles(ctx, stackedView, stackNode)
1123+
11231124 log.Debug().
11241125 Int("removed_index", closingIndex).
11251126 Msg("incremental stack close completed successfully")
11261127 return nil
11271128 }
11281129
1130+// syncStackedPaneTitles updates all title bars in a StackedView with current titles.
1131+func (c *WorkspaceCoordinator) syncStackedPaneTitles(ctx context.Context, stackedView *layout.StackedView, stackNode *entity.PaneNode) {
1132+ log := logging.FromContext(ctx)
1133+
1134+ if stackedView == nil || stackNode == nil {
1135+ return
1136+ }
1137+
1138+ for i, child := range stackNode.Children {
1139+ if child.Pane == nil {
1140+ continue
1141+ }
1142+
1143+ title := c.contentCoord.GetTitle(child.Pane.ID)
1144+ if title == "" {
1145+ title = child.Pane.Title
1146+ }
1147+ if title == "" {
1148+ title = defaultPaneTitle
1149+ }
1150+
1151+ if err := stackedView.UpdateTitle(i, title); err != nil {
1152+ log.Warn().
1153+ Err(err).
1154+ Int("index", i).
1155+ Str("pane_id", string(child.Pane.ID)).
1156+ Msg("failed to sync title bar")
1157+ }
1158+ }
1159+}
1160+
11291161 // FocusPane navigates focus to an adjacent pane.
11301162 func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
11311163 log := logging.FromContext(ctx)
@@ -1228,6 +1260,7 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
12281260 }
12291261
12301262 // StackPane adds a new pane stacked on top of the active pane.
1263+// Uses CreateStack use case for new stacks, AddToStack for existing stacks.
12311264 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12321265 log := logging.FromContext(ctx)
12331266
@@ -1236,19 +1269,64 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12361269 return nil
12371270 }
12381271
1272+ // Create new pane entity
12391273 newPaneID := entity.PaneID(c.generateID())
12401274 newPane := entity.NewPane(newPaneID)
12411275 newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)
12421276 newPane.Title = defaultPaneTitle
12431277
1244- stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle)
1245- insertIndex := insertStackChild(stackNode, newPaneID, newPane)
1278+ // Determine if we need to create a new stack or add to existing
1279+ var stackNode *entity.PaneNode
1280+ var needsFirstPaneTitleUpdate bool
12461281
1247- log.Debug().
1248- Int("stack_size", len(stackNode.Children)).
1249- Int("insert_index", insertIndex).
1250- Int("active_index", stackNode.ActiveStackIndex).
1251- Msg("domain tree updated with position-aware insertion")
1282+ if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1283+ // Already in a stack - use AddToStack use case
1284+ stackNode = stackCtx.activeNode.Parent
1285+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1286+ if err != nil {
1287+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1288+ return err
1289+ }
1290+ log.Debug().
1291+ Int("stack_size", len(stackNode.Children)).
1292+ Int("insert_index", output.StackIndex).
1293+ Msg("added to existing stack via use case")
1294+ } else if stackCtx.activeNode.IsStacked {
1295+ // Active node is already a stack container - add to it
1296+ stackNode = stackCtx.activeNode
1297+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1298+ if err != nil {
1299+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1300+ return err
1301+ }
1302+ log.Debug().
1303+ Int("stack_size", len(stackNode.Children)).
1304+ Int("insert_index", output.StackIndex).
1305+ Msg("added to stack container via use case")
1306+ } else {
1307+ // Need to create a new stack - use CreateStack use case
1308+ // But CreateStack creates its own pane, so we need a different approach
1309+ // Convert to stack first, then the new pane is already created
1310+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode)
1311+ if err != nil {
1312+ log.Error().Err(err).Msg("failed to create stack via use case")
1313+ return err
1314+ }
1315+ stackNode = output.StackNode
1316+ // CreateStack creates its own new pane, use that instead
1317+ newPane = output.NewPane
1318+ newPaneID = newPane.ID
1319+ needsFirstPaneTitleUpdate = true
1320+
1321+ // Update the original pane's title in the domain
1322+ if output.OriginalNode != nil && output.OriginalNode.Pane != nil {
1323+ output.OriginalNode.Pane.Title = stackCtx.originalTitle
1324+ }
1325+
1326+ log.Debug().
1327+ Int("stack_size", len(stackNode.Children)).
1328+ Msg("created new stack via use case")
1329+ }
12521330
12531331 // Create PaneView for the new pane
12541332 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
@@ -1281,9 +1359,6 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12811359 }
12821360 }
12831361
1284- // Update workspace active pane ID
1285- stackCtx.ws.ActivePaneID = newPaneID
1286-
12871362 // Update workspace view
12881363 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
12891364 log.Warn().Err(err).Msg("failed to set active pane")
@@ -1364,71 +1439,6 @@ func (c *WorkspaceCoordinator) prepareStackPane(
13641439 }, true
13651440 }
13661441
1367-func (c *WorkspaceCoordinator) ensureStackNode(
1368- ctx context.Context,
1369- activeNode *entity.PaneNode,
1370- activePaneID entity.PaneID,
1371- originalTitle string,
1372-) (*entity.PaneNode, bool) {
1373- log := logging.FromContext(ctx)
1374- if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1375- stackNode := activeNode.Parent
1376- log.Debug().
1377- Str("stack_id", stackNode.ID).
1378- Str("active_pane", string(activePaneID)).
1379- Int("stack_size", len(stackNode.Children)).
1380- Msg("adding to existing stack via parent")
1381- return stackNode, false
1382- }
1383-
1384- if activeNode.IsStacked {
1385- return activeNode, false
1386- }
1387-
1388- originalPane := activeNode.Pane
1389- originalPane.Title = originalTitle
1390- originalPaneChild := &entity.PaneNode{
1391- ID: activeNode.ID + "_0",
1392- Pane: originalPane,
1393- Parent: activeNode,
1394- }
1395-
1396- activeNode.Pane = nil
1397- activeNode.IsStacked = true
1398- activeNode.Children = []*entity.PaneNode{originalPaneChild}
1399-
1400- log.Debug().
1401- Str("node_id", activeNode.ID).
1402- Str("original_pane", string(originalPane.ID)).
1403- Str("original_title", originalTitle).
1404- Msg("converted leaf to stacked node")
1405-
1406- return activeNode, true
1407-}
1408-
1409-func insertStackChild(stackNode *entity.PaneNode, newPaneID entity.PaneID, newPane *entity.Pane) int {
1410- newChildNode := &entity.PaneNode{
1411- ID: stackNode.ID + "_" + string(newPaneID),
1412- Pane: newPane,
1413- Parent: stackNode,
1414- }
1415-
1416- currentActiveIndex := stackNode.ActiveStackIndex
1417- if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1418- currentActiveIndex = len(stackNode.Children) - 1
1419- if currentActiveIndex < 0 {
1420- currentActiveIndex = -1
1421- }
1422- }
1423- insertIndex := currentActiveIndex + 1
1424-
1425- stackNode.Children = append(stackNode.Children, nil)
1426- copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1427- stackNode.Children[insertIndex] = newChildNode
1428- stackNode.ActiveStackIndex = insertIndex
1429- return insertIndex
1430-}
1431-
14321442 func (c *WorkspaceCoordinator) updateFirstStackTitle(
14331443 ctx context.Context,
14341444 wsView *component.WorkspaceView,
@@ -1577,6 +1587,54 @@ func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID en
15771587 }
15781588 }
15791589
1590+// SetupStackedPaneCallbacks sets up title bar click and close callbacks for all stacked panes.
1591+// This must be called after Rebuild() to restore callbacks on newly created StackedView instances.
1592+func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
1593+ log := logging.FromContext(ctx)
1594+
1595+ if ws == nil || ws.Root == nil || wsView == nil {
1596+ return
1597+ }
1598+
1599+ tr := wsView.TreeRenderer()
1600+ if tr == nil {
1601+ return
1602+ }
1603+
1604+ // Walk the tree and set up callbacks for all stacked nodes
1605+ ws.Root.Walk(func(node *entity.PaneNode) bool {
1606+ if !node.IsStacked || len(node.Children) == 0 {
1607+ return true
1608+ }
1609+
1610+ // Get the StackedView for any pane in this stack
1611+ firstChild := node.Children[0]
1612+ if firstChild.Pane == nil {
1613+ return true
1614+ }
1615+
1616+ stackedView := tr.GetStackedViewForPane(string(firstChild.Pane.ID))
1617+ if stackedView == nil {
1618+ return true
1619+ }
1620+
1621+ capturedStackNode := node
1622+ stackedView.SetOnActivate(func(index int) {
1623+ c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1624+ })
1625+ stackedView.SetOnClosePane(func(paneID string) {
1626+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1627+ })
1628+
1629+ log.Debug().
1630+ Str("stack_id", node.ID).
1631+ Int("stack_size", len(node.Children)).
1632+ Msg("set up stacked pane callbacks")
1633+
1634+ return true
1635+ })
1636+}
1637+
15801638 // --- Popup Insertion ---
15811639
15821640 // SetOnCreatePopupTab sets the callback for creating popup tabs.
@@ -1726,6 +1784,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
17261784 }
17271785
17281786 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1787+// Uses CreateStack and AddToStack use cases for proper domain model management.
17291788 func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
17301789 log := logging.FromContext(ctx)
17311790
@@ -1739,16 +1798,17 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
17391798 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
17401799 }
17411800
1742- stackNode := c.resolveStackNode(input, parentNode, log)
1743- if stackNode == nil {
1744- return fmt.Errorf("failed to resolve stack node for popup")
1745- }
1801+ // Resolve or create stack node
1802+ stackNode := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
17461803
1747- c.insertStackChild(stackNode, input.PopupPane)
1804+ // Add popup pane to stack using use case
1805+ if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {
1806+ return fmt.Errorf("failed to add popup to stack: %w", err)
1807+ }
17481808
17491809 if wsView == nil {
1750- // No workspace view to update; keep domain model consistent.
1751- ws.ActivePaneID = input.PopupPane.ID
1810+ // No workspace view to update; domain model already updated by use case.
1811+ return nil
17521812 }
17531813
17541814 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
@@ -1763,27 +1823,37 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
17631823 return nil
17641824 }
17651825
1766-func (c *WorkspaceCoordinator) resolveStackNode(
1767- input InsertPopupInput,
1826+// resolveOrCreateStackNode finds an existing stack or creates one for popup insertion.
1827+// Note: For popups, we can't use CreateStack use case since it creates a new pane,
1828+// but we already have the popup pane. This conversion is a special case.
1829+func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1830+ ctx context.Context,
17681831 parentNode *entity.PaneNode,
1769- log *zerolog.Logger,
1832+ parentPaneID entity.PaneID,
17701833 ) *entity.PaneNode {
1834+ log := logging.FromContext(ctx)
1835+
1836+ // Check if parent is already in a stack
17711837 if parentNode.Parent != nil && parentNode.Parent.IsStacked {
1838+ log.Debug().
1839+ Str("stack_id", parentNode.Parent.ID).
1840+ Msg("using existing parent stack for popup")
17721841 return parentNode.Parent
17731842 }
1843+
1844+ // Check if parent is already a stack container
17741845 if parentNode.IsStacked {
1846+ log.Debug().
1847+ Str("stack_id", parentNode.ID).
1848+ Msg("parent is already a stack container")
17751849 return parentNode
17761850 }
1777- return c.convertToStackedNode(input, parentNode, log)
1778-}
17791851
1780-func (c *WorkspaceCoordinator) convertToStackedNode(
1781- input InsertPopupInput,
1782- parentNode *entity.PaneNode,
1783- log *zerolog.Logger,
1784-) *entity.PaneNode {
1852+ // Need to convert leaf to stack for popup insertion.
1853+ // We can't use CreateStack use case since it creates a new pane,
1854+ // but we already have the popup pane to insert.
17851855 originalPane := parentNode.Pane
1786- originalTitle := c.contentCoord.GetTitle(input.ParentPaneID)
1856+ originalTitle := c.contentCoord.GetTitle(parentPaneID)
17871857 if originalTitle == "" {
17881858 originalTitle = originalPane.Title
17891859 }
@@ -1792,14 +1862,17 @@ func (c *WorkspaceCoordinator) convertToStackedNode(
17921862 }
17931863 originalPane.Title = originalTitle
17941864
1865+ // Create child node for the original pane
17951866 originalPaneChild := &entity.PaneNode{
17961867 ID: parentNode.ID + "_0",
17971868 Pane: originalPane,
17981869 Parent: parentNode,
17991870 }
18001871
1872+ // Convert parentNode to stack container
18011873 parentNode.Pane = nil
18021874 parentNode.IsStacked = true
1875+ parentNode.ActiveStackIndex = 0
18031876 parentNode.Children = []*entity.PaneNode{originalPaneChild}
18041877
18051878 log.Debug().
@@ -1810,28 +1883,6 @@ func (c *WorkspaceCoordinator) convertToStackedNode(
18101883 return parentNode
18111884 }
18121885
1813-func (c *WorkspaceCoordinator) insertStackChild(stackNode *entity.PaneNode, pane *entity.Pane) {
1814- newChildNode := &entity.PaneNode{
1815- ID: stackNode.ID + "_popup_" + string(pane.ID),
1816- Pane: pane,
1817- Parent: stackNode,
1818- }
1819-
1820- currentActiveIndex := stackNode.ActiveStackIndex
1821- if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1822- currentActiveIndex = len(stackNode.Children) - 1
1823- if currentActiveIndex < 0 {
1824- currentActiveIndex = -1
1825- }
1826- }
1827- insertIndex := currentActiveIndex + 1
1828-
1829- stackNode.Children = append(stackNode.Children, nil)
1830- copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1831- stackNode.Children[insertIndex] = newChildNode
1832- stackNode.ActiveStackIndex = insertIndex
1833-}
1834-
18351886 func (c *WorkspaceCoordinator) attachPopupPaneView(
18361887 ctx context.Context,
18371888 input InsertPopupInput,
@@ -1933,6 +1984,7 @@ func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction
19331984 log.Warn().Err(err).Msg("failed to rebuild workspace view")
19341985 }
19351986 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1987+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
19361988 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
19371989 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
19381990 }
@@ -263,6 +263,7 @@ func (c *WorkspaceCoordinator) applySplitToView(
263 263
264 if needsAttach {264 if needsAttach {
265 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)265 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
266+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
266 }267 }
267 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {268 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
268 log.Warn().Err(err).Msg("failed to set active pane in workspace view")269 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
@@ -1055,6 +1056,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1055 }1056 }
1056 c.contentCoord.ReleaseWebView(ctx, paneID)1057 c.contentCoord.ReleaseWebView(ctx, paneID)
1057 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)1058 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1059+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1058 }1060 }
1059 } else {1061 } else {
1060 if err := wsView.Rebuild(ctx); err != nil {1062 if err := wsView.Rebuild(ctx); err != nil {
@@ -1062,6 +1064,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1062 }1064 }
1063 c.contentCoord.ReleaseWebView(ctx, paneID)1065 c.contentCoord.ReleaseWebView(ctx, paneID)
1064 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)1066 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1067+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1065 }1068 }
1066 1069
1067 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {1070 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
@@ -1095,17 +1098,10 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
1095 return fmt.Errorf("stacked view not found for pane %s", closingPaneID)1098 return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
1096 }1099 }
1097 1100
1098- // Find the index of the closing pane in the stack1101+ // Find the index of the closing pane in the StackedView UI
1099- closingIndex := -11102+ closingIndex := stackedView.FindPaneIndex(string(closingPaneID))
1100- for i, child := range stackNode.Children {
1101- if child.Pane != nil && child.Pane.ID == closingPaneID {
1102- closingIndex = i
1103- break
1104- }
1105- }
1106-
1107 if closingIndex < 0 {1103 if closingIndex < 0 {
1108- return fmt.Errorf("pane %s not found in stack", closingPaneID)1104+ return fmt.Errorf("pane %s not found in stacked view", closingPaneID)
1109 }1105 }
1110 1106
1111 // Remove the pane from the StackedView widget1107 // Remove the pane from the StackedView widget
@@ -1120,12 +1116,48 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
1120 // Release the closing pane's webview1116 // Release the closing pane's webview
1121 c.contentCoord.ReleaseWebView(ctx, closingPaneID)1117 c.contentCoord.ReleaseWebView(ctx, closingPaneID)
1122 1118
1119+ // Sync remaining title bars with current titles from ContentCoordinator.
1120+ // The domain model (stackNode.Children) has already been updated by the use case,
1121+ // so we iterate the remaining children and update their titles in the UI.
1122+ c.syncStackedPaneTitles(ctx, stackedView, stackNode)
1123+
1123 log.Debug().1124 log.Debug().
1124 Int("removed_index", closingIndex).1125 Int("removed_index", closingIndex).
1125 Msg("incremental stack close completed successfully")1126 Msg("incremental stack close completed successfully")
1126 return nil1127 return nil
1127 }1128 }
1128 1129
1130+// syncStackedPaneTitles updates all title bars in a StackedView with current titles.
1131+func (c *WorkspaceCoordinator) syncStackedPaneTitles(ctx context.Context, stackedView *layout.StackedView, stackNode *entity.PaneNode) {
1132+ log := logging.FromContext(ctx)
1133+
1134+ if stackedView == nil || stackNode == nil {
1135+ return
1136+ }
1137+
1138+ for i, child := range stackNode.Children {
1139+ if child.Pane == nil {
1140+ continue
1141+ }
1142+
1143+ title := c.contentCoord.GetTitle(child.Pane.ID)
1144+ if title == "" {
1145+ title = child.Pane.Title
1146+ }
1147+ if title == "" {
1148+ title = defaultPaneTitle
1149+ }
1150+
1151+ if err := stackedView.UpdateTitle(i, title); err != nil {
1152+ log.Warn().
1153+ Err(err).
1154+ Int("index", i).
1155+ Str("pane_id", string(child.Pane.ID)).
1156+ Msg("failed to sync title bar")
1157+ }
1158+ }
1159+}
1160+
1129 // FocusPane navigates focus to an adjacent pane.1161 // FocusPane navigates focus to an adjacent pane.
1130 func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {1162 func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
1131 log := logging.FromContext(ctx)1163 log := logging.FromContext(ctx)
@@ -1228,6 +1260,7 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
1228 }1260 }
1229 1261
1230 // StackPane adds a new pane stacked on top of the active pane.1262 // StackPane adds a new pane stacked on top of the active pane.
1263+// Uses CreateStack use case for new stacks, AddToStack for existing stacks.
1231 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {1264 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1232 log := logging.FromContext(ctx)1265 log := logging.FromContext(ctx)
1233 1266
@@ -1236,19 +1269,64 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1236 return nil1269 return nil
1237 }1270 }
1238 1271
1272+ // Create new pane entity
1239 newPaneID := entity.PaneID(c.generateID())1273 newPaneID := entity.PaneID(c.generateID())
1240 newPane := entity.NewPane(newPaneID)1274 newPane := entity.NewPane(newPaneID)
1241 newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)1275 newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)
1242 newPane.Title = defaultPaneTitle1276 newPane.Title = defaultPaneTitle
1243 1277
1244- stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle)1278+ // Determine if we need to create a new stack or add to existing
1245- insertIndex := insertStackChild(stackNode, newPaneID, newPane)1279+ var stackNode *entity.PaneNode
1280+ var needsFirstPaneTitleUpdate bool
1246 1281
1247- log.Debug().1282+ if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1248- Int("stack_size", len(stackNode.Children)).1283+ // Already in a stack - use AddToStack use case
1249- Int("insert_index", insertIndex).1284+ stackNode = stackCtx.activeNode.Parent
1250- Int("active_index", stackNode.ActiveStackIndex).1285+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1251- Msg("domain tree updated with position-aware insertion")1286+ if err != nil {
1287+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1288+ return err
1289+ }
1290+ log.Debug().
1291+ Int("stack_size", len(stackNode.Children)).
1292+ Int("insert_index", output.StackIndex).
1293+ Msg("added to existing stack via use case")
1294+ } else if stackCtx.activeNode.IsStacked {
1295+ // Active node is already a stack container - add to it
1296+ stackNode = stackCtx.activeNode
1297+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1298+ if err != nil {
1299+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1300+ return err
1301+ }
1302+ log.Debug().
1303+ Int("stack_size", len(stackNode.Children)).
1304+ Int("insert_index", output.StackIndex).
1305+ Msg("added to stack container via use case")
1306+ } else {
1307+ // Need to create a new stack - use CreateStack use case
1308+ // But CreateStack creates its own pane, so we need a different approach
1309+ // Convert to stack first, then the new pane is already created
1310+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode)
1311+ if err != nil {
1312+ log.Error().Err(err).Msg("failed to create stack via use case")
1313+ return err
1314+ }
1315+ stackNode = output.StackNode
1316+ // CreateStack creates its own new pane, use that instead
1317+ newPane = output.NewPane
1318+ newPaneID = newPane.ID
1319+ needsFirstPaneTitleUpdate = true
1320+
1321+ // Update the original pane's title in the domain
1322+ if output.OriginalNode != nil && output.OriginalNode.Pane != nil {
1323+ output.OriginalNode.Pane.Title = stackCtx.originalTitle
1324+ }
1325+
1326+ log.Debug().
1327+ Int("stack_size", len(stackNode.Children)).
1328+ Msg("created new stack via use case")
1329+ }
1252 1330
1253 // Create PaneView for the new pane1331 // Create PaneView for the new pane
1254 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)1332 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
@@ -1281,9 +1359,6 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1281 }1359 }
1282 }1360 }
1283 1361
1284- // Update workspace active pane ID
1285- stackCtx.ws.ActivePaneID = newPaneID
1286-
1287 // Update workspace view1362 // Update workspace view
1288 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {1363 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1289 log.Warn().Err(err).Msg("failed to set active pane")1364 log.Warn().Err(err).Msg("failed to set active pane")
@@ -1364,71 +1439,6 @@ func (c *WorkspaceCoordinator) prepareStackPane(
1364 }, true1439 }, true
1365 }1440 }
1366 1441
1367-func (c *WorkspaceCoordinator) ensureStackNode(
1368- ctx context.Context,
1369- activeNode *entity.PaneNode,
1370- activePaneID entity.PaneID,
1371- originalTitle string,
1372-) (*entity.PaneNode, bool) {
1373- log := logging.FromContext(ctx)
1374- if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1375- stackNode := activeNode.Parent
1376- log.Debug().
1377- Str("stack_id", stackNode.ID).
1378- Str("active_pane", string(activePaneID)).
1379- Int("stack_size", len(stackNode.Children)).
1380- Msg("adding to existing stack via parent")
1381- return stackNode, false
1382- }
1383-
1384- if activeNode.IsStacked {
1385- return activeNode, false
1386- }
1387-
1388- originalPane := activeNode.Pane
1389- originalPane.Title = originalTitle
1390- originalPaneChild := &entity.PaneNode{
1391- ID: activeNode.ID + "_0",
1392- Pane: originalPane,
1393- Parent: activeNode,
1394- }
1395-
1396- activeNode.Pane = nil
1397- activeNode.IsStacked = true
1398- activeNode.Children = []*entity.PaneNode{originalPaneChild}
1399-
1400- log.Debug().
1401- Str("node_id", activeNode.ID).
1402- Str("original_pane", string(originalPane.ID)).
1403- Str("original_title", originalTitle).
1404- Msg("converted leaf to stacked node")
1405-
1406- return activeNode, true
1407-}
1408-
1409-func insertStackChild(stackNode *entity.PaneNode, newPaneID entity.PaneID, newPane *entity.Pane) int {
1410- newChildNode := &entity.PaneNode{
1411- ID: stackNode.ID + "_" + string(newPaneID),
1412- Pane: newPane,
1413- Parent: stackNode,
1414- }
1415-
1416- currentActiveIndex := stackNode.ActiveStackIndex
1417- if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1418- currentActiveIndex = len(stackNode.Children) - 1
1419- if currentActiveIndex < 0 {
1420- currentActiveIndex = -1
1421- }
1422- }
1423- insertIndex := currentActiveIndex + 1
1424-
1425- stackNode.Children = append(stackNode.Children, nil)
1426- copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1427- stackNode.Children[insertIndex] = newChildNode
1428- stackNode.ActiveStackIndex = insertIndex
1429- return insertIndex
1430-}
1431-
1432 func (c *WorkspaceCoordinator) updateFirstStackTitle(1442 func (c *WorkspaceCoordinator) updateFirstStackTitle(
1433 ctx context.Context,1443 ctx context.Context,
1434 wsView *component.WorkspaceView,1444 wsView *component.WorkspaceView,
@@ -1577,6 +1587,54 @@ func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID en
1577 }1587 }
1578 }1588 }
1579 1589
1590+// SetupStackedPaneCallbacks sets up title bar click and close callbacks for all stacked panes.
1591+// This must be called after Rebuild() to restore callbacks on newly created StackedView instances.
1592+func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
1593+ log := logging.FromContext(ctx)
1594+
1595+ if ws == nil || ws.Root == nil || wsView == nil {
1596+ return
1597+ }
1598+
1599+ tr := wsView.TreeRenderer()
1600+ if tr == nil {
1601+ return
1602+ }
1603+
1604+ // Walk the tree and set up callbacks for all stacked nodes
1605+ ws.Root.Walk(func(node *entity.PaneNode) bool {
1606+ if !node.IsStacked || len(node.Children) == 0 {
1607+ return true
1608+ }
1609+
1610+ // Get the StackedView for any pane in this stack
1611+ firstChild := node.Children[0]
1612+ if firstChild.Pane == nil {
1613+ return true
1614+ }
1615+
1616+ stackedView := tr.GetStackedViewForPane(string(firstChild.Pane.ID))
1617+ if stackedView == nil {
1618+ return true
1619+ }
1620+
1621+ capturedStackNode := node
1622+ stackedView.SetOnActivate(func(index int) {
1623+ c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1624+ })
1625+ stackedView.SetOnClosePane(func(paneID string) {
1626+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1627+ })
1628+
1629+ log.Debug().
1630+ Str("stack_id", node.ID).
1631+ Int("stack_size", len(node.Children)).
1632+ Msg("set up stacked pane callbacks")
1633+
1634+ return true
1635+ })
1636+}
1637+
1580 // --- Popup Insertion ---1638 // --- Popup Insertion ---
1581 1639
1582 // SetOnCreatePopupTab sets the callback for creating popup tabs.1640 // SetOnCreatePopupTab sets the callback for creating popup tabs.
@@ -1726,6 +1784,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
1726 }1784 }
1727 1785
1728 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.1786 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1787+// Uses CreateStack and AddToStack use cases for proper domain model management.
1729 func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {1788 func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
1730 log := logging.FromContext(ctx)1789 log := logging.FromContext(ctx)
1731 1790
@@ -1739,16 +1798,17 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
1739 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)1798 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1740 }1799 }
1741 1800
1742- stackNode := c.resolveStackNode(input, parentNode, log)1801+ // Resolve or create stack node
1743- if stackNode == nil {1802+ stackNode := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
1744- return fmt.Errorf("failed to resolve stack node for popup")
1745- }
1746 1803
1747- c.insertStackChild(stackNode, input.PopupPane)1804+ // Add popup pane to stack using use case
1805+ if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {
1806+ return fmt.Errorf("failed to add popup to stack: %w", err)
1807+ }
1748 1808
1749 if wsView == nil {1809 if wsView == nil {
1750- // No workspace view to update; keep domain model consistent.1810+ // No workspace view to update; domain model already updated by use case.
1751- ws.ActivePaneID = input.PopupPane.ID1811+ return nil
1752 }1812 }
1753 1813
1754 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {1814 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
@@ -1763,27 +1823,37 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
1763 return nil1823 return nil
1764 }1824 }
1765 1825
1766-func (c *WorkspaceCoordinator) resolveStackNode(1826+// resolveOrCreateStackNode finds an existing stack or creates one for popup insertion.
1767- input InsertPopupInput,1827+// Note: For popups, we can't use CreateStack use case since it creates a new pane,
1828+// but we already have the popup pane. This conversion is a special case.
1829+func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1830+ ctx context.Context,
1768 parentNode *entity.PaneNode,1831 parentNode *entity.PaneNode,
1769- log *zerolog.Logger,1832+ parentPaneID entity.PaneID,
1770 ) *entity.PaneNode {1833 ) *entity.PaneNode {
1834+ log := logging.FromContext(ctx)
1835+
1836+ // Check if parent is already in a stack
1771 if parentNode.Parent != nil && parentNode.Parent.IsStacked {1837 if parentNode.Parent != nil && parentNode.Parent.IsStacked {
1838+ log.Debug().
1839+ Str("stack_id", parentNode.Parent.ID).
1840+ Msg("using existing parent stack for popup")
1772 return parentNode.Parent1841 return parentNode.Parent
1773 }1842 }
1843+
1844+ // Check if parent is already a stack container
1774 if parentNode.IsStacked {1845 if parentNode.IsStacked {
1846+ log.Debug().
1847+ Str("stack_id", parentNode.ID).
1848+ Msg("parent is already a stack container")
1775 return parentNode1849 return parentNode
1776 }1850 }
1777- return c.convertToStackedNode(input, parentNode, log)
1778-}
1779 1851
1780-func (c *WorkspaceCoordinator) convertToStackedNode(1852+ // Need to convert leaf to stack for popup insertion.
1781- input InsertPopupInput,1853+ // We can't use CreateStack use case since it creates a new pane,
1782- parentNode *entity.PaneNode,1854+ // but we already have the popup pane to insert.
1783- log *zerolog.Logger,
1784-) *entity.PaneNode {
1785 originalPane := parentNode.Pane1855 originalPane := parentNode.Pane
1786- originalTitle := c.contentCoord.GetTitle(input.ParentPaneID)1856+ originalTitle := c.contentCoord.GetTitle(parentPaneID)
1787 if originalTitle == "" {1857 if originalTitle == "" {
1788 originalTitle = originalPane.Title1858 originalTitle = originalPane.Title
1789 }1859 }
@@ -1792,14 +1862,17 @@ func (c *WorkspaceCoordinator) convertToStackedNode(
1792 }1862 }
1793 originalPane.Title = originalTitle1863 originalPane.Title = originalTitle
1794 1864
1865+ // Create child node for the original pane
1795 originalPaneChild := &entity.PaneNode{1866 originalPaneChild := &entity.PaneNode{
1796 ID: parentNode.ID + "_0",1867 ID: parentNode.ID + "_0",
1797 Pane: originalPane,1868 Pane: originalPane,
1798 Parent: parentNode,1869 Parent: parentNode,
1799 }1870 }
1800 1871
1872+ // Convert parentNode to stack container
1801 parentNode.Pane = nil1873 parentNode.Pane = nil
1802 parentNode.IsStacked = true1874 parentNode.IsStacked = true
1875+ parentNode.ActiveStackIndex = 0
1803 parentNode.Children = []*entity.PaneNode{originalPaneChild}1876 parentNode.Children = []*entity.PaneNode{originalPaneChild}
1804 1877
1805 log.Debug().1878 log.Debug().
@@ -1810,28 +1883,6 @@ func (c *WorkspaceCoordinator) convertToStackedNode(
1810 return parentNode1883 return parentNode
1811 }1884 }
1812 1885
1813-func (c *WorkspaceCoordinator) insertStackChild(stackNode *entity.PaneNode, pane *entity.Pane) {
1814- newChildNode := &entity.PaneNode{
1815- ID: stackNode.ID + "_popup_" + string(pane.ID),
1816- Pane: pane,
1817- Parent: stackNode,
1818- }
1819-
1820- currentActiveIndex := stackNode.ActiveStackIndex
1821- if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1822- currentActiveIndex = len(stackNode.Children) - 1
1823- if currentActiveIndex < 0 {
1824- currentActiveIndex = -1
1825- }
1826- }
1827- insertIndex := currentActiveIndex + 1
1828-
1829- stackNode.Children = append(stackNode.Children, nil)
1830- copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1831- stackNode.Children[insertIndex] = newChildNode
1832- stackNode.ActiveStackIndex = insertIndex
1833-}
1834-
1835 func (c *WorkspaceCoordinator) attachPopupPaneView(1886 func (c *WorkspaceCoordinator) attachPopupPaneView(
1836 ctx context.Context,1887 ctx context.Context,
1837 input InsertPopupInput,1888 input InsertPopupInput,
@@ -1933,6 +1984,7 @@ func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction
1933 log.Warn().Err(err).Msg("failed to rebuild workspace view")1984 log.Warn().Err(err).Msg("failed to rebuild workspace view")
1934 }1985 }
1935 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)1986 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1987+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1936 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {1988 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1937 log.Warn().Err(err).Msg("failed to set active pane in workspace view")1989 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1938 }1990 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close