detached 320f6a5

fix(ui): keep the last legend click across intermediate config reloads

commit
320f6a594fd0f03b7ffe83dd60ab136914e3cbf3
author
brice · · 2026-10-14 08:21 UTC
parent
5df8873
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +57 -10
M internal/ui/app_mode_frame.go +9 -10
@@ -86,8 +86,8 @@ func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {
8686 // modeLegendPreference is the app-wide "legend collapsed" state, shared by
8787 // every mode and window. A click is applied at once and saved in the
8888 // background. It keeps priority over the configured value, which may still be
89-// the previous one while the save is in flight, until a configuration reload
90-// settles it (see configReloaded).
89+// the previous one while saves are in flight, until a configuration reload
90+// received after the writer drained settles it (see configReloaded).
9191 type modeLegendPreference struct {
9292 mu sync.Mutex
9393 collapsed bool // last click; meaningful while clicked is set
@@ -97,7 +97,6 @@ type modeLegendPreference struct {
9797 // and the file ends on the last click.
9898 pending *bool
9999 saving bool
100- idle sync.WaitGroup
101100 }
102101
103102 // current returns the state to show, given the configured one.
@@ -120,7 +119,6 @@ func (p *modeLegendPreference) click(collapsed bool) (startWriter bool) {
120119 return false
121120 }
122121 p.saving = true
123- p.idle.Add(1)
124122 return true
125123 }
126124
@@ -130,20 +128,21 @@ func (p *modeLegendPreference) next() (collapsed, ok bool) {
130128 defer p.mu.Unlock()
131129 if p.pending == nil {
132130 p.saving = false
133- p.idle.Done()
134131 return false, false
135132 }
136133 collapsed, p.pending = *p.pending, nil
137134 return collapsed, true
138135 }
139136
140-// configReloaded hands the state back to the configuration when a reload
141-// agrees with the last click (it is that click's own save), or when no save is
142-// in flight (an external edit, or a save that failed).
143-func (p *modeLegendPreference) configReloaded(configured bool) {
137+// configReloaded hands the state back to the configuration on a reload
138+// received once the writer has drained: that reload reflects the saved clicks
139+// (or an external edit, or a failed save). Reloads received while the writer
140+// runs are ignored: they may come from an earlier save and would undo the
141+// latest click.
142+func (p *modeLegendPreference) configReloaded(_ bool) {
144143 p.mu.Lock()
145144 defer p.mu.Unlock()
146- if !p.saving || configured == p.collapsed {
145+ if !p.saving {
147146 p.clicked = false
148147 }
149148 }
@@ -86,8 +86,8 @@ func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {
86 // modeLegendPreference is the app-wide "legend collapsed" state, shared by86 // modeLegendPreference is the app-wide "legend collapsed" state, shared by
87 // every mode and window. A click is applied at once and saved in the87 // every mode and window. A click is applied at once and saved in the
88 // background. It keeps priority over the configured value, which may still be88 // background. It keeps priority over the configured value, which may still be
89-// the previous one while the save is in flight, until a configuration reload89+// the previous one while saves are in flight, until a configuration reload
90-// settles it (see configReloaded).90+// received after the writer drained settles it (see configReloaded).
91 type modeLegendPreference struct {91 type modeLegendPreference struct {
92 mu sync.Mutex92 mu sync.Mutex
93 collapsed bool // last click; meaningful while clicked is set93 collapsed bool // last click; meaningful while clicked is set
@@ -97,7 +97,6 @@ type modeLegendPreference struct {
97 // and the file ends on the last click.97 // and the file ends on the last click.
98 pending *bool98 pending *bool
99 saving bool99 saving bool
100- idle sync.WaitGroup
101 }100 }
102 101
103 // current returns the state to show, given the configured one.102 // current returns the state to show, given the configured one.
@@ -120,7 +119,6 @@ func (p *modeLegendPreference) click(collapsed bool) (startWriter bool) {
120 return false119 return false
121 }120 }
122 p.saving = true121 p.saving = true
123- p.idle.Add(1)
124 return true122 return true
125 }123 }
126 124
@@ -130,20 +128,21 @@ func (p *modeLegendPreference) next() (collapsed, ok bool) {
130 defer p.mu.Unlock()128 defer p.mu.Unlock()
131 if p.pending == nil {129 if p.pending == nil {
132 p.saving = false130 p.saving = false
133- p.idle.Done()
134 return false, false131 return false, false
135 }132 }
136 collapsed, p.pending = *p.pending, nil133 collapsed, p.pending = *p.pending, nil
137 return collapsed, true134 return collapsed, true
138 }135 }
139 136
140-// configReloaded hands the state back to the configuration when a reload137+// configReloaded hands the state back to the configuration on a reload
141-// agrees with the last click (it is that click's own save), or when no save is138+// received once the writer has drained: that reload reflects the saved clicks
142-// in flight (an external edit, or a save that failed).139+// (or an external edit, or a failed save). Reloads received while the writer
143-func (p *modeLegendPreference) configReloaded(configured bool) {140+// runs are ignored: they may come from an earlier save and would undo the
141+// latest click.
142+func (p *modeLegendPreference) configReloaded(_ bool) {
144 p.mu.Lock()143 p.mu.Lock()
145 defer p.mu.Unlock()144 defer p.mu.Unlock()
146- if !p.saving || configured == p.collapsed {145+ if !p.saving {
147 p.clicked = false146 p.clicked = false
148 }147 }
149 }148 }
M internal/ui/mode_toaster_test.go +48 -0
@@ -473,6 +473,54 @@ func TestModeLegendPreferenceClickWinsUntilSettled(t *testing.T) {
473473 require.True(t, p.current(true))
474474 }
475475
476+func TestModeLegendPreferenceReloadsDuringSavesDoNotUndoLastClick(t *testing.T) {
477+ var p modeLegendPreference
478+ require.True(t, p.click(true))
479+ first, ok := p.next() // the writer takes the first value
480+ require.True(t, ok)
481+ require.True(t, first)
482+ require.False(t, p.click(false), "the writer is still active")
483+
484+ p.configReloaded(false) // matches the latest click, but the save is still pending
485+ p.configReloaded(true) // the first save's own reload, arriving late
486+ require.False(t, p.current(true), "the latest click survives intermediate reloads")
487+
488+ second, ok := p.next()
489+ require.True(t, ok)
490+ require.False(t, second)
491+ require.False(t, p.current(true), "still the latest click until the writer drains")
492+ _, ok = p.next()
493+ require.False(t, ok)
494+ require.False(t, p.current(true), "drained: reloads have not been received yet")
495+
496+ p.configReloaded(false) // first reload after the drain
497+ require.False(t, p.current(false))
498+ require.True(t, p.current(true), "the configuration decides again")
499+}
500+
501+func TestModeLegendPreferenceReloadAfterDrain(t *testing.T) {
502+ tests := []struct {
503+ name string
504+ reloaded bool
505+ configured bool
506+ }{
507+ {"own save reloads", true, true},
508+ {"external edit", false, false},
509+ {"failed save leaves the file unchanged", false, false},
510+ }
511+ for _, tt := range tests {
512+ t.Run(tt.name, func(t *testing.T) {
513+ var p modeLegendPreference
514+ require.True(t, p.click(true))
515+ _, _ = p.next()
516+ _, _ = p.next()
517+ p.configReloaded(tt.reloaded)
518+ require.Equal(t, tt.configured, p.current(tt.configured))
519+ require.Equal(t, !tt.configured, p.current(!tt.configured))
520+ })
521+ }
522+}
523+
476524 func TestModeLegendPreferenceCoalescesClicksAndKeepsLast(t *testing.T) {
477525 var p modeLegendPreference
478526 require.True(t, p.click(true))
@@ -473,6 +473,54 @@ func TestModeLegendPreferenceClickWinsUntilSettled(t *testing.T) {
473 require.True(t, p.current(true))473 require.True(t, p.current(true))
474 }474 }
475 475
476+func TestModeLegendPreferenceReloadsDuringSavesDoNotUndoLastClick(t *testing.T) {
477+ var p modeLegendPreference
478+ require.True(t, p.click(true))
479+ first, ok := p.next() // the writer takes the first value
480+ require.True(t, ok)
481+ require.True(t, first)
482+ require.False(t, p.click(false), "the writer is still active")
483+
484+ p.configReloaded(false) // matches the latest click, but the save is still pending
485+ p.configReloaded(true) // the first save's own reload, arriving late
486+ require.False(t, p.current(true), "the latest click survives intermediate reloads")
487+
488+ second, ok := p.next()
489+ require.True(t, ok)
490+ require.False(t, second)
491+ require.False(t, p.current(true), "still the latest click until the writer drains")
492+ _, ok = p.next()
493+ require.False(t, ok)
494+ require.False(t, p.current(true), "drained: reloads have not been received yet")
495+
496+ p.configReloaded(false) // first reload after the drain
497+ require.False(t, p.current(false))
498+ require.True(t, p.current(true), "the configuration decides again")
499+}
500+
501+func TestModeLegendPreferenceReloadAfterDrain(t *testing.T) {
502+ tests := []struct {
503+ name string
504+ reloaded bool
505+ configured bool
506+ }{
507+ {"own save reloads", true, true},
508+ {"external edit", false, false},
509+ {"failed save leaves the file unchanged", false, false},
510+ }
511+ for _, tt := range tests {
512+ t.Run(tt.name, func(t *testing.T) {
513+ var p modeLegendPreference
514+ require.True(t, p.click(true))
515+ _, _ = p.next()
516+ _, _ = p.next()
517+ p.configReloaded(tt.reloaded)
518+ require.Equal(t, tt.configured, p.current(tt.configured))
519+ require.Equal(t, !tt.configured, p.current(!tt.configured))
520+ })
521+ }
522+}
523+
476 func TestModeLegendPreferenceCoalescesClicksAndKeepsLast(t *testing.T) {524 func TestModeLegendPreferenceCoalescesClicksAndKeepsLast(t *testing.T) {
477 var p modeLegendPreference525 var p modeLegendPreference
478 require.True(t, p.click(true))526 require.True(t, p.click(true))

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