detached 739acba

feat(ui): implement incremental stacked pane operations with title tracking

- Add dynamic pane title tracking with paneTitles map and RWMutex for thread-safe access
- Implement handleStackPane to convert leaf panes to stacked containers incrementally
- Wire title bar click callback to switch between collapsed panes in stacked views
- Add handleStackNavigate for stack navigation (up/down actions)
- Create StackedPaneManager for incremental widget operations on stacked panes
- Fix terminology: change 'New Tab' to 'Untitled' since tabs hold workspaces
- Fix split pane regression: default new panes from splits to 'about:blank' URI
- Update ManagePanesUseCase with CreateStack and AddToStack returning structured output
- Register title tracking with keyboard handler and add stack navigation actions
commit
739acba460ecc551a935ff5198db380059b398dc
author
brice · · 2025-12-16 06:21 UTC
parent
385e5a2
signature
unsigned
8 files · +978 -63
M internal/application/usecase/manage_panes.go +94 -29
@@ -87,6 +87,8 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
8787 newPane = entity.NewPane(paneID)
8888 if input.InitialURL != "" {
8989 newPane.URI = input.InitialURL
90+ } else {
91+ newPane.URI = "about:blank"
9092 }
9193 }
9294
@@ -381,63 +383,119 @@ func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd
381383 return nil
382384 }
383385
384-// CreateStack converts a pane into a stacked container.
385-func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) error {
386+// CreateStackOutput contains the result of creating a stack from a pane.
387+type CreateStackOutput struct {
388+ StackNode *entity.PaneNode // The stack container node
389+ OriginalNode *entity.PaneNode // The original pane, now a child of the stack
390+ NewPaneNode *entity.PaneNode // The new pane added to the stack
391+ NewPane *entity.Pane // The new pane entity
392+}
393+
394+// CreateStack converts a pane into a stacked container and adds a new pane.
395+// The original pane becomes the first child, a new pane becomes the second child.
396+// Returns the stack node and the new pane node for UI operations.
397+//
398+// Domain tree transformation:
399+//
400+// Before: parent -> targetNode (leaf with pane)
401+// After: parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)]
402+//
403+// The targetNode keeps its ID but becomes a stack container.
404+// This allows the UI layer to update widget mappings incrementally.
405+func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*CreateStackOutput, error) {
386406 log := logging.FromContext(ctx)
387407 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
388408
389409 if ws == nil {
390- return fmt.Errorf("workspace is required")
410+ return nil, fmt.Errorf("workspace is required")
391411 }
392412 if paneNode == nil {
393- return fmt.Errorf("pane node is required")
413+ return nil, fmt.Errorf("pane node is required")
394414 }
395415 if !paneNode.IsLeaf() {
396- return fmt.Errorf("can only create stack from leaf pane")
416+ return nil, fmt.Errorf("can only create stack from leaf pane")
397417 }
398418 if paneNode.IsStacked {
399- return fmt.Errorf("pane is already stacked")
419+ return nil, fmt.Errorf("pane is already stacked")
400420 }
401421
402- // Convert to stacked container
403- paneNode.IsStacked = true
404- paneNode.ActiveStackIndex = 0
422+ // Save original pane reference
423+ originalPane := paneNode.Pane
424+ originalPaneID := paneNode.ID
405425
406- // The pane becomes a child of itself conceptually
407- // Create a child node for the actual pane
408- childNode := &entity.PaneNode{
409- ID: paneNode.ID + "_child",
410- Pane: paneNode.Pane,
426+ // Create child node for the original pane
427+ originalChildNode := &entity.PaneNode{
428+ ID: originalPaneID + "_0",
429+ Pane: originalPane,
411430 Parent: paneNode,
412431 }
432+
433+ // Create new pane
434+ newPaneID := entity.PaneID(uc.idGenerator())
435+ newPane := entity.NewPane(newPaneID)
436+
437+ // Create child node for the new pane
438+ newChildNode := &entity.PaneNode{
439+ ID: string(newPaneID),
440+ Pane: newPane,
441+ Parent: paneNode,
442+ }
443+
444+ // Convert targetNode to stack container
413445 paneNode.Pane = nil
414- paneNode.Children = []*entity.PaneNode{childNode}
446+ paneNode.IsStacked = true
447+ paneNode.ActiveStackIndex = 1 // New pane is active
448+ paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode}
415449
416- log.Info().Str("stack_id", paneNode.ID).Msg("stack created")
417- return nil
450+ // Update workspace active pane
451+ ws.ActivePaneID = newPaneID
452+
453+ log.Info().
454+ Str("stack_id", paneNode.ID).
455+ Str("original_pane", string(originalPane.ID)).
456+ Str("new_pane", string(newPaneID)).
457+ Msg("stack created")
458+
459+ return &CreateStackOutput{
460+ StackNode: paneNode,
461+ OriginalNode: originalChildNode,
462+ NewPaneNode: newChildNode,
463+ NewPane: newPane,
464+ }, nil
418465 }
419466
420-// AddToStack adds a pane to an existing stack.
421-func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) error {
467+// AddToStackOutput contains the result of adding a pane to a stack.
468+type AddToStackOutput struct {
469+ NewPaneNode *entity.PaneNode // The new pane node added to the stack
470+ StackIndex int // Index of the new pane in the stack
471+}
472+
473+// AddToStack adds a new pane to an existing stack.
474+// Optionally pass a pre-created pane, or nil to create a new one.
475+func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) (*AddToStackOutput, error) {
422476 log := logging.FromContext(ctx)
423- log.Debug().
424- Str("stack_id", stackNode.ID).
425- Str("pane_id", string(pane.ID)).
426- Msg("adding pane to stack")
427477
428478 if ws == nil {
429- return fmt.Errorf("workspace is required")
479+ return nil, fmt.Errorf("workspace is required")
430480 }
431481 if stackNode == nil {
432- return fmt.Errorf("stack node is required")
482+ return nil, fmt.Errorf("stack node is required")
433483 }
434484 if !stackNode.IsStacked {
435- return fmt.Errorf("node is not a stack")
485+ return nil, fmt.Errorf("node is not a stack")
436486 }
487+
488+ // Create pane if not provided
437489 if pane == nil {
438- return fmt.Errorf("pane is required")
490+ paneID := entity.PaneID(uc.idGenerator())
491+ pane = entity.NewPane(paneID)
439492 }
440493
494+ log.Debug().
495+ Str("stack_id", stackNode.ID).
496+ Str("pane_id", string(pane.ID)).
497+ Msg("adding pane to stack")
498+
441499 // Create node for the new pane
442500 newNode := &entity.PaneNode{
443501 ID: string(pane.ID),
@@ -447,7 +505,11 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa
447505
448506 // Add to stack
449507 stackNode.Children = append(stackNode.Children, newNode)
450- stackNode.ActiveStackIndex = len(stackNode.Children) - 1
508+ newIndex := len(stackNode.Children) - 1
509+ stackNode.ActiveStackIndex = newIndex
510+
511+ // Update workspace active pane
512+ ws.ActivePaneID = pane.ID
451513
452514 log.Info().
453515 Str("stack_id", stackNode.ID).
@@ -455,7 +517,10 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa
455517 Int("stack_size", len(stackNode.Children)).
456518 Msg("pane added to stack")
457519
458- return nil
520+ return &AddToStackOutput{
521+ NewPaneNode: newNode,
522+ StackIndex: newIndex,
523+ }, nil
459524 }
460525
461526 // NavigateStack cycles through stacked panes.
@@ -87,6 +87,8 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
87 newPane = entity.NewPane(paneID)87 newPane = entity.NewPane(paneID)
88 if input.InitialURL != "" {88 if input.InitialURL != "" {
89 newPane.URI = input.InitialURL89 newPane.URI = input.InitialURL
90+ } else {
91+ newPane.URI = "about:blank"
90 }92 }
91 }93 }
92 94
@@ -381,63 +383,119 @@ func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd
381 return nil383 return nil
382 }384 }
383 385
384-// CreateStack converts a pane into a stacked container.386+// CreateStackOutput contains the result of creating a stack from a pane.
385-func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) error {387+type CreateStackOutput struct {
388+ StackNode *entity.PaneNode // The stack container node
389+ OriginalNode *entity.PaneNode // The original pane, now a child of the stack
390+ NewPaneNode *entity.PaneNode // The new pane added to the stack
391+ NewPane *entity.Pane // The new pane entity
392+}
393+
394+// CreateStack converts a pane into a stacked container and adds a new pane.
395+// The original pane becomes the first child, a new pane becomes the second child.
396+// Returns the stack node and the new pane node for UI operations.
397+//
398+// Domain tree transformation:
399+//
400+// Before: parent -> targetNode (leaf with pane)
401+// After: parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)]
402+//
403+// The targetNode keeps its ID but becomes a stack container.
404+// This allows the UI layer to update widget mappings incrementally.
405+func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*CreateStackOutput, error) {
386 log := logging.FromContext(ctx)406 log := logging.FromContext(ctx)
387 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")407 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
388 408
389 if ws == nil {409 if ws == nil {
390- return fmt.Errorf("workspace is required")410+ return nil, fmt.Errorf("workspace is required")
391 }411 }
392 if paneNode == nil {412 if paneNode == nil {
393- return fmt.Errorf("pane node is required")413+ return nil, fmt.Errorf("pane node is required")
394 }414 }
395 if !paneNode.IsLeaf() {415 if !paneNode.IsLeaf() {
396- return fmt.Errorf("can only create stack from leaf pane")416+ return nil, fmt.Errorf("can only create stack from leaf pane")
397 }417 }
398 if paneNode.IsStacked {418 if paneNode.IsStacked {
399- return fmt.Errorf("pane is already stacked")419+ return nil, fmt.Errorf("pane is already stacked")
400 }420 }
401 421
402- // Convert to stacked container422+ // Save original pane reference
403- paneNode.IsStacked = true423+ originalPane := paneNode.Pane
404- paneNode.ActiveStackIndex = 0424+ originalPaneID := paneNode.ID
405 425
406- // The pane becomes a child of itself conceptually426+ // Create child node for the original pane
407- // Create a child node for the actual pane427+ originalChildNode := &entity.PaneNode{
408- childNode := &entity.PaneNode{428+ ID: originalPaneID + "_0",
409- ID: paneNode.ID + "_child",429+ Pane: originalPane,
410- Pane: paneNode.Pane,
411 Parent: paneNode,430 Parent: paneNode,
412 }431 }
432+
433+ // Create new pane
434+ newPaneID := entity.PaneID(uc.idGenerator())
435+ newPane := entity.NewPane(newPaneID)
436+
437+ // Create child node for the new pane
438+ newChildNode := &entity.PaneNode{
439+ ID: string(newPaneID),
440+ Pane: newPane,
441+ Parent: paneNode,
442+ }
443+
444+ // Convert targetNode to stack container
413 paneNode.Pane = nil445 paneNode.Pane = nil
414- paneNode.Children = []*entity.PaneNode{childNode}446+ paneNode.IsStacked = true
447+ paneNode.ActiveStackIndex = 1 // New pane is active
448+ paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode}
415 449
416- log.Info().Str("stack_id", paneNode.ID).Msg("stack created")450+ // Update workspace active pane
417- return nil451+ ws.ActivePaneID = newPaneID
452+
453+ log.Info().
454+ Str("stack_id", paneNode.ID).
455+ Str("original_pane", string(originalPane.ID)).
456+ Str("new_pane", string(newPaneID)).
457+ Msg("stack created")
458+
459+ return &CreateStackOutput{
460+ StackNode: paneNode,
461+ OriginalNode: originalChildNode,
462+ NewPaneNode: newChildNode,
463+ NewPane: newPane,
464+ }, nil
418 }465 }
419 466
420-// AddToStack adds a pane to an existing stack.467+// AddToStackOutput contains the result of adding a pane to a stack.
421-func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) error {468+type AddToStackOutput struct {
469+ NewPaneNode *entity.PaneNode // The new pane node added to the stack
470+ StackIndex int // Index of the new pane in the stack
471+}
472+
473+// AddToStack adds a new pane to an existing stack.
474+// Optionally pass a pre-created pane, or nil to create a new one.
475+func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) (*AddToStackOutput, error) {
422 log := logging.FromContext(ctx)476 log := logging.FromContext(ctx)
423- log.Debug().
424- Str("stack_id", stackNode.ID).
425- Str("pane_id", string(pane.ID)).
426- Msg("adding pane to stack")
427 477
428 if ws == nil {478 if ws == nil {
429- return fmt.Errorf("workspace is required")479+ return nil, fmt.Errorf("workspace is required")
430 }480 }
431 if stackNode == nil {481 if stackNode == nil {
432- return fmt.Errorf("stack node is required")482+ return nil, fmt.Errorf("stack node is required")
433 }483 }
434 if !stackNode.IsStacked {484 if !stackNode.IsStacked {
435- return fmt.Errorf("node is not a stack")485+ return nil, fmt.Errorf("node is not a stack")
436 }486 }
487+
488+ // Create pane if not provided
437 if pane == nil {489 if pane == nil {
438- return fmt.Errorf("pane is required")490+ paneID := entity.PaneID(uc.idGenerator())
491+ pane = entity.NewPane(paneID)
439 }492 }
440 493
494+ log.Debug().
495+ Str("stack_id", stackNode.ID).
496+ Str("pane_id", string(pane.ID)).
497+ Msg("adding pane to stack")
498+
441 // Create node for the new pane499 // Create node for the new pane
442 newNode := &entity.PaneNode{500 newNode := &entity.PaneNode{
443 ID: string(pane.ID),501 ID: string(pane.ID),
@@ -447,7 +505,11 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa
447 505
448 // Add to stack506 // Add to stack
449 stackNode.Children = append(stackNode.Children, newNode)507 stackNode.Children = append(stackNode.Children, newNode)
450- stackNode.ActiveStackIndex = len(stackNode.Children) - 1508+ newIndex := len(stackNode.Children) - 1
509+ stackNode.ActiveStackIndex = newIndex
510+
511+ // Update workspace active pane
512+ ws.ActivePaneID = pane.ID
451 513
452 log.Info().514 log.Info().
453 Str("stack_id", stackNode.ID).515 Str("stack_id", stackNode.ID).
@@ -455,7 +517,10 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa
455 Int("stack_size", len(stackNode.Children)).517 Int("stack_size", len(stackNode.Children)).
456 Msg("pane added to stack")518 Msg("pane added to stack")
457 519
458- return nil520+ return &AddToStackOutput{
521+ NewPaneNode: newNode,
522+ StackIndex: newIndex,
523+ }, nil
459 }524 }
460 525
461 // NavigateStack cycles through stacked panes.526 // NavigateStack cycles through stacked panes.
M internal/ui/app.go +414 -13
@@ -39,6 +39,7 @@ type App struct {
3939 panesUC *usecase.ManagePanesUseCase
4040 workspaceViews map[entity.TabID]*component.WorkspaceView
4141 widgetFactory layout.WidgetFactory
42+ stackedPaneMgr *component.StackedPaneManager
4243
4344 // Input handling
4445 keyboardHandler *input.KeyboardHandler
@@ -47,11 +48,13 @@ type App struct {
4748 omnibox *component.Omnibox
4849
4950 // Web content
50- pool *webkit.WebViewPool
51- injector *webkit.ContentInjector
52- router *webkit.MessageRouter
53- settings *webkit.SettingsManager
54- webViews map[entity.PaneID]*webkit.WebView
51+ pool *webkit.WebViewPool
52+ injector *webkit.ContentInjector
53+ router *webkit.MessageRouter
54+ settings *webkit.SettingsManager
55+ webViews map[entity.PaneID]*webkit.WebView
56+ paneTitles map[entity.PaneID]string // Dynamic title tracking
57+ titleMu sync.RWMutex
5558
5659 // ID generator for tabs/panes
5760 idCounter uint64
@@ -80,6 +83,7 @@ func New(deps *Dependencies) (*App, error) {
8083 router: deps.MessageRouter,
8184 settings: deps.Settings,
8285 webViews: make(map[entity.PaneID]*webkit.WebView),
86+ paneTitles: make(map[entity.PaneID]string),
8387 cancel: cancel,
8488 }
8589 if app.router == nil {
@@ -149,6 +153,9 @@ func (a *App) onActivate(ctx context.Context) {
149153 // Initialize widget factory for pane layout
150154 a.widgetFactory = layout.NewGtkWidgetFactory()
151155
156+ // Initialize stacked pane manager for incremental widget operations
157+ a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
158+
152159 // Create keyboard handler
153160 a.keyboardHandler = input.NewKeyboardHandler(
154161 ctx,
@@ -311,6 +318,12 @@ func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) err
311318 case input.ActionFocusDown:
312319 return a.handlePaneFocus(ctx, usecase.NavDown)
313320
321+ // Stack navigation
322+ case input.ActionStackNavUp:
323+ return a.handleStackNavigate(ctx, "up")
324+ case input.ActionStackNavDown:
325+ return a.handleStackNavigate(ctx, "down")
326+
314327 // Navigation (stub implementations for now)
315328 case input.ActionGoBack:
316329 log.Debug().Msg("go back action (not yet implemented)")
@@ -670,20 +683,322 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir
670683 return nil
671684 }
672685
673-// handleStackPane converts the active pane into a stacked container.
674-// TODO: Implement incremental widget operations for stacked panes (like old repo).
675-// Currently, stacked panes require incremental GTK widget manipulation rather than
676-// full tree rebuilds because WebViews maintain state and parent references.
686+// handleStackPane adds a new pane stacked on top of the active pane.
687+// This modifies both the domain tree and the UI StackedView.
677688 func (a *App) handleStackPane(ctx context.Context) error {
678689 log := logging.FromContext(ctx)
679690
680- // Stacked panes are not yet fully supported in the new architecture.
681- // The rebuild approach conflicts with WebView's stateful widget lifecycle.
682- // See dumber-refs/internal/app/browser/stacked_panes.go for incremental approach.
683- log.Warn().Msg("stacked panes not yet implemented in clean architecture - requires incremental widget operations")
691+ if a.stackedPaneMgr == nil {
692+ log.Warn().Msg("stacked pane manager not available")
693+ return nil
694+ }
695+
696+ ws := a.activeWorkspace()
697+ if ws == nil {
698+ log.Warn().Msg("no active workspace")
699+ return nil
700+ }
701+
702+ activeNode := ws.ActivePane()
703+ if activeNode == nil || activeNode.Pane == nil {
704+ log.Warn().Msg("no active pane")
705+ return nil
706+ }
707+
708+ wsView := a.activeWorkspaceView()
709+ if wsView == nil {
710+ log.Warn().Msg("no workspace view")
711+ return nil
712+ }
713+
714+ activePaneID := activeNode.Pane.ID
715+
716+ // Create a new pane entity
717+ newPaneID := entity.PaneID(a.generateID())
718+ newPane := entity.NewPane(newPaneID)
719+ newPane.URI = "about:blank"
720+ newPane.Title = "Untitled"
721+
722+ // Get the original pane's current title
723+ originalTitle := a.getPaneTitle(activePaneID)
724+ if originalTitle == "" {
725+ originalTitle = activeNode.Pane.Title
726+ }
727+ if originalTitle == "" {
728+ originalTitle = "Untitled"
729+ }
730+
731+ // Update domain model: convert leaf to stacked if needed, add new pane
732+ var stackNode *entity.PaneNode
733+ var needsFirstPaneTitleUpdate bool
734+ if activeNode.IsStacked {
735+ // Already stacked, just add to it
736+ stackNode = activeNode
737+ } else {
738+ // Convert leaf node to stacked container
739+ // Move current pane to a child node
740+ originalPane := activeNode.Pane
741+ originalPane.Title = originalTitle // Ensure title is set
742+ originalPaneChild := &entity.PaneNode{
743+ ID: activeNode.ID + "_0",
744+ Pane: originalPane,
745+ Parent: activeNode,
746+ }
747+
748+ activeNode.Pane = nil // No longer a leaf
749+ activeNode.IsStacked = true
750+ activeNode.Children = []*entity.PaneNode{originalPaneChild}
751+ stackNode = activeNode
752+ needsFirstPaneTitleUpdate = true
753+
754+ log.Debug().
755+ Str("node_id", activeNode.ID).
756+ Str("original_pane", string(originalPane.ID)).
757+ Str("original_title", originalTitle).
758+ Msg("converted leaf to stacked node")
759+ }
760+
761+ // Create new child node for the new pane
762+ newChildNode := &entity.PaneNode{
763+ ID: stackNode.ID + "_" + string(newPaneID),
764+ Pane: newPane,
765+ Parent: stackNode,
766+ }
767+ stackNode.Children = append(stackNode.Children, newChildNode)
768+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
769+
770+ log.Debug().
771+ Int("stack_size", len(stackNode.Children)).
772+ Int("active_index", stackNode.ActiveStackIndex).
773+ Msg("domain tree updated")
774+
775+ // Create PaneView for the new pane
776+ newPaneView := component.NewPaneView(a.widgetFactory, newPaneID, nil)
777+ wsView.RegisterPaneView(newPaneID, newPaneView)
778+
779+ // Add to the UI StackedView
780+ if err := a.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil {
781+ log.Error().Err(err).Msg("failed to add pane to stack")
782+ return err
783+ }
784+
785+ // Update the first pane's title if we just converted from leaf to stacked
786+ if needsFirstPaneTitleUpdate {
787+ tr := wsView.TreeRenderer()
788+ if tr != nil {
789+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
790+ if stackedView != nil {
791+ if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
792+ log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
793+ } else {
794+ log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
795+ }
796+ }
797+ }
798+ }
799+
800+ // Get WebView and attach
801+ wv, err := a.ensureWebViewForPane(ctx, newPaneID)
802+ if err != nil {
803+ log.Warn().Err(err).Msg("failed to get webview for new pane")
804+ } else {
805+ widget := a.wrapWebViewWidget(ctx, wv)
806+ if widget != nil {
807+ newPaneView.SetWebViewWidget(widget)
808+ }
809+ // Load blank page
810+ if err := wv.LoadURI(ctx, "about:blank"); err != nil {
811+ log.Warn().Err(err).Msg("failed to load blank page")
812+ }
813+ }
814+
815+ // Update workspace active pane ID
816+ ws.ActivePaneID = newPaneID
817+
818+ // Update workspace view
819+ if err := wsView.SetActivePaneID(newPaneID); err != nil {
820+ log.Warn().Err(err).Msg("failed to set active pane")
821+ }
822+
823+ // Set up title bar click callback
824+ tr := wsView.TreeRenderer()
825+ if tr != nil {
826+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
827+ if stackedView != nil {
828+ // Capture stackNode for the callback
829+ capturedStackNode := stackNode
830+ stackedView.SetOnActivate(func(index int) {
831+ a.handleTitleBarClick(ctx, capturedStackNode, stackedView, index)
832+ })
833+ }
834+ }
835+
836+ log.Info().
837+ Str("original_pane", string(activePaneID)).
838+ Str("new_pane", string(newPaneID)).
839+ Int("stack_size", len(stackNode.Children)).
840+ Msg("stacked new pane")
841+
842+ return nil
843+}
844+
845+// handleStackNavigate navigates up or down within a stacked pane container.
846+// With the new architecture, every pane is in a StackedView - we just navigate within it.
847+func (a *App) handleStackNavigate(ctx context.Context, direction string) error {
848+ log := logging.FromContext(ctx)
849+
850+ if a.stackedPaneMgr == nil {
851+ return nil
852+ }
853+
854+ ws := a.activeWorkspace()
855+ if ws == nil {
856+ return nil
857+ }
858+
859+ activePane := ws.ActivePane()
860+ if activePane == nil || activePane.Pane == nil {
861+ return nil
862+ }
863+
864+ wsView := a.activeWorkspaceView()
865+ if wsView == nil {
866+ return nil
867+ }
868+
869+ currentPaneID := activePane.Pane.ID
870+
871+ // Check if we're actually in a multi-pane stack
872+ if !a.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
873+ log.Debug().Msg("current pane is not in a multi-pane stack")
874+ return nil
875+ }
876+
877+ // Navigate within the stack
878+ _, err := a.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
879+ if err != nil {
880+ log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
881+ return err
882+ }
883+
884+ // Note: With the current StackedView navigation, the new pane ID is not returned
885+ // because the mapping from visual index to pane ID requires additional tracking.
886+ // For now, the StackedView handles the visual navigation.
887+ // TODO: Add pane ID tracking to StackedView for proper domain model sync
888+
889+ log.Debug().
890+ Str("direction", direction).
891+ Str("current_pane", string(currentPaneID)).
892+ Msg("navigated stack")
893+
684894 return nil
685895 }
686896
897+// handleStackPaneActivated is called when a pane in a stack is activated via title bar click.
898+// This is a stub for future use when title bar click callbacks are wired up.
899+// The paneID parameter would come from the StackedView's OnActivate callback.
900+func (a *App) handleStackPaneActivated(ctx context.Context, paneID entity.PaneID) {
901+ log := logging.FromContext(ctx)
902+
903+ if paneID == "" {
904+ return
905+ }
906+
907+ // Update workspace active pane
908+ ws := a.activeWorkspace()
909+ if ws != nil {
910+ ws.ActivePaneID = paneID
911+ }
912+
913+ // Update workspace view
914+ wsView := a.activeWorkspaceView()
915+ if wsView != nil {
916+ if err := wsView.SetActivePaneID(paneID); err != nil {
917+ log.Warn().Err(err).Msg("failed to set active pane after stack activation")
918+ }
919+ }
920+
921+ log.Debug().
922+ Str("pane_id", string(paneID)).
923+ Msg("stack pane activated")
924+}
925+
926+// handleTitleBarClick handles clicks on title bars to switch the active pane in a stack.
927+func (a *App) handleTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
928+ log := logging.FromContext(ctx)
929+
930+ if stackNode == nil || sv == nil {
931+ return
932+ }
933+
934+ // Validate index
935+ if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
936+ log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
937+ return
938+ }
939+
940+ // Get the current active index
941+ currentIndex := sv.ActiveIndex()
942+ if clickedIndex == currentIndex {
943+ log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
944+ return
945+ }
946+
947+ // Update the outgoing pane's title bar with its current webpage title
948+ if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
949+ outgoingChild := stackNode.Children[currentIndex]
950+ if outgoingChild.Pane != nil {
951+ outgoingPaneID := outgoingChild.Pane.ID
952+ outgoingTitle := a.getPaneTitle(outgoingPaneID)
953+ if outgoingTitle == "" {
954+ outgoingTitle = outgoingChild.Pane.Title
955+ }
956+ if outgoingTitle != "" {
957+ if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
958+ log.Warn().Err(err).Msg("failed to update outgoing pane title")
959+ }
960+ }
961+ }
962+ }
963+
964+ // Get the pane ID at the clicked index
965+ clickedChild := stackNode.Children[clickedIndex]
966+ if clickedChild.Pane == nil {
967+ log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
968+ return
969+ }
970+ clickedPaneID := clickedChild.Pane.ID
971+
972+ // Update StackedView active index
973+ if err := sv.SetActive(ctx, clickedIndex); err != nil {
974+ log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
975+ return
976+ }
977+
978+ // Update domain model
979+ stackNode.ActiveStackIndex = clickedIndex
980+
981+ // Update workspace active pane
982+ ws := a.activeWorkspace()
983+ if ws != nil {
984+ ws.ActivePaneID = clickedPaneID
985+ }
986+
987+ // Update workspace view
988+ wsView := a.activeWorkspaceView()
989+ if wsView != nil {
990+ if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
991+ log.Warn().Err(err).Msg("failed to set active pane in workspace view")
992+ }
993+ }
994+
995+ log.Info().
996+ Int("from_index", currentIndex).
997+ Int("to_index", clickedIndex).
998+ Str("pane_id", string(clickedPaneID)).
999+ Msg("switched active pane via title bar click")
1000+}
1001+
6871002 // attachWorkspaceWebViews ensures each pane has a WebView widget attached.
6881003 func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
6891004 log := logging.FromContext(ctx)
@@ -736,9 +1051,95 @@ func (a *App) ensureWebViewForPane(ctx context.Context, paneID entity.PaneID) (*
7361051 }
7371052
7381053 a.webViews[paneID] = wv
1054+
1055+ // Set up title change callback to track dynamic titles
1056+ wv.OnTitleChanged = func(title string) {
1057+ a.handlePaneTitleChanged(ctx, paneID, title)
1058+ }
1059+
7391060 return wv, nil
7401061 }
7411062
1063+// handlePaneTitleChanged updates title tracking when a WebView's title changes.
1064+func (a *App) handlePaneTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
1065+ log := logging.FromContext(ctx)
1066+
1067+ // Update title map
1068+ a.titleMu.Lock()
1069+ a.paneTitles[paneID] = title
1070+ a.titleMu.Unlock()
1071+
1072+ // Update domain model
1073+ ws := a.activeWorkspace()
1074+ if ws != nil {
1075+ paneNode := ws.FindPane(paneID)
1076+ if paneNode != nil && paneNode.Pane != nil {
1077+ paneNode.Pane.Title = title
1078+ }
1079+ }
1080+
1081+ // Update StackedView title bar if this pane is in a stack
1082+ wsView := a.activeWorkspaceView()
1083+ if wsView != nil {
1084+ tr := wsView.TreeRenderer()
1085+ if tr != nil {
1086+ stackedView := tr.GetStackedViewForPane(string(paneID))
1087+ if stackedView != nil {
1088+ // Find the pane's index in the stack and update title
1089+ a.updateStackedPaneTitle(ctx, stackedView, paneID, title)
1090+ }
1091+ }
1092+ }
1093+
1094+ log.Debug().
1095+ Str("pane_id", string(paneID)).
1096+ Str("title", title).
1097+ Msg("pane title updated")
1098+}
1099+
1100+// updateStackedPaneTitle updates the title of a pane in a StackedView.
1101+func (a *App) updateStackedPaneTitle(ctx context.Context, sv *layout.StackedView, paneID entity.PaneID, title string) {
1102+ // We need to find which index this pane is at in the stack
1103+ // For now, we track by iterating - could be optimized with reverse mapping
1104+ wsView := a.activeWorkspaceView()
1105+ if wsView == nil {
1106+ return
1107+ }
1108+
1109+ tr := wsView.TreeRenderer()
1110+ if tr == nil {
1111+ return
1112+ }
1113+
1114+ // Find the stack node in the domain to get the index
1115+ ws := a.activeWorkspace()
1116+ if ws == nil {
1117+ return
1118+ }
1119+
1120+ paneNode := ws.FindPane(paneID)
1121+ if paneNode == nil {
1122+ return
1123+ }
1124+
1125+ // If the pane is in a stacked parent, find its index
1126+ if paneNode.Parent != nil && paneNode.Parent.IsStacked {
1127+ for i, child := range paneNode.Parent.Children {
1128+ if child.Pane != nil && child.Pane.ID == paneID {
1129+ _ = sv.UpdateTitle(i, title)
1130+ return
1131+ }
1132+ }
1133+ }
1134+}
1135+
1136+// getPaneTitle returns the current title for a pane.
1137+func (a *App) getPaneTitle(paneID entity.PaneID) string {
1138+ a.titleMu.RLock()
1139+ defer a.titleMu.RUnlock()
1140+ return a.paneTitles[paneID]
1141+}
1142+
7421143 // releasePaneWebView returns the WebView for a pane to the pool.
7431144 func (a *App) releasePaneWebView(ctx context.Context, paneID entity.PaneID) {
7441145 wv, ok := a.webViews[paneID]
@@ -39,6 +39,7 @@ type App struct {
39 panesUC *usecase.ManagePanesUseCase39 panesUC *usecase.ManagePanesUseCase
40 workspaceViews map[entity.TabID]*component.WorkspaceView40 workspaceViews map[entity.TabID]*component.WorkspaceView
41 widgetFactory layout.WidgetFactory41 widgetFactory layout.WidgetFactory
42+ stackedPaneMgr *component.StackedPaneManager
42 43
43 // Input handling44 // Input handling
44 keyboardHandler *input.KeyboardHandler45 keyboardHandler *input.KeyboardHandler
@@ -47,11 +48,13 @@ type App struct {
47 omnibox *component.Omnibox48 omnibox *component.Omnibox
48 49
49 // Web content50 // Web content
50- pool *webkit.WebViewPool51+ pool *webkit.WebViewPool
51- injector *webkit.ContentInjector52+ injector *webkit.ContentInjector
52- router *webkit.MessageRouter53+ router *webkit.MessageRouter
53- settings *webkit.SettingsManager54+ settings *webkit.SettingsManager
54- webViews map[entity.PaneID]*webkit.WebView55+ webViews map[entity.PaneID]*webkit.WebView
56+ paneTitles map[entity.PaneID]string // Dynamic title tracking
57+ titleMu sync.RWMutex
55 58
56 // ID generator for tabs/panes59 // ID generator for tabs/panes
57 idCounter uint6460 idCounter uint64
@@ -80,6 +83,7 @@ func New(deps *Dependencies) (*App, error) {
80 router: deps.MessageRouter,83 router: deps.MessageRouter,
81 settings: deps.Settings,84 settings: deps.Settings,
82 webViews: make(map[entity.PaneID]*webkit.WebView),85 webViews: make(map[entity.PaneID]*webkit.WebView),
86+ paneTitles: make(map[entity.PaneID]string),
83 cancel: cancel,87 cancel: cancel,
84 }88 }
85 if app.router == nil {89 if app.router == nil {
@@ -149,6 +153,9 @@ func (a *App) onActivate(ctx context.Context) {
149 // Initialize widget factory for pane layout153 // Initialize widget factory for pane layout
150 a.widgetFactory = layout.NewGtkWidgetFactory()154 a.widgetFactory = layout.NewGtkWidgetFactory()
151 155
156+ // Initialize stacked pane manager for incremental widget operations
157+ a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
158+
152 // Create keyboard handler159 // Create keyboard handler
153 a.keyboardHandler = input.NewKeyboardHandler(160 a.keyboardHandler = input.NewKeyboardHandler(
154 ctx,161 ctx,
@@ -311,6 +318,12 @@ func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) err
311 case input.ActionFocusDown:318 case input.ActionFocusDown:
312 return a.handlePaneFocus(ctx, usecase.NavDown)319 return a.handlePaneFocus(ctx, usecase.NavDown)
313 320
321+ // Stack navigation
322+ case input.ActionStackNavUp:
323+ return a.handleStackNavigate(ctx, "up")
324+ case input.ActionStackNavDown:
325+ return a.handleStackNavigate(ctx, "down")
326+
314 // Navigation (stub implementations for now)327 // Navigation (stub implementations for now)
315 case input.ActionGoBack:328 case input.ActionGoBack:
316 log.Debug().Msg("go back action (not yet implemented)")329 log.Debug().Msg("go back action (not yet implemented)")
@@ -670,20 +683,322 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir
670 return nil683 return nil
671 }684 }
672 685
673-// handleStackPane converts the active pane into a stacked container.686+// handleStackPane adds a new pane stacked on top of the active pane.
674-// TODO: Implement incremental widget operations for stacked panes (like old repo).687+// This modifies both the domain tree and the UI StackedView.
675-// Currently, stacked panes require incremental GTK widget manipulation rather than
676-// full tree rebuilds because WebViews maintain state and parent references.
677 func (a *App) handleStackPane(ctx context.Context) error {688 func (a *App) handleStackPane(ctx context.Context) error {
678 log := logging.FromContext(ctx)689 log := logging.FromContext(ctx)
679 690
680- // Stacked panes are not yet fully supported in the new architecture.691+ if a.stackedPaneMgr == nil {
681- // The rebuild approach conflicts with WebView's stateful widget lifecycle.692+ log.Warn().Msg("stacked pane manager not available")
682- // See dumber-refs/internal/app/browser/stacked_panes.go for incremental approach.693+ return nil
683- log.Warn().Msg("stacked panes not yet implemented in clean architecture - requires incremental widget operations")694+ }
695+
696+ ws := a.activeWorkspace()
697+ if ws == nil {
698+ log.Warn().Msg("no active workspace")
699+ return nil
700+ }
701+
702+ activeNode := ws.ActivePane()
703+ if activeNode == nil || activeNode.Pane == nil {
704+ log.Warn().Msg("no active pane")
705+ return nil
706+ }
707+
708+ wsView := a.activeWorkspaceView()
709+ if wsView == nil {
710+ log.Warn().Msg("no workspace view")
711+ return nil
712+ }
713+
714+ activePaneID := activeNode.Pane.ID
715+
716+ // Create a new pane entity
717+ newPaneID := entity.PaneID(a.generateID())
718+ newPane := entity.NewPane(newPaneID)
719+ newPane.URI = "about:blank"
720+ newPane.Title = "Untitled"
721+
722+ // Get the original pane's current title
723+ originalTitle := a.getPaneTitle(activePaneID)
724+ if originalTitle == "" {
725+ originalTitle = activeNode.Pane.Title
726+ }
727+ if originalTitle == "" {
728+ originalTitle = "Untitled"
729+ }
730+
731+ // Update domain model: convert leaf to stacked if needed, add new pane
732+ var stackNode *entity.PaneNode
733+ var needsFirstPaneTitleUpdate bool
734+ if activeNode.IsStacked {
735+ // Already stacked, just add to it
736+ stackNode = activeNode
737+ } else {
738+ // Convert leaf node to stacked container
739+ // Move current pane to a child node
740+ originalPane := activeNode.Pane
741+ originalPane.Title = originalTitle // Ensure title is set
742+ originalPaneChild := &entity.PaneNode{
743+ ID: activeNode.ID + "_0",
744+ Pane: originalPane,
745+ Parent: activeNode,
746+ }
747+
748+ activeNode.Pane = nil // No longer a leaf
749+ activeNode.IsStacked = true
750+ activeNode.Children = []*entity.PaneNode{originalPaneChild}
751+ stackNode = activeNode
752+ needsFirstPaneTitleUpdate = true
753+
754+ log.Debug().
755+ Str("node_id", activeNode.ID).
756+ Str("original_pane", string(originalPane.ID)).
757+ Str("original_title", originalTitle).
758+ Msg("converted leaf to stacked node")
759+ }
760+
761+ // Create new child node for the new pane
762+ newChildNode := &entity.PaneNode{
763+ ID: stackNode.ID + "_" + string(newPaneID),
764+ Pane: newPane,
765+ Parent: stackNode,
766+ }
767+ stackNode.Children = append(stackNode.Children, newChildNode)
768+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
769+
770+ log.Debug().
771+ Int("stack_size", len(stackNode.Children)).
772+ Int("active_index", stackNode.ActiveStackIndex).
773+ Msg("domain tree updated")
774+
775+ // Create PaneView for the new pane
776+ newPaneView := component.NewPaneView(a.widgetFactory, newPaneID, nil)
777+ wsView.RegisterPaneView(newPaneID, newPaneView)
778+
779+ // Add to the UI StackedView
780+ if err := a.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil {
781+ log.Error().Err(err).Msg("failed to add pane to stack")
782+ return err
783+ }
784+
785+ // Update the first pane's title if we just converted from leaf to stacked
786+ if needsFirstPaneTitleUpdate {
787+ tr := wsView.TreeRenderer()
788+ if tr != nil {
789+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
790+ if stackedView != nil {
791+ if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
792+ log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
793+ } else {
794+ log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
795+ }
796+ }
797+ }
798+ }
799+
800+ // Get WebView and attach
801+ wv, err := a.ensureWebViewForPane(ctx, newPaneID)
802+ if err != nil {
803+ log.Warn().Err(err).Msg("failed to get webview for new pane")
804+ } else {
805+ widget := a.wrapWebViewWidget(ctx, wv)
806+ if widget != nil {
807+ newPaneView.SetWebViewWidget(widget)
808+ }
809+ // Load blank page
810+ if err := wv.LoadURI(ctx, "about:blank"); err != nil {
811+ log.Warn().Err(err).Msg("failed to load blank page")
812+ }
813+ }
814+
815+ // Update workspace active pane ID
816+ ws.ActivePaneID = newPaneID
817+
818+ // Update workspace view
819+ if err := wsView.SetActivePaneID(newPaneID); err != nil {
820+ log.Warn().Err(err).Msg("failed to set active pane")
821+ }
822+
823+ // Set up title bar click callback
824+ tr := wsView.TreeRenderer()
825+ if tr != nil {
826+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
827+ if stackedView != nil {
828+ // Capture stackNode for the callback
829+ capturedStackNode := stackNode
830+ stackedView.SetOnActivate(func(index int) {
831+ a.handleTitleBarClick(ctx, capturedStackNode, stackedView, index)
832+ })
833+ }
834+ }
835+
836+ log.Info().
837+ Str("original_pane", string(activePaneID)).
838+ Str("new_pane", string(newPaneID)).
839+ Int("stack_size", len(stackNode.Children)).
840+ Msg("stacked new pane")
841+
842+ return nil
843+}
844+
845+// handleStackNavigate navigates up or down within a stacked pane container.
846+// With the new architecture, every pane is in a StackedView - we just navigate within it.
847+func (a *App) handleStackNavigate(ctx context.Context, direction string) error {
848+ log := logging.FromContext(ctx)
849+
850+ if a.stackedPaneMgr == nil {
851+ return nil
852+ }
853+
854+ ws := a.activeWorkspace()
855+ if ws == nil {
856+ return nil
857+ }
858+
859+ activePane := ws.ActivePane()
860+ if activePane == nil || activePane.Pane == nil {
861+ return nil
862+ }
863+
864+ wsView := a.activeWorkspaceView()
865+ if wsView == nil {
866+ return nil
867+ }
868+
869+ currentPaneID := activePane.Pane.ID
870+
871+ // Check if we're actually in a multi-pane stack
872+ if !a.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
873+ log.Debug().Msg("current pane is not in a multi-pane stack")
874+ return nil
875+ }
876+
877+ // Navigate within the stack
878+ _, err := a.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
879+ if err != nil {
880+ log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
881+ return err
882+ }
883+
884+ // Note: With the current StackedView navigation, the new pane ID is not returned
885+ // because the mapping from visual index to pane ID requires additional tracking.
886+ // For now, the StackedView handles the visual navigation.
887+ // TODO: Add pane ID tracking to StackedView for proper domain model sync
888+
889+ log.Debug().
890+ Str("direction", direction).
891+ Str("current_pane", string(currentPaneID)).
892+ Msg("navigated stack")
893+
684 return nil894 return nil
685 }895 }
686 896
897+// handleStackPaneActivated is called when a pane in a stack is activated via title bar click.
898+// This is a stub for future use when title bar click callbacks are wired up.
899+// The paneID parameter would come from the StackedView's OnActivate callback.
900+func (a *App) handleStackPaneActivated(ctx context.Context, paneID entity.PaneID) {
901+ log := logging.FromContext(ctx)
902+
903+ if paneID == "" {
904+ return
905+ }
906+
907+ // Update workspace active pane
908+ ws := a.activeWorkspace()
909+ if ws != nil {
910+ ws.ActivePaneID = paneID
911+ }
912+
913+ // Update workspace view
914+ wsView := a.activeWorkspaceView()
915+ if wsView != nil {
916+ if err := wsView.SetActivePaneID(paneID); err != nil {
917+ log.Warn().Err(err).Msg("failed to set active pane after stack activation")
918+ }
919+ }
920+
921+ log.Debug().
922+ Str("pane_id", string(paneID)).
923+ Msg("stack pane activated")
924+}
925+
926+// handleTitleBarClick handles clicks on title bars to switch the active pane in a stack.
927+func (a *App) handleTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
928+ log := logging.FromContext(ctx)
929+
930+ if stackNode == nil || sv == nil {
931+ return
932+ }
933+
934+ // Validate index
935+ if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
936+ log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
937+ return
938+ }
939+
940+ // Get the current active index
941+ currentIndex := sv.ActiveIndex()
942+ if clickedIndex == currentIndex {
943+ log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
944+ return
945+ }
946+
947+ // Update the outgoing pane's title bar with its current webpage title
948+ if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
949+ outgoingChild := stackNode.Children[currentIndex]
950+ if outgoingChild.Pane != nil {
951+ outgoingPaneID := outgoingChild.Pane.ID
952+ outgoingTitle := a.getPaneTitle(outgoingPaneID)
953+ if outgoingTitle == "" {
954+ outgoingTitle = outgoingChild.Pane.Title
955+ }
956+ if outgoingTitle != "" {
957+ if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
958+ log.Warn().Err(err).Msg("failed to update outgoing pane title")
959+ }
960+ }
961+ }
962+ }
963+
964+ // Get the pane ID at the clicked index
965+ clickedChild := stackNode.Children[clickedIndex]
966+ if clickedChild.Pane == nil {
967+ log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
968+ return
969+ }
970+ clickedPaneID := clickedChild.Pane.ID
971+
972+ // Update StackedView active index
973+ if err := sv.SetActive(ctx, clickedIndex); err != nil {
974+ log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
975+ return
976+ }
977+
978+ // Update domain model
979+ stackNode.ActiveStackIndex = clickedIndex
980+
981+ // Update workspace active pane
982+ ws := a.activeWorkspace()
983+ if ws != nil {
984+ ws.ActivePaneID = clickedPaneID
985+ }
986+
987+ // Update workspace view
988+ wsView := a.activeWorkspaceView()
989+ if wsView != nil {
990+ if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
991+ log.Warn().Err(err).Msg("failed to set active pane in workspace view")
992+ }
993+ }
994+
995+ log.Info().
996+ Int("from_index", currentIndex).
997+ Int("to_index", clickedIndex).
998+ Str("pane_id", string(clickedPaneID)).
999+ Msg("switched active pane via title bar click")
1000+}
1001+
687 // attachWorkspaceWebViews ensures each pane has a WebView widget attached.1002 // attachWorkspaceWebViews ensures each pane has a WebView widget attached.
688 func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {1003 func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
689 log := logging.FromContext(ctx)1004 log := logging.FromContext(ctx)
@@ -736,9 +1051,95 @@ func (a *App) ensureWebViewForPane(ctx context.Context, paneID entity.PaneID) (*
736 }1051 }
737 1052
738 a.webViews[paneID] = wv1053 a.webViews[paneID] = wv
1054+
1055+ // Set up title change callback to track dynamic titles
1056+ wv.OnTitleChanged = func(title string) {
1057+ a.handlePaneTitleChanged(ctx, paneID, title)
1058+ }
1059+
739 return wv, nil1060 return wv, nil
740 }1061 }
741 1062
1063+// handlePaneTitleChanged updates title tracking when a WebView's title changes.
1064+func (a *App) handlePaneTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
1065+ log := logging.FromContext(ctx)
1066+
1067+ // Update title map
1068+ a.titleMu.Lock()
1069+ a.paneTitles[paneID] = title
1070+ a.titleMu.Unlock()
1071+
1072+ // Update domain model
1073+ ws := a.activeWorkspace()
1074+ if ws != nil {
1075+ paneNode := ws.FindPane(paneID)
1076+ if paneNode != nil && paneNode.Pane != nil {
1077+ paneNode.Pane.Title = title
1078+ }
1079+ }
1080+
1081+ // Update StackedView title bar if this pane is in a stack
1082+ wsView := a.activeWorkspaceView()
1083+ if wsView != nil {
1084+ tr := wsView.TreeRenderer()
1085+ if tr != nil {
1086+ stackedView := tr.GetStackedViewForPane(string(paneID))
1087+ if stackedView != nil {
1088+ // Find the pane's index in the stack and update title
1089+ a.updateStackedPaneTitle(ctx, stackedView, paneID, title)
1090+ }
1091+ }
1092+ }
1093+
1094+ log.Debug().
1095+ Str("pane_id", string(paneID)).
1096+ Str("title", title).
1097+ Msg("pane title updated")
1098+}
1099+
1100+// updateStackedPaneTitle updates the title of a pane in a StackedView.
1101+func (a *App) updateStackedPaneTitle(ctx context.Context, sv *layout.StackedView, paneID entity.PaneID, title string) {
1102+ // We need to find which index this pane is at in the stack
1103+ // For now, we track by iterating - could be optimized with reverse mapping
1104+ wsView := a.activeWorkspaceView()
1105+ if wsView == nil {
1106+ return
1107+ }
1108+
1109+ tr := wsView.TreeRenderer()
1110+ if tr == nil {
1111+ return
1112+ }
1113+
1114+ // Find the stack node in the domain to get the index
1115+ ws := a.activeWorkspace()
1116+ if ws == nil {
1117+ return
1118+ }
1119+
1120+ paneNode := ws.FindPane(paneID)
1121+ if paneNode == nil {
1122+ return
1123+ }
1124+
1125+ // If the pane is in a stacked parent, find its index
1126+ if paneNode.Parent != nil && paneNode.Parent.IsStacked {
1127+ for i, child := range paneNode.Parent.Children {
1128+ if child.Pane != nil && child.Pane.ID == paneID {
1129+ _ = sv.UpdateTitle(i, title)
1130+ return
1131+ }
1132+ }
1133+ }
1134+}
1135+
1136+// getPaneTitle returns the current title for a pane.
1137+func (a *App) getPaneTitle(paneID entity.PaneID) string {
1138+ a.titleMu.RLock()
1139+ defer a.titleMu.RUnlock()
1140+ return a.paneTitles[paneID]
1141+}
1142+
742 // releasePaneWebView returns the WebView for a pane to the pool.1143 // releasePaneWebView returns the WebView for a pane to the pool.
743 func (a *App) releasePaneWebView(ctx context.Context, paneID entity.PaneID) {1144 func (a *App) releasePaneWebView(ctx context.Context, paneID entity.PaneID) {
744 wv, ok := a.webViews[paneID]1145 wv, ok := a.webViews[paneID]
A internal/ui/component/stacked_pane_manager.go +176 -0
@@ -0,0 +1,176 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "context"
6+ "errors"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/layout"
11+)
12+
13+// ErrStackNotFound is returned when a StackedView cannot be found for a pane.
14+var ErrStackNotFound = errors.New("stacked view not found for pane")
15+
16+// StackedPaneManager handles stacked pane operations.
17+// With the new architecture, every pane is wrapped in a StackedView from the start,
18+// making stacking operations trivial - we just add panes to the existing StackedView.
19+type StackedPaneManager struct {
20+ factory layout.WidgetFactory
21+}
22+
23+// NewStackedPaneManager creates a new stacked pane manager.
24+func NewStackedPaneManager(factory layout.WidgetFactory) *StackedPaneManager {
25+ return &StackedPaneManager{
26+ factory: factory,
27+ }
28+}
29+
30+// AddPaneToStack adds a new pane to the stack containing the active pane.
31+// The StackedView is looked up from the TreeRenderer.
32+func (spm *StackedPaneManager) AddPaneToStack(
33+ ctx context.Context,
34+ wsView *WorkspaceView,
35+ activePaneID entity.PaneID,
36+ newPaneView *PaneView,
37+ title string,
38+) error {
39+ log := logging.FromContext(ctx)
40+
41+ // Get the StackedView containing the active pane
42+ tr := wsView.TreeRenderer()
43+ if tr == nil {
44+ return errors.New("tree renderer not available")
45+ }
46+
47+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
48+ if stackedView == nil {
49+ return ErrStackNotFound
50+ }
51+
52+ // Get the new pane's widget
53+ widget := newPaneView.Widget()
54+ if widget == nil {
55+ return errors.New("new pane has no widget")
56+ }
57+
58+ // Ensure widget is unparented
59+ if widget.GetParent() != nil {
60+ widget.Unparent()
61+ }
62+
63+ // Add to the stack (this makes it active automatically)
64+ if title == "" {
65+ title = "Untitled"
66+ }
67+
68+ log.Debug().
69+ Str("active_pane", string(activePaneID)).
70+ Str("new_pane", string(newPaneView.PaneID())).
71+ Str("title", title).
72+ Int("stack_size_before", stackedView.Count()).
73+ Msg("StackedPaneManager: adding pane to stack")
74+
75+ stackedView.AddPane(ctx, title, "", widget)
76+
77+ // Register the new pane in the TreeRenderer's tracking
78+ tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)
79+
80+ log.Info().
81+ Str("active_pane", string(activePaneID)).
82+ Str("new_pane", string(newPaneView.PaneID())).
83+ Int("stack_size", stackedView.Count()).
84+ Msg("added pane to stack")
85+
86+ return nil
87+}
88+
89+// NavigateStack moves to the next or previous pane in a stack.
90+// Returns the pane ID that became active.
91+func (spm *StackedPaneManager) NavigateStack(
92+ ctx context.Context,
93+ wsView *WorkspaceView,
94+ currentPaneID entity.PaneID,
95+ direction string,
96+) (entity.PaneID, error) {
97+ log := logging.FromContext(ctx)
98+
99+ tr := wsView.TreeRenderer()
100+ if tr == nil {
101+ return "", errors.New("tree renderer not available")
102+ }
103+
104+ stackedView := tr.GetStackedViewForPane(string(currentPaneID))
105+ if stackedView == nil {
106+ return "", ErrStackNotFound
107+ }
108+
109+ // Only navigate if there are multiple panes
110+ if stackedView.Count() <= 1 {
111+ log.Debug().Msg("only one pane in stack, nothing to navigate")
112+ return currentPaneID, nil
113+ }
114+
115+ var err error
116+ switch direction {
117+ case "up":
118+ err = stackedView.NavigatePrevious(ctx)
119+ case "down":
120+ err = stackedView.NavigateNext(ctx)
121+ default:
122+ return "", errors.New("invalid direction: use 'up' or 'down'")
123+ }
124+ if err != nil {
125+ return "", err
126+ }
127+
128+ // Get the newly active pane's widget to find its ID
129+ // We need to find which pane is now at the active index
130+ activeIndex := stackedView.ActiveIndex()
131+
132+ // Find the pane ID at this index by looking through registered panes
133+ // This is a bit indirect but works with the current architecture
134+ log.Debug().
135+ Str("current_pane", string(currentPaneID)).
136+ Str("direction", direction).
137+ Int("new_index", activeIndex).
138+ Msg("navigated stack")
139+
140+ // Return empty - caller should update based on domain model
141+ return "", nil
142+}
143+
144+// SetStackActiveCallback sets up the callback for when a stack pane is clicked.
145+func (spm *StackedPaneManager) SetStackActiveCallback(
146+ wsView *WorkspaceView,
147+ paneID entity.PaneID,
148+ callback func(index int),
149+) {
150+ tr := wsView.TreeRenderer()
151+ if tr == nil {
152+ return
153+ }
154+
155+ stackedView := tr.GetStackedViewForPane(string(paneID))
156+ if stackedView == nil {
157+ return
158+ }
159+
160+ stackedView.SetOnActivate(callback)
161+}
162+
163+// GetStackedView returns the StackedView for a pane.
164+func (spm *StackedPaneManager) GetStackedView(wsView *WorkspaceView, paneID entity.PaneID) *layout.StackedView {
165+ tr := wsView.TreeRenderer()
166+ if tr == nil {
167+ return nil
168+ }
169+ return tr.GetStackedViewForPane(string(paneID))
170+}
171+
172+// IsStacked returns true if the pane is in a stack with multiple panes.
173+func (spm *StackedPaneManager) IsStacked(wsView *WorkspaceView, paneID entity.PaneID) bool {
174+ sv := spm.GetStackedView(wsView, paneID)
175+ return sv != nil && sv.Count() > 1
176+}
@@ -0,0 +1,176 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "context"
6+ "errors"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/layout"
11+)
12+
13+// ErrStackNotFound is returned when a StackedView cannot be found for a pane.
14+var ErrStackNotFound = errors.New("stacked view not found for pane")
15+
16+// StackedPaneManager handles stacked pane operations.
17+// With the new architecture, every pane is wrapped in a StackedView from the start,
18+// making stacking operations trivial - we just add panes to the existing StackedView.
19+type StackedPaneManager struct {
20+ factory layout.WidgetFactory
21+}
22+
23+// NewStackedPaneManager creates a new stacked pane manager.
24+func NewStackedPaneManager(factory layout.WidgetFactory) *StackedPaneManager {
25+ return &StackedPaneManager{
26+ factory: factory,
27+ }
28+}
29+
30+// AddPaneToStack adds a new pane to the stack containing the active pane.
31+// The StackedView is looked up from the TreeRenderer.
32+func (spm *StackedPaneManager) AddPaneToStack(
33+ ctx context.Context,
34+ wsView *WorkspaceView,
35+ activePaneID entity.PaneID,
36+ newPaneView *PaneView,
37+ title string,
38+) error {
39+ log := logging.FromContext(ctx)
40+
41+ // Get the StackedView containing the active pane
42+ tr := wsView.TreeRenderer()
43+ if tr == nil {
44+ return errors.New("tree renderer not available")
45+ }
46+
47+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
48+ if stackedView == nil {
49+ return ErrStackNotFound
50+ }
51+
52+ // Get the new pane's widget
53+ widget := newPaneView.Widget()
54+ if widget == nil {
55+ return errors.New("new pane has no widget")
56+ }
57+
58+ // Ensure widget is unparented
59+ if widget.GetParent() != nil {
60+ widget.Unparent()
61+ }
62+
63+ // Add to the stack (this makes it active automatically)
64+ if title == "" {
65+ title = "Untitled"
66+ }
67+
68+ log.Debug().
69+ Str("active_pane", string(activePaneID)).
70+ Str("new_pane", string(newPaneView.PaneID())).
71+ Str("title", title).
72+ Int("stack_size_before", stackedView.Count()).
73+ Msg("StackedPaneManager: adding pane to stack")
74+
75+ stackedView.AddPane(ctx, title, "", widget)
76+
77+ // Register the new pane in the TreeRenderer's tracking
78+ tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)
79+
80+ log.Info().
81+ Str("active_pane", string(activePaneID)).
82+ Str("new_pane", string(newPaneView.PaneID())).
83+ Int("stack_size", stackedView.Count()).
84+ Msg("added pane to stack")
85+
86+ return nil
87+}
88+
89+// NavigateStack moves to the next or previous pane in a stack.
90+// Returns the pane ID that became active.
91+func (spm *StackedPaneManager) NavigateStack(
92+ ctx context.Context,
93+ wsView *WorkspaceView,
94+ currentPaneID entity.PaneID,
95+ direction string,
96+) (entity.PaneID, error) {
97+ log := logging.FromContext(ctx)
98+
99+ tr := wsView.TreeRenderer()
100+ if tr == nil {
101+ return "", errors.New("tree renderer not available")
102+ }
103+
104+ stackedView := tr.GetStackedViewForPane(string(currentPaneID))
105+ if stackedView == nil {
106+ return "", ErrStackNotFound
107+ }
108+
109+ // Only navigate if there are multiple panes
110+ if stackedView.Count() <= 1 {
111+ log.Debug().Msg("only one pane in stack, nothing to navigate")
112+ return currentPaneID, nil
113+ }
114+
115+ var err error
116+ switch direction {
117+ case "up":
118+ err = stackedView.NavigatePrevious(ctx)
119+ case "down":
120+ err = stackedView.NavigateNext(ctx)
121+ default:
122+ return "", errors.New("invalid direction: use 'up' or 'down'")
123+ }
124+ if err != nil {
125+ return "", err
126+ }
127+
128+ // Get the newly active pane's widget to find its ID
129+ // We need to find which pane is now at the active index
130+ activeIndex := stackedView.ActiveIndex()
131+
132+ // Find the pane ID at this index by looking through registered panes
133+ // This is a bit indirect but works with the current architecture
134+ log.Debug().
135+ Str("current_pane", string(currentPaneID)).
136+ Str("direction", direction).
137+ Int("new_index", activeIndex).
138+ Msg("navigated stack")
139+
140+ // Return empty - caller should update based on domain model
141+ return "", nil
142+}
143+
144+// SetStackActiveCallback sets up the callback for when a stack pane is clicked.
145+func (spm *StackedPaneManager) SetStackActiveCallback(
146+ wsView *WorkspaceView,
147+ paneID entity.PaneID,
148+ callback func(index int),
149+) {
150+ tr := wsView.TreeRenderer()
151+ if tr == nil {
152+ return
153+ }
154+
155+ stackedView := tr.GetStackedViewForPane(string(paneID))
156+ if stackedView == nil {
157+ return
158+ }
159+
160+ stackedView.SetOnActivate(callback)
161+}
162+
163+// GetStackedView returns the StackedView for a pane.
164+func (spm *StackedPaneManager) GetStackedView(wsView *WorkspaceView, paneID entity.PaneID) *layout.StackedView {
165+ tr := wsView.TreeRenderer()
166+ if tr == nil {
167+ return nil
168+ }
169+ return tr.GetStackedViewForPane(string(paneID))
170+}
171+
172+// IsStacked returns true if the pane is in a stack with multiple panes.
173+func (spm *StackedPaneManager) IsStacked(wsView *WorkspaceView, paneID entity.PaneID) bool {
174+ sv := spm.GetStackedView(wsView, paneID)
175+ return sv != nil && sv.Count() > 1
176+}
M internal/ui/component/workspace_view.go +52 -0
@@ -270,3 +270,55 @@ func (wv *WorkspaceView) Workspace() *entity.Workspace {
270270
271271 return wv.workspace
272272 }
273+
274+// RegisterPaneView adds a PaneView to the tracking map without rebuilding.
275+// Use this for incremental operations like stacked panes.
276+func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
277+ wv.mu.Lock()
278+ defer wv.mu.Unlock()
279+
280+ wv.paneViews[paneID] = pv
281+}
282+
283+// UnregisterPaneView removes a PaneView from the tracking map.
284+// Use this when closing a pane incrementally.
285+func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
286+ wv.mu.Lock()
287+ defer wv.mu.Unlock()
288+
289+ delete(wv.paneViews, paneID)
290+}
291+
292+// GetRootWidget returns the current root widget of the workspace.
293+// This is useful for incremental operations that need to modify the tree.
294+func (wv *WorkspaceView) GetRootWidget() layout.Widget {
295+ wv.mu.RLock()
296+ defer wv.mu.RUnlock()
297+
298+ return wv.rootWidget
299+}
300+
301+// SetRootWidgetDirect replaces the root widget without rebuilding the entire tree.
302+// Use this for incremental operations when converting to/from stacked panes.
303+func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
304+ wv.mu.Lock()
305+ defer wv.mu.Unlock()
306+
307+ // Remove old root if present
308+ if wv.rootWidget != nil {
309+ wv.container.Remove(wv.rootWidget)
310+ }
311+
312+ // Add new root
313+ if widget != nil {
314+ widget.SetVisible(true)
315+ wv.container.Append(widget)
316+ }
317+
318+ wv.rootWidget = widget
319+}
320+
321+// Factory returns the widget factory used by this workspace view.
322+func (wv *WorkspaceView) Factory() layout.WidgetFactory {
323+ return wv.factory
324+}
@@ -270,3 +270,55 @@ func (wv *WorkspaceView) Workspace() *entity.Workspace {
270 270
271 return wv.workspace271 return wv.workspace
272 }272 }
273+
274+// RegisterPaneView adds a PaneView to the tracking map without rebuilding.
275+// Use this for incremental operations like stacked panes.
276+func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
277+ wv.mu.Lock()
278+ defer wv.mu.Unlock()
279+
280+ wv.paneViews[paneID] = pv
281+}
282+
283+// UnregisterPaneView removes a PaneView from the tracking map.
284+// Use this when closing a pane incrementally.
285+func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
286+ wv.mu.Lock()
287+ defer wv.mu.Unlock()
288+
289+ delete(wv.paneViews, paneID)
290+}
291+
292+// GetRootWidget returns the current root widget of the workspace.
293+// This is useful for incremental operations that need to modify the tree.
294+func (wv *WorkspaceView) GetRootWidget() layout.Widget {
295+ wv.mu.RLock()
296+ defer wv.mu.RUnlock()
297+
298+ return wv.rootWidget
299+}
300+
301+// SetRootWidgetDirect replaces the root widget without rebuilding the entire tree.
302+// Use this for incremental operations when converting to/from stacked panes.
303+func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
304+ wv.mu.Lock()
305+ defer wv.mu.Unlock()
306+
307+ // Remove old root if present
308+ if wv.rootWidget != nil {
309+ wv.container.Remove(wv.rootWidget)
310+ }
311+
312+ // Add new root
313+ if widget != nil {
314+ widget.SetVisible(true)
315+ wv.container.Append(widget)
316+ }
317+
318+ wv.rootWidget = widget
319+}
320+
321+// Factory returns the widget factory used by this workspace view.
322+func (wv *WorkspaceView) Factory() layout.WidgetFactory {
323+ return wv.factory
324+}
M internal/ui/input/shortcuts.go +10 -0
@@ -64,6 +64,10 @@ const (
6464 ActionFocusUp Action = "focus_up"
6565 ActionFocusDown Action = "focus_down"
6666
67+ // Stack navigation (within stacked panes)
68+ ActionStackNavUp Action = "stack_nav_up"
69+ ActionStackNavDown Action = "stack_nav_down"
70+
6771 // Page navigation
6872 ActionGoBack Action = "go_back"
6973 ActionGoForward Action = "go_forward"
@@ -259,6 +263,12 @@ func mapConfigAction(configAction string) Action {
259263 case "focus-down":
260264 return ActionFocusDown
261265
266+ // Stack navigation
267+ case "stack-nav-up", "stack-up":
268+ return ActionStackNavUp
269+ case "stack-nav-down", "stack-down":
270+ return ActionStackNavDown
271+
262272 default:
263273 return ""
264274 }
@@ -64,6 +64,10 @@ const (
64 ActionFocusUp Action = "focus_up"64 ActionFocusUp Action = "focus_up"
65 ActionFocusDown Action = "focus_down"65 ActionFocusDown Action = "focus_down"
66 66
67+ // Stack navigation (within stacked panes)
68+ ActionStackNavUp Action = "stack_nav_up"
69+ ActionStackNavDown Action = "stack_nav_down"
70+
67 // Page navigation71 // Page navigation
68 ActionGoBack Action = "go_back"72 ActionGoBack Action = "go_back"
69 ActionGoForward Action = "go_forward"73 ActionGoForward Action = "go_forward"
@@ -259,6 +263,12 @@ func mapConfigAction(configAction string) Action {
259 case "focus-down":263 case "focus-down":
260 return ActionFocusDown264 return ActionFocusDown
261 265
266+ // Stack navigation
267+ case "stack-nav-up", "stack-up":
268+ return ActionStackNavUp
269+ case "stack-nav-down", "stack-down":
270+ return ActionStackNavDown
271+
262 default:272 default:
263 return ""273 return ""
264 }274 }
M internal/ui/layout/stacked.go +77 -15
@@ -1,8 +1,11 @@
11 package layout
22
33 import (
4+ "context"
45 "errors"
56 "sync"
7+
8+ "github.com/bnema/dumber/internal/logging"
69 )
710
811 // ErrStackEmpty is returned when operating on an empty stack.
@@ -53,13 +56,21 @@ func NewStackedView(factory WidgetFactory) *StackedView {
5356
5457 // AddPane adds a new pane to the stack.
5558 // The new pane becomes active (visible).
56-func (sv *StackedView) AddPane(title, faviconIconName string, container Widget) int {
59+func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName string, container Widget) int {
60+ log := logging.FromContext(ctx)
5761 sv.mu.Lock()
5862 defer sv.mu.Unlock()
5963
60- // Create title bar
64+ log.Debug().
65+ Str("title", title).
66+ Bool("container_nil", container == nil).
67+ Int("current_count", len(sv.panes)).
68+ Msg("StackedView.AddPane called")
69+
70+ // Create title bar - must not expand vertically
6171 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
6272 titleBar.AddCssClass("stacked-pane-titlebar")
73+ titleBar.SetVexpand(false)
6374
6475 // Create favicon image
6576 favicon := sv.factory.NewImage()
@@ -79,11 +90,13 @@ func (sv *StackedView) AddPane(title, faviconIconName string, container Widget)
7990 label.SetXalign(0.0)
8091 titleBar.Append(label)
8192
82- // Make title bar clickable
93+ // Make title bar clickable - ensure it doesn't expand vertically
8394 titleButton := sv.factory.NewButton()
8495 titleButton.SetChild(titleBar)
8596 titleButton.AddCssClass("stacked-pane-title-button")
8697 titleButton.SetFocusOnClick(false)
98+ titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically
99+ titleButton.SetHexpand(true) // But fill horizontal space
87100
88101 pane := &stackedPane{
89102 titleBar: titleBar,
@@ -109,23 +122,37 @@ func (sv *StackedView) AddPane(title, faviconIconName string, container Widget)
109122 })
110123
111124 // Add to container
125+ log.Debug().
126+ Int("index", index).
127+ Msg("StackedView: appending titleButton and container to box")
112128 sv.box.Append(titleButton)
113129 if container != nil {
114130 sv.box.Append(container)
115131 }
116132
117133 // Set this pane as active
118- sv.setActiveInternal(index)
134+ sv.setActiveInternal(ctx, index)
135+
136+ log.Debug().
137+ Int("new_index", index).
138+ Int("new_count", len(sv.panes)).
139+ Msg("StackedView.AddPane completed")
119140
120141 return index
121142 }
122143
123144 // RemovePane removes a pane from the stack by index.
124145 // Returns an error if trying to remove the last pane.
125-func (sv *StackedView) RemovePane(index int) error {
146+func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
147+ log := logging.FromContext(ctx)
126148 sv.mu.Lock()
127149 defer sv.mu.Unlock()
128150
151+ log.Debug().
152+ Int("index", index).
153+ Int("pane_count", len(sv.panes)).
154+ Msg("StackedView.RemovePane called")
155+
129156 if len(sv.panes) == 0 {
130157 return ErrStackEmpty
131158 }
@@ -162,7 +189,7 @@ func (sv *StackedView) RemovePane(index int) error {
162189
163190 // Update visibility
164191 if sv.activeIndex >= 0 {
165- sv.updateVisibilityInternal()
192+ sv.updateVisibilityInternal(ctx)
166193 }
167194
168195 return nil
@@ -170,10 +197,16 @@ func (sv *StackedView) RemovePane(index int) error {
170197
171198 // SetActive activates the pane at the given index.
172199 // The active pane's container is shown; inactive panes show only title bars.
173-func (sv *StackedView) SetActive(index int) error {
200+func (sv *StackedView) SetActive(ctx context.Context, index int) error {
201+ log := logging.FromContext(ctx)
174202 sv.mu.Lock()
175203 defer sv.mu.Unlock()
176204
205+ log.Debug().
206+ Int("index", index).
207+ Int("pane_count", len(sv.panes)).
208+ Msg("StackedView.SetActive called")
209+
177210 if len(sv.panes) == 0 {
178211 return ErrStackEmpty
179212 }
@@ -181,18 +214,25 @@ func (sv *StackedView) SetActive(index int) error {
181214 return ErrIndexOutOfBounds
182215 }
183216
184- sv.setActiveInternal(index)
217+ sv.setActiveInternal(ctx, index)
185218 return nil
186219 }
187220
188221 // setActiveInternal sets the active pane without locking.
189-func (sv *StackedView) setActiveInternal(index int) {
222+func (sv *StackedView) setActiveInternal(ctx context.Context, index int) {
190223 sv.activeIndex = index
191- sv.updateVisibilityInternal()
224+ sv.updateVisibilityInternal(ctx)
192225 }
193226
194227 // updateVisibilityInternal updates visibility of all panes based on activeIndex.
195-func (sv *StackedView) updateVisibilityInternal() {
228+func (sv *StackedView) updateVisibilityInternal(ctx context.Context) {
229+ log := logging.FromContext(ctx)
230+
231+ log.Debug().
232+ Int("active_index", sv.activeIndex).
233+ Int("pane_count", len(sv.panes)).
234+ Msg("StackedView.updateVisibilityInternal called")
235+
196236 for i, pane := range sv.panes {
197237 isActive := i == sv.activeIndex
198238 pane.isActive = isActive
@@ -202,12 +242,24 @@ func (sv *StackedView) updateVisibilityInternal() {
202242 parent := pane.titleBar.GetParent()
203243 if parent != nil {
204244 parent.SetVisible(!isActive)
245+ log.Debug().
246+ Int("pane_index", i).
247+ Str("title", pane.title).
248+ Bool("is_active", isActive).
249+ Bool("titlebar_visible", !isActive).
250+ Msg("StackedView: set titlebar visibility")
205251 }
206252 }
207253
208254 // Container is visible only for active pane
209255 if pane.container != nil {
210256 pane.container.SetVisible(isActive)
257+ log.Debug().
258+ Int("pane_index", i).
259+ Str("title", pane.title).
260+ Bool("is_active", isActive).
261+ Bool("container_visible", isActive).
262+ Msg("StackedView: set container visibility")
211263 }
212264
213265 // Update CSS classes
@@ -306,7 +358,8 @@ func (sv *StackedView) Box() BoxWidget {
306358 }
307359
308360 // NavigateNext moves to the next pane in the stack (wraps around).
309-func (sv *StackedView) NavigateNext() error {
361+func (sv *StackedView) NavigateNext(ctx context.Context) error {
362+ log := logging.FromContext(ctx)
310363 sv.mu.Lock()
311364 defer sv.mu.Unlock()
312365
@@ -315,12 +368,17 @@ func (sv *StackedView) NavigateNext() error {
315368 }
316369
317370 newIndex := (sv.activeIndex + 1) % len(sv.panes)
318- sv.setActiveInternal(newIndex)
371+ log.Debug().
372+ Int("old_index", sv.activeIndex).
373+ Int("new_index", newIndex).
374+ Msg("StackedView.NavigateNext")
375+ sv.setActiveInternal(ctx, newIndex)
319376 return nil
320377 }
321378
322379 // NavigatePrevious moves to the previous pane in the stack (wraps around).
323-func (sv *StackedView) NavigatePrevious() error {
380+func (sv *StackedView) NavigatePrevious(ctx context.Context) error {
381+ log := logging.FromContext(ctx)
324382 sv.mu.Lock()
325383 defer sv.mu.Unlock()
326384
@@ -329,6 +387,10 @@ func (sv *StackedView) NavigatePrevious() error {
329387 }
330388
331389 newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes)
332- sv.setActiveInternal(newIndex)
390+ log.Debug().
391+ Int("old_index", sv.activeIndex).
392+ Int("new_index", newIndex).
393+ Msg("StackedView.NavigatePrevious")
394+ sv.setActiveInternal(ctx, newIndex)
333395 return nil
334396 }
@@ -1,8 +1,11 @@
1 package layout1 package layout
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "sync"6 "sync"
7+
8+ "github.com/bnema/dumber/internal/logging"
6 )9 )
7 10
8 // ErrStackEmpty is returned when operating on an empty stack.11 // ErrStackEmpty is returned when operating on an empty stack.
@@ -53,13 +56,21 @@ func NewStackedView(factory WidgetFactory) *StackedView {
53 56
54 // AddPane adds a new pane to the stack.57 // AddPane adds a new pane to the stack.
55 // The new pane becomes active (visible).58 // The new pane becomes active (visible).
56-func (sv *StackedView) AddPane(title, faviconIconName string, container Widget) int {59+func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName string, container Widget) int {
60+ log := logging.FromContext(ctx)
57 sv.mu.Lock()61 sv.mu.Lock()
58 defer sv.mu.Unlock()62 defer sv.mu.Unlock()
59 63
60- // Create title bar64+ log.Debug().
65+ Str("title", title).
66+ Bool("container_nil", container == nil).
67+ Int("current_count", len(sv.panes)).
68+ Msg("StackedView.AddPane called")
69+
70+ // Create title bar - must not expand vertically
61 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)71 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
62 titleBar.AddCssClass("stacked-pane-titlebar")72 titleBar.AddCssClass("stacked-pane-titlebar")
73+ titleBar.SetVexpand(false)
63 74
64 // Create favicon image75 // Create favicon image
65 favicon := sv.factory.NewImage()76 favicon := sv.factory.NewImage()
@@ -79,11 +90,13 @@ func (sv *StackedView) AddPane(title, faviconIconName string, container Widget)
79 label.SetXalign(0.0)90 label.SetXalign(0.0)
80 titleBar.Append(label)91 titleBar.Append(label)
81 92
82- // Make title bar clickable93+ // Make title bar clickable - ensure it doesn't expand vertically
83 titleButton := sv.factory.NewButton()94 titleButton := sv.factory.NewButton()
84 titleButton.SetChild(titleBar)95 titleButton.SetChild(titleBar)
85 titleButton.AddCssClass("stacked-pane-title-button")96 titleButton.AddCssClass("stacked-pane-title-button")
86 titleButton.SetFocusOnClick(false)97 titleButton.SetFocusOnClick(false)
98+ titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically
99+ titleButton.SetHexpand(true) // But fill horizontal space
87 100
88 pane := &stackedPane{101 pane := &stackedPane{
89 titleBar: titleBar,102 titleBar: titleBar,
@@ -109,23 +122,37 @@ func (sv *StackedView) AddPane(title, faviconIconName string, container Widget)
109 })122 })
110 123
111 // Add to container124 // Add to container
125+ log.Debug().
126+ Int("index", index).
127+ Msg("StackedView: appending titleButton and container to box")
112 sv.box.Append(titleButton)128 sv.box.Append(titleButton)
113 if container != nil {129 if container != nil {
114 sv.box.Append(container)130 sv.box.Append(container)
115 }131 }
116 132
117 // Set this pane as active133 // Set this pane as active
118- sv.setActiveInternal(index)134+ sv.setActiveInternal(ctx, index)
135+
136+ log.Debug().
137+ Int("new_index", index).
138+ Int("new_count", len(sv.panes)).
139+ Msg("StackedView.AddPane completed")
119 140
120 return index141 return index
121 }142 }
122 143
123 // RemovePane removes a pane from the stack by index.144 // RemovePane removes a pane from the stack by index.
124 // Returns an error if trying to remove the last pane.145 // Returns an error if trying to remove the last pane.
125-func (sv *StackedView) RemovePane(index int) error {146+func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
147+ log := logging.FromContext(ctx)
126 sv.mu.Lock()148 sv.mu.Lock()
127 defer sv.mu.Unlock()149 defer sv.mu.Unlock()
128 150
151+ log.Debug().
152+ Int("index", index).
153+ Int("pane_count", len(sv.panes)).
154+ Msg("StackedView.RemovePane called")
155+
129 if len(sv.panes) == 0 {156 if len(sv.panes) == 0 {
130 return ErrStackEmpty157 return ErrStackEmpty
131 }158 }
@@ -162,7 +189,7 @@ func (sv *StackedView) RemovePane(index int) error {
162 189
163 // Update visibility190 // Update visibility
164 if sv.activeIndex >= 0 {191 if sv.activeIndex >= 0 {
165- sv.updateVisibilityInternal()192+ sv.updateVisibilityInternal(ctx)
166 }193 }
167 194
168 return nil195 return nil
@@ -170,10 +197,16 @@ func (sv *StackedView) RemovePane(index int) error {
170 197
171 // SetActive activates the pane at the given index.198 // SetActive activates the pane at the given index.
172 // The active pane's container is shown; inactive panes show only title bars.199 // The active pane's container is shown; inactive panes show only title bars.
173-func (sv *StackedView) SetActive(index int) error {200+func (sv *StackedView) SetActive(ctx context.Context, index int) error {
201+ log := logging.FromContext(ctx)
174 sv.mu.Lock()202 sv.mu.Lock()
175 defer sv.mu.Unlock()203 defer sv.mu.Unlock()
176 204
205+ log.Debug().
206+ Int("index", index).
207+ Int("pane_count", len(sv.panes)).
208+ Msg("StackedView.SetActive called")
209+
177 if len(sv.panes) == 0 {210 if len(sv.panes) == 0 {
178 return ErrStackEmpty211 return ErrStackEmpty
179 }212 }
@@ -181,18 +214,25 @@ func (sv *StackedView) SetActive(index int) error {
181 return ErrIndexOutOfBounds214 return ErrIndexOutOfBounds
182 }215 }
183 216
184- sv.setActiveInternal(index)217+ sv.setActiveInternal(ctx, index)
185 return nil218 return nil
186 }219 }
187 220
188 // setActiveInternal sets the active pane without locking.221 // setActiveInternal sets the active pane without locking.
189-func (sv *StackedView) setActiveInternal(index int) {222+func (sv *StackedView) setActiveInternal(ctx context.Context, index int) {
190 sv.activeIndex = index223 sv.activeIndex = index
191- sv.updateVisibilityInternal()224+ sv.updateVisibilityInternal(ctx)
192 }225 }
193 226
194 // updateVisibilityInternal updates visibility of all panes based on activeIndex.227 // updateVisibilityInternal updates visibility of all panes based on activeIndex.
195-func (sv *StackedView) updateVisibilityInternal() {228+func (sv *StackedView) updateVisibilityInternal(ctx context.Context) {
229+ log := logging.FromContext(ctx)
230+
231+ log.Debug().
232+ Int("active_index", sv.activeIndex).
233+ Int("pane_count", len(sv.panes)).
234+ Msg("StackedView.updateVisibilityInternal called")
235+
196 for i, pane := range sv.panes {236 for i, pane := range sv.panes {
197 isActive := i == sv.activeIndex237 isActive := i == sv.activeIndex
198 pane.isActive = isActive238 pane.isActive = isActive
@@ -202,12 +242,24 @@ func (sv *StackedView) updateVisibilityInternal() {
202 parent := pane.titleBar.GetParent()242 parent := pane.titleBar.GetParent()
203 if parent != nil {243 if parent != nil {
204 parent.SetVisible(!isActive)244 parent.SetVisible(!isActive)
245+ log.Debug().
246+ Int("pane_index", i).
247+ Str("title", pane.title).
248+ Bool("is_active", isActive).
249+ Bool("titlebar_visible", !isActive).
250+ Msg("StackedView: set titlebar visibility")
205 }251 }
206 }252 }
207 253
208 // Container is visible only for active pane254 // Container is visible only for active pane
209 if pane.container != nil {255 if pane.container != nil {
210 pane.container.SetVisible(isActive)256 pane.container.SetVisible(isActive)
257+ log.Debug().
258+ Int("pane_index", i).
259+ Str("title", pane.title).
260+ Bool("is_active", isActive).
261+ Bool("container_visible", isActive).
262+ Msg("StackedView: set container visibility")
211 }263 }
212 264
213 // Update CSS classes265 // Update CSS classes
@@ -306,7 +358,8 @@ func (sv *StackedView) Box() BoxWidget {
306 }358 }
307 359
308 // NavigateNext moves to the next pane in the stack (wraps around).360 // NavigateNext moves to the next pane in the stack (wraps around).
309-func (sv *StackedView) NavigateNext() error {361+func (sv *StackedView) NavigateNext(ctx context.Context) error {
362+ log := logging.FromContext(ctx)
310 sv.mu.Lock()363 sv.mu.Lock()
311 defer sv.mu.Unlock()364 defer sv.mu.Unlock()
312 365
@@ -315,12 +368,17 @@ func (sv *StackedView) NavigateNext() error {
315 }368 }
316 369
317 newIndex := (sv.activeIndex + 1) % len(sv.panes)370 newIndex := (sv.activeIndex + 1) % len(sv.panes)
318- sv.setActiveInternal(newIndex)371+ log.Debug().
372+ Int("old_index", sv.activeIndex).
373+ Int("new_index", newIndex).
374+ Msg("StackedView.NavigateNext")
375+ sv.setActiveInternal(ctx, newIndex)
319 return nil376 return nil
320 }377 }
321 378
322 // NavigatePrevious moves to the previous pane in the stack (wraps around).379 // NavigatePrevious moves to the previous pane in the stack (wraps around).
323-func (sv *StackedView) NavigatePrevious() error {380+func (sv *StackedView) NavigatePrevious(ctx context.Context) error {
381+ log := logging.FromContext(ctx)
324 sv.mu.Lock()382 sv.mu.Lock()
325 defer sv.mu.Unlock()383 defer sv.mu.Unlock()
326 384
@@ -329,6 +387,10 @@ func (sv *StackedView) NavigatePrevious() error {
329 }387 }
330 388
331 newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes)389 newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes)
332- sv.setActiveInternal(newIndex)390+ log.Debug().
391+ Int("old_index", sv.activeIndex).
392+ Int("new_index", newIndex).
393+ Msg("StackedView.NavigatePrevious")
394+ sv.setActiveInternal(ctx, newIndex)
333 return nil395 return nil
334 }396 }
M internal/ui/layout/tree.go +92 -6
@@ -31,8 +31,11 @@ type TreeRenderer struct {
3131 paneViewFactory PaneViewFactory
3232 logger zerolog.Logger
3333 nodeToWidget map[string]Widget
34- ctx context.Context
35- mu sync.RWMutex
34+ // paneToStack maps pane IDs to their containing StackedView.
35+ // Every leaf pane is wrapped in a StackedView for easy stacking later.
36+ paneToStack map[string]*StackedView
37+ ctx context.Context
38+ mu sync.RWMutex
3639 }
3740
3841 // NewTreeRenderer creates a new tree renderer.
@@ -44,6 +47,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
4447 paneViewFactory: paneViewFactory,
4548 logger: log.With().Str("component", "tree-renderer").Logger(),
4649 nodeToWidget: make(map[string]Widget),
50+ paneToStack: make(map[string]*StackedView),
4751 ctx: ctx,
4852 }
4953 }
@@ -60,6 +64,7 @@ func (tr *TreeRenderer) Build(root *entity.PaneNode) (Widget, error) {
6064
6165 // Clear previous mappings
6266 tr.nodeToWidget = make(map[string]Widget)
67+ tr.paneToStack = make(map[string]*StackedView)
6368
6469 return tr.renderNode(root)
6570 }
@@ -100,12 +105,45 @@ func (tr *TreeRenderer) renderNode(node *entity.PaneNode) (Widget, error) {
100105 return widget, nil
101106 }
102107
103-// renderLeaf creates a PaneView widget for a leaf node.
108+// renderLeaf creates a PaneView widget wrapped in a StackedView.
109+// Every pane is wrapped in a StackedView from the start, making stacking trivial.
110+// When there's only 1 pane, the StackedView shows no title bar.
104111 func (tr *TreeRenderer) renderLeaf(node *entity.PaneNode) Widget {
105112 if tr.paneViewFactory == nil {
106113 return nil
107114 }
108- return tr.paneViewFactory.CreatePaneView(node)
115+
116+ paneWidget := tr.paneViewFactory.CreatePaneView(node)
117+ if paneWidget == nil {
118+ return nil
119+ }
120+
121+ // Wrap the pane in a StackedView
122+ stackedView := NewStackedView(tr.factory)
123+
124+ // Get title from pane if available
125+ title := "Untitled"
126+ paneID := ""
127+ if node.Pane != nil {
128+ paneID = string(node.Pane.ID)
129+ if node.Pane.Title != "" {
130+ title = node.Pane.Title
131+ }
132+ }
133+
134+ tr.logger.Debug().
135+ Str("pane_id", paneID).
136+ Str("title", title).
137+ Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView")
138+
139+ stackedView.AddPane(tr.ctx, title, "", paneWidget)
140+
141+ // Track this pane's StackedView for later stacking operations
142+ if node.Pane != nil {
143+ tr.paneToStack[paneID] = stackedView
144+ }
145+
146+ return stackedView.Widget()
109147 }
110148
111149 // renderSplit creates a SplitView for a split node.
@@ -137,6 +175,10 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) {
137175 func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) {
138176 stackedView := NewStackedView(tr.factory)
139177
178+ tr.logger.Debug().
179+ Int("child_count", len(node.Children)).
180+ Msg("TreeRenderer.renderStacked: creating stacked view")
181+
140182 // Add each child pane to the stack
141183 for _, child := range node.Children {
142184 childWidget, err := tr.renderNode(child)
@@ -154,12 +196,12 @@ func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) {
154196 favicon = child.Pane.FaviconURL
155197 }
156198
157- stackedView.AddPane(title, favicon, childWidget)
199+ stackedView.AddPane(tr.ctx, title, favicon, childWidget)
158200 }
159201
160202 // Set active index
161203 if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() {
162- _ = stackedView.SetActive(node.ActiveStackIndex)
204+ _ = stackedView.SetActive(tr.ctx, node.ActiveStackIndex)
163205 }
164206
165207 return stackedView.Widget(), nil
@@ -200,6 +242,24 @@ func (tr *TreeRenderer) Clear() {
200242 tr.nodeToWidget = make(map[string]Widget)
201243 }
202244
245+// RegisterWidget adds or updates a node-to-widget mapping.
246+// Use this for incremental updates when you don't want to rebuild the entire tree.
247+func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
248+ tr.mu.Lock()
249+ defer tr.mu.Unlock()
250+
251+ tr.nodeToWidget[nodeID] = widget
252+}
253+
254+// UnregisterWidget removes a node-to-widget mapping.
255+// Use this when a node is removed or replaced during incremental operations.
256+func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
257+ tr.mu.Lock()
258+ defer tr.mu.Unlock()
259+
260+ delete(tr.nodeToWidget, nodeID)
261+}
262+
203263 // UpdateSplitRatio updates the ratio of a split view by node ID.
204264 // Returns an error if the node is not found or is not a split.
205265 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
@@ -232,3 +292,29 @@ func (tr *TreeRenderer) GetNodeIDs() []string {
232292 }
233293 return ids
234294 }
295+
296+// GetStackedViewForPane returns the StackedView containing the given pane.
297+// Returns nil if the pane is not found.
298+func (tr *TreeRenderer) GetStackedViewForPane(paneID string) *StackedView {
299+ tr.mu.RLock()
300+ defer tr.mu.RUnlock()
301+
302+ return tr.paneToStack[paneID]
303+}
304+
305+// RegisterPaneInStack adds a pane to the paneToStack mapping.
306+// Use this when adding a pane to an existing stack without rebuild.
307+func (tr *TreeRenderer) RegisterPaneInStack(paneID string, stackedView *StackedView) {
308+ tr.mu.Lock()
309+ defer tr.mu.Unlock()
310+
311+ tr.paneToStack[paneID] = stackedView
312+}
313+
314+// UnregisterPane removes a pane from the paneToStack mapping.
315+func (tr *TreeRenderer) UnregisterPane(paneID string) {
316+ tr.mu.Lock()
317+ defer tr.mu.Unlock()
318+
319+ delete(tr.paneToStack, paneID)
320+}
@@ -31,8 +31,11 @@ type TreeRenderer struct {
31 paneViewFactory PaneViewFactory31 paneViewFactory PaneViewFactory
32 logger zerolog.Logger32 logger zerolog.Logger
33 nodeToWidget map[string]Widget33 nodeToWidget map[string]Widget
34- ctx context.Context34+ // paneToStack maps pane IDs to their containing StackedView.
35- mu sync.RWMutex35+ // Every leaf pane is wrapped in a StackedView for easy stacking later.
36+ paneToStack map[string]*StackedView
37+ ctx context.Context
38+ mu sync.RWMutex
36 }39 }
37 40
38 // NewTreeRenderer creates a new tree renderer.41 // NewTreeRenderer creates a new tree renderer.
@@ -44,6 +47,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
44 paneViewFactory: paneViewFactory,47 paneViewFactory: paneViewFactory,
45 logger: log.With().Str("component", "tree-renderer").Logger(),48 logger: log.With().Str("component", "tree-renderer").Logger(),
46 nodeToWidget: make(map[string]Widget),49 nodeToWidget: make(map[string]Widget),
50+ paneToStack: make(map[string]*StackedView),
47 ctx: ctx,51 ctx: ctx,
48 }52 }
49 }53 }
@@ -60,6 +64,7 @@ func (tr *TreeRenderer) Build(root *entity.PaneNode) (Widget, error) {
60 64
61 // Clear previous mappings65 // Clear previous mappings
62 tr.nodeToWidget = make(map[string]Widget)66 tr.nodeToWidget = make(map[string]Widget)
67+ tr.paneToStack = make(map[string]*StackedView)
63 68
64 return tr.renderNode(root)69 return tr.renderNode(root)
65 }70 }
@@ -100,12 +105,45 @@ func (tr *TreeRenderer) renderNode(node *entity.PaneNode) (Widget, error) {
100 return widget, nil105 return widget, nil
101 }106 }
102 107
103-// renderLeaf creates a PaneView widget for a leaf node.108+// renderLeaf creates a PaneView widget wrapped in a StackedView.
109+// Every pane is wrapped in a StackedView from the start, making stacking trivial.
110+// When there's only 1 pane, the StackedView shows no title bar.
104 func (tr *TreeRenderer) renderLeaf(node *entity.PaneNode) Widget {111 func (tr *TreeRenderer) renderLeaf(node *entity.PaneNode) Widget {
105 if tr.paneViewFactory == nil {112 if tr.paneViewFactory == nil {
106 return nil113 return nil
107 }114 }
108- return tr.paneViewFactory.CreatePaneView(node)115+
116+ paneWidget := tr.paneViewFactory.CreatePaneView(node)
117+ if paneWidget == nil {
118+ return nil
119+ }
120+
121+ // Wrap the pane in a StackedView
122+ stackedView := NewStackedView(tr.factory)
123+
124+ // Get title from pane if available
125+ title := "Untitled"
126+ paneID := ""
127+ if node.Pane != nil {
128+ paneID = string(node.Pane.ID)
129+ if node.Pane.Title != "" {
130+ title = node.Pane.Title
131+ }
132+ }
133+
134+ tr.logger.Debug().
135+ Str("pane_id", paneID).
136+ Str("title", title).
137+ Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView")
138+
139+ stackedView.AddPane(tr.ctx, title, "", paneWidget)
140+
141+ // Track this pane's StackedView for later stacking operations
142+ if node.Pane != nil {
143+ tr.paneToStack[paneID] = stackedView
144+ }
145+
146+ return stackedView.Widget()
109 }147 }
110 148
111 // renderSplit creates a SplitView for a split node.149 // renderSplit creates a SplitView for a split node.
@@ -137,6 +175,10 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) {
137 func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) {175 func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) {
138 stackedView := NewStackedView(tr.factory)176 stackedView := NewStackedView(tr.factory)
139 177
178+ tr.logger.Debug().
179+ Int("child_count", len(node.Children)).
180+ Msg("TreeRenderer.renderStacked: creating stacked view")
181+
140 // Add each child pane to the stack182 // Add each child pane to the stack
141 for _, child := range node.Children {183 for _, child := range node.Children {
142 childWidget, err := tr.renderNode(child)184 childWidget, err := tr.renderNode(child)
@@ -154,12 +196,12 @@ func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) {
154 favicon = child.Pane.FaviconURL196 favicon = child.Pane.FaviconURL
155 }197 }
156 198
157- stackedView.AddPane(title, favicon, childWidget)199+ stackedView.AddPane(tr.ctx, title, favicon, childWidget)
158 }200 }
159 201
160 // Set active index202 // Set active index
161 if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() {203 if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() {
162- _ = stackedView.SetActive(node.ActiveStackIndex)204+ _ = stackedView.SetActive(tr.ctx, node.ActiveStackIndex)
163 }205 }
164 206
165 return stackedView.Widget(), nil207 return stackedView.Widget(), nil
@@ -200,6 +242,24 @@ func (tr *TreeRenderer) Clear() {
200 tr.nodeToWidget = make(map[string]Widget)242 tr.nodeToWidget = make(map[string]Widget)
201 }243 }
202 244
245+// RegisterWidget adds or updates a node-to-widget mapping.
246+// Use this for incremental updates when you don't want to rebuild the entire tree.
247+func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
248+ tr.mu.Lock()
249+ defer tr.mu.Unlock()
250+
251+ tr.nodeToWidget[nodeID] = widget
252+}
253+
254+// UnregisterWidget removes a node-to-widget mapping.
255+// Use this when a node is removed or replaced during incremental operations.
256+func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
257+ tr.mu.Lock()
258+ defer tr.mu.Unlock()
259+
260+ delete(tr.nodeToWidget, nodeID)
261+}
262+
203 // UpdateSplitRatio updates the ratio of a split view by node ID.263 // UpdateSplitRatio updates the ratio of a split view by node ID.
204 // Returns an error if the node is not found or is not a split.264 // Returns an error if the node is not found or is not a split.
205 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {265 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
@@ -232,3 +292,29 @@ func (tr *TreeRenderer) GetNodeIDs() []string {
232 }292 }
233 return ids293 return ids
234 }294 }
295+
296+// GetStackedViewForPane returns the StackedView containing the given pane.
297+// Returns nil if the pane is not found.
298+func (tr *TreeRenderer) GetStackedViewForPane(paneID string) *StackedView {
299+ tr.mu.RLock()
300+ defer tr.mu.RUnlock()
301+
302+ return tr.paneToStack[paneID]
303+}
304+
305+// RegisterPaneInStack adds a pane to the paneToStack mapping.
306+// Use this when adding a pane to an existing stack without rebuild.
307+func (tr *TreeRenderer) RegisterPaneInStack(paneID string, stackedView *StackedView) {
308+ tr.mu.Lock()
309+ defer tr.mu.Unlock()
310+
311+ tr.paneToStack[paneID] = stackedView
312+}
313+
314+// UnregisterPane removes a pane from the paneToStack mapping.
315+func (tr *TreeRenderer) UnregisterPane(paneID string) {
316+ tr.mu.Lock()
317+ defer tr.mu.Unlock()
318+
319+ delete(tr.paneToStack, paneID)
320+}
M internal/ui/theme/css.go +63 -0
@@ -25,6 +25,10 @@ func GenerateCSS(p Palette) string {
2525
2626 // Pane styling
2727 sb.WriteString(generatePaneCSS(p))
28+ sb.WriteString("\n")
29+
30+ // Stacked pane styling
31+ sb.WriteString(generateStackedPaneCSS(p))
2832
2933 return sb.String()
3034 }
@@ -234,3 +238,62 @@ func generatePaneCSS(p Palette) string {
234238 }
235239 `
236240 }
241+
242+// generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles.
243+func generateStackedPaneCSS(p Palette) string {
244+ return `/* ===== Stacked Pane Styling ===== */
245+
246+/* Title bar for inactive panes in a stack */
247+.stacked-pane-titlebar {
248+ background-color: var(--surface-variant);
249+ border-bottom: 1px solid var(--border);
250+ padding: 4px 8px;
251+ min-height: 24px;
252+}
253+
254+/* Title bar button wrapper - clickable area */
255+button.stacked-pane-title-button {
256+ background-color: var(--surface-variant);
257+ background-image: none;
258+ border: none;
259+ border-bottom: 1px solid var(--border);
260+ border-radius: 0;
261+ padding: 0;
262+ margin: 0;
263+ transition: background-color 150ms ease-in-out;
264+}
265+
266+button.stacked-pane-title-button:hover {
267+ background-color: alpha(var(--accent), 0.15);
268+}
269+
270+button.stacked-pane-title-button:focus {
271+ outline: none;
272+ box-shadow: none;
273+}
274+
275+/* Title bar content box */
276+.stacked-pane-titlebar.active {
277+ background-color: alpha(var(--accent), 0.1);
278+ border-left: 3px solid var(--accent);
279+}
280+
281+/* Favicon image in title bar */
282+.stacked-pane-titlebar image {
283+ min-width: 16px;
284+ min-height: 16px;
285+ margin-right: 6px;
286+}
287+
288+/* Title text in title bar */
289+.stacked-pane-titlebar label {
290+ color: var(--text);
291+ font-size: 12px;
292+ font-weight: 400;
293+}
294+
295+button.stacked-pane-title-button:hover .stacked-pane-titlebar label {
296+ color: var(--accent);
297+}
298+`
299+}
@@ -25,6 +25,10 @@ func GenerateCSS(p Palette) string {
25 25
26 // Pane styling26 // Pane styling
27 sb.WriteString(generatePaneCSS(p))27 sb.WriteString(generatePaneCSS(p))
28+ sb.WriteString("\n")
29+
30+ // Stacked pane styling
31+ sb.WriteString(generateStackedPaneCSS(p))
28 32
29 return sb.String()33 return sb.String()
30 }34 }
@@ -234,3 +238,62 @@ func generatePaneCSS(p Palette) string {
234 }238 }
235 `239 `
236 }240 }
241+
242+// generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles.
243+func generateStackedPaneCSS(p Palette) string {
244+ return `/* ===== Stacked Pane Styling ===== */
245+
246+/* Title bar for inactive panes in a stack */
247+.stacked-pane-titlebar {
248+ background-color: var(--surface-variant);
249+ border-bottom: 1px solid var(--border);
250+ padding: 4px 8px;
251+ min-height: 24px;
252+}
253+
254+/* Title bar button wrapper - clickable area */
255+button.stacked-pane-title-button {
256+ background-color: var(--surface-variant);
257+ background-image: none;
258+ border: none;
259+ border-bottom: 1px solid var(--border);
260+ border-radius: 0;
261+ padding: 0;
262+ margin: 0;
263+ transition: background-color 150ms ease-in-out;
264+}
265+
266+button.stacked-pane-title-button:hover {
267+ background-color: alpha(var(--accent), 0.15);
268+}
269+
270+button.stacked-pane-title-button:focus {
271+ outline: none;
272+ box-shadow: none;
273+}
274+
275+/* Title bar content box */
276+.stacked-pane-titlebar.active {
277+ background-color: alpha(var(--accent), 0.1);
278+ border-left: 3px solid var(--accent);
279+}
280+
281+/* Favicon image in title bar */
282+.stacked-pane-titlebar image {
283+ min-width: 16px;
284+ min-height: 16px;
285+ margin-right: 6px;
286+}
287+
288+/* Title text in title bar */
289+.stacked-pane-titlebar label {
290+ color: var(--text);
291+ font-size: 12px;
292+ font-weight: 400;
293+}
294+
295+button.stacked-pane-title-button:hover .stacked-pane-titlebar label {
296+ color: var(--accent);
297+}
298+`
299+}

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