detached 03c91d2

feat(ui): implement incremental stack split with visible pane navigation

commit
03c91d21f120ef06cbf1dfd353ab824399175b70
author
brice · · 2025-12-15 22:37 UTC
committer
brice · · 2025-12-15 22:18 UTC
parent
05af959
signature
unsigned
4 files · +150 -11
M internal/application/usecase/manage_panes.go +8 -4
@@ -72,11 +72,15 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
7272 return nil, fmt.Errorf("target pane is required")
7373 }
7474
75- // If target is stacked, split around the entire stack
75+ // If target is inside a stack and splitting left/right, split around the entire stack
76+ // (i.e., create the new pane as a sibling to the stack, not inside it)
7677 targetNode := input.TargetPane
77- if targetNode.IsStacked {
78- // Find the stack container
79- log.Debug().Msg("target is stacked, splitting around stack")
78+ if input.Direction == SplitLeft || input.Direction == SplitRight {
79+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
80+ // Promote to the stack container so split happens outside the stack
81+ targetNode = targetNode.Parent
82+ log.Debug().Msg("target is inside a stack, splitting around stack container")
83+ }
8084 }
8185
8286 // Create new pane
@@ -72,11 +72,15 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
72 return nil, fmt.Errorf("target pane is required")72 return nil, fmt.Errorf("target pane is required")
73 }73 }
74 74
75- // If target is stacked, split around the entire stack75+ // If target is inside a stack and splitting left/right, split around the entire stack
76+ // (i.e., create the new pane as a sibling to the stack, not inside it)
76 targetNode := input.TargetPane77 targetNode := input.TargetPane
77- if targetNode.IsStacked {78+ if input.Direction == SplitLeft || input.Direction == SplitRight {
78- // Find the stack container79+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
79- log.Debug().Msg("target is stacked, splitting around stack")80+ // Promote to the stack container so split happens outside the stack
81+ targetNode = targetNode.Parent
82+ log.Debug().Msg("target is inside a stack, splitting around stack container")
83+ }
80 }84 }
81 85
82 // Create new pane86 // Create new pane
M internal/ui/app.go +126 -5
@@ -587,6 +587,25 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect
587587 return nil
588588 }
589589
590+ wsView := a.activeWorkspaceView()
591+
592+ // Check if we're splitting from inside a stack (horizontal split around stack)
593+ isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
594+ activePane.Parent != nil && activePane.Parent.IsStacked
595+
596+ // Get the existing StackedView BEFORE domain changes (for incremental operation)
597+ var existingStackWidget layout.Widget
598+ if isStackSplit && wsView != nil {
599+ tr := wsView.TreeRenderer()
600+ if tr != nil {
601+ // Get any pane's StackedView from the stack - they all map to the same one
602+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
603+ if stackedView != nil {
604+ existingStackWidget = stackedView.Widget()
605+ }
606+ }
607+ }
608+
590609 output, err := a.panesUC.Split(ctx, usecase.SplitPaneInput{
591610 Workspace: ws,
592611 TargetPane: activePane,
@@ -597,16 +616,31 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect
597616 return err
598617 }
599618
619+ // Remember old active pane before changing
620+ oldActivePaneID := activePane.Pane.ID
621+
600622 // Set the new pane as active
601623 ws.ActivePaneID = output.NewPaneNode.Pane.ID
602624
603- // Rebuild the workspace view
604- wsView := a.activeWorkspaceView()
625+ // Update the workspace view
605626 if wsView != nil {
606- if err := wsView.Rebuild(); err != nil {
607- log.Error().Err(err).Msg("failed to rebuild workspace view")
627+ if isStackSplit && existingStackWidget != nil {
628+ // Incremental split: reuse existing stack widget
629+ if err := a.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingStackWidget, oldActivePaneID); err != nil {
630+ log.Warn().Err(err).Msg("incremental split failed, falling back to rebuild")
631+ // Fallback to full rebuild
632+ if err := wsView.Rebuild(); err != nil {
633+ log.Error().Err(err).Msg("failed to rebuild workspace view")
634+ }
635+ a.attachWorkspaceWebViews(ctx, ws, wsView)
636+ }
637+ } else {
638+ // Regular split: full rebuild
639+ if err := wsView.Rebuild(); err != nil {
640+ log.Error().Err(err).Msg("failed to rebuild workspace view")
641+ }
642+ a.attachWorkspaceWebViews(ctx, ws, wsView)
608643 }
609- a.attachWorkspaceWebViews(ctx, ws, wsView)
610644 wsView.FocusPane(ws.ActivePaneID)
611645 }
612646
@@ -615,6 +649,93 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect
615649 return nil
616650 }
617651
652+// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
653+// This preserves existing WebViews by reusing the existing StackedView widget and creating
654+// a new SplitView around it, rather than rebuilding the entire widget tree.
655+func (a *App) doIncrementalStackSplit(
656+ ctx context.Context,
657+ wsView *component.WorkspaceView,
658+ ws *entity.Workspace,
659+ output *usecase.SplitPaneOutput,
660+ direction usecase.SplitDirection,
661+ existingStackWidget layout.Widget,
662+ oldActivePaneID entity.PaneID,
663+) error {
664+ log := logging.FromContext(ctx)
665+ factory := wsView.Factory()
666+
667+ log.Debug().
668+ Str("direction", string(direction)).
669+ Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
670+ Str("old_active_pane_id", string(oldActivePaneID)).
671+ Msg("performing incremental stack split")
672+
673+ // 1. Deactivate the old active pane (in the stack)
674+ if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
675+ oldPaneView.SetActive(false)
676+ }
677+
678+ // 2. Unparent the existing stack widget from the workspace container
679+ wsView.Container().Remove(existingStackWidget)
680+
681+ // 2. Create new PaneView for the new pane (without WebView - will attach later)
682+ newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
683+
684+ // 3. Wrap the new PaneView in a StackedView
685+ newStackedView := layout.NewStackedView(factory)
686+ newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
687+
688+ // 4. Create a SplitView with the appropriate orientation and child placement
689+ var splitView *layout.SplitView
690+ if direction == usecase.SplitLeft {
691+ // New pane goes on the left, existing stack on the right
692+ splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
693+ newStackedView.Widget(), existingStackWidget, output.SplitRatio)
694+ } else {
695+ // New pane goes on the right, existing stack on the left (SplitRight)
696+ splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
697+ existingStackWidget, newStackedView.Widget(), output.SplitRatio)
698+ }
699+
700+ // 5. Replace the root widget in the workspace view
701+ wsView.SetRootWidgetDirect(splitView.Widget())
702+
703+ // 6. Register the new pane in tracking maps and activate it
704+ wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
705+ newPaneView.SetActive(true)
706+
707+ tr := wsView.TreeRenderer()
708+ if tr != nil {
709+ // Register the new pane's StackedView mapping
710+ tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
711+ // Register the split node widget
712+ tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
713+ }
714+
715+ // 7. Attach WebView only for the new pane
716+ wv, err := a.ensureWebViewForPane(ctx, output.NewPaneNode.Pane.ID)
717+ if err != nil {
718+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
719+ return err
720+ }
721+
722+ // Load the default URL for new pane
723+ newPaneEntity := output.NewPaneNode.Pane
724+ if newPaneEntity.URI != "" {
725+ if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
726+ log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
727+ }
728+ }
729+
730+ widget := a.wrapWebViewWidget(ctx, wv)
731+ if widget != nil {
732+ newPaneView.SetWebViewWidget(widget)
733+ }
734+
735+ log.Debug().Msg("incremental stack split completed successfully")
736+ return nil
737+}
738+
618739 // handleClosePane closes the active pane.
619740 func (a *App) handleClosePane(ctx context.Context) error {
620741 log := logging.FromContext(ctx)
@@ -587,6 +587,25 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect
587 return nil587 return nil
588 }588 }
589 589
590+ wsView := a.activeWorkspaceView()
591+
592+ // Check if we're splitting from inside a stack (horizontal split around stack)
593+ isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
594+ activePane.Parent != nil && activePane.Parent.IsStacked
595+
596+ // Get the existing StackedView BEFORE domain changes (for incremental operation)
597+ var existingStackWidget layout.Widget
598+ if isStackSplit && wsView != nil {
599+ tr := wsView.TreeRenderer()
600+ if tr != nil {
601+ // Get any pane's StackedView from the stack - they all map to the same one
602+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
603+ if stackedView != nil {
604+ existingStackWidget = stackedView.Widget()
605+ }
606+ }
607+ }
608+
590 output, err := a.panesUC.Split(ctx, usecase.SplitPaneInput{609 output, err := a.panesUC.Split(ctx, usecase.SplitPaneInput{
591 Workspace: ws,610 Workspace: ws,
592 TargetPane: activePane,611 TargetPane: activePane,
@@ -597,16 +616,31 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect
597 return err616 return err
598 }617 }
599 618
619+ // Remember old active pane before changing
620+ oldActivePaneID := activePane.Pane.ID
621+
600 // Set the new pane as active622 // Set the new pane as active
601 ws.ActivePaneID = output.NewPaneNode.Pane.ID623 ws.ActivePaneID = output.NewPaneNode.Pane.ID
602 624
603- // Rebuild the workspace view625+ // Update the workspace view
604- wsView := a.activeWorkspaceView()
605 if wsView != nil {626 if wsView != nil {
606- if err := wsView.Rebuild(); err != nil {627+ if isStackSplit && existingStackWidget != nil {
607- log.Error().Err(err).Msg("failed to rebuild workspace view")628+ // Incremental split: reuse existing stack widget
629+ if err := a.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingStackWidget, oldActivePaneID); err != nil {
630+ log.Warn().Err(err).Msg("incremental split failed, falling back to rebuild")
631+ // Fallback to full rebuild
632+ if err := wsView.Rebuild(); err != nil {
633+ log.Error().Err(err).Msg("failed to rebuild workspace view")
634+ }
635+ a.attachWorkspaceWebViews(ctx, ws, wsView)
636+ }
637+ } else {
638+ // Regular split: full rebuild
639+ if err := wsView.Rebuild(); err != nil {
640+ log.Error().Err(err).Msg("failed to rebuild workspace view")
641+ }
642+ a.attachWorkspaceWebViews(ctx, ws, wsView)
608 }643 }
609- a.attachWorkspaceWebViews(ctx, ws, wsView)
610 wsView.FocusPane(ws.ActivePaneID)644 wsView.FocusPane(ws.ActivePaneID)
611 }645 }
612 646
@@ -615,6 +649,93 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect
615 return nil649 return nil
616 }650 }
617 651
652+// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
653+// This preserves existing WebViews by reusing the existing StackedView widget and creating
654+// a new SplitView around it, rather than rebuilding the entire widget tree.
655+func (a *App) doIncrementalStackSplit(
656+ ctx context.Context,
657+ wsView *component.WorkspaceView,
658+ ws *entity.Workspace,
659+ output *usecase.SplitPaneOutput,
660+ direction usecase.SplitDirection,
661+ existingStackWidget layout.Widget,
662+ oldActivePaneID entity.PaneID,
663+) error {
664+ log := logging.FromContext(ctx)
665+ factory := wsView.Factory()
666+
667+ log.Debug().
668+ Str("direction", string(direction)).
669+ Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
670+ Str("old_active_pane_id", string(oldActivePaneID)).
671+ Msg("performing incremental stack split")
672+
673+ // 1. Deactivate the old active pane (in the stack)
674+ if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
675+ oldPaneView.SetActive(false)
676+ }
677+
678+ // 2. Unparent the existing stack widget from the workspace container
679+ wsView.Container().Remove(existingStackWidget)
680+
681+ // 2. Create new PaneView for the new pane (without WebView - will attach later)
682+ newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
683+
684+ // 3. Wrap the new PaneView in a StackedView
685+ newStackedView := layout.NewStackedView(factory)
686+ newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
687+
688+ // 4. Create a SplitView with the appropriate orientation and child placement
689+ var splitView *layout.SplitView
690+ if direction == usecase.SplitLeft {
691+ // New pane goes on the left, existing stack on the right
692+ splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
693+ newStackedView.Widget(), existingStackWidget, output.SplitRatio)
694+ } else {
695+ // New pane goes on the right, existing stack on the left (SplitRight)
696+ splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
697+ existingStackWidget, newStackedView.Widget(), output.SplitRatio)
698+ }
699+
700+ // 5. Replace the root widget in the workspace view
701+ wsView.SetRootWidgetDirect(splitView.Widget())
702+
703+ // 6. Register the new pane in tracking maps and activate it
704+ wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
705+ newPaneView.SetActive(true)
706+
707+ tr := wsView.TreeRenderer()
708+ if tr != nil {
709+ // Register the new pane's StackedView mapping
710+ tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
711+ // Register the split node widget
712+ tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
713+ }
714+
715+ // 7. Attach WebView only for the new pane
716+ wv, err := a.ensureWebViewForPane(ctx, output.NewPaneNode.Pane.ID)
717+ if err != nil {
718+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
719+ return err
720+ }
721+
722+ // Load the default URL for new pane
723+ newPaneEntity := output.NewPaneNode.Pane
724+ if newPaneEntity.URI != "" {
725+ if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
726+ log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
727+ }
728+ }
729+
730+ widget := a.wrapWebViewWidget(ctx, wv)
731+ if widget != nil {
732+ newPaneView.SetWebViewWidget(widget)
733+ }
734+
735+ log.Debug().Msg("incremental stack split completed successfully")
736+ return nil
737+}
738+
618 // handleClosePane closes the active pane.739 // handleClosePane closes the active pane.
619 func (a *App) handleClosePane(ctx context.Context) error {740 func (a *App) handleClosePane(ctx context.Context) error {
620 log := logging.FromContext(ctx)741 log := logging.FromContext(ctx)
M internal/ui/focus/manager.go +10 -2
@@ -37,18 +37,26 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
3737 // CollectPaneRects gathers geometry from all visible panes.
3838 // For panes in stacks, uses the stack container's geometry since individual
3939 // collapsed panes have no allocated size.
40+// Only visible panes are included - for stacked panes, only the active one is visible.
4041 func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {
4142 var rects []entity.PaneRect
4243
4344 container := provider.ContainerWidget()
4445
4546 for _, paneID := range provider.GetPaneIDs() {
46- // First check if pane is in a stack - use stack container geometry
47+ paneWidget := provider.GetPaneWidget(paneID)
48+
49+ // Skip panes that aren't visible (includes inactive stacked panes)
50+ if paneWidget == nil || !paneWidget.IsVisible() {
51+ continue
52+ }
53+
54+ // For stacked panes, use stack container geometry (pane itself has no size)
4755 var widget layout.Widget
4856 if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil {
4957 widget = stackWidget
5058 } else {
51- widget = provider.GetPaneWidget(paneID)
59+ widget = paneWidget
5260 }
5361
5462 if widget == nil || !widget.IsVisible() {
@@ -37,18 +37,26 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
37 // CollectPaneRects gathers geometry from all visible panes.37 // CollectPaneRects gathers geometry from all visible panes.
38 // For panes in stacks, uses the stack container's geometry since individual38 // For panes in stacks, uses the stack container's geometry since individual
39 // collapsed panes have no allocated size.39 // collapsed panes have no allocated size.
40+// Only visible panes are included - for stacked panes, only the active one is visible.
40 func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {41 func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {
41 var rects []entity.PaneRect42 var rects []entity.PaneRect
42 43
43 container := provider.ContainerWidget()44 container := provider.ContainerWidget()
44 45
45 for _, paneID := range provider.GetPaneIDs() {46 for _, paneID := range provider.GetPaneIDs() {
46- // First check if pane is in a stack - use stack container geometry47+ paneWidget := provider.GetPaneWidget(paneID)
48+
49+ // Skip panes that aren't visible (includes inactive stacked panes)
50+ if paneWidget == nil || !paneWidget.IsVisible() {
51+ continue
52+ }
53+
54+ // For stacked panes, use stack container geometry (pane itself has no size)
47 var widget layout.Widget55 var widget layout.Widget
48 if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil {56 if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil {
49 widget = stackWidget57 widget = stackWidget
50 } else {58 } else {
51- widget = provider.GetPaneWidget(paneID)59+ widget = paneWidget
52 }60 }
53 61
54 if widget == nil || !widget.IsVisible() {62 if widget == nil || !widget.IsVisible() {
M internal/ui/layout/tree.go +6 -0
@@ -197,6 +197,12 @@ func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) {
197197 }
198198
199199 stackedView.AddPane(tr.ctx, title, favicon, childWidget)
200+
201+ // Override paneToStack to point to THIS stacked view, not the child's wrapper.
202+ // This ensures geometric navigation and stack sync work correctly.
203+ if child.Pane != nil {
204+ tr.paneToStack[string(child.Pane.ID)] = stackedView
205+ }
200206 }
201207
202208 // Set active index
@@ -197,6 +197,12 @@ func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) {
197 }197 }
198 198
199 stackedView.AddPane(tr.ctx, title, favicon, childWidget)199 stackedView.AddPane(tr.ctx, title, favicon, childWidget)
200+
201+ // Override paneToStack to point to THIS stacked view, not the child's wrapper.
202+ // This ensures geometric navigation and stack sync work correctly.
203+ if child.Pane != nil {
204+ tr.paneToStack[string(child.Pane.ID)] = stackedView
205+ }
200 }206 }
201 207
202 // Set active index208 // Set active index

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