detached 05fb5c2

refactor(workspace): rewrite closePane with simplified architecture and helper functions

commit
05fb5c299a023f900802e50aff38f663aa925633
author
Brice · · 2025-09-30 05:15 UTC
parent
91bff85
signature
unsigned
1 file · +314 -380
M internal/app/browser/workspace_pane_ops.go +314 -380
@@ -3,12 +3,48 @@ package browser
33
44 import (
55 "errors"
6- "fmt"
76 "log"
87
98 "github.com/bnema/dumber/pkg/webkit"
109 )
1110
11+// closeContext manages the context for a pane close operation
12+type closeContext struct {
13+ wm *WorkspaceManager
14+ target *paneNode
15+ remaining int
16+ err error
17+ generation uint
18+}
19+
20+// beginClose initializes a close context with validation
21+func (wm *WorkspaceManager) beginClose(node *paneNode) closeContext {
22+ ctx := closeContext{wm: wm, target: node, generation: wm.nextCleanupGeneration()}
23+ switch {
24+ case wm == nil:
25+ ctx.err = errors.New("workspace manager nil")
26+ case node == nil || !node.isLeaf:
27+ ctx.err = errors.New("invalid close target")
28+ case node.pane == nil || node.pane.webView == nil:
29+ ctx.err = errors.New("close target missing webview")
30+ default:
31+ ctx.remaining = len(wm.app.panes)
32+ }
33+ return ctx
34+}
35+
36+// Generation returns the cleanup generation for this context
37+func (ctx closeContext) Generation() uint {
38+ return ctx.generation
39+}
40+
41+// finish handles cleanup operations after close completion
42+func (ctx closeContext) finish() {
43+ if ctx.err == nil {
44+ ctx.wm.updateMainPane()
45+ }
46+}
47+
1248 // collectLeaves returns all leaf nodes in the workspace tree
1349 func (wm *WorkspaceManager) collectLeaves() []*paneNode {
1450 return wm.collectLeavesFrom(wm.root)
@@ -703,451 +739,349 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
703739 return newLeaf, nil
704740 }
705741
706-// closePane closes a specific pane and handles tree restructuring
707-func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
708- if wm == nil || node == nil || !node.isLeaf {
709- return nil, errors.New("close target must be a leaf pane")
742+// Helper functions for the simplified closePane implementation
743+
744+// getSibling returns the sibling node for a given node
745+func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
746+ if node.parent == nil {
747+ return nil
710748 }
711- if node.pane == nil || node.pane.webView == nil {
712- return nil, errors.New("close target missing webview")
749+ if node.parent.left == node {
750+ return node.parent.right
713751 }
752+ return node.parent.left
753+}
714754
715- var promoted *paneNode
755+// nextCleanupGeneration returns the next cleanup generation counter
756+func (wm *WorkspaceManager) nextCleanupGeneration() uint {
757+ wm.cleanupCounter++
758+ return wm.cleanupCounter
759+}
716760
717- // Handle closing panes in a stack
718- if node.parent != nil && node.parent.isStacked {
719- return nil, wm.stackedPaneManager.CloseStackedPane(node)
761+// ensureWidgets validates and recovers SafeWidget instances
762+func (wm *WorkspaceManager) ensureWidgets(nodes ...*paneNode) {
763+ for _, n := range nodes {
764+ if n == nil || n.container == nil {
765+ continue
766+ }
767+ if n.container.IsValid() {
768+ continue
769+ }
770+ ptr := n.container.Ptr()
771+ typeInfo := n.container.typeInfo
772+ n.container = wm.widgetRegistry.Register(ptr, typeInfo)
720773 }
774+}
721775
722- if node.parent != nil && node.parent.container != nil {
723- node.parent.container.Execute(func(panedPtr uintptr) error {
724- if node.parent.left == node {
725- webkit.PanedSetStartChild(panedPtr, 0)
726- // GTK4 automatically unparents when setting child to 0
727- } else if node.parent.right == node {
728- webkit.PanedSetEndChild(panedPtr, 0)
729- // GTK4 automatically unparents when setting child to 0
730- }
731- return nil
732- })
733- } else if node.container != nil {
734- // Only manually unparent if widget wasn't auto-unparented above
735- node.container.Execute(func(containerPtr uintptr) error {
736- if webkit.WidgetGetParent(containerPtr) != 0 {
737- webkit.WidgetUnparent(containerPtr)
738- }
739- return nil
740- })
776+// promoteSibling promotes a sibling node to replace its parent in the tree
777+func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
778+ if grand == nil {
779+ wm.root = sibling
780+ sibling.parent = nil
781+ return
741782 }
783+ sibling.parent = grand
784+ if grand.left == parent {
785+ grand.left = sibling
786+ } else {
787+ grand.right = sibling
788+ }
789+}
742790
743- remaining := len(wm.app.panes)
744- willBeLastPane := remaining <= 1
745- paneCleaned := false
746- ensureCleanup := func() {
747- if paneCleaned {
748- return
791+// swapContainers updates GTK widget hierarchy for promoted siblings
792+func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
793+ if grand == nil {
794+ // When TreeRebalancer is enabled, skip GTK attachment here as it will be
795+ // handled by the rebalancer's promotion transaction to avoid double-attachment
796+ if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
797+ wm.attachRoot(sibling)
749798 }
750- node.pane.Cleanup()
751- paneCleaned = true
799+ return
752800 }
753-
754- if remaining == 2 {
755- var sibling *paneNode
756- if node.parent != nil {
757- if node.parent.left == node {
758- sibling = node.parent.right
759- } else if node.parent.right == node {
760- sibling = node.parent.left
801+ grand.container.Execute(func(gPtr uintptr) error {
802+ sibling.container.Execute(func(sPtr uintptr) error {
803+ // GTK4 auto-unparents when setting paned child
804+ // But we should verify the widget is ready for reparenting
805+ if parent := webkit.WidgetGetParent(sPtr); parent != 0 && parent != gPtr {
806+ log.Printf("[workspace] widget %#x has unexpected parent %#x, expected %#x or 0", sPtr, parent, gPtr)
761807 }
762- }
763- if sibling != nil && sibling.isLeaf && sibling.pane != nil && sibling.pane.webView != nil {
764- if sibling.isPopup && !node.isPopup {
765- parentNode := node.parent
766- var parentContainer *SafeWidget
767- if parentNode != nil {
768- parentContainer = parentNode.container
769- }
770-
771- // Detach remaining GTK widgets before destroying the WebViews so GTK
772- // does not attempt to dispose still-parented children.
773- if parentContainer != nil {
774- parentContainer.Execute(func(panedPtr uintptr) error {
775- if parentNode.left == sibling {
776- webkit.PanedSetStartChild(panedPtr, 0)
777- } else if parentNode.right == sibling {
778- webkit.PanedSetEndChild(panedPtr, 0)
779- }
780- return nil
781- })
782- }
783- if sibling.container != nil {
784- sibling.container.Execute(func(containerPtr uintptr) error {
785- webkit.WidgetUnparent(containerPtr)
786- return nil
787- })
788- }
789- if parentContainer != nil && wm.window != nil {
790- wm.window.SetChild(0)
791- }
792-
793- log.Printf("[workspace] closing primary pane with related popup present; exiting")
794- ensureCleanup()
795- sibling.pane.Cleanup()
796808
797- wm.detachHover(node)
798- wm.detachHover(sibling)
799-
800- // Clean up workspace tracking for both panes
801- node.pane.CleanupFromWorkspace(wm)
802- sibling.pane.CleanupFromWorkspace(wm)
809+ if grand.left == sibling {
810+ webkit.PanedSetStartChild(gPtr, sPtr)
811+ log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sPtr, gPtr)
812+ } else {
813+ webkit.PanedSetEndChild(gPtr, sPtr)
814+ log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sPtr, gPtr)
815+ }
816+ webkit.WidgetQueueAllocate(gPtr)
817+ return nil
818+ })
819+ return nil
820+ })
821+}
803822
804- if node.pane.webView != nil {
805- if err := node.pane.webView.Destroy(); err != nil {
806- log.Printf("[workspace] failed to destroy primary webview: %v", err)
807- }
808- }
809- if sibling.pane.webView != nil {
810- if err := sibling.pane.webView.Destroy(); err != nil {
811- log.Printf("[workspace] failed to destroy popup webview: %v", err)
812- }
813- }
823+// attachRoot attaches a node as the new window root
824+func (wm *WorkspaceManager) attachRoot(root *paneNode) {
825+ if root == nil || root.container == nil || wm.window == nil {
826+ return
827+ }
828+ root.container.Execute(func(ptr uintptr) error {
829+ // Check if widget has a parent and unparent it first
830+ if parent := webkit.WidgetGetParent(ptr); parent != 0 {
831+ log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", ptr, parent)
832+ webkit.WidgetUnparent(ptr)
833+ }
814834
815- wm.root = nil
816- wm.mainPane = nil
835+ // Now safe to set as window child
836+ wm.window.SetChild(ptr)
837+ webkit.WidgetQueueAllocate(ptr)
838+ webkit.WidgetShow(ptr)
817839
818- webkit.QuitMainLoop()
819- return nil, nil
820- }
840+ // Verify attachment succeeded
841+ if finalParent := webkit.WidgetGetParent(ptr); finalParent == 0 {
842+ log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", ptr)
843+ } else {
844+ log.Printf("[workspace] attachRoot successful: widget %#x now child of %#x", ptr, finalParent)
821845 }
846+ return nil
847+ })
848+}
849+
850+// cleanupPane safely cleans up a pane node with generation tracking
851+func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
852+ if node == nil {
853+ return
822854 }
823- if willBeLastPane && wm.root == node {
824- log.Printf("[workspace] closing final pane; exiting browser")
825- ensureCleanup()
826- wm.detachHover(node)
827- if err := node.pane.webView.Destroy(); err != nil {
828- log.Printf("[workspace] failed to destroy webview: %v", err)
829- }
830- webkit.QuitMainLoop()
831- return nil, nil
855+ if !node.widgetValid {
856+ return
832857 }
833858
834- parent := node.parent
835- if parent == nil {
836- // This is the root pane (no parent in tree structure)
837- if node != wm.root {
838- return nil, errors.New("inconsistent state: node has no parent but is not root")
839- }
859+ node.widgetValid = false
860+ node.cleanupGeneration = generation
840861
841- // Check if we can find a replacement root
842- replacement := wm.findReplacementRoot(node)
843- if replacement == nil {
844- // No other panes exist, this is the final pane
845- log.Printf("[workspace] closing final pane; exiting browser")
846- ensureCleanup()
847- wm.detachHover(node)
848- if err := node.pane.webView.Destroy(); err != nil {
849- log.Printf("[workspace] failed to destroy webview: %v", err)
850- }
851- webkit.QuitMainLoop()
852- return nil, nil
862+ if node.pane != nil {
863+ node.pane.CleanupFromWorkspace(wm)
864+ if node.pane.webView != nil {
865+ node.pane.webView.Destroy()
853866 }
867+ }
854868
855- // We have a replacement - delegate root status and close this pane
856- log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)
869+ if node.container != nil {
870+ node.container.Invalidate()
871+ node.container = nil
872+ }
857873
858- // Clean up workspace tracking
859- node.pane.CleanupFromWorkspace(wm)
874+ node.parent = nil
875+ node.left = nil
876+ node.right = nil
877+}
860878
861- if wm.mainPane == node {
862- wm.mainPane = nil
863- }
879+// decommissionParent cleans up a parent node after promotion
880+func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
881+ if parent == nil {
882+ return
883+ }
864884
865- // Clear current active if it's the node being closed
866- if wm.GetActiveNode() == node {
867- }
885+ // For branch nodes (paneds), ensure the GTK widget is destroyed properly
886+ if !parent.isLeaf && parent.container != nil {
887+ parent.container.Execute(func(ptr uintptr) error {
888+ // Clear any remaining children first to ensure clean destruction
889+ // Note: Children may already be reparented during promotion, so verify parent relationship
890+ if webkit.IsPaned(ptr) {
891+ startChild := webkit.PanedGetStartChild(ptr)
892+ endChild := webkit.PanedGetEndChild(ptr)
868893
869- // Set replacement as new root
870- wm.root = replacement
871- replacement.parent = nil // Critical: clear parent pointer for new root
872- promoted = replacement
873-
874- // Replace window child - must unparent first to satisfy GTK4 assertion
875- if wm.window != nil && replacement.container != nil {
876- replacement.container.Execute(func(containerPtr uintptr) error {
877- // Unparent from current parent before setting as window child
878- if webkit.WidgetGetParent(containerPtr) != 0 {
879- webkit.WidgetUnparent(containerPtr)
894+ // Only clear children that are still valid AND still parented to this paned
895+ // If the child was reparented during swapContainers, its parent will be different
896+ if startChild != 0 && webkit.WidgetIsValid(startChild) && webkit.WidgetGetParent(startChild) == ptr {
897+ webkit.PanedSetStartChild(ptr, 0)
880898 }
881- wm.window.SetChild(containerPtr)
882- webkit.WidgetQueueAllocate(containerPtr)
883- webkit.WidgetShow(containerPtr)
884- return nil
885- })
886- }
887-
888- // Focus a suitable pane
889- focusTarget := wm.leftmostLeaf(replacement)
890- if focusTarget != nil {
891- wm.SetActivePane(focusTarget, SourceClose)
892- }
893-
894- // Destroy the webview and detach hover AFTER rearranging hierarchy
895- // Only destroy the webview if this is the final pane, otherwise just clean up
896- ensureCleanup()
897- wm.detachHover(node)
898- switch {
899- case node.isPopup:
900- if err := node.pane.webView.Destroy(); err != nil {
901- log.Printf("[workspace] failed to destroy popup webview: %v", err)
902- }
903- case willBeLastPane:
904- // This is the last pane, safe to destroy completely
905- if err := node.pane.webView.Destroy(); err != nil {
906- log.Printf("[workspace] failed to destroy webview: %v", err)
899+ if endChild != 0 && webkit.WidgetIsValid(endChild) && webkit.WidgetGetParent(endChild) == ptr {
900+ webkit.PanedSetEndChild(ptr, 0)
901+ }
902+ log.Printf("[workspace] cleared paned children before destruction: %#x", ptr)
907903 }
908- default:
909- // Multiple panes remain, don't destroy the window - just clean up the webview
910- log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
911- // TODO: Add a method to destroy just the webview without the window
912- }
913-
914- wm.updateMainPane()
915- // Update CSS classes after pane count changes
916- log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
917- return promoted, nil
904+ // Hook widget destruction
905+ webkit.WidgetHookDestroy(ptr)
906+ log.Printf("[workspace] destroyed paned widget: %#x", ptr)
907+ return nil
908+ })
918909 }
919910
920- var sibling *paneNode
921- if parent.left == node {
922- sibling = parent.right
923- } else {
924- sibling = parent.left
911+ wm.cleanupPane(parent, generation)
912+}
913+
914+// promoteNewRoot handles root replacement when closing the root pane
915+func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
916+ candidate := wm.findReplacementRoot(oldRoot)
917+ if candidate == nil {
918+ return wm.cleanupAndExit(oldRoot)
925919 }
926- if sibling == nil {
927- return nil, errors.New("pane close failed: missing sibling")
920+
921+ sibling := wm.getSibling(candidate)
922+ if sibling != nil {
923+ wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
928924 }
929925
930- grand := parent.parent
926+ candidate.parent = nil
927+ wm.root = candidate
931928
932- ensureSafeWidget := func(owner string, widget **SafeWidget) error {
933- if widget == nil {
934- return nil
935- }
936- current := *widget
937- if current == nil {
938- return fmt.Errorf("%s widget missing", owner)
939- }
940- if current.IsValid() {
941- return nil
942- }
929+ wm.attachRoot(candidate)
943930
944- ptr := current.Ptr()
945- typeInfo := current.typeInfo
946- current.Invalidate()
947- if wm.widgetRegistry != nil && ptr != 0 {
948- wm.widgetRegistry.Unregister(ptr)
949- }
931+ wm.cleanupPane(oldRoot, ctx.Generation())
932+ return candidate, nil
933+}
950934
951- if ptr == 0 || !webkit.WidgetIsValid(ptr) {
952- *widget = nil
953- return fmt.Errorf("%s widget %#x invalidated", owner, ptr)
954- }
935+// cleanupAndExit handles the final pane cleanup and application exit
936+func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
937+ log.Printf("[workspace] closing final pane; exiting browser")
938+ wm.cleanupPane(node, wm.nextCleanupGeneration())
939+ wm.detachHover(node)
940+ if err := node.pane.webView.Destroy(); err != nil {
941+ log.Printf("[workspace] failed to destroy webview: %v", err)
942+ }
943+ webkit.QuitMainLoop()
944+ return nil, nil
945+}
955946
956- if wm.widgetRegistry == nil {
957- return fmt.Errorf("%s widget registry unavailable", owner)
958- }
947+// setFocusToLeaf sets focus to the leftmost leaf of a node
948+func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
949+ focusTarget := wm.leftmostLeaf(node)
950+ if focusTarget != nil {
951+ wm.SetActivePane(focusTarget, SourceClose)
952+ }
953+}
959954
960- reRegistered := wm.widgetRegistry.Register(ptr, typeInfo)
961- if reRegistered == nil {
962- return fmt.Errorf("%s widget %#x re-registration failed", owner, ptr)
963- }
964- *widget = reRegistered
965- log.Printf("[workspace] re-registered %s widget: ptr=%#x type=%s", owner, ptr, typeInfo)
966- return nil
955+// closePane closes a specific pane and handles tree restructuring (simplified implementation)
956+func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
957+ ctx := wm.beginClose(node)
958+ defer ctx.finish()
959+
960+ // STEP 1: Basic validation (quick fail)
961+ if ctx.err != nil {
962+ return nil, ctx.err
967963 }
968964
969- if err := ensureSafeWidget("sibling", &sibling.container); err != nil {
970- log.Printf("[workspace] close aborted: %v", err)
971- return nil, err
965+ // STEP 2: Handle stacked panes via compatibility shim
966+ if node.parent != nil && node.parent.isStacked {
967+ return wm.closeStackedPaneCompat(node)
972968 }
973- if parent.container != nil {
974- if err := ensureSafeWidget("parent", &parent.container); err != nil {
975- log.Printf("[workspace] close aborted: %v", err)
976- return nil, err
977- }
969+
970+ // STEP 3: Handle trivial exit cases
971+ if ctx.remaining == 1 {
972+ return wm.cleanupAndExit(node)
978973 }
979- if grand != nil && grand.container != nil {
980- if err := ensureSafeWidget("grandparent", &grand.container); err != nil {
981- log.Printf("[workspace] close aborted: %v", err)
982- return nil, err
983- }
974+ if node == wm.root {
975+ return wm.promoteNewRoot(ctx, node)
984976 }
985977
986- if err := wm.validateWidgetsForReparenting(sibling, parent, grand); err != nil {
987- log.Printf("[workspace] invalid widget state prior to close: %v", err)
988- return nil, err
989- }
978+ // STEP 4: Promote sibling in-place
979+ parent := node.parent
980+ sibling := wm.getSibling(node)
981+ grandparent := parent.parent
982+ wm.ensureWidgets(grandparent, parent, sibling)
990983
991- log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
984+ wm.promoteSibling(grandparent, parent, sibling)
992985
993- // Clean up workspace tracking
994- node.pane.CleanupFromWorkspace(wm)
986+ // STEP 5: GTK updates leverage auto-unparenting
987+ wm.swapContainers(grandparent, sibling)
995988
996- if wm.mainPane == node {
997- wm.mainPane = nil
989+ // STEP 6: Cleanup & focus
990+ wm.cleanupPane(node, ctx.Generation())
991+ wm.decommissionParent(parent, ctx.Generation())
992+ wm.setFocusToLeaf(sibling)
993+
994+ return sibling, nil
995+}
996+
997+// closeStackedPaneCompat provides compatibility layer for closing stacked panes
998+func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
999+ stack := node.parent
1000+
1001+ // Find index
1002+ index := -1
1003+ for i, pane := range stack.stackedPanes {
1004+ if pane == node {
1005+ index = i
1006+ break
1007+ }
9981008 }
9991009
1000- // Clear current active if it's the node being closed
1001- if wm.GetActiveNode() == node {
1010+ if index == -1 {
1011+ return nil, errors.New("pane not in stack")
10021012 }
10031013
1004- promoted = sibling
1005- if grand == nil {
1006- // Parent is the root node. Promote sibling to become the new root.
1007- log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
1014+ // Remove from array
1015+ stack.stackedPanes = append(
1016+ stack.stackedPanes[:index],
1017+ stack.stackedPanes[index+1:]...,
1018+ )
10081019
1009- // Update tree structure first
1010- wm.root = sibling
1011- sibling.parent = nil // Critical: clear parent pointer for new root
1012-
1013- // For root promotion, handle the widget transition carefully
1014- if wm.window != nil && sibling.container != nil {
1015- sibling.container.Execute(func(containerPtr uintptr) error {
1016- parentBefore := webkit.WidgetGetParent(containerPtr)
1017- log.Printf("[workspace] root promotion: sibling parent before=%#x", parentBefore)
1018-
1019- // Only unparent if it has a parent (the paned)
1020- if parentBefore != 0 {
1021- // Get info about the paned before we destroy it
1022- oldParentStart := webkit.PanedGetStartChild(parentBefore)
1023- oldParentEnd := webkit.PanedGetEndChild(parentBefore)
1024- log.Printf("[workspace] root promotion: old parent start=%#x end=%#x", oldParentStart, oldParentEnd)
1025-
1026- // Unparent the sibling from the paned
1027- webkit.WidgetUnparent(containerPtr)
1028-
1029- // Ensure the widget is properly configured for window child
1030- webkit.WidgetSetHExpand(containerPtr, true)
1031- webkit.WidgetSetVExpand(containerPtr, true)
1032- }
1020+ // If only one pane left, unstack it
1021+ if len(stack.stackedPanes) == 1 {
1022+ remaining := stack.stackedPanes[0]
10331023
1034- // Set as window child
1035- wm.window.SetChild(containerPtr)
1036-
1037- // Queue draw and allocation to ensure proper rendering
1038- webkit.WidgetQueueAllocate(containerPtr)
1039- webkit.WidgetShow(containerPtr)
1040-
1041- // Verify attachment succeeded
1042- parentAfter := webkit.WidgetGetParent(containerPtr)
1043- if parentAfter == 0 {
1044- log.Printf("[workspace] WARNING: root promotion attachment failed, widget has no parent")
1045- // Force another attempt at setting the child
1046- wm.window.SetChild(containerPtr)
1047- webkit.WidgetQueueAllocate(containerPtr)
1048- parentAfter = webkit.WidgetGetParent(containerPtr)
1049- }
1024+ // Replace stack with remaining pane in tree
1025+ remaining.parent = stack.parent
1026+ if stack.parent != nil {
1027+ if stack.parent.left == stack {
1028+ stack.parent.left = remaining
1029+ } else {
1030+ stack.parent.right = remaining
1031+ }
10501032
1051- log.Printf("[workspace] root promotion: sibling parent after=%#x", parentAfter)
1052- if parentAfter != 0 {
1053- log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr)
1054- } else {
1055- log.Printf("[workspace] ERROR: failed to attach promoted sibling as window child: %#x", containerPtr)
1056- }
1033+ // Update GTK widgets
1034+ stack.parent.container.Execute(func(pPtr uintptr) error {
1035+ remaining.container.Execute(func(rPtr uintptr) error {
1036+ if stack.parent.left == remaining {
1037+ webkit.PanedSetStartChild(pPtr, rPtr)
1038+ } else {
1039+ webkit.PanedSetEndChild(pPtr, rPtr)
1040+ }
1041+ return nil
1042+ })
10571043 return nil
10581044 })
1059- }
1060- } else {
1061- // Parent has a grandparent, so promote sibling to take parent's place
1062- log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
1063-
1064- // Update tree structure
1065- sibling.parent = grand
1066- if grand.left == parent {
1067- grand.left = sibling
10681045 } else {
1069- grand.right = sibling
1070- }
1071-
1072- // Move sibling to grandparent's paned widget
1073- // GTK4 handles unparenting automatically when we set the new paned child
1074- grand.container.Execute(func(grandPtr uintptr) error {
1075- sibling.container.Execute(func(siblingPtr uintptr) error {
1076- parentBefore := webkit.WidgetGetParent(siblingPtr)
1077- log.Printf("[workspace] promotion: sibling current parent=%#x grand=%#x", parentBefore, grandPtr)
1078-
1079- startBefore := webkit.PanedGetStartChild(grandPtr)
1080- endBefore := webkit.PanedGetEndChild(grandPtr)
1081- log.Printf("[workspace] promotion: grand start before=%#x end before=%#x", startBefore, endBefore)
1082-
1083- if grand.left == sibling {
1084- webkit.PanedSetStartChild(grandPtr, siblingPtr)
1085- } else {
1086- webkit.PanedSetEndChild(grandPtr, siblingPtr)
1087- }
1088-
1089- webkit.WidgetQueueAllocate(grandPtr)
1090- webkit.WidgetQueueAllocate(siblingPtr)
1091- webkit.WidgetShow(siblingPtr)
1092-
1093- parentAfter := webkit.WidgetGetParent(siblingPtr)
1094- startAfter := webkit.PanedGetStartChild(grandPtr)
1095- endAfter := webkit.PanedGetEndChild(grandPtr)
1096- log.Printf("[workspace] promotion: sibling parent after=%#x", parentAfter)
1097- log.Printf("[workspace] promotion: grand start after=%#x end after=%#x", startAfter, endAfter)
1098- if parentAfter == 0 {
1099- log.Printf("[workspace] WARNING: sibling widget %#x has no parent after promotion", siblingPtr)
1100- }
1101- log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr)
1046+ // Stack was root
1047+ wm.root = remaining
1048+ remaining.container.Execute(func(ptr uintptr) error {
1049+ wm.window.SetChild(ptr)
11021050 return nil
11031051 })
1052+ }
11041053
1105- // Also queue allocate the grandparent to ensure layout updates
1106- return nil
1107- })
1108- }
1054+ // Cleanup stack container
1055+ wm.cleanupPane(stack, wm.nextCleanupGeneration())
11091056
1110- // Parent container is now detached; ensure it is cleaned up to avoid lingering invalid widgets
1111- if parent.container != nil {
1112- ptr := parent.container.Ptr()
1113- parent.container.Invalidate()
1114- if wm.widgetRegistry != nil && ptr != 0 {
1115- wm.widgetRegistry.Unregister(ptr)
1116- }
1117- parent.container = nil
1057+ return remaining, nil
11181058 }
1119- parent.left = nil
1120- parent.right = nil
1121- parent.parent = nil
11221059
1123- // Find a suitable focus target
1124- focusTarget := wm.leftmostLeaf(sibling)
1125- if focusTarget != nil {
1126- wm.SetActivePane(focusTarget, SourceClose)
1060+ // Update active index if needed
1061+ if stack.activeStackIndex >= len(stack.stackedPanes) {
1062+ stack.activeStackIndex = len(stack.stackedPanes) - 1
11271063 }
11281064
1129- // Clean up the node being closed
1130- ensureCleanup()
1131- wm.detachHover(node)
1065+ // Remove widget from GTK box
1066+ stack.container.Execute(func(boxPtr uintptr) error {
1067+ node.container.Execute(func(nodePtr uintptr) error {
1068+ webkit.BoxRemove(boxPtr, nodePtr)
1069+ return nil
1070+ })
1071+ return nil
1072+ })
11321073
1133- switch {
1134- case node.isPopup:
1135- if err := node.pane.webView.Destroy(); err != nil {
1136- log.Printf("[workspace] failed to destroy popup webview: %v", err)
1137- }
1138- case willBeLastPane:
1139- // This is the last pane, safe to destroy completely
1140- if err := node.pane.webView.Destroy(); err != nil {
1141- log.Printf("[workspace] failed to destroy webview: %v", err)
1142- }
1143- default:
1144- // Multiple panes remain, don't destroy the window - just clean up the webview
1145- log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1146- // TODO: Add a method to destroy just the webview without the window
1074+ // Show new active pane
1075+ if stack.activeStackIndex >= 0 {
1076+ active := stack.stackedPanes[stack.activeStackIndex]
1077+ active.container.Execute(func(ptr uintptr) error {
1078+ webkit.WidgetShow(ptr)
1079+ return nil
1080+ })
11471081 }
11481082
1149- wm.updateMainPane()
1150- // Update CSS classes after pane count changes
1151- log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
1152- return promoted, nil
1083+ // Cleanup closed pane
1084+ wm.cleanupPane(node, wm.nextCleanupGeneration())
1085+
1086+ return stack, nil
11531087 }
@@ -3,12 +3,48 @@ package browser
3 3
4 import (4 import (
5 "errors"5 "errors"
6- "fmt"
7 "log"6 "log"
8 7
9 "github.com/bnema/dumber/pkg/webkit"8 "github.com/bnema/dumber/pkg/webkit"
10 )9 )
11 10
11+// closeContext manages the context for a pane close operation
12+type closeContext struct {
13+ wm *WorkspaceManager
14+ target *paneNode
15+ remaining int
16+ err error
17+ generation uint
18+}
19+
20+// beginClose initializes a close context with validation
21+func (wm *WorkspaceManager) beginClose(node *paneNode) closeContext {
22+ ctx := closeContext{wm: wm, target: node, generation: wm.nextCleanupGeneration()}
23+ switch {
24+ case wm == nil:
25+ ctx.err = errors.New("workspace manager nil")
26+ case node == nil || !node.isLeaf:
27+ ctx.err = errors.New("invalid close target")
28+ case node.pane == nil || node.pane.webView == nil:
29+ ctx.err = errors.New("close target missing webview")
30+ default:
31+ ctx.remaining = len(wm.app.panes)
32+ }
33+ return ctx
34+}
35+
36+// Generation returns the cleanup generation for this context
37+func (ctx closeContext) Generation() uint {
38+ return ctx.generation
39+}
40+
41+// finish handles cleanup operations after close completion
42+func (ctx closeContext) finish() {
43+ if ctx.err == nil {
44+ ctx.wm.updateMainPane()
45+ }
46+}
47+
12 // collectLeaves returns all leaf nodes in the workspace tree48 // collectLeaves returns all leaf nodes in the workspace tree
13 func (wm *WorkspaceManager) collectLeaves() []*paneNode {49 func (wm *WorkspaceManager) collectLeaves() []*paneNode {
14 return wm.collectLeavesFrom(wm.root)50 return wm.collectLeavesFrom(wm.root)
@@ -703,451 +739,349 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
703 return newLeaf, nil739 return newLeaf, nil
704 }740 }
705 741
706-// closePane closes a specific pane and handles tree restructuring742+// Helper functions for the simplified closePane implementation
707-func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {743+
708- if wm == nil || node == nil || !node.isLeaf {744+// getSibling returns the sibling node for a given node
709- return nil, errors.New("close target must be a leaf pane")745+func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
746+ if node.parent == nil {
747+ return nil
710 }748 }
711- if node.pane == nil || node.pane.webView == nil {749+ if node.parent.left == node {
712- return nil, errors.New("close target missing webview")750+ return node.parent.right
713 }751 }
752+ return node.parent.left
753+}
714 754
715- var promoted *paneNode755+// nextCleanupGeneration returns the next cleanup generation counter
756+func (wm *WorkspaceManager) nextCleanupGeneration() uint {
757+ wm.cleanupCounter++
758+ return wm.cleanupCounter
759+}
716 760
717- // Handle closing panes in a stack761+// ensureWidgets validates and recovers SafeWidget instances
718- if node.parent != nil && node.parent.isStacked {762+func (wm *WorkspaceManager) ensureWidgets(nodes ...*paneNode) {
719- return nil, wm.stackedPaneManager.CloseStackedPane(node)763+ for _, n := range nodes {
764+ if n == nil || n.container == nil {
765+ continue
766+ }
767+ if n.container.IsValid() {
768+ continue
769+ }
770+ ptr := n.container.Ptr()
771+ typeInfo := n.container.typeInfo
772+ n.container = wm.widgetRegistry.Register(ptr, typeInfo)
720 }773 }
774+}
721 775
722- if node.parent != nil && node.parent.container != nil {776+// promoteSibling promotes a sibling node to replace its parent in the tree
723- node.parent.container.Execute(func(panedPtr uintptr) error {777+func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
724- if node.parent.left == node {778+ if grand == nil {
725- webkit.PanedSetStartChild(panedPtr, 0)779+ wm.root = sibling
726- // GTK4 automatically unparents when setting child to 0780+ sibling.parent = nil
727- } else if node.parent.right == node {781+ return
728- webkit.PanedSetEndChild(panedPtr, 0)
729- // GTK4 automatically unparents when setting child to 0
730- }
731- return nil
732- })
733- } else if node.container != nil {
734- // Only manually unparent if widget wasn't auto-unparented above
735- node.container.Execute(func(containerPtr uintptr) error {
736- if webkit.WidgetGetParent(containerPtr) != 0 {
737- webkit.WidgetUnparent(containerPtr)
738- }
739- return nil
740- })
741 }782 }
783+ sibling.parent = grand
784+ if grand.left == parent {
785+ grand.left = sibling
786+ } else {
787+ grand.right = sibling
788+ }
789+}
742 790
743- remaining := len(wm.app.panes)791+// swapContainers updates GTK widget hierarchy for promoted siblings
744- willBeLastPane := remaining <= 1792+func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
745- paneCleaned := false793+ if grand == nil {
746- ensureCleanup := func() {794+ // When TreeRebalancer is enabled, skip GTK attachment here as it will be
747- if paneCleaned {795+ // handled by the rebalancer's promotion transaction to avoid double-attachment
748- return796+ if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
797+ wm.attachRoot(sibling)
749 }798 }
750- node.pane.Cleanup()799+ return
751- paneCleaned = true
752 }800 }
753-801+ grand.container.Execute(func(gPtr uintptr) error {
754- if remaining == 2 {802+ sibling.container.Execute(func(sPtr uintptr) error {
755- var sibling *paneNode803+ // GTK4 auto-unparents when setting paned child
756- if node.parent != nil {804+ // But we should verify the widget is ready for reparenting
757- if node.parent.left == node {805+ if parent := webkit.WidgetGetParent(sPtr); parent != 0 && parent != gPtr {
758- sibling = node.parent.right806+ log.Printf("[workspace] widget %#x has unexpected parent %#x, expected %#x or 0", sPtr, parent, gPtr)
759- } else if node.parent.right == node {
760- sibling = node.parent.left
761 }807 }
762- }
763- if sibling != nil && sibling.isLeaf && sibling.pane != nil && sibling.pane.webView != nil {
764- if sibling.isPopup && !node.isPopup {
765- parentNode := node.parent
766- var parentContainer *SafeWidget
767- if parentNode != nil {
768- parentContainer = parentNode.container
769- }
770-
771- // Detach remaining GTK widgets before destroying the WebViews so GTK
772- // does not attempt to dispose still-parented children.
773- if parentContainer != nil {
774- parentContainer.Execute(func(panedPtr uintptr) error {
775- if parentNode.left == sibling {
776- webkit.PanedSetStartChild(panedPtr, 0)
777- } else if parentNode.right == sibling {
778- webkit.PanedSetEndChild(panedPtr, 0)
779- }
780- return nil
781- })
782- }
783- if sibling.container != nil {
784- sibling.container.Execute(func(containerPtr uintptr) error {
785- webkit.WidgetUnparent(containerPtr)
786- return nil
787- })
788- }
789- if parentContainer != nil && wm.window != nil {
790- wm.window.SetChild(0)
791- }
792-
793- log.Printf("[workspace] closing primary pane with related popup present; exiting")
794- ensureCleanup()
795- sibling.pane.Cleanup()
796 808
797- wm.detachHover(node)809+ if grand.left == sibling {
798- wm.detachHover(sibling)810+ webkit.PanedSetStartChild(gPtr, sPtr)
799-811+ log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sPtr, gPtr)
800- // Clean up workspace tracking for both panes812+ } else {
801- node.pane.CleanupFromWorkspace(wm)813+ webkit.PanedSetEndChild(gPtr, sPtr)
802- sibling.pane.CleanupFromWorkspace(wm)814+ log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sPtr, gPtr)
815+ }
816+ webkit.WidgetQueueAllocate(gPtr)
817+ return nil
818+ })
819+ return nil
820+ })
821+}
803 822
804- if node.pane.webView != nil {823+// attachRoot attaches a node as the new window root
805- if err := node.pane.webView.Destroy(); err != nil {824+func (wm *WorkspaceManager) attachRoot(root *paneNode) {
806- log.Printf("[workspace] failed to destroy primary webview: %v", err)825+ if root == nil || root.container == nil || wm.window == nil {
807- }826+ return
808- }827+ }
809- if sibling.pane.webView != nil {828+ root.container.Execute(func(ptr uintptr) error {
810- if err := sibling.pane.webView.Destroy(); err != nil {829+ // Check if widget has a parent and unparent it first
811- log.Printf("[workspace] failed to destroy popup webview: %v", err)830+ if parent := webkit.WidgetGetParent(ptr); parent != 0 {
812- }831+ log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", ptr, parent)
813- }832+ webkit.WidgetUnparent(ptr)
833+ }
814 834
815- wm.root = nil835+ // Now safe to set as window child
816- wm.mainPane = nil836+ wm.window.SetChild(ptr)
837+ webkit.WidgetQueueAllocate(ptr)
838+ webkit.WidgetShow(ptr)
817 839
818- webkit.QuitMainLoop()840+ // Verify attachment succeeded
819- return nil, nil841+ if finalParent := webkit.WidgetGetParent(ptr); finalParent == 0 {
820- }842+ log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", ptr)
843+ } else {
844+ log.Printf("[workspace] attachRoot successful: widget %#x now child of %#x", ptr, finalParent)
821 }845 }
846+ return nil
847+ })
848+}
849+
850+// cleanupPane safely cleans up a pane node with generation tracking
851+func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
852+ if node == nil {
853+ return
822 }854 }
823- if willBeLastPane && wm.root == node {855+ if !node.widgetValid {
824- log.Printf("[workspace] closing final pane; exiting browser")856+ return
825- ensureCleanup()
826- wm.detachHover(node)
827- if err := node.pane.webView.Destroy(); err != nil {
828- log.Printf("[workspace] failed to destroy webview: %v", err)
829- }
830- webkit.QuitMainLoop()
831- return nil, nil
832 }857 }
833 858
834- parent := node.parent859+ node.widgetValid = false
835- if parent == nil {860+ node.cleanupGeneration = generation
836- // This is the root pane (no parent in tree structure)
837- if node != wm.root {
838- return nil, errors.New("inconsistent state: node has no parent but is not root")
839- }
840 861
841- // Check if we can find a replacement root862+ if node.pane != nil {
842- replacement := wm.findReplacementRoot(node)863+ node.pane.CleanupFromWorkspace(wm)
843- if replacement == nil {864+ if node.pane.webView != nil {
844- // No other panes exist, this is the final pane865+ node.pane.webView.Destroy()
845- log.Printf("[workspace] closing final pane; exiting browser")
846- ensureCleanup()
847- wm.detachHover(node)
848- if err := node.pane.webView.Destroy(); err != nil {
849- log.Printf("[workspace] failed to destroy webview: %v", err)
850- }
851- webkit.QuitMainLoop()
852- return nil, nil
853 }866 }
867+ }
854 868
855- // We have a replacement - delegate root status and close this pane869+ if node.container != nil {
856- log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)870+ node.container.Invalidate()
871+ node.container = nil
872+ }
857 873
858- // Clean up workspace tracking874+ node.parent = nil
859- node.pane.CleanupFromWorkspace(wm)875+ node.left = nil
876+ node.right = nil
877+}
860 878
861- if wm.mainPane == node {879+// decommissionParent cleans up a parent node after promotion
862- wm.mainPane = nil880+func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
863- }881+ if parent == nil {
882+ return
883+ }
864 884
865- // Clear current active if it's the node being closed885+ // For branch nodes (paneds), ensure the GTK widget is destroyed properly
866- if wm.GetActiveNode() == node {886+ if !parent.isLeaf && parent.container != nil {
867- }887+ parent.container.Execute(func(ptr uintptr) error {
888+ // Clear any remaining children first to ensure clean destruction
889+ // Note: Children may already be reparented during promotion, so verify parent relationship
890+ if webkit.IsPaned(ptr) {
891+ startChild := webkit.PanedGetStartChild(ptr)
892+ endChild := webkit.PanedGetEndChild(ptr)
868 893
869- // Set replacement as new root894+ // Only clear children that are still valid AND still parented to this paned
870- wm.root = replacement895+ // If the child was reparented during swapContainers, its parent will be different
871- replacement.parent = nil // Critical: clear parent pointer for new root896+ if startChild != 0 && webkit.WidgetIsValid(startChild) && webkit.WidgetGetParent(startChild) == ptr {
872- promoted = replacement897+ webkit.PanedSetStartChild(ptr, 0)
873-
874- // Replace window child - must unparent first to satisfy GTK4 assertion
875- if wm.window != nil && replacement.container != nil {
876- replacement.container.Execute(func(containerPtr uintptr) error {
877- // Unparent from current parent before setting as window child
878- if webkit.WidgetGetParent(containerPtr) != 0 {
879- webkit.WidgetUnparent(containerPtr)
880 }898 }
881- wm.window.SetChild(containerPtr)899+ if endChild != 0 && webkit.WidgetIsValid(endChild) && webkit.WidgetGetParent(endChild) == ptr {
882- webkit.WidgetQueueAllocate(containerPtr)900+ webkit.PanedSetEndChild(ptr, 0)
883- webkit.WidgetShow(containerPtr)901+ }
884- return nil902+ log.Printf("[workspace] cleared paned children before destruction: %#x", ptr)
885- })
886- }
887-
888- // Focus a suitable pane
889- focusTarget := wm.leftmostLeaf(replacement)
890- if focusTarget != nil {
891- wm.SetActivePane(focusTarget, SourceClose)
892- }
893-
894- // Destroy the webview and detach hover AFTER rearranging hierarchy
895- // Only destroy the webview if this is the final pane, otherwise just clean up
896- ensureCleanup()
897- wm.detachHover(node)
898- switch {
899- case node.isPopup:
900- if err := node.pane.webView.Destroy(); err != nil {
901- log.Printf("[workspace] failed to destroy popup webview: %v", err)
902- }
903- case willBeLastPane:
904- // This is the last pane, safe to destroy completely
905- if err := node.pane.webView.Destroy(); err != nil {
906- log.Printf("[workspace] failed to destroy webview: %v", err)
907 }903 }
908- default:904+ // Hook widget destruction
909- // Multiple panes remain, don't destroy the window - just clean up the webview905+ webkit.WidgetHookDestroy(ptr)
910- log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)906+ log.Printf("[workspace] destroyed paned widget: %#x", ptr)
911- // TODO: Add a method to destroy just the webview without the window907+ return nil
912- }908+ })
913-
914- wm.updateMainPane()
915- // Update CSS classes after pane count changes
916- log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
917- return promoted, nil
918 }909 }
919 910
920- var sibling *paneNode911+ wm.cleanupPane(parent, generation)
921- if parent.left == node {912+}
922- sibling = parent.right913+
923- } else {914+// promoteNewRoot handles root replacement when closing the root pane
924- sibling = parent.left915+func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
916+ candidate := wm.findReplacementRoot(oldRoot)
917+ if candidate == nil {
918+ return wm.cleanupAndExit(oldRoot)
925 }919 }
926- if sibling == nil {920+
927- return nil, errors.New("pane close failed: missing sibling")921+ sibling := wm.getSibling(candidate)
922+ if sibling != nil {
923+ wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
928 }924 }
929 925
930- grand := parent.parent926+ candidate.parent = nil
927+ wm.root = candidate
931 928
932- ensureSafeWidget := func(owner string, widget **SafeWidget) error {929+ wm.attachRoot(candidate)
933- if widget == nil {
934- return nil
935- }
936- current := *widget
937- if current == nil {
938- return fmt.Errorf("%s widget missing", owner)
939- }
940- if current.IsValid() {
941- return nil
942- }
943 930
944- ptr := current.Ptr()931+ wm.cleanupPane(oldRoot, ctx.Generation())
945- typeInfo := current.typeInfo932+ return candidate, nil
946- current.Invalidate()933+}
947- if wm.widgetRegistry != nil && ptr != 0 {
948- wm.widgetRegistry.Unregister(ptr)
949- }
950 934
951- if ptr == 0 || !webkit.WidgetIsValid(ptr) {935+// cleanupAndExit handles the final pane cleanup and application exit
952- *widget = nil936+func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
953- return fmt.Errorf("%s widget %#x invalidated", owner, ptr)937+ log.Printf("[workspace] closing final pane; exiting browser")
954- }938+ wm.cleanupPane(node, wm.nextCleanupGeneration())
939+ wm.detachHover(node)
940+ if err := node.pane.webView.Destroy(); err != nil {
941+ log.Printf("[workspace] failed to destroy webview: %v", err)
942+ }
943+ webkit.QuitMainLoop()
944+ return nil, nil
945+}
955 946
956- if wm.widgetRegistry == nil {947+// setFocusToLeaf sets focus to the leftmost leaf of a node
957- return fmt.Errorf("%s widget registry unavailable", owner)948+func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
958- }949+ focusTarget := wm.leftmostLeaf(node)
950+ if focusTarget != nil {
951+ wm.SetActivePane(focusTarget, SourceClose)
952+ }
953+}
959 954
960- reRegistered := wm.widgetRegistry.Register(ptr, typeInfo)955+// closePane closes a specific pane and handles tree restructuring (simplified implementation)
961- if reRegistered == nil {956+func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
962- return fmt.Errorf("%s widget %#x re-registration failed", owner, ptr)957+ ctx := wm.beginClose(node)
963- }958+ defer ctx.finish()
964- *widget = reRegistered959+
965- log.Printf("[workspace] re-registered %s widget: ptr=%#x type=%s", owner, ptr, typeInfo)960+ // STEP 1: Basic validation (quick fail)
966- return nil961+ if ctx.err != nil {
962+ return nil, ctx.err
967 }963 }
968 964
969- if err := ensureSafeWidget("sibling", &sibling.container); err != nil {965+ // STEP 2: Handle stacked panes via compatibility shim
970- log.Printf("[workspace] close aborted: %v", err)966+ if node.parent != nil && node.parent.isStacked {
971- return nil, err967+ return wm.closeStackedPaneCompat(node)
972 }968 }
973- if parent.container != nil {969+
974- if err := ensureSafeWidget("parent", &parent.container); err != nil {970+ // STEP 3: Handle trivial exit cases
975- log.Printf("[workspace] close aborted: %v", err)971+ if ctx.remaining == 1 {
976- return nil, err972+ return wm.cleanupAndExit(node)
977- }
978 }973 }
979- if grand != nil && grand.container != nil {974+ if node == wm.root {
980- if err := ensureSafeWidget("grandparent", &grand.container); err != nil {975+ return wm.promoteNewRoot(ctx, node)
981- log.Printf("[workspace] close aborted: %v", err)
982- return nil, err
983- }
984 }976 }
985 977
986- if err := wm.validateWidgetsForReparenting(sibling, parent, grand); err != nil {978+ // STEP 4: Promote sibling in-place
987- log.Printf("[workspace] invalid widget state prior to close: %v", err)979+ parent := node.parent
988- return nil, err980+ sibling := wm.getSibling(node)
989- }981+ grandparent := parent.parent
982+ wm.ensureWidgets(grandparent, parent, sibling)
990 983
991- log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)984+ wm.promoteSibling(grandparent, parent, sibling)
992 985
993- // Clean up workspace tracking986+ // STEP 5: GTK updates leverage auto-unparenting
994- node.pane.CleanupFromWorkspace(wm)987+ wm.swapContainers(grandparent, sibling)
995 988
996- if wm.mainPane == node {989+ // STEP 6: Cleanup & focus
997- wm.mainPane = nil990+ wm.cleanupPane(node, ctx.Generation())
991+ wm.decommissionParent(parent, ctx.Generation())
992+ wm.setFocusToLeaf(sibling)
993+
994+ return sibling, nil
995+}
996+
997+// closeStackedPaneCompat provides compatibility layer for closing stacked panes
998+func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
999+ stack := node.parent
1000+
1001+ // Find index
1002+ index := -1
1003+ for i, pane := range stack.stackedPanes {
1004+ if pane == node {
1005+ index = i
1006+ break
1007+ }
998 }1008 }
999 1009
1000- // Clear current active if it's the node being closed1010+ if index == -1 {
1001- if wm.GetActiveNode() == node {1011+ return nil, errors.New("pane not in stack")
1002 }1012 }
1003 1013
1004- promoted = sibling1014+ // Remove from array
1005- if grand == nil {1015+ stack.stackedPanes = append(
1006- // Parent is the root node. Promote sibling to become the new root.1016+ stack.stackedPanes[:index],
1007- log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)1017+ stack.stackedPanes[index+1:]...,
1018+ )
1008 1019
1009- // Update tree structure first1020+ // If only one pane left, unstack it
1010- wm.root = sibling1021+ if len(stack.stackedPanes) == 1 {
1011- sibling.parent = nil // Critical: clear parent pointer for new root1022+ remaining := stack.stackedPanes[0]
1012-
1013- // For root promotion, handle the widget transition carefully
1014- if wm.window != nil && sibling.container != nil {
1015- sibling.container.Execute(func(containerPtr uintptr) error {
1016- parentBefore := webkit.WidgetGetParent(containerPtr)
1017- log.Printf("[workspace] root promotion: sibling parent before=%#x", parentBefore)
1018-
1019- // Only unparent if it has a parent (the paned)
1020- if parentBefore != 0 {
1021- // Get info about the paned before we destroy it
1022- oldParentStart := webkit.PanedGetStartChild(parentBefore)
1023- oldParentEnd := webkit.PanedGetEndChild(parentBefore)
1024- log.Printf("[workspace] root promotion: old parent start=%#x end=%#x", oldParentStart, oldParentEnd)
1025-
1026- // Unparent the sibling from the paned
1027- webkit.WidgetUnparent(containerPtr)
1028-
1029- // Ensure the widget is properly configured for window child
1030- webkit.WidgetSetHExpand(containerPtr, true)
1031- webkit.WidgetSetVExpand(containerPtr, true)
1032- }
1033 1023
1034- // Set as window child1024+ // Replace stack with remaining pane in tree
1035- wm.window.SetChild(containerPtr)1025+ remaining.parent = stack.parent
1036-1026+ if stack.parent != nil {
1037- // Queue draw and allocation to ensure proper rendering1027+ if stack.parent.left == stack {
1038- webkit.WidgetQueueAllocate(containerPtr)1028+ stack.parent.left = remaining
1039- webkit.WidgetShow(containerPtr)1029+ } else {
1040-1030+ stack.parent.right = remaining
1041- // Verify attachment succeeded1031+ }
1042- parentAfter := webkit.WidgetGetParent(containerPtr)
1043- if parentAfter == 0 {
1044- log.Printf("[workspace] WARNING: root promotion attachment failed, widget has no parent")
1045- // Force another attempt at setting the child
1046- wm.window.SetChild(containerPtr)
1047- webkit.WidgetQueueAllocate(containerPtr)
1048- parentAfter = webkit.WidgetGetParent(containerPtr)
1049- }
1050 1032
1051- log.Printf("[workspace] root promotion: sibling parent after=%#x", parentAfter)1033+ // Update GTK widgets
1052- if parentAfter != 0 {1034+ stack.parent.container.Execute(func(pPtr uintptr) error {
1053- log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr)1035+ remaining.container.Execute(func(rPtr uintptr) error {
1054- } else {1036+ if stack.parent.left == remaining {
1055- log.Printf("[workspace] ERROR: failed to attach promoted sibling as window child: %#x", containerPtr)1037+ webkit.PanedSetStartChild(pPtr, rPtr)
1056- }1038+ } else {
1039+ webkit.PanedSetEndChild(pPtr, rPtr)
1040+ }
1041+ return nil
1042+ })
1057 return nil1043 return nil
1058 })1044 })
1059- }
1060- } else {
1061- // Parent has a grandparent, so promote sibling to take parent's place
1062- log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
1063-
1064- // Update tree structure
1065- sibling.parent = grand
1066- if grand.left == parent {
1067- grand.left = sibling
1068 } else {1045 } else {
1069- grand.right = sibling1046+ // Stack was root
1070- }1047+ wm.root = remaining
1071-1048+ remaining.container.Execute(func(ptr uintptr) error {
1072- // Move sibling to grandparent's paned widget1049+ wm.window.SetChild(ptr)
1073- // GTK4 handles unparenting automatically when we set the new paned child
1074- grand.container.Execute(func(grandPtr uintptr) error {
1075- sibling.container.Execute(func(siblingPtr uintptr) error {
1076- parentBefore := webkit.WidgetGetParent(siblingPtr)
1077- log.Printf("[workspace] promotion: sibling current parent=%#x grand=%#x", parentBefore, grandPtr)
1078-
1079- startBefore := webkit.PanedGetStartChild(grandPtr)
1080- endBefore := webkit.PanedGetEndChild(grandPtr)
1081- log.Printf("[workspace] promotion: grand start before=%#x end before=%#x", startBefore, endBefore)
1082-
1083- if grand.left == sibling {
1084- webkit.PanedSetStartChild(grandPtr, siblingPtr)
1085- } else {
1086- webkit.PanedSetEndChild(grandPtr, siblingPtr)
1087- }
1088-
1089- webkit.WidgetQueueAllocate(grandPtr)
1090- webkit.WidgetQueueAllocate(siblingPtr)
1091- webkit.WidgetShow(siblingPtr)
1092-
1093- parentAfter := webkit.WidgetGetParent(siblingPtr)
1094- startAfter := webkit.PanedGetStartChild(grandPtr)
1095- endAfter := webkit.PanedGetEndChild(grandPtr)
1096- log.Printf("[workspace] promotion: sibling parent after=%#x", parentAfter)
1097- log.Printf("[workspace] promotion: grand start after=%#x end after=%#x", startAfter, endAfter)
1098- if parentAfter == 0 {
1099- log.Printf("[workspace] WARNING: sibling widget %#x has no parent after promotion", siblingPtr)
1100- }
1101- log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr)
1102 return nil1050 return nil
1103 })1051 })
1052+ }
1104 1053
1105- // Also queue allocate the grandparent to ensure layout updates1054+ // Cleanup stack container
1106- return nil1055+ wm.cleanupPane(stack, wm.nextCleanupGeneration())
1107- })
1108- }
1109 1056
1110- // Parent container is now detached; ensure it is cleaned up to avoid lingering invalid widgets1057+ return remaining, nil
1111- if parent.container != nil {
1112- ptr := parent.container.Ptr()
1113- parent.container.Invalidate()
1114- if wm.widgetRegistry != nil && ptr != 0 {
1115- wm.widgetRegistry.Unregister(ptr)
1116- }
1117- parent.container = nil
1118 }1058 }
1119- parent.left = nil
1120- parent.right = nil
1121- parent.parent = nil
1122 1059
1123- // Find a suitable focus target1060+ // Update active index if needed
1124- focusTarget := wm.leftmostLeaf(sibling)1061+ if stack.activeStackIndex >= len(stack.stackedPanes) {
1125- if focusTarget != nil {1062+ stack.activeStackIndex = len(stack.stackedPanes) - 1
1126- wm.SetActivePane(focusTarget, SourceClose)
1127 }1063 }
1128 1064
1129- // Clean up the node being closed1065+ // Remove widget from GTK box
1130- ensureCleanup()1066+ stack.container.Execute(func(boxPtr uintptr) error {
1131- wm.detachHover(node)1067+ node.container.Execute(func(nodePtr uintptr) error {
1068+ webkit.BoxRemove(boxPtr, nodePtr)
1069+ return nil
1070+ })
1071+ return nil
1072+ })
1132 1073
1133- switch {1074+ // Show new active pane
1134- case node.isPopup:1075+ if stack.activeStackIndex >= 0 {
1135- if err := node.pane.webView.Destroy(); err != nil {1076+ active := stack.stackedPanes[stack.activeStackIndex]
1136- log.Printf("[workspace] failed to destroy popup webview: %v", err)1077+ active.container.Execute(func(ptr uintptr) error {
1137- }1078+ webkit.WidgetShow(ptr)
1138- case willBeLastPane:1079+ return nil
1139- // This is the last pane, safe to destroy completely1080+ })
1140- if err := node.pane.webView.Destroy(); err != nil {
1141- log.Printf("[workspace] failed to destroy webview: %v", err)
1142- }
1143- default:
1144- // Multiple panes remain, don't destroy the window - just clean up the webview
1145- log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1146- // TODO: Add a method to destroy just the webview without the window
1147 }1081 }
1148 1082
1149- wm.updateMainPane()1083+ // Cleanup closed pane
1150- // Update CSS classes after pane count changes1084+ wm.cleanupPane(node, wm.nextCleanupGeneration())
1151- log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))1085+
1152- return promoted, nil1086+ return stack, nil
1153 }1087 }

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