fix(lifecycle): release rebuilt pane and split owners
- commit
- 1c59f12460ce06b06e702148e9d0ba8b44a94b7d
- author
- brice · · 2026-07-14 06:22 UTC
- committer
- brice · · 2026-07-14 07:06 UTC
- parent
- 81a8f56
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +37 -5
M internal/ui/component/pane_view.go +5 -0
| @@ -516,6 +516,7 @@ func (pv *PaneView) Cleanup() { | |||
| 516 | 516 | pv.onFocusIn = nil | |
| 517 | 517 | pv.onFocusOut = nil | |
| 518 | 518 | pv.onHover = nil | |
| 519 | + | pv.onMouseMotion = nil | |
| 519 | 520 | ||
| 520 | 521 | // Detach hover handler if present | |
| 521 | 522 | if pv.hoverHandler != nil { | |
| @@ -530,6 +531,10 @@ func (pv *PaneView) Cleanup() { | |||
| 530 | 531 | } | |
| 531 | 532 | ||
| 532 | 533 | // Clear other overlay children | |
| 534 | + | if pv.loading != nil { | |
| 535 | + | pv.overlay.RemoveOverlay(pv.loading.Widget()) | |
| 536 | + | pv.loading = nil | |
| 537 | + | } | |
| 533 | 538 | if pv.progressBar != nil { | |
| 534 | 539 | pv.overlay.RemoveOverlay(pv.progressBar.Widget()) | |
| 535 | 540 | pv.progressBar = nil | |
| @@ -516,6 +516,7 @@ func (pv *PaneView) Cleanup() { | |||||
| 516 | pv.onFocusIn = nil | 516 | pv.onFocusIn = nil | ||
| 517 | pv.onFocusOut = nil | 517 | pv.onFocusOut = nil | ||
| 518 | pv.onHover = nil | 518 | pv.onHover = nil | ||
| 519 | + | pv.onMouseMotion = nil | |||
| 519 | 520 | ||||
| 520 | // Detach hover handler if present | 521 | // Detach hover handler if present | ||
| 521 | if pv.hoverHandler != nil { | 522 | if pv.hoverHandler != nil { | ||
| @@ -530,6 +531,10 @@ func (pv *PaneView) Cleanup() { | |||||
| 530 | } | 531 | } | ||
| 531 | 532 | ||||
| 532 | // Clear other overlay children | 533 | // Clear other overlay children | ||
| 534 | + | if pv.loading != nil { | |||
| 535 | + | pv.overlay.RemoveOverlay(pv.loading.Widget()) | |||
| 536 | + | pv.loading = nil | |||
| 537 | + | } | |||
| 533 | if pv.progressBar != nil { | 538 | if pv.progressBar != nil { | ||
| 534 | pv.overlay.RemoveOverlay(pv.progressBar.Widget()) | 539 | pv.overlay.RemoveOverlay(pv.progressBar.Widget()) | ||
| 535 | pv.progressBar = nil | 540 | pv.progressBar = nil | ||
M internal/ui/component/workspace_view.go +8 -0
| @@ -192,6 +192,14 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) | |||
| 192 | 192 | wv.mu.Lock() | |
| 193 | 193 | defer wv.mu.Unlock() | |
| 194 | 194 | ||
| 195 | + | // Release previous views before dropping their ownership. This also detaches | |
| 196 | + | // hover controllers and callback closures before their widgets are rebuilt. | |
| 197 | + | for _, pv := range wv.paneViews { | |
| 198 | + | if pv != nil { | |
| 199 | + | pv.Cleanup() | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 195 | 203 | // Clear previous state | |
| 196 | 204 | wv.workspace = ws | |
| 197 | 205 | wv.paneViews = make(map[entity.PaneID]*PaneView) | |
| @@ -192,6 +192,14 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) | |||||
| 192 | wv.mu.Lock() | 192 | wv.mu.Lock() | ||
| 193 | defer wv.mu.Unlock() | 193 | defer wv.mu.Unlock() | ||
| 194 | 194 | ||||
| 195 | + | // Release previous views before dropping their ownership. This also detaches | |||
| 196 | + | // hover controllers and callback closures before their widgets are rebuilt. | |||
| 197 | + | for _, pv := range wv.paneViews { | |||
| 198 | + | if pv != nil { | |||
| 199 | + | pv.Cleanup() | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 195 | // Clear previous state | 203 | // Clear previous state | ||
| 196 | wv.workspace = ws | 204 | wv.workspace = ws | ||
| 197 | wv.paneViews = make(map[entity.PaneID]*PaneView) | 205 | wv.paneViews = make(map[entity.PaneID]*PaneView) | ||
M internal/ui/layout/tree.go +24 -5
| @@ -33,6 +33,9 @@ type TreeRenderer struct { | |||
| 33 | 33 | logger zerolog.Logger | |
| 34 | 34 | nodeToWidget map[string]Widget | |
| 35 | 35 | splitOrientation map[string]Orientation | |
| 36 | + | // splitViews owns the SplitView callback registrations created while | |
| 37 | + | // rendering. Widgets alone cannot release their tick callbacks. | |
| 38 | + | splitViews map[string]*SplitView | |
| 36 | 39 | // paneToStack maps pane IDs to their containing StackedView. | |
| 37 | 40 | // Every leaf pane is wrapped in a StackedView for easy stacking later. | |
| 38 | 41 | paneToStack map[string]*StackedView | |
| @@ -52,6 +55,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||
| 52 | 55 | logger: log.With().Str("component", "tree-renderer").Logger(), | |
| 53 | 56 | nodeToWidget: make(map[string]Widget), | |
| 54 | 57 | splitOrientation: make(map[string]Orientation), | |
| 58 | + | splitViews: make(map[string]*SplitView), | |
| 55 | 59 | paneToStack: make(map[string]*StackedView), | |
| 56 | 60 | } | |
| 57 | 61 | } | |
| @@ -73,10 +77,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge | |||
| 73 | 77 | tr.mu.Lock() | |
| 74 | 78 | defer tr.mu.Unlock() | |
| 75 | 79 | ||
| 76 | - | // Clear previous mappings | |
| 77 | - | tr.nodeToWidget = make(map[string]Widget) | |
| 78 | - | tr.splitOrientation = make(map[string]Orientation) | |
| 79 | - | tr.paneToStack = make(map[string]*StackedView) | |
| 80 | + | tr.clearInternal() | |
| 80 | 81 | ||
| 81 | 82 | return tr.renderNode(ctx, root) | |
| 82 | 83 | } | |
| @@ -189,6 +190,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode) | |||
| 189 | 190 | } | |
| 190 | 191 | }) | |
| 191 | 192 | tr.splitOrientation[node.ID] = orientation | |
| 193 | + | tr.splitViews[node.ID] = splitView | |
| 192 | 194 | ||
| 193 | 195 | return splitView.Widget(), nil | |
| 194 | 196 | } | |
| @@ -264,13 +266,26 @@ func (tr *TreeRenderer) NodeCount() int { | |||
| 264 | 266 | return len(tr.nodeToWidget) | |
| 265 | 267 | } | |
| 266 | 268 | ||
| 267 | - | // Clear removes all node-to-widget mappings. | |
| 269 | + | // Clear releases all renderer-owned split callbacks and removes mappings. | |
| 268 | 270 | func (tr *TreeRenderer) Clear() { | |
| 269 | 271 | tr.mu.Lock() | |
| 270 | 272 | defer tr.mu.Unlock() | |
| 271 | 273 | ||
| 274 | + | tr.clearInternal() | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // clearInternal releases renderer-owned views and resets the lookup maps. | |
| 278 | + | // The caller must hold tr.mu. | |
| 279 | + | func (tr *TreeRenderer) clearInternal() { | |
| 280 | + | for _, splitView := range tr.splitViews { | |
| 281 | + | if splitView != nil { | |
| 282 | + | splitView.Cleanup() | |
| 283 | + | } | |
| 284 | + | } | |
| 272 | 285 | tr.nodeToWidget = make(map[string]Widget) | |
| 273 | 286 | tr.splitOrientation = make(map[string]Orientation) | |
| 287 | + | tr.splitViews = make(map[string]*SplitView) | |
| 288 | + | tr.paneToStack = make(map[string]*StackedView) | |
| 274 | 289 | } | |
| 275 | 290 | ||
| 276 | 291 | // RegisterWidget adds or updates a node-to-widget mapping. | |
| @@ -297,8 +312,12 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||
| 297 | 312 | tr.mu.Lock() | |
| 298 | 313 | defer tr.mu.Unlock() | |
| 299 | 314 | ||
| 315 | + | if splitView := tr.splitViews[nodeID]; splitView != nil { | |
| 316 | + | splitView.Cleanup() | |
| 317 | + | } | |
| 300 | 318 | delete(tr.nodeToWidget, nodeID) | |
| 301 | 319 | delete(tr.splitOrientation, nodeID) | |
| 320 | + | delete(tr.splitViews, nodeID) | |
| 302 | 321 | } | |
| 303 | 322 | ||
| 304 | 323 | // UpdateSplitRatio updates the ratio of a split view by node ID. | |
| @@ -33,6 +33,9 @@ type TreeRenderer struct { | |||||
| 33 | logger zerolog.Logger | 33 | logger zerolog.Logger | ||
| 34 | nodeToWidget map[string]Widget | 34 | nodeToWidget map[string]Widget | ||
| 35 | splitOrientation map[string]Orientation | 35 | splitOrientation map[string]Orientation | ||
| 36 | + | // splitViews owns the SplitView callback registrations created while | |||
| 37 | + | // rendering. Widgets alone cannot release their tick callbacks. | |||
| 38 | + | splitViews map[string]*SplitView | |||
| 36 | // paneToStack maps pane IDs to their containing StackedView. | 39 | // paneToStack maps pane IDs to their containing StackedView. | ||
| 37 | // Every leaf pane is wrapped in a StackedView for easy stacking later. | 40 | // Every leaf pane is wrapped in a StackedView for easy stacking later. | ||
| 38 | paneToStack map[string]*StackedView | 41 | paneToStack map[string]*StackedView | ||
| @@ -52,6 +55,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||||
| 52 | logger: log.With().Str("component", "tree-renderer").Logger(), | 55 | logger: log.With().Str("component", "tree-renderer").Logger(), | ||
| 53 | nodeToWidget: make(map[string]Widget), | 56 | nodeToWidget: make(map[string]Widget), | ||
| 54 | splitOrientation: make(map[string]Orientation), | 57 | splitOrientation: make(map[string]Orientation), | ||
| 58 | + | splitViews: make(map[string]*SplitView), | |||
| 55 | paneToStack: make(map[string]*StackedView), | 59 | paneToStack: make(map[string]*StackedView), | ||
| 56 | } | 60 | } | ||
| 57 | } | 61 | } | ||
| @@ -73,10 +77,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge | |||||
| 73 | tr.mu.Lock() | 77 | tr.mu.Lock() | ||
| 74 | defer tr.mu.Unlock() | 78 | defer tr.mu.Unlock() | ||
| 75 | 79 | ||||
| 76 | - | // Clear previous mappings | 80 | + | tr.clearInternal() |
| 77 | - | tr.nodeToWidget = make(map[string]Widget) | |||
| 78 | - | tr.splitOrientation = make(map[string]Orientation) | |||
| 79 | - | tr.paneToStack = make(map[string]*StackedView) | |||
| 80 | 81 | ||||
| 81 | return tr.renderNode(ctx, root) | 82 | return tr.renderNode(ctx, root) | ||
| 82 | } | 83 | } | ||
| @@ -189,6 +190,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode) | |||||
| 189 | } | 190 | } | ||
| 190 | }) | 191 | }) | ||
| 191 | tr.splitOrientation[node.ID] = orientation | 192 | tr.splitOrientation[node.ID] = orientation | ||
| 193 | + | tr.splitViews[node.ID] = splitView | |||
| 192 | 194 | ||||
| 193 | return splitView.Widget(), nil | 195 | return splitView.Widget(), nil | ||
| 194 | } | 196 | } | ||
| @@ -264,13 +266,26 @@ func (tr *TreeRenderer) NodeCount() int { | |||||
| 264 | return len(tr.nodeToWidget) | 266 | return len(tr.nodeToWidget) | ||
| 265 | } | 267 | } | ||
| 266 | 268 | ||||
| 267 | - | // Clear removes all node-to-widget mappings. | 269 | + | // Clear releases all renderer-owned split callbacks and removes mappings. |
| 268 | func (tr *TreeRenderer) Clear() { | 270 | func (tr *TreeRenderer) Clear() { | ||
| 269 | tr.mu.Lock() | 271 | tr.mu.Lock() | ||
| 270 | defer tr.mu.Unlock() | 272 | defer tr.mu.Unlock() | ||
| 271 | 273 | ||||
| 274 | + | tr.clearInternal() | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // clearInternal releases renderer-owned views and resets the lookup maps. | |||
| 278 | + | // The caller must hold tr.mu. | |||
| 279 | + | func (tr *TreeRenderer) clearInternal() { | |||
| 280 | + | for _, splitView := range tr.splitViews { | |||
| 281 | + | if splitView != nil { | |||
| 282 | + | splitView.Cleanup() | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 272 | tr.nodeToWidget = make(map[string]Widget) | 285 | tr.nodeToWidget = make(map[string]Widget) | ||
| 273 | tr.splitOrientation = make(map[string]Orientation) | 286 | tr.splitOrientation = make(map[string]Orientation) | ||
| 287 | + | tr.splitViews = make(map[string]*SplitView) | |||
| 288 | + | tr.paneToStack = make(map[string]*StackedView) | |||
| 274 | } | 289 | } | ||
| 275 | 290 | ||||
| 276 | // RegisterWidget adds or updates a node-to-widget mapping. | 291 | // RegisterWidget adds or updates a node-to-widget mapping. | ||
| @@ -297,8 +312,12 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||||
| 297 | tr.mu.Lock() | 312 | tr.mu.Lock() | ||
| 298 | defer tr.mu.Unlock() | 313 | defer tr.mu.Unlock() | ||
| 299 | 314 | ||||
| 315 | + | if splitView := tr.splitViews[nodeID]; splitView != nil { | |||
| 316 | + | splitView.Cleanup() | |||
| 317 | + | } | |||
| 300 | delete(tr.nodeToWidget, nodeID) | 318 | delete(tr.nodeToWidget, nodeID) | ||
| 301 | delete(tr.splitOrientation, nodeID) | 319 | delete(tr.splitOrientation, nodeID) | ||
| 320 | + | delete(tr.splitViews, nodeID) | |||
| 302 | } | 321 | } | ||
| 303 | 322 | ||||
| 304 | // UpdateSplitRatio updates the ratio of a split view by node ID. | 323 | // UpdateSplitRatio updates the ratio of a split view by node ID. | ||