detached 1c59f12

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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() {
516516 pv.onFocusIn = nil
517517 pv.onFocusOut = nil
518518 pv.onHover = nil
519+ pv.onMouseMotion = nil
519520
520521 // Detach hover handler if present
521522 if pv.hoverHandler != nil {
@@ -530,6 +531,10 @@ func (pv *PaneView) Cleanup() {
530531 }
531532
532533 // Clear other overlay children
534+ if pv.loading != nil {
535+ pv.overlay.RemoveOverlay(pv.loading.Widget())
536+ pv.loading = nil
537+ }
533538 if pv.progressBar != nil {
534539 pv.overlay.RemoveOverlay(pv.progressBar.Widget())
535540 pv.progressBar = nil
@@ -516,6 +516,7 @@ func (pv *PaneView) Cleanup() {
516 pv.onFocusIn = nil516 pv.onFocusIn = nil
517 pv.onFocusOut = nil517 pv.onFocusOut = nil
518 pv.onHover = nil518 pv.onHover = nil
519+ pv.onMouseMotion = nil
519 520
520 // Detach hover handler if present521 // 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 children533 // 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 = nil540 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)
192192 wv.mu.Lock()
193193 defer wv.mu.Unlock()
194194
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+
195203 // Clear previous state
196204 wv.workspace = ws
197205 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 state203 // Clear previous state
196 wv.workspace = ws204 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 {
3333 logger zerolog.Logger
3434 nodeToWidget map[string]Widget
3535 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
3639 // paneToStack maps pane IDs to their containing StackedView.
3740 // Every leaf pane is wrapped in a StackedView for easy stacking later.
3841 paneToStack map[string]*StackedView
@@ -52,6 +55,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
5255 logger: log.With().Str("component", "tree-renderer").Logger(),
5356 nodeToWidget: make(map[string]Widget),
5457 splitOrientation: make(map[string]Orientation),
58+ splitViews: make(map[string]*SplitView),
5559 paneToStack: make(map[string]*StackedView),
5660 }
5761 }
@@ -73,10 +77,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge
7377 tr.mu.Lock()
7478 defer tr.mu.Unlock()
7579
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()
8081
8182 return tr.renderNode(ctx, root)
8283 }
@@ -189,6 +190,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
189190 }
190191 })
191192 tr.splitOrientation[node.ID] = orientation
193+ tr.splitViews[node.ID] = splitView
192194
193195 return splitView.Widget(), nil
194196 }
@@ -264,13 +266,26 @@ func (tr *TreeRenderer) NodeCount() int {
264266 return len(tr.nodeToWidget)
265267 }
266268
267-// Clear removes all node-to-widget mappings.
269+// Clear releases all renderer-owned split callbacks and removes mappings.
268270 func (tr *TreeRenderer) Clear() {
269271 tr.mu.Lock()
270272 defer tr.mu.Unlock()
271273
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+ }
272285 tr.nodeToWidget = make(map[string]Widget)
273286 tr.splitOrientation = make(map[string]Orientation)
287+ tr.splitViews = make(map[string]*SplitView)
288+ tr.paneToStack = make(map[string]*StackedView)
274289 }
275290
276291 // RegisterWidget adds or updates a node-to-widget mapping.
@@ -297,8 +312,12 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
297312 tr.mu.Lock()
298313 defer tr.mu.Unlock()
299314
315+ if splitView := tr.splitViews[nodeID]; splitView != nil {
316+ splitView.Cleanup()
317+ }
300318 delete(tr.nodeToWidget, nodeID)
301319 delete(tr.splitOrientation, nodeID)
320+ delete(tr.splitViews, nodeID)
302321 }
303322
304323 // UpdateSplitRatio updates the ratio of a split view by node ID.
@@ -33,6 +33,9 @@ type TreeRenderer struct {
33 logger zerolog.Logger33 logger zerolog.Logger
34 nodeToWidget map[string]Widget34 nodeToWidget map[string]Widget
35 splitOrientation map[string]Orientation35 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]*StackedView41 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 mappings80+ 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] = orientation192 tr.splitOrientation[node.ID] = orientation
193+ tr.splitViews[node.ID] = splitView
192 194
193 return splitView.Widget(), nil195 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.

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