detached 23819c3

refactor(daemon): consolidate notification lifecycle helpers

commit
23819c376aec93eeffe3803380dcd66fb5ec38cb
author
brice · · 2026-07-21 10:48 UTC
parent
dfadb21
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +344 -300
M internal/usecase/daemon/notices_overlay.go +148 -89
@@ -27,67 +27,164 @@ func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
2727 d.invalidateRender(sess, ac, true, "notices_overlay.go")
2828 }
2929
30-func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
31- sess := ac.currentSession()
32- if sess == nil {
33- return
34- }
35- ac.overlays.noticeMu.Lock()
36- if ac.overlays.noticesOverlay == nil {
37- ac.overlays.noticesPending = nil
38- d.stopNoticesPendingTimerLocked(ac)
39- ac.overlays.noticeMu.Unlock()
40- return
41- }
42- if len(ac.overlays.noticesPending) > 0 {
43- d.stopNoticesPendingTimerLocked(ac)
44- combined := make([]byte, 0, len(ac.overlays.noticesPending)+len(data))
45- combined = append(combined, ac.overlays.noticesPending...)
30+// listInputState owns the common pending-ESC lifecycle for list overlays.
31+// The caller holds the overlay lock while handleListInputLocked runs. The
32+// timer callback takes the same lock before checking its identity, so a later
33+// input chunk cannot let a stale lone-ESC close a reopened overlay.
34+type listInputState struct {
35+ pending *[]byte
36+ esc *pendingByteTimer
37+ moveUp func()
38+ moveDown func()
39+ lock func()
40+ unlock func()
41+ active func() bool
42+ closeLocked func()
43+ afterClose func()
44+}
45+
46+type listInputResult struct {
47+ changed bool
48+ exit bool
49+ action byte
50+ stop bool
51+}
52+
53+func handleListInputLocked(clock ports.Clock, data []byte, state listInputState, action func(byte) listInputResult) listInputResult {
54+ if len(*state.pending) > 0 {
55+ state.esc.stop()
56+ combined := make([]byte, 0, len(*state.pending)+len(data))
57+ combined = append(combined, (*state.pending)...)
4658 combined = append(combined, data...)
4759 data = combined
48- ac.overlays.noticesPending = nil
60+ *state.pending = nil
4961 }
50- changed := false
51- exit := false
52- yank := false
53- var yankTarget domain.Notification
62+
63+ var result listInputResult
5464 for i := 0; i < len(data); i++ {
5565 switch data[i] {
5666 case 'j':
57- ac.overlays.noticesOverlay.Down()
58- changed = true
67+ state.moveDown()
68+ result.changed = true
5969 case 'k':
60- ac.overlays.noticesOverlay.Up()
61- changed = true
62- case 'y':
63- if n, ok := ac.overlays.noticesOverlay.Selected(); ok {
64- yankTarget = n
65- yank = true
70+ state.moveUp()
71+ result.changed = true
72+ case 'q', 0x03:
73+ result.exit = true
74+ case keys.ESC:
75+ tail := data[i:]
76+ if consumed, ok := routeListEscape(state, tail); ok {
77+ i += consumed - 1
78+ result.changed = true
79+ continue
80+ }
81+ if len(tail) == 1 {
82+ retainListESCLocked(clock, state)
83+ break
6684 }
67- case 'q', 0x03, 0x1b:
68- if data[i] == 0x1b {
69- tail := data[i:]
70- consumed, ok := routeNoticesEscape(ac.overlays.noticesOverlay, tail)
71- if ok {
72- i += consumed - 1
73- changed = true
74- continue
75- }
76- if len(tail) == 1 {
77- d.retainNoticesESCLocked(ac)
78- break
79- }
80- if isPickerEscapePrefix(tail) {
81- ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], tail...)
82- break
83- }
85+ if isListEscapePrefix(tail) {
86+ *state.pending = append((*state.pending)[:0], tail...)
87+ break
88+ }
89+ result.exit = true
90+ default:
91+ custom := action(data[i])
92+ if custom.action != 0 {
93+ result.action = custom.action
94+ }
95+ result.exit = result.exit || custom.exit
96+ result.stop = custom.stop
97+ if result.stop {
98+ return result
8499 }
85- exit = true
86100 }
87101 }
88- ac.overlays.noticeMu.Unlock()
102+ return result
103+}
89104
90- if exit {
105+func routeListEscape(state listInputState, data []byte) (int, bool) {
106+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
107+ switch data[2] {
108+ case 'A':
109+ state.moveUp()
110+ return 3, true
111+ case 'B':
112+ state.moveDown()
113+ return 3, true
114+ }
115+ }
116+ return 0, false
117+}
118+
119+func isListEscapePrefix(data []byte) bool {
120+ return len(data) == 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
121+}
122+
123+func retainListESCLocked(clock ports.Clock, state listInputState) {
124+ *state.pending = append((*state.pending)[:0], keys.ESC)
125+ state.esc.retain(clock, keys.ESCDelay, func(timer ports.Timer) {
126+ state.lock()
127+ if state.esc.timer != timer || len(*state.pending) != 1 || (*state.pending)[0] != keys.ESC || !state.active() {
128+ state.unlock()
129+ return
130+ }
131+ *state.pending = nil
132+ state.esc.timer = nil
133+ state.esc.done = nil
134+ state.closeLocked()
135+ state.unlock()
136+ state.afterClose()
137+ })
138+}
139+
140+func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState {
141+ rt := ac.overlays
142+ return listInputState{
143+ pending: &rt.noticesPending,
144+ esc: &rt.noticesESC,
145+ moveUp: rt.noticesOverlay.Up,
146+ moveDown: rt.noticesOverlay.Down,
147+ lock: rt.noticeMu.Lock,
148+ unlock: rt.noticeMu.Unlock,
149+ active: func() bool { return rt.noticesOverlay != nil },
150+ closeLocked: func() {
151+ rt.noticesOverlay = nil
152+ },
153+ afterClose: func() {
154+ if sess := ac.currentSession(); sess != nil {
155+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
156+ }
157+ },
158+ }
159+}
160+
161+func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
162+ sess := ac.currentSession()
163+ if sess == nil {
164+ return
165+ }
166+ rt := ac.overlays
167+ rt.noticeMu.Lock()
168+ if rt.noticesOverlay == nil {
169+ rt.noticesPending = nil
170+ rt.noticesESC.stop()
171+ rt.noticeMu.Unlock()
172+ return
173+ }
174+ result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult {
175+ if b == 'y' {
176+ return listInputResult{action: b}
177+ }
178+ return listInputResult{}
179+ })
180+ var yankTarget domain.Notification
181+ yank := result.action == 'y'
182+ if yank {
183+ yankTarget, yank = rt.noticesOverlay.Selected()
184+ }
185+ rt.noticeMu.Unlock()
186+
187+ if result.exit {
91188 d.closeNotices(ac)
92189 }
93190 if yank {
@@ -96,7 +193,7 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
96193 // way copy mode's own 'y' commits and exits.
97194 d.yankNotice(sess, ac, yankTarget)
98195 }
99- if exit || changed {
196+ if result.exit || result.changed {
100197 d.invalidateRender(sess, ac, true, "notices_overlay.go")
101198 }
102199 }
@@ -153,48 +250,10 @@ func severityWord(sev domain.NoticeSeverity) string {
153250 }
154251 }
155252
156-func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) {
157- ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC)
158- ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
159- ac.overlays.noticeMu.Lock()
160- if ac.overlays.noticesESC.timer != timer || len(ac.overlays.noticesPending) != 1 || ac.overlays.noticesPending[0] != keys.ESC || ac.overlays.noticesOverlay == nil {
161- ac.overlays.noticeMu.Unlock()
162- return
163- }
164- ac.overlays.noticesPending = nil
165- ac.overlays.noticesESC.timer = nil
166- ac.overlays.noticesESC.done = nil
167- ac.overlays.noticesOverlay = nil
168- ac.overlays.noticeMu.Unlock()
169-
170- if sess := ac.currentSession(); sess != nil {
171- d.invalidateRender(sess, ac, true, "notices_overlay.go")
172- }
173- })
174-}
175-
176-func (d *Daemon) stopNoticesPendingTimerLocked(ac *attachedClient) {
177- ac.overlays.noticesESC.stop()
178-}
179-
180253 func (d *Daemon) closeNotices(ac *attachedClient) {
181254 ac.overlays.noticeMu.Lock()
182255 ac.overlays.noticesOverlay = nil
183256 ac.overlays.noticesPending = nil
184- d.stopNoticesPendingTimerLocked(ac)
257+ ac.overlays.noticesESC.stop()
185258 ac.overlays.noticeMu.Unlock()
186259 }
187-
188-func routeNoticesEscape(m *notices.Model, data []byte) (int, bool) {
189- if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
190- switch data[2] {
191- case 'A':
192- m.Up()
193- return 3, true
194- case 'B':
195- m.Down()
196- return 3, true
197- }
198- }
199- return 0, false
200-}
@@ -27,67 +27,164 @@ func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
27 d.invalidateRender(sess, ac, true, "notices_overlay.go")27 d.invalidateRender(sess, ac, true, "notices_overlay.go")
28 }28 }
29 29
30-func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {30+// listInputState owns the common pending-ESC lifecycle for list overlays.
31- sess := ac.currentSession()31+// The caller holds the overlay lock while handleListInputLocked runs. The
32- if sess == nil {32+// timer callback takes the same lock before checking its identity, so a later
33- return33+// input chunk cannot let a stale lone-ESC close a reopened overlay.
34- }34+type listInputState struct {
35- ac.overlays.noticeMu.Lock()35+ pending *[]byte
36- if ac.overlays.noticesOverlay == nil {36+ esc *pendingByteTimer
37- ac.overlays.noticesPending = nil37+ moveUp func()
38- d.stopNoticesPendingTimerLocked(ac)38+ moveDown func()
39- ac.overlays.noticeMu.Unlock()39+ lock func()
40- return40+ unlock func()
41- }41+ active func() bool
42- if len(ac.overlays.noticesPending) > 0 {42+ closeLocked func()
43- d.stopNoticesPendingTimerLocked(ac)43+ afterClose func()
44- combined := make([]byte, 0, len(ac.overlays.noticesPending)+len(data))44+}
45- combined = append(combined, ac.overlays.noticesPending...)45+
46+type listInputResult struct {
47+ changed bool
48+ exit bool
49+ action byte
50+ stop bool
51+}
52+
53+func handleListInputLocked(clock ports.Clock, data []byte, state listInputState, action func(byte) listInputResult) listInputResult {
54+ if len(*state.pending) > 0 {
55+ state.esc.stop()
56+ combined := make([]byte, 0, len(*state.pending)+len(data))
57+ combined = append(combined, (*state.pending)...)
46 combined = append(combined, data...)58 combined = append(combined, data...)
47 data = combined59 data = combined
48- ac.overlays.noticesPending = nil60+ *state.pending = nil
49 }61 }
50- changed := false62+
51- exit := false63+ var result listInputResult
52- yank := false
53- var yankTarget domain.Notification
54 for i := 0; i < len(data); i++ {64 for i := 0; i < len(data); i++ {
55 switch data[i] {65 switch data[i] {
56 case 'j':66 case 'j':
57- ac.overlays.noticesOverlay.Down()67+ state.moveDown()
58- changed = true68+ result.changed = true
59 case 'k':69 case 'k':
60- ac.overlays.noticesOverlay.Up()70+ state.moveUp()
61- changed = true71+ result.changed = true
62- case 'y':72+ case 'q', 0x03:
63- if n, ok := ac.overlays.noticesOverlay.Selected(); ok {73+ result.exit = true
64- yankTarget = n74+ case keys.ESC:
65- yank = true75+ tail := data[i:]
76+ if consumed, ok := routeListEscape(state, tail); ok {
77+ i += consumed - 1
78+ result.changed = true
79+ continue
80+ }
81+ if len(tail) == 1 {
82+ retainListESCLocked(clock, state)
83+ break
66 }84 }
67- case 'q', 0x03, 0x1b:85+ if isListEscapePrefix(tail) {
68- if data[i] == 0x1b {86+ *state.pending = append((*state.pending)[:0], tail...)
69- tail := data[i:]87+ break
70- consumed, ok := routeNoticesEscape(ac.overlays.noticesOverlay, tail)88+ }
71- if ok {89+ result.exit = true
72- i += consumed - 190+ default:
73- changed = true91+ custom := action(data[i])
74- continue92+ if custom.action != 0 {
75- }93+ result.action = custom.action
76- if len(tail) == 1 {94+ }
77- d.retainNoticesESCLocked(ac)95+ result.exit = result.exit || custom.exit
78- break96+ result.stop = custom.stop
79- }97+ if result.stop {
80- if isPickerEscapePrefix(tail) {98+ return result
81- ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], tail...)
82- break
83- }
84 }99 }
85- exit = true
86 }100 }
87 }101 }
88- ac.overlays.noticeMu.Unlock()102+ return result
103+}
89 104
90- if exit {105+func routeListEscape(state listInputState, data []byte) (int, bool) {
106+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
107+ switch data[2] {
108+ case 'A':
109+ state.moveUp()
110+ return 3, true
111+ case 'B':
112+ state.moveDown()
113+ return 3, true
114+ }
115+ }
116+ return 0, false
117+}
118+
119+func isListEscapePrefix(data []byte) bool {
120+ return len(data) == 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
121+}
122+
123+func retainListESCLocked(clock ports.Clock, state listInputState) {
124+ *state.pending = append((*state.pending)[:0], keys.ESC)
125+ state.esc.retain(clock, keys.ESCDelay, func(timer ports.Timer) {
126+ state.lock()
127+ if state.esc.timer != timer || len(*state.pending) != 1 || (*state.pending)[0] != keys.ESC || !state.active() {
128+ state.unlock()
129+ return
130+ }
131+ *state.pending = nil
132+ state.esc.timer = nil
133+ state.esc.done = nil
134+ state.closeLocked()
135+ state.unlock()
136+ state.afterClose()
137+ })
138+}
139+
140+func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState {
141+ rt := ac.overlays
142+ return listInputState{
143+ pending: &rt.noticesPending,
144+ esc: &rt.noticesESC,
145+ moveUp: rt.noticesOverlay.Up,
146+ moveDown: rt.noticesOverlay.Down,
147+ lock: rt.noticeMu.Lock,
148+ unlock: rt.noticeMu.Unlock,
149+ active: func() bool { return rt.noticesOverlay != nil },
150+ closeLocked: func() {
151+ rt.noticesOverlay = nil
152+ },
153+ afterClose: func() {
154+ if sess := ac.currentSession(); sess != nil {
155+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
156+ }
157+ },
158+ }
159+}
160+
161+func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
162+ sess := ac.currentSession()
163+ if sess == nil {
164+ return
165+ }
166+ rt := ac.overlays
167+ rt.noticeMu.Lock()
168+ if rt.noticesOverlay == nil {
169+ rt.noticesPending = nil
170+ rt.noticesESC.stop()
171+ rt.noticeMu.Unlock()
172+ return
173+ }
174+ result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult {
175+ if b == 'y' {
176+ return listInputResult{action: b}
177+ }
178+ return listInputResult{}
179+ })
180+ var yankTarget domain.Notification
181+ yank := result.action == 'y'
182+ if yank {
183+ yankTarget, yank = rt.noticesOverlay.Selected()
184+ }
185+ rt.noticeMu.Unlock()
186+
187+ if result.exit {
91 d.closeNotices(ac)188 d.closeNotices(ac)
92 }189 }
93 if yank {190 if yank {
@@ -96,7 +193,7 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
96 // way copy mode's own 'y' commits and exits.193 // way copy mode's own 'y' commits and exits.
97 d.yankNotice(sess, ac, yankTarget)194 d.yankNotice(sess, ac, yankTarget)
98 }195 }
99- if exit || changed {196+ if result.exit || result.changed {
100 d.invalidateRender(sess, ac, true, "notices_overlay.go")197 d.invalidateRender(sess, ac, true, "notices_overlay.go")
101 }198 }
102 }199 }
@@ -153,48 +250,10 @@ func severityWord(sev domain.NoticeSeverity) string {
153 }250 }
154 }251 }
155 252
156-func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) {
157- ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC)
158- ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
159- ac.overlays.noticeMu.Lock()
160- if ac.overlays.noticesESC.timer != timer || len(ac.overlays.noticesPending) != 1 || ac.overlays.noticesPending[0] != keys.ESC || ac.overlays.noticesOverlay == nil {
161- ac.overlays.noticeMu.Unlock()
162- return
163- }
164- ac.overlays.noticesPending = nil
165- ac.overlays.noticesESC.timer = nil
166- ac.overlays.noticesESC.done = nil
167- ac.overlays.noticesOverlay = nil
168- ac.overlays.noticeMu.Unlock()
169-
170- if sess := ac.currentSession(); sess != nil {
171- d.invalidateRender(sess, ac, true, "notices_overlay.go")
172- }
173- })
174-}
175-
176-func (d *Daemon) stopNoticesPendingTimerLocked(ac *attachedClient) {
177- ac.overlays.noticesESC.stop()
178-}
179-
180 func (d *Daemon) closeNotices(ac *attachedClient) {253 func (d *Daemon) closeNotices(ac *attachedClient) {
181 ac.overlays.noticeMu.Lock()254 ac.overlays.noticeMu.Lock()
182 ac.overlays.noticesOverlay = nil255 ac.overlays.noticesOverlay = nil
183 ac.overlays.noticesPending = nil256 ac.overlays.noticesPending = nil
184- d.stopNoticesPendingTimerLocked(ac)257+ ac.overlays.noticesESC.stop()
185 ac.overlays.noticeMu.Unlock()258 ac.overlays.noticeMu.Unlock()
186 }259 }
187-
188-func routeNoticesEscape(m *notices.Model, data []byte) (int, bool) {
189- if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
190- switch data[2] {
191- case 'A':
192- m.Up()
193- return 3, true
194- case 'B':
195- m.Down()
196- return 3, true
197- }
198- }
199- return 0, false
200-}
M internal/usecase/daemon/notices_overlay_test.go +33 -0
@@ -114,6 +114,39 @@ func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) {
114114 awaitFrame(t, sends, ports.MsgOutput)
115115 }
116116
117+func TestNoticesSplitArrowNavigatesWithoutClosing(t *testing.T) {
118+ cases := []struct {
119+ name string
120+ input [][]byte
121+ want string
122+ }{
123+ {name: "escape then CSI down", input: [][]byte{[]byte("\x1b"), []byte("[B")}, want: "first"},
124+ {name: "split CSI down", input: [][]byte{[]byte("\x1b["), []byte("B")}, want: "first"},
125+ {name: "split SS3 down", input: [][]byte{[]byte("\x1bO"), []byte("B")}, want: "first"},
126+ {name: "split CSI up", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, want: "second"},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ p, release := newBlockingPTY(t)
131+ defer release()
132+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
133+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
134+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
135+
136+ d.enterNotices(sess, ac)
137+ awaitFrame(t, sends, ports.MsgOutput)
138+ for _, input := range tc.input {
139+ d.handleInput(sess, ac, input)
140+ }
141+
142+ require.True(t, ac.overlays.noticesActive())
143+ selected, ok := ac.overlays.noticesOverlay.Selected()
144+ require.True(t, ok)
145+ require.Equal(t, tc.want, selected.Message)
146+ })
147+ }
148+}
149+
117150 func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
118151 p, release := newBlockingPTY(t)
119152 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -114,6 +114,39 @@ func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) {
114 awaitFrame(t, sends, ports.MsgOutput)114 awaitFrame(t, sends, ports.MsgOutput)
115 }115 }
116 116
117+func TestNoticesSplitArrowNavigatesWithoutClosing(t *testing.T) {
118+ cases := []struct {
119+ name string
120+ input [][]byte
121+ want string
122+ }{
123+ {name: "escape then CSI down", input: [][]byte{[]byte("\x1b"), []byte("[B")}, want: "first"},
124+ {name: "split CSI down", input: [][]byte{[]byte("\x1b["), []byte("B")}, want: "first"},
125+ {name: "split SS3 down", input: [][]byte{[]byte("\x1bO"), []byte("B")}, want: "first"},
126+ {name: "split CSI up", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, want: "second"},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ p, release := newBlockingPTY(t)
131+ defer release()
132+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
133+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
134+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
135+
136+ d.enterNotices(sess, ac)
137+ awaitFrame(t, sends, ports.MsgOutput)
138+ for _, input := range tc.input {
139+ d.handleInput(sess, ac, input)
140+ }
141+
142+ require.True(t, ac.overlays.noticesActive())
143+ selected, ok := ac.overlays.noticesOverlay.Selected()
144+ require.True(t, ok)
145+ require.Equal(t, tc.want, selected.Message)
146+ })
147+ }
148+}
149+
117 func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {150 func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
118 p, release := newBlockingPTY(t)151 p, release := newBlockingPTY(t)
119 d, sess, ac, sends := newManualSessionWithPTYs(t, p)152 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/notify.go +19 -0
@@ -159,6 +159,25 @@ func benignNoticeError(err error) bool {
159159 errors.Is(err, ports.ErrNoClipboardImage)
160160 }
161161
162+// persistShutdownSnapshotFailure records a terminal snapshot failure that
163+// cannot be toasted because daemon shutdown has already begun. It belongs to
164+// notification ownership so session teardown only decides whether shutdown
165+// makes the failure non-retryable.
166+func (d *Daemon) persistShutdownSnapshotFailure(name string, cause error) {
167+ if d.noticeStore == nil {
168+ return
169+ }
170+ if err := d.noticeStore.Append(domain.Notification{
171+ Code: domain.NoticeSnapshotWrite,
172+ Severity: domain.NoticeError,
173+ Message: "session " + name + " shut down without saving terminal state",
174+ Details: noticeDetails(cause),
175+ Time: d.clock.Now(),
176+ }); err != nil {
177+ d.log.Warn("persisting shutdown notice failed", "err", err, "session", name)
178+ }
179+}
180+
162181 // reportError turns any error into a user-facing notice. Unclassified errors
163182 // become NoticeInternal: an error reaching here is never silently dropped
164183 // unless benignNoticeError says it is routine.
@@ -159,6 +159,25 @@ func benignNoticeError(err error) bool {
159 errors.Is(err, ports.ErrNoClipboardImage)159 errors.Is(err, ports.ErrNoClipboardImage)
160 }160 }
161 161
162+// persistShutdownSnapshotFailure records a terminal snapshot failure that
163+// cannot be toasted because daemon shutdown has already begun. It belongs to
164+// notification ownership so session teardown only decides whether shutdown
165+// makes the failure non-retryable.
166+func (d *Daemon) persistShutdownSnapshotFailure(name string, cause error) {
167+ if d.noticeStore == nil {
168+ return
169+ }
170+ if err := d.noticeStore.Append(domain.Notification{
171+ Code: domain.NoticeSnapshotWrite,
172+ Severity: domain.NoticeError,
173+ Message: "session " + name + " shut down without saving terminal state",
174+ Details: noticeDetails(cause),
175+ Time: d.clock.Now(),
176+ }); err != nil {
177+ d.log.Warn("persisting shutdown notice failed", "err", err, "session", name)
178+ }
179+}
180+
162 // reportError turns any error into a user-facing notice. Unclassified errors181 // reportError turns any error into a user-facing notice. Unclassified errors
163 // become NoticeInternal: an error reaching here is never silently dropped182 // become NoticeInternal: an error reaching here is never silently dropped
164 // unless benignNoticeError says it is routine.183 // unless benignNoticeError says it is routine.
M internal/usecase/daemon/picker.go +55 -109
@@ -6,7 +6,6 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/usecase/keys"
109 "github.com/bnema/vev/internal/usecase/layout"
1110 "github.com/bnema/vev/internal/usecase/picker"
1211 "github.com/bnema/vev/internal/usecase/ui"
@@ -93,140 +92,87 @@ func attentionSuffix(label string) string {
9392 return label + " " + string(ui.AttentionGlyph)
9493 }
9594
95+func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
96+ rt := ac.overlays
97+ var generation uint64
98+ return listInputState{
99+ pending: &rt.pickerPending,
100+ esc: &rt.pickerESC,
101+ moveUp: rt.picker.Up,
102+ moveDown: rt.picker.Down,
103+ lock: rt.pickerMu.Lock,
104+ unlock: rt.pickerMu.Unlock,
105+ active: func() bool { return rt.picker != nil },
106+ closeLocked: func() {
107+ rt.picker = nil
108+ generation = rt.pickerPreviewGeneration
109+ },
110+ afterClose: func() {
111+ d.clearPreviewGeneration(ac, generation)
112+ if sess := ac.currentSession(); sess != nil {
113+ d.invalidateRender(sess, ac, true, "picker.go")
114+ }
115+ },
116+ }
117+}
118+
96119 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
97120 sess := ac.currentSession()
98121 if sess == nil {
99122 return
100123 }
101- ac.overlays.pickerMu.Lock()
102- if ac.overlays.picker == nil {
103- ac.overlays.pickerPending = nil
104- d.stopPickerPendingTimerLocked(ac)
105- ac.overlays.pickerMu.Unlock()
124+ rt := ac.overlays
125+ rt.pickerMu.Lock()
126+ if rt.picker == nil {
127+ rt.pickerPending = nil
128+ rt.pickerESC.stop()
129+ rt.pickerMu.Unlock()
106130 return
107131 }
108- if len(ac.overlays.pickerPending) > 0 {
109- d.stopPickerPendingTimerLocked(ac)
110- combined := make([]byte, 0, len(ac.overlays.pickerPending)+len(data))
111- combined = append(combined, ac.overlays.pickerPending...)
112- combined = append(combined, data...)
113- data = combined
114- ac.overlays.pickerPending = nil
115- }
116- changed := false
117- exit := false
118- switchTarget := false
119- for i := 0; i < len(data); i++ {
120- switch data[i] {
132+ result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult {
133+ switch b {
121134 case 'x':
122- target, ok := ac.overlays.picker.Selected()
123- ac.overlays.pickerMu.Unlock()
124- if ok {
125- if err := d.killPickerTarget(target); err != nil {
126- d.reportError(sess, err)
127- }
128- }
129- d.refreshPicker(ac)
130- d.invalidateRender(sess, ac, true, "picker.go")
131- return
132- case 'j':
133- ac.overlays.picker.Down()
134- changed = true
135- case 'k':
136- ac.overlays.picker.Up()
137- changed = true
135+ return listInputResult{action: b, stop: true}
138136 case '\r', '\n':
139- switchTarget = true
140- exit = true
141- case 'q', 0x03, 0x1b:
142- if data[i] == 0x1b {
143- tail := data[i:]
144- consumed, ok := routePickerEscape(ac.overlays.picker, tail)
145- if ok {
146- i += consumed - 1
147- changed = true
148- continue
149- }
150- if len(tail) == 1 {
151- d.retainPickerESCLocked(ac)
152- break
153- }
154- if isPickerEscapePrefix(tail) {
155- ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], tail...)
156- break
157- }
158- }
159- exit = true
137+ return listInputResult{action: b, exit: true}
138+ default:
139+ return listInputResult{}
160140 }
161- }
141+ })
162142 var target picker.Target
163143 var ok bool
164- if switchTarget {
165- target, ok = ac.overlays.picker.Selected()
144+ if result.action == 'x' || result.action == '\r' || result.action == '\n' {
145+ target, ok = rt.picker.Selected()
166146 }
167- ac.overlays.pickerMu.Unlock()
147+ rt.pickerMu.Unlock()
168148
169- if changed {
149+ if result.action == 'x' {
150+ if ok {
151+ if err := d.killPickerTarget(target); err != nil {
152+ d.reportError(sess, err)
153+ }
154+ }
155+ d.refreshPicker(ac)
156+ d.invalidateRender(sess, ac, true, "picker.go")
157+ return
158+ }
159+ if result.changed {
170160 d.registerPreviewForSelection(ac)
171161 }
172- if exit {
162+ if result.exit {
173163 d.closePicker(ac)
174164 }
175- if switchTarget && ok {
165+ if (result.action == '\r' || result.action == '\n') && ok {
176166 if err := d.switchToTarget(sess, ac, target); err != nil {
177167 d.reportError(sess, err)
178168 }
179169 return
180170 }
181- if exit || changed {
171+ if result.exit || result.changed {
182172 d.invalidateRender(sess, ac, true, "picker.go")
183173 }
184174 }
185175
186-func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
187- ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], keys.ESC)
188- ac.overlays.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
189- ac.overlays.pickerMu.Lock()
190- if ac.overlays.pickerESC.timer != timer || len(ac.overlays.pickerPending) != 1 || ac.overlays.pickerPending[0] != keys.ESC || ac.overlays.picker == nil {
191- ac.overlays.pickerMu.Unlock()
192- return
193- }
194- ac.overlays.pickerPending = nil
195- ac.overlays.pickerESC.timer = nil
196- ac.overlays.pickerESC.done = nil
197- ac.overlays.picker = nil
198- generation := ac.overlays.pickerPreviewGeneration
199- ac.overlays.pickerMu.Unlock()
200-
201- d.clearPreviewGeneration(ac, generation)
202- if sess := ac.currentSession(); sess != nil {
203- d.invalidateRender(sess, ac, true, "picker.go")
204- }
205- })
206-}
207-
208-func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
209- ac.overlays.pickerESC.stop()
210-}
211-
212-func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
213- if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
214- switch data[2] {
215- case 'A':
216- m.Up()
217- return 3, true
218- case 'B':
219- m.Down()
220- return 3, true
221- }
222- }
223- return 0, false
224-}
225-
226-func isPickerEscapePrefix(data []byte) bool {
227- return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O')
228-}
229-
230176 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
231177 if ac == nil || ac.overlays == nil {
232178 return
@@ -368,7 +314,7 @@ func (d *Daemon) closePicker(ac *attachedClient) {
368314 ac.overlays.pickerMu.Lock()
369315 ac.overlays.picker = nil
370316 ac.overlays.pickerPending = nil
371- d.stopPickerPendingTimerLocked(ac)
317+ ac.overlays.pickerESC.stop()
372318 generation := ac.overlays.pickerPreviewGeneration
373319 ac.overlays.pickerMu.Unlock()
374320 d.clearPreviewGeneration(ac, generation)
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/usecase/keys"
10 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/internal/usecase/picker"10 "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/bnema/vev/internal/usecase/ui"11 "github.com/bnema/vev/internal/usecase/ui"
@@ -93,140 +92,87 @@ func attentionSuffix(label string) string {
93 return label + " " + string(ui.AttentionGlyph)92 return label + " " + string(ui.AttentionGlyph)
94 }93 }
95 94
95+func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
96+ rt := ac.overlays
97+ var generation uint64
98+ return listInputState{
99+ pending: &rt.pickerPending,
100+ esc: &rt.pickerESC,
101+ moveUp: rt.picker.Up,
102+ moveDown: rt.picker.Down,
103+ lock: rt.pickerMu.Lock,
104+ unlock: rt.pickerMu.Unlock,
105+ active: func() bool { return rt.picker != nil },
106+ closeLocked: func() {
107+ rt.picker = nil
108+ generation = rt.pickerPreviewGeneration
109+ },
110+ afterClose: func() {
111+ d.clearPreviewGeneration(ac, generation)
112+ if sess := ac.currentSession(); sess != nil {
113+ d.invalidateRender(sess, ac, true, "picker.go")
114+ }
115+ },
116+ }
117+}
118+
96 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {119 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
97 sess := ac.currentSession()120 sess := ac.currentSession()
98 if sess == nil {121 if sess == nil {
99 return122 return
100 }123 }
101- ac.overlays.pickerMu.Lock()124+ rt := ac.overlays
102- if ac.overlays.picker == nil {125+ rt.pickerMu.Lock()
103- ac.overlays.pickerPending = nil126+ if rt.picker == nil {
104- d.stopPickerPendingTimerLocked(ac)127+ rt.pickerPending = nil
105- ac.overlays.pickerMu.Unlock()128+ rt.pickerESC.stop()
129+ rt.pickerMu.Unlock()
106 return130 return
107 }131 }
108- if len(ac.overlays.pickerPending) > 0 {132+ result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult {
109- d.stopPickerPendingTimerLocked(ac)133+ switch b {
110- combined := make([]byte, 0, len(ac.overlays.pickerPending)+len(data))
111- combined = append(combined, ac.overlays.pickerPending...)
112- combined = append(combined, data...)
113- data = combined
114- ac.overlays.pickerPending = nil
115- }
116- changed := false
117- exit := false
118- switchTarget := false
119- for i := 0; i < len(data); i++ {
120- switch data[i] {
121 case 'x':134 case 'x':
122- target, ok := ac.overlays.picker.Selected()135+ return listInputResult{action: b, stop: true}
123- ac.overlays.pickerMu.Unlock()
124- if ok {
125- if err := d.killPickerTarget(target); err != nil {
126- d.reportError(sess, err)
127- }
128- }
129- d.refreshPicker(ac)
130- d.invalidateRender(sess, ac, true, "picker.go")
131- return
132- case 'j':
133- ac.overlays.picker.Down()
134- changed = true
135- case 'k':
136- ac.overlays.picker.Up()
137- changed = true
138 case '\r', '\n':136 case '\r', '\n':
139- switchTarget = true137+ return listInputResult{action: b, exit: true}
140- exit = true138+ default:
141- case 'q', 0x03, 0x1b:139+ return listInputResult{}
142- if data[i] == 0x1b {
143- tail := data[i:]
144- consumed, ok := routePickerEscape(ac.overlays.picker, tail)
145- if ok {
146- i += consumed - 1
147- changed = true
148- continue
149- }
150- if len(tail) == 1 {
151- d.retainPickerESCLocked(ac)
152- break
153- }
154- if isPickerEscapePrefix(tail) {
155- ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], tail...)
156- break
157- }
158- }
159- exit = true
160 }140 }
161- }141+ })
162 var target picker.Target142 var target picker.Target
163 var ok bool143 var ok bool
164- if switchTarget {144+ if result.action == 'x' || result.action == '\r' || result.action == '\n' {
165- target, ok = ac.overlays.picker.Selected()145+ target, ok = rt.picker.Selected()
166 }146 }
167- ac.overlays.pickerMu.Unlock()147+ rt.pickerMu.Unlock()
168 148
169- if changed {149+ if result.action == 'x' {
150+ if ok {
151+ if err := d.killPickerTarget(target); err != nil {
152+ d.reportError(sess, err)
153+ }
154+ }
155+ d.refreshPicker(ac)
156+ d.invalidateRender(sess, ac, true, "picker.go")
157+ return
158+ }
159+ if result.changed {
170 d.registerPreviewForSelection(ac)160 d.registerPreviewForSelection(ac)
171 }161 }
172- if exit {162+ if result.exit {
173 d.closePicker(ac)163 d.closePicker(ac)
174 }164 }
175- if switchTarget && ok {165+ if (result.action == '\r' || result.action == '\n') && ok {
176 if err := d.switchToTarget(sess, ac, target); err != nil {166 if err := d.switchToTarget(sess, ac, target); err != nil {
177 d.reportError(sess, err)167 d.reportError(sess, err)
178 }168 }
179 return169 return
180 }170 }
181- if exit || changed {171+ if result.exit || result.changed {
182 d.invalidateRender(sess, ac, true, "picker.go")172 d.invalidateRender(sess, ac, true, "picker.go")
183 }173 }
184 }174 }
185 175
186-func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
187- ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], keys.ESC)
188- ac.overlays.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
189- ac.overlays.pickerMu.Lock()
190- if ac.overlays.pickerESC.timer != timer || len(ac.overlays.pickerPending) != 1 || ac.overlays.pickerPending[0] != keys.ESC || ac.overlays.picker == nil {
191- ac.overlays.pickerMu.Unlock()
192- return
193- }
194- ac.overlays.pickerPending = nil
195- ac.overlays.pickerESC.timer = nil
196- ac.overlays.pickerESC.done = nil
197- ac.overlays.picker = nil
198- generation := ac.overlays.pickerPreviewGeneration
199- ac.overlays.pickerMu.Unlock()
200-
201- d.clearPreviewGeneration(ac, generation)
202- if sess := ac.currentSession(); sess != nil {
203- d.invalidateRender(sess, ac, true, "picker.go")
204- }
205- })
206-}
207-
208-func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
209- ac.overlays.pickerESC.stop()
210-}
211-
212-func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
213- if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
214- switch data[2] {
215- case 'A':
216- m.Up()
217- return 3, true
218- case 'B':
219- m.Down()
220- return 3, true
221- }
222- }
223- return 0, false
224-}
225-
226-func isPickerEscapePrefix(data []byte) bool {
227- return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O')
228-}
229-
230 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {176 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
231 if ac == nil || ac.overlays == nil {177 if ac == nil || ac.overlays == nil {
232 return178 return
@@ -368,7 +314,7 @@ func (d *Daemon) closePicker(ac *attachedClient) {
368 ac.overlays.pickerMu.Lock()314 ac.overlays.pickerMu.Lock()
369 ac.overlays.picker = nil315 ac.overlays.picker = nil
370 ac.overlays.pickerPending = nil316 ac.overlays.pickerPending = nil
371- d.stopPickerPendingTimerLocked(ac)317+ ac.overlays.pickerESC.stop()
372 generation := ac.overlays.pickerPreviewGeneration318 generation := ac.overlays.pickerPreviewGeneration
373 ac.overlays.pickerMu.Unlock()319 ac.overlays.pickerMu.Unlock()
374 d.clearPreviewGeneration(ac, generation)320 d.clearPreviewGeneration(ac, generation)
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -313,7 +313,7 @@ func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.
313313 }
314314 views := make([]ui.NoticeView, len(overlays.notices))
315315 for i, n := range overlays.notices {
316- views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count}
316+ views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count}
317317 }
318318 return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
319319 }
@@ -313,7 +313,7 @@ func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.
313 }313 }
314 views := make([]ui.NoticeView, len(overlays.notices))314 views := make([]ui.NoticeView, len(overlays.notices))
315 for i, n := range overlays.notices {315 for i, n := range overlays.notices {
316- views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count}316+ views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count}
317 }317 }
318 return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))318 return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
319 }319 }
M internal/usecase/daemon/session.go +1 -13
@@ -745,19 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
745745 if reason != ports.ReasonServerShutdown {
746746 return terminalSnapshotErr
747747 }
748- // The daemon is dying: there is no attached client left to toast, so
749- // persist the failure for the next startup to surface instead.
750- if ns := d.noticeStore; ns != nil {
751- if err := ns.Append(domain.Notification{
752- Code: domain.NoticeSnapshotWrite,
753- Severity: domain.NoticeError,
754- Message: "session " + name + " shut down without saving terminal state",
755- Details: noticeDetails(terminalSnapshotErr),
756- Time: d.clock.Now(),
757- }); err != nil {
758- d.log.Warn("persisting shutdown notice failed", "err", err, "session", name)
759- }
760- }
748+ d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
761749 }
762750 }
763751 }
@@ -745,19 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
745 if reason != ports.ReasonServerShutdown {745 if reason != ports.ReasonServerShutdown {
746 return terminalSnapshotErr746 return terminalSnapshotErr
747 }747 }
748- // The daemon is dying: there is no attached client left to toast, so748+ d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
749- // persist the failure for the next startup to surface instead.
750- if ns := d.noticeStore; ns != nil {
751- if err := ns.Append(domain.Notification{
752- Code: domain.NoticeSnapshotWrite,
753- Severity: domain.NoticeError,
754- Message: "session " + name + " shut down without saving terminal state",
755- Details: noticeDetails(terminalSnapshotErr),
756- Time: d.clock.Now(),
757- }); err != nil {
758- d.log.Warn("persisting shutdown notice failed", "err", err, "session", name)
759- }
760- }
761 }749 }
762 }750 }
763 }751 }
M internal/usecase/daemon/session_test.go +0 -75
@@ -3,7 +3,6 @@ package daemon
33 import (
44 "context"
55 "errors"
6- "fmt"
76 "io"
87 "log/slog"
98 "math"
@@ -21,7 +20,6 @@ import (
2120 "github.com/bnema/vev/internal/ports"
2221 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2322 "github.com/bnema/vev/internal/usecase/picker"
24- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2523 )
2624
2725 // --- test doubles -----------------------------------------------------------
@@ -974,79 +972,6 @@ func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
974972 requireFloatingInitialized(t, first)
975973 }
976974
977-// TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the
978-// last tab of a named session is refused because the snapshot worker cannot
979-// retain the final state, the session survives for a retry and the still
980-// attached client is told why, instead of the close silently no-oping.
981-//
982-// The snapshot worker is saturated deterministically with the same fixture as
983-// the kill-session ordering tests: one capture blocked in marshal, one in the
984-// bounded queue, and the terminal-retention fallback filled to capacity. No
985-// assertion runs on the worker goroutine; the marshal gate is released only on
986-// cleanup, after every assertion has run on the test goroutine.
987-func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) {
988- store := discardSnapshotStore{}
989- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
990- WithSnapshotStore(store)(d)
991- startSnapshotEncodeWorker(t, d)
992-
993- entered := make(chan struct{})
994- release := make(chan struct{})
995- // Release the sole blocked marshal on the test goroutine before the worker
996- // is stopped in cleanup, so teardown never waits on the gate.
997- defer close(release)
998- var calls atomic.Int32
999- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
1000- if calls.Add(1) == 1 {
1001- close(entered)
1002- <-release
1003- }
1004- return snapcodec.Marshal(s)
1005- }
1006-
1007- active := newSnapshotTestSession(t, "active", false, "/work")
1008- queued := newSnapshotTestSession(t, "queued", false, "/work")
1009- markSnapshotDirty(active)
1010- markSnapshotDirty(queued)
1011- require.True(t, d.scheduleSnapshot(active))
1012- <-entered // the worker is now parked inside marshal for active
1013- require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
1014- for i := range snapshotFinalQueueCapacity {
1015- s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
1016- markSnapshotDirty(s)
1017- require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
1018- }
1019-
1020- // A single-tab named session whose client is still attached.
1021- target := newSnapshotTestSession(t, "target", false, "/work")
1022- tr, _ := newCapturingTransport(t)
1023- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1024- ac.initOverlays()
1025- ac.setSession(target)
1026- target.client = ac
1027- d.mu.Lock()
1028- d.sessions[target.id] = target
1029- d.mu.Unlock()
1030- require.Len(t, target.tabs, 1)
1031-
1032- d.closeTab(target, target.tabs[0], false)
1033-
1034- // The refused close retains the session for a retry.
1035- d.mu.Lock()
1036- require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session")
1037- d.mu.Unlock()
1038-
1039- // The refusal is recorded in history and shown on the still-attached client.
1040- hist := d.notices.history()
1041- require.NotEmpty(t, hist, "the refused close must be recorded as a notice")
1042- require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code)
1043- require.Equal(t, target.id, hist[0].SessionID)
1044-
1045- toasts := awaitToastCount(t, ac, 1)
1046- require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code)
1047- require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message)
1048-}
1049-
1050975 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
1051976 sz := domain.Size{Cols: 80, Rows: 24}
1052977 p, release := newBlockingPTY(t)
@@ -3,7 +3,6 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "fmt"
7 "io"6 "io"
8 "log/slog"7 "log/slog"
9 "math"8 "math"
@@ -21,7 +20,6 @@ import (
21 "github.com/bnema/vev/internal/ports"20 "github.com/bnema/vev/internal/ports"
22 portsmocks "github.com/bnema/vev/internal/ports/mocks"21 portsmocks "github.com/bnema/vev/internal/ports/mocks"
23 "github.com/bnema/vev/internal/usecase/picker"22 "github.com/bnema/vev/internal/usecase/picker"
24- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
25 )23 )
26 24
27 // --- test doubles -----------------------------------------------------------25 // --- test doubles -----------------------------------------------------------
@@ -974,79 +972,6 @@ func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
974 requireFloatingInitialized(t, first)972 requireFloatingInitialized(t, first)
975 }973 }
976 974
977-// TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the
978-// last tab of a named session is refused because the snapshot worker cannot
979-// retain the final state, the session survives for a retry and the still
980-// attached client is told why, instead of the close silently no-oping.
981-//
982-// The snapshot worker is saturated deterministically with the same fixture as
983-// the kill-session ordering tests: one capture blocked in marshal, one in the
984-// bounded queue, and the terminal-retention fallback filled to capacity. No
985-// assertion runs on the worker goroutine; the marshal gate is released only on
986-// cleanup, after every assertion has run on the test goroutine.
987-func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) {
988- store := discardSnapshotStore{}
989- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
990- WithSnapshotStore(store)(d)
991- startSnapshotEncodeWorker(t, d)
992-
993- entered := make(chan struct{})
994- release := make(chan struct{})
995- // Release the sole blocked marshal on the test goroutine before the worker
996- // is stopped in cleanup, so teardown never waits on the gate.
997- defer close(release)
998- var calls atomic.Int32
999- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
1000- if calls.Add(1) == 1 {
1001- close(entered)
1002- <-release
1003- }
1004- return snapcodec.Marshal(s)
1005- }
1006-
1007- active := newSnapshotTestSession(t, "active", false, "/work")
1008- queued := newSnapshotTestSession(t, "queued", false, "/work")
1009- markSnapshotDirty(active)
1010- markSnapshotDirty(queued)
1011- require.True(t, d.scheduleSnapshot(active))
1012- <-entered // the worker is now parked inside marshal for active
1013- require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
1014- for i := range snapshotFinalQueueCapacity {
1015- s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
1016- markSnapshotDirty(s)
1017- require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
1018- }
1019-
1020- // A single-tab named session whose client is still attached.
1021- target := newSnapshotTestSession(t, "target", false, "/work")
1022- tr, _ := newCapturingTransport(t)
1023- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1024- ac.initOverlays()
1025- ac.setSession(target)
1026- target.client = ac
1027- d.mu.Lock()
1028- d.sessions[target.id] = target
1029- d.mu.Unlock()
1030- require.Len(t, target.tabs, 1)
1031-
1032- d.closeTab(target, target.tabs[0], false)
1033-
1034- // The refused close retains the session for a retry.
1035- d.mu.Lock()
1036- require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session")
1037- d.mu.Unlock()
1038-
1039- // The refusal is recorded in history and shown on the still-attached client.
1040- hist := d.notices.history()
1041- require.NotEmpty(t, hist, "the refused close must be recorded as a notice")
1042- require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code)
1043- require.Equal(t, target.id, hist[0].SessionID)
1044-
1045- toasts := awaitToastCount(t, ac, 1)
1046- require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code)
1047- require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message)
1048-}
1049-
1050 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {975 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
1051 sz := domain.Size{Cols: 80, Rows: 24}976 sz := domain.Size{Cols: 80, Rows: 24}
1052 p, release := newBlockingPTY(t)977 p, release := newBlockingPTY(t)
M internal/usecase/daemon/snapshot_notice_test.go +76 -0
@@ -2,6 +2,8 @@ package daemon
22
33 import (
44 "errors"
5+ "fmt"
6+ "sync/atomic"
57 "testing"
68
79 "github.com/stretchr/testify/mock"
@@ -9,6 +11,7 @@ import (
911
1012 "github.com/bnema/vev/internal/domain"
1113 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1215 )
1316
1417 // TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces
@@ -43,3 +46,76 @@ func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) {
4346 "snapshot write failures are global, never scoped to the (possibly gone) failing session")
4447 require.Contains(t, toasts[0].Details, "disk full")
4548 }
49+
50+// TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the
51+// last tab of a named session is refused because the snapshot worker cannot
52+// retain the final state, the session survives for a retry and the still
53+// attached client is told why, instead of the close silently no-oping.
54+//
55+// The snapshot worker is saturated deterministically with the same fixture as
56+// the kill-session ordering tests: one capture blocked in marshal, one in the
57+// bounded queue, and the terminal-retention fallback filled to capacity. No
58+// assertion runs on the worker goroutine; the marshal gate is released only on
59+// cleanup, after every assertion has run on the test goroutine.
60+func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) {
61+ store := discardSnapshotStore{}
62+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
63+ WithSnapshotStore(store)(d)
64+ startSnapshotEncodeWorker(t, d)
65+
66+ entered := make(chan struct{})
67+ release := make(chan struct{})
68+ // Release the sole blocked marshal on the test goroutine before the worker
69+ // is stopped in cleanup, so teardown never waits on the gate.
70+ defer close(release)
71+ var calls atomic.Int32
72+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
73+ if calls.Add(1) == 1 {
74+ close(entered)
75+ <-release
76+ }
77+ return snapcodec.Marshal(s)
78+ }
79+
80+ active := newSnapshotTestSession(t, "active", false, "/work")
81+ queued := newSnapshotTestSession(t, "queued", false, "/work")
82+ markSnapshotDirty(active)
83+ markSnapshotDirty(queued)
84+ require.True(t, d.scheduleSnapshot(active))
85+ <-entered // the worker is now parked inside marshal for active
86+ require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
87+ for i := range snapshotFinalQueueCapacity {
88+ s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
89+ markSnapshotDirty(s)
90+ require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
91+ }
92+
93+ // A single-tab named session whose client is still attached.
94+ target := newSnapshotTestSession(t, "target", false, "/work")
95+ tr, _ := newCapturingTransport(t)
96+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
97+ ac.initOverlays()
98+ ac.setSession(target)
99+ target.client = ac
100+ d.mu.Lock()
101+ d.sessions[target.id] = target
102+ d.mu.Unlock()
103+ require.Len(t, target.tabs, 1)
104+
105+ d.closeTab(target, target.tabs[0], false)
106+
107+ // The refused close retains the session for a retry.
108+ d.mu.Lock()
109+ require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session")
110+ d.mu.Unlock()
111+
112+ // The refusal is recorded in history and shown on the still-attached client.
113+ hist := d.notices.history()
114+ require.NotEmpty(t, hist, "the refused close must be recorded as a notice")
115+ require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code)
116+ require.Equal(t, target.id, hist[0].SessionID)
117+
118+ toasts := awaitToastCount(t, ac, 1)
119+ require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code)
120+ require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message)
121+}
@@ -2,6 +2,8 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "fmt"
6+ "sync/atomic"
5 "testing"7 "testing"
6 8
7 "github.com/stretchr/testify/mock"9 "github.com/stretchr/testify/mock"
@@ -9,6 +11,7 @@ import (
9 11
10 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
11 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
12 )15 )
13 16
14 // TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces17 // TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces
@@ -43,3 +46,76 @@ func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) {
43 "snapshot write failures are global, never scoped to the (possibly gone) failing session")46 "snapshot write failures are global, never scoped to the (possibly gone) failing session")
44 require.Contains(t, toasts[0].Details, "disk full")47 require.Contains(t, toasts[0].Details, "disk full")
45 }48 }
49+
50+// TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the
51+// last tab of a named session is refused because the snapshot worker cannot
52+// retain the final state, the session survives for a retry and the still
53+// attached client is told why, instead of the close silently no-oping.
54+//
55+// The snapshot worker is saturated deterministically with the same fixture as
56+// the kill-session ordering tests: one capture blocked in marshal, one in the
57+// bounded queue, and the terminal-retention fallback filled to capacity. No
58+// assertion runs on the worker goroutine; the marshal gate is released only on
59+// cleanup, after every assertion has run on the test goroutine.
60+func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) {
61+ store := discardSnapshotStore{}
62+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
63+ WithSnapshotStore(store)(d)
64+ startSnapshotEncodeWorker(t, d)
65+
66+ entered := make(chan struct{})
67+ release := make(chan struct{})
68+ // Release the sole blocked marshal on the test goroutine before the worker
69+ // is stopped in cleanup, so teardown never waits on the gate.
70+ defer close(release)
71+ var calls atomic.Int32
72+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
73+ if calls.Add(1) == 1 {
74+ close(entered)
75+ <-release
76+ }
77+ return snapcodec.Marshal(s)
78+ }
79+
80+ active := newSnapshotTestSession(t, "active", false, "/work")
81+ queued := newSnapshotTestSession(t, "queued", false, "/work")
82+ markSnapshotDirty(active)
83+ markSnapshotDirty(queued)
84+ require.True(t, d.scheduleSnapshot(active))
85+ <-entered // the worker is now parked inside marshal for active
86+ require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
87+ for i := range snapshotFinalQueueCapacity {
88+ s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
89+ markSnapshotDirty(s)
90+ require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
91+ }
92+
93+ // A single-tab named session whose client is still attached.
94+ target := newSnapshotTestSession(t, "target", false, "/work")
95+ tr, _ := newCapturingTransport(t)
96+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
97+ ac.initOverlays()
98+ ac.setSession(target)
99+ target.client = ac
100+ d.mu.Lock()
101+ d.sessions[target.id] = target
102+ d.mu.Unlock()
103+ require.Len(t, target.tabs, 1)
104+
105+ d.closeTab(target, target.tabs[0], false)
106+
107+ // The refused close retains the session for a retry.
108+ d.mu.Lock()
109+ require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session")
110+ d.mu.Unlock()
111+
112+ // The refusal is recorded in history and shown on the still-attached client.
113+ hist := d.notices.history()
114+ require.NotEmpty(t, hist, "the refused close must be recorded as a notice")
115+ require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code)
116+ require.Equal(t, target.id, hist[0].SessionID)
117+
118+ toasts := awaitToastCount(t, ac, 1)
119+ require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code)
120+ require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message)
121+}
M internal/usecase/ui/notice.go +4 -6
@@ -15,11 +15,9 @@ const (
1515 noticeMaxLines = 3
1616 )
1717
18-// NoticeView is a plain rendering view of one notification. Severity mirrors
19-// domain.NoticeSeverity as a bare uint8 so this package never needs to depend
20-// on internal/usecase/daemon for its input type.
18+// NoticeView is a plain rendering view of one notification.
2119 type NoticeView struct {
22- Severity uint8
20+ Severity domain.NoticeSeverity
2321 Title string
2422 Message string
2523 Count int
@@ -120,8 +118,8 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st
120118 return footprints
121119 }
122120
123-func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style {
124- switch domain.NoticeSeverity(severity) {
121+func noticeBoxStyle(styles NoticeStyles, severity domain.NoticeSeverity) renderer.Style {
122+ switch severity {
125123 case domain.NoticeWarn:
126124 return styles.BoxWarn
127125 case domain.NoticeError:
@@ -15,11 +15,9 @@ const (
15 noticeMaxLines = 315 noticeMaxLines = 3
16 )16 )
17 17
18-// NoticeView is a plain rendering view of one notification. Severity mirrors18+// NoticeView is a plain rendering view of one notification.
19-// domain.NoticeSeverity as a bare uint8 so this package never needs to depend
20-// on internal/usecase/daemon for its input type.
21 type NoticeView struct {19 type NoticeView struct {
22- Severity uint820+ Severity domain.NoticeSeverity
23 Title string21 Title string
24 Message string22 Message string
25 Count int23 Count int
@@ -120,8 +118,8 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st
120 return footprints118 return footprints
121 }119 }
122 120
123-func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style {121+func noticeBoxStyle(styles NoticeStyles, severity domain.NoticeSeverity) renderer.Style {
124- switch domain.NoticeSeverity(severity) {122+ switch severity {
125 case domain.NoticeWarn:123 case domain.NoticeWarn:
126 return styles.BoxWarn124 return styles.BoxWarn
127 case domain.NoticeError:125 case domain.NoticeError:
M internal/usecase/ui/notice_test.go +7 -7
@@ -67,7 +67,7 @@ func TestComposeNotices(t *testing.T) {
6767 {
6868 name: "single error notice draws a bordered box top-right with title and message",
6969 notices: []NoticeView{
70- {Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "couldn't open pane", Count: 3},
70+ {Severity: domain.NoticeError, Title: "pane-spawn", Message: "couldn't open pane", Count: 3},
7171 },
7272 check: func(t *testing.T, f renderer.Frame) {
7373 found := false
@@ -94,9 +94,9 @@ func TestComposeNotices(t *testing.T) {
9494 {
9595 name: "three notices with overflow renders a +N more line",
9696 notices: []NoticeView{
97- {Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "one"},
98- {Severity: uint8(domain.NoticeWarn), Title: "tab-spawn", Message: "two"},
99- {Severity: uint8(domain.NoticeInfo), Title: "clipboard", Message: "three"},
97+ {Severity: domain.NoticeError, Title: "pane-spawn", Message: "one"},
98+ {Severity: domain.NoticeWarn, Title: "tab-spawn", Message: "two"},
99+ {Severity: domain.NoticeInfo, Title: "clipboard", Message: "three"},
100100 },
101101 overflow: 2,
102102 check: func(t *testing.T, f renderer.Frame) {
@@ -157,7 +157,7 @@ func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) {
157157 for _, tt := range tests {
158158 t.Run(tt.name, func(t *testing.T) {
159159 f := renderer.NewFrame(80, 24)
160- ComposeNotices(f, []NoticeView{{Severity: uint8(tt.sev), Title: "x", Message: "y"}}, 0, styles)
160+ ComposeNotices(f, []NoticeView{{Severity: tt.sev, Title: "x", Message: "y"}}, 0, styles)
161161 corner := findRune(f, '┌')
162162 if corner == nil {
163163 t.Fatalf("no box top-left corner found:\n%s", frameText(f))
@@ -228,7 +228,7 @@ func TestComposeNoticesWidth(t *testing.T) {
228228 for _, tt := range tests {
229229 t.Run(tt.name, func(t *testing.T) {
230230 f := renderer.NewFrame(tt.frameWidth, 10)
231- ComposeNotices(f, []NoticeView{{Severity: uint8(domain.NoticeInfo), Title: "x", Message: "y"}}, 0, styles)
231+ ComposeNotices(f, []NoticeView{{Severity: domain.NoticeInfo, Title: "x", Message: "y"}}, 0, styles)
232232
233233 wantRight := tt.wantX + tt.wantWidth - 1
234234 rightX, rightY := findRunePos(f, '┐')
@@ -273,7 +273,7 @@ func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
273273 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {
274274 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
275275 f := renderer.NewFrame(size.Cols, size.Rows)
276- notices := []NoticeView{{Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}
276+ notices := []NoticeView{{Severity: domain.NoticeError, Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}
277277 ComposeNotices(f, notices, 5, styles)
278278 })
279279 }
@@ -67,7 +67,7 @@ func TestComposeNotices(t *testing.T) {
67 {67 {
68 name: "single error notice draws a bordered box top-right with title and message",68 name: "single error notice draws a bordered box top-right with title and message",
69 notices: []NoticeView{69 notices: []NoticeView{
70- {Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "couldn't open pane", Count: 3},70+ {Severity: domain.NoticeError, Title: "pane-spawn", Message: "couldn't open pane", Count: 3},
71 },71 },
72 check: func(t *testing.T, f renderer.Frame) {72 check: func(t *testing.T, f renderer.Frame) {
73 found := false73 found := false
@@ -94,9 +94,9 @@ func TestComposeNotices(t *testing.T) {
94 {94 {
95 name: "three notices with overflow renders a +N more line",95 name: "three notices with overflow renders a +N more line",
96 notices: []NoticeView{96 notices: []NoticeView{
97- {Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "one"},97+ {Severity: domain.NoticeError, Title: "pane-spawn", Message: "one"},
98- {Severity: uint8(domain.NoticeWarn), Title: "tab-spawn", Message: "two"},98+ {Severity: domain.NoticeWarn, Title: "tab-spawn", Message: "two"},
99- {Severity: uint8(domain.NoticeInfo), Title: "clipboard", Message: "three"},99+ {Severity: domain.NoticeInfo, Title: "clipboard", Message: "three"},
100 },100 },
101 overflow: 2,101 overflow: 2,
102 check: func(t *testing.T, f renderer.Frame) {102 check: func(t *testing.T, f renderer.Frame) {
@@ -157,7 +157,7 @@ func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) {
157 for _, tt := range tests {157 for _, tt := range tests {
158 t.Run(tt.name, func(t *testing.T) {158 t.Run(tt.name, func(t *testing.T) {
159 f := renderer.NewFrame(80, 24)159 f := renderer.NewFrame(80, 24)
160- ComposeNotices(f, []NoticeView{{Severity: uint8(tt.sev), Title: "x", Message: "y"}}, 0, styles)160+ ComposeNotices(f, []NoticeView{{Severity: tt.sev, Title: "x", Message: "y"}}, 0, styles)
161 corner := findRune(f, '┌')161 corner := findRune(f, '┌')
162 if corner == nil {162 if corner == nil {
163 t.Fatalf("no box top-left corner found:\n%s", frameText(f))163 t.Fatalf("no box top-left corner found:\n%s", frameText(f))
@@ -228,7 +228,7 @@ func TestComposeNoticesWidth(t *testing.T) {
228 for _, tt := range tests {228 for _, tt := range tests {
229 t.Run(tt.name, func(t *testing.T) {229 t.Run(tt.name, func(t *testing.T) {
230 f := renderer.NewFrame(tt.frameWidth, 10)230 f := renderer.NewFrame(tt.frameWidth, 10)
231- ComposeNotices(f, []NoticeView{{Severity: uint8(domain.NoticeInfo), Title: "x", Message: "y"}}, 0, styles)231+ ComposeNotices(f, []NoticeView{{Severity: domain.NoticeInfo, Title: "x", Message: "y"}}, 0, styles)
232 232
233 wantRight := tt.wantX + tt.wantWidth - 1233 wantRight := tt.wantX + tt.wantWidth - 1
234 rightX, rightY := findRunePos(f, '┐')234 rightX, rightY := findRunePos(f, '┐')
@@ -273,7 +273,7 @@ func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
273 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {273 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {
274 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {274 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
275 f := renderer.NewFrame(size.Cols, size.Rows)275 f := renderer.NewFrame(size.Cols, size.Rows)
276- notices := []NoticeView{{Severity: uint8(domain.NoticeError), Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}276+ notices := []NoticeView{{Severity: domain.NoticeError, Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}
277 ComposeNotices(f, notices, 5, styles)277 ComposeNotices(f, notices, 5, styles)
278 })278 })
279 }279 }

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