refactor(daemon): consolidate notification lifecycle helpers
- commit
- 23819c376aec93eeffe3803380dcd66fb5ec38cb
- author
- brice · · 2026-07-21 10:48 UTC
- parent
- dfadb21
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 27 | 27 | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 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)...) | |
| 46 | 58 | combined = append(combined, data...) | |
| 47 | 59 | data = combined | |
| 48 | - | ac.overlays.noticesPending = nil | |
| 60 | + | *state.pending = nil | |
| 49 | 61 | } | |
| 50 | - | changed := false | |
| 51 | - | exit := false | |
| 52 | - | yank := false | |
| 53 | - | var yankTarget domain.Notification | |
| 62 | + | ||
| 63 | + | var result listInputResult | |
| 54 | 64 | for i := 0; i < len(data); i++ { | |
| 55 | 65 | switch data[i] { | |
| 56 | 66 | case 'j': | |
| 57 | - | ac.overlays.noticesOverlay.Down() | |
| 58 | - | changed = true | |
| 67 | + | state.moveDown() | |
| 68 | + | result.changed = true | |
| 59 | 69 | 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 | |
| 66 | 84 | } | |
| 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 | |
| 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 | 188 | d.closeNotices(ac) | |
| 92 | 189 | } | |
| 93 | 190 | if yank { | |
| @@ -96,7 +193,7 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||
| 96 | 193 | // way copy mode's own 'y' commits and exits. | |
| 97 | 194 | d.yankNotice(sess, ac, yankTarget) | |
| 98 | 195 | } | |
| 99 | - | if exit || changed { | |
| 196 | + | if result.exit || result.changed { | |
| 100 | 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 | 253 | func (d *Daemon) closeNotices(ac *attachedClient) { | |
| 181 | 254 | ac.overlays.noticeMu.Lock() | |
| 182 | 255 | ac.overlays.noticesOverlay = nil | |
| 183 | 256 | ac.overlays.noticesPending = nil | |
| 184 | - | d.stopNoticesPendingTimerLocked(ac) | |
| 257 | + | ac.overlays.noticesESC.stop() | |
| 185 | 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 | - | } | |
| @@ -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 | - | return | 33 | + | // 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 = nil | 37 | + | moveUp func() |
| 38 | - | d.stopNoticesPendingTimerLocked(ac) | 38 | + | moveDown func() |
| 39 | - | ac.overlays.noticeMu.Unlock() | 39 | + | lock func() |
| 40 | - | return | 40 | + | 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 = combined | 59 | data = combined | ||
| 48 | - | ac.overlays.noticesPending = nil | 60 | + | *state.pending = nil |
| 49 | } | 61 | } | ||
| 50 | - | changed := false | 62 | + | |
| 51 | - | exit := false | 63 | + | 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 = true | 68 | + | result.changed = true |
| 59 | case 'k': | 69 | case 'k': | ||
| 60 | - | ac.overlays.noticesOverlay.Up() | 70 | + | state.moveUp() |
| 61 | - | changed = true | 71 | + | 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 = n | 74 | + | case keys.ESC: |
| 65 | - | yank = true | 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 | |||
| 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 - 1 | 90 | + | default: |
| 73 | - | changed = true | 91 | + | custom := action(data[i]) |
| 74 | - | continue | 92 | + | 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 | - | break | 96 | + | 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 = nil | 255 | ac.overlays.noticesOverlay = nil | ||
| 183 | ac.overlays.noticesPending = nil | 256 | 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) { | |||
| 114 | 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 | 150 | func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) { | |
| 118 | 151 | p, release := newBlockingPTY(t) | |
| 119 | 152 | 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 { | |||
| 159 | 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 | 181 | // reportError turns any error into a user-facing notice. Unclassified errors | |
| 163 | 182 | // become NoticeInternal: an error reaching here is never silently dropped | |
| 164 | 183 | // 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 errors | 181 | // reportError turns any error into a user-facing notice. Unclassified errors | ||
| 163 | // become NoticeInternal: an error reaching here is never silently dropped | 182 | // 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 ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 10 | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | 11 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -93,140 +92,87 @@ func attentionSuffix(label string) string { | |||
| 93 | 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 | 119 | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |
| 97 | 120 | sess := ac.currentSession() | |
| 98 | 121 | if sess == nil { | |
| 99 | 122 | return | |
| 100 | 123 | } | |
| 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() | |
| 106 | 130 | return | |
| 107 | 131 | } | |
| 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 { | |
| 121 | 134 | 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} | |
| 138 | 136 | 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{} | |
| 160 | 140 | } | |
| 161 | - | } | |
| 141 | + | }) | |
| 162 | 142 | var target picker.Target | |
| 163 | 143 | 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() | |
| 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 | 160 | d.registerPreviewForSelection(ac) | |
| 171 | 161 | } | |
| 172 | - | if exit { | |
| 162 | + | if result.exit { | |
| 173 | 163 | d.closePicker(ac) | |
| 174 | 164 | } | |
| 175 | - | if switchTarget && ok { | |
| 165 | + | if (result.action == '\r' || result.action == '\n') && ok { | |
| 176 | 166 | if err := d.switchToTarget(sess, ac, target); err != nil { | |
| 177 | 167 | d.reportError(sess, err) | |
| 178 | 168 | } | |
| 179 | 169 | return | |
| 180 | 170 | } | |
| 181 | - | if exit || changed { | |
| 171 | + | if result.exit || result.changed { | |
| 182 | 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 | 176 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |
| 231 | 177 | if ac == nil || ac.overlays == nil { | |
| 232 | 178 | return | |
| @@ -368,7 +314,7 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 368 | 314 | ac.overlays.pickerMu.Lock() | |
| 369 | 315 | ac.overlays.picker = nil | |
| 370 | 316 | ac.overlays.pickerPending = nil | |
| 371 | - | d.stopPickerPendingTimerLocked(ac) | |
| 317 | + | ac.overlays.pickerESC.stop() | |
| 372 | 318 | generation := ac.overlays.pickerPreviewGeneration | |
| 373 | 319 | ac.overlays.pickerMu.Unlock() | |
| 374 | 320 | 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 | return | 122 | 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 = nil | 126 | + | 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 | return | 130 | 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 = true | 137 | + | return listInputResult{action: b, exit: true} |
| 140 | - | exit = true | 138 | + | 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.Target | 142 | var target picker.Target | ||
| 163 | var ok bool | 143 | 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 | return | 169 | 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 | return | 178 | 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 = nil | 315 | ac.overlays.picker = nil | ||
| 370 | ac.overlays.pickerPending = nil | 316 | ac.overlays.pickerPending = nil | ||
| 371 | - | d.stopPickerPendingTimerLocked(ac) | 317 | + | ac.overlays.pickerESC.stop() |
| 372 | generation := ac.overlays.pickerPreviewGeneration | 318 | 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. | |||
| 313 | 313 | } | |
| 314 | 314 | views := make([]ui.NoticeView, len(overlays.notices)) | |
| 315 | 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 | 318 | return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles)) | |
| 319 | 319 | } | |
| @@ -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 { | |||
| 745 | 745 | if reason != ports.ReasonServerShutdown { | |
| 746 | 746 | return terminalSnapshotErr | |
| 747 | 747 | } | |
| 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) | |
| 761 | 749 | } | |
| 762 | 750 | } | |
| 763 | 751 | } | |
| @@ -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 terminalSnapshotErr | 746 | return terminalSnapshotErr | ||
| 747 | } | 747 | } | ||
| 748 | - | // The daemon is dying: there is no attached client left to toast, so | 748 | + | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "fmt" | |
| 7 | 6 | "io" | |
| 8 | 7 | "log/slog" | |
| 9 | 8 | "math" | |
| @@ -21,7 +20,6 @@ import ( | |||
| 21 | 20 | "github.com/bnema/vev/internal/ports" | |
| 22 | 21 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 23 | 22 | "github.com/bnema/vev/internal/usecase/picker" | |
| 24 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 25 | 23 | ) | |
| 26 | 24 | ||
| 27 | 25 | // --- test doubles ----------------------------------------------------------- | |
| @@ -974,79 +972,6 @@ func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) { | |||
| 974 | 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 | 975 | func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) { | |
| 1051 | 976 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 1052 | 977 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "sync/atomic" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/stretchr/testify/mock" | |
| @@ -9,6 +11,7 @@ import ( | |||
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/vev/internal/domain" | |
| 11 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 12 | 15 | ) | |
| 13 | 16 | ||
| 14 | 17 | // TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces | |
| @@ -43,3 +46,76 @@ func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) { | |||
| 43 | 46 | "snapshot write failures are global, never scoped to the (possibly gone) failing session") | |
| 44 | 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 | + | } | |
| @@ -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 surfaces | 17 | // 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 ( | |||
| 15 | 15 | noticeMaxLines = 3 | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 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. | |
| 21 | 19 | type NoticeView struct { | |
| 22 | - | Severity uint8 | |
| 20 | + | Severity domain.NoticeSeverity | |
| 23 | 21 | Title string | |
| 24 | 22 | Message string | |
| 25 | 23 | Count int | |
| @@ -120,8 +118,8 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st | |||
| 120 | 118 | return footprints | |
| 121 | 119 | } | |
| 122 | 120 | ||
| 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 { | |
| 125 | 123 | case domain.NoticeWarn: | |
| 126 | 124 | return styles.BoxWarn | |
| 127 | 125 | case domain.NoticeError: | |
| @@ -15,11 +15,9 @@ const ( | |||||
| 15 | noticeMaxLines = 3 | 15 | noticeMaxLines = 3 | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | - | // NoticeView is a plain rendering view of one notification. Severity mirrors | 18 | + | // 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 uint8 | 20 | + | Severity domain.NoticeSeverity |
| 23 | Title string | 21 | Title string | ||
| 24 | Message string | 22 | Message string | ||
| 25 | Count int | 23 | Count int | ||
| @@ -120,8 +118,8 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st | |||||
| 120 | return footprints | 118 | 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.BoxWarn | 124 | 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) { | |||
| 67 | 67 | { | |
| 68 | 68 | name: "single error notice draws a bordered box top-right with title and message", | |
| 69 | 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 | 72 | check: func(t *testing.T, f renderer.Frame) { | |
| 73 | 73 | found := false | |
| @@ -94,9 +94,9 @@ func TestComposeNotices(t *testing.T) { | |||
| 94 | 94 | { | |
| 95 | 95 | name: "three notices with overflow renders a +N more line", | |
| 96 | 96 | 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"}, | |
| 100 | 100 | }, | |
| 101 | 101 | overflow: 2, | |
| 102 | 102 | check: func(t *testing.T, f renderer.Frame) { | |
| @@ -157,7 +157,7 @@ func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) { | |||
| 157 | 157 | for _, tt := range tests { | |
| 158 | 158 | t.Run(tt.name, func(t *testing.T) { | |
| 159 | 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 | 161 | corner := findRune(f, '┌') | |
| 162 | 162 | if corner == nil { | |
| 163 | 163 | t.Fatalf("no box top-left corner found:\n%s", frameText(f)) | |
| @@ -228,7 +228,7 @@ func TestComposeNoticesWidth(t *testing.T) { | |||
| 228 | 228 | for _, tt := range tests { | |
| 229 | 229 | t.Run(tt.name, func(t *testing.T) { | |
| 230 | 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 | 233 | wantRight := tt.wantX + tt.wantWidth - 1 | |
| 234 | 234 | rightX, rightY := findRunePos(f, '┐') | |
| @@ -273,7 +273,7 @@ func TestComposeNoticesHandlesTinyFrames(t *testing.T) { | |||
| 273 | 273 | for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} { | |
| 274 | 274 | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | |
| 275 | 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 | 277 | ComposeNotices(f, notices, 5, styles) | |
| 278 | 278 | }) | |
| 279 | 279 | } | |
| @@ -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 := false | 73 | 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 - 1 | 233 | 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 | } | ||