detached 753c87f

fix(selection): build every overlay before starting any so Tab and F never race the wake list

commit
753c87f322f1db824ed97d679189a33ffc623941
author
brice · · 2026-10-18 09:36 UTC
parent
07981f5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
feat/freeze-key
3 files · +39 -2
M Makefile +1 -1
@@ -48,7 +48,7 @@ arch:
4848 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
4949 # the packages holding them. Every named test must exist, so the guard cannot
5050 # pass vacuously when a frame-path test is removed or renamed.
51-PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations
51+PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestFreezeKeyAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations
5252 PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
5353 perf_empty :=
5454 perf_space := $(perf_empty) $(perf_empty)
@@ -48,7 +48,7 @@ arch:
48 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS48 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
49 # the packages holding them. Every named test must exist, so the guard cannot49 # the packages holding them. Every named test must exist, so the guard cannot
50 # pass vacuously when a frame-path test is removed or renamed.50 # pass vacuously when a frame-path test is removed or renamed.
51-PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations51+PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestFreezeKeyAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations
52 PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland52 PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
53 perf_empty :=53 perf_empty :=
54 perf_space := $(perf_empty) $(perf_empty)54 perf_space := $(perf_empty) $(perf_empty)
M internal/adapters/selection/backdrop_test.go +31 -0
@@ -94,6 +94,37 @@ func TestFreezeKeyTogglesEveryOverlay(t *testing.T) {
9494 assert.False(t, sess.acceptedFrozen(), "a pick on the live screen is captured live")
9595 }
9696
97+// The live state survives a round trip through record mode, where F is
98+// ignored.
99+func TestFreezeKeyWithTab(t *testing.T) {
100+ sess, _ := testSession()
101+ sess.canFreeze = true
102+ m := frozenModel(t, 0, sess)
103+ m.toggle = true
104+ m.input(key("f"))
105+ m.input(key("Tab"))
106+ require.Equal(t, ports.Record, m.mode)
107+ assert.False(t, m.input(key("f")), "F is ignored in record mode")
108+ m.input(key("Tab"))
109+ assert.False(t, drawsBackdrop(m), "still live after the round trip")
110+ assert.Contains(t, m.footerText, "F freeze")
111+}
112+
113+// Like Tab, F allocates only the rebuilt footer text.
114+func TestFreezeKeyAllocations(t *testing.T) {
115+ if raceEnabled {
116+ t.Skip("the race detector adds allocations")
117+ }
118+ sess, _ := testSession()
119+ sess.canFreeze = true
120+ m := frozenModel(t, 0, sess)
121+ n := testing.AllocsPerRun(100, func() {
122+ m.input(key("f"))
123+ _ = m.scene()
124+ })
125+ assert.LessOrEqual(t, n, 1.0, "an F press allocates only the rebuilt legend text")
126+}
127+
97128 // Without a frozen screen, or in record mode, F does nothing and is not shown.
98129 func TestFreezeKeyHiddenWithoutBackdrop(t *testing.T) {
99130 m := newTestModel(t)
@@ -94,6 +94,37 @@ func TestFreezeKeyTogglesEveryOverlay(t *testing.T) {
94 assert.False(t, sess.acceptedFrozen(), "a pick on the live screen is captured live")94 assert.False(t, sess.acceptedFrozen(), "a pick on the live screen is captured live")
95 }95 }
96 96
97+// The live state survives a round trip through record mode, where F is
98+// ignored.
99+func TestFreezeKeyWithTab(t *testing.T) {
100+ sess, _ := testSession()
101+ sess.canFreeze = true
102+ m := frozenModel(t, 0, sess)
103+ m.toggle = true
104+ m.input(key("f"))
105+ m.input(key("Tab"))
106+ require.Equal(t, ports.Record, m.mode)
107+ assert.False(t, m.input(key("f")), "F is ignored in record mode")
108+ m.input(key("Tab"))
109+ assert.False(t, drawsBackdrop(m), "still live after the round trip")
110+ assert.Contains(t, m.footerText, "F freeze")
111+}
112+
113+// Like Tab, F allocates only the rebuilt footer text.
114+func TestFreezeKeyAllocations(t *testing.T) {
115+ if raceEnabled {
116+ t.Skip("the race detector adds allocations")
117+ }
118+ sess, _ := testSession()
119+ sess.canFreeze = true
120+ m := frozenModel(t, 0, sess)
121+ n := testing.AllocsPerRun(100, func() {
122+ m.input(key("f"))
123+ _ = m.scene()
124+ })
125+ assert.LessOrEqual(t, n, 1.0, "an F press allocates only the rebuilt legend text")
126+}
127+
97 // Without a frozen screen, or in record mode, F does nothing and is not shown.128 // Without a frozen screen, or in record mode, F does nothing and is not shown.
98 func TestFreezeKeyHiddenWithoutBackdrop(t *testing.T) {129 func TestFreezeKeyHiddenWithoutBackdrop(t *testing.T) {
99 m := newTestModel(t)130 m := newTestModel(t)
M internal/adapters/selection/selector.go +7 -1
@@ -171,7 +171,9 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
171171 sess := newSession(cancel)
172172 sess.canFreeze = len(s.backdrops) > 0
173173 s.frozen = false
174- var wg sync.WaitGroup
174+ // Build every model and the complete wakes slice before any overlay
175+ // starts: a running overlay reads sess.wakes on Tab or F.
176+ models := make([]*model, len(outputs))
175177 for i := range outputs {
176178 m := newModel(s.mode, outputs, i, sess, s.grid)
177179 m.setWorkspaces(s.workspaces)
@@ -187,6 +189,10 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
187189 sess.wakes = append(sess.wakes, m.wake)
188190 }
189191 m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator, m.freezeHint())
192+ models[i] = m
193+ }
194+ var wg sync.WaitGroup
195+ for i, m := range models {
190196 wg.Add(1)
191197 go func() {
192198 defer wg.Done()
@@ -171,7 +171,9 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
171 sess := newSession(cancel)171 sess := newSession(cancel)
172 sess.canFreeze = len(s.backdrops) > 0172 sess.canFreeze = len(s.backdrops) > 0
173 s.frozen = false173 s.frozen = false
174- var wg sync.WaitGroup174+ // Build every model and the complete wakes slice before any overlay
175+ // starts: a running overlay reads sess.wakes on Tab or F.
176+ models := make([]*model, len(outputs))
175 for i := range outputs {177 for i := range outputs {
176 m := newModel(s.mode, outputs, i, sess, s.grid)178 m := newModel(s.mode, outputs, i, sess, s.grid)
177 m.setWorkspaces(s.workspaces)179 m.setWorkspaces(s.workspaces)
@@ -187,6 +189,10 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
187 sess.wakes = append(sess.wakes, m.wake)189 sess.wakes = append(sess.wakes, m.wake)
188 }190 }
189 m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator, m.freezeHint())191 m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator, m.freezeHint())
192+ models[i] = m
193+ }
194+ var wg sync.WaitGroup
195+ for i, m := range models {
190 wg.Add(1)196 wg.Add(1)
191 go func() {197 go func() {
192 defer wg.Done()198 defer wg.Done()

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 2

Tags 9

↑↓ moveenter openesc close