detached 519071b

fix(split): release tick callback during cleanup

commit
519071b96f07158a21b2e2ad290c935be691a681
author
brice · · 2026-07-14 06:17 UTC
committer
brice · · 2026-07-14 07:06 UTC
parent
a784a53
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +69 -3
M internal/ui/layout/gtk_widgets.go +2 -0
@@ -182,6 +182,8 @@ func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
182182 return p.inner.AddTickCallback(&cb, 0, nil)
183183 }
184184
185+func (p *gtkPaned) RemoveTickCallback(id uint) { p.inner.RemoveTickCallback(id) }
186+
185187 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }
186188 func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() }
187189 func (p *gtkPaned) AddController(c *gtk.EventController) { p.inner.AddController(c) }
@@ -182,6 +182,8 @@ func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
182 return p.inner.AddTickCallback(&cb, 0, nil)182 return p.inner.AddTickCallback(&cb, 0, nil)
183 }183 }
184 184
185+func (p *gtkPaned) RemoveTickCallback(id uint) { p.inner.RemoveTickCallback(id) }
186+
185 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }187 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }
186 func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() }188 func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() }
187 func (p *gtkPaned) AddController(c *gtk.EventController) { p.inner.AddController(c) }189 func (p *gtkPaned) AddController(c *gtk.EventController) { p.inner.AddController(c) }
M internal/ui/layout/mocks/widgets.go +40 -0
@@ -2425,6 +2425,46 @@ func (_c *MockPanedWidget_RemoveCssClass_Call) RunAndReturn(run func(cssClass st
24252425 return _c
24262426 }
24272427
2428+// RemoveTickCallback provides a mock function for the type MockPanedWidget
2429+func (_mock *MockPanedWidget) RemoveTickCallback(id uint) {
2430+ _mock.Called(id)
2431+ return
2432+}
2433+
2434+// MockPanedWidget_RemoveTickCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveTickCallback'
2435+type MockPanedWidget_RemoveTickCallback_Call struct {
2436+ *mock.Call
2437+}
2438+
2439+// RemoveTickCallback is a helper method to define mock.On call
2440+// - id uint
2441+func (_e *MockPanedWidget_Expecter) RemoveTickCallback(id any) *MockPanedWidget_RemoveTickCallback_Call {
2442+ return &MockPanedWidget_RemoveTickCallback_Call{Call: _e.mock.On("RemoveTickCallback", id)}
2443+}
2444+
2445+func (_c *MockPanedWidget_RemoveTickCallback_Call) Run(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call {
2446+ _c.Call.Run(func(args mock.Arguments) {
2447+ var arg0 uint
2448+ if args[0] != nil {
2449+ arg0 = args[0].(uint)
2450+ }
2451+ run(
2452+ arg0,
2453+ )
2454+ })
2455+ return _c
2456+}
2457+
2458+func (_c *MockPanedWidget_RemoveTickCallback_Call) Return() *MockPanedWidget_RemoveTickCallback_Call {
2459+ _c.Call.Return()
2460+ return _c
2461+}
2462+
2463+func (_c *MockPanedWidget_RemoveTickCallback_Call) RunAndReturn(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call {
2464+ _c.Run(run)
2465+ return _c
2466+}
2467+
24282468 // SetCanFocus provides a mock function for the type MockPanedWidget
24292469 func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) {
24302470 _mock.Called(canFocus)
@@ -2425,6 +2425,46 @@ func (_c *MockPanedWidget_RemoveCssClass_Call) RunAndReturn(run func(cssClass st
2425 return _c2425 return _c
2426 }2426 }
2427 2427
2428+// RemoveTickCallback provides a mock function for the type MockPanedWidget
2429+func (_mock *MockPanedWidget) RemoveTickCallback(id uint) {
2430+ _mock.Called(id)
2431+ return
2432+}
2433+
2434+// MockPanedWidget_RemoveTickCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveTickCallback'
2435+type MockPanedWidget_RemoveTickCallback_Call struct {
2436+ *mock.Call
2437+}
2438+
2439+// RemoveTickCallback is a helper method to define mock.On call
2440+// - id uint
2441+func (_e *MockPanedWidget_Expecter) RemoveTickCallback(id any) *MockPanedWidget_RemoveTickCallback_Call {
2442+ return &MockPanedWidget_RemoveTickCallback_Call{Call: _e.mock.On("RemoveTickCallback", id)}
2443+}
2444+
2445+func (_c *MockPanedWidget_RemoveTickCallback_Call) Run(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call {
2446+ _c.Call.Run(func(args mock.Arguments) {
2447+ var arg0 uint
2448+ if args[0] != nil {
2449+ arg0 = args[0].(uint)
2450+ }
2451+ run(
2452+ arg0,
2453+ )
2454+ })
2455+ return _c
2456+}
2457+
2458+func (_c *MockPanedWidget_RemoveTickCallback_Call) Return() *MockPanedWidget_RemoveTickCallback_Call {
2459+ _c.Call.Return()
2460+ return _c
2461+}
2462+
2463+func (_c *MockPanedWidget_RemoveTickCallback_Call) RunAndReturn(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call {
2464+ _c.Run(run)
2465+ return _c
2466+}
2467+
2428 // SetCanFocus provides a mock function for the type MockPanedWidget2468 // SetCanFocus provides a mock function for the type MockPanedWidget
2429 func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) {2469 func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) {
2430 _mock.Called(canFocus)2470 _mock.Called(canFocus)
M internal/ui/layout/split.go +22 -1
@@ -23,6 +23,8 @@ type SplitView struct {
2323 onRatioChanged func(ratio float64)
2424 pendingNotifyRatio float64
2525 notifyDebounceTimer *time.Timer
26+ tickCallbackID uint
27+ cleanedUp bool
2628
2729 hasAppliedRatio bool
2830 suppressNotifyUntil time.Time
@@ -188,7 +190,7 @@ func NewSplitView(
188190 // Add tick callback to retry applying ratio every frame until successful.
189191 // This handles cases where allocation isn't ready even after Map signal.
190192 frames := 0
191- paned.AddTickCallback(func() bool {
193+ sv.tickCallbackID = paned.AddTickCallback(func() bool {
192194 frames++
193195 if sv.ApplyRatio() {
194196 sv.logger.Debug().
@@ -213,6 +215,25 @@ func NewSplitView(
213215 // The ratio is clamped to the range [0.0, 1.0].
214216 // Note: This sets the position based on ratio; actual pixel position
215217 // depends on the allocated size of the paned widget.
218+// Cleanup releases callbacks owned by this split. It is idempotent because
219+// tree rebuild and GTK teardown can both reach the same view.
220+func (sv *SplitView) Cleanup() {
221+ sv.mu.Lock()
222+ defer sv.mu.Unlock()
223+ if sv.cleanedUp {
224+ return
225+ }
226+ sv.cleanedUp = true
227+ if sv.notifyDebounceTimer != nil {
228+ sv.notifyDebounceTimer.Stop()
229+ sv.notifyDebounceTimer = nil
230+ }
231+ if sv.tickCallbackID != 0 {
232+ sv.paned.RemoveTickCallback(sv.tickCallbackID)
233+ sv.tickCallbackID = 0
234+ }
235+}
236+
216237 func (sv *SplitView) SetRatio(ratio float64) {
217238 sv.mu.Lock()
218239 defer sv.mu.Unlock()
@@ -23,6 +23,8 @@ type SplitView struct {
23 onRatioChanged func(ratio float64)23 onRatioChanged func(ratio float64)
24 pendingNotifyRatio float6424 pendingNotifyRatio float64
25 notifyDebounceTimer *time.Timer25 notifyDebounceTimer *time.Timer
26+ tickCallbackID uint
27+ cleanedUp bool
26 28
27 hasAppliedRatio bool29 hasAppliedRatio bool
28 suppressNotifyUntil time.Time30 suppressNotifyUntil time.Time
@@ -188,7 +190,7 @@ func NewSplitView(
188 // Add tick callback to retry applying ratio every frame until successful.190 // Add tick callback to retry applying ratio every frame until successful.
189 // This handles cases where allocation isn't ready even after Map signal.191 // This handles cases where allocation isn't ready even after Map signal.
190 frames := 0192 frames := 0
191- paned.AddTickCallback(func() bool {193+ sv.tickCallbackID = paned.AddTickCallback(func() bool {
192 frames++194 frames++
193 if sv.ApplyRatio() {195 if sv.ApplyRatio() {
194 sv.logger.Debug().196 sv.logger.Debug().
@@ -213,6 +215,25 @@ func NewSplitView(
213 // The ratio is clamped to the range [0.0, 1.0].215 // The ratio is clamped to the range [0.0, 1.0].
214 // Note: This sets the position based on ratio; actual pixel position216 // Note: This sets the position based on ratio; actual pixel position
215 // depends on the allocated size of the paned widget.217 // depends on the allocated size of the paned widget.
218+// Cleanup releases callbacks owned by this split. It is idempotent because
219+// tree rebuild and GTK teardown can both reach the same view.
220+func (sv *SplitView) Cleanup() {
221+ sv.mu.Lock()
222+ defer sv.mu.Unlock()
223+ if sv.cleanedUp {
224+ return
225+ }
226+ sv.cleanedUp = true
227+ if sv.notifyDebounceTimer != nil {
228+ sv.notifyDebounceTimer.Stop()
229+ sv.notifyDebounceTimer = nil
230+ }
231+ if sv.tickCallbackID != 0 {
232+ sv.paned.RemoveTickCallback(sv.tickCallbackID)
233+ sv.tickCallbackID = 0
234+ }
235+}
236+
216 func (sv *SplitView) SetRatio(ratio float64) {237 func (sv *SplitView) SetRatio(ratio float64) {
217 sv.mu.Lock()238 sv.mu.Lock()
218 defer sv.mu.Unlock()239 defer sv.mu.Unlock()
M internal/ui/layout/widgets.go +5 -2
@@ -99,9 +99,12 @@ type PanedWidget interface {
9999 ConnectMap(callback func()) uint
100100 ConnectNotifyPosition(callback func()) uint
101101
102- // Tick callback for frame-based updates (returns signal ID)
103- // Callback returns true to continue, false to stop
102+ // Tick callback for frame-based updates (returns signal ID).
103+ // Callback returns true to continue, false to stop.
104104 AddTickCallback(callback func() bool) uint
105+ // RemoveTickCallback releases an active callback slot. It is safe to call
106+ // once after the callback has stopped itself.
107+ RemoveTickCallback(id uint)
105108
106109 // Handle appearance
107110 SetWideHandle(wide bool)
@@ -99,9 +99,12 @@ type PanedWidget interface {
99 ConnectMap(callback func()) uint99 ConnectMap(callback func()) uint
100 ConnectNotifyPosition(callback func()) uint100 ConnectNotifyPosition(callback func()) uint
101 101
102- // Tick callback for frame-based updates (returns signal ID)102+ // Tick callback for frame-based updates (returns signal ID).
103- // Callback returns true to continue, false to stop103+ // Callback returns true to continue, false to stop.
104 AddTickCallback(callback func() bool) uint104 AddTickCallback(callback func() bool) uint
105+ // RemoveTickCallback releases an active callback slot. It is safe to call
106+ // once after the callback has stopped itself.
107+ RemoveTickCallback(id uint)
105 108
106 // Handle appearance109 // Handle appearance
107 SetWideHandle(wide bool)110 SetWideHandle(wide bool)

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