detached b0266fa

fix: isolate split idle scheduler

commit
b0266fa617d83e77f16c638aace473ecb90bfc92
author
brice · · 2026-07-15 08:15 UTC
parent
7137fd1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +34 -20
M internal/ui/layout/split.go +4 -8
@@ -21,7 +21,7 @@ type SplitView struct {
2121 logger zerolog.Logger
2222
2323 onRatioChanged func(ratio float64)
24- pendingNotifyRatio float64
24+ idleAdd func(*glib.SourceFunc, uintptr) uint
2525 notifyDebounceTimer *time.Timer
2626 notifyGeneration uint64
2727 tickCallbackID uint
@@ -41,9 +41,6 @@ const notifyPositionDebounceDelay = 100 * time.Millisecond
4141 const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond
4242 const splitAllocationSettledTolerance = 2
4343
44-// idleAdd is a seam for scheduling work on GLib's main loop.
45-var idleAdd = glib.IdleAdd
46-
4744 func childAllocation(widget Widget) (int, int) {
4845 if widget == nil {
4946 return 0, 0
@@ -115,6 +112,7 @@ func NewSplitView(
115112 endChild: endChild,
116113 ratio: clampRatio(ratio),
117114 logger: log.With().Str("component", "split-view").Logger(),
115+ idleAdd: glib.IdleAdd,
118116 }
119117
120118 // Set children
@@ -162,7 +160,6 @@ func NewSplitView(
162160 return
163161 }
164162 sv.ratio = ratio
165- sv.pendingNotifyRatio = ratio
166163 sv.notifyGeneration++
167164 generation := sv.notifyGeneration
168165
@@ -184,15 +181,14 @@ func NewSplitView(
184181 return false
185182 }
186183 onRatioChanged := sv.onRatioChanged
187- pending := sv.pendingNotifyRatio
188184 sv.mu.RUnlock()
189185
190186 if onRatioChanged != nil {
191- onRatioChanged(pending)
187+ onRatioChanged(ratio)
192188 }
193189 return false
194190 })
195- idleAdd(&cb, 0)
191+ sv.idleAdd(&cb, 0)
196192 })
197193
198194 sv.mu.Unlock()
@@ -21,7 +21,7 @@ type SplitView struct {
21 logger zerolog.Logger21 logger zerolog.Logger
22 22
23 onRatioChanged func(ratio float64)23 onRatioChanged func(ratio float64)
24- pendingNotifyRatio float6424+ idleAdd func(*glib.SourceFunc, uintptr) uint
25 notifyDebounceTimer *time.Timer25 notifyDebounceTimer *time.Timer
26 notifyGeneration uint6426 notifyGeneration uint64
27 tickCallbackID uint27 tickCallbackID uint
@@ -41,9 +41,6 @@ const notifyPositionDebounceDelay = 100 * time.Millisecond
41 const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond41 const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond
42 const splitAllocationSettledTolerance = 242 const splitAllocationSettledTolerance = 2
43 43
44-// idleAdd is a seam for scheduling work on GLib's main loop.
45-var idleAdd = glib.IdleAdd
46-
47 func childAllocation(widget Widget) (int, int) {44 func childAllocation(widget Widget) (int, int) {
48 if widget == nil {45 if widget == nil {
49 return 0, 046 return 0, 0
@@ -115,6 +112,7 @@ func NewSplitView(
115 endChild: endChild,112 endChild: endChild,
116 ratio: clampRatio(ratio),113 ratio: clampRatio(ratio),
117 logger: log.With().Str("component", "split-view").Logger(),114 logger: log.With().Str("component", "split-view").Logger(),
115+ idleAdd: glib.IdleAdd,
118 }116 }
119 117
120 // Set children118 // Set children
@@ -162,7 +160,6 @@ func NewSplitView(
162 return160 return
163 }161 }
164 sv.ratio = ratio162 sv.ratio = ratio
165- sv.pendingNotifyRatio = ratio
166 sv.notifyGeneration++163 sv.notifyGeneration++
167 generation := sv.notifyGeneration164 generation := sv.notifyGeneration
168 165
@@ -184,15 +181,14 @@ func NewSplitView(
184 return false181 return false
185 }182 }
186 onRatioChanged := sv.onRatioChanged183 onRatioChanged := sv.onRatioChanged
187- pending := sv.pendingNotifyRatio
188 sv.mu.RUnlock()184 sv.mu.RUnlock()
189 185
190 if onRatioChanged != nil {186 if onRatioChanged != nil {
191- onRatioChanged(pending)187+ onRatioChanged(ratio)
192 }188 }
193 return false189 return false
194 })190 })
195- idleAdd(&cb, 0)191+ sv.idleAdd(&cb, 0)
196 })192 })
197 193
198 sv.mu.Unlock()194 sv.mu.Unlock()
M internal/ui/layout/split_notify_cleanup_test.go +30 -12
@@ -60,16 +60,17 @@ func receiveQueuedIdle(t *testing.T, queued <-chan *glib.SourceFunc) *glib.Sourc
6060 }
6161 }
6262
63-func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) {
64- queued := make(chan *glib.SourceFunc, 1)
65- previousIdleAdd := idleAdd
66- idleAdd = func(source *glib.SourceFunc, _ uintptr) uint {
63+func setIdleScheduler(sv *SplitView, queued chan<- *glib.SourceFunc) {
64+ sv.idleAdd = func(source *glib.SourceFunc, _ uintptr) uint {
6765 queued <- source
6866 return 1
6967 }
70- t.Cleanup(func() { idleAdd = previousIdleAdd })
68+}
7169
70+func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) {
71+ queued := make(chan *glib.SourceFunc, 1)
7272 sv, notify := newNotifyingSplitView(t)
73+ setIdleScheduler(sv, queued)
7374 var calls atomic.Int32
7475 sv.SetOnRatioChanged(func(float64) { calls.Add(1) })
7576
@@ -83,14 +84,8 @@ func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) {
8384
8485 func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.T) {
8586 queued := make(chan *glib.SourceFunc, 2)
86- previousIdleAdd := idleAdd
87- idleAdd = func(source *glib.SourceFunc, _ uintptr) uint {
88- queued <- source
89- return 1
90- }
91- t.Cleanup(func() { idleAdd = previousIdleAdd })
92-
9387 sv, notify := newNotifyingSplitView(t)
88+ setIdleScheduler(sv, queued)
9489 var ratios []float64
9590 sv.SetOnRatioChanged(func(ratio float64) { ratios = append(ratios, ratio) })
9691
@@ -103,3 +98,26 @@ func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.
10398
10499 require.Equal(t, []float64{0.8}, ratios)
105100 }
101+
102+func TestSplitViewIdleSchedulersAreInstanceScoped(t *testing.T) {
103+ firstQueued := make(chan *glib.SourceFunc, 1)
104+ secondQueued := make(chan *glib.SourceFunc, 1)
105+ first, firstNotify := newNotifyingSplitView(t)
106+ second, secondNotify := newNotifyingSplitView(t)
107+ setIdleScheduler(first, firstQueued)
108+ setIdleScheduler(second, secondQueued)
109+
110+ firstNotify(20)
111+ secondNotify(80)
112+
113+ firstSource := receiveQueuedIdle(t, firstQueued)
114+ secondSource := receiveQueuedIdle(t, secondQueued)
115+ var firstCalls, secondCalls atomic.Int32
116+ first.SetOnRatioChanged(func(float64) { firstCalls.Add(1) })
117+ second.SetOnRatioChanged(func(float64) { secondCalls.Add(1) })
118+ (*firstSource)(0)
119+ (*secondSource)(0)
120+
121+ require.Equal(t, int32(1), firstCalls.Load())
122+ require.Equal(t, int32(1), secondCalls.Load())
123+}
@@ -60,16 +60,17 @@ func receiveQueuedIdle(t *testing.T, queued <-chan *glib.SourceFunc) *glib.Sourc
60 }60 }
61 }61 }
62 62
63-func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) {63+func setIdleScheduler(sv *SplitView, queued chan<- *glib.SourceFunc) {
64- queued := make(chan *glib.SourceFunc, 1)64+ sv.idleAdd = func(source *glib.SourceFunc, _ uintptr) uint {
65- previousIdleAdd := idleAdd
66- idleAdd = func(source *glib.SourceFunc, _ uintptr) uint {
67 queued <- source65 queued <- source
68 return 166 return 1
69 }67 }
70- t.Cleanup(func() { idleAdd = previousIdleAdd })68+}
71 69
70+func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) {
71+ queued := make(chan *glib.SourceFunc, 1)
72 sv, notify := newNotifyingSplitView(t)72 sv, notify := newNotifyingSplitView(t)
73+ setIdleScheduler(sv, queued)
73 var calls atomic.Int3274 var calls atomic.Int32
74 sv.SetOnRatioChanged(func(float64) { calls.Add(1) })75 sv.SetOnRatioChanged(func(float64) { calls.Add(1) })
75 76
@@ -83,14 +84,8 @@ func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) {
83 84
84 func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.T) {85 func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.T) {
85 queued := make(chan *glib.SourceFunc, 2)86 queued := make(chan *glib.SourceFunc, 2)
86- previousIdleAdd := idleAdd
87- idleAdd = func(source *glib.SourceFunc, _ uintptr) uint {
88- queued <- source
89- return 1
90- }
91- t.Cleanup(func() { idleAdd = previousIdleAdd })
92-
93 sv, notify := newNotifyingSplitView(t)87 sv, notify := newNotifyingSplitView(t)
88+ setIdleScheduler(sv, queued)
94 var ratios []float6489 var ratios []float64
95 sv.SetOnRatioChanged(func(ratio float64) { ratios = append(ratios, ratio) })90 sv.SetOnRatioChanged(func(ratio float64) { ratios = append(ratios, ratio) })
96 91
@@ -103,3 +98,26 @@ func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.
103 98
104 require.Equal(t, []float64{0.8}, ratios)99 require.Equal(t, []float64{0.8}, ratios)
105 }100 }
101+
102+func TestSplitViewIdleSchedulersAreInstanceScoped(t *testing.T) {
103+ firstQueued := make(chan *glib.SourceFunc, 1)
104+ secondQueued := make(chan *glib.SourceFunc, 1)
105+ first, firstNotify := newNotifyingSplitView(t)
106+ second, secondNotify := newNotifyingSplitView(t)
107+ setIdleScheduler(first, firstQueued)
108+ setIdleScheduler(second, secondQueued)
109+
110+ firstNotify(20)
111+ secondNotify(80)
112+
113+ firstSource := receiveQueuedIdle(t, firstQueued)
114+ secondSource := receiveQueuedIdle(t, secondQueued)
115+ var firstCalls, secondCalls atomic.Int32
116+ first.SetOnRatioChanged(func(float64) { firstCalls.Add(1) })
117+ second.SetOnRatioChanged(func(float64) { secondCalls.Add(1) })
118+ (*firstSource)(0)
119+ (*secondSource)(0)
120+
121+ require.Equal(t, int32(1), firstCalls.Load())
122+ require.Equal(t, int32(1), secondCalls.Load())
123+}

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