detached 89abebd

fix(daemon): preserve batched notification yank selection

commit
89abebd99d4767d215af44faf680938548012316
author
brice · · 2026-07-21 10:52 UTC
parent
23819c3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +41 -5
M internal/usecase/daemon/notices_overlay.go +3 -5
@@ -171,17 +171,15 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
171171 rt.noticeMu.Unlock()
172172 return
173173 }
174+ var yankTarget domain.Notification
175+ var yank bool
174176 result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult {
175177 if b == 'y' {
178+ yankTarget, yank = rt.noticesOverlay.Selected()
176179 return listInputResult{action: b}
177180 }
178181 return listInputResult{}
179182 })
180- var yankTarget domain.Notification
181- yank := result.action == 'y'
182- if yank {
183- yankTarget, yank = rt.noticesOverlay.Selected()
184- }
185183 rt.noticeMu.Unlock()
186184
187185 if result.exit {
@@ -171,17 +171,15 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
171 rt.noticeMu.Unlock()171 rt.noticeMu.Unlock()
172 return172 return
173 }173 }
174+ var yankTarget domain.Notification
175+ var yank bool
174 result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult {176 result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult {
175 if b == 'y' {177 if b == 'y' {
178+ yankTarget, yank = rt.noticesOverlay.Selected()
176 return listInputResult{action: b}179 return listInputResult{action: b}
177 }180 }
178 return listInputResult{}181 return listInputResult{}
179 })182 })
180- var yankTarget domain.Notification
181- yank := result.action == 'y'
182- if yank {
183- yankTarget, yank = rt.noticesOverlay.Selected()
184- }
185 rt.noticeMu.Unlock()183 rt.noticeMu.Unlock()
186 184
187 if result.exit {185 if result.exit {
M internal/usecase/daemon/notices_overlay_test.go +38 -0
@@ -271,6 +271,44 @@ func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
271271 require.True(t, ac.overlays.noticesActive())
272272 }
273273
274+func TestNoticesBatchedYankCapturesSelectionAtY(t *testing.T) {
275+ tests := []struct {
276+ name string
277+ input []byte
278+ wantYanked string
279+ wantSelected string
280+ }{
281+ {name: "yank then down", input: []byte("yj"), wantYanked: "third", wantSelected: "second"},
282+ {name: "down then yank", input: []byte("jy"), wantYanked: "second", wantSelected: "second"},
283+ }
284+ for _, tt := range tests {
285+ t.Run(tt.name, func(t *testing.T) {
286+ p, release := newBlockingPTY(t)
287+ defer release()
288+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
289+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
290+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
291+ d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
292+
293+ d.enterNotices(sess, ac)
294+ awaitFrame(t, sends, ports.MsgOutput)
295+ d.handleInput(sess, ac, tt.input)
296+
297+ out := awaitFrame(t, sends, ports.MsgOutput)
298+ msg, err := ports.UnmarshalOutput(out.Payload)
299+ require.NoError(t, err)
300+ payload := strings.TrimSuffix(strings.TrimPrefix(string(msg.Data), "\x1b]52;c;"), "\a")
301+ decoded, err := base64.StdEncoding.DecodeString(payload)
302+ require.NoError(t, err)
303+ require.Contains(t, string(decoded), "\n"+tt.wantYanked+"\n")
304+
305+ selected, ok := ac.overlays.noticesOverlay.Selected()
306+ require.True(t, ok)
307+ require.Equal(t, tt.wantSelected, selected.Message)
308+ })
309+ }
310+}
311+
274312 func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) {
275313 p, release := newBlockingPTY(t)
276314 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -271,6 +271,44 @@ func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
271 require.True(t, ac.overlays.noticesActive())271 require.True(t, ac.overlays.noticesActive())
272 }272 }
273 273
274+func TestNoticesBatchedYankCapturesSelectionAtY(t *testing.T) {
275+ tests := []struct {
276+ name string
277+ input []byte
278+ wantYanked string
279+ wantSelected string
280+ }{
281+ {name: "yank then down", input: []byte("yj"), wantYanked: "third", wantSelected: "second"},
282+ {name: "down then yank", input: []byte("jy"), wantYanked: "second", wantSelected: "second"},
283+ }
284+ for _, tt := range tests {
285+ t.Run(tt.name, func(t *testing.T) {
286+ p, release := newBlockingPTY(t)
287+ defer release()
288+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
289+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
290+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
291+ d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
292+
293+ d.enterNotices(sess, ac)
294+ awaitFrame(t, sends, ports.MsgOutput)
295+ d.handleInput(sess, ac, tt.input)
296+
297+ out := awaitFrame(t, sends, ports.MsgOutput)
298+ msg, err := ports.UnmarshalOutput(out.Payload)
299+ require.NoError(t, err)
300+ payload := strings.TrimSuffix(strings.TrimPrefix(string(msg.Data), "\x1b]52;c;"), "\a")
301+ decoded, err := base64.StdEncoding.DecodeString(payload)
302+ require.NoError(t, err)
303+ require.Contains(t, string(decoded), "\n"+tt.wantYanked+"\n")
304+
305+ selected, ok := ac.overlays.noticesOverlay.Selected()
306+ require.True(t, ok)
307+ require.Equal(t, tt.wantSelected, selected.Message)
308+ })
309+ }
310+}
311+
274 func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) {312 func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) {
275 p, release := newBlockingPTY(t)313 p, release := newBlockingPTY(t)
276 d, sess, ac, sends := newManualSessionWithPTYs(t, p)314 d, sess, ac, sends := newManualSessionWithPTYs(t, p)

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 12

Tags 11

↑↓ moveenter openesc close