detached 38a0923

fix(workspace): prevent domain/UI desync in stacked panes

- Add bounds check in syncStackedPaneTitles to handle desync gracefully
- Add rollback in insertPopupStacked when UI update fails
- Track stack conversion state for proper cleanup on failure
commit
38a09237f8e0b2c0687e0099eef14f4bfb98e868
author
brice · · 2026-01-14 08:45 UTC
parent
d759bef
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +57 -6
M internal/ui/coordinator/workspace.go +57 -6
@@ -1135,11 +1135,24 @@ func (c *WorkspaceCoordinator) syncStackedPaneTitles(ctx context.Context, stacke
11351135 return
11361136 }
11371137
1138+ // Get UI pane count to avoid index out of bounds when domain and UI are out of sync
1139+ uiPaneCount := stackedView.Count()
1140+
11381141 for i, child := range stackNode.Children {
11391142 if child.Pane == nil {
11401143 continue
11411144 }
11421145
1146+ // Skip if index exceeds UI pane count (domain/UI desync)
1147+ if i >= uiPaneCount {
1148+ log.Debug().
1149+ Int("index", i).
1150+ Int("ui_count", uiPaneCount).
1151+ Str("pane_id", string(child.Pane.ID)).
1152+ Msg("skipping title sync: index exceeds UI pane count")
1153+ continue
1154+ }
1155+
11431156 title := c.contentCoord.GetTitle(child.Pane.ID)
11441157 if title == "" {
11451158 title = child.Pane.Title
@@ -1798,11 +1811,13 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
17981811 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
17991812 }
18001813
1801- // Resolve or create stack node
1802- stackNode := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
1814+ // Resolve or create stack node (track conversion for potential rollback)
1815+ stackNode, conversionInfo := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
18031816
18041817 // Add popup pane to stack using use case
18051818 if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {
1819+ // Rollback stack conversion if we created a new stack
1820+ conversionInfo.revert()
18061821 return fmt.Errorf("failed to add popup to stack: %w", err)
18071822 }
18081823
@@ -1812,6 +1827,17 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
18121827 }
18131828
18141829 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
1830+ // UI update failed - rollback domain changes to maintain consistency
1831+ log.Warn().Err(err).Msg("rolling back domain changes due to UI failure")
1832+
1833+ // Remove popup pane from stack
1834+ if removeErr := c.panesUC.RemoveFromStack(ctx, stackNode, input.PopupPane.ID); removeErr != nil {
1835+ log.Error().Err(removeErr).Msg("failed to rollback popup pane addition")
1836+ }
1837+
1838+ // Revert stack conversion if we created a new stack
1839+ conversionInfo.revert()
1840+
18151841 return err
18161842 }
18171843
@@ -1823,14 +1849,35 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
18231849 return nil
18241850 }
18251851
1852+// stackConversionInfo holds state needed to revert a leaf-to-stack conversion.
1853+type stackConversionInfo struct {
1854+ wasConverted bool // true if resolveOrCreateStackNode converted a leaf to a stack
1855+ originalPane *entity.Pane // the pane that was in the node before conversion
1856+ node *entity.PaneNode // the node that was converted
1857+}
1858+
1859+// revert undoes a leaf-to-stack conversion.
1860+func (info *stackConversionInfo) revert() {
1861+ if !info.wasConverted || info.node == nil {
1862+ return
1863+ }
1864+
1865+ // Restore node to leaf state
1866+ info.node.Pane = info.originalPane
1867+ info.node.IsStacked = false
1868+ info.node.ActiveStackIndex = 0
1869+ info.node.Children = nil
1870+}
1871+
18261872 // resolveOrCreateStackNode finds an existing stack or creates one for popup insertion.
18271873 // Note: For popups, we can't use CreateStack use case since it creates a new pane,
18281874 // but we already have the popup pane. This conversion is a special case.
1875+// Returns the stack node and conversion info for potential rollback.
18291876 func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
18301877 ctx context.Context,
18311878 parentNode *entity.PaneNode,
18321879 parentPaneID entity.PaneID,
1833-) *entity.PaneNode {
1880+) (*entity.PaneNode, stackConversionInfo) {
18341881 log := logging.FromContext(ctx)
18351882
18361883 // Check if parent is already in a stack
@@ -1838,7 +1885,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
18381885 log.Debug().
18391886 Str("stack_id", parentNode.Parent.ID).
18401887 Msg("using existing parent stack for popup")
1841- return parentNode.Parent
1888+ return parentNode.Parent, stackConversionInfo{}
18421889 }
18431890
18441891 // Check if parent is already a stack container
@@ -1846,7 +1893,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
18461893 log.Debug().
18471894 Str("stack_id", parentNode.ID).
18481895 Msg("parent is already a stack container")
1849- return parentNode
1896+ return parentNode, stackConversionInfo{}
18501897 }
18511898
18521899 // Need to convert leaf to stack for popup insertion.
@@ -1880,7 +1927,11 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
18801927 Str("original_pane", string(originalPane.ID)).
18811928 Msg("converted leaf to stacked node for popup")
18821929
1883- return parentNode
1930+ return parentNode, stackConversionInfo{
1931+ wasConverted: true,
1932+ originalPane: originalPane,
1933+ node: parentNode,
1934+ }
18841935 }
18851936
18861937 func (c *WorkspaceCoordinator) attachPopupPaneView(
@@ -1135,11 +1135,24 @@ func (c *WorkspaceCoordinator) syncStackedPaneTitles(ctx context.Context, stacke
1135 return1135 return
1136 }1136 }
1137 1137
1138+ // Get UI pane count to avoid index out of bounds when domain and UI are out of sync
1139+ uiPaneCount := stackedView.Count()
1140+
1138 for i, child := range stackNode.Children {1141 for i, child := range stackNode.Children {
1139 if child.Pane == nil {1142 if child.Pane == nil {
1140 continue1143 continue
1141 }1144 }
1142 1145
1146+ // Skip if index exceeds UI pane count (domain/UI desync)
1147+ if i >= uiPaneCount {
1148+ log.Debug().
1149+ Int("index", i).
1150+ Int("ui_count", uiPaneCount).
1151+ Str("pane_id", string(child.Pane.ID)).
1152+ Msg("skipping title sync: index exceeds UI pane count")
1153+ continue
1154+ }
1155+
1143 title := c.contentCoord.GetTitle(child.Pane.ID)1156 title := c.contentCoord.GetTitle(child.Pane.ID)
1144 if title == "" {1157 if title == "" {
1145 title = child.Pane.Title1158 title = child.Pane.Title
@@ -1798,11 +1811,13 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
1798 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)1811 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1799 }1812 }
1800 1813
1801- // Resolve or create stack node1814+ // Resolve or create stack node (track conversion for potential rollback)
1802- stackNode := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)1815+ stackNode, conversionInfo := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
1803 1816
1804 // Add popup pane to stack using use case1817 // Add popup pane to stack using use case
1805 if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {1818 if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {
1819+ // Rollback stack conversion if we created a new stack
1820+ conversionInfo.revert()
1806 return fmt.Errorf("failed to add popup to stack: %w", err)1821 return fmt.Errorf("failed to add popup to stack: %w", err)
1807 }1822 }
1808 1823
@@ -1812,6 +1827,17 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
1812 }1827 }
1813 1828
1814 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {1829 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
1830+ // UI update failed - rollback domain changes to maintain consistency
1831+ log.Warn().Err(err).Msg("rolling back domain changes due to UI failure")
1832+
1833+ // Remove popup pane from stack
1834+ if removeErr := c.panesUC.RemoveFromStack(ctx, stackNode, input.PopupPane.ID); removeErr != nil {
1835+ log.Error().Err(removeErr).Msg("failed to rollback popup pane addition")
1836+ }
1837+
1838+ // Revert stack conversion if we created a new stack
1839+ conversionInfo.revert()
1840+
1815 return err1841 return err
1816 }1842 }
1817 1843
@@ -1823,14 +1849,35 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
1823 return nil1849 return nil
1824 }1850 }
1825 1851
1852+// stackConversionInfo holds state needed to revert a leaf-to-stack conversion.
1853+type stackConversionInfo struct {
1854+ wasConverted bool // true if resolveOrCreateStackNode converted a leaf to a stack
1855+ originalPane *entity.Pane // the pane that was in the node before conversion
1856+ node *entity.PaneNode // the node that was converted
1857+}
1858+
1859+// revert undoes a leaf-to-stack conversion.
1860+func (info *stackConversionInfo) revert() {
1861+ if !info.wasConverted || info.node == nil {
1862+ return
1863+ }
1864+
1865+ // Restore node to leaf state
1866+ info.node.Pane = info.originalPane
1867+ info.node.IsStacked = false
1868+ info.node.ActiveStackIndex = 0
1869+ info.node.Children = nil
1870+}
1871+
1826 // resolveOrCreateStackNode finds an existing stack or creates one for popup insertion.1872 // 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,1873 // 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.1874 // but we already have the popup pane. This conversion is a special case.
1875+// Returns the stack node and conversion info for potential rollback.
1829 func (c *WorkspaceCoordinator) resolveOrCreateStackNode(1876 func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1830 ctx context.Context,1877 ctx context.Context,
1831 parentNode *entity.PaneNode,1878 parentNode *entity.PaneNode,
1832 parentPaneID entity.PaneID,1879 parentPaneID entity.PaneID,
1833-) *entity.PaneNode {1880+) (*entity.PaneNode, stackConversionInfo) {
1834 log := logging.FromContext(ctx)1881 log := logging.FromContext(ctx)
1835 1882
1836 // Check if parent is already in a stack1883 // Check if parent is already in a stack
@@ -1838,7 +1885,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1838 log.Debug().1885 log.Debug().
1839 Str("stack_id", parentNode.Parent.ID).1886 Str("stack_id", parentNode.Parent.ID).
1840 Msg("using existing parent stack for popup")1887 Msg("using existing parent stack for popup")
1841- return parentNode.Parent1888+ return parentNode.Parent, stackConversionInfo{}
1842 }1889 }
1843 1890
1844 // Check if parent is already a stack container1891 // Check if parent is already a stack container
@@ -1846,7 +1893,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1846 log.Debug().1893 log.Debug().
1847 Str("stack_id", parentNode.ID).1894 Str("stack_id", parentNode.ID).
1848 Msg("parent is already a stack container")1895 Msg("parent is already a stack container")
1849- return parentNode1896+ return parentNode, stackConversionInfo{}
1850 }1897 }
1851 1898
1852 // Need to convert leaf to stack for popup insertion.1899 // Need to convert leaf to stack for popup insertion.
@@ -1880,7 +1927,11 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1880 Str("original_pane", string(originalPane.ID)).1927 Str("original_pane", string(originalPane.ID)).
1881 Msg("converted leaf to stacked node for popup")1928 Msg("converted leaf to stacked node for popup")
1882 1929
1883- return parentNode1930+ return parentNode, stackConversionInfo{
1931+ wasConverted: true,
1932+ originalPane: originalPane,
1933+ node: parentNode,
1934+ }
1884 }1935 }
1885 1936
1886 func (c *WorkspaceCoordinator) attachPopupPaneView(1937 func (c *WorkspaceCoordinator) attachPopupPaneView(

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