Merge pull request #118 from bnema/feat/notifications
feat(notifications): add user-facing notification system
77 files · +6724 -290
A internal/adapters/noticefile/noticefile.go +365 -0
| @@ -0,0 +1,365 @@ | |||||
| 1 | + | // Package noticefile persists undeliverable daemon notices as newline- | |||
| 2 | + | // delimited JSON so they survive a daemon restart and can be shown at the | |||
| 3 | + | // next client attach. | |||
| 4 | + | package noticefile | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "bufio" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "os" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "sync" | |||
| 15 | + | "syscall" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/vev/internal/domain" | |||
| 18 | + | "github.com/bnema/vev/internal/ports" | |||
| 19 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | const ( | |||
| 23 | + | fileName = "pending-notices.jsonl" | |||
| 24 | + | inFlightFileName = "pending-notices.inflight.jsonl" | |||
| 25 | + | lockFileName = "pending-notices.lock" | |||
| 26 | + | claimLockFileName = "pending-notices.claim.lock" | |||
| 27 | + | maxNoticeRecordSize = 4 << 20 | |||
| 28 | + | noticeReadBufferSize = 64 << 10 | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // Store persists notices as JSON-lines under dir. | |||
| 32 | + | type Store struct { | |||
| 33 | + | dir string | |||
| 34 | + | ||||
| 35 | + | claimMu sync.Mutex | |||
| 36 | + | claimLock *os.File | |||
| 37 | + | ||||
| 38 | + | // afterAppendOpen is used by package tests to pause an append after it has | |||
| 39 | + | // opened the pending file while it owns the interprocess lock. It is nil in | |||
| 40 | + | // production. | |||
| 41 | + | afterAppendOpen func() | |||
| 42 | + | ||||
| 43 | + | // removeFile, syncDirectory, and beforeAckRelease make Ack cleanup and | |||
| 44 | + | // claim-release ordering deterministic in package tests. They are nil in | |||
| 45 | + | // production. | |||
| 46 | + | removeFile func(string) error | |||
| 47 | + | syncDirectory func(string) error | |||
| 48 | + | beforeAckRelease func() | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | var ( | |||
| 52 | + | // ErrClaimInProgress means another live Store owns the in-flight claim. | |||
| 53 | + | // Callers must leave it for that owner rather than replaying or acknowledging | |||
| 54 | + | // its notices. | |||
| 55 | + | ErrClaimInProgress = errors.New("pending notice claim in progress") | |||
| 56 | + | // ErrNoClaimOwner means Ack was called by a Store that did not make the | |||
| 57 | + | // current claim. | |||
| 58 | + | ErrNoClaimOwner = errors.New("pending notice claim is not owned by this store") | |||
| 59 | + | ) | |||
| 60 | + | ||||
| 61 | + | var _ ports.NoticeStore = (*Store)(nil) | |||
| 62 | + | ||||
| 63 | + | // New creates a file-backed notice store rooted at dir. | |||
| 64 | + | func New(dir string) *Store { | |||
| 65 | + | return &Store{dir: dir} | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (s *Store) path() string { | |||
| 69 | + | return filepath.Join(s.dir, fileName) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (s *Store) inFlightPath() string { | |||
| 73 | + | return filepath.Join(s.dir, inFlightFileName) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (s *Store) lockPath() string { | |||
| 77 | + | return filepath.Join(s.dir, lockFileName) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (s *Store) claimLockPath() string { | |||
| 81 | + | return filepath.Join(s.dir, claimLockFileName) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // withLock serializes Append, Claim, and Ack across Store instances and | |||
| 85 | + | // processes. Holding this lock while Claim rotates and reads the file prevents | |||
| 86 | + | // an appender with an already-open file descriptor from writing into a claim | |||
| 87 | + | // after its reader reached EOF. | |||
| 88 | + | func (s *Store) withLock(fn func() error) error { | |||
| 89 | + | if err := safedir.EnsurePrivate(s.dir); err != nil { | |||
| 90 | + | return fmt.Errorf("create notice dir: %w", err) | |||
| 91 | + | } | |||
| 92 | + | f, err := os.OpenFile(s.lockPath(), os.O_CREATE|os.O_RDWR, 0o600) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return fmt.Errorf("open notice lock: %w", err) | |||
| 95 | + | } | |||
| 96 | + | defer func() { _ = f.Close() }() | |||
| 97 | + | if err := f.Chmod(0o600); err != nil { | |||
| 98 | + | return fmt.Errorf("secure notice lock: %w", err) | |||
| 99 | + | } | |||
| 100 | + | if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { | |||
| 101 | + | return fmt.Errorf("lock pending notices: %w", err) | |||
| 102 | + | } | |||
| 103 | + | defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() | |||
| 104 | + | return fn() | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // acquireClaimLock reserves the current in-flight file for this Store until | |||
| 108 | + | // Ack. The flock is released by the kernel if this process exits, allowing a | |||
| 109 | + | // later daemon to recover an abandoned claim. | |||
| 110 | + | func (s *Store) acquireClaimLock() error { | |||
| 111 | + | s.claimMu.Lock() | |||
| 112 | + | defer s.claimMu.Unlock() | |||
| 113 | + | if s.claimLock != nil { | |||
| 114 | + | return ErrClaimInProgress | |||
| 115 | + | } | |||
| 116 | + | f, err := os.OpenFile(s.claimLockPath(), os.O_CREATE|os.O_RDWR, 0o600) | |||
| 117 | + | if err != nil { | |||
| 118 | + | return fmt.Errorf("open notice claim lock: %w", err) | |||
| 119 | + | } | |||
| 120 | + | if err := f.Chmod(0o600); err != nil { | |||
| 121 | + | _ = f.Close() | |||
| 122 | + | return fmt.Errorf("secure notice claim lock: %w", err) | |||
| 123 | + | } | |||
| 124 | + | if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |||
| 125 | + | _ = f.Close() | |||
| 126 | + | if errors.Is(err, syscall.EWOULDBLOCK) { | |||
| 127 | + | return ErrClaimInProgress | |||
| 128 | + | } | |||
| 129 | + | return fmt.Errorf("lock pending notice claim: %w", err) | |||
| 130 | + | } | |||
| 131 | + | s.claimLock = f | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (s *Store) releaseClaimLock() { | |||
| 136 | + | s.claimMu.Lock() | |||
| 137 | + | f := s.claimLock | |||
| 138 | + | s.claimLock = nil | |||
| 139 | + | s.claimMu.Unlock() | |||
| 140 | + | if f == nil { | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | |||
| 144 | + | _ = f.Close() | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (s *Store) ownsClaim() bool { | |||
| 148 | + | s.claimMu.Lock() | |||
| 149 | + | defer s.claimMu.Unlock() | |||
| 150 | + | return s.claimLock != nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func syncDir(dir string) error { | |||
| 154 | + | f, err := os.Open(dir) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | defer func() { _ = f.Close() }() | |||
| 159 | + | return f.Sync() | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // Append writes n as one JSON line, creating the private state dir if needed. | |||
| 163 | + | // A successful append is synced before it is made available to a later claim. | |||
| 164 | + | func (s *Store) Append(n domain.Notification) error { | |||
| 165 | + | data, err := json.Marshal(n) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return fmt.Errorf("marshal notice: %w", err) | |||
| 168 | + | } | |||
| 169 | + | data = append(data, '\n') | |||
| 170 | + | ||||
| 171 | + | return s.withLock(func() error { | |||
| 172 | + | f, err := os.OpenFile(s.path(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) | |||
| 173 | + | if err != nil { | |||
| 174 | + | return fmt.Errorf("open pending notices: %w", err) | |||
| 175 | + | } | |||
| 176 | + | if err := f.Chmod(0o600); err != nil { | |||
| 177 | + | _ = f.Close() | |||
| 178 | + | return fmt.Errorf("secure pending notices: %w", err) | |||
| 179 | + | } | |||
| 180 | + | if s.afterAppendOpen != nil { | |||
| 181 | + | s.afterAppendOpen() | |||
| 182 | + | } | |||
| 183 | + | if _, err := f.Write(data); err != nil { | |||
| 184 | + | _ = f.Close() | |||
| 185 | + | return fmt.Errorf("write notice: %w", err) | |||
| 186 | + | } | |||
| 187 | + | if err := f.Sync(); err != nil { | |||
| 188 | + | _ = f.Close() | |||
| 189 | + | return fmt.Errorf("sync pending notices: %w", err) | |||
| 190 | + | } | |||
| 191 | + | if err := f.Close(); err != nil { | |||
| 192 | + | return fmt.Errorf("close pending notices: %w", err) | |||
| 193 | + | } | |||
| 194 | + | if err := syncDir(s.dir); err != nil { | |||
| 195 | + | return fmt.Errorf("sync notice dir: %w", err) | |||
| 196 | + | } | |||
| 197 | + | return nil | |||
| 198 | + | }) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // Claim atomically moves pending notices into an in-flight file and returns | |||
| 202 | + | // their valid JSONL entries in order. Repeating Claim on its owning Store | |||
| 203 | + | // replays that Store's existing claim; a concurrent Store cannot replay or | |||
| 204 | + | // acknowledge it. The ownership flock is released on process exit, allowing | |||
| 205 | + | // an unacknowledged import to be replayed after a crash. A missing pending | |||
| 206 | + | // file means there is nothing to claim. | |||
| 207 | + | func (s *Store) Claim() ([]domain.Notification, error) { | |||
| 208 | + | var notices []domain.Notification | |||
| 209 | + | err := s.withLock(func() error { | |||
| 210 | + | if s.ownsClaim() { | |||
| 211 | + | var err error | |||
| 212 | + | notices, err = readNotices(s.inFlightPath()) | |||
| 213 | + | return err | |||
| 214 | + | } | |||
| 215 | + | if err := s.acquireClaimLock(); err != nil { | |||
| 216 | + | return err | |||
| 217 | + | } | |||
| 218 | + | keepClaim := false | |||
| 219 | + | defer func() { | |||
| 220 | + | if !keepClaim { | |||
| 221 | + | s.releaseClaimLock() | |||
| 222 | + | } | |||
| 223 | + | }() | |||
| 224 | + | ||||
| 225 | + | inFlight := s.inFlightPath() | |||
| 226 | + | if _, err := os.Stat(inFlight); err != nil { | |||
| 227 | + | if !errors.Is(err, os.ErrNotExist) { | |||
| 228 | + | return fmt.Errorf("stat in-flight notices: %w", err) | |||
| 229 | + | } | |||
| 230 | + | if err := os.Rename(s.path(), inFlight); err != nil { | |||
| 231 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 234 | + | return fmt.Errorf("claim pending notices: %w", err) | |||
| 235 | + | } | |||
| 236 | + | // fsync the parent directory so the atomic claim is recoverable after | |||
| 237 | + | // power loss, not merely after a clean process restart. | |||
| 238 | + | if err := syncDir(s.dir); err != nil { | |||
| 239 | + | return fmt.Errorf("sync claimed notices: %w", err) | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | var err error | |||
| 244 | + | notices, err = readNotices(inFlight) | |||
| 245 | + | if err == nil { | |||
| 246 | + | keepClaim = true | |||
| 247 | + | } | |||
| 248 | + | return err | |||
| 249 | + | }) | |||
| 250 | + | if err != nil { | |||
| 251 | + | return nil, err | |||
| 252 | + | } | |||
| 253 | + | return notices, nil | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func readNotices(path string) ([]domain.Notification, error) { | |||
| 257 | + | f, err := os.Open(path) | |||
| 258 | + | if err != nil { | |||
| 259 | + | return nil, fmt.Errorf("open claimed notices: %w", err) | |||
| 260 | + | } | |||
| 261 | + | defer func() { _ = f.Close() }() | |||
| 262 | + | ||||
| 263 | + | var notices []domain.Notification | |||
| 264 | + | reader := bufio.NewReaderSize(f, noticeReadBufferSize) | |||
| 265 | + | for { | |||
| 266 | + | line, oversized, eof, err := readNoticeRecord(reader) | |||
| 267 | + | if err != nil { | |||
| 268 | + | return nil, fmt.Errorf("read claimed notices: %w", err) | |||
| 269 | + | } | |||
| 270 | + | if len(line) > 0 && !oversized { | |||
| 271 | + | var n domain.Notification | |||
| 272 | + | if err := json.Unmarshal(line, &n); err == nil { | |||
| 273 | + | notices = append(notices, n) | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | if eof { | |||
| 277 | + | return notices, nil | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // readNoticeRecord reads one newline-delimited record without retaining more | |||
| 283 | + | // than maxNoticeRecordSize bytes. Oversized records are discarded through | |||
| 284 | + | // their delimiter so a later valid record can still be read. | |||
| 285 | + | func readNoticeRecord(reader *bufio.Reader) (line []byte, oversized, eof bool, err error) { | |||
| 286 | + | for { | |||
| 287 | + | fragment, readErr := reader.ReadSlice('\n') | |||
| 288 | + | if len(fragment) > 0 && fragment[len(fragment)-1] == '\n' { | |||
| 289 | + | fragment = fragment[:len(fragment)-1] | |||
| 290 | + | } | |||
| 291 | + | if !oversized { | |||
| 292 | + | line, oversized = appendNoticeFragment(line, fragment) | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | switch { | |||
| 296 | + | case readErr == nil: | |||
| 297 | + | return line, oversized, false, nil | |||
| 298 | + | case errors.Is(readErr, bufio.ErrBufferFull): | |||
| 299 | + | continue | |||
| 300 | + | case errors.Is(readErr, io.EOF): | |||
| 301 | + | return line, oversized, true, nil | |||
| 302 | + | default: | |||
| 303 | + | return nil, false, false, readErr | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func appendNoticeFragment(line, fragment []byte) ([]byte, bool) { | |||
| 309 | + | if len(fragment) > maxNoticeRecordSize-len(line) { | |||
| 310 | + | return nil, true | |||
| 311 | + | } | |||
| 312 | + | required := len(line) + len(fragment) | |||
| 313 | + | if required > cap(line) { | |||
| 314 | + | capacity := cap(line) * 2 | |||
| 315 | + | if capacity < noticeReadBufferSize { | |||
| 316 | + | capacity = noticeReadBufferSize | |||
| 317 | + | } | |||
| 318 | + | if capacity > maxNoticeRecordSize { | |||
| 319 | + | capacity = maxNoticeRecordSize | |||
| 320 | + | } | |||
| 321 | + | grown := make([]byte, len(line), capacity) | |||
| 322 | + | copy(grown, line) | |||
| 323 | + | line = grown | |||
| 324 | + | } | |||
| 325 | + | return append(line, fragment...), false | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | // Ack permanently removes this Store's in-flight claim. It is only called once | |||
| 329 | + | // the daemon has recorded and queued every claimed notice. | |||
| 330 | + | func (s *Store) Ack() error { | |||
| 331 | + | if !s.ownsClaim() { | |||
| 332 | + | return ErrNoClaimOwner | |||
| 333 | + | } | |||
| 334 | + | return s.withLock(func() error { | |||
| 335 | + | if !s.ownsClaim() { | |||
| 336 | + | return ErrNoClaimOwner | |||
| 337 | + | } | |||
| 338 | + | // Ack is terminal for this Store's claim even when removing or syncing | |||
| 339 | + | // the record fails. Release while the operation lock is still held so a | |||
| 340 | + | // concurrent Claim on this Store cannot see a removed in-flight file | |||
| 341 | + | // while it still appears to own the claim. | |||
| 342 | + | defer func() { | |||
| 343 | + | if s.beforeAckRelease != nil { | |||
| 344 | + | s.beforeAckRelease() | |||
| 345 | + | } | |||
| 346 | + | s.releaseClaimLock() | |||
| 347 | + | }() | |||
| 348 | + | ||||
| 349 | + | remove := os.Remove | |||
| 350 | + | if s.removeFile != nil { | |||
| 351 | + | remove = s.removeFile | |||
| 352 | + | } | |||
| 353 | + | if err := remove(s.inFlightPath()); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 354 | + | return fmt.Errorf("ack claimed notices: %w", err) | |||
| 355 | + | } | |||
| 356 | + | sync := syncDir | |||
| 357 | + | if s.syncDirectory != nil { | |||
| 358 | + | sync = s.syncDirectory | |||
| 359 | + | } | |||
| 360 | + | if err := sync(s.dir); err != nil { | |||
| 361 | + | return fmt.Errorf("sync acknowledged notices: %w", err) | |||
| 362 | + | } | |||
| 363 | + | return nil | |||
| 364 | + | }) | |||
| 365 | + | } | |||
A internal/adapters/noticefile/noticefile_test.go +349 -0
| @@ -0,0 +1,349 @@ | |||||
| 1 | + | package noticefile | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "syscall" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestStoreClaimAckRoundTrip(t *testing.T) { | |||
| 19 | + | t.Parallel() | |||
| 20 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 21 | + | store := New(dir) | |||
| 22 | + | ||||
| 23 | + | first := domain.Notification{ | |||
| 24 | + | Code: domain.NoticeSnapshotWrite, | |||
| 25 | + | Severity: domain.NoticeError, | |||
| 26 | + | Message: "session foo shut down without saving terminal state", | |||
| 27 | + | Details: "boom: disk full", | |||
| 28 | + | Time: time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC), | |||
| 29 | + | Count: 1, | |||
| 30 | + | SessionID: "sess-1", | |||
| 31 | + | } | |||
| 32 | + | second := domain.Notification{ | |||
| 33 | + | Code: domain.NoticeSnapshotRestore, | |||
| 34 | + | Severity: domain.NoticeWarn, | |||
| 35 | + | Message: "session bar could not be restored", | |||
| 36 | + | Time: time.Date(2026, 7, 20, 10, 5, 0, 0, time.UTC), | |||
| 37 | + | Count: 2, | |||
| 38 | + | } | |||
| 39 | + | require.NoError(t, store.Append(first)) | |||
| 40 | + | require.NoError(t, store.Append(second)) | |||
| 41 | + | ||||
| 42 | + | claimed, err := store.Claim() | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | require.Equal(t, []domain.Notification{first, second}, claimed) | |||
| 45 | + | ||||
| 46 | + | // Releasing the claim lock simulates the kernel closing it when the daemon | |||
| 47 | + | // crashes. The in-flight file remains and a fresh process/store replays it. | |||
| 48 | + | store.releaseClaimLock() | |||
| 49 | + | recovered := New(dir) | |||
| 50 | + | replayed, err := recovered.Claim() | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | require.Equal(t, claimed, replayed) | |||
| 53 | + | ||||
| 54 | + | require.NoError(t, recovered.Ack()) | |||
| 55 | + | again, err := New(dir).Claim() | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | require.Empty(t, again) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestStoreClaimWaitsForAppendInCriticalSection(t *testing.T) { | |||
| 61 | + | t.Parallel() | |||
| 62 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 63 | + | store := New(dir) | |||
| 64 | + | first := domain.Notification{Message: "before claim"} | |||
| 65 | + | second := domain.Notification{Message: "append holds pending file open"} | |||
| 66 | + | require.NoError(t, store.Append(first)) | |||
| 67 | + | ||||
| 68 | + | appendEntered := make(chan struct{}) | |||
| 69 | + | allowAppend := make(chan struct{}) | |||
| 70 | + | store.afterAppendOpen = func() { | |||
| 71 | + | close(appendEntered) | |||
| 72 | + | <-allowAppend | |||
| 73 | + | } | |||
| 74 | + | appendDone := make(chan error, 1) | |||
| 75 | + | go func() { appendDone <- store.Append(second) }() | |||
| 76 | + | <-appendEntered // Append owns the operation lock and an open pending file. | |||
| 77 | + | ||||
| 78 | + | claimant := New(dir) | |||
| 79 | + | claimDone := make(chan []domain.Notification, 1) | |||
| 80 | + | errDone := make(chan error, 1) | |||
| 81 | + | go func() { | |||
| 82 | + | claimed, err := claimant.Claim() | |||
| 83 | + | claimDone <- claimed | |||
| 84 | + | errDone <- err | |||
| 85 | + | }() | |||
| 86 | + | close(allowAppend) | |||
| 87 | + | require.NoError(t, <-appendDone) | |||
| 88 | + | require.NoError(t, <-errDone) | |||
| 89 | + | require.Equal(t, []domain.Notification{first, second}, <-claimDone) | |||
| 90 | + | require.NoError(t, claimant.Ack()) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestStoreRepeatedClaimReplaysOwnerAndBlocksCompetitor(t *testing.T) { | |||
| 94 | + | t.Parallel() | |||
| 95 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 96 | + | owner := New(dir) | |||
| 97 | + | want := domain.Notification{Message: "owned"} | |||
| 98 | + | require.NoError(t, owner.Append(want)) | |||
| 99 | + | ||||
| 100 | + | first, err := owner.Claim() | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | require.Equal(t, []domain.Notification{want}, first) | |||
| 103 | + | ||||
| 104 | + | replayed, err := owner.Claim() | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | require.Equal(t, first, replayed) | |||
| 107 | + | ||||
| 108 | + | other := New(dir) | |||
| 109 | + | blocked, err := other.Claim() | |||
| 110 | + | require.ErrorIs(t, err, ErrClaimInProgress) | |||
| 111 | + | require.Nil(t, blocked) | |||
| 112 | + | ||||
| 113 | + | require.NoError(t, owner.Ack()) | |||
| 114 | + | empty, err := other.Claim() | |||
| 115 | + | require.NoError(t, err) | |||
| 116 | + | require.Empty(t, empty) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestStoreAckReleasesClaimAfterCleanupFailure(t *testing.T) { | |||
| 120 | + | t.Parallel() | |||
| 121 | + | ||||
| 122 | + | for _, tt := range []struct { | |||
| 123 | + | name string | |||
| 124 | + | inject func(*Store, error) | |||
| 125 | + | want []domain.Notification | |||
| 126 | + | }{ | |||
| 127 | + | { | |||
| 128 | + | name: "remove", | |||
| 129 | + | inject: func(store *Store, cause error) { | |||
| 130 | + | store.removeFile = func(string) error { return cause } | |||
| 131 | + | }, | |||
| 132 | + | want: []domain.Notification{{Message: "owned"}}, | |||
| 133 | + | }, | |||
| 134 | + | { | |||
| 135 | + | name: "sync directory", | |||
| 136 | + | inject: func(store *Store, cause error) { | |||
| 137 | + | store.syncDirectory = func(string) error { return cause } | |||
| 138 | + | }, | |||
| 139 | + | want: nil, | |||
| 140 | + | }, | |||
| 141 | + | } { | |||
| 142 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 143 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 144 | + | owner := New(dir) | |||
| 145 | + | require.NoError(t, owner.Append(domain.Notification{Message: "owned"})) | |||
| 146 | + | _, err := owner.Claim() | |||
| 147 | + | require.NoError(t, err) | |||
| 148 | + | ||||
| 149 | + | cause := errors.New("cleanup failed") | |||
| 150 | + | tt.inject(owner, cause) | |||
| 151 | + | require.ErrorIs(t, owner.Ack(), cause) | |||
| 152 | + | ||||
| 153 | + | // A new Store proves the failed Ack released the ownership flock. | |||
| 154 | + | recovered, err := New(dir).Claim() | |||
| 155 | + | require.NoError(t, err) | |||
| 156 | + | require.Equal(t, tt.want, recovered) | |||
| 157 | + | }) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestStoreAckSerializesClaimReleaseWithSameStoreReclaim(t *testing.T) { | |||
| 162 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 163 | + | store := New(dir) | |||
| 164 | + | require.NoError(t, store.Append(domain.Notification{Message: "owned"})) | |||
| 165 | + | _, err := store.Claim() | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | ||||
| 168 | + | releaseEntered := make(chan struct{}) | |||
| 169 | + | allowRelease := make(chan struct{}) | |||
| 170 | + | lockHeld := make(chan error, 1) | |||
| 171 | + | store.beforeAckRelease = func() { | |||
| 172 | + | held, err := operationLockHeld(store.lockPath()) | |||
| 173 | + | if err != nil { | |||
| 174 | + | lockHeld <- err | |||
| 175 | + | } else if !held { | |||
| 176 | + | lockHeld <- errors.New("Ack released the operation lock before claim ownership") | |||
| 177 | + | } else { | |||
| 178 | + | lockHeld <- nil | |||
| 179 | + | } | |||
| 180 | + | close(releaseEntered) | |||
| 181 | + | <-allowRelease | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | ackDone := make(chan error, 1) | |||
| 185 | + | go func() { ackDone <- store.Ack() }() | |||
| 186 | + | <-releaseEntered // The in-flight file is gone, but Ack still owns the operation lock. | |||
| 187 | + | require.NoError(t, <-lockHeld) | |||
| 188 | + | ||||
| 189 | + | claimStarted := make(chan struct{}) | |||
| 190 | + | claimDone := make(chan struct { | |||
| 191 | + | notices []domain.Notification | |||
| 192 | + | err error | |||
| 193 | + | }, 1) | |||
| 194 | + | go func() { | |||
| 195 | + | close(claimStarted) | |||
| 196 | + | notices, err := store.Claim() | |||
| 197 | + | claimDone <- struct { | |||
| 198 | + | notices []domain.Notification | |||
| 199 | + | err error | |||
| 200 | + | }{notices, err} | |||
| 201 | + | }() | |||
| 202 | + | <-claimStarted | |||
| 203 | + | close(allowRelease) | |||
| 204 | + | ||||
| 205 | + | require.NoError(t, <-ackDone) | |||
| 206 | + | reclaimed := <-claimDone | |||
| 207 | + | require.NoError(t, reclaimed.err) | |||
| 208 | + | require.Empty(t, reclaimed.notices) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func operationLockHeld(path string) (bool, error) { | |||
| 212 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) | |||
| 213 | + | if err != nil { | |||
| 214 | + | return false, err | |||
| 215 | + | } | |||
| 216 | + | defer func() { _ = f.Close() }() | |||
| 217 | + | if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |||
| 218 | + | if errors.Is(err, syscall.EWOULDBLOCK) { | |||
| 219 | + | return true, nil | |||
| 220 | + | } | |||
| 221 | + | return false, err | |||
| 222 | + | } | |||
| 223 | + | defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() | |||
| 224 | + | return false, nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func TestStoreClaimOwnershipPreventsLiveReplayOrAck(t *testing.T) { | |||
| 228 | + | t.Parallel() | |||
| 229 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 230 | + | owner := New(dir) | |||
| 231 | + | require.NoError(t, owner.Append(domain.Notification{Message: "owned"})) | |||
| 232 | + | claimed, err := owner.Claim() | |||
| 233 | + | require.NoError(t, err) | |||
| 234 | + | require.Len(t, claimed, 1) | |||
| 235 | + | ||||
| 236 | + | other := New(dir) | |||
| 237 | + | replayed, err := other.Claim() | |||
| 238 | + | require.ErrorIs(t, err, ErrClaimInProgress) | |||
| 239 | + | require.Nil(t, replayed) | |||
| 240 | + | require.ErrorIs(t, other.Ack(), ErrNoClaimOwner) | |||
| 241 | + | ||||
| 242 | + | // The rejected Ack must leave the owner's in-flight record intact. | |||
| 243 | + | require.NoError(t, owner.Ack()) | |||
| 244 | + | empty, err := New(dir).Claim() | |||
| 245 | + | require.NoError(t, err) | |||
| 246 | + | require.Empty(t, empty) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestStoreClaimMissingFileReturnsNil(t *testing.T) { | |||
| 250 | + | t.Parallel() | |||
| 251 | + | ||||
| 252 | + | got, err := New(filepath.Join(t.TempDir(), "vev")).Claim() | |||
| 253 | + | require.NoError(t, err) | |||
| 254 | + | require.Nil(t, got) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func TestStoreClaimSkipsGarbageLinesInOrder(t *testing.T) { | |||
| 258 | + | t.Parallel() | |||
| 259 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 260 | + | store := New(dir) | |||
| 261 | + | path := filepath.Join(dir, fileName) | |||
| 262 | + | require.NoError(t, os.MkdirAll(dir, 0o700)) | |||
| 263 | + | ||||
| 264 | + | good := `{"Code":9,"Severity":2,"Message":"first"}` + "\n" | |||
| 265 | + | garbage := "not json at all\n" | |||
| 266 | + | good2 := `{"Code":10,"Severity":1,"Message":"second"}` + "\n" | |||
| 267 | + | require.NoError(t, os.WriteFile(path, []byte(good+garbage+good2), 0o600)) | |||
| 268 | + | ||||
| 269 | + | got, err := store.Claim() | |||
| 270 | + | require.NoError(t, err) | |||
| 271 | + | require.Len(t, got, 2) | |||
| 272 | + | require.Equal(t, "first", got[0].Message) | |||
| 273 | + | require.Equal(t, "second", got[1].Message) | |||
| 274 | + | require.NoError(t, store.Ack()) | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func TestStoreClaimSkipsOversizedLines(t *testing.T) { | |||
| 278 | + | t.Parallel() | |||
| 279 | + | ||||
| 280 | + | validLine := func(message string) string { | |||
| 281 | + | t.Helper() | |||
| 282 | + | data, err := json.Marshal(domain.Notification{Message: message}) | |||
| 283 | + | require.NoError(t, err) | |||
| 284 | + | return string(data) | |||
| 285 | + | } | |||
| 286 | + | // Derive JSON overhead from the encoder rather than duplicating its current | |||
| 287 | + | // field layout here. The boundary payload uses ASCII so each added message | |||
| 288 | + | // byte remains one marshaled byte. | |||
| 289 | + | boundaryMessage := strings.Repeat("x", maxNoticeRecordSize-len(validLine(""))) | |||
| 290 | + | ||||
| 291 | + | for _, tt := range []struct { | |||
| 292 | + | name string | |||
| 293 | + | lines []string | |||
| 294 | + | want []string | |||
| 295 | + | }{ | |||
| 296 | + | { | |||
| 297 | + | name: "recovers after oversized line", | |||
| 298 | + | lines: []string{validLine("first"), validLine(strings.Repeat("x", len(boundaryMessage)+1)), validLine("second")}, | |||
| 299 | + | want: []string{"first", "second"}, | |||
| 300 | + | }, | |||
| 301 | + | { | |||
| 302 | + | name: "skips oversized final unterminated line", | |||
| 303 | + | lines: []string{validLine("first"), validLine(strings.Repeat("x", len(boundaryMessage)+1))}, | |||
| 304 | + | want: []string{"first"}, | |||
| 305 | + | }, | |||
| 306 | + | { | |||
| 307 | + | name: "accepts record at size limit", | |||
| 308 | + | lines: []string{validLine(boundaryMessage)}, | |||
| 309 | + | want: []string{boundaryMessage}, | |||
| 310 | + | }, | |||
| 311 | + | } { | |||
| 312 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 313 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 314 | + | records := strings.Join(tt.lines, "\n") | |||
| 315 | + | if tt.name != "skips oversized final unterminated line" { | |||
| 316 | + | records += "\n" | |||
| 317 | + | } | |||
| 318 | + | require.NoError(t, os.MkdirAll(dir, 0o700)) | |||
| 319 | + | require.NoError(t, os.WriteFile(filepath.Join(dir, fileName), []byte(records), 0o600)) | |||
| 320 | + | ||||
| 321 | + | got, err := New(dir).Claim() | |||
| 322 | + | require.NoError(t, err) | |||
| 323 | + | require.Len(t, got, len(tt.want)) | |||
| 324 | + | for i, want := range tt.want { | |||
| 325 | + | require.Equal(t, want, got[i].Message) | |||
| 326 | + | } | |||
| 327 | + | }) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func TestStoreAppendUsesPrivatePermissions(t *testing.T) { | |||
| 332 | + | t.Parallel() | |||
| 333 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 334 | + | store := New(dir) | |||
| 335 | + | ||||
| 336 | + | require.NoError(t, store.Append(domain.Notification{Message: "x"})) | |||
| 337 | + | info, err := os.Stat(dir) | |||
| 338 | + | require.NoError(t, err) | |||
| 339 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |||
| 340 | + | fi, err := os.Stat(filepath.Join(dir, fileName)) | |||
| 341 | + | require.NoError(t, err) | |||
| 342 | + | require.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | |||
| 343 | + | ||||
| 344 | + | _, err = store.Claim() | |||
| 345 | + | require.NoError(t, err) | |||
| 346 | + | fi, err = os.Stat(filepath.Join(dir, inFlightFileName)) | |||
| 347 | + | require.NoError(t, err) | |||
| 348 | + | require.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | |||
| 349 | + | } | |||
M internal/app/run.go +8 -0
| @@ -32,6 +32,7 @@ import ( | |||||
| 32 | "github.com/bnema/vev/internal/adapters/config" | 32 | "github.com/bnema/vev/internal/adapters/config" | ||
| 33 | "github.com/bnema/vev/internal/adapters/dgram" | 33 | "github.com/bnema/vev/internal/adapters/dgram" | ||
| 34 | "github.com/bnema/vev/internal/adapters/ipc" | 34 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 35 | + | "github.com/bnema/vev/internal/adapters/noticefile" | |||
| 35 | "github.com/bnema/vev/internal/adapters/observability" | 36 | "github.com/bnema/vev/internal/adapters/observability" | ||
| 36 | "github.com/bnema/vev/internal/adapters/pty" | 37 | "github.com/bnema/vev/internal/adapters/pty" | ||
| 37 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | 38 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | ||
| @@ -450,14 +451,21 @@ func runDaemon() (retErr error) { | |||||
| 450 | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | 451 | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | ||
| 451 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | 452 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | ||
| 452 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | 453 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | ||
| 454 | + | daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir()))) | |||
| 453 | storePath := persist.StorePath(platform.StateDir()) | 455 | storePath := persist.StorePath(platform.StateDir()) | ||
| 456 | + | var storeErr error | |||
| 454 | if store, err := kv.Open(storePath); err != nil { | 457 | if store, err := kv.Open(storePath); err != nil { | ||
| 455 | log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err) | 458 | log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err) | ||
| 459 | + | storeErr = err | |||
| 456 | } else { | 460 | } else { | ||
| 457 | log.Info("session persistence enabled", "path", storePath) | 461 | log.Info("session persistence enabled", "path", storePath) | ||
| 458 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | 462 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | ||
| 459 | } | 463 | } | ||
| 460 | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | 464 | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | ||
| 465 | + | if storeErr != nil { | |||
| 466 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled, | |||
| 467 | + | "session persistence is disabled; sessions will not survive daemon restarts", storeErr) | |||
| 468 | + | } | |||
| 461 | watchCtx, stopWatch := context.WithCancel(ctx) | 469 | watchCtx, stopWatch := context.WithCancel(ctx) | ||
| 462 | defer stopWatch() | 470 | defer stopWatch() | ||
| 463 | go func() { | 471 | go func() { | ||
A internal/domain/notice.go +117 -0
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // NoticeSeverity ranks a user-facing notification. | |||
| 6 | + | // | |||
| 7 | + | // Its numeric values persist in JSON notice records and are a protocol/domain | |||
| 8 | + | // contract. Add values only at the end; never reorder existing values. | |||
| 9 | + | type NoticeSeverity uint8 | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | NoticeInfo NoticeSeverity = iota | |||
| 13 | + | NoticeWarn | |||
| 14 | + | NoticeError | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // NoticeCode identifies a failure meaning, not a call site. | |||
| 18 | + | // | |||
| 19 | + | // Its numeric values persist in JSON notice records and are a protocol/domain | |||
| 20 | + | // contract. Add values only at the end; never reorder existing values. | |||
| 21 | + | type NoticeCode uint16 | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | NoticeInternal NoticeCode = iota | |||
| 25 | + | NoticePaneSpawn | |||
| 26 | + | NoticeTabSpawn | |||
| 27 | + | NoticeFloatingSpawn | |||
| 28 | + | NoticeSessionSpawn | |||
| 29 | + | NoticeLayoutTooSmall | |||
| 30 | + | NoticePaneNotFound | |||
| 31 | + | NoticeSessionUnavailable | |||
| 32 | + | NoticePersistDisabled | |||
| 33 | + | NoticeSnapshotWrite | |||
| 34 | + | NoticeSnapshotRestore | |||
| 35 | + | NoticeSnapshotSaturated | |||
| 36 | + | NoticePersistDelete | |||
| 37 | + | NoticeConfigReload | |||
| 38 | + | NoticeInputDropped | |||
| 39 | + | NoticeResizeFailed | |||
| 40 | + | NoticeClipboard | |||
| 41 | + | NoticeClipboardTooLarge | |||
| 42 | + | NoticeAutoResume | |||
| 43 | + | NoticeConnection | |||
| 44 | + | ||||
| 45 | + | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | |||
| 46 | + | // valid notice code. Keep it last when adding a NoticeCode. | |||
| 47 | + | noticeCodeLimit | |||
| 48 | + | ) | |||
| 49 | + | ||||
| 50 | + | var noticeSlugs = map[NoticeCode]string{ | |||
| 51 | + | NoticeInternal: "internal", | |||
| 52 | + | NoticePaneSpawn: "pane-spawn", | |||
| 53 | + | NoticeTabSpawn: "tab-spawn", | |||
| 54 | + | NoticeFloatingSpawn: "floating-spawn", | |||
| 55 | + | NoticeSessionSpawn: "session-spawn", | |||
| 56 | + | NoticeLayoutTooSmall: "layout-too-small", | |||
| 57 | + | NoticePaneNotFound: "pane-not-found", | |||
| 58 | + | NoticeSessionUnavailable: "session-unavailable", | |||
| 59 | + | NoticePersistDisabled: "persist-disabled", | |||
| 60 | + | NoticeSnapshotWrite: "snapshot-write", | |||
| 61 | + | NoticeSnapshotRestore: "snapshot-restore", | |||
| 62 | + | NoticeSnapshotSaturated: "snapshot-saturated", | |||
| 63 | + | NoticePersistDelete: "persist-delete", | |||
| 64 | + | NoticeConfigReload: "config-reload", | |||
| 65 | + | NoticeInputDropped: "input-dropped", | |||
| 66 | + | NoticeResizeFailed: "resize-failed", | |||
| 67 | + | NoticeClipboard: "clipboard", | |||
| 68 | + | NoticeClipboardTooLarge: "clipboard-too-large", | |||
| 69 | + | NoticeAutoResume: "auto-resume", | |||
| 70 | + | NoticeConnection: "connection", | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (c NoticeCode) String() string { | |||
| 74 | + | if s, ok := noticeSlugs[c]; ok { | |||
| 75 | + | return s | |||
| 76 | + | } | |||
| 77 | + | return "unknown" | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // UserError is an error whose message is written for the attached user. | |||
| 81 | + | // The wrapped cause is shown only in yank details, never in the toast. | |||
| 82 | + | type UserError struct { | |||
| 83 | + | Code NoticeCode | |||
| 84 | + | Severity NoticeSeverity | |||
| 85 | + | Msg string | |||
| 86 | + | Err error | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (e *UserError) Error() string { | |||
| 90 | + | if e.Err == nil { | |||
| 91 | + | return e.Msg | |||
| 92 | + | } | |||
| 93 | + | return e.Msg + ": " + e.Err.Error() | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (e *UserError) Unwrap() error { return e.Err } | |||
| 97 | + | ||||
| 98 | + | // UserErr wraps err as an error-severity user-facing failure. | |||
| 99 | + | func UserErr(code NoticeCode, msg string, err error) *UserError { | |||
| 100 | + | return &UserError{Code: code, Severity: NoticeError, Msg: msg, Err: err} | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // UserWarn wraps err as a warn-severity user-facing failure. | |||
| 104 | + | func UserWarn(code NoticeCode, msg string, err error) *UserError { | |||
| 105 | + | return &UserError{Code: code, Severity: NoticeWarn, Msg: msg, Err: err} | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Notification is one entry in the daemon's user-facing notice history. | |||
| 109 | + | type Notification struct { | |||
| 110 | + | Code NoticeCode | |||
| 111 | + | Severity NoticeSeverity | |||
| 112 | + | Message string | |||
| 113 | + | Details string // formatted error chain for yank | |||
| 114 | + | Time time.Time | |||
| 115 | + | Count int // >1 when coalesced | |||
| 116 | + | SessionID SessionID // "" = daemon-global | |||
| 117 | + | } | |||
A internal/domain/notice_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestNoticeCodeString(t *testing.T) { | |||
| 10 | + | for code := range noticeCodeLimit { | |||
| 11 | + | if got := code.String(); got == "unknown" { | |||
| 12 | + | t.Errorf("NoticeCode(%d).String() = unknown, want declared code slug", code) | |||
| 13 | + | } | |||
| 14 | + | } | |||
| 15 | + | if got := NoticeCode(9999).String(); got != "unknown" { | |||
| 16 | + | t.Errorf("NoticeCode(9999).String() = %q, want unknown", got) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestUserError(t *testing.T) { | |||
| 21 | + | cause := errors.New("boom") | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | err *UserError | |||
| 25 | + | wantMsg string | |||
| 26 | + | wantSev NoticeSeverity | |||
| 27 | + | wantWrap error | |||
| 28 | + | }{ | |||
| 29 | + | {"err with cause", UserErr(NoticePaneSpawn, "couldn't open pane", cause), "couldn't open pane: boom", NoticeError, cause}, | |||
| 30 | + | {"err without cause", UserErr(NoticeSessionUnavailable, "session is unavailable", nil), "session is unavailable", NoticeError, nil}, | |||
| 31 | + | {"warn", UserWarn(NoticeResizeFailed, "pane resize failed", cause), "pane resize failed: boom", NoticeWarn, cause}, | |||
| 32 | + | } | |||
| 33 | + | for _, tt := range tests { | |||
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | + | if got := tt.err.Error(); got != tt.wantMsg { | |||
| 36 | + | t.Errorf("Error() = %q, want %q", got, tt.wantMsg) | |||
| 37 | + | } | |||
| 38 | + | if tt.err.Severity != tt.wantSev { | |||
| 39 | + | t.Errorf("Severity = %d, want %d", tt.err.Severity, tt.wantSev) | |||
| 40 | + | } | |||
| 41 | + | if !errors.Is(tt.err, tt.wantWrap) && tt.wantWrap != nil { | |||
| 42 | + | t.Errorf("errors.Is(err, cause) = false, want true") | |||
| 43 | + | } | |||
| 44 | + | var ue *UserError | |||
| 45 | + | if !errors.As(fmt.Errorf("wrapped: %w", tt.err), &ue) { | |||
| 46 | + | t.Errorf("errors.As through wrapping = false, want true") | |||
| 47 | + | } | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | } | |||
M internal/ports/frame.go +12 -11
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 16 | 4 | + | const ProtocolVersion uint16 = 17 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
| @@ -10,16 +10,17 @@ type MsgType uint8 | |||||
| 10 | // through 15, server-originated messages from 16, leaving room for growth in | 10 | // through 15, server-originated messages from 16, leaving room for growth in | ||
| 11 | // each band. | 11 | // each band. | ||
| 12 | const ( | 12 | const ( | ||
| 13 | - | MsgHello MsgType = 1 | 13 | + | MsgHello MsgType = 1 |
| 14 | - | MsgInput MsgType = 2 | 14 | + | MsgInput MsgType = 2 |
| 15 | - | MsgResize MsgType = 3 | 15 | + | MsgResize MsgType = 3 |
| 16 | - | MsgDetach MsgType = 4 | 16 | + | MsgDetach MsgType = 4 |
| 17 | - | MsgPing MsgType = 5 | 17 | + | MsgPing MsgType = 5 |
| 18 | - | MsgList MsgType = 6 | 18 | + | MsgList MsgType = 6 |
| 19 | - | MsgKill MsgType = 7 | 19 | + | MsgKill MsgType = 7 |
| 20 | - | MsgTheme MsgType = 8 | 20 | + | MsgTheme MsgType = 8 |
| 21 | - | MsgAck MsgType = 9 | 21 | + | MsgAck MsgType = 9 |
| 22 | - | MsgImagePush MsgType = 10 | 22 | + | MsgImagePush MsgType = 10 |
| 23 | + | MsgClientNotice MsgType = 11 | |||
| 23 | 24 | ||||
| 24 | MsgWelcome MsgType = 16 | 25 | MsgWelcome MsgType = 16 | ||
| 25 | MsgError MsgType = 17 | 26 | MsgError MsgType = 17 | ||
M internal/ports/frame_test.go +3 -2
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 16 { | 6 | + | if ProtocolVersion != 17 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -23,6 +23,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 23 | {"MsgTheme", MsgTheme}, | 23 | {"MsgTheme", MsgTheme}, | ||
| 24 | {"MsgAck", MsgAck}, | 24 | {"MsgAck", MsgAck}, | ||
| 25 | {"MsgImagePush", MsgImagePush}, | 25 | {"MsgImagePush", MsgImagePush}, | ||
| 26 | + | {"MsgClientNotice", MsgClientNotice}, | |||
| 26 | {"MsgWelcome", MsgWelcome}, | 27 | {"MsgWelcome", MsgWelcome}, | ||
| 27 | {"MsgError", MsgError}, | 28 | {"MsgError", MsgError}, | ||
| 28 | {"MsgOutput", MsgOutput}, | 29 | {"MsgOutput", MsgOutput}, | ||
A internal/ports/mocks/notice_mock.go +187 -0
| @@ -0,0 +1,187 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package portsmocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | mock "github.com/stretchr/testify/mock" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // NewMockNoticeStore creates a new instance of MockNoticeStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 13 | + | // The first argument is typically a *testing.T value. | |||
| 14 | + | func NewMockNoticeStore(t interface { | |||
| 15 | + | mock.TestingT | |||
| 16 | + | Cleanup(func()) | |||
| 17 | + | }) *MockNoticeStore { | |||
| 18 | + | mock := &MockNoticeStore{} | |||
| 19 | + | mock.Mock.Test(t) | |||
| 20 | + | ||||
| 21 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 22 | + | ||||
| 23 | + | return mock | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // MockNoticeStore is an autogenerated mock type for the NoticeStore type | |||
| 27 | + | type MockNoticeStore struct { | |||
| 28 | + | mock.Mock | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type MockNoticeStore_Expecter struct { | |||
| 32 | + | mock *mock.Mock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (_m *MockNoticeStore) EXPECT() *MockNoticeStore_Expecter { | |||
| 36 | + | return &MockNoticeStore_Expecter{mock: &_m.Mock} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Ack provides a mock function for the type MockNoticeStore | |||
| 40 | + | func (_mock *MockNoticeStore) Ack() error { | |||
| 41 | + | ret := _mock.Called() | |||
| 42 | + | ||||
| 43 | + | if len(ret) == 0 { | |||
| 44 | + | panic("no return value specified for Ack") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var r0 error | |||
| 48 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 49 | + | r0 = returnFunc() | |||
| 50 | + | } else { | |||
| 51 | + | r0 = ret.Error(0) | |||
| 52 | + | } | |||
| 53 | + | return r0 | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // MockNoticeStore_Ack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ack' | |||
| 57 | + | type MockNoticeStore_Ack_Call struct { | |||
| 58 | + | *mock.Call | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Ack is a helper method to define mock.On call | |||
| 62 | + | func (_e *MockNoticeStore_Expecter) Ack() *MockNoticeStore_Ack_Call { | |||
| 63 | + | return &MockNoticeStore_Ack_Call{Call: _e.mock.On("Ack")} | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (_c *MockNoticeStore_Ack_Call) Run(run func()) *MockNoticeStore_Ack_Call { | |||
| 67 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 68 | + | run() | |||
| 69 | + | }) | |||
| 70 | + | return _c | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (_c *MockNoticeStore_Ack_Call) Return(err error) *MockNoticeStore_Ack_Call { | |||
| 74 | + | _c.Call.Return(err) | |||
| 75 | + | return _c | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (_c *MockNoticeStore_Ack_Call) RunAndReturn(run func() error) *MockNoticeStore_Ack_Call { | |||
| 79 | + | _c.Call.Return(run) | |||
| 80 | + | return _c | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Append provides a mock function for the type MockNoticeStore | |||
| 84 | + | func (_mock *MockNoticeStore) Append(n domain.Notification) error { | |||
| 85 | + | ret := _mock.Called(n) | |||
| 86 | + | ||||
| 87 | + | if len(ret) == 0 { | |||
| 88 | + | panic("no return value specified for Append") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | var r0 error | |||
| 92 | + | if returnFunc, ok := ret.Get(0).(func(domain.Notification) error); ok { | |||
| 93 | + | r0 = returnFunc(n) | |||
| 94 | + | } else { | |||
| 95 | + | r0 = ret.Error(0) | |||
| 96 | + | } | |||
| 97 | + | return r0 | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // MockNoticeStore_Append_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Append' | |||
| 101 | + | type MockNoticeStore_Append_Call struct { | |||
| 102 | + | *mock.Call | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Append is a helper method to define mock.On call | |||
| 106 | + | // - n domain.Notification | |||
| 107 | + | func (_e *MockNoticeStore_Expecter) Append(n any) *MockNoticeStore_Append_Call { | |||
| 108 | + | return &MockNoticeStore_Append_Call{Call: _e.mock.On("Append", n)} | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (_c *MockNoticeStore_Append_Call) Run(run func(n domain.Notification)) *MockNoticeStore_Append_Call { | |||
| 112 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 113 | + | var arg0 domain.Notification | |||
| 114 | + | if args[0] != nil { | |||
| 115 | + | arg0 = args[0].(domain.Notification) | |||
| 116 | + | } | |||
| 117 | + | run( | |||
| 118 | + | arg0, | |||
| 119 | + | ) | |||
| 120 | + | }) | |||
| 121 | + | return _c | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (_c *MockNoticeStore_Append_Call) Return(err error) *MockNoticeStore_Append_Call { | |||
| 125 | + | _c.Call.Return(err) | |||
| 126 | + | return _c | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func (_c *MockNoticeStore_Append_Call) RunAndReturn(run func(n domain.Notification) error) *MockNoticeStore_Append_Call { | |||
| 130 | + | _c.Call.Return(run) | |||
| 131 | + | return _c | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Claim provides a mock function for the type MockNoticeStore | |||
| 135 | + | func (_mock *MockNoticeStore) Claim() ([]domain.Notification, error) { | |||
| 136 | + | ret := _mock.Called() | |||
| 137 | + | ||||
| 138 | + | if len(ret) == 0 { | |||
| 139 | + | panic("no return value specified for Claim") | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | var r0 []domain.Notification | |||
| 143 | + | var r1 error | |||
| 144 | + | if returnFunc, ok := ret.Get(0).(func() ([]domain.Notification, error)); ok { | |||
| 145 | + | return returnFunc() | |||
| 146 | + | } | |||
| 147 | + | if returnFunc, ok := ret.Get(0).(func() []domain.Notification); ok { | |||
| 148 | + | r0 = returnFunc() | |||
| 149 | + | } else { | |||
| 150 | + | if ret.Get(0) != nil { | |||
| 151 | + | r0 = ret.Get(0).([]domain.Notification) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 155 | + | r1 = returnFunc() | |||
| 156 | + | } else { | |||
| 157 | + | r1 = ret.Error(1) | |||
| 158 | + | } | |||
| 159 | + | return r0, r1 | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // MockNoticeStore_Claim_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Claim' | |||
| 163 | + | type MockNoticeStore_Claim_Call struct { | |||
| 164 | + | *mock.Call | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // Claim is a helper method to define mock.On call | |||
| 168 | + | func (_e *MockNoticeStore_Expecter) Claim() *MockNoticeStore_Claim_Call { | |||
| 169 | + | return &MockNoticeStore_Claim_Call{Call: _e.mock.On("Claim")} | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (_c *MockNoticeStore_Claim_Call) Run(run func()) *MockNoticeStore_Claim_Call { | |||
| 173 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 174 | + | run() | |||
| 175 | + | }) | |||
| 176 | + | return _c | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (_c *MockNoticeStore_Claim_Call) Return(notifications []domain.Notification, err error) *MockNoticeStore_Claim_Call { | |||
| 180 | + | _c.Call.Return(notifications, err) | |||
| 181 | + | return _c | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (_c *MockNoticeStore_Claim_Call) RunAndReturn(run func() ([]domain.Notification, error)) *MockNoticeStore_Claim_Call { | |||
| 185 | + | _c.Call.Return(run) | |||
| 186 | + | return _c | |||
| 187 | + | } | |||
A internal/ports/notice.go +15 -0
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | // NoticeStore persists undeliverable notices across daemon restarts. | |||
| 6 | + | type NoticeStore interface { | |||
| 7 | + | Append(n domain.Notification) error | |||
| 8 | + | // Claim atomically reserves pending notices for import. Repeating Claim on | |||
| 9 | + | // the owning store replays its live claim; another store cannot replay or | |||
| 10 | + | // acknowledge it. The claim remains recoverable after the claimant process | |||
| 11 | + | // crashes, until its owning store calls Ack. | |||
| 12 | + | Claim() ([]domain.Notification, error) | |||
| 13 | + | // Ack permanently removes the notices returned by this store's claim. | |||
| 14 | + | Ack() error | |||
| 15 | + | } | |||
M internal/ports/wire.go +46 -0
| @@ -53,6 +53,7 @@ const ( | |||||
| 53 | ReasonDetach uint8 = 0 | 53 | ReasonDetach uint8 = 0 | ||
| 54 | ReasonSessionKilled uint8 = 1 | 54 | ReasonSessionKilled uint8 = 1 | ||
| 55 | ReasonServerShutdown uint8 = 2 | 55 | ReasonServerShutdown uint8 = 2 | ||
| 56 | + | ReasonReplaced uint8 = 3 | |||
| 56 | ) | 57 | ) | ||
| 57 | 58 | ||||
| 58 | // Hello is sent by the client immediately after connecting. | 59 | // Hello is sent by the client immediately after connecting. | ||
| @@ -108,6 +109,29 @@ type ImagePush struct { | |||||
| 108 | Data []byte | 109 | Data []byte | ||
| 109 | } | 110 | } | ||
| 110 | 111 | ||||
| 112 | + | // ClientNotice reports a fixed client-side event for daemon-rendered user | |||
| 113 | + | // feedback. Action values are deliberately closed so a client cannot inject | |||
| 114 | + | // arbitrary display text into a shared session. | |||
| 115 | + | type ClientNotice struct { | |||
| 116 | + | Action uint8 | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | const ( | |||
| 120 | + | ClientNoticeClipboardFallback uint8 = 1 | |||
| 121 | + | ClientNoticeClipboardTooLarge uint8 = 2 | |||
| 122 | + | ClientNoticeLinkDegraded uint8 = 3 | |||
| 123 | + | ClientNoticeLinkConnected uint8 = 4 | |||
| 124 | + | ) | |||
| 125 | + | ||||
| 126 | + | func validClientNoticeAction(action uint8) bool { | |||
| 127 | + | switch action { | |||
| 128 | + | case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected: | |||
| 129 | + | return true | |||
| 130 | + | default: | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 111 | // Detach asks the daemon to detach the current client without killing the | 135 | // Detach asks the daemon to detach the current client without killing the | ||
| 112 | // session. | 136 | // session. | ||
| 113 | type Detach struct{} | 137 | type Detach struct{} | ||
| @@ -458,6 +482,28 @@ func UnmarshalImagePush(b []byte) (ImagePush, error) { | |||||
| 458 | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil | 482 | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil | ||
| 459 | } | 483 | } | ||
| 460 | 484 | ||||
| 485 | + | // MarshalClientNotice encodes a fixed one-byte client notice action. | |||
| 486 | + | func MarshalClientNotice(m ClientNotice) []byte { | |||
| 487 | + | return []byte{m.Action} | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | // UnmarshalClientNotice decodes a fixed client notice action and rejects both | |||
| 491 | + | // unknown values and any trailing bytes. | |||
| 492 | + | func UnmarshalClientNotice(b []byte) (ClientNotice, error) { | |||
| 493 | + | r := payloadReader{b: b} | |||
| 494 | + | action, err := r.getUint8() | |||
| 495 | + | if err != nil { | |||
| 496 | + | return ClientNotice{}, err | |||
| 497 | + | } | |||
| 498 | + | if !validClientNoticeAction(action) { | |||
| 499 | + | return ClientNotice{}, errors.New("ports: unknown client notice action") | |||
| 500 | + | } | |||
| 501 | + | if err := r.done(); err != nil { | |||
| 502 | + | return ClientNotice{}, err | |||
| 503 | + | } | |||
| 504 | + | return ClientNotice{Action: action}, nil | |||
| 505 | + | } | |||
| 506 | + | ||||
| 461 | // MarshalResize encodes m into a Resize message payload. | 507 | // MarshalResize encodes m into a Resize message payload. | ||
| 462 | func MarshalResize(m Resize) []byte { | 508 | func MarshalResize(m Resize) []byte { | ||
| 463 | w := payloadWriter{} | 509 | w := payloadWriter{} | ||
M internal/ports/wire_test.go +33 -6
| @@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 121 | Env: []string{"A=B", "XY=123"}, | 121 | Env: []string{"A=B", "XY=123"}, | ||
| 122 | }) | 122 | }) | ||
| 123 | want := []byte{ | 123 | want := []byte{ | ||
| 124 | - | 0x00, 0x10, 0x00, // version, intent | 124 | + | 0x00, 0x11, 0x00, // version, intent |
| 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 335 | } | 335 | } | ||
| 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 337 | require.Equal(t, want, MarshalTheme(cleared)) | 337 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 338 | - | require.Equal(t, uint16(16), ProtocolVersion) | 338 | + | require.Equal(t, uint16(17), ProtocolVersion) |
| 339 | } | 339 | } | ||
| 340 | 340 | ||||
| 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -502,8 +502,8 @@ func TestAckGoldenAndRoundTrip(t *testing.T) { | |||||
| 502 | } | 502 | } | ||
| 503 | 503 | ||||
| 504 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | 504 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | ||
| 505 | - | msg := Detached{Reason: ReasonSessionKilled} | 505 | + | msg := Detached{Reason: ReasonReplaced} |
| 506 | - | want := []byte{0x01} | 506 | + | want := []byte{0x03} |
| 507 | 507 | ||||
| 508 | got := MarshalDetached(msg) | 508 | got := MarshalDetached(msg) | ||
| 509 | if !bytes.Equal(got, want) { | 509 | if !bytes.Equal(got, want) { | ||
| @@ -521,6 +521,32 @@ func TestDetachedGoldenAndRoundTrip(t *testing.T) { | |||||
| 521 | assertTrailingGarbageFails(t, got, UnmarshalDetached) | 521 | assertTrailingGarbageFails(t, got, UnmarshalDetached) | ||
| 522 | } | 522 | } | ||
| 523 | 523 | ||||
| 524 | + | func TestClientNoticeGoldenAndStrict(t *testing.T) { | |||
| 525 | + | tests := []struct { | |||
| 526 | + | name string | |||
| 527 | + | msg ClientNotice | |||
| 528 | + | want []byte | |||
| 529 | + | }{ | |||
| 530 | + | {name: "clipboard fallback", msg: ClientNotice{Action: ClientNoticeClipboardFallback}, want: []byte{0x01}}, | |||
| 531 | + | {name: "clipboard too large", msg: ClientNotice{Action: ClientNoticeClipboardTooLarge}, want: []byte{0x02}}, | |||
| 532 | + | {name: "link degraded", msg: ClientNotice{Action: ClientNoticeLinkDegraded}, want: []byte{0x03}}, | |||
| 533 | + | {name: "link connected", msg: ClientNotice{Action: ClientNoticeLinkConnected}, want: []byte{0x04}}, | |||
| 534 | + | } | |||
| 535 | + | for _, tt := range tests { | |||
| 536 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 537 | + | got := MarshalClientNotice(tt.msg) | |||
| 538 | + | require.Equal(t, tt.want, got) | |||
| 539 | + | back, err := UnmarshalClientNotice(got) | |||
| 540 | + | require.NoError(t, err) | |||
| 541 | + | require.Equal(t, tt.msg, back) | |||
| 542 | + | assertAllPrefixesFail(t, got, UnmarshalClientNotice) | |||
| 543 | + | assertTrailingGarbageFails(t, got, UnmarshalClientNotice) | |||
| 544 | + | }) | |||
| 545 | + | } | |||
| 546 | + | _, err := UnmarshalClientNotice([]byte{0xff}) | |||
| 547 | + | require.Error(t, err, "unknown actions must not become display text") | |||
| 548 | + | } | |||
| 549 | + | ||||
| 524 | func TestListGoldenAndRoundTrip(t *testing.T) { | 550 | func TestListGoldenAndRoundTrip(t *testing.T) { | ||
| 525 | got := MarshalList(List{}) | 551 | got := MarshalList(List{}) | ||
| 526 | if len(got) != 0 { | 552 | if len(got) != 0 { | ||
| @@ -661,9 +687,10 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||||
| 661 | ReasonDetach: "ReasonDetach", | 687 | ReasonDetach: "ReasonDetach", | ||
| 662 | ReasonSessionKilled: "ReasonSessionKilled", | 688 | ReasonSessionKilled: "ReasonSessionKilled", | ||
| 663 | ReasonServerShutdown: "ReasonServerShutdown", | 689 | ReasonServerShutdown: "ReasonServerShutdown", | ||
| 690 | + | ReasonReplaced: "ReasonReplaced", | |||
| 664 | } | 691 | } | ||
| 665 | - | if len(vals) != 3 { | 692 | + | if len(vals) != 4 { |
| 666 | - | t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals)) | 693 | + | t.Fatalf("expected 4 distinct detached reasons, got %d", len(vals)) |
| 667 | } | 694 | } | ||
| 668 | }) | 695 | }) | ||
| 669 | } | 696 | } | ||
M internal/usecase/client/client.go +15 -1
| @@ -798,11 +798,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 798 | } | 798 | } | ||
| 799 | if ev.State == ports.LinkStateConnected { | 799 | if ev.State == ports.LinkStateConnected { | ||
| 800 | reconnect.clear() | 800 | reconnect.clear() | ||
| 801 | + | select { | |||
| 802 | + | case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})}: | |||
| 803 | + | case <-loopCtx.Done(): | |||
| 804 | + | return welcomedResult(nil) | |||
| 805 | + | } | |||
| 801 | continue | 806 | continue | ||
| 802 | } | 807 | } | ||
| 803 | if ev.State == ports.LinkStateDegraded { | 808 | if ev.State == ports.LinkStateDegraded { | ||
| 804 | log.Warn("UDP link degraded") | 809 | log.Warn("UDP link degraded") | ||
| 805 | - | reconnect.clear() | 810 | + | select { |
| 811 | + | case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})}: | |||
| 812 | + | case <-loopCtx.Done(): | |||
| 813 | + | return welcomedResult(nil) | |||
| 814 | + | } | |||
| 806 | continue | 815 | continue | ||
| 807 | } | 816 | } | ||
| 808 | reconnect.drawStage(stageForLinkState(ev.State)) | 817 | reconnect.drawStage(stageForLinkState(ev.State)) | ||
| @@ -1366,6 +1375,9 @@ func (p *stdinPump) run() { | |||||
| 1366 | coalescer: coalescer, | 1375 | coalescer: coalescer, | ||
| 1367 | reader: p.clipboard, | 1376 | reader: p.clipboard, | ||
| 1368 | log: p.logger, | 1377 | log: p.logger, | ||
| 1378 | + | sendNotice: func(action uint8) { | |||
| 1379 | + | send(ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: action})}) | |||
| 1380 | + | }, | |||
| 1369 | sendImage: func(mime string, data []byte) { | 1381 | sendImage: func(mime string, data []byte) { | ||
| 1370 | inputSeq++ | 1382 | inputSeq++ | ||
| 1371 | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})}) | 1383 | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})}) | ||
| @@ -1562,6 +1574,8 @@ func detachedResult(reason uint8) error { | |||||
| 1562 | return &DetachedError{Reason: reason, Text: "session was killed"} | 1574 | return &DetachedError{Reason: reason, Text: "session was killed"} | ||
| 1563 | case ports.ReasonServerShutdown: | 1575 | case ports.ReasonServerShutdown: | ||
| 1564 | return &DetachedError{Reason: reason, Text: "daemon shut down"} | 1576 | return &DetachedError{Reason: reason, Text: "daemon shut down"} | ||
| 1577 | + | case ports.ReasonReplaced: | |||
| 1578 | + | return &DetachedError{Reason: reason, Text: "session taken over by another client"} | |||
| 1565 | default: | 1579 | default: | ||
| 1566 | return &DetachedError{Reason: reason, Text: "detached by daemon"} | 1580 | return &DetachedError{Reason: reason, Text: "detached by daemon"} | ||
| 1567 | } | 1581 | } | ||
M internal/usecase/client/clipboard_intercept.go +12 -5
| @@ -25,11 +25,12 @@ const maxClipboardImagePush = 1 << 20 // 1 MiB | |||||
| 25 | // text may legitimately contain it) — is forwarded to next unchanged, so it | 25 | // text may legitimately contain it) — is forwarded to next unchanged, so it | ||
| 26 | // still gets the coalescer's marker-splitting treatment. | 26 | // still gets the coalescer's marker-splitting treatment. | ||
| 27 | type clipboardIntercept struct { | 27 | type clipboardIntercept struct { | ||
| 28 | - | coalescer *pasteCoalescer | 28 | + | coalescer *pasteCoalescer |
| 29 | - | reader ports.ClipboardReader | 29 | + | reader ports.ClipboardReader |
| 30 | - | log *slog.Logger | 30 | + | log *slog.Logger |
| 31 | - | sendImage func(mime string, data []byte) | 31 | + | sendImage func(mime string, data []byte) |
| 32 | - | next func([]byte) | 32 | + | sendNotice func(action uint8) |
| 33 | + | next func([]byte) | |||
| 33 | } | 34 | } | ||
| 34 | 35 | ||||
| 35 | // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it | 36 | // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it | ||
| @@ -99,12 +100,18 @@ func (c *clipboardIntercept) handleCtrlV() { | |||||
| 99 | if err != nil { | 100 | if err != nil { | ||
| 100 | if !errors.Is(err, ports.ErrNoClipboardImage) { | 101 | if !errors.Is(err, ports.ErrNoClipboardImage) { | ||
| 101 | c.log.Warn("clipboard image read failed", "err", err) | 102 | c.log.Warn("clipboard image read failed", "err", err) | ||
| 103 | + | if c.sendNotice != nil { | |||
| 104 | + | c.sendNotice(ports.ClientNoticeClipboardFallback) | |||
| 105 | + | } | |||
| 102 | } | 106 | } | ||
| 103 | c.next([]byte{ctrlV}) | 107 | c.next([]byte{ctrlV}) | ||
| 104 | return | 108 | return | ||
| 105 | } | 109 | } | ||
| 106 | if len(data) > maxClipboardImagePush { | 110 | if len(data) > maxClipboardImagePush { | ||
| 107 | c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush) | 111 | c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush) | ||
| 112 | + | if c.sendNotice != nil { | |||
| 113 | + | c.sendNotice(ports.ClientNoticeClipboardTooLarge) | |||
| 114 | + | } | |||
| 108 | c.next([]byte{ctrlV}) | 115 | c.next([]byte{ctrlV}) | ||
| 109 | return | 116 | return | ||
| 110 | } | 117 | } | ||
M internal/usecase/client/clipboard_intercept_internal_test.go +25 -0
| @@ -79,6 +79,31 @@ func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) { | |||||
| 79 | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | 79 | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | func TestClipboardInterceptFailureNotifiesDaemonAndForwardsCtrlV(t *testing.T) { | |||
| 83 | + | tests := []struct { | |||
| 84 | + | name string | |||
| 85 | + | reader ports.ClipboardReader | |||
| 86 | + | want uint8 | |||
| 87 | + | }{ | |||
| 88 | + | {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: ports.ClientNoticeClipboardFallback}, | |||
| 89 | + | {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: ports.ClientNoticeClipboardTooLarge}, | |||
| 90 | + | } | |||
| 91 | + | for _, tt := range tests { | |||
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 93 | + | ci, pc, _, col, images := newTestClipboardIntercept(tt.reader) | |||
| 94 | + | defer pc.Close() | |||
| 95 | + | var actions []uint8 | |||
| 96 | + | ci.sendNotice = func(action uint8) { actions = append(actions, action) } | |||
| 97 | + | ||||
| 98 | + | ci.Scan([]byte{ctrlV}) | |||
| 99 | + | ||||
| 100 | + | require.Equal(t, []uint8{tt.want}, actions) | |||
| 101 | + | require.Empty(t, *images) | |||
| 102 | + | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | |||
| 103 | + | }) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 82 | func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) { | 107 | func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) { | ||
| 83 | huge := make([]byte, maxClipboardImagePush+1) | 108 | huge := make([]byte, maxClipboardImagePush+1) | ||
| 84 | reader := &ciFakeReader{mime: "image/png", data: huge} | 109 | reader := &ciFakeReader{mime: "image/png", data: huge} | ||
M internal/usecase/client/clipboard_intercept_test.go +74 -1
| @@ -3,6 +3,7 @@ package client_test | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "io" | 7 | "io" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -30,7 +31,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||||
| 30 | defer input.unblock() | 31 | defer input.unblock() | ||
| 31 | 32 | ||||
| 32 | tm := portsmocks.NewMockTerminal(t) | 33 | tm := portsmocks.NewMockTerminal(t) | ||
| 33 | - | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | 34 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe() |
| 34 | tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once() | 35 | tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once() | ||
| 35 | tm.EXPECT().In().Return(input).Maybe() | 36 | tm.EXPECT().In().Return(input).Maybe() | ||
| 36 | tm.EXPECT().Out().Return(&out).Maybe() | 37 | tm.EXPECT().Out().Return(&out).Maybe() | ||
| @@ -44,6 +45,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||||
| 44 | tr := portsmocks.NewMockTransport(t) | 45 | tr := portsmocks.NewMockTransport(t) | ||
| 45 | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | 46 | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | ||
| 46 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | 47 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | ||
| 48 | + | tr.EXPECT().Send(isType(ports.MsgResize)).Return(nil).Maybe() | |||
| 47 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | 49 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | ||
| 48 | in, err := ports.UnmarshalInput(f.Payload) | 50 | in, err := ports.UnmarshalInput(f.Payload) | ||
| 49 | require.NoError(t, err) | 51 | require.NoError(t, err) | ||
| @@ -59,6 +61,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||||
| 59 | gotImage <- ip | 61 | gotImage <- ip | ||
| 60 | return nil | 62 | return nil | ||
| 61 | }).Maybe() | 63 | }).Maybe() | ||
| 64 | + | tr.EXPECT().Send(isType(ports.MsgClientNotice)).Return(nil).Maybe() | |||
| 62 | 65 | ||||
| 63 | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | 66 | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | ||
| 64 | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | 67 | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | ||
| @@ -137,6 +140,76 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) { | |||||
| 137 | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image") | 140 | require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image") | ||
| 138 | } | 141 | } | ||
| 139 | 142 | ||||
| 143 | + | type clipboardToastLifecycleTransport struct { | |||
| 144 | + | recv chan ports.Frame | |||
| 145 | + | sends chan ports.Frame | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (t *clipboardToastLifecycleTransport) Send(f ports.Frame) error { | |||
| 149 | + | t.sends <- f | |||
| 150 | + | return nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (t *clipboardToastLifecycleTransport) Recv() (ports.Frame, error) { return <-t.recv, nil } | |||
| 154 | + | func (*clipboardToastLifecycleTransport) Close() error { return nil } | |||
| 155 | + | ||||
| 156 | + | type clipboardToastLifecycleDialer struct{ transport ports.Transport } | |||
| 157 | + | ||||
| 158 | + | func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 159 | + | return d.transport, nil | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) { | |||
| 163 | + | var out bytes.Buffer | |||
| 164 | + | resizeEvents := make(chan domain.Size) | |||
| 165 | + | input := newOneShotBlockingReader([]byte{0x16}) | |||
| 166 | + | defer input.unblock() | |||
| 167 | + | term := portsmocks.NewMockTerminal(t) | |||
| 168 | + | term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe() | |||
| 169 | + | term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once() | |||
| 170 | + | term.EXPECT().In().Return(input).Maybe() | |||
| 171 | + | term.EXPECT().Out().Return(&out).Maybe() | |||
| 172 | + | term.EXPECT().Flush().Return(nil).Maybe() | |||
| 173 | + | term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(resizeEvents)).Maybe() | |||
| 174 | + | ||||
| 175 | + | clipboard := portsmocks.NewMockClipboardReader(t) | |||
| 176 | + | clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once() | |||
| 177 | + | transport := &clipboardToastLifecycleTransport{recv: make(chan ports.Frame, 8), sends: make(chan ports.Frame, 16)} | |||
| 178 | + | transport.recv <- frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 179 | + | ||||
| 180 | + | result := make(chan error, 1) | |||
| 181 | + | go func() { | |||
| 182 | + | result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true}) | |||
| 183 | + | }() | |||
| 184 | + | ||||
| 185 | + | var gotNotice bool | |||
| 186 | + | for !gotNotice { | |||
| 187 | + | select { | |||
| 188 | + | case sent := <-transport.sends: | |||
| 189 | + | if sent.Type != ports.MsgClientNotice { | |||
| 190 | + | continue | |||
| 191 | + | } | |||
| 192 | + | notice, err := ports.UnmarshalClientNotice(sent.Payload) | |||
| 193 | + | require.NoError(t, err) | |||
| 194 | + | require.Equal(t, ports.ClientNoticeClipboardFallback, notice.Action) | |||
| 195 | + | gotNotice = true | |||
| 196 | + | case <-time.After(time.Second): | |||
| 197 | + | t.Fatal("client did not notify daemon of clipboard failure") | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | beforeOutput := out.String() | |||
| 201 | + | transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")})) | |||
| 202 | + | transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 203 | + | ||||
| 204 | + | select { | |||
| 205 | + | case err := <-result: | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | case <-time.After(time.Second): | |||
| 208 | + | t.Fatal("client did not finish") | |||
| 209 | + | } | |||
| 210 | + | require.Equal(t, beforeOutput+"incremental", out.String(), "daemon output must remain verbatim with no local toast bytes") | |||
| 211 | + | } | |||
| 212 | + | ||||
| 140 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | 213 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | ||
| 141 | huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap | 214 | huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap | ||
| 142 | clip := portsmocks.NewMockClipboardReader(t) | 215 | clip := portsmocks.NewMockClipboardReader(t) | ||
M internal/usecase/client/reconnect_toast.go +7 -1
| @@ -33,7 +33,13 @@ func drawReconnectToast(out io.Writer, size domain.Size) error { | |||||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) { | 35 | func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) { | ||
| 36 | - | message := reconnectStageMessage(stage) | 36 | + | return drawClientToast(out, size, reconnectStageMessage(stage)) |
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // drawClientToast draws a client-local toast without changing terminal state. | |||
| 40 | + | // The attach main loop owns both this write and the later daemon-frame | |||
| 41 | + | // reconciliation; input pumps must only publish a request for it. | |||
| 42 | + | func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Rect, error) { | |||
| 37 | bounds := reconnectToastBoundsFor(size, message) | 43 | bounds := reconnectToastBoundsFor(size, message) | ||
| 38 | if bounds.Width <= 0 || bounds.Height <= 0 { | 44 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 39 | return domain.Rect{}, nil | 45 | return domain.Rect{}, nil | ||
M internal/usecase/client/reconnect_toast_test.go +69 -19
| @@ -141,22 +141,30 @@ func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnect | |||||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | type reconnectToastLinkTransport struct { | 143 | type reconnectToastLinkTransport struct { | ||
| 144 | - | recvCh chan reconnectToastRecv | 144 | + | recvCh chan reconnectToastRecv |
| 145 | - | sends []ports.Frame | 145 | + | clientNotices chan ports.ClientNotice |
| 146 | - | events chan ports.LinkEvent | 146 | + | events chan ports.LinkEvent |
| 147 | - | state ports.LinkState | 147 | + | state ports.LinkState |
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | func newReconnectToastLinkTransport() *reconnectToastLinkTransport { | 150 | func newReconnectToastLinkTransport() *reconnectToastLinkTransport { | ||
| 151 | return &reconnectToastLinkTransport{ | 151 | return &reconnectToastLinkTransport{ | ||
| 152 | - | recvCh: make(chan reconnectToastRecv, 4), | 152 | + | recvCh: make(chan reconnectToastRecv, 4), |
| 153 | - | events: make(chan ports.LinkEvent, 4), | 153 | + | clientNotices: make(chan ports.ClientNotice, 2), |
| 154 | - | state: ports.LinkStateConnected, | 154 | + | events: make(chan ports.LinkEvent, 4), |
| 155 | + | state: ports.LinkStateConnected, | |||
| 155 | } | 156 | } | ||
| 156 | } | 157 | } | ||
| 157 | 158 | ||||
| 158 | func (t *reconnectToastLinkTransport) Send(f ports.Frame) error { | 159 | func (t *reconnectToastLinkTransport) Send(f ports.Frame) error { | ||
| 159 | - | t.sends = append(t.sends, f) | 160 | + | if f.Type != ports.MsgClientNotice { |
| 161 | + | return nil | |||
| 162 | + | } | |||
| 163 | + | notice, err := ports.UnmarshalClientNotice(f.Payload) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | t.clientNotices <- notice | |||
| 160 | return nil | 168 | return nil | ||
| 161 | } | 169 | } | ||
| 162 | 170 | ||||
| @@ -282,7 +290,7 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c | |||||
| 282 | } | 290 | } | ||
| 283 | } | 291 | } | ||
| 284 | 292 | ||||
| 285 | - | func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | 293 | + | func TestReconnectLinkEventsNotifyDaemonWithoutLocalTerminalWrites(t *testing.T) { |
| 286 | out := newReconnectToastOutputRecorder() | 294 | out := newReconnectToastOutputRecorder() | ||
| 287 | term := newReconnectToastTerminalHarnessWithOutput(t, out) | 295 | term := newReconnectToastTerminalHarnessWithOutput(t, out) | ||
| 288 | defer term.closeInput() | 296 | defer term.closeInput() | ||
| @@ -294,17 +302,15 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |||||
| 294 | attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms) | 302 | attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms) | ||
| 295 | go func() { resultCh <- attempt.run(context.Background()) }() | 303 | go func() { resultCh <- attempt.run(context.Background()) }() | ||
| 296 | 304 | ||||
| 297 | - | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | |||
| 298 | - | probed := <-out.completed | |||
| 299 | - | require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP)) | |||
| 300 | - | ||||
| 301 | tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded} | 305 | tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded} | ||
| 302 | - | cleared := <-out.completed | 306 | + | tr.events <- ports.LinkEvent{State: ports.LinkStateConnected} |
| 303 | - | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP))) | 307 | + | require.Equal(t, ports.ClientNoticeLinkDegraded, (<-tr.clientNotices).Action) |
| 304 | - | 308 | + | require.Equal(t, ports.ClientNoticeLinkConnected, (<-tr.clientNotices).Action) | |
| 305 | - | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | 309 | + | select { |
| 306 | - | probed = <-out.completed | 310 | + | case bytes := <-out.completed: |
| 307 | - | require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP)) | 311 | + | t.Fatalf("link notices wrote client-local terminal bytes: %q", bytes) |
| 312 | + | default: | |||
| 313 | + | } | |||
| 308 | 314 | ||||
| 309 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | 315 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | ||
| 310 | result := <-resultCh | 316 | result := <-resultCh | ||
| @@ -330,6 +336,28 @@ func TestAttachAttemptOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||||
| 330 | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | 336 | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | ||
| 331 | } | 337 | } | ||
| 332 | 338 | ||||
| 339 | + | func TestDetachedResultReplacementAndCleanDetach(t *testing.T) { | |||
| 340 | + | for _, tt := range []struct { | |||
| 341 | + | name string | |||
| 342 | + | reason uint8 | |||
| 343 | + | wantText string | |||
| 344 | + | }{ | |||
| 345 | + | {name: "clean detach", reason: ports.ReasonDetach}, | |||
| 346 | + | {name: "replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"}, | |||
| 347 | + | } { | |||
| 348 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 349 | + | err := detachedResult(tt.reason) | |||
| 350 | + | if tt.wantText == "" { | |||
| 351 | + | require.NoError(t, err) | |||
| 352 | + | return | |||
| 353 | + | } | |||
| 354 | + | var detached *DetachedError | |||
| 355 | + | require.ErrorAs(t, err, &detached) | |||
| 356 | + | require.Equal(t, tt.wantText, detached.Text) | |||
| 357 | + | }) | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ||||
| 333 | func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { | 361 | func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { | ||
| 334 | bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24}) | 362 | bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24}) | ||
| 335 | require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds) | 363 | require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds) | ||
| @@ -359,6 +387,28 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) { | |||||
| 359 | require.Contains(t, strings.Join(lines, "\n"), "…") | 387 | require.Contains(t, strings.Join(lines, "\n"), "…") | ||
| 360 | } | 388 | } | ||
| 361 | 389 | ||||
| 390 | + | func TestReconnectToastStageBytesUnchangedAfterClientHelperExtraction(t *testing.T) { | |||
| 391 | + | for _, stage := range []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying} { | |||
| 392 | + | t.Run(reconnectStageMessage(stage), func(t *testing.T) { | |||
| 393 | + | var got, want bytes.Buffer | |||
| 394 | + | gotBounds, gotErr := drawReconnectToastStage(&got, domain.Size{Cols: 80, Rows: 24}, stage) | |||
| 395 | + | wantBounds, wantErr := legacyReconnectToastStage(&want, domain.Size{Cols: 80, Rows: 24}, stage) | |||
| 396 | + | require.Equal(t, wantErr, gotErr) | |||
| 397 | + | require.Equal(t, wantBounds, gotBounds) | |||
| 398 | + | require.Equal(t, want.String(), got.String()) | |||
| 399 | + | }) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | func legacyReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) { | |||
| 404 | + | message := reconnectStageMessage(stage) | |||
| 405 | + | bounds := reconnectToastBoundsFor(size, message) | |||
| 406 | + | if bounds.Width <= 0 || bounds.Height <= 0 { | |||
| 407 | + | return domain.Rect{}, nil | |||
| 408 | + | } | |||
| 409 | + | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | |||
| 410 | + | } | |||
| 411 | + | ||||
| 362 | func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) { | 412 | func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) { | ||
| 363 | size := domain.Size{Cols: 80, Rows: 24} | 413 | size := domain.Size{Cols: 80, Rows: 24} | ||
| 364 | tests := []struct { | 414 | tests := []struct { | ||
M internal/usecase/command/command.go +2 -0
| @@ -51,6 +51,8 @@ type Context interface { | |||||
| 51 | RenameSession() error | 51 | RenameSession() error | ||
| 52 | RenameTab() error | 52 | RenameTab() error | ||
| 53 | OpenSessionPicker() error | 53 | OpenSessionPicker() error | ||
| 54 | + | OpenNotifications() error | |||
| 55 | + | YankLastNotification() error | |||
| 54 | JumpRecentSession(rank int) error | 56 | JumpRecentSession(rank int) error | ||
| 55 | } | 57 | } | ||
| 56 | 58 | ||||
M internal/usecase/command/context_mock_test.go +88 -0
| @@ -614,6 +614,50 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_ | |||||
| 614 | return _c | 614 | return _c | ||
| 615 | } | 615 | } | ||
| 616 | 616 | ||||
| 617 | + | // OpenNotifications provides a mock function for the type MockContext | |||
| 618 | + | func (_mock *MockContext) OpenNotifications() error { | |||
| 619 | + | ret := _mock.Called() | |||
| 620 | + | ||||
| 621 | + | if len(ret) == 0 { | |||
| 622 | + | panic("no return value specified for OpenNotifications") | |||
| 623 | + | } | |||
| 624 | + | ||||
| 625 | + | var r0 error | |||
| 626 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 627 | + | r0 = returnFunc() | |||
| 628 | + | } else { | |||
| 629 | + | r0 = ret.Error(0) | |||
| 630 | + | } | |||
| 631 | + | return r0 | |||
| 632 | + | } | |||
| 633 | + | ||||
| 634 | + | // MockContext_OpenNotifications_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenNotifications' | |||
| 635 | + | type MockContext_OpenNotifications_Call struct { | |||
| 636 | + | *mock.Call | |||
| 637 | + | } | |||
| 638 | + | ||||
| 639 | + | // OpenNotifications is a helper method to define mock.On call | |||
| 640 | + | func (_e *MockContext_Expecter) OpenNotifications() *MockContext_OpenNotifications_Call { | |||
| 641 | + | return &MockContext_OpenNotifications_Call{Call: _e.mock.On("OpenNotifications")} | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | func (_c *MockContext_OpenNotifications_Call) Run(run func()) *MockContext_OpenNotifications_Call { | |||
| 645 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 646 | + | run() | |||
| 647 | + | }) | |||
| 648 | + | return _c | |||
| 649 | + | } | |||
| 650 | + | ||||
| 651 | + | func (_c *MockContext_OpenNotifications_Call) Return(err error) *MockContext_OpenNotifications_Call { | |||
| 652 | + | _c.Call.Return(err) | |||
| 653 | + | return _c | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | func (_c *MockContext_OpenNotifications_Call) RunAndReturn(run func() error) *MockContext_OpenNotifications_Call { | |||
| 657 | + | _c.Call.Return(run) | |||
| 658 | + | return _c | |||
| 659 | + | } | |||
| 660 | + | ||||
| 617 | // OpenSessionPicker provides a mock function for the type MockContext | 661 | // OpenSessionPicker provides a mock function for the type MockContext | ||
| 618 | func (_mock *MockContext) OpenSessionPicker() error { | 662 | func (_mock *MockContext) OpenSessionPicker() error { | ||
| 619 | ret := _mock.Called() | 663 | ret := _mock.Called() | ||
| @@ -1097,3 +1141,47 @@ func (_c *MockContext_ToggleStack_Call) RunAndReturn(run func() error) *MockCont | |||||
| 1097 | _c.Call.Return(run) | 1141 | _c.Call.Return(run) | ||
| 1098 | return _c | 1142 | return _c | ||
| 1099 | } | 1143 | } | ||
| 1144 | + | ||||
| 1145 | + | // YankLastNotification provides a mock function for the type MockContext | |||
| 1146 | + | func (_mock *MockContext) YankLastNotification() error { | |||
| 1147 | + | ret := _mock.Called() | |||
| 1148 | + | ||||
| 1149 | + | if len(ret) == 0 { | |||
| 1150 | + | panic("no return value specified for YankLastNotification") | |||
| 1151 | + | } | |||
| 1152 | + | ||||
| 1153 | + | var r0 error | |||
| 1154 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 1155 | + | r0 = returnFunc() | |||
| 1156 | + | } else { | |||
| 1157 | + | r0 = ret.Error(0) | |||
| 1158 | + | } | |||
| 1159 | + | return r0 | |||
| 1160 | + | } | |||
| 1161 | + | ||||
| 1162 | + | // MockContext_YankLastNotification_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'YankLastNotification' | |||
| 1163 | + | type MockContext_YankLastNotification_Call struct { | |||
| 1164 | + | *mock.Call | |||
| 1165 | + | } | |||
| 1166 | + | ||||
| 1167 | + | // YankLastNotification is a helper method to define mock.On call | |||
| 1168 | + | func (_e *MockContext_Expecter) YankLastNotification() *MockContext_YankLastNotification_Call { | |||
| 1169 | + | return &MockContext_YankLastNotification_Call{Call: _e.mock.On("YankLastNotification")} | |||
| 1170 | + | } | |||
| 1171 | + | ||||
| 1172 | + | func (_c *MockContext_YankLastNotification_Call) Run(run func()) *MockContext_YankLastNotification_Call { | |||
| 1173 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1174 | + | run() | |||
| 1175 | + | }) | |||
| 1176 | + | return _c | |||
| 1177 | + | } | |||
| 1178 | + | ||||
| 1179 | + | func (_c *MockContext_YankLastNotification_Call) Return(err error) *MockContext_YankLastNotification_Call { | |||
| 1180 | + | _c.Call.Return(err) | |||
| 1181 | + | return _c | |||
| 1182 | + | } | |||
| 1183 | + | ||||
| 1184 | + | func (_c *MockContext_YankLastNotification_Call) RunAndReturn(run func() error) *MockContext_YankLastNotification_Call { | |||
| 1185 | + | _c.Call.Return(run) | |||
| 1186 | + | return _c | |||
| 1187 | + | } | |||
M internal/usecase/command/registry.go +2 -0
| @@ -31,6 +31,8 @@ func Registry() []Command { | |||||
| 31 | return ctx.JumpRecentSession(rank) | 31 | return ctx.JumpRecentSession(rank) | ||
| 32 | }}, | 32 | }}, | ||
| 33 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | 33 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | ||
| 34 | + | {Slug: "notifications", Code: "NTC", Name: "Notifications", Desc: "Show notification history", Run: func(ctx Context, _ []string) error { return ctx.OpenNotifications() }}, | |||
| 35 | + | {Slug: "yank-last-notification", Code: "YLN", Name: "Yank last notification", Desc: "Copy the most recent notification's details to the clipboard", Run: func(ctx Context, _ []string) error { return ctx.YankLastNotification() }}, | |||
| 34 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | 36 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | ||
| 35 | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }}, | 37 | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }}, | ||
| 36 | {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }}, | 38 | {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }}, | ||
M internal/usecase/command/registry_test.go +24 -1
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | ||
| 9 | commands := Registry() | 9 | commands := Registry() | ||
| 10 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "VIS", "RNS", "RNT", "DET"} | 10 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} |
| 11 | 11 | ||||
| 12 | if len(commands) != len(wantCodes) { | 12 | if len(commands) != len(wantCodes) { | ||
| 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -47,6 +47,27 @@ func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | |||||
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | func TestRegistryIncludesNotificationCommands(t *testing.T) { | |||
| 51 | + | for _, tt := range []struct { | |||
| 52 | + | code string | |||
| 53 | + | slug string | |||
| 54 | + | name string | |||
| 55 | + | }{ | |||
| 56 | + | {code: "NTC", slug: "notifications", name: "Notifications"}, | |||
| 57 | + | {code: "YLN", slug: "yank-last-notification", name: "Yank last notification"}, | |||
| 58 | + | } { | |||
| 59 | + | t.Run(tt.code, func(t *testing.T) { | |||
| 60 | + | cmd, ok := ByCode(tt.code) | |||
| 61 | + | if !ok { | |||
| 62 | + | t.Fatalf("ByCode(%q) ok = false, want true", tt.code) | |||
| 63 | + | } | |||
| 64 | + | if cmd.Slug != tt.slug || cmd.Code != tt.code || cmd.Name != tt.name { | |||
| 65 | + | t.Fatalf("command = %#v, want %s/%s/%s", cmd, tt.slug, tt.code, tt.name) | |||
| 66 | + | } | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 50 | func TestByCodeIsCaseInsensitive(t *testing.T) { | 71 | func TestByCodeIsCaseInsensitive(t *testing.T) { | ||
| 51 | for _, code := range []string{"CNT", "cnt", "CnT"} { | 72 | for _, code := range []string{"CNT", "cnt", "CnT"} { | ||
| 52 | cmd, ok := ByCode(code) | 73 | cmd, ok := ByCode(code) | ||
| @@ -88,6 +109,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 88 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | 109 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | ||
| 89 | {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }}, | 110 | {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }}, | ||
| 90 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | 111 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | ||
| 112 | + | {code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }}, | |||
| 113 | + | {code: "YLN", expect: func(ctx *MockContext) { ctx.EXPECT().YankLastNotification().Return(nil).Once() }}, | |||
| 91 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | 114 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | ||
| 92 | {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }}, | 115 | {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }}, | ||
| 93 | {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }}, | 116 | {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }}, | ||
M internal/usecase/daemon/attention.go +9 -5
| @@ -42,23 +42,27 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||||
| 42 | d.pokeAttentionTicker() | 42 | d.pokeAttentionTicker() | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | - | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) { | 45 | + | // jumpAttention switches to the oldest pending attention target, local tab |
| 46 | + | // first and then another session's. Returning nil never implies a switch | |||
| 47 | + | // happened — it also covers "no target exists", which is routine and not an | |||
| 48 | + | // error. Only a failure to reach a target that does exist is a genuine error. | |||
| 49 | + | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |||
| 46 | if sess == nil || ac == nil { | 50 | if sess == nil || ac == nil { | ||
| 47 | - | return | 51 | + | return nil |
| 48 | } | 52 | } | ||
| 49 | if idx, ok := oldestAttentionTab(sess); ok { | 53 | if idx, ok := oldestAttentionTab(sess); ok { | ||
| 50 | if sess.switchTab(idx) { | 54 | if sess.switchTab(idx) { | ||
| 51 | d.activateTab(sess, sess.activeTab()) | 55 | d.activateTab(sess, sess.activeTab()) | ||
| 52 | d.invalidateRender(sess, ac, true, "attention.go") | 56 | d.invalidateRender(sess, ac, true, "attention.go") | ||
| 53 | } | 57 | } | ||
| 54 | - | return | 58 | + | return nil |
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
| 57 | target, ok := d.oldestOtherSessionAttention(sess) | 61 | target, ok := d.oldestOtherSessionAttention(sess) | ||
| 58 | if !ok { | 62 | if !ok { | ||
| 59 | - | return | 63 | + | return nil |
| 60 | } | 64 | } | ||
| 61 | - | d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | 65 | + | return d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) |
| 62 | } | 66 | } | ||
| 63 | 67 | ||||
| 64 | func oldestAttentionTab(sess *session) (int, bool) { | 68 | func oldestAttentionTab(sess *session) (int, bool) { | ||
M internal/usecase/daemon/attention_test.go +37 -2
| @@ -511,7 +511,7 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||||
| 511 | } | 511 | } | ||
| 512 | d.sessions[sess2.id] = sess2 | 512 | d.sessions[sess2.id] = sess2 | ||
| 513 | 513 | ||||
| 514 | - | d.jumpAttention(sess1, ac) | 514 | + | require.NoError(t, d.jumpAttention(sess1, ac)) |
| 515 | 515 | ||||
| 516 | require.Same(t, sess2, ac.currentSession()) | 516 | require.Same(t, sess2, ac.currentSession()) | ||
| 517 | require.Same(t, ac, sess2.client) | 517 | require.Same(t, ac, sess2.client) | ||
| @@ -526,7 +526,7 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) { | |||||
| 526 | defer releases[1]() | 526 | defer releases[1]() | ||
| 527 | sess.active = 0 | 527 | sess.active = 0 | ||
| 528 | 528 | ||||
| 529 | - | d.jumpAttention(sess, ac) | 529 | + | require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure") |
| 530 | 530 | ||||
| 531 | require.Same(t, sess, ac.currentSession()) | 531 | require.Same(t, sess, ac.currentSession()) | ||
| 532 | require.Equal(t, 0, activeTabIndex(sess)) | 532 | require.Equal(t, 0, activeTabIndex(sess)) | ||
| @@ -537,6 +537,41 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) { | |||||
| 537 | } | 537 | } | ||
| 538 | } | 538 | } | ||
| 539 | 539 | ||||
| 540 | + | // TestJumpAttentionSwitchFailureReportsNotice drives jumpAttention's | |||
| 541 | + | // cross-session path once a target has already been chosen, then makes the | |||
| 542 | + | // commit itself fail (the origin session's client no longer matches ac — | |||
| 543 | + | // simulating a client that detached between target selection and commit). | |||
| 544 | + | // Unlike the no-target case, a target existed and the switch to it failed: | |||
| 545 | + | // that is a genuine error, not a benign no-op, and must reach the user. | |||
| 546 | + | func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) { | |||
| 547 | + | p1, release1 := newBlockingPTY(t) | |||
| 548 | + | p2, release2 := newBlockingPTY(t) | |||
| 549 | + | defer release1() | |||
| 550 | + | defer release2() | |||
| 551 | + | d, sess1, ac, _ := newManualSessionWithPTYs(t, p1) | |||
| 552 | + | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |||
| 553 | + | defer cancel2() | |||
| 554 | + | tab2 := newTestTabWithContext(p2, sctx2, cancel2) | |||
| 555 | + | tab2.attention = true | |||
| 556 | + | tab2.attentionAt = time.Unix(9, 0) | |||
| 557 | + | sess2 := &session{id: "other", name: "other", ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}} | |||
| 558 | + | d.sessions[sess2.id] = sess2 | |||
| 559 | + | ||||
| 560 | + | sess1.mu.Lock() | |||
| 561 | + | sess1.client = nil | |||
| 562 | + | sess1.mu.Unlock() | |||
| 563 | + | ||||
| 564 | + | err := d.jumpAttention(sess1, ac) | |||
| 565 | + | ||||
| 566 | + | require.Error(t, err) | |||
| 567 | + | require.Same(t, sess1, ac.currentSession(), "a failed switch must leave the client on its origin session") | |||
| 568 | + | ||||
| 569 | + | d.reportError(sess1, err) | |||
| 570 | + | history := d.notices.history() | |||
| 571 | + | require.Len(t, history, 1) | |||
| 572 | + | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |||
| 573 | + | } | |||
| 574 | + | ||||
| 540 | func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) { | 575 | func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) { | ||
| 541 | d, sess, _, sends, releases := newManualTabSession(t, 2) | 576 | d, sess, _, sends, releases := newManualTabSession(t, 2) | ||
| 542 | defer releases[0]() | 577 | defer releases[0]() | ||
M internal/usecase/daemon/client.go +64 -5
| @@ -43,6 +43,10 @@ type attachedClient struct { | |||||
| 43 | resumeToken uint64 | 43 | resumeToken uint64 | ||
| 44 | parked bool | 44 | parked bool | ||
| 45 | echoAck atomic.Uint64 | 45 | echoAck atomic.Uint64 | ||
| 46 | + | // prepareFailureFallback prevents a direct fallback paint from recursively | |||
| 47 | + | // reporting the same failed prepare through its notice repaint. It is only | |||
| 48 | + | // needed while no render coordinator is installed. | |||
| 49 | + | prepareFailureFallback atomic.Bool | |||
| 46 | // pipelineCache is the last successfully emitted composition. pipelineScratch | 50 | // pipelineCache is the last successfully emitted composition. pipelineScratch | ||
| 47 | // is its attachment-owned alternate buffer; both are only touched under | 51 | // is its attachment-owned alternate buffer; both are only touched under | ||
| 48 | // sendMu and must never share mutable backing storage. | 52 | // sendMu and must never share mutable backing storage. | ||
| @@ -443,8 +447,10 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||||
| 443 | ac.setSession(sess) | 447 | ac.setSession(sess) | ||
| 444 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | 448 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | ||
| 445 | // Daemon attachment callers hold d.mu, serialising this preparation with | 449 | // Daemon attachment callers hold d.mu, serialising this preparation with | ||
| 446 | - | // other publications. Bind coordinator ownership before sess.client becomes | 450 | + | // other publications. routingMu makes the whole replacement atomic with |
| 447 | - | // visible: an old deadline can then never target this new output chain. | 451 | + | // global notice selection: a selected old client cannot become stale before |
| 452 | + | // its toast is published, and a new client is never missed after publication. | |||
| 453 | + | d.notices.routingMu.Lock() | |||
| 448 | sess.mu.Lock() | 454 | sess.mu.Lock() | ||
| 449 | old := sess.client | 455 | old := sess.client | ||
| 450 | name := sess.name | 456 | name := sess.name | ||
| @@ -453,6 +459,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||||
| 453 | sess.mu.Lock() | 459 | sess.mu.Lock() | ||
| 454 | sess.client = ac | 460 | sess.client = ac | ||
| 455 | sess.mu.Unlock() | 461 | sess.mu.Unlock() | ||
| 462 | + | d.notices.routingMu.Unlock() | |||
| 456 | d.touchMRU(sess) | 463 | d.touchMRU(sess) | ||
| 457 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | 464 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | ||
| 458 | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | 465 | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | ||
| @@ -571,7 +578,7 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc | |||||
| 571 | if !blockedRender { | 578 | if !blockedRender { | ||
| 572 | // A healthy idle client observes the required replacement notice. Its | 579 | // A healthy idle client observes the required replacement notice. Its | ||
| 573 | // watchdog still bounds a peer that stops draining after this check. | 580 | // watchdog still bounds a peer that stops draining after this check. | ||
| 574 | - | d.boundedSend(old, frameDetached(ports.ReasonDetach)) | 581 | + | d.boundedSend(old, frameDetached(ports.ReasonReplaced)) |
| 575 | } | 582 | } | ||
| 576 | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | 583 | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | ||
| 577 | d.unregisterPreview(old) | 584 | d.unregisterPreview(old) | ||
| @@ -589,6 +596,10 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc | |||||
| 589 | // emits a full redraw. Attach must not wait for the resize-idle fallback | 596 | // emits a full redraw. Attach must not wait for the resize-idle fallback | ||
| 590 | // timer. | 597 | // timer. | ||
| 591 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | 598 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | ||
| 599 | + | // Global notices raised while nothing was attached surface on this client. | |||
| 600 | + | // Drained before the early return below so a session without an active tab | |||
| 601 | + | // cannot swallow the queue. | |||
| 602 | + | d.drainPendingForFirstPaint(sess, ac) | |||
| 592 | tb := sess.activeTab() | 603 | tb := sess.activeTab() | ||
| 593 | if tb == nil { | 604 | if tb == nil { | ||
| 594 | return | 605 | return | ||
| @@ -655,6 +666,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 655 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | 666 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | ||
| 656 | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | 667 | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | ||
| 657 | } | 668 | } | ||
| 669 | + | case ports.MsgClientNotice: | |||
| 670 | + | if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil { | |||
| 671 | + | d.handleClientNotice(sess, ac, notice) | |||
| 672 | + | } else { | |||
| 673 | + | d.log.Warn("malformed client notice", "err", derr) | |||
| 674 | + | } | |||
| 658 | case ports.MsgDetach: | 675 | case ports.MsgDetach: | ||
| 659 | d.clientGone(sess, ac, tr, true) | 676 | d.clientGone(sess, ac, tr, true) | ||
| 660 | return | 677 | return | ||
| @@ -680,7 +697,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||||
| 680 | if failed != nil && !ac.currentTransportIs(failed) { | 697 | if failed != nil && !ac.currentTransportIs(failed) { | ||
| 681 | return // stale connection loop; a newer transport owns this client | 698 | return // stale connection loop; a newer transport owns this client | ||
| 682 | } | 699 | } | ||
| 683 | - | if !sess.detachIfCurrent(ac) { | 700 | + | if !d.detachIfCurrent(sess, ac) { |
| 684 | return // already displaced by a newer client; nothing to do | 701 | return // already displaced by a newer client; nothing to do | ||
| 685 | } | 702 | } | ||
| 686 | if rc := sess.renderCoordinator(); rc != nil { | 703 | if rc := sess.renderCoordinator(); rc != nil { | ||
| @@ -744,6 +761,48 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 744 | d.requestTransactionalResize(sess, ac, sz, false) | 761 | d.requestTransactionalResize(sess, ac, sz, false) | ||
| 745 | } | 762 | } | ||
| 746 | 763 | ||||
| 764 | + | // handleClientNotice maps the closed client-event enum to daemon-owned notice | |||
| 765 | + | // content. routingMu makes ownership validation and toast mutation one atomic | |||
| 766 | + | // attachment-routing operation: replacement also takes routingMu before | |||
| 767 | + | // publishing sess.client. Never retain sess.mu while touching notice or overlay | |||
| 768 | + | // state, and retain the routingMu -> sess.mu order used by attachment paths. | |||
| 769 | + | func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |||
| 770 | + | d.notices.routingMu.Lock() | |||
| 771 | + | defer d.notices.routingMu.Unlock() | |||
| 772 | + | ||||
| 773 | + | sess.mu.Lock() | |||
| 774 | + | current := sess.client == ac | |||
| 775 | + | sess.mu.Unlock() | |||
| 776 | + | if !current { | |||
| 777 | + | return | |||
| 778 | + | } | |||
| 779 | + | if d.notices.beforeClientNoticeMutation != nil { | |||
| 780 | + | d.notices.beforeClientNoticeMutation() | |||
| 781 | + | } | |||
| 782 | + | ||||
| 783 | + | switch notice.Action { | |||
| 784 | + | case ports.ClientNoticeClipboardFallback: | |||
| 785 | + | d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V") | |||
| 786 | + | case ports.ClientNoticeClipboardTooLarge: | |||
| 787 | + | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste") | |||
| 788 | + | case ports.ClientNoticeLinkDegraded: | |||
| 789 | + | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded") | |||
| 790 | + | case ports.ClientNoticeLinkConnected: | |||
| 791 | + | d.dismissToast(ac, domain.NoticeConnection, sess.id) | |||
| 792 | + | } | |||
| 793 | + | } | |||
| 794 | + | ||||
| 795 | + | func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) { | |||
| 796 | + | n := d.notices.record(domain.Notification{ | |||
| 797 | + | Code: code, | |||
| 798 | + | Severity: sev, | |||
| 799 | + | Message: message, | |||
| 800 | + | Time: d.clock.Now(), | |||
| 801 | + | SessionID: sess.id, | |||
| 802 | + | }) | |||
| 803 | + | d.showToast(ac, n) | |||
| 804 | + | } | |||
| 805 | + | ||||
| 747 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | 806 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | ||
| 748 | // returned value reports whether the synchronous request was accepted. | 807 | // returned value reports whether the synchronous request was accepted. | ||
| 749 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | 808 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | ||
| @@ -756,7 +815,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por | |||||
| 756 | if failed != nil && !ac.currentTransportIs(failed) { | 815 | if failed != nil && !ac.currentTransportIs(failed) { | ||
| 757 | return | 816 | return | ||
| 758 | } | 817 | } | ||
| 759 | - | if sess.detachIfCurrent(ac) { | 818 | + | if d.detachIfCurrent(sess, ac) { |
| 760 | if rc := sess.renderCoordinator(); rc != nil { | 819 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 761 | rc.noteDetach(ac) | 820 | rc.noteDetach(ac) | ||
| 762 | } | 821 | } | ||
M internal/usecase/daemon/config.go +45 -9
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "maps" | 6 | "maps" | ||
| 6 | "regexp" | 7 | "regexp" | ||
| @@ -40,10 +41,11 @@ func (d *Daemon) currentThemeConfig() themeConfigSnapshot { | |||||
| 40 | // ApplyConfig validates and atomically swaps daemon runtime configuration. | 41 | // ApplyConfig validates and atomically swaps daemon runtime configuration. | ||
| 41 | func (d *Daemon) ApplyConfig(cfg domain.Config) { | 42 | func (d *Daemon) ApplyConfig(cfg domain.Config) { | ||
| 42 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | 43 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | ||
| 43 | - | for _, warning := range warnings { | 44 | + | overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes) |
| 45 | + | allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...) | |||
| 46 | + | for _, warning := range allWarnings { | |||
| 44 | d.logConfigWarning(warning) | 47 | d.logConfigWarning(warning) | ||
| 45 | } | 48 | } | ||
| 46 | - | overrides := d.buildCodeOverrides(cfg.Codes) | |||
| 47 | allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot) | 49 | allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot) | ||
| 48 | d.bindings.Store(bindings) | 50 | d.bindings.Store(bindings) | ||
| 49 | d.codeOverrides.Store(&overrides) | 51 | d.codeOverrides.Store(&overrides) | ||
| @@ -80,24 +82,53 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 80 | } | 82 | } | ||
| 81 | d.reapplyThemeAllSessions() | 83 | d.reapplyThemeAllSessions() | ||
| 82 | d.repaintAllAttachedClients() | 84 | d.repaintAllAttachedClients() | ||
| 85 | + | ||||
| 86 | + | if len(allWarnings) > 0 { | |||
| 87 | + | d.notify(nil, domain.NoticeWarn, domain.NoticeConfigReload, | |||
| 88 | + | configReloadNoticeMessage(allWarnings), configWarningsError(allWarnings)) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func configReloadNoticeMessage(warnings []domain.Warning) string { | |||
| 93 | + | first := warnings[0] | |||
| 94 | + | if first.Line > 0 { | |||
| 95 | + | return fmt.Sprintf("config reloaded with %d warning(s): line %d: %s", len(warnings), first.Line, first.Msg) | |||
| 96 | + | } | |||
| 97 | + | return fmt.Sprintf("config reloaded with %d warning(s): %s", len(warnings), first.Msg) | |||
| 83 | } | 98 | } | ||
| 84 | 99 | ||||
| 85 | - | func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string { | 100 | + | // configWarningsError joins every config warning into a single error whose |
| 101 | + | // message lists them all, so noticeDetails (which renders a cause's own | |||
| 102 | + | // Error() string) surfaces every warning line in one notice's Details. | |||
| 103 | + | func configWarningsError(warnings []domain.Warning) error { | |||
| 104 | + | lines := make([]string, len(warnings)) | |||
| 105 | + | for i, w := range warnings { | |||
| 106 | + | if w.Line > 0 { | |||
| 107 | + | lines[i] = fmt.Sprintf("line %d: %s", w.Line, w.Msg) | |||
| 108 | + | } else { | |||
| 109 | + | lines[i] = w.Msg | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return errors.New(strings.Join(lines, "; ")) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) { | |||
| 86 | commands := command.Registry() | 116 | commands := command.Registry() | ||
| 87 | bySlug := make(map[string]command.Command, len(commands)) | 117 | bySlug := make(map[string]command.Command, len(commands)) | ||
| 88 | for _, cmd := range commands { | 118 | for _, cmd := range commands { | ||
| 89 | bySlug[cmd.Slug] = cmd | 119 | bySlug[cmd.Slug] = cmd | ||
| 90 | } | 120 | } | ||
| 91 | 121 | ||||
| 122 | + | var warnings []domain.Warning | |||
| 92 | desired := make(map[string]string) | 123 | desired := make(map[string]string) | ||
| 93 | for _, slug := range sortedKeys(configured) { | 124 | for _, slug := range sortedKeys(configured) { | ||
| 94 | if _, ok := bySlug[slug]; !ok { | 125 | if _, ok := bySlug[slug]; !ok { | ||
| 95 | - | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)}) | 126 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)}) |
| 96 | continue | 127 | continue | ||
| 97 | } | 128 | } | ||
| 98 | code := strings.ToUpper(strings.TrimSpace(configured[slug])) | 129 | code := strings.ToUpper(strings.TrimSpace(configured[slug])) | ||
| 99 | if !commandCodePattern.MatchString(code) { | 130 | if !commandCodePattern.MatchString(code) { | ||
| 100 | - | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)}) | 131 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)}) |
| 101 | continue | 132 | continue | ||
| 102 | } | 133 | } | ||
| 103 | desired[slug] = code | 134 | desired[slug] = code | ||
| @@ -126,10 +157,15 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||||
| 126 | } | 157 | } | ||
| 127 | 158 | ||||
| 128 | dropped := false | 159 | dropped := false | ||
| 160 | + | conflictCodes := make([]string, 0, len(claimants)) | |||
| 129 | for code, slugs := range claimants { | 161 | for code, slugs := range claimants { | ||
| 130 | - | if len(slugs) < 2 { | 162 | + | if len(slugs) > 1 { |
| 131 | - | continue | 163 | + | conflictCodes = append(conflictCodes, code) |
| 132 | } | 164 | } | ||
| 165 | + | } | |||
| 166 | + | sort.Strings(conflictCodes) | |||
| 167 | + | for _, code := range conflictCodes { | |||
| 168 | + | slugs := claimants[code] | |||
| 133 | sort.Strings(slugs) | 169 | sort.Strings(slugs) | ||
| 134 | 170 | ||||
| 135 | // If one claimant holds this code by default (no accepted | 171 | // If one claimant holds this code by default (no accepted | ||
| @@ -153,7 +189,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||||
| 153 | if _, isOverride := accepted[slug]; !isOverride { | 189 | if _, isOverride := accepted[slug]; !isOverride { | ||
| 154 | continue | 190 | continue | ||
| 155 | } | 191 | } | ||
| 156 | - | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)}) | 192 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)}) |
| 157 | delete(accepted, slug) | 193 | delete(accepted, slug) | ||
| 158 | dropped = true | 194 | dropped = true | ||
| 159 | } | 195 | } | ||
| @@ -162,7 +198,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||||
| 162 | break | 198 | break | ||
| 163 | } | 199 | } | ||
| 164 | } | 200 | } | ||
| 165 | - | return accepted | 201 | + | return accepted, warnings |
| 166 | } | 202 | } | ||
| 167 | 203 | ||||
| 168 | func sortedKeys(values map[string]string) []string { | 204 | func sortedKeys(values map[string]string) []string { | ||
M internal/usecase/daemon/config_test.go +72 -0
| @@ -520,6 +520,22 @@ func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) | |||||
| 520 | require.Equal(t, "new-session", byCode["CNT"]) | 520 | require.Equal(t, "new-session", byCode["CNT"]) | ||
| 521 | } | 521 | } | ||
| 522 | 522 | ||||
| 523 | + | func TestBuildCodeOverridesOrdersIndependentConflictWarnings(t *testing.T) { | |||
| 524 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 525 | + | ||||
| 526 | + | _, warnings := d.buildCodeOverrides(map[string]string{ | |||
| 527 | + | "new-session": "ZZ", | |||
| 528 | + | "new-tab": "ZZ", | |||
| 529 | + | "split-left": "YY", | |||
| 530 | + | "split-right": "YY", | |||
| 531 | + | }) | |||
| 532 | + | ||||
| 533 | + | require.Equal(t, []domain.Warning{ | |||
| 534 | + | {Msg: `command code "YY" for "split-right" conflicts with "split-left"`}, | |||
| 535 | + | {Msg: `command code "ZZ" for "new-tab" conflicts with "new-session"`}, | |||
| 536 | + | }, warnings) | |||
| 537 | + | } | |||
| 538 | + | ||||
| 523 | func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | 539 | func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | ||
| 524 | d := newTestDaemon(t, nil, stubClock{}) | 540 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 525 | require.NotPanics(t, func() { | 541 | require.NotPanics(t, func() { | ||
| @@ -543,3 +559,59 @@ func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | |||||
| 543 | _, ok = d.commandByEffectiveCode("TOOLONG") | 559 | _, ok = d.commandByEffectiveCode("TOOLONG") | ||
| 544 | require.False(t, ok) | 560 | require.False(t, ok) | ||
| 545 | } | 561 | } | ||
| 562 | + | ||||
| 563 | + | func TestConfigReloadNoticeMessageIncludesLineOnlyWhenKnown(t *testing.T) { | |||
| 564 | + | tests := []struct { | |||
| 565 | + | name string | |||
| 566 | + | warnings []domain.Warning | |||
| 567 | + | want string | |||
| 568 | + | }{ | |||
| 569 | + | { | |||
| 570 | + | name: "no source line", | |||
| 571 | + | warnings: []domain.Warning{{Msg: "unknown action"}}, | |||
| 572 | + | want: "config reloaded with 1 warning(s): unknown action", | |||
| 573 | + | }, | |||
| 574 | + | { | |||
| 575 | + | name: "source line", | |||
| 576 | + | warnings: []domain.Warning{{Line: 12, Msg: "unknown action"}}, | |||
| 577 | + | want: "config reloaded with 1 warning(s): line 12: unknown action", | |||
| 578 | + | }, | |||
| 579 | + | } | |||
| 580 | + | for _, tt := range tests { | |||
| 581 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 582 | + | require.Equal(t, tt.want, configReloadNoticeMessage(tt.warnings)) | |||
| 583 | + | }) | |||
| 584 | + | } | |||
| 585 | + | } | |||
| 586 | + | ||||
| 587 | + | func TestApplyConfigSurfacesOneNoticeAggregatingAllWarnings(t *testing.T) { | |||
| 588 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 589 | + | d.ApplyConfig(domain.Config{ | |||
| 590 | + | Theme: domain.ThemeAuto, | |||
| 591 | + | BindingEntries: []domain.ConfigEntry{ | |||
| 592 | + | {Key: "unknown-action", Value: "alt+x"}, | |||
| 593 | + | }, | |||
| 594 | + | Codes: map[string]string{ | |||
| 595 | + | "missing-command": "OK", | |||
| 596 | + | "new-tab": "toolong", | |||
| 597 | + | }, | |||
| 598 | + | }) | |||
| 599 | + | ||||
| 600 | + | history := d.notices.history() | |||
| 601 | + | var reloadNotices []domain.Notification | |||
| 602 | + | for _, n := range history { | |||
| 603 | + | if n.Code == domain.NoticeConfigReload { | |||
| 604 | + | reloadNotices = append(reloadNotices, n) | |||
| 605 | + | } | |||
| 606 | + | } | |||
| 607 | + | require.Len(t, reloadNotices, 1, "expected exactly one config-reload notice per ApplyConfig call") | |||
| 608 | + | ||||
| 609 | + | n := reloadNotices[0] | |||
| 610 | + | require.Equal(t, domain.NoticeWarn, n.Severity) | |||
| 611 | + | require.Equal(t, domain.SessionID(""), n.SessionID, "config reload notice must be global") | |||
| 612 | + | require.Contains(t, n.Message, "config reloaded with 3 warning(s):") | |||
| 613 | + | require.NotContains(t, n.Message, "line 0:") | |||
| 614 | + | require.Contains(t, n.Details, `unknown action "unknown-action"`) | |||
| 615 | + | require.Contains(t, n.Details, `unknown command code slug "missing-command"`) | |||
| 616 | + | require.Contains(t, n.Details, `invalid command code for "new-tab"`) | |||
| 617 | + | } | |||
M internal/usecase/daemon/copymode.go +2 -2
| @@ -327,9 +327,9 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 327 | } | 327 | } | ||
| 328 | rt.copyMu.Lock() | 328 | rt.copyMu.Lock() | ||
| 329 | if len(chunks) > 0 { | 329 | if len(chunks) > 0 { | ||
| 330 | - | rt.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard" | 330 | + | rt.statusFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard" |
| 331 | } else { | 331 | } else { | ||
| 332 | - | rt.copyFeedback = "selection too large to copy" | 332 | + | rt.statusFeedback = "selection too large to copy" |
| 333 | } | 333 | } | ||
| 334 | rt.copyMu.Unlock() | 334 | rt.copyMu.Unlock() | ||
| 335 | } | 335 | } | ||
M internal/usecase/daemon/daemon.go +9 -0
| @@ -114,6 +114,7 @@ type Daemon struct { | |||||
| 114 | persistEnabled bool | 114 | persistEnabled bool | ||
| 115 | snaps ports.SnapshotStore | 115 | snaps ports.SnapshotStore | ||
| 116 | snapsEnabled bool | 116 | snapsEnabled bool | ||
| 117 | + | noticeStore ports.NoticeStore | |||
| 117 | snapshotMarshal func(snapcodec.Session) ([]byte, error) | 118 | snapshotMarshal func(snapcodec.Session) ([]byte, error) | ||
| 118 | snapshotJobs chan *snapshotCapture | 119 | snapshotJobs chan *snapshotCapture | ||
| 119 | snapshotWorkerMu sync.Mutex | 120 | snapshotWorkerMu sync.Mutex | ||
| @@ -146,6 +147,7 @@ type Daemon struct { | |||||
| 146 | tabsConfig atomic.Pointer[domain.TabsConfig] | 147 | tabsConfig atomic.Pointer[domain.TabsConfig] | ||
| 147 | themeConfig atomic.Pointer[themeConfigSnapshot] | 148 | themeConfig atomic.Pointer[themeConfigSnapshot] | ||
| 148 | barScripts *barScriptState | 149 | barScripts *barScriptState | ||
| 150 | + | notices *noticeCenter | |||
| 149 | resumeParkGrace time.Duration | 151 | resumeParkGrace time.Duration | ||
| 150 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 152 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 151 | // (see clipboard.go); empty means use os.TempDir(). | 153 | // (see clipboard.go); empty means use os.TempDir(). | ||
| @@ -239,6 +241,12 @@ func WithSnapshotStore(store ports.SnapshotStore) Option { | |||||
| 239 | } | 241 | } | ||
| 240 | } | 242 | } | ||
| 241 | 243 | ||||
| 244 | + | // WithNoticeStore enables persisting undeliverable notices across daemon | |||
| 245 | + | // restarts. A nil store keeps the daemon in no-op notice-persistence mode. | |||
| 246 | + | func WithNoticeStore(store ports.NoticeStore) Option { | |||
| 247 | + | return func(d *Daemon) { d.noticeStore = store } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 242 | // WithCwdReader overrides the process cwd reader used for persistence tests. | 250 | // WithCwdReader overrides the process cwd reader used for persistence tests. | ||
| 243 | func WithCwdReader(fn func(int) (string, error)) Option { | 251 | func WithCwdReader(fn func(int) (string, error)) Option { | ||
| 244 | return func(d *Daemon) { | 252 | return func(d *Daemon) { | ||
| @@ -350,6 +358,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 350 | animWake: make(chan struct{}, 1), | 358 | animWake: make(chan struct{}, 1), | ||
| 351 | snapshotMarshal: snapcodec.Marshal, | 359 | snapshotMarshal: snapcodec.Marshal, | ||
| 352 | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | 360 | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | ||
| 361 | + | notices: newNoticeCenter(), | |||
| 353 | resumeParkGrace: defaultResumeParkGrace, | 362 | resumeParkGrace: defaultResumeParkGrace, | ||
| 354 | barScripts: &barScriptState{ | 363 | barScripts: &barScriptState{ | ||
| 355 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 364 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
M internal/usecase/daemon/daemon_test.go +20 -0
| @@ -105,6 +105,26 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M | |||||
| 105 | return p, unblock | 105 | return p, unblock | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | // newBlockingPTYWithFailingWrite returns a MockPTY whose Write always fails | |||
| 109 | + | // with writeErr, simulating a wedged pty (or a dead child) that cannot | |||
| 110 | + | // accept forwarded keystrokes. | |||
| 111 | + | func newBlockingPTYWithFailingWrite(t *testing.T, writeErr error) (*portsmocks.MockPTY, func()) { | |||
| 112 | + | t.Helper() | |||
| 113 | + | p := portsmocks.NewMockPTY(t) | |||
| 114 | + | release := make(chan struct{}) | |||
| 115 | + | var once sync.Once | |||
| 116 | + | unblock := func() { once.Do(func() { close(release) }) } | |||
| 117 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) { | |||
| 118 | + | <-release | |||
| 119 | + | return 0, io.EOF | |||
| 120 | + | }).Maybe() | |||
| 121 | + | p.EXPECT().Write(mock.Anything).Return(0, writeErr).Maybe() | |||
| 122 | + | p.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 123 | + | p.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Maybe() | |||
| 124 | + | p.EXPECT().Pid().Return(4242).Maybe() | |||
| 125 | + | return p, unblock | |||
| 126 | + | } | |||
| 127 | + | ||||
| 108 | // quietPTY is an independently closable PTY for background floating prewarms. | 128 | // quietPTY is an independently closable PTY for background floating prewarms. | ||
| 109 | // It intentionally has no testify expectations: tests using the factories below | 129 | // It intentionally has no testify expectations: tests using the factories below | ||
| 110 | // are asserting their regular panes, not incidental asynchronous prewarms. | 130 | // are asserting their regular panes, not incidental asynchronous prewarms. | ||
M internal/usecase/daemon/floating.go +15 -7
| @@ -2,7 +2,6 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "fmt" | |||
| 6 | "path/filepath" | 5 | "path/filepath" | ||
| 7 | "strings" | 6 | "strings" | ||
| 8 | 7 | ||||
| @@ -192,11 +191,11 @@ func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccep | |||||
| 192 | // launches asynchronously; installed slots are retained when hidden. | 191 | // launches asynchronously; installed slots are retained when hidden. | ||
| 193 | func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | 192 | func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | ||
| 194 | if d == nil || sess == nil { | 193 | if d == nil || sess == nil { | ||
| 195 | - | return fmt.Errorf("floating pane: session required") | 194 | + | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil) |
| 196 | } | 195 | } | ||
| 197 | tb := sess.activeTab() | 196 | tb := sess.activeTab() | ||
| 198 | if tb == nil { | 197 | if tb == nil { | ||
| 199 | - | return layout.ErrNotFound | 198 | + | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound) |
| 200 | } | 199 | } | ||
| 201 | cfg := d.currentFloatingConfig() | 200 | cfg := d.currentFloatingConfig() | ||
| 202 | tb.mu.Lock() | 201 | tb.mu.Lock() | ||
| @@ -293,12 +292,12 @@ type floatingLaunchSpec struct { | |||||
| 293 | func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) { | 292 | func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) { | ||
| 294 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen) | 293 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen) | ||
| 295 | if err != nil { | 294 | if err != nil { | ||
| 296 | - | d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err) | 295 | + | d.failFloatingLaunch(sess, tb, generation, userOpen, spec.sessionName, err) |
| 297 | return | 296 | return | ||
| 298 | } | 297 | } | ||
| 299 | launch, ok := sess.registerFloatingLaunch() | 298 | launch, ok := sess.registerFloatingLaunch() | ||
| 300 | if !ok { | 299 | if !ok { | ||
| 301 | - | d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, context.Canceled) | 300 | + | d.failFloatingLaunch(sess, tb, generation, userOpen, spec.sessionName, context.Canceled) |
| 302 | return | 301 | return | ||
| 303 | } | 302 | } | ||
| 304 | // Count the launch before its goroutine starts. It remains counted through | 303 | // Count the launch before its goroutine starts. It remains counted through | ||
| @@ -382,7 +381,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 382 | }() | 381 | }() | ||
| 383 | pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size) | 382 | pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size) | ||
| 384 | if err != nil { | 383 | if err != nil { | ||
| 385 | - | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | 384 | + | d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err) |
| 386 | return | 385 | return | ||
| 387 | } | 386 | } | ||
| 388 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | 387 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | ||
| @@ -403,7 +402,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 403 | d.installFloating(sess, tb, p, generation) | 402 | d.installFloating(sess, tb, p, generation) | ||
| 404 | } | 403 | } | ||
| 405 | 404 | ||||
| 406 | - | func (d *Daemon) failFloatingLaunch(tb *tab, generation uint64, userOpen bool, sessionName string, err error) { | 405 | + | func (d *Daemon) failFloatingLaunch(sess *session, tb *tab, generation uint64, userOpen bool, sessionName string, err error) { |
| 407 | tb.mu.Lock() | 406 | tb.mu.Lock() | ||
| 408 | current := tb.failFloatingLocked(generation) | 407 | current := tb.failFloatingLocked(generation) | ||
| 409 | tb.mu.Unlock() | 408 | tb.mu.Unlock() | ||
| @@ -413,6 +412,15 @@ func (d *Daemon) failFloatingLaunch(tb *tab, generation uint64, userOpen bool, s | |||||
| 413 | kind = "user-open" | 412 | kind = "user-open" | ||
| 414 | } | 413 | } | ||
| 415 | d.log.Warn("floating pty spawn failed", "err", err, "session", sessionName, "kind", kind) | 414 | d.log.Warn("floating pty spawn failed", "err", err, "session", sessionName, "kind", kind) | ||
| 415 | + | // A background prewarm is speculative: the user never asked for it, so | |||
| 416 | + | // a toast here would be confusing noise. Only a user-initiated open | |||
| 417 | + | // (the user pressed the toggle-floating key) earns a toast; if a | |||
| 418 | + | // prewarm failure matters, the user's later open attempt will fail | |||
| 419 | + | // too and surface then. | |||
| 420 | + | if userOpen { | |||
| 421 | + | d.reportError(sess, domain.UserErr(domain.NoticeFloatingSpawn, | |||
| 422 | + | "couldn't open floating pane: command failed to start", err)) | |||
| 423 | + | } | |||
| 416 | } | 424 | } | ||
| 417 | } | 425 | } | ||
| 418 | 426 | ||||
M internal/usecase/daemon/floating_test.go +88 -4
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "io" | 7 | "io" | ||
| 7 | "log/slog" | 8 | "log/slog" | ||
| 8 | "strings" | 9 | "strings" | ||
| @@ -500,11 +501,18 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) { | |||||
| 500 | type floatingLogHandler struct { | 501 | type floatingLogHandler struct { | ||
| 501 | slog.Handler | 502 | slog.Handler | ||
| 502 | handled chan struct{} | 503 | handled chan struct{} | ||
| 504 | + | mu sync.Mutex | |||
| 503 | } | 505 | } | ||
| 504 | 506 | ||||
| 505 | - | func (h floatingLogHandler) Handle(ctx context.Context, r slog.Record) error { | 507 | + | func (h *floatingLogHandler) Handle(ctx context.Context, r slog.Record) error { |
| 506 | err := h.Handler.Handle(ctx, r) | 508 | err := h.Handler.Handle(ctx, r) | ||
| 507 | - | close(h.handled) | 509 | + | h.mu.Lock() |
| 510 | + | select { | |||
| 511 | + | case <-h.handled: | |||
| 512 | + | default: | |||
| 513 | + | close(h.handled) | |||
| 514 | + | } | |||
| 515 | + | h.mu.Unlock() | |||
| 508 | return err | 516 | return err | ||
| 509 | } | 517 | } | ||
| 510 | 518 | ||||
| @@ -513,7 +521,7 @@ func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testi | |||||
| 513 | var logs bytes.Buffer | 521 | var logs bytes.Buffer | ||
| 514 | handled := make(chan struct{}) | 522 | handled := make(chan struct{}) | ||
| 515 | d := newTestDaemon(t, factory, stubClock{}) | 523 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 516 | - | d.log = slog.New(floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | 524 | + | d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) |
| 517 | tb := newFloatingTestTab(t) | 525 | tb := newFloatingTestTab(t) | ||
| 518 | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | 526 | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | ||
| 519 | d.startFloating(sess, tb, false) | 527 | d.startFloating(sess, tb, false) | ||
| @@ -551,7 +559,7 @@ func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) { | |||||
| 551 | var logs bytes.Buffer | 559 | var logs bytes.Buffer | ||
| 552 | handled := make(chan struct{}) | 560 | handled := make(chan struct{}) | ||
| 553 | d := newTestDaemon(t, factory, stubClock{}) | 561 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 554 | - | d.log = slog.New(floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | 562 | + | d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) |
| 555 | tb := newFloatingTestTab(t) | 563 | tb := newFloatingTestTab(t) | ||
| 556 | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | 564 | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | ||
| 557 | 565 | ||||
| @@ -592,6 +600,82 @@ func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) { | |||||
| 592 | d.sessWg.Wait() | 600 | d.sessWg.Wait() | ||
| 593 | } | 601 | } | ||
| 594 | 602 | ||||
| 603 | + | // TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen drives a failed | |||
| 604 | + | // asynchronous PTY open through failFloatingLaunch. A user-initiated open | |||
| 605 | + | // must surface a toast; a background prewarm must stay silent (the user | |||
| 606 | + | // never asked for it). The failure completes on a worker goroutine, so the | |||
| 607 | + | // test waits on d.sessWg (which the launch worker joins) before asserting on | |||
| 608 | + | // the test goroutine -- no require inside the worker, no sleeps. | |||
| 609 | + | func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) { | |||
| 610 | + | tests := []struct { | |||
| 611 | + | name string | |||
| 612 | + | userOpen bool | |||
| 613 | + | }{ | |||
| 614 | + | {name: "user-initiated open toasts", userOpen: true}, | |||
| 615 | + | {name: "background prewarm stays silent", userOpen: false}, | |||
| 616 | + | } | |||
| 617 | + | for _, tc := range tests { | |||
| 618 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 619 | + | spawnErr := errors.New("fork/exec: no such file or directory") | |||
| 620 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 621 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |||
| 622 | + | Return(nil, spawnErr).Once() | |||
| 623 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 624 | + | tb := newFloatingTestTab(t) | |||
| 625 | + | tr, _ := newCapturingTransport(t) | |||
| 626 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 627 | + | ac.initOverlays() | |||
| 628 | + | sess := &session{id: "floating-spawn", name: "work", tabs: []*tab{tb}, ctx: t.Context(), client: ac} | |||
| 629 | + | ac.setSession(sess) | |||
| 630 | + | ||||
| 631 | + | d.startFloating(sess, tb, tc.userOpen) | |||
| 632 | + | ||||
| 633 | + | // The launch worker (registered via d.sessWg.Go in launchFloating) | |||
| 634 | + | // runs failFloatingLaunch synchronously before returning, so | |||
| 635 | + | // waiting on the group is a deterministic completion signal. | |||
| 636 | + | d.sessWg.Wait() | |||
| 637 | + | ||||
| 638 | + | if tc.userOpen { | |||
| 639 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 640 | + | require.Equal(t, domain.NoticeFloatingSpawn, toasts[0].Code) | |||
| 641 | + | require.Equal(t, "couldn't open floating pane: command failed to start", toasts[0].Message) | |||
| 642 | + | } else { | |||
| 643 | + | ns, _ := visibleToasts(ac) | |||
| 644 | + | require.Empty(t, ns, "background prewarm failure must not toast") | |||
| 645 | + | } | |||
| 646 | + | }) | |||
| 647 | + | } | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | func TestToggleFloatingStructuralErrorsAreUserErrors(t *testing.T) { | |||
| 651 | + | tests := []struct { | |||
| 652 | + | name string | |||
| 653 | + | d *Daemon | |||
| 654 | + | sess *session | |||
| 655 | + | wantMsg string | |||
| 656 | + | cause error | |||
| 657 | + | }{ | |||
| 658 | + | {"nil session", newTestDaemon(t, nil, stubClock{}), nil, "couldn't open floating pane: no active session", nil}, | |||
| 659 | + | {"no active tab", newTestDaemon(t, nil, stubClock{}), &session{}, "couldn't open floating pane: no active tab", layout.ErrNotFound}, | |||
| 660 | + | } | |||
| 661 | + | for _, tc := range tests { | |||
| 662 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 663 | + | err := tc.d.toggleFloating(tc.sess, nil) | |||
| 664 | + | require.Error(t, err) | |||
| 665 | + | ||||
| 666 | + | var ue *domain.UserError | |||
| 667 | + | require.ErrorAs(t, err, &ue) | |||
| 668 | + | require.Equal(t, domain.NoticeFloatingSpawn, ue.Code) | |||
| 669 | + | require.Equal(t, domain.NoticeError, ue.Severity) | |||
| 670 | + | require.Equal(t, tc.wantMsg, ue.Msg) | |||
| 671 | + | if tc.cause != nil { | |||
| 672 | + | require.NotContains(t, ue.Msg, tc.cause.Error()) | |||
| 673 | + | require.ErrorIs(t, ue, tc.cause) | |||
| 674 | + | } | |||
| 675 | + | }) | |||
| 676 | + | } | |||
| 677 | + | } | |||
| 678 | + | ||||
| 595 | func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | 679 | func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | ||
| 596 | d := newTestDaemon(t, nil, stubClock{}) | 680 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 597 | tb := newFloatingTestTab(t) | 681 | tb := newFloatingTestTab(t) | ||
M internal/usecase/daemon/input.go +20 -6
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | "github.com/bnema/vev/internal/usecase/keys" | 9 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | "github.com/bnema/vev/internal/usecase/mouse" | 11 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| @@ -35,7 +36,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 35 | frameEvent := ev | 36 | frameEvent := ev | ||
| 36 | ac.initOverlays() | 37 | ac.initOverlays() | ||
| 37 | rt := ac.overlays | 38 | rt := ac.overlays | ||
| 38 | - | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() { | 39 | + | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() { |
| 39 | return | 40 | return | ||
| 40 | } | 41 | } | ||
| 41 | sess := ac.currentSession() | 42 | sess := ac.currentSession() | ||
| @@ -212,6 +213,8 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) { | |||||
| 212 | sess.mu.Unlock() | 213 | sess.mu.Unlock() | ||
| 213 | } | 214 | } | ||
| 214 | d.log.Error("pty write failed", "err", err, "session", name) | 215 | d.log.Error("pty write failed", "err", err, "session", name) | ||
| 216 | + | d.notify(sess, domain.NoticeError, domain.NoticeInputDropped, | |||
| 217 | + | "input not delivered to pane", err) | |||
| 215 | } | 218 | } | ||
| 216 | } | 219 | } | ||
| 217 | 220 | ||||
| @@ -286,19 +289,30 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 286 | case keys.ActionOpenPalette: | 289 | case keys.ActionOpenPalette: | ||
| 287 | h.d.enterPalette(sess, h.ac) | 290 | h.d.enterPalette(sess, h.ac) | ||
| 288 | case keys.ActionJumpAttention: | 291 | case keys.ActionJumpAttention: | ||
| 289 | - | h.d.jumpAttention(sess, h.ac) | 292 | + | if err := h.d.jumpAttention(sess, h.ac); err != nil { |
| 293 | + | h.d.reportError(sess, err) | |||
| 294 | + | } | |||
| 290 | case keys.ActionToggleFloatingPane: | 295 | case keys.ActionToggleFloatingPane: | ||
| 291 | if err := h.d.toggleFloating(sess, h.ac); err != nil { | 296 | if err := h.d.toggleFloating(sess, h.ac); err != nil { | ||
| 292 | h.d.log.Warn("toggle floating pane failed", "err", err) | 297 | h.d.log.Warn("toggle floating pane failed", "err", err) | ||
| 298 | + | h.d.reportError(sess, err) | |||
| 293 | } | 299 | } | ||
| 294 | case keys.ActionFocusPaneLeft: | 300 | case keys.ActionFocusPaneLeft: | ||
| 295 | - | _ = h.d.focusDir(sess, h.ac, layout.Left) | 301 | + | if err := h.d.focusDir(sess, h.ac, layout.Left); err != nil { |
| 302 | + | h.d.reportError(sess, err) | |||
| 303 | + | } | |||
| 296 | case keys.ActionFocusPaneRight: | 304 | case keys.ActionFocusPaneRight: | ||
| 297 | - | _ = h.d.focusDir(sess, h.ac, layout.Right) | 305 | + | if err := h.d.focusDir(sess, h.ac, layout.Right); err != nil { |
| 306 | + | h.d.reportError(sess, err) | |||
| 307 | + | } | |||
| 298 | case keys.ActionFocusPaneUp: | 308 | case keys.ActionFocusPaneUp: | ||
| 299 | - | _ = h.d.focusDir(sess, h.ac, layout.Up) | 309 | + | if err := h.d.focusDir(sess, h.ac, layout.Up); err != nil { |
| 310 | + | h.d.reportError(sess, err) | |||
| 311 | + | } | |||
| 300 | case keys.ActionFocusPaneDown: | 312 | case keys.ActionFocusPaneDown: | ||
| 301 | - | _ = h.d.focusDir(sess, h.ac, layout.Down) | 313 | + | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { |
| 314 | + | h.d.reportError(sess, err) | |||
| 315 | + | } | |||
| 302 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | 316 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | ||
| 303 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | 317 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | ||
| 304 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 318 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
M internal/usecase/daemon/input_test.go +120 -2
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync" | 9 | "sync" | ||
| @@ -172,6 +173,31 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||||
| 172 | releasePTY() | 173 | releasePTY() | ||
| 173 | } | 174 | } | ||
| 174 | 175 | ||||
| 176 | + | // TestWriteToPaneFailureNotifiesDroppedInput drives forwarded keystrokes into | |||
| 177 | + | // a pane whose pty.Write always fails (a wedged pty, or a dead child). The | |||
| 178 | + | // user's typing must not silently vanish: each failed write records a | |||
| 179 | + | // NoticeInputDropped notice, and repeated failures on the same code coalesce | |||
| 180 | + | // into a single toast with a rising count rather than spamming one per key. | |||
| 181 | + | func TestWriteToPaneFailureNotifiesDroppedInput(t *testing.T) { | |||
| 182 | + | writeErr := errors.New("write /dev/ptmx: input/output error") | |||
| 183 | + | p, releasePTY := newBlockingPTYWithFailingWrite(t, writeErr) | |||
| 184 | + | defer releasePTY() | |||
| 185 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 186 | + | ||||
| 187 | + | for range 5 { | |||
| 188 | + | d.handleInput(sess, ac, []byte("x")) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 192 | + | require.Equal(t, domain.NoticeInputDropped, toasts[0].Code) | |||
| 193 | + | require.Equal(t, domain.NoticeError, toasts[0].Severity) | |||
| 194 | + | require.Equal(t, "input not delivered to pane", toasts[0].Message) | |||
| 195 | + | require.Equal(t, 5, toasts[0].Count, "5 failed writes must coalesce into one toast, not one per keystroke") | |||
| 196 | + | ||||
| 197 | + | hist := d.notices.history() | |||
| 198 | + | require.Len(t, hist, 5, "coalescing is display-only; history keeps every occurrence") | |||
| 199 | + | } | |||
| 200 | + | ||||
| 175 | func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | 201 | func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | ||
| 176 | normal, releaseNormal := newBlockingPTY(t) | 202 | normal, releaseNormal := newBlockingPTY(t) | ||
| 177 | floatingPTY, releaseFloating := newBlockingPTY(t) | 203 | floatingPTY, releaseFloating := newBlockingPTY(t) | ||
| @@ -329,6 +355,38 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |||||
| 329 | requirePTYWrite(t, floatingWrites, []byte("x")) | 355 | requirePTYWrite(t, floatingWrites, []byte("x")) | ||
| 330 | } | 356 | } | ||
| 331 | 357 | ||||
| 358 | + | // TestActionFocusPaneAtEdgeStaysSilent drives a directional focus move with | |||
| 359 | + | // no pane on that side (the session has a single, unsplit pane). This is | |||
| 360 | + | // routine navigation, not a failure, and must never produce a notice. | |||
| 361 | + | func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) { | |||
| 362 | + | d, _, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 363 | + | ||||
| 364 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft) | |||
| 365 | + | ||||
| 366 | + | require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent") | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | // TestActionFocusPaneGenuineErrorReportsNoticeInternal drives a directional | |||
| 370 | + | // focus move against a tab with no layout tree at all — a genuine structural | |||
| 371 | + | // error distinct from "no pane in that direction" — and asserts it reaches | |||
| 372 | + | // the user as a notice instead of being silently discarded. | |||
| 373 | + | func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) { | |||
| 374 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 375 | + | tb := sess.activeTab() | |||
| 376 | + | tb.mu.Lock() | |||
| 377 | + | tb.tree = nil | |||
| 378 | + | tb.mu.Unlock() | |||
| 379 | + | ||||
| 380 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft) | |||
| 381 | + | ||||
| 382 | + | history := d.notices.history() | |||
| 383 | + | require.Len(t, history, 2, "the layout error and its failed direct display update must both surface") | |||
| 384 | + | require.Equal(t, domain.NoticeInternal, history[0].Code) | |||
| 385 | + | require.Equal(t, "display update failed", history[0].Message) | |||
| 386 | + | require.Equal(t, domain.NoticeInternal, history[1].Code) | |||
| 387 | + | require.Equal(t, "internal error", history[1].Message) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 332 | func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) { | 390 | func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) { | ||
| 333 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | 391 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 334 | first := sess.activeTab() | 392 | first := sess.activeTab() | ||
| @@ -560,7 +618,7 @@ func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) { | |||||
| 560 | recent := d.sessions[domain.SessionID("recent")] | 618 | recent := d.sessions[domain.SessionID("recent")] | ||
| 561 | 619 | ||||
| 562 | // Picker-style successful transition records its origin. | 620 | // Picker-style successful transition records its origin. | ||
| 563 | - | d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}) | 621 | + | require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})) |
| 564 | for _, sess := range []*session{current, recent, current} { | 622 | for _, sess := range []*session{current, recent, current} { | ||
| 565 | runPaletteCommand(t, d, ac.currentSession(), ac, "BSK") | 623 | runPaletteCommand(t, d, ac.currentSession(), ac, "BSK") | ||
| 566 | require.Same(t, sess, ac.currentSession()) | 624 | require.Same(t, sess, ac.currentSession()) | ||
| @@ -590,6 +648,27 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) { | |||||
| 590 | } | 648 | } | ||
| 591 | } | 649 | } | ||
| 592 | 650 | ||||
| 651 | + | func TestBackSessionReportsStaleHandoffOnce(t *testing.T) { | |||
| 652 | + | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |||
| 653 | + | defer releaseAll(releases) | |||
| 654 | + | target := d.sessions[domain.SessionID("recent")] | |||
| 655 | + | ac.previousSession.Set(target) | |||
| 656 | + | ||||
| 657 | + | // A displaced attachment makes the previously valid handoff stale by the | |||
| 658 | + | // time switchToTarget commits it. | |||
| 659 | + | current.mu.Lock() | |||
| 660 | + | current.client = nil | |||
| 661 | + | current.mu.Unlock() | |||
| 662 | + | ||||
| 663 | + | d.backSession(current, ac) | |||
| 664 | + | ||||
| 665 | + | require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin") | |||
| 666 | + | require.Same(t, target, ac.previousSession.Get(), "a failed handoff remains retryable") | |||
| 667 | + | history := d.notices.history() | |||
| 668 | + | require.Len(t, history, 1, "the switch failure must be reported exactly once") | |||
| 669 | + | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |||
| 670 | + | } | |||
| 671 | + | ||||
| 593 | func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) { | 672 | func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) { | ||
| 594 | ac := &attachedClient{} | 673 | ac := &attachedClient{} | ||
| 595 | stale := &session{id: "stale"} | 674 | stale := &session{id: "stale"} | ||
| @@ -688,7 +767,11 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | |||||
| 688 | defer releaseAll(releases) | 767 | defer releaseAll(releases) | ||
| 689 | recent := d.sessions[domain.SessionID("recent")] | 768 | recent := d.sessions[domain.SessionID("recent")] | ||
| 690 | 769 | ||||
| 691 | - | d.switchToTarget(current, ac, tc.target(current, recent)) | 770 | + | if tc.name == "missing target does not record" { |
| 771 | + | require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent))) | |||
| 772 | + | } else { | |||
| 773 | + | require.NoError(t, d.switchToTarget(current, ac, tc.target(current, recent))) | |||
| 774 | + | } | |||
| 692 | require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id) | 775 | require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id) | ||
| 693 | if tc.wantPrev == "" { | 776 | if tc.wantPrev == "" { | ||
| 694 | require.Nil(t, ac.previousSession.Get()) | 777 | require.Nil(t, ac.previousSession.Get()) | ||
| @@ -1444,6 +1527,41 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) { | |||||
| 1444 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | 1527 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | ||
| 1445 | } | 1528 | } | ||
| 1446 | 1529 | ||||
| 1530 | + | // TestMouseGatedWhileNoticesOverlayActive guards against a click on the | |||
| 1531 | + | // notifications modal falling through to pane hit-testing underneath it: the | |||
| 1532 | + | // modal has no mouse handler of its own, so without the gate a click would | |||
| 1533 | + | // silently retarget focus and forward SGR bytes to whichever pane sits under | |||
| 1534 | + | // the modal's coordinates. | |||
| 1535 | + | func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) { | |||
| 1536 | + | p1 := portsmocks.NewMockPTY(t) | |||
| 1537 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |||
| 1538 | + | p2 := portsmocks.NewMockPTY(t) | |||
| 1539 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |||
| 1540 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | |||
| 1541 | + | d.procComm = nil | |||
| 1542 | + | tb := sess.activeTab() | |||
| 1543 | + | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5}) | |||
| 1544 | + | p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 1545 | + | tb.mu.Lock() | |||
| 1546 | + | tb.size = domain.Size{Cols: 41, Rows: 5} | |||
| 1547 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 1548 | + | tb.tree.Focus = "pane-1" | |||
| 1549 | + | tb.panes["pane-2"] = p2pane | |||
| 1550 | + | tb.mu.Unlock() | |||
| 1551 | + | ||||
| 1552 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |||
| 1553 | + | d.enterNotices(sess, ac) | |||
| 1554 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1555 | + | require.True(t, ac.overlays.noticesActive()) | |||
| 1556 | + | ||||
| 1557 | + | // Same coordinates that TestMouseHitTestFocusesPaneAndTranslatesSGRColumns | |||
| 1558 | + | // proves would otherwise focus pane-2 and forward the SGR report to it. | |||
| 1559 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |||
| 1560 | + | ||||
| 1561 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus, "mouse must not reach pane hit-testing while the notices overlay is open") | |||
| 1562 | + | require.True(t, ac.overlays.noticesActive(), "mouse click must not close the notices overlay") | |||
| 1563 | + | } | |||
| 1564 | + | ||||
| 1447 | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | 1565 | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | ||
| 1448 | p1 := portsmocks.NewMockPTY(t) | 1566 | p1 := portsmocks.NewMockPTY(t) | ||
| 1449 | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | 1567 | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | ||
A internal/usecase/daemon/notice_persist_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/mock" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 14 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the | |||
| 18 | + | // daemon is shutting down and the snapshot worker is saturated — so there is | |||
| 19 | + | // no way to retain the session's terminal state and no attached client to | |||
| 20 | + | // toast, because the daemon is dying — the failure is handed to the | |||
| 21 | + | // NoticeStore instead of being silently lost. | |||
| 22 | + | func TestKillSessionServerShutdownSaturatedAppendsNotice(t *testing.T) { | |||
| 23 | + | store := discardSnapshotStore{} | |||
| 24 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 25 | + | WithSnapshotStore(store)(d) | |||
| 26 | + | startSnapshotEncodeWorker(t, d) | |||
| 27 | + | ||||
| 28 | + | entered := make(chan struct{}) | |||
| 29 | + | release := make(chan struct{}) | |||
| 30 | + | defer close(release) | |||
| 31 | + | var calls atomic.Int32 | |||
| 32 | + | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 33 | + | if calls.Add(1) == 1 { | |||
| 34 | + | close(entered) | |||
| 35 | + | <-release | |||
| 36 | + | } | |||
| 37 | + | return snapcodec.Marshal(s) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | active := newSnapshotTestSession(t, "active", false, "/work") | |||
| 41 | + | queued := newSnapshotTestSession(t, "queued", false, "/work") | |||
| 42 | + | markSnapshotDirty(active) | |||
| 43 | + | markSnapshotDirty(queued) | |||
| 44 | + | require.True(t, d.scheduleSnapshot(active)) | |||
| 45 | + | <-entered // the worker is now parked inside marshal for active | |||
| 46 | + | require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue") | |||
| 47 | + | for i := range snapshotFinalQueueCapacity { | |||
| 48 | + | s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |||
| 49 | + | markSnapshotDirty(s) | |||
| 50 | + | require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | target := newSnapshotTestSession(t, "target", false, "/work") | |||
| 54 | + | targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 55 | + | require.True(t, ok, "target pane PTY must be a MockPTY") | |||
| 56 | + | targetPTY.EXPECT().Close().Return(nil).Maybe() | |||
| 57 | + | d.mu.Lock() | |||
| 58 | + | d.sessions[target.id] = target | |||
| 59 | + | d.mu.Unlock() | |||
| 60 | + | markSnapshotDirty(target) | |||
| 61 | + | ||||
| 62 | + | notices := portsmocks.NewMockNoticeStore(t) | |||
| 63 | + | var appended domain.Notification | |||
| 64 | + | notices.EXPECT().Append(mock.Anything).RunAndReturn(func(n domain.Notification) error { | |||
| 65 | + | appended = n | |||
| 66 | + | return nil | |||
| 67 | + | }).Once() | |||
| 68 | + | d.noticeStore = notices | |||
| 69 | + | ||||
| 70 | + | err := d.killSession(target, ports.ReasonServerShutdown, false) | |||
| 71 | + | require.ErrorContains(t, err, "snapshot worker unavailable or saturated") | |||
| 72 | + | ||||
| 73 | + | d.mu.Lock() | |||
| 74 | + | _, stillPresent := d.sessions[target.id] | |||
| 75 | + | d.mu.Unlock() | |||
| 76 | + | require.False(t, stillPresent, "the session must be torn down even though its state could not be saved") | |||
| 77 | + | ||||
| 78 | + | require.Equal(t, domain.NoticeSnapshotWrite, appended.Code) | |||
| 79 | + | require.Equal(t, domain.NoticeError, appended.Severity) | |||
| 80 | + | require.Equal(t, "session target shut down without saving terminal state", appended.Message) | |||
| 81 | + | require.NotEmpty(t, appended.Details) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // TestKillSessionServerShutdownNotSaturatedSkipsAppend proves the notice store | |||
| 85 | + | // is left untouched when the final snapshot is retained normally. | |||
| 86 | + | func TestKillSessionServerShutdownNotSaturatedSkipsAppend(t *testing.T) { | |||
| 87 | + | store := discardSnapshotStore{} | |||
| 88 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 89 | + | WithSnapshotStore(store)(d) | |||
| 90 | + | startSnapshotEncodeWorker(t, d) | |||
| 91 | + | ||||
| 92 | + | notices := portsmocks.NewMockNoticeStore(t) // no .EXPECT(): Append must not be called | |||
| 93 | + | d.noticeStore = notices | |||
| 94 | + | ||||
| 95 | + | target := newSnapshotTestSession(t, "target", false, "/work") | |||
| 96 | + | targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 97 | + | require.True(t, ok, "target pane PTY must be a MockPTY") | |||
| 98 | + | targetPTY.EXPECT().Close().Return(nil).Maybe() | |||
| 99 | + | d.mu.Lock() | |||
| 100 | + | d.sessions[target.id] = target | |||
| 101 | + | d.mu.Unlock() | |||
| 102 | + | markSnapshotDirty(target) | |||
| 103 | + | ||||
| 104 | + | err := d.killSession(target, ports.ReasonServerShutdown, false) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by | |||
| 109 | + | // a previous daemon are recorded to history and toasted to the first client | |||
| 110 | + | // that attaches after this daemon starts before their claim is acknowledged. | |||
| 111 | + | func TestStartupClaimRecordsAndToastsPendingNotices(t *testing.T) { | |||
| 112 | + | claimed := []domain.Notification{ | |||
| 113 | + | {Code: domain.NoticeSnapshotWrite, Severity: domain.NoticeError, Message: "session a shut down without saving terminal state"}, | |||
| 114 | + | {Code: domain.NoticeSnapshotWrite, Severity: domain.NoticeError, Message: "session b shut down without saving terminal state"}, | |||
| 115 | + | } | |||
| 116 | + | notices := portsmocks.NewMockNoticeStore(t) | |||
| 117 | + | notices.EXPECT().Claim().Return(claimed, nil).Once() | |||
| 118 | + | notices.EXPECT().Ack().Return(nil).Once() | |||
| 119 | + | ||||
| 120 | + | p, releasePTY := newBlockingPTY(t) | |||
| 121 | + | defer releasePTY() | |||
| 122 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 123 | + | d.noticeStore = notices | |||
| 124 | + | ||||
| 125 | + | d.restoreSnapshots(t.Context()) | |||
| 126 | + | ||||
| 127 | + | require.Len(t, d.notices.history(), 2, "each drained notice is recorded individually") | |||
| 128 | + | ||||
| 129 | + | tr, _ := newCapturingTransport(t) | |||
| 130 | + | sess, ac, err := d.route(ports.Hello{ | |||
| 131 | + | Version: ports.ProtocolVersion, | |||
| 132 | + | Intent: ports.IntentEphemeral, | |||
| 133 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 134 | + | }, tr) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | d.firstPaint(sess, ac, ac.size) | |||
| 137 | + | ||||
| 138 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 139 | + | require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code) | |||
| 140 | + | require.Equal(t, 2, toasts[0].Count, "two same-code drained notices dedup into one toast counted twice") | |||
| 141 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 142 | + | } | |||
A internal/usecase/daemon/notice_render_test.go +108 -0
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | "github.com/bnema/vev/pkg/vt" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes | |||
| 16 | + | // into screen, growing its shadow the same way a real client would. | |||
| 17 | + | func replayOutputFrames(t *testing.T, screen *vt.Screen, frames []ports.Frame) { | |||
| 18 | + | t.Helper() | |||
| 19 | + | for _, f := range frames { | |||
| 20 | + | require.Equal(t, ports.MsgOutput, f.Type) | |||
| 21 | + | out, err := ports.UnmarshalOutput(f.Payload) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | screen.Write(out.Data) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // drainAllFrames collects every frame already queued on sends without blocking. | |||
| 28 | + | func drainAllFrames(sends chan ports.Frame) []ports.Frame { | |||
| 29 | + | var out []ports.Frame | |||
| 30 | + | for { | |||
| 31 | + | select { | |||
| 32 | + | case f := <-sends: | |||
| 33 | + | out = append(out, f) | |||
| 34 | + | default: | |||
| 35 | + | return out | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // runeColumn returns the rune-indexed column at which text first occurs in | |||
| 41 | + | // row, or -1. Rows are exactly one rune per terminal column, so this must not | |||
| 42 | + | // use strings.Index: box-drawing borders are multi-byte and would inflate a | |||
| 43 | + | // byte offset past the real column. | |||
| 44 | + | func runeColumn(row, text string) int { | |||
| 45 | + | runes, sub := []rune(row), []rune(text) | |||
| 46 | + | for i := 0; i+len(sub) <= len(runes); i++ { | |||
| 47 | + | match := true | |||
| 48 | + | for j, r := range sub { | |||
| 49 | + | if runes[i+j] != r { | |||
| 50 | + | match = false | |||
| 51 | + | break | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | if match { | |||
| 55 | + | return i | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | return -1 | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // containsTopRight reports whether text appears anywhere at or past minCol in | |||
| 62 | + | // the frame's top half. | |||
| 63 | + | func containsTopRight(rows []string, text string, minCol int) bool { | |||
| 64 | + | for y, row := range rows { | |||
| 65 | + | if y > len(rows)/2 { | |||
| 66 | + | break | |||
| 67 | + | } | |||
| 68 | + | if runeColumn(row, text) >= minCol { | |||
| 69 | + | return true | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // TestPaintComposesNoticeToastTopRightAndExpiresOnTTL drives an actual | |||
| 76 | + | // notify -> showToast -> paint -> emit round trip and decodes the resulting | |||
| 77 | + | // wire bytes through a terminal emulator, the same way a real client would, | |||
| 78 | + | // so this proves the composed frame (not just internal toast state) carries | |||
| 79 | + | // the message top-right, and that it disappears once the fake clock crosses | |||
| 80 | + | // the toast's TTL. | |||
| 81 | + | func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) { | |||
| 82 | + | clk := newNoticeClock() | |||
| 83 | + | d, sess, ac, sends := newNoticeFixture(t, clk) | |||
| 84 | + | screen := vt.NewScreen(ac.size.Cols, ac.size.Rows) | |||
| 85 | + | ||||
| 86 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil) | |||
| 87 | + | awaitToastCount(t, ac, 1) | |||
| 88 | + | replayOutputFrames(t, screen, drainAllFrames(sends)) | |||
| 89 | + | ||||
| 90 | + | rows := frameRows(screen.Frame) | |||
| 91 | + | if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) { | |||
| 92 | + | t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n")) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | clk.advance(noticeTTL(domain.NoticeError)) | |||
| 96 | + | awaitToastCount(t, ac, 0) | |||
| 97 | + | select { | |||
| 98 | + | case f := <-sends: | |||
| 99 | + | replayOutputFrames(t, screen, append([]ports.Frame{f}, drainAllFrames(sends)...)) | |||
| 100 | + | case <-time.After(2 * time.Second): | |||
| 101 | + | t.Fatal("expiry did not invalidate the render") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | rows = frameRows(screen.Frame) | |||
| 105 | + | if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) { | |||
| 106 | + | t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n")) | |||
| 107 | + | } | |||
| 108 | + | } | |||
A internal/usecase/daemon/notices_overlay.go +278 -0
| @@ -0,0 +1,278 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 12 | + | "github.com/bnema/vev/internal/usecase/notices" | |||
| 13 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight: 8, Title: " Notifications ", Anchor: domain.AnchorCenter} | |||
| 17 | + | ||||
| 18 | + | // enterNotices opens the notification history overlay from the daemon's full | |||
| 19 | + | // notice center history, newest first. | |||
| 20 | + | func (d *Daemon) enterNotices(sess *session, ac *attachedClient) { | |||
| 21 | + | history := d.notices.history() | |||
| 22 | + | now := d.clock.Now() | |||
| 23 | + | ac.overlays.noticeMu.Lock() | |||
| 24 | + | ac.overlays.noticesOverlay = notices.New(history, now) | |||
| 25 | + | ac.overlays.noticesPending = nil | |||
| 26 | + | ac.overlays.noticeMu.Unlock() | |||
| 27 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 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)...) | |||
| 58 | + | combined = append(combined, data...) | |||
| 59 | + | data = combined | |||
| 60 | + | *state.pending = nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | var result listInputResult | |||
| 64 | + | for i := 0; i < len(data); i++ { | |||
| 65 | + | switch data[i] { | |||
| 66 | + | case 'j': | |||
| 67 | + | state.moveDown() | |||
| 68 | + | result.changed = true | |||
| 69 | + | case 'k': | |||
| 70 | + | state.moveUp() | |||
| 71 | + | result.changed = true | |||
| 72 | + | case 'q', 0x03: | |||
| 73 | + | result.exit = true | |||
| 74 | + | return result | |||
| 75 | + | case keys.ESC: | |||
| 76 | + | tail := data[i:] | |||
| 77 | + | if consumed, move := routeListEscape(tail); consumed > 0 { | |||
| 78 | + | switch move { | |||
| 79 | + | case 'A': | |||
| 80 | + | state.moveUp() | |||
| 81 | + | result.changed = true | |||
| 82 | + | case 'B': | |||
| 83 | + | state.moveDown() | |||
| 84 | + | result.changed = true | |||
| 85 | + | } | |||
| 86 | + | i += consumed - 1 | |||
| 87 | + | continue | |||
| 88 | + | } | |||
| 89 | + | if len(tail) == 1 { | |||
| 90 | + | retainListESCLocked(clock, state) | |||
| 91 | + | break | |||
| 92 | + | } | |||
| 93 | + | if isListEscapePrefix(tail) { | |||
| 94 | + | *state.pending = append((*state.pending)[:0], tail...) | |||
| 95 | + | i += len(tail) - 1 | |||
| 96 | + | continue | |||
| 97 | + | } | |||
| 98 | + | result.exit = true | |||
| 99 | + | return result | |||
| 100 | + | default: | |||
| 101 | + | custom := action(data[i]) | |||
| 102 | + | if custom.action != 0 { | |||
| 103 | + | result.action = custom.action | |||
| 104 | + | } | |||
| 105 | + | if custom.exit { | |||
| 106 | + | result.exit = true | |||
| 107 | + | } | |||
| 108 | + | if custom.stop { | |||
| 109 | + | result.stop = true | |||
| 110 | + | return result | |||
| 111 | + | } | |||
| 112 | + | if custom.exit { | |||
| 113 | + | return result | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | return result | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // routeListEscape consumes one complete CSI or SS3 sequence. It returns its | |||
| 121 | + | // byte length and the simple arrow final when it is an Up/Down sequence. Other | |||
| 122 | + | // complete terminal sequences are deliberately ignored so their bytes cannot | |||
| 123 | + | // trigger list actions or close the overlay. | |||
| 124 | + | func routeListEscape(data []byte) (consumed int, move byte) { | |||
| 125 | + | if len(data) < 2 || data[0] != keys.ESC || (data[1] != '[' && data[1] != 'O') { | |||
| 126 | + | return 0, 0 | |||
| 127 | + | } | |||
| 128 | + | for i := 2; i < len(data); i++ { | |||
| 129 | + | if data[i] < 0x40 || data[i] > 0x7e { | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | if i == 2 && (data[i] == 'A' || data[i] == 'B') { | |||
| 133 | + | return i + 1, data[i] | |||
| 134 | + | } | |||
| 135 | + | return i + 1, 0 | |||
| 136 | + | } | |||
| 137 | + | return 0, 0 | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func isListEscapePrefix(data []byte) bool { | |||
| 141 | + | return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O') | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func retainListESCLocked(clock ports.Clock, state listInputState) { | |||
| 145 | + | *state.pending = append((*state.pending)[:0], keys.ESC) | |||
| 146 | + | state.esc.retain(clock, keys.ESCDelay, func(timer ports.Timer) { | |||
| 147 | + | state.lock() | |||
| 148 | + | if state.esc.timer != timer || len(*state.pending) != 1 || (*state.pending)[0] != keys.ESC || !state.active() { | |||
| 149 | + | state.unlock() | |||
| 150 | + | return | |||
| 151 | + | } | |||
| 152 | + | *state.pending = nil | |||
| 153 | + | state.esc.timer = nil | |||
| 154 | + | state.esc.done = nil | |||
| 155 | + | state.closeLocked() | |||
| 156 | + | state.unlock() | |||
| 157 | + | state.afterClose() | |||
| 158 | + | }) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState { | |||
| 162 | + | rt := ac.overlays | |||
| 163 | + | return listInputState{ | |||
| 164 | + | pending: &rt.noticesPending, | |||
| 165 | + | esc: &rt.noticesESC, | |||
| 166 | + | moveUp: rt.noticesOverlay.Up, | |||
| 167 | + | moveDown: rt.noticesOverlay.Down, | |||
| 168 | + | lock: rt.noticeMu.Lock, | |||
| 169 | + | unlock: rt.noticeMu.Unlock, | |||
| 170 | + | active: func() bool { return rt.noticesOverlay != nil }, | |||
| 171 | + | closeLocked: func() { | |||
| 172 | + | rt.noticesOverlay = nil | |||
| 173 | + | }, | |||
| 174 | + | afterClose: func() { | |||
| 175 | + | if sess := ac.currentSession(); sess != nil { | |||
| 176 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |||
| 177 | + | } | |||
| 178 | + | }, | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||
| 183 | + | sess := ac.currentSession() | |||
| 184 | + | if sess == nil { | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | rt := ac.overlays | |||
| 188 | + | rt.noticeMu.Lock() | |||
| 189 | + | if rt.noticesOverlay == nil { | |||
| 190 | + | rt.noticesPending = nil | |||
| 191 | + | rt.noticesESC.stop() | |||
| 192 | + | rt.noticeMu.Unlock() | |||
| 193 | + | return | |||
| 194 | + | } | |||
| 195 | + | var yankTarget domain.Notification | |||
| 196 | + | var yank bool | |||
| 197 | + | result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult { | |||
| 198 | + | if b == 'y' { | |||
| 199 | + | yankTarget, yank = rt.noticesOverlay.Selected() | |||
| 200 | + | return listInputResult{action: b} | |||
| 201 | + | } | |||
| 202 | + | return listInputResult{} | |||
| 203 | + | }) | |||
| 204 | + | rt.noticeMu.Unlock() | |||
| 205 | + | ||||
| 206 | + | if result.exit { | |||
| 207 | + | d.closeNotices(ac) | |||
| 208 | + | } | |||
| 209 | + | if yank { | |||
| 210 | + | // yankNotice sends over the wire and repaints itself, so it must run | |||
| 211 | + | // with noticeMu released; a quick yank doesn't close the overlay the | |||
| 212 | + | // way copy mode's own 'y' commits and exits. | |||
| 213 | + | d.yankNotice(sess, ac, yankTarget) | |||
| 214 | + | } | |||
| 215 | + | if result.exit || result.changed { | |||
| 216 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // yankNotice copies n's formatted details to the client's clipboard via | |||
| 221 | + | // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput): | |||
| 222 | + | // send each chunk, then leave a one-shot status-bar confirmation that the | |||
| 223 | + | // next repaint clears. | |||
| 224 | + | func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notification) { | |||
| 225 | + | chunks := scopy.OSC52(noticeYankPayload(n)) | |||
| 226 | + | for _, chunk := range chunks { | |||
| 227 | + | failed, err := d.boundedSendOutputErrTransport(ac, chunk) | |||
| 228 | + | if err != nil { | |||
| 229 | + | d.detachOnSendError(sess, ac, failed) | |||
| 230 | + | return | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ac.overlays.copyMu.Lock() | |||
| 234 | + | if len(chunks) > 0 { | |||
| 235 | + | ac.overlays.statusFeedback = "copied notification details" | |||
| 236 | + | } else { | |||
| 237 | + | ac.overlays.statusFeedback = "notification too large to copy" | |||
| 238 | + | } | |||
| 239 | + | ac.overlays.copyMu.Unlock() | |||
| 240 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // noticeYankPayload formats n the way the yank commands put it on the | |||
| 244 | + | // clipboard: a header line with timestamp, severity, code slug, and coalesce | |||
| 245 | + | // count, then the message, then the full cause chain when present. | |||
| 246 | + | func noticeYankPayload(n domain.Notification) string { | |||
| 247 | + | b := &strings.Builder{} | |||
| 248 | + | fmt.Fprintf(b, "[%s] %s %s", n.Time.Format(time.RFC3339), severityWord(n.Severity), n.Code.String()) | |||
| 249 | + | if n.Count > 1 { | |||
| 250 | + | fmt.Fprintf(b, " ×%d", n.Count) | |||
| 251 | + | } | |||
| 252 | + | fmt.Fprintf(b, "\n%s\n", n.Message) | |||
| 253 | + | if n.Details != "" { | |||
| 254 | + | fmt.Fprintf(b, "details: %s\n", n.Details) | |||
| 255 | + | } | |||
| 256 | + | return b.String() | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // severityWord renders sev as the word used in yank payloads, distinct from | |||
| 260 | + | // the single-letter form the history list draws (notices.severityLetter). | |||
| 261 | + | func severityWord(sev domain.NoticeSeverity) string { | |||
| 262 | + | switch sev { | |||
| 263 | + | case domain.NoticeWarn: | |||
| 264 | + | return "warn" | |||
| 265 | + | case domain.NoticeError: | |||
| 266 | + | return "error" | |||
| 267 | + | default: | |||
| 268 | + | return "info" | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func (d *Daemon) closeNotices(ac *attachedClient) { | |||
| 273 | + | ac.overlays.noticeMu.Lock() | |||
| 274 | + | ac.overlays.noticesOverlay = nil | |||
| 275 | + | ac.overlays.noticesPending = nil | |||
| 276 | + | ac.overlays.noticesESC.stop() | |||
| 277 | + | ac.overlays.noticeMu.Unlock() | |||
| 278 | + | } | |||
A internal/usecase/daemon/notices_overlay_test.go +485 -0
| @@ -0,0 +1,485 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/base64" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestNoticesOpenViaPaletteCommandShowsModalWithHistory(t *testing.T) { | |||
| 17 | + | p, release := newBlockingPTY(t) | |||
| 18 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 19 | + | defer release() | |||
| 20 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Time: time.Unix(1, 0)}) | |||
| 21 | + | ||||
| 22 | + | d.enterPalette(sess, ac) | |||
| 23 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 24 | + | d.handlePaletteInput(ac, []byte("NTC\r")) | |||
| 25 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 26 | + | ||||
| 27 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 28 | + | require.NotNil(t, ac.overlays.noticesOverlay) | |||
| 29 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | require.Contains(t, string(msg.Data), "Notifications") | |||
| 32 | + | require.Contains(t, string(msg.Data), "pane-spawn") | |||
| 33 | + | require.Contains(t, string(msg.Data), "couldn't open pane") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestNoticesJKNavigatesSelection(t *testing.T) { | |||
| 37 | + | p, release := newBlockingPTY(t) | |||
| 38 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 39 | + | defer release() | |||
| 40 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)}) | |||
| 41 | + | d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)}) | |||
| 42 | + | d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)}) | |||
| 43 | + | ||||
| 44 | + | d.enterNotices(sess, ac) | |||
| 45 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 46 | + | selected, ok := ac.overlays.noticesOverlay.Selected() | |||
| 47 | + | require.True(t, ok) | |||
| 48 | + | require.Equal(t, "third", selected.Message) | |||
| 49 | + | ||||
| 50 | + | d.handleInput(sess, ac, []byte("j")) | |||
| 51 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 52 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |||
| 53 | + | require.True(t, ok) | |||
| 54 | + | require.Equal(t, "second", selected.Message) | |||
| 55 | + | ||||
| 56 | + | d.handleInput(sess, ac, []byte("j")) | |||
| 57 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 58 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |||
| 59 | + | require.True(t, ok) | |||
| 60 | + | require.Equal(t, "first", selected.Message) | |||
| 61 | + | ||||
| 62 | + | // One more 'j' at the bottom must clamp rather than wrap. | |||
| 63 | + | d.handleInput(sess, ac, []byte("j")) | |||
| 64 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 65 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |||
| 66 | + | require.True(t, ok) | |||
| 67 | + | require.Equal(t, "first", selected.Message) | |||
| 68 | + | ||||
| 69 | + | d.handleInput(sess, ac, []byte("k")) | |||
| 70 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 71 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |||
| 72 | + | require.True(t, ok) | |||
| 73 | + | require.Equal(t, "second", selected.Message) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestNoticesQAndCtrlCCloseImmediatelyAndClearState(t *testing.T) { | |||
| 77 | + | for _, key := range []byte{'q', 0x03} { | |||
| 78 | + | t.Run(fmt.Sprintf("key=%q", key), func(t *testing.T) { | |||
| 79 | + | p, release := newBlockingPTY(t) | |||
| 80 | + | defer release() | |||
| 81 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 82 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |||
| 83 | + | ||||
| 84 | + | d.enterNotices(sess, ac) | |||
| 85 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 86 | + | require.True(t, ac.overlays.noticesActive()) | |||
| 87 | + | ||||
| 88 | + | d.handleInput(sess, ac, []byte{key}) | |||
| 89 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 90 | + | ||||
| 91 | + | require.False(t, ac.overlays.noticesActive()) | |||
| 92 | + | require.Nil(t, ac.overlays.noticesOverlay) | |||
| 93 | + | }) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) { | |||
| 98 | + | p, release := newBlockingPTY(t) | |||
| 99 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 100 | + | defer release() | |||
| 101 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |||
| 102 | + | ||||
| 103 | + | d.enterNotices(sess, ac) | |||
| 104 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 105 | + | ||||
| 106 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 107 | + | d.clock = clk | |||
| 108 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 109 | + | timer := <-clk.timers | |||
| 110 | + | require.True(t, ac.overlays.noticesActive()) | |||
| 111 | + | ||||
| 112 | + | timer.ch <- time.Now() | |||
| 113 | + | require.Eventually(t, func() bool { return !ac.overlays.noticesActive() }, time.Second, 5*time.Millisecond) | |||
| 114 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 115 | + | } | |||
| 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 | + | ||||
| 150 | + | func TestHandleListInputConsumesSplitEscapePrefix(t *testing.T) { | |||
| 151 | + | for _, tt := range []struct { | |||
| 152 | + | name string | |||
| 153 | + | prefix []byte | |||
| 154 | + | tail byte | |||
| 155 | + | wantUp int | |||
| 156 | + | wantDown int | |||
| 157 | + | }{ | |||
| 158 | + | {name: "CSI up", prefix: []byte("\x1b["), tail: 'A', wantUp: 1}, | |||
| 159 | + | {name: "CSI down", prefix: []byte("\x1b["), tail: 'B', wantDown: 1}, | |||
| 160 | + | {name: "SS3 up", prefix: []byte("\x1bO"), tail: 'A', wantUp: 1}, | |||
| 161 | + | {name: "SS3 down", prefix: []byte("\x1bO"), tail: 'B', wantDown: 1}, | |||
| 162 | + | } { | |||
| 163 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 164 | + | var pending, custom []byte | |||
| 165 | + | var up, down int | |||
| 166 | + | state := listInputState{ | |||
| 167 | + | pending: &pending, | |||
| 168 | + | esc: &pendingByteTimer{}, | |||
| 169 | + | moveUp: func() { up++ }, | |||
| 170 | + | moveDown: func() { down++ }, | |||
| 171 | + | } | |||
| 172 | + | action := func(b byte) listInputResult { | |||
| 173 | + | custom = append(custom, b) | |||
| 174 | + | return listInputResult{} | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | handleListInputLocked(nil, tt.prefix, state, action) | |||
| 178 | + | handleListInputLocked(nil, []byte{tt.tail}, state, action) | |||
| 179 | + | ||||
| 180 | + | require.Equal(t, tt.wantUp, up) | |||
| 181 | + | require.Equal(t, tt.wantDown, down) | |||
| 182 | + | require.Empty(t, custom, "escape prefix bytes must not reach the custom action") | |||
| 183 | + | require.Empty(t, pending) | |||
| 184 | + | }) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestHandleListInputConsumesUnsupportedCompleteEscapeSequences(t *testing.T) { | |||
| 189 | + | for _, tt := range []struct { | |||
| 190 | + | name string | |||
| 191 | + | input [][]byte | |||
| 192 | + | }{ | |||
| 193 | + | {name: "complete CSI right", input: [][]byte{[]byte("\x1b[C")}}, | |||
| 194 | + | {name: "complete SS3 left", input: [][]byte{[]byte("\x1bOD")}}, | |||
| 195 | + | {name: "parameterized CSI", input: [][]byte{[]byte("\x1b[1;5C")}}, | |||
| 196 | + | {name: "split CSI right", input: [][]byte{[]byte("\x1b["), []byte("C")}}, | |||
| 197 | + | {name: "split SS3 left", input: [][]byte{[]byte("\x1bO"), []byte("D")}}, | |||
| 198 | + | } { | |||
| 199 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 200 | + | var pending, custom []byte | |||
| 201 | + | state := listInputState{ | |||
| 202 | + | pending: &pending, | |||
| 203 | + | esc: &pendingByteTimer{}, | |||
| 204 | + | moveUp: func() {}, | |||
| 205 | + | moveDown: func() {}, | |||
| 206 | + | } | |||
| 207 | + | action := func(b byte) listInputResult { | |||
| 208 | + | custom = append(custom, b) | |||
| 209 | + | return listInputResult{action: b} | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | var result listInputResult | |||
| 213 | + | for _, input := range tt.input { | |||
| 214 | + | result = handleListInputLocked(nil, input, state, action) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | require.False(t, result.exit) | |||
| 218 | + | require.Zero(t, result.action) | |||
| 219 | + | require.Empty(t, custom, "complete escape bytes must not reach the custom action") | |||
| 220 | + | require.Empty(t, pending) | |||
| 221 | + | }) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestHandleListInputStopsAfterExitInBatchedInput(t *testing.T) { | |||
| 226 | + | for _, tt := range []struct { | |||
| 227 | + | name string | |||
| 228 | + | input []byte | |||
| 229 | + | custom func(byte) listInputResult | |||
| 230 | + | wantExit bool | |||
| 231 | + | wantStop bool | |||
| 232 | + | wantAction byte | |||
| 233 | + | wantCalls []byte | |||
| 234 | + | }{ | |||
| 235 | + | { | |||
| 236 | + | name: "q ignores trailing navigation and actions", | |||
| 237 | + | input: []byte("qjy"), | |||
| 238 | + | custom: func(b byte) listInputResult { return listInputResult{action: b} }, | |||
| 239 | + | wantExit: true, | |||
| 240 | + | }, | |||
| 241 | + | { | |||
| 242 | + | name: "ctrl-c ignores trailing navigation and actions", | |||
| 243 | + | input: []byte{0x03, 'j', 'y'}, | |||
| 244 | + | custom: func(b byte) listInputResult { return listInputResult{action: b} }, | |||
| 245 | + | wantExit: true, | |||
| 246 | + | }, | |||
| 247 | + | { | |||
| 248 | + | name: "invalid escape exit ignores trailing navigation and actions", | |||
| 249 | + | input: []byte{'\x1b', 'x', 'j', 'y'}, | |||
| 250 | + | custom: func(b byte) listInputResult { return listInputResult{action: b} }, | |||
| 251 | + | wantExit: true, | |||
| 252 | + | wantCalls: nil, | |||
| 253 | + | wantAction: 0, | |||
| 254 | + | }, | |||
| 255 | + | { | |||
| 256 | + | name: "custom exit retains action and ignores trailing bytes", | |||
| 257 | + | input: []byte("xjy"), | |||
| 258 | + | custom: func(b byte) listInputResult { | |||
| 259 | + | if b == 'x' { | |||
| 260 | + | return listInputResult{action: b, exit: true} | |||
| 261 | + | } | |||
| 262 | + | return listInputResult{action: b} | |||
| 263 | + | }, | |||
| 264 | + | wantExit: true, wantAction: 'x', wantCalls: []byte("x"), | |||
| 265 | + | }, | |||
| 266 | + | { | |||
| 267 | + | name: "custom stop retains action and ignores trailing bytes", | |||
| 268 | + | input: []byte("xjy"), | |||
| 269 | + | custom: func(b byte) listInputResult { | |||
| 270 | + | if b == 'x' { | |||
| 271 | + | return listInputResult{action: b, stop: true} | |||
| 272 | + | } | |||
| 273 | + | return listInputResult{action: b} | |||
| 274 | + | }, | |||
| 275 | + | wantStop: true, wantAction: 'x', wantCalls: []byte("x"), | |||
| 276 | + | }, | |||
| 277 | + | { | |||
| 278 | + | name: "custom exit and stop retain both flags and action", | |||
| 279 | + | input: []byte("xjy"), | |||
| 280 | + | custom: func(b byte) listInputResult { | |||
| 281 | + | if b == 'x' { | |||
| 282 | + | return listInputResult{action: b, exit: true, stop: true} | |||
| 283 | + | } | |||
| 284 | + | return listInputResult{action: b} | |||
| 285 | + | }, | |||
| 286 | + | wantExit: true, wantStop: true, wantAction: 'x', wantCalls: []byte("x"), | |||
| 287 | + | }, | |||
| 288 | + | } { | |||
| 289 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 290 | + | var pending, calls []byte | |||
| 291 | + | var down int | |||
| 292 | + | state := listInputState{ | |||
| 293 | + | pending: &pending, | |||
| 294 | + | esc: &pendingByteTimer{}, | |||
| 295 | + | moveUp: func() {}, | |||
| 296 | + | moveDown: func() { down++ }, | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | result := handleListInputLocked(nil, tt.input, state, func(b byte) listInputResult { | |||
| 300 | + | calls = append(calls, b) | |||
| 301 | + | return tt.custom(b) | |||
| 302 | + | }) | |||
| 303 | + | ||||
| 304 | + | require.Equal(t, tt.wantExit, result.exit) | |||
| 305 | + | require.Equal(t, tt.wantStop, result.stop) | |||
| 306 | + | require.Equal(t, tt.wantAction, result.action) | |||
| 307 | + | require.Equal(t, tt.wantCalls, calls) | |||
| 308 | + | require.Zero(t, down, "trailing j must not be processed") | |||
| 309 | + | }) | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) { | |||
| 314 | + | p, release := newBlockingPTY(t) | |||
| 315 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 316 | + | defer release() | |||
| 317 | + | ||||
| 318 | + | require.NotPanics(t, func() { d.enterNotices(sess, ac) }) | |||
| 319 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 320 | + | ||||
| 321 | + | require.NotNil(t, ac.overlays.noticesOverlay) | |||
| 322 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 323 | + | require.NoError(t, err) | |||
| 324 | + | require.Contains(t, string(msg.Data), "Notifications") | |||
| 325 | + | require.Contains(t, string(msg.Data), "No notifications") | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func TestNoticeYankPayload(t *testing.T) { | |||
| 329 | + | at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) | |||
| 330 | + | tests := []struct { | |||
| 331 | + | name string | |||
| 332 | + | n domain.Notification | |||
| 333 | + | want string | |||
| 334 | + | }{ | |||
| 335 | + | { | |||
| 336 | + | name: "single occurrence, no details", | |||
| 337 | + | n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeInfo, Message: "opened pane", Time: at}, | |||
| 338 | + | want: "[2024-01-02T03:04:05Z] info pane-spawn\nopened pane\n", | |||
| 339 | + | }, | |||
| 340 | + | { | |||
| 341 | + | name: "coalesced count and details", | |||
| 342 | + | n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom ← permission denied", Count: 3, Time: at}, | |||
| 343 | + | want: "[2024-01-02T03:04:05Z] error pane-spawn ×3\ncouldn't open pane\ndetails: boom ← permission denied\n", | |||
| 344 | + | }, | |||
| 345 | + | { | |||
| 346 | + | name: "warn severity", | |||
| 347 | + | n: domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeWarn, Message: "selection too large", Time: at}, | |||
| 348 | + | want: "[2024-01-02T03:04:05Z] warn clipboard\nselection too large\n", | |||
| 349 | + | }, | |||
| 350 | + | } | |||
| 351 | + | for _, tt := range tests { | |||
| 352 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 353 | + | require.Equal(t, tt.want, noticeYankPayload(tt.n)) | |||
| 354 | + | }) | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func TestYankLastNotificationCommandCopiesOSC52AndShowsFeedback(t *testing.T) { | |||
| 359 | + | p, release := newBlockingPTY(t) | |||
| 360 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 361 | + | defer release() | |||
| 362 | + | n := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom", Time: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)} | |||
| 363 | + | d.notices.record(n) | |||
| 364 | + | want := noticeYankPayload(n) | |||
| 365 | + | ||||
| 366 | + | d.enterPalette(sess, ac) | |||
| 367 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 368 | + | d.handlePaletteInput(ac, []byte("YLN\r")) | |||
| 369 | + | ||||
| 370 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 371 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 372 | + | require.NoError(t, err) | |||
| 373 | + | payload := string(msg.Data) | |||
| 374 | + | require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload) | |||
| 375 | + | require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload) | |||
| 376 | + | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a")) | |||
| 377 | + | require.NoError(t, err) | |||
| 378 | + | require.Equal(t, want, string(decoded)) | |||
| 379 | + | ||||
| 380 | + | live := awaitFrame(t, sends, ports.MsgOutput) | |||
| 381 | + | liveMsg, err := ports.UnmarshalOutput(live.Payload) | |||
| 382 | + | require.NoError(t, err) | |||
| 383 | + | require.Contains(t, string(liveMsg.Data), "copied notification details") | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) { | |||
| 387 | + | p, release := newBlockingPTY(t) | |||
| 388 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 389 | + | defer release() | |||
| 390 | + | ||||
| 391 | + | d.enterPalette(sess, ac) | |||
| 392 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 393 | + | d.handlePaletteInput(ac, []byte("YLN\r")) | |||
| 394 | + | ||||
| 395 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 396 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 397 | + | require.NoError(t, err) | |||
| 398 | + | require.Contains(t, string(msg.Data), "no notifications yet") | |||
| 399 | + | ||||
| 400 | + | history := d.notices.history() | |||
| 401 | + | require.Len(t, history, 1) | |||
| 402 | + | require.Equal(t, domain.NoticeClipboard, history[0].Code) | |||
| 403 | + | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |||
| 404 | + | require.Equal(t, "no notifications yet", history[0].Message) | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | func TestNoticesYKeyYanksSelectedNotification(t *testing.T) { | |||
| 408 | + | p, release := newBlockingPTY(t) | |||
| 409 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 410 | + | defer release() | |||
| 411 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "first", Time: time.Unix(1, 0)}) | |||
| 412 | + | d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Severity: domain.NoticeError, Message: "second", Time: time.Unix(2, 0)}) | |||
| 413 | + | ||||
| 414 | + | d.enterNotices(sess, ac) | |||
| 415 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 416 | + | selected, ok := ac.overlays.noticesOverlay.Selected() | |||
| 417 | + | require.True(t, ok) | |||
| 418 | + | require.Equal(t, "second", selected.Message) | |||
| 419 | + | want := noticeYankPayload(selected) | |||
| 420 | + | ||||
| 421 | + | d.handleInput(sess, ac, []byte("y")) | |||
| 422 | + | ||||
| 423 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 424 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 425 | + | require.NoError(t, err) | |||
| 426 | + | payload := string(msg.Data) | |||
| 427 | + | require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload) | |||
| 428 | + | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a")) | |||
| 429 | + | require.NoError(t, err) | |||
| 430 | + | require.Equal(t, want, string(decoded)) | |||
| 431 | + | ||||
| 432 | + | // The overlay stays open after a quick yank; it isn't a modal commit like | |||
| 433 | + | // copy mode's own 'y'. | |||
| 434 | + | require.True(t, ac.overlays.noticesActive()) | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func TestNoticesBatchedYankCapturesSelectionAtY(t *testing.T) { | |||
| 438 | + | tests := []struct { | |||
| 439 | + | name string | |||
| 440 | + | input []byte | |||
| 441 | + | wantYanked string | |||
| 442 | + | wantSelected string | |||
| 443 | + | }{ | |||
| 444 | + | {name: "yank then down", input: []byte("yj"), wantYanked: "third", wantSelected: "second"}, | |||
| 445 | + | {name: "down then yank", input: []byte("jy"), wantYanked: "second", wantSelected: "second"}, | |||
| 446 | + | } | |||
| 447 | + | for _, tt := range tests { | |||
| 448 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 449 | + | p, release := newBlockingPTY(t) | |||
| 450 | + | defer release() | |||
| 451 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 452 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)}) | |||
| 453 | + | d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)}) | |||
| 454 | + | d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)}) | |||
| 455 | + | ||||
| 456 | + | d.enterNotices(sess, ac) | |||
| 457 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 458 | + | d.handleInput(sess, ac, tt.input) | |||
| 459 | + | ||||
| 460 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 461 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 462 | + | require.NoError(t, err) | |||
| 463 | + | payload := strings.TrimSuffix(strings.TrimPrefix(string(msg.Data), "\x1b]52;c;"), "\a") | |||
| 464 | + | decoded, err := base64.StdEncoding.DecodeString(payload) | |||
| 465 | + | require.NoError(t, err) | |||
| 466 | + | require.Contains(t, string(decoded), "\n"+tt.wantYanked+"\n") | |||
| 467 | + | ||||
| 468 | + | selected, ok := ac.overlays.noticesOverlay.Selected() | |||
| 469 | + | require.True(t, ok) | |||
| 470 | + | require.Equal(t, tt.wantSelected, selected.Message) | |||
| 471 | + | }) | |||
| 472 | + | } | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) { | |||
| 476 | + | p, release := newBlockingPTY(t) | |||
| 477 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 478 | + | defer release() | |||
| 479 | + | ||||
| 480 | + | require.NotPanics(t, func() { d.enterNotices(sess, ac) }) | |||
| 481 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 482 | + | ||||
| 483 | + | require.NotPanics(t, func() { d.handleInput(sess, ac, []byte("y")) }) | |||
| 484 | + | require.True(t, ac.overlays.noticesActive()) | |||
| 485 | + | } | |||
A internal/usecase/daemon/notify.go +403 -0
| @@ -0,0 +1,403 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "log/slog" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | noticeHistoryCap = 200 | |||
| 17 | + | noticePendingCap = 32 | |||
| 18 | + | // maxVisibleToasts bounds the stack drawn over a client's screen; anything | |||
| 19 | + | // trimmed is counted in noticeOverflow and stays reachable in history. | |||
| 20 | + | maxVisibleToasts = 3 | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // errNoNeighbor reports a directional focus move with nothing to move to. It is | |||
| 24 | + | // an ordinary outcome of navigation, never a user-facing failure. | |||
| 25 | + | var errNoNeighbor = errors.New("no pane in that direction") | |||
| 26 | + | ||||
| 27 | + | // noticeCenter owns the daemon-wide notification history and the queue of | |||
| 28 | + | // global notices awaiting a first attached client. Its mu is leaf-level: no | |||
| 29 | + | // other lock is ever taken while holding it. | |||
| 30 | + | type noticeCenter struct { | |||
| 31 | + | mu sync.Mutex | |||
| 32 | + | // routingMu serializes global deliver-or-queue with the first-paint drain. | |||
| 33 | + | // Attachment removal also takes it, so a client selected for a global toast | |||
| 34 | + | // remains current until that toast is published. | |||
| 35 | + | routingMu sync.Mutex | |||
| 36 | + | ring []domain.Notification | |||
| 37 | + | pending []domain.Notification | |||
| 38 | + | // beforeQueueGlobal is a deterministic test seam after routing has observed | |||
| 39 | + | // no attachments and before it queues the notice. | |||
| 40 | + | beforeQueueGlobal func() | |||
| 41 | + | // beforeGlobalDelivery is a deterministic test seam after targets have been | |||
| 42 | + | // selected and before their toast stacks are touched. | |||
| 43 | + | beforeGlobalDelivery func() | |||
| 44 | + | // beforeClientNoticeMutation is a deterministic test seam after a client | |||
| 45 | + | // notice's ownership validation and before its toast state is changed. | |||
| 46 | + | beforeClientNoticeMutation func() | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func newNoticeCenter() *noticeCenter { return ¬iceCenter{} } | |||
| 50 | + | ||||
| 51 | + | func (nc *noticeCenter) record(n domain.Notification) domain.Notification { | |||
| 52 | + | if n.Count == 0 { | |||
| 53 | + | n.Count = 1 | |||
| 54 | + | } | |||
| 55 | + | nc.mu.Lock() | |||
| 56 | + | defer nc.mu.Unlock() | |||
| 57 | + | nc.ring = append(nc.ring, n) | |||
| 58 | + | if len(nc.ring) > noticeHistoryCap { | |||
| 59 | + | nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:] | |||
| 60 | + | } | |||
| 61 | + | return n | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (nc *noticeCenter) history() []domain.Notification { | |||
| 65 | + | nc.mu.Lock() | |||
| 66 | + | defer nc.mu.Unlock() | |||
| 67 | + | out := make([]domain.Notification, len(nc.ring)) | |||
| 68 | + | for i, n := range nc.ring { | |||
| 69 | + | out[len(nc.ring)-1-i] = n | |||
| 70 | + | } | |||
| 71 | + | return out | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (nc *noticeCenter) latest() (domain.Notification, bool) { | |||
| 75 | + | nc.mu.Lock() | |||
| 76 | + | defer nc.mu.Unlock() | |||
| 77 | + | if len(nc.ring) == 0 { | |||
| 78 | + | return domain.Notification{}, false | |||
| 79 | + | } | |||
| 80 | + | return nc.ring[len(nc.ring)-1], true | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (nc *noticeCenter) queueGlobal(n domain.Notification) { | |||
| 84 | + | if n.Count == 0 { | |||
| 85 | + | n.Count = 1 | |||
| 86 | + | } | |||
| 87 | + | nc.mu.Lock() | |||
| 88 | + | defer nc.mu.Unlock() | |||
| 89 | + | for i := range nc.pending { | |||
| 90 | + | if nc.pending[i].Code == n.Code { | |||
| 91 | + | nc.pending[i].Count += n.Count | |||
| 92 | + | nc.pending[i].Time = n.Time | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | if len(nc.pending) >= noticePendingCap { | |||
| 97 | + | return // history already has it via record(); toast is dropped | |||
| 98 | + | } | |||
| 99 | + | nc.pending = append(nc.pending, n) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (nc *noticeCenter) drainPending() []domain.Notification { | |||
| 103 | + | nc.mu.Lock() | |||
| 104 | + | defer nc.mu.Unlock() | |||
| 105 | + | out := nc.pending | |||
| 106 | + | nc.pending = nil | |||
| 107 | + | return out | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // noticeToast is one entry in a client's visible toast stack. seq identifies the | |||
| 111 | + | // entry across coalescing so a stale expiry timer removes only its own toast. | |||
| 112 | + | type noticeToast struct { | |||
| 113 | + | n domain.Notification | |||
| 114 | + | seq uint64 | |||
| 115 | + | timer pendingByteTimer | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // noticeTTL is how long a toast of this severity stays visible. | |||
| 119 | + | func noticeTTL(sev domain.NoticeSeverity) time.Duration { | |||
| 120 | + | switch sev { | |||
| 121 | + | case domain.NoticeInfo: | |||
| 122 | + | return 4 * time.Second | |||
| 123 | + | case domain.NoticeWarn: | |||
| 124 | + | return 6 * time.Second | |||
| 125 | + | default: | |||
| 126 | + | return 8 * time.Second | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // slogLevelFor maps a notice severity to the slog level it should log at, so | |||
| 131 | + | // routine info notices (e.g. a successful clipboard copy) don't drown out | |||
| 132 | + | // real warnings at the default VEV_LOG=info verbosity. | |||
| 133 | + | func slogLevelFor(sev domain.NoticeSeverity) slog.Level { | |||
| 134 | + | switch sev { | |||
| 135 | + | case domain.NoticeInfo: | |||
| 136 | + | return slog.LevelInfo | |||
| 137 | + | case domain.NoticeWarn: | |||
| 138 | + | return slog.LevelWarn | |||
| 139 | + | default: | |||
| 140 | + | return slog.LevelError | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // noticeDetails renders the full Unwrap chain for the yank/history views. The | |||
| 145 | + | // toast itself never shows this — only the UserError's own message. | |||
| 146 | + | func noticeDetails(err error) string { | |||
| 147 | + | if err == nil { | |||
| 148 | + | return "" | |||
| 149 | + | } | |||
| 150 | + | var parts []string | |||
| 151 | + | for e := err; e != nil; e = errors.Unwrap(e) { | |||
| 152 | + | parts = append(parts, e.Error()) | |||
| 153 | + | } | |||
| 154 | + | return strings.Join(parts, " ← ") | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // benignNoticeError reports errors that are expected control flow rather than | |||
| 158 | + | // something the user needs to be told about. | |||
| 159 | + | func benignNoticeError(err error) bool { | |||
| 160 | + | return errors.Is(err, context.Canceled) || | |||
| 161 | + | errors.Is(err, errNoNeighbor) || | |||
| 162 | + | errors.Is(err, ports.ErrNoClipboardImage) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // persistShutdownSnapshotFailure records a terminal snapshot failure that | |||
| 166 | + | // cannot be toasted because daemon shutdown has already begun. It belongs to | |||
| 167 | + | // notification ownership so session teardown only decides whether shutdown | |||
| 168 | + | // makes the failure non-retryable. | |||
| 169 | + | func (d *Daemon) persistShutdownSnapshotFailure(name string, cause error) { | |||
| 170 | + | if d.noticeStore == nil { | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | if err := d.noticeStore.Append(domain.Notification{ | |||
| 174 | + | Code: domain.NoticeSnapshotWrite, | |||
| 175 | + | Severity: domain.NoticeError, | |||
| 176 | + | Message: "session " + name + " shut down without saving terminal state", | |||
| 177 | + | Details: noticeDetails(cause), | |||
| 178 | + | Time: d.clock.Now(), | |||
| 179 | + | }); err != nil { | |||
| 180 | + | d.log.Warn("persisting shutdown notice failed", "err", err, "session", name) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // reportError turns any error into a user-facing notice. Unclassified errors | |||
| 185 | + | // become NoticeInternal: an error reaching here is never silently dropped | |||
| 186 | + | // unless benignNoticeError says it is routine. | |||
| 187 | + | func (d *Daemon) reportError(sess *session, err error) { | |||
| 188 | + | if err == nil || benignNoticeError(err) { | |||
| 189 | + | return | |||
| 190 | + | } | |||
| 191 | + | var ue *domain.UserError | |||
| 192 | + | if errors.As(err, &ue) { | |||
| 193 | + | d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err) | |||
| 194 | + | return | |||
| 195 | + | } | |||
| 196 | + | d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // notify records a notice and routes it to whoever can see it. A nil sess means | |||
| 200 | + | // daemon-global: every attached client gets it, and if none is attached the | |||
| 201 | + | // notice waits in the pending queue for the next attach. | |||
| 202 | + | // | |||
| 203 | + | // Locking: sess.mu and d.mu are only held to snapshot; showToast is always | |||
| 204 | + | // called with no daemon, session, or pane lock held. Global routing holds the | |||
| 205 | + | // notice center's routingMu across selection and publication, which serializes | |||
| 206 | + | // it with attachment detach and pending-queue drains. | |||
| 207 | + | func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |||
| 208 | + | n := domain.Notification{ | |||
| 209 | + | Code: code, | |||
| 210 | + | Severity: sev, | |||
| 211 | + | Message: msg, | |||
| 212 | + | Details: noticeDetails(cause), | |||
| 213 | + | Time: d.clock.Now(), | |||
| 214 | + | } | |||
| 215 | + | if sess != nil { | |||
| 216 | + | n.SessionID = sess.id | |||
| 217 | + | } | |||
| 218 | + | n = d.notices.record(n) | |||
| 219 | + | d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |||
| 220 | + | ||||
| 221 | + | if sess != nil { | |||
| 222 | + | sess.mu.Lock() | |||
| 223 | + | ac := sess.client | |||
| 224 | + | sess.mu.Unlock() | |||
| 225 | + | if ac != nil { | |||
| 226 | + | d.showToast(ac, n) | |||
| 227 | + | } | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | d.deliverGlobal(n) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // deliverGlobal atomically selects all current attachments and either publishes | |||
| 235 | + | // to all of them or queues the notice. routingMu is deliberately held through | |||
| 236 | + | // showToast, but never with d.mu or sess.mu held: detach cannot turn a selected | |||
| 237 | + | // target stale between selection and publication, and firstPaint cannot drain | |||
| 238 | + | // before an unattached route has queued its notice. | |||
| 239 | + | func (d *Daemon) deliverGlobal(n domain.Notification) { | |||
| 240 | + | d.mu.Lock() | |||
| 241 | + | d.notices.routingMu.Lock() | |||
| 242 | + | sessions := make([]*session, 0, len(d.sessions)) | |||
| 243 | + | for _, s := range d.sessions { | |||
| 244 | + | sessions = append(sessions, s) | |||
| 245 | + | } | |||
| 246 | + | d.mu.Unlock() | |||
| 247 | + | defer d.notices.routingMu.Unlock() | |||
| 248 | + | ||||
| 249 | + | if d.notices.beforeGlobalDelivery != nil { | |||
| 250 | + | d.notices.beforeGlobalDelivery() | |||
| 251 | + | } | |||
| 252 | + | delivered := false | |||
| 253 | + | for _, s := range sessions { | |||
| 254 | + | s.mu.Lock() | |||
| 255 | + | ac := s.client | |||
| 256 | + | s.mu.Unlock() | |||
| 257 | + | if ac == nil { | |||
| 258 | + | continue | |||
| 259 | + | } | |||
| 260 | + | d.showToast(ac, n) | |||
| 261 | + | delivered = true | |||
| 262 | + | } | |||
| 263 | + | if !delivered { | |||
| 264 | + | if d.notices.beforeQueueGlobal != nil { | |||
| 265 | + | d.notices.beforeQueueGlobal() | |||
| 266 | + | } | |||
| 267 | + | d.notices.queueGlobal(n) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // drainPendingForFirstPaint publishes pending globals only while ac remains the | |||
| 272 | + | // session's current attachment. It shares routingMu with deliverGlobal, so an | |||
| 273 | + | // unattached global route cannot queue after this drain has already happened. | |||
| 274 | + | func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) { | |||
| 275 | + | d.notices.routingMu.Lock() | |||
| 276 | + | defer d.notices.routingMu.Unlock() | |||
| 277 | + | ||||
| 278 | + | sess.mu.Lock() | |||
| 279 | + | current := sess.client == ac | |||
| 280 | + | sess.mu.Unlock() | |||
| 281 | + | if !current { | |||
| 282 | + | return | |||
| 283 | + | } | |||
| 284 | + | for _, n := range d.notices.drainPending() { | |||
| 285 | + | d.showToast(ac, n) | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | // NotifyGlobal raises a daemon-wide notice from outside the daemon package. | |||
| 290 | + | func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |||
| 291 | + | d.notify(nil, sev, code, msg, cause) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // showToast publishes a notice into one client's toast stack. An identical | |||
| 295 | + | // code within the same scope coalesces into the existing entry and restarts its | |||
| 296 | + | // TTL; otherwise the notice is prepended and the stack trimmed. The repaint is | |||
| 297 | + | // issued after noticeMu is released. | |||
| 298 | + | func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | |||
| 299 | + | if ac == nil { | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | ac.initOverlays() | |||
| 303 | + | rt := ac.overlays | |||
| 304 | + | ||||
| 305 | + | rt.noticeMu.Lock() | |||
| 306 | + | if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 { | |||
| 307 | + | rt.noticeToasts[i].n.Count += n.Count | |||
| 308 | + | rt.noticeToasts[i].n.Time = n.Time | |||
| 309 | + | rt.noticeToasts[i].n.Details = n.Details | |||
| 310 | + | rt.noticeToasts[i].timer.stop() | |||
| 311 | + | d.retainToastTimerLocked(ac, &rt.noticeToasts[i]) | |||
| 312 | + | rt.noticeMu.Unlock() | |||
| 313 | + | d.repaintForNotice(ac) | |||
| 314 | + | return | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | toast := noticeToast{n: n} | |||
| 318 | + | rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...) | |||
| 319 | + | if len(rt.noticeToasts) > maxVisibleToasts { | |||
| 320 | + | for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ { | |||
| 321 | + | rt.noticeToasts[i].timer.stop() | |||
| 322 | + | rt.noticeOverflow++ | |||
| 323 | + | } | |||
| 324 | + | rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts] | |||
| 325 | + | } | |||
| 326 | + | d.retainToastTimerLocked(ac, &rt.noticeToasts[0]) | |||
| 327 | + | rt.noticeMu.Unlock() | |||
| 328 | + | d.repaintForNotice(ac) | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // indexOfToastLocked finds a visible toast with the same code and scope. | |||
| 332 | + | // Callers must hold noticeMu. | |||
| 333 | + | func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int { | |||
| 334 | + | for i := range rt.noticeToasts { | |||
| 335 | + | if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid { | |||
| 336 | + | return i | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | return -1 | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | // dismissToast removes only the specified visible toast. In particular, a | |||
| 343 | + | // link-connected event must not clear clipboard or other notice state. | |||
| 344 | + | func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) { | |||
| 345 | + | if ac == nil || ac.overlays == nil { | |||
| 346 | + | return | |||
| 347 | + | } | |||
| 348 | + | rt := ac.overlays | |||
| 349 | + | rt.noticeMu.Lock() | |||
| 350 | + | i := rt.indexOfToastLocked(code, sid) | |||
| 351 | + | if i < 0 { | |||
| 352 | + | rt.noticeMu.Unlock() | |||
| 353 | + | return | |||
| 354 | + | } | |||
| 355 | + | rt.noticeToasts[i].timer.stop() | |||
| 356 | + | rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...) | |||
| 357 | + | if len(rt.noticeToasts) == 0 { | |||
| 358 | + | rt.noticeOverflow = 0 | |||
| 359 | + | } | |||
| 360 | + | rt.noticeMu.Unlock() | |||
| 361 | + | d.repaintForNotice(ac) | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | |||
| 365 | + | // timer goroutine re-acquires it and releases it before repainting, so the | |||
| 366 | + | // callback never runs with noticeMu held. | |||
| 367 | + | func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) { | |||
| 368 | + | rt := ac.overlays | |||
| 369 | + | rt.noticeSeq++ | |||
| 370 | + | t.seq = rt.noticeSeq | |||
| 371 | + | seq := t.seq | |||
| 372 | + | t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) { | |||
| 373 | + | rt.noticeMu.Lock() | |||
| 374 | + | kept := rt.noticeToasts[:0] | |||
| 375 | + | for _, tt := range rt.noticeToasts { | |||
| 376 | + | if tt.seq == seq { | |||
| 377 | + | continue | |||
| 378 | + | } | |||
| 379 | + | kept = append(kept, tt) | |||
| 380 | + | } | |||
| 381 | + | rt.noticeToasts = kept | |||
| 382 | + | if len(rt.noticeToasts) == 0 { | |||
| 383 | + | rt.noticeOverflow = 0 | |||
| 384 | + | } | |||
| 385 | + | rt.noticeMu.Unlock() | |||
| 386 | + | d.repaintForNotice(ac) | |||
| 387 | + | }) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // repaintForNotice asks for an urgent redraw. It must be called with noticeMu | |||
| 391 | + | // released: invalidateRender can paint inline, which takes sendMu. | |||
| 392 | + | func (d *Daemon) repaintForNotice(ac *attachedClient) { | |||
| 393 | + | sess := ac.currentSession() | |||
| 394 | + | if sess == nil { | |||
| 395 | + | // Mid-handoff or torn down: whoever attaches next repaints anyway, and | |||
| 396 | + | // invalidateRender cannot paint without a session. | |||
| 397 | + | return | |||
| 398 | + | } | |||
| 399 | + | // Toast footprints carry their own old/new damage, so a normal notice | |||
| 400 | + | // transition can remain incremental. First paint and transport recovery | |||
| 401 | + | // still provide their independent reset invariants. | |||
| 402 | + | d.invalidateRender(sess, ac, false, "notify.go") | |||
| 403 | + | } | |||
A internal/usecase/daemon/notify_test.go +909 -0
| @@ -0,0 +1,909 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | + | "github.com/bnema/vev/internal/ports" | |||
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 18 | + | "github.com/bnema/vev/pkg/vt" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) { | |||
| 22 | + | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |||
| 23 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}) | |||
| 24 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardTooLarge}) | |||
| 25 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded}) | |||
| 26 | + | ||||
| 27 | + | toasts, _ := visibleToasts(ac) | |||
| 28 | + | require.Len(t, toasts, 3) | |||
| 29 | + | require.Equal(t, []domain.Notification{ | |||
| 30 | + | {Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", SessionID: sess.id, Count: 1}, | |||
| 31 | + | {Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", SessionID: sess.id, Count: 1}, | |||
| 32 | + | {Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", SessionID: sess.id, Count: 1}, | |||
| 33 | + | }, stripNoticeTimes(toasts)) | |||
| 34 | + | ||||
| 35 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}) | |||
| 36 | + | toasts, _ = visibleToasts(ac) | |||
| 37 | + | require.Len(t, toasts, 2) | |||
| 38 | + | for _, toast := range toasts { | |||
| 39 | + | require.NotEqual(t, domain.NoticeConnection, toast.Code) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestHandleClientNoticeSerializesReplacement(t *testing.T) { | |||
| 44 | + | d, sess, old, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 45 | + | ||||
| 46 | + | enteredMutation := make(chan struct{}) | |||
| 47 | + | releaseMutation := make(chan struct{}) | |||
| 48 | + | d.notices.beforeClientNoticeMutation = func() { | |||
| 49 | + | close(enteredMutation) | |||
| 50 | + | <-releaseMutation | |||
| 51 | + | } | |||
| 52 | + | t.Cleanup(func() { | |||
| 53 | + | select { | |||
| 54 | + | case <-releaseMutation: | |||
| 55 | + | default: | |||
| 56 | + | close(releaseMutation) | |||
| 57 | + | } | |||
| 58 | + | }) | |||
| 59 | + | ||||
| 60 | + | noticeDone := make(chan struct{}) | |||
| 61 | + | go func() { | |||
| 62 | + | d.handleClientNotice(sess, old, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}) | |||
| 63 | + | close(noticeDone) | |||
| 64 | + | }() | |||
| 65 | + | <-enteredMutation // old has validated ownership and holds routingMu | |||
| 66 | + | if d.notices.routingMu.TryLock() { | |||
| 67 | + | d.notices.routingMu.Unlock() | |||
| 68 | + | t.Fatal("client notice mutation must retain routingMu through publication") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | tr, _ := newCapturingTransport(t) | |||
| 72 | + | replaced := make(chan *attachedClient, 1) | |||
| 73 | + | go func() { | |||
| 74 | + | current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |||
| 75 | + | replaced <- current | |||
| 76 | + | }() | |||
| 77 | + | ||||
| 78 | + | // Releasing the mutation lets the already-validated notice publish before | |||
| 79 | + | // replacement can own the session. Thus no stale attachment can mutate a | |||
| 80 | + | // toast stack after sess.client changes. | |||
| 81 | + | close(releaseMutation) | |||
| 82 | + | <-noticeDone | |||
| 83 | + | current := <-replaced | |||
| 84 | + | ||||
| 85 | + | history := d.notices.history() | |||
| 86 | + | require.Len(t, history, 1) | |||
| 87 | + | require.Equal(t, domain.NoticeClipboard, history[0].Code) | |||
| 88 | + | oldToasts, _ := visibleToasts(old) | |||
| 89 | + | require.Len(t, oldToasts, 1) | |||
| 90 | + | require.Equal(t, domain.NoticeClipboard, oldToasts[0].Code) | |||
| 91 | + | newToasts, _ := visibleToasts(current) | |||
| 92 | + | require.Empty(t, newToasts) | |||
| 93 | + | sess.mu.Lock() | |||
| 94 | + | require.Same(t, current, sess.client) | |||
| 95 | + | sess.mu.Unlock() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) { | |||
| 99 | + | for _, tt := range []struct { | |||
| 100 | + | name string | |||
| 101 | + | setup func(*Daemon, *attachedClient, domain.SessionID) | |||
| 102 | + | notice ports.ClientNotice | |||
| 103 | + | wantToast domain.NoticeCode | |||
| 104 | + | }{ | |||
| 105 | + | { | |||
| 106 | + | name: "record", | |||
| 107 | + | notice: ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}, | |||
| 108 | + | }, | |||
| 109 | + | { | |||
| 110 | + | name: "dismiss", | |||
| 111 | + | setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) { | |||
| 112 | + | d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid)) | |||
| 113 | + | }, | |||
| 114 | + | notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}, | |||
| 115 | + | wantToast: domain.NoticeConnection, | |||
| 116 | + | }, | |||
| 117 | + | } { | |||
| 118 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 119 | + | d, sess, old, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 120 | + | tr, _ := newCapturingTransport(t) | |||
| 121 | + | current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |||
| 122 | + | if tt.setup != nil { | |||
| 123 | + | tt.setup(d, old, sess.id) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | d.handleClientNotice(sess, old, tt.notice) | |||
| 127 | + | ||||
| 128 | + | require.Empty(t, d.notices.history(), "a replaced client must not record notices") | |||
| 129 | + | oldToasts, _ := visibleToasts(old) | |||
| 130 | + | if tt.setup == nil { | |||
| 131 | + | require.Empty(t, oldToasts) | |||
| 132 | + | } else { | |||
| 133 | + | require.Len(t, oldToasts, 1) | |||
| 134 | + | require.Equal(t, tt.wantToast, oldToasts[0].Code, "a replaced client must not dismiss toasts") | |||
| 135 | + | } | |||
| 136 | + | currentToasts, _ := visibleToasts(current) | |||
| 137 | + | require.Empty(t, currentToasts) | |||
| 138 | + | }) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestMalformedClientNoticeIsIgnored(t *testing.T) { | |||
| 143 | + | d, _, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |||
| 144 | + | tr, ok := ac.tr.(*portsmocks.MockTransport) | |||
| 145 | + | require.True(t, ok, "attached client transport must be a MockTransport") | |||
| 146 | + | frames := []ports.Frame{ | |||
| 147 | + | {Type: ports.MsgClientNotice, Payload: []byte{0xff}}, | |||
| 148 | + | {Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}, | |||
| 149 | + | } | |||
| 150 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 151 | + | frame := frames[0] | |||
| 152 | + | frames = frames[1:] | |||
| 153 | + | return frame, nil | |||
| 154 | + | }).Times(2) | |||
| 155 | + | d.runConnLoop(ac) | |||
| 156 | + | toasts, _ := visibleToasts(ac) | |||
| 157 | + | require.Empty(t, toasts) | |||
| 158 | + | require.Empty(t, d.notices.history()) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func stripNoticeTimes(notices []domain.Notification) []domain.Notification { | |||
| 162 | + | out := append([]domain.Notification(nil), notices...) | |||
| 163 | + | for i := range out { | |||
| 164 | + | out[i].Time = time.Time{} | |||
| 165 | + | } | |||
| 166 | + | return out | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | |||
| 170 | + | return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid} | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestNoticeCenterRingEviction(t *testing.T) { | |||
| 174 | + | nc := newNoticeCenter() | |||
| 175 | + | for i := 0; i < 205; i++ { | |||
| 176 | + | nc.record(notice(domain.NoticeInternal, fmt.Sprintf("n%d", i), "")) | |||
| 177 | + | } | |||
| 178 | + | h := nc.history() | |||
| 179 | + | if len(h) != 200 { | |||
| 180 | + | t.Fatalf("history len = %d, want 200", len(h)) | |||
| 181 | + | } | |||
| 182 | + | if h[0].Message != "n204" { | |||
| 183 | + | t.Fatalf("history[0] = %q, want n204 (newest insert)", h[0].Message) | |||
| 184 | + | } | |||
| 185 | + | if h[199].Message != "n5" { | |||
| 186 | + | t.Fatalf("history[199] = %q, want n5 (oldest retained after eviction)", h[199].Message) | |||
| 187 | + | } | |||
| 188 | + | // Every slot must hold exactly the expected message: strictly newest-first, | |||
| 189 | + | // no gaps, and none of the evicted n0..n4 leaked back in. | |||
| 190 | + | for i, n := range h { | |||
| 191 | + | want := fmt.Sprintf("n%d", 204-i) | |||
| 192 | + | if n.Message != want { | |||
| 193 | + | t.Fatalf("history[%d] = %q, want %q", i, n.Message, want) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestNoticeCenterHistoryNewestFirst(t *testing.T) { | |||
| 199 | + | nc := newNoticeCenter() | |||
| 200 | + | nc.record(notice(domain.NoticePaneSpawn, "first", "")) | |||
| 201 | + | nc.record(notice(domain.NoticeTabSpawn, "second", "")) | |||
| 202 | + | h := nc.history() | |||
| 203 | + | if h[0].Message != "second" || h[1].Message != "first" { | |||
| 204 | + | t.Fatalf("history order = %q,%q; want second,first", h[0].Message, h[1].Message) | |||
| 205 | + | } | |||
| 206 | + | last, ok := nc.latest() | |||
| 207 | + | if !ok || last.Message != "second" { | |||
| 208 | + | t.Fatalf("latest = %v,%v", last.Message, ok) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestNoticeCenterPendingQueue(t *testing.T) { | |||
| 213 | + | t.Run("dedup coalesces by code without growing the queue", func(t *testing.T) { | |||
| 214 | + | nc := newNoticeCenter() | |||
| 215 | + | for i := 0; i < 40; i++ { | |||
| 216 | + | nc.queueGlobal(notice(domain.NoticeSnapshotRestore, "restore failed", "")) | |||
| 217 | + | } | |||
| 218 | + | nc.queueGlobal(notice(domain.NoticePersistDisabled, "persistence disabled", "")) | |||
| 219 | + | pending := nc.drainPending() | |||
| 220 | + | if len(pending) != 2 { | |||
| 221 | + | t.Fatalf("pending len = %d, want 2 (deduped by code)", len(pending)) | |||
| 222 | + | } | |||
| 223 | + | if pending[0].Count != 40 { | |||
| 224 | + | t.Fatalf("coalesced Count = %d, want 40", pending[0].Count) | |||
| 225 | + | } | |||
| 226 | + | if got := nc.drainPending(); len(got) != 0 { | |||
| 227 | + | t.Fatalf("second drain len = %d, want 0", len(got)) | |||
| 228 | + | } | |||
| 229 | + | }) | |||
| 230 | + | ||||
| 231 | + | t.Run("cap overflow drops the 33rd distinct code", func(t *testing.T) { | |||
| 232 | + | nc := newNoticeCenter() | |||
| 233 | + | for i := 0; i < 33; i++ { | |||
| 234 | + | nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), "")) | |||
| 235 | + | } | |||
| 236 | + | pending := nc.drainPending() | |||
| 237 | + | if len(pending) != 32 { | |||
| 238 | + | t.Fatalf("pending len = %d, want 32 (bounded at cap)", len(pending)) | |||
| 239 | + | } | |||
| 240 | + | for i, n := range pending { | |||
| 241 | + | if n.Code != domain.NoticeCode(i) { | |||
| 242 | + | t.Fatalf("pending[%d].Code = %v, want %v (first 32 distinct codes retained, in order)", i, n.Code, domain.NoticeCode(i)) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | for _, n := range pending { | |||
| 246 | + | if n.Code == domain.NoticeCode(32) { | |||
| 247 | + | t.Fatalf("pending contains code %v, which should have been dropped on overflow", domain.NoticeCode(32)) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | }) | |||
| 251 | + | ||||
| 252 | + | t.Run("dedup on an already-queued code still increments Count at the cap", func(t *testing.T) { | |||
| 253 | + | nc := newNoticeCenter() | |||
| 254 | + | for i := 0; i < 32; i++ { | |||
| 255 | + | nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), "")) | |||
| 256 | + | } | |||
| 257 | + | // The queue is now full. Re-queuing an existing code must coalesce | |||
| 258 | + | // in place, not hit the overflow-drop branch. | |||
| 259 | + | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", "")) | |||
| 260 | + | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", "")) | |||
| 261 | + | pending := nc.drainPending() | |||
| 262 | + | if len(pending) != 32 { | |||
| 263 | + | t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending)) | |||
| 264 | + | } | |||
| 265 | + | if pending[0].Code != domain.NoticeCode(0) || pending[0].Count != 3 { | |||
| 266 | + | t.Fatalf("pending[0] = {Code:%v Count:%d}, want {Code:%v Count:3}", pending[0].Code, pending[0].Count, domain.NoticeCode(0)) | |||
| 267 | + | } | |||
| 268 | + | }) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // --- Task 3: routing, per-client toast state, attach drain ------------------ | |||
| 272 | + | ||||
| 273 | + | // noticeClock hands out timers the test fires explicitly by advancing time. | |||
| 274 | + | // Toast TTLs are asserted from the durations recorded here, so the tests never | |||
| 275 | + | // depend on wall-clock progress. | |||
| 276 | + | type noticeClock struct { | |||
| 277 | + | mu sync.Mutex | |||
| 278 | + | now time.Time | |||
| 279 | + | timers []*noticeTimer | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | type noticeTimer struct { | |||
| 283 | + | ch chan time.Time | |||
| 284 | + | duration time.Duration | |||
| 285 | + | deadline time.Time | |||
| 286 | + | stopped atomic.Bool | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func newNoticeClock() *noticeClock { | |||
| 290 | + | return ¬iceClock{now: time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)} | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func (c *noticeClock) Now() time.Time { | |||
| 294 | + | c.mu.Lock() | |||
| 295 | + | defer c.mu.Unlock() | |||
| 296 | + | return c.now | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func (c *noticeClock) NewTimer(d time.Duration) ports.Timer { | |||
| 300 | + | c.mu.Lock() | |||
| 301 | + | defer c.mu.Unlock() | |||
| 302 | + | t := ¬iceTimer{ch: make(chan time.Time, 1), duration: d, deadline: c.now.Add(d)} | |||
| 303 | + | c.timers = append(c.timers, t) | |||
| 304 | + | return t | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func (t *noticeTimer) C() <-chan time.Time { return t.ch } | |||
| 308 | + | ||||
| 309 | + | func (t *noticeTimer) Reset(time.Duration) bool { return false } | |||
| 310 | + | ||||
| 311 | + | func (t *noticeTimer) Stop() bool { | |||
| 312 | + | t.stopped.Store(true) | |||
| 313 | + | return true | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // advance moves the clock forward and fires every live timer that came due. | |||
| 317 | + | // Sends are non-blocking on a buffered channel, so no timer goroutine can wedge | |||
| 318 | + | // the test goroutine. | |||
| 319 | + | func (c *noticeClock) advance(d time.Duration) { | |||
| 320 | + | c.mu.Lock() | |||
| 321 | + | c.now = c.now.Add(d) | |||
| 322 | + | due := make([]*noticeTimer, 0, len(c.timers)) | |||
| 323 | + | kept := make([]*noticeTimer, 0, len(c.timers)) | |||
| 324 | + | for _, t := range c.timers { | |||
| 325 | + | if !t.stopped.Load() && !t.deadline.After(c.now) { | |||
| 326 | + | t.stopped.Store(true) | |||
| 327 | + | due = append(due, t) | |||
| 328 | + | continue | |||
| 329 | + | } | |||
| 330 | + | kept = append(kept, t) | |||
| 331 | + | } | |||
| 332 | + | c.timers = kept | |||
| 333 | + | now := c.now | |||
| 334 | + | c.mu.Unlock() | |||
| 335 | + | for _, t := range due { | |||
| 336 | + | select { | |||
| 337 | + | case t.ch <- now: | |||
| 338 | + | default: | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // durations returns every TTL requested so far, in creation order. | |||
| 344 | + | func (c *noticeClock) durations() []time.Duration { | |||
| 345 | + | c.mu.Lock() | |||
| 346 | + | defer c.mu.Unlock() | |||
| 347 | + | out := make([]time.Duration, 0, len(c.timers)) | |||
| 348 | + | for _, t := range c.timers { | |||
| 349 | + | out = append(out, t.duration) | |||
| 350 | + | } | |||
| 351 | + | return out | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // newNoticeFixture builds a manual session owned by one attached client, with a | |||
| 355 | + | // caller-supplied clock so toast TTLs are deterministic. The session has no | |||
| 356 | + | // render coordinator, so invalidateRender paints directly and every repaint is | |||
| 357 | + | // observable as a frame on the returned channel. | |||
| 358 | + | func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |||
| 359 | + | t.Helper() | |||
| 360 | + | p, _ := newBlockingPTY(t) | |||
| 361 | + | d := newTestDaemon(t, nil, clk) | |||
| 362 | + | tr, sends := newCapturingTransport(t) | |||
| 363 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 364 | + | ac.initOverlays() | |||
| 365 | + | sctx, cancel := context.WithCancel(d.serveCtx) | |||
| 366 | + | wctx, wcancel := context.WithCancel(sctx) | |||
| 367 | + | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |||
| 368 | + | tb.ctx, tb.cancel = wctx, wcancel | |||
| 369 | + | for _, pane := range tb.panes { | |||
| 370 | + | pane.ctx, pane.cancel = wctx, wcancel | |||
| 371 | + | } | |||
| 372 | + | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac} | |||
| 373 | + | ac.setSession(sess) | |||
| 374 | + | d.sessions[sess.id] = sess | |||
| 375 | + | t.Cleanup(cancel) | |||
| 376 | + | return d, sess, ac, sends | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // visibleToasts copies the client's toast state under noticeMu. | |||
| 380 | + | func visibleToasts(ac *attachedClient) ([]domain.Notification, int) { | |||
| 381 | + | rt := ac.overlays | |||
| 382 | + | rt.noticeMu.Lock() | |||
| 383 | + | defer rt.noticeMu.Unlock() | |||
| 384 | + | out := make([]domain.Notification, 0, len(rt.noticeToasts)) | |||
| 385 | + | for _, toast := range rt.noticeToasts { | |||
| 386 | + | out = append(out, toast.n) | |||
| 387 | + | } | |||
| 388 | + | return out, rt.noticeOverflow | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | // awaitToastCount waits for the toast slice to reach want. The polled condition | |||
| 392 | + | // only reads state; assertions stay on the test goroutine. | |||
| 393 | + | func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notification { | |||
| 394 | + | t.Helper() | |||
| 395 | + | require.Eventually(t, func() bool { | |||
| 396 | + | ns, _ := visibleToasts(ac) | |||
| 397 | + | return len(ns) == want | |||
| 398 | + | }, 2*time.Second, time.Millisecond, "toast count never reached %d", want) | |||
| 399 | + | got, _ := visibleToasts(ac) | |||
| 400 | + | return got | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | func TestReportErrorUserErrorBecomesNotification(t *testing.T) { | |||
| 404 | + | tests := []struct { | |||
| 405 | + | name string | |||
| 406 | + | err *domain.UserError | |||
| 407 | + | wantSev domain.NoticeSeverity | |||
| 408 | + | }{ | |||
| 409 | + | { | |||
| 410 | + | name: "error severity", | |||
| 411 | + | err: domain.UserErr(domain.NoticePaneSpawn, "could not open pane", errors.New("fork/exec: no such file")), | |||
| 412 | + | wantSev: domain.NoticeError, | |||
| 413 | + | }, | |||
| 414 | + | { | |||
| 415 | + | name: "warn severity", | |||
| 416 | + | err: domain.UserWarn(domain.NoticeConfigReload, "config reload skipped", errors.New("parse: line 4")), | |||
| 417 | + | wantSev: domain.NoticeWarn, | |||
| 418 | + | }, | |||
| 419 | + | } | |||
| 420 | + | for _, tc := range tests { | |||
| 421 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 422 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 423 | + | ||||
| 424 | + | d.reportError(sess, tc.err) | |||
| 425 | + | ||||
| 426 | + | hist := d.notices.history() | |||
| 427 | + | require.Len(t, hist, 1) | |||
| 428 | + | require.Equal(t, tc.err.Code, hist[0].Code) | |||
| 429 | + | require.Equal(t, tc.wantSev, hist[0].Severity) | |||
| 430 | + | require.Equal(t, tc.err.Msg, hist[0].Message) | |||
| 431 | + | require.Equal(t, sess.id, hist[0].SessionID) | |||
| 432 | + | require.Equal(t, 1, hist[0].Count) | |||
| 433 | + | // Details carry the cause chain; the message never does. | |||
| 434 | + | require.Contains(t, hist[0].Details, tc.err.Err.Error()) | |||
| 435 | + | require.NotContains(t, hist[0].Message, tc.err.Err.Error()) | |||
| 436 | + | ||||
| 437 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 438 | + | require.Equal(t, tc.err.Msg, toasts[0].Message) | |||
| 439 | + | require.Equal(t, tc.err.Code, toasts[0].Code) | |||
| 440 | + | }) | |||
| 441 | + | } | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | func TestReportErrorDetailsJoinUnwrapChain(t *testing.T) { | |||
| 445 | + | d, sess, _, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 446 | + | root := errors.New("permission denied") | |||
| 447 | + | mid := fmt.Errorf("open /dev/pts/3: %w", root) | |||
| 448 | + | ||||
| 449 | + | d.reportError(sess, domain.UserErr(domain.NoticePaneSpawn, "could not open pane", mid)) | |||
| 450 | + | ||||
| 451 | + | hist := d.notices.history() | |||
| 452 | + | require.Len(t, hist, 1) | |||
| 453 | + | require.Equal(t, mid.Error()+" ← "+root.Error(), hist[0].Details) | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func TestReportErrorUnknownErrorBecomesInternal(t *testing.T) { | |||
| 457 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 458 | + | raw := errors.New("something unclassified broke") | |||
| 459 | + | ||||
| 460 | + | d.reportError(sess, raw) | |||
| 461 | + | ||||
| 462 | + | hist := d.notices.history() | |||
| 463 | + | require.Len(t, hist, 1) | |||
| 464 | + | require.Equal(t, domain.NoticeInternal, hist[0].Code) | |||
| 465 | + | require.Equal(t, domain.NoticeError, hist[0].Severity) | |||
| 466 | + | require.Equal(t, "internal error", hist[0].Message) | |||
| 467 | + | require.Equal(t, raw.Error(), hist[0].Details) | |||
| 468 | + | ||||
| 469 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 470 | + | require.Equal(t, "internal error", toasts[0].Message) | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func TestReportErrorBenignFiltered(t *testing.T) { | |||
| 474 | + | tests := []struct { | |||
| 475 | + | name string | |||
| 476 | + | err error | |||
| 477 | + | }{ | |||
| 478 | + | {name: "nil", err: nil}, | |||
| 479 | + | {name: "context canceled", err: context.Canceled}, | |||
| 480 | + | {name: "wrapped context canceled", err: fmt.Errorf("pane read: %w", context.Canceled)}, | |||
| 481 | + | {name: "no neighbor", err: errNoNeighbor}, | |||
| 482 | + | {name: "wrapped no neighbor", err: fmt.Errorf("focus: %w", errNoNeighbor)}, | |||
| 483 | + | {name: "no clipboard image", err: ports.ErrNoClipboardImage}, | |||
| 484 | + | {name: "wrapped no clipboard image", err: fmt.Errorf("yank: %w", ports.ErrNoClipboardImage)}, | |||
| 485 | + | } | |||
| 486 | + | for _, tc := range tests { | |||
| 487 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 488 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 489 | + | ||||
| 490 | + | d.reportError(sess, tc.err) | |||
| 491 | + | ||||
| 492 | + | require.Empty(t, d.notices.history()) | |||
| 493 | + | toasts, _ := visibleToasts(ac) | |||
| 494 | + | require.Empty(t, toasts) | |||
| 495 | + | }) | |||
| 496 | + | } | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | // TestReportErrorNonBenignIsNeverDropped guards the inverse of the filter: an | |||
| 500 | + | // error that merely reads like a sentinel must still reach history. | |||
| 501 | + | func TestReportErrorNonBenignIsNeverDropped(t *testing.T) { | |||
| 502 | + | d, sess, _, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 503 | + | ||||
| 504 | + | d.reportError(sess, errors.New("no pane in that direction")) | |||
| 505 | + | ||||
| 506 | + | require.Len(t, d.notices.history(), 1, "a look-alike error must not be filtered by message text") | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||
| 510 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 511 | + | ||||
| 512 | + | other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 513 | + | other.initOverlays() | |||
| 514 | + | sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: other} | |||
| 515 | + | other.setSession(sess2) | |||
| 516 | + | d.mu.Lock() | |||
| 517 | + | d.sessions[sess2.id] = sess2 | |||
| 518 | + | d.mu.Unlock() | |||
| 519 | + | ||||
| 520 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) | |||
| 521 | + | ||||
| 522 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 523 | + | require.Equal(t, sess.id, toasts[0].SessionID) | |||
| 524 | + | otherToasts, _ := visibleToasts(other) | |||
| 525 | + | require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices") | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) { | |||
| 529 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 530 | + | sess.mu.Lock() | |||
| 531 | + | sess.client = nil | |||
| 532 | + | sess.mu.Unlock() | |||
| 533 | + | ||||
| 534 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 535 | + | ||||
| 536 | + | require.Len(t, d.notices.history(), 1) | |||
| 537 | + | toasts, _ := visibleToasts(ac) | |||
| 538 | + | require.Empty(t, toasts, "a detached session must not paint a toast") | |||
| 539 | + | require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals") | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||
| 543 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 544 | + | ||||
| 545 | + | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 546 | + | second.initOverlays() | |||
| 547 | + | sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second} | |||
| 548 | + | second.setSession(sess2) | |||
| 549 | + | d.mu.Lock() | |||
| 550 | + | d.sessions[sess2.id] = sess2 | |||
| 551 | + | d.mu.Unlock() | |||
| 552 | + | ||||
| 553 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil) | |||
| 554 | + | ||||
| 555 | + | first := awaitToastCount(t, ac, 1) | |||
| 556 | + | require.Equal(t, domain.SessionID(""), first[0].SessionID, "global notices carry an empty session id") | |||
| 557 | + | secondToasts := awaitToastCount(t, second, 1) | |||
| 558 | + | require.Equal(t, domain.NoticeConfigReload, secondToasts[0].Code) | |||
| 559 | + | require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued") | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) { | |||
| 563 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 564 | + | sess.mu.Lock() | |||
| 565 | + | sess.client = nil | |||
| 566 | + | sess.mu.Unlock() | |||
| 567 | + | ||||
| 568 | + | routeObserved := make(chan struct{}) | |||
| 569 | + | releaseRoute := make(chan struct{}) | |||
| 570 | + | d.notices.beforeQueueGlobal = func() { | |||
| 571 | + | close(routeObserved) | |||
| 572 | + | <-releaseRoute | |||
| 573 | + | } | |||
| 574 | + | globalDone := make(chan struct{}) | |||
| 575 | + | go func() { | |||
| 576 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil) | |||
| 577 | + | close(globalDone) | |||
| 578 | + | }() | |||
| 579 | + | <-routeObserved // global routing has observed the detached session | |||
| 580 | + | ||||
| 581 | + | // Publish the attachment and start firstPaint while global routing still | |||
| 582 | + | // owns its gate. firstPaint must wait for the queue, then drain it. | |||
| 583 | + | sess.mu.Lock() | |||
| 584 | + | sess.client = ac | |||
| 585 | + | sess.mu.Unlock() | |||
| 586 | + | paintDone := make(chan struct{}) | |||
| 587 | + | go func() { | |||
| 588 | + | d.firstPaint(sess, ac, domain.Size{}) | |||
| 589 | + | close(paintDone) | |||
| 590 | + | }() | |||
| 591 | + | ||||
| 592 | + | close(releaseRoute) | |||
| 593 | + | <-globalDone | |||
| 594 | + | <-paintDone | |||
| 595 | + | toasts, _ := visibleToasts(ac) | |||
| 596 | + | require.Len(t, toasts, 1) | |||
| 597 | + | require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code) | |||
| 598 | + | require.Empty(t, d.notices.drainPending()) | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) { | |||
| 602 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 603 | + | ||||
| 604 | + | routeObserved := make(chan struct{}) | |||
| 605 | + | releaseRoute := make(chan struct{}) | |||
| 606 | + | d.notices.beforeGlobalDelivery = func() { | |||
| 607 | + | close(routeObserved) | |||
| 608 | + | <-releaseRoute | |||
| 609 | + | } | |||
| 610 | + | globalDone := make(chan struct{}) | |||
| 611 | + | go func() { | |||
| 612 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil) | |||
| 613 | + | close(globalDone) | |||
| 614 | + | }() | |||
| 615 | + | <-routeObserved // ac is selected and routingMu is held | |||
| 616 | + | ||||
| 617 | + | detached := make(chan bool, 1) | |||
| 618 | + | go func() { detached <- d.detachIfCurrent(sess, ac) }() | |||
| 619 | + | close(releaseRoute) | |||
| 620 | + | <-globalDone | |||
| 621 | + | require.True(t, <-detached) | |||
| 622 | + | ||||
| 623 | + | // The selected attachment received the toast before it could become stale. | |||
| 624 | + | toasts, _ := visibleToasts(ac) | |||
| 625 | + | require.Len(t, toasts, 1) | |||
| 626 | + | sess.mu.Lock() | |||
| 627 | + | require.Nil(t, sess.client) | |||
| 628 | + | sess.mu.Unlock() | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) { | |||
| 632 | + | d, sess, old, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 633 | + | ||||
| 634 | + | routeObserved := make(chan struct{}) | |||
| 635 | + | releaseRoute := make(chan struct{}) | |||
| 636 | + | d.notices.beforeGlobalDelivery = func() { | |||
| 637 | + | close(routeObserved) | |||
| 638 | + | <-releaseRoute | |||
| 639 | + | } | |||
| 640 | + | globalDone := make(chan struct{}) | |||
| 641 | + | go func() { | |||
| 642 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil) | |||
| 643 | + | close(globalDone) | |||
| 644 | + | }() | |||
| 645 | + | <-routeObserved // old is selected and routingMu is held | |||
| 646 | + | ||||
| 647 | + | tr, _ := newCapturingTransport(t) | |||
| 648 | + | replaced := make(chan *attachedClient, 1) | |||
| 649 | + | go func() { | |||
| 650 | + | ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |||
| 651 | + | replaced <- ac | |||
| 652 | + | }() | |||
| 653 | + | close(releaseRoute) | |||
| 654 | + | <-globalDone | |||
| 655 | + | current := <-replaced | |||
| 656 | + | ||||
| 657 | + | // Replacement cannot make old a stale delivery target: it publishes only | |||
| 658 | + | // after the selected attachment has received the global toast. | |||
| 659 | + | oldToasts, _ := visibleToasts(old) | |||
| 660 | + | require.Len(t, oldToasts, 1) | |||
| 661 | + | newToasts, _ := visibleToasts(current) | |||
| 662 | + | require.Empty(t, newToasts) | |||
| 663 | + | sess.mu.Lock() | |||
| 664 | + | require.Same(t, current, sess.client) | |||
| 665 | + | sess.mu.Unlock() | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 669 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 670 | + | sess.mu.Lock() | |||
| 671 | + | sess.client = nil | |||
| 672 | + | sess.mu.Unlock() | |||
| 673 | + | ||||
| 674 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil) | |||
| 675 | + | ||||
| 676 | + | require.Len(t, d.notices.history(), 1) | |||
| 677 | + | toasts, _ := visibleToasts(ac) | |||
| 678 | + | require.Empty(t, toasts, "no attached client means nothing to paint yet") | |||
| 679 | + | ||||
| 680 | + | // Re-attach and let firstPaint drain the pending queue. | |||
| 681 | + | sess.mu.Lock() | |||
| 682 | + | sess.client = ac | |||
| 683 | + | sess.mu.Unlock() | |||
| 684 | + | d.firstPaint(sess, ac, domain.Size{}) | |||
| 685 | + | ||||
| 686 | + | drained := awaitToastCount(t, ac, 1) | |||
| 687 | + | require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code) | |||
| 688 | + | require.Equal(t, "snapshot write failed", drained[0].Message) | |||
| 689 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | // TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open | |||
| 693 | + | // failure contract (Task 13): app/run.go queues this exact code and message | |||
| 694 | + | // via NotifyGlobal before any client has attached, and the first attach must | |||
| 695 | + | // see it via firstPaint's pending-queue drain. | |||
| 696 | + | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |||
| 697 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 698 | + | sess.mu.Lock() | |||
| 699 | + | sess.client = nil | |||
| 700 | + | sess.mu.Unlock() | |||
| 701 | + | ||||
| 702 | + | cause := errors.New("open store.db: permission denied") | |||
| 703 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled, | |||
| 704 | + | "session persistence is disabled; sessions will not survive daemon restarts", cause) | |||
| 705 | + | ||||
| 706 | + | require.Len(t, d.notices.history(), 1) | |||
| 707 | + | toasts, _ := visibleToasts(ac) | |||
| 708 | + | require.Empty(t, toasts, "no attached client means nothing to paint yet") | |||
| 709 | + | ||||
| 710 | + | // Re-attach and let firstPaint drain the pending queue. | |||
| 711 | + | sess.mu.Lock() | |||
| 712 | + | sess.client = ac | |||
| 713 | + | sess.mu.Unlock() | |||
| 714 | + | d.firstPaint(sess, ac, domain.Size{}) | |||
| 715 | + | ||||
| 716 | + | drained := awaitToastCount(t, ac, 1) | |||
| 717 | + | require.Equal(t, domain.NoticePersistDisabled, drained[0].Code) | |||
| 718 | + | require.Equal(t, domain.NoticeWarn, drained[0].Severity) | |||
| 719 | + | require.Equal(t, "session persistence is disabled; sessions will not survive daemon restarts", drained[0].Message) | |||
| 720 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 721 | + | } | |||
| 722 | + | ||||
| 723 | + | // levelCaptureHandler records the slog.Level of every record it's handed, so | |||
| 724 | + | // tests can assert on log verbosity without parsing formatted output. | |||
| 725 | + | type levelCaptureHandler struct { | |||
| 726 | + | mu sync.Mutex | |||
| 727 | + | levels []slog.Level | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | func (h *levelCaptureHandler) Enabled(context.Context, slog.Level) bool { return true } | |||
| 731 | + | ||||
| 732 | + | func (h *levelCaptureHandler) Handle(_ context.Context, r slog.Record) error { | |||
| 733 | + | h.mu.Lock() | |||
| 734 | + | defer h.mu.Unlock() | |||
| 735 | + | h.levels = append(h.levels, r.Level) | |||
| 736 | + | return nil | |||
| 737 | + | } | |||
| 738 | + | ||||
| 739 | + | func (h *levelCaptureHandler) WithAttrs([]slog.Attr) slog.Handler { return h } | |||
| 740 | + | func (h *levelCaptureHandler) WithGroup(string) slog.Handler { return h } | |||
| 741 | + | ||||
| 742 | + | func TestNotifyLogsAtSeverityLevel(t *testing.T) { | |||
| 743 | + | tests := []struct { | |||
| 744 | + | name string | |||
| 745 | + | sev domain.NoticeSeverity | |||
| 746 | + | want slog.Level | |||
| 747 | + | }{ | |||
| 748 | + | {"info severity logs at info", domain.NoticeInfo, slog.LevelInfo}, | |||
| 749 | + | {"warn severity logs at warn", domain.NoticeWarn, slog.LevelWarn}, | |||
| 750 | + | {"error severity logs at error", domain.NoticeError, slog.LevelError}, | |||
| 751 | + | } | |||
| 752 | + | for _, tt := range tests { | |||
| 753 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 754 | + | d := newTestDaemon(t, nil, newNoticeClock()) | |||
| 755 | + | capture := &levelCaptureHandler{} | |||
| 756 | + | d.log = slog.New(capture) | |||
| 757 | + | ||||
| 758 | + | d.notify(nil, tt.sev, domain.NoticeClipboard, "msg", nil) | |||
| 759 | + | ||||
| 760 | + | require.Equal(t, []slog.Level{tt.want}, capture.levels) | |||
| 761 | + | }) | |||
| 762 | + | } | |||
| 763 | + | } | |||
| 764 | + | ||||
| 765 | + | func TestShowToastCoalesceAndTrim(t *testing.T) { | |||
| 766 | + | t.Run("same code and scope coalesces", func(t *testing.T) { | |||
| 767 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 768 | + | ||||
| 769 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 770 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 771 | + | ||||
| 772 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 773 | + | require.Equal(t, 2, toasts[0].Count) | |||
| 774 | + | _, overflow := visibleToasts(ac) | |||
| 775 | + | require.Zero(t, overflow) | |||
| 776 | + | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | |||
| 777 | + | }) | |||
| 778 | + | ||||
| 779 | + | t.Run("same code different scope does not coalesce", func(t *testing.T) { | |||
| 780 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 781 | + | ||||
| 782 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 783 | + | d.notify(nil, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 784 | + | ||||
| 785 | + | toasts := awaitToastCount(t, ac, 2) | |||
| 786 | + | require.Equal(t, 1, toasts[0].Count) | |||
| 787 | + | require.Equal(t, 1, toasts[1].Count) | |||
| 788 | + | }) | |||
| 789 | + | ||||
| 790 | + | t.Run("four distinct codes trim to three newest first", func(t *testing.T) { | |||
| 791 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 792 | + | codes := []domain.NoticeCode{ | |||
| 793 | + | domain.NoticePaneSpawn, | |||
| 794 | + | domain.NoticeTabSpawn, | |||
| 795 | + | domain.NoticeSnapshotWrite, | |||
| 796 | + | domain.NoticeClipboard, | |||
| 797 | + | } | |||
| 798 | + | for _, c := range codes { | |||
| 799 | + | d.notify(sess, domain.NoticeError, c, c.String(), nil) | |||
| 800 | + | } | |||
| 801 | + | ||||
| 802 | + | toasts := awaitToastCount(t, ac, maxVisibleToasts) | |||
| 803 | + | require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{ | |||
| 804 | + | toasts[0].Code, toasts[1].Code, toasts[2].Code, | |||
| 805 | + | }, "newest first, oldest evicted") | |||
| 806 | + | _, overflow := visibleToasts(ac) | |||
| 807 | + | require.Equal(t, 1, overflow) | |||
| 808 | + | }) | |||
| 809 | + | } | |||
| 810 | + | ||||
| 811 | + | func TestNoticeTTLPerSeverity(t *testing.T) { | |||
| 812 | + | tests := []struct { | |||
| 813 | + | name string | |||
| 814 | + | sev domain.NoticeSeverity | |||
| 815 | + | want time.Duration | |||
| 816 | + | }{ | |||
| 817 | + | {name: "info", sev: domain.NoticeInfo, want: 4 * time.Second}, | |||
| 818 | + | {name: "warn", sev: domain.NoticeWarn, want: 6 * time.Second}, | |||
| 819 | + | {name: "error", sev: domain.NoticeError, want: 8 * time.Second}, | |||
| 820 | + | } | |||
| 821 | + | for _, tc := range tests { | |||
| 822 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 823 | + | require.Equal(t, tc.want, noticeTTL(tc.sev)) | |||
| 824 | + | ||||
| 825 | + | clk := newNoticeClock() | |||
| 826 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |||
| 827 | + | d.notify(sess, tc.sev, domain.NoticeClipboard, "msg", nil) | |||
| 828 | + | awaitToastCount(t, ac, 1) | |||
| 829 | + | require.Equal(t, []time.Duration{tc.want}, clk.durations(), "the retained toast timer uses the severity TTL") | |||
| 830 | + | }) | |||
| 831 | + | } | |||
| 832 | + | } | |||
| 833 | + | ||||
| 834 | + | func TestToastExpiresOnFakeClock(t *testing.T) { | |||
| 835 | + | clk := newNoticeClock() | |||
| 836 | + | d, sess, ac, sends := newNoticeFixture(t, clk) | |||
| 837 | + | ||||
| 838 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |||
| 839 | + | awaitToastCount(t, ac, 1) | |||
| 840 | + | shown := awaitFrame(t, sends, ports.MsgOutput) | |||
| 841 | + | shownOutput, err := ports.UnmarshalOutput(shown.Payload) | |||
| 842 | + | require.NoError(t, err) | |||
| 843 | + | terminal := vt.NewScreen(80, 25) | |||
| 844 | + | terminal.Write(shownOutput.Data) | |||
| 845 | + | ||||
| 846 | + | // Short of the 8s error TTL nothing expires. | |||
| 847 | + | clk.advance(7 * time.Second) | |||
| 848 | + | toasts, _ := visibleToasts(ac) | |||
| 849 | + | require.Len(t, toasts, 1, "toast must survive until its TTL elapses") | |||
| 850 | + | ||||
| 851 | + | clk.advance(2 * time.Second) | |||
| 852 | + | awaitToastCount(t, ac, 0) | |||
| 853 | + | _, overflow := visibleToasts(ac) | |||
| 854 | + | require.Zero(t, overflow, "overflow resets once no toast is visible") | |||
| 855 | + | ||||
| 856 | + | expired := awaitFrame(t, sends, ports.MsgOutput) | |||
| 857 | + | expiredOutput, err := ports.UnmarshalOutput(expired.Payload) | |||
| 858 | + | require.NoError(t, err) | |||
| 859 | + | terminal.Write(expiredOutput.Data) | |||
| 860 | + | ac.sendMu.Lock() | |||
| 861 | + | base := ac.pipelineCache.frame.Clone() | |||
| 862 | + | ac.sendMu.Unlock() | |||
| 863 | + | require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell") | |||
| 864 | + | } | |||
| 865 | + | ||||
| 866 | + | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | |||
| 867 | + | // toast that outlived it. | |||
| 868 | + | func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) { | |||
| 869 | + | clk := newNoticeClock() | |||
| 870 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |||
| 871 | + | ||||
| 872 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) // 4s TTL | |||
| 873 | + | awaitToastCount(t, ac, 1) | |||
| 874 | + | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "boom", nil) // 8s TTL | |||
| 875 | + | awaitToastCount(t, ac, 2) | |||
| 876 | + | ||||
| 877 | + | clk.advance(5 * time.Second) | |||
| 878 | + | ||||
| 879 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 880 | + | require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed") | |||
| 881 | + | } | |||
| 882 | + | ||||
| 883 | + | // TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an | |||
| 884 | + | // expiry timer identifies its toast by entry sequence, not by code and scope. | |||
| 885 | + | // Coalescing re-arms the entry under a fresh sequence, so a timer that was | |||
| 886 | + | // stopped but had already slipped past its select cannot dismiss the refreshed | |||
| 887 | + | // toast that replaced the one it belonged to. | |||
| 888 | + | func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) { | |||
| 889 | + | clk := newNoticeClock() | |||
| 890 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |||
| 891 | + | ||||
| 892 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) | |||
| 893 | + | awaitToastCount(t, ac, 1) | |||
| 894 | + | ||||
| 895 | + | // Re-stamp the live entry's sequence, exactly as coalescing does, without | |||
| 896 | + | // disturbing the already-armed timer. Its code and scope are unchanged. | |||
| 897 | + | rt := ac.overlays | |||
| 898 | + | rt.noticeMu.Lock() | |||
| 899 | + | rt.noticeSeq++ | |||
| 900 | + | rt.noticeToasts[0].seq = rt.noticeSeq | |||
| 901 | + | rt.noticeMu.Unlock() | |||
| 902 | + | ||||
| 903 | + | clk.advance(4 * time.Second) | |||
| 904 | + | ||||
| 905 | + | require.Never(t, func() bool { | |||
| 906 | + | ns, _ := visibleToasts(ac) | |||
| 907 | + | return len(ns) == 0 | |||
| 908 | + | }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed") | |||
| 909 | + | } | |||
M internal/usecase/daemon/overlay_runtime.go +60 -6
| @@ -4,7 +4,9 @@ import ( | |||||
| 4 | "sync" | 4 | "sync" | ||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 9 | + | "github.com/bnema/vev/internal/usecase/notices" | |||
| 8 | "github.com/bnema/vev/internal/usecase/palette" | 10 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 9 | "github.com/bnema/vev/internal/usecase/picker" | 11 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 10 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 12 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| @@ -44,10 +46,29 @@ type overlayRuntime struct { | |||||
| 44 | copyESC pendingByteTimer | 46 | copyESC pendingByteTimer | ||
| 45 | copySearch *visualsearch.Model | 47 | copySearch *visualsearch.Model | ||
| 46 | copySearchPending []byte | 48 | copySearchPending []byte | ||
| 47 | - | copyFeedback string | 49 | + | statusFeedback string |
| 48 | copyPointer copyPointerState | 50 | copyPointer copyPointerState | ||
| 49 | copyClick copyClickCandidate | 51 | copyClick copyClickCandidate | ||
| 50 | copyPointerEpoch uint64 | 52 | copyPointerEpoch uint64 | ||
| 53 | + | ||||
| 54 | + | // noticeMu is the innermost overlay lock: it guards the toast fields below | |||
| 55 | + | // and nothing is ever locked, sent, or rendered while it is held. The only | |||
| 56 | + | // permitted nesting is sendMu -> noticeMu (rendering reads the toasts). | |||
| 57 | + | // The notification history overlay's fields share this lock rather than | |||
| 58 | + | // adding one of their own: both concern notice presentation for this | |||
| 59 | + | // client and neither is ever held across a render call. | |||
| 60 | + | noticeMu sync.Mutex | |||
| 61 | + | noticeToasts []noticeToast | |||
| 62 | + | noticeOverflow int | |||
| 63 | + | // noticeSeq numbers toast entries so an already-fired expiry timer cannot | |||
| 64 | + | // dismiss the refreshed entry that replaced the one it belonged to. | |||
| 65 | + | noticeSeq uint64 | |||
| 66 | + | ||||
| 67 | + | // noticesOverlay is the `notifications` command's history modal. nil when | |||
| 68 | + | // closed. | |||
| 69 | + | noticesOverlay *notices.Model | |||
| 70 | + | noticesPending []byte | |||
| 71 | + | noticesESC pendingByteTimer | |||
| 51 | } | 72 | } | ||
| 52 | 73 | ||||
| 53 | type copyPointerState struct { | 74 | type copyPointerState struct { | ||
| @@ -77,7 +98,7 @@ func (rt *overlayRuntime) Active() bool { | |||||
| 77 | if rt == nil || rt.ac == nil { | 98 | if rt == nil || rt.ac == nil { | ||
| 78 | return false | 99 | return false | ||
| 79 | } | 100 | } | ||
| 80 | - | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive() | 101 | + | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive() |
| 81 | } | 102 | } | ||
| 82 | 103 | ||||
| 83 | func (rt *overlayRuntime) promptActive() bool { | 104 | func (rt *overlayRuntime) promptActive() bool { | ||
| @@ -107,6 +128,15 @@ func (rt *overlayRuntime) pickerActive() bool { | |||||
| 107 | return rt.picker != nil | 128 | return rt.picker != nil | ||
| 108 | } | 129 | } | ||
| 109 | 130 | ||||
| 131 | + | func (rt *overlayRuntime) noticesActive() bool { | |||
| 132 | + | if rt == nil { | |||
| 133 | + | return false | |||
| 134 | + | } | |||
| 135 | + | rt.noticeMu.Lock() | |||
| 136 | + | defer rt.noticeMu.Unlock() | |||
| 137 | + | return rt.noticesOverlay != nil | |||
| 138 | + | } | |||
| 139 | + | ||||
| 110 | func (rt *overlayRuntime) copyActive() bool { | 140 | func (rt *overlayRuntime) copyActive() bool { | ||
| 111 | if rt == nil { | 141 | if rt == nil { | ||
| 112 | return false | 142 | return false | ||
| @@ -201,6 +231,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |||||
| 201 | d.handlePickerInput(ac, data) | 231 | d.handlePickerInput(ac, data) | ||
| 202 | return true | 232 | return true | ||
| 203 | } | 233 | } | ||
| 234 | + | if rt.noticesActive() { | |||
| 235 | + | d.handleNoticesInput(ac, data) | |||
| 236 | + | return true | |||
| 237 | + | } | |||
| 204 | if rt.copyActive() { | 238 | if rt.copyActive() { | ||
| 205 | d.handleCopyInput(ac, data) | 239 | d.handleCopyInput(ac, data) | ||
| 206 | return true | 240 | return true | ||
| @@ -215,12 +249,15 @@ type overlayRenderSnapshot struct { | |||||
| 215 | copyMode *scopy.Mode | 249 | copyMode *scopy.Mode | ||
| 216 | copyPane *pane | 250 | copyPane *pane | ||
| 217 | copySearchModel *visualsearch.Model | 251 | copySearchModel *visualsearch.Model | ||
| 218 | - | copyFeedback string | 252 | + | statusFeedback string |
| 219 | 253 | ||||
| 220 | pickerActive bool | 254 | pickerActive bool | ||
| 221 | pickerModel *picker.Model | 255 | pickerModel *picker.Model | ||
| 222 | previewTab *tab | 256 | previewTab *tab | ||
| 223 | 257 | ||||
| 258 | + | noticesOverlayActive bool | |||
| 259 | + | noticesOverlayModel *notices.Model | |||
| 260 | + | ||||
| 224 | paletteActive bool | 261 | paletteActive bool | ||
| 225 | paletteModel *palette.Model | 262 | paletteModel *palette.Model | ||
| 226 | // paletteHints is a copy captured under paletteMu. Rendering must use this | 263 | // paletteHints is a copy captured under paletteMu. Rendering must use this | ||
| @@ -233,6 +270,9 @@ type overlayRenderSnapshot struct { | |||||
| 233 | promptActive bool | 270 | promptActive bool | ||
| 234 | promptModel *promptui.Model | 271 | promptModel *promptui.Model | ||
| 235 | promptLocked bool | 272 | promptLocked bool | ||
| 273 | + | ||||
| 274 | + | notices []domain.Notification | |||
| 275 | + | noticeOverflow int | |||
| 236 | } | 276 | } | ||
| 237 | 277 | ||||
| 238 | // SnapshotForRender captures the overlay state needed by paint. | 278 | // SnapshotForRender captures the overlay state needed by paint. | ||
| @@ -247,6 +287,20 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 247 | return snap | 287 | return snap | ||
| 248 | } | 288 | } | ||
| 249 | 289 | ||||
| 290 | + | // noticeMu is innermost and never held across render, so it is taken and | |||
| 291 | + | // released here rather than tracked like paletteMu/promptMu below. | |||
| 292 | + | rt.noticeMu.Lock() | |||
| 293 | + | if len(rt.noticeToasts) > 0 { | |||
| 294 | + | snap.notices = make([]domain.Notification, len(rt.noticeToasts)) | |||
| 295 | + | for i, t := range rt.noticeToasts { | |||
| 296 | + | snap.notices[i] = t.n | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | snap.noticeOverflow = rt.noticeOverflow | |||
| 300 | + | snap.noticesOverlayActive = rt.noticesOverlay != nil | |||
| 301 | + | snap.noticesOverlayModel = rt.noticesOverlay.Clone() | |||
| 302 | + | rt.noticeMu.Unlock() | |||
| 303 | + | ||||
| 250 | rt.copyMu.Lock() | 304 | rt.copyMu.Lock() | ||
| 251 | snap.copyActive = rt.copyMode != nil | 305 | snap.copyActive = rt.copyMode != nil | ||
| 252 | snap.copyPane = rt.copyPane | 306 | snap.copyPane = rt.copyPane | ||
| @@ -256,9 +310,9 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 256 | snap.copyMode = ©ModeValue | 310 | snap.copyMode = ©ModeValue | ||
| 257 | } | 311 | } | ||
| 258 | snap.copySearchModel = rt.copySearch.Clone() | 312 | snap.copySearchModel = rt.copySearch.Clone() | ||
| 259 | - | snap.copyFeedback = rt.copyFeedback | 313 | + | snap.statusFeedback = rt.statusFeedback |
| 260 | - | if snap.copyFeedback != "" && !snap.copyActive { | 314 | + | if snap.statusFeedback != "" && !snap.copyActive { |
| 261 | - | rt.copyFeedback = "" | 315 | + | rt.statusFeedback = "" |
| 262 | } | 316 | } | ||
| 263 | rt.copyMu.Unlock() | 317 | rt.copyMu.Unlock() | ||
| 264 | 318 | ||||
M internal/usecase/daemon/palette.go +21 -2
| @@ -270,7 +270,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 270 | } else { | 270 | } else { | ||
| 271 | target.Stopped = true | 271 | target.Stopped = true | ||
| 272 | } | 272 | } | ||
| 273 | - | if !d.switchToTarget(sess, ac, target) { | 273 | + | if err := d.switchToTarget(sess, ac, target); err != nil { |
| 274 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | 274 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | ||
| 275 | d.invalidateRender(sess, ac, true, "palette.go") | 275 | d.invalidateRender(sess, ac, true, "palette.go") | ||
| 276 | return | 276 | return | ||
| @@ -305,6 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 305 | } | 305 | } | ||
| 306 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil { | 306 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil { | ||
| 307 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | 307 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | ||
| 308 | + | d.reportError(sess, err) | |||
| 308 | } else { | 309 | } else { | ||
| 309 | d.recordPaletteUse(cmd.Code) | 310 | d.recordPaletteUse(cmd.Code) | ||
| 310 | } | 311 | } | ||
| @@ -480,6 +481,24 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 480 | return nil | 481 | return nil | ||
| 481 | } | 482 | } | ||
| 482 | 483 | ||||
| 484 | + | func (e paletteExec) OpenNotifications() error { | |||
| 485 | + | e.d.enterNotices(e.sess, e.ac) | |||
| 486 | + | return nil | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | func (e paletteExec) YankLastNotification() error { | |||
| 490 | + | n, ok := e.d.notices.latest() | |||
| 491 | + | if !ok { | |||
| 492 | + | // Reported directly rather than returned: the generic cmd.Run error | |||
| 493 | + | // path only logs, so a returned error here would never reach the | |||
| 494 | + | // user as the warn toast this no-op is supposed to produce. | |||
| 495 | + | e.d.reportError(e.sess, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil)) | |||
| 496 | + | return nil | |||
| 497 | + | } | |||
| 498 | + | e.d.yankNotice(e.sess, e.ac, n) | |||
| 499 | + | return nil | |||
| 500 | + | } | |||
| 501 | + | ||||
| 483 | func (e paletteExec) JumpRecentSession(rank int) error { | 502 | func (e paletteExec) JumpRecentSession(rank int) error { | ||
| 484 | if rank < 1 || rank > len(e.recent) { | 503 | if rank < 1 || rank > len(e.recent) { | ||
| 485 | return command.ErrInvalidArguments | 504 | return command.ErrInvalidArguments | ||
| @@ -491,7 +510,7 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||||
| 491 | if e.d.beforeRecentSessionHandoff != nil { | 510 | if e.d.beforeRecentSessionHandoff != nil { | ||
| 492 | e.d.beforeRecentSessionHandoff() | 511 | e.d.beforeRecentSessionHandoff() | ||
| 493 | } | 512 | } | ||
| 494 | - | if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) { | 513 | + | if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil { |
| 495 | return command.ErrInvalidArguments | 514 | return command.ErrInvalidArguments | ||
| 496 | } | 515 | } | ||
| 497 | return nil | 516 | return nil | ||
M internal/usecase/daemon/palette_test.go +30 -5
| @@ -119,6 +119,29 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 119 | awaitFrame(t, sends, ports.MsgOutput) | 119 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | + | // TestPaletteCommandFailureSurfacesAsNotice drives a palette command whose Run | |||
| 123 | + | // target fails and asserts the failure reaches the user as a notice instead of | |||
| 124 | + | // only a log line. The new-tab spawn failure is wrapped as a domain.UserError | |||
| 125 | + | // with NoticeTabSpawn (session.go createTab), so it surfaces under that code | |||
| 126 | + | // rather than the NoticeInternal catch-all. | |||
| 127 | + | func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) { | |||
| 128 | + | p, release := newBlockingPTY(t) | |||
| 129 | + | defer release() | |||
| 130 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 131 | + | ptys := portsmocks.NewMockPTYFactory(t) | |||
| 132 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once() | |||
| 133 | + | d.ptys = ptys | |||
| 134 | + | ||||
| 135 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 136 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 137 | + | d.handleInput(sess, ac, []byte("CNT\r")) | |||
| 138 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 139 | + | ||||
| 140 | + | history := d.notices.history() | |||
| 141 | + | require.NotEmpty(t, history, "failed palette command must record a notice") | |||
| 142 | + | require.Equal(t, domain.NoticeTabSpawn, history[0].Code) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 122 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | 145 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | ||
| 123 | p, release := newBlockingPTY(t) | 146 | p, release := newBlockingPTY(t) | ||
| 124 | defer release() | 147 | defer release() | ||
| @@ -563,7 +586,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||||
| 563 | for i, cmd := range commands { | 586 | for i, cmd := range commands { | ||
| 564 | codes[i] = cmd.Code | 587 | codes[i] = cmd.Code | ||
| 565 | } | 588 | } | ||
| 566 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "VIS", "RNS", "RNT", "DET"}, codes) | 589 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) |
| 567 | } | 590 | } | ||
| 568 | 591 | ||||
| 569 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | 592 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | ||
| @@ -915,10 +938,12 @@ func TestPaletteExecMethods(t *testing.T) { | |||||
| 915 | require.NoError(t, exec.StackPane()) | 938 | require.NoError(t, exec.StackPane()) | ||
| 916 | require.NoError(t, exec.ToggleStack()) | 939 | require.NoError(t, exec.ToggleStack()) | ||
| 917 | require.NoError(t, exec.ClosePane()) | 940 | require.NoError(t, exec.ClosePane()) | ||
| 918 | - | require.NoError(t, exec.FocusPaneLeft()) | 941 | + | // A single pane remains: every direction is a benign no-neighbor move, |
| 919 | - | require.NoError(t, exec.FocusPaneRight()) | 942 | + | // not an error the palette should ever surface (Task 10). |
| 920 | - | require.NoError(t, exec.FocusPaneUp()) | 943 | + | require.ErrorIs(t, exec.FocusPaneLeft(), errNoNeighbor) |
| 921 | - | require.NoError(t, exec.FocusPaneDown()) | 944 | + | require.ErrorIs(t, exec.FocusPaneRight(), errNoNeighbor) |
| 945 | + | require.ErrorIs(t, exec.FocusPaneUp(), errNoNeighbor) | |||
| 946 | + | require.ErrorIs(t, exec.FocusPaneDown(), errNoNeighbor) | |||
| 922 | require.NoError(t, exec.CloseTab()) | 947 | require.NoError(t, exec.CloseTab()) | ||
| 923 | require.Len(t, sess.tabs, 2) | 948 | require.Len(t, sess.tabs, 2) | ||
| 924 | // Drain paints generated by methods above. | 949 | // Drain paints generated by methods above. | ||
M internal/usecase/daemon/pane_actions.go +6 -3
| @@ -48,6 +48,9 @@ func (d *Daemon) spawnPaneOp( | |||||
| 48 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 48 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | ||
| 49 | if err := mutate(tb.tree, oldFocus, newID, area); err != nil { | 49 | if err := mutate(tb.tree, oldFocus, newID, area); err != nil { | ||
| 50 | tb.mu.Unlock() | 50 | tb.mu.Unlock() | ||
| 51 | + | if errors.Is(err, layout.ErrTooSmall) { | |||
| 52 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) | |||
| 53 | + | } | |||
| 51 | return err | 54 | return err | ||
| 52 | } | 55 | } | ||
| 53 | tb.nextPaneID++ | 56 | tb.nextPaneID++ | ||
| @@ -56,7 +59,7 @@ func (d *Daemon) spawnPaneOp( | |||||
| 56 | _ = tb.tree.Close(newID) | 59 | _ = tb.tree.Close(newID) | ||
| 57 | tb.tree.Focus = oldFocus | 60 | tb.tree.Focus = oldFocus | ||
| 58 | tb.mu.Unlock() | 61 | tb.mu.Unlock() | ||
| 59 | - | return layout.ErrTooSmall | 62 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) |
| 60 | } | 63 | } | ||
| 61 | newRect := placementContent(placements, newID) | 64 | newRect := placementContent(placements, newID) | ||
| 62 | tabStableID := tb.stableID | 65 | tabStableID := tb.stableID | ||
| @@ -78,7 +81,7 @@ func (d *Daemon) spawnPaneOp( | |||||
| 78 | _ = tb.tree.Close(newID) | 81 | _ = tb.tree.Close(newID) | ||
| 79 | tb.tree.Focus = oldFocus | 82 | tb.tree.Focus = oldFocus | ||
| 80 | tb.mu.Unlock() | 83 | tb.mu.Unlock() | ||
| 81 | - | return err | 84 | + | return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) |
| 82 | } | 85 | } | ||
| 83 | 86 | ||||
| 84 | pctx, cancel := context.WithCancel(tb.ctx) | 87 | pctx, cancel := context.WithCancel(tb.ctx) | ||
| @@ -314,7 +317,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||||
| 314 | d.invalidateRender(sess, ac, true, "pane_actions.go") | 317 | d.invalidateRender(sess, ac, true, "pane_actions.go") | ||
| 315 | } | 318 | } | ||
| 316 | if errors.Is(err, layout.ErrNoPane) { | 319 | if errors.Is(err, layout.ErrNoPane) { | ||
| 317 | - | return nil | 320 | + | return errNoNeighbor |
| 318 | } | 321 | } | ||
| 319 | return err | 322 | return err | ||
| 320 | } | 323 | } | ||
M internal/usecase/daemon/pane_actions_test.go +21 -3
| @@ -85,14 +85,24 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||||
| 85 | 85 | ||||
| 86 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | 86 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | ||
| 87 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | 87 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | ||
| 88 | - | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once() | 88 | + | cause := errors.New("open failed") |
| 89 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once() | |||
| 89 | 90 | ||||
| 90 | tb := sess.activeTab() | 91 | tb := sess.activeTab() | ||
| 91 | tb.mu.Lock() | 92 | tb.mu.Lock() | ||
| 92 | beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes) | 93 | beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes) | ||
| 93 | tb.mu.Unlock() | 94 | tb.mu.Unlock() | ||
| 94 | 95 | ||||
| 95 | - | require.Error(t, d.splitPane(sess, nil, layout.Right)) | 96 | + | err := d.splitPane(sess, nil, layout.Right) |
| 97 | + | require.Error(t, err) | |||
| 98 | + | ||||
| 99 | + | var ue *domain.UserError | |||
| 100 | + | require.ErrorAs(t, err, &ue) | |||
| 101 | + | require.Equal(t, domain.NoticePaneSpawn, ue.Code) | |||
| 102 | + | require.Equal(t, domain.NoticeError, ue.Severity) | |||
| 103 | + | require.Equal(t, "couldn't open pane: shell failed to start", ue.Msg) | |||
| 104 | + | require.ErrorIs(t, err, cause) | |||
| 105 | + | require.NotContains(t, ue.Msg, cause.Error()) | |||
| 96 | 106 | ||||
| 97 | tb.mu.Lock() | 107 | tb.mu.Lock() | ||
| 98 | defer tb.mu.Unlock() | 108 | defer tb.mu.Unlock() | ||
| @@ -263,8 +273,16 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||||
| 263 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | 273 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | ||
| 264 | thirdPTY := portsmocks.NewMockPTY(t) | 274 | thirdPTY := portsmocks.NewMockPTY(t) | ||
| 265 | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe() | 275 | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe() | ||
| 266 | - | require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall) | 276 | + | err := d.stackPane(sess, nil) |
| 277 | + | require.ErrorIs(t, err, layout.ErrTooSmall) | |||
| 267 | require.Len(t, tb.panes, 2) | 278 | require.Len(t, tb.panes, 2) | ||
| 279 | + | ||||
| 280 | + | var ue *domain.UserError | |||
| 281 | + | require.ErrorAs(t, err, &ue) | |||
| 282 | + | require.Equal(t, domain.NoticeLayoutTooSmall, ue.Code) | |||
| 283 | + | require.Equal(t, domain.NoticeWarn, ue.Severity) | |||
| 284 | + | require.Equal(t, "not enough space to split", ue.Msg) | |||
| 285 | + | require.NotContains(t, ue.Msg, layout.ErrTooSmall.Error()) | |||
| 268 | } | 286 | } | ||
| 269 | 287 | ||||
| 270 | func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *portsmocks.MockPTY, *portsmocks.MockPTYFactory) { | 288 | func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *portsmocks.MockPTY, *portsmocks.MockPTYFactory) { | ||
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
| @@ -132,7 +132,7 @@ func TestTransactionalResizeMetrics(t *testing.T) { | |||||
| 132 | require.Equal(t, uint64(1), metrics.resizeCommits) | 132 | require.Equal(t, uint64(1), metrics.resizeCommits) | ||
| 133 | require.Equal(t, uint64(1), metrics.ptyFailures) | 133 | require.Equal(t, uint64(1), metrics.ptyFailures) | ||
| 134 | require.Equal(t, uint64(1), metrics.ptyRetries) | 134 | require.Equal(t, uint64(1), metrics.ptyRetries) | ||
| 135 | - | require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry") | 135 | + | require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry") |
| 136 | require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame") | 136 | require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame") | ||
| 137 | require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested()) | 137 | require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested()) | ||
| 138 | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size) | 138 | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size) | ||
M internal/usecase/daemon/picker.go +82 -122
| @@ -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,136 +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 | - | d.killPickerTarget(target) | |||
| 126 | - | } | |||
| 127 | - | d.refreshPicker(ac) | |||
| 128 | - | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 129 | - | return | |||
| 130 | - | case 'j': | |||
| 131 | - | ac.overlays.picker.Down() | |||
| 132 | - | changed = true | |||
| 133 | - | case 'k': | |||
| 134 | - | ac.overlays.picker.Up() | |||
| 135 | - | changed = true | |||
| 136 | case '\r', '\n': | 136 | case '\r', '\n': | ||
| 137 | - | switchTarget = true | 137 | + | return listInputResult{action: b, exit: true} |
| 138 | - | exit = true | 138 | + | default: |
| 139 | - | case 'q', 0x03, 0x1b: | 139 | + | return listInputResult{} |
| 140 | - | if data[i] == 0x1b { | |||
| 141 | - | tail := data[i:] | |||
| 142 | - | consumed, ok := routePickerEscape(ac.overlays.picker, tail) | |||
| 143 | - | if ok { | |||
| 144 | - | i += consumed - 1 | |||
| 145 | - | changed = true | |||
| 146 | - | continue | |||
| 147 | - | } | |||
| 148 | - | if len(tail) == 1 { | |||
| 149 | - | d.retainPickerESCLocked(ac) | |||
| 150 | - | break | |||
| 151 | - | } | |||
| 152 | - | if isPickerEscapePrefix(tail) { | |||
| 153 | - | ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], tail...) | |||
| 154 | - | break | |||
| 155 | - | } | |||
| 156 | - | } | |||
| 157 | - | exit = true | |||
| 158 | } | 140 | } | ||
| 159 | - | } | 141 | + | }) |
| 160 | var target picker.Target | 142 | var target picker.Target | ||
| 161 | var ok bool | 143 | var ok bool | ||
| 162 | - | if switchTarget { | 144 | + | if result.action == 'x' || result.action == '\r' || result.action == '\n' { |
| 163 | - | target, ok = ac.overlays.picker.Selected() | 145 | + | target, ok = rt.picker.Selected() |
| 164 | } | 146 | } | ||
| 165 | - | ac.overlays.pickerMu.Unlock() | 147 | + | rt.pickerMu.Unlock() |
| 166 | 148 | ||||
| 167 | - | 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 { | |||
| 168 | d.registerPreviewForSelection(ac) | 160 | d.registerPreviewForSelection(ac) | ||
| 169 | } | 161 | } | ||
| 170 | - | if exit { | 162 | + | if result.exit { |
| 171 | d.closePicker(ac) | 163 | d.closePicker(ac) | ||
| 172 | } | 164 | } | ||
| 173 | - | if switchTarget && ok { | 165 | + | if (result.action == '\r' || result.action == '\n') && ok { |
| 174 | - | d.switchToTarget(sess, ac, target) | 166 | + | if err := d.switchToTarget(sess, ac, target); err != nil { |
| 167 | + | d.reportError(sess, err) | |||
| 168 | + | } | |||
| 175 | return | 169 | return | ||
| 176 | } | 170 | } | ||
| 177 | - | if exit || changed { | 171 | + | if result.exit || result.changed { |
| 178 | d.invalidateRender(sess, ac, true, "picker.go") | 172 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 179 | } | 173 | } | ||
| 180 | } | 174 | } | ||
| 181 | 175 | ||||
| 182 | - | func (d *Daemon) retainPickerESCLocked(ac *attachedClient) { | |||
| 183 | - | ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], keys.ESC) | |||
| 184 | - | ac.overlays.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |||
| 185 | - | ac.overlays.pickerMu.Lock() | |||
| 186 | - | if ac.overlays.pickerESC.timer != timer || len(ac.overlays.pickerPending) != 1 || ac.overlays.pickerPending[0] != keys.ESC || ac.overlays.picker == nil { | |||
| 187 | - | ac.overlays.pickerMu.Unlock() | |||
| 188 | - | return | |||
| 189 | - | } | |||
| 190 | - | ac.overlays.pickerPending = nil | |||
| 191 | - | ac.overlays.pickerESC.timer = nil | |||
| 192 | - | ac.overlays.pickerESC.done = nil | |||
| 193 | - | ac.overlays.picker = nil | |||
| 194 | - | generation := ac.overlays.pickerPreviewGeneration | |||
| 195 | - | ac.overlays.pickerMu.Unlock() | |||
| 196 | - | ||||
| 197 | - | d.clearPreviewGeneration(ac, generation) | |||
| 198 | - | if sess := ac.currentSession(); sess != nil { | |||
| 199 | - | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 200 | - | } | |||
| 201 | - | }) | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) { | |||
| 205 | - | ac.overlays.pickerESC.stop() | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func routePickerEscape(m *picker.Model, data []byte) (int, bool) { | |||
| 209 | - | if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') { | |||
| 210 | - | switch data[2] { | |||
| 211 | - | case 'A': | |||
| 212 | - | m.Up() | |||
| 213 | - | return 3, true | |||
| 214 | - | case 'B': | |||
| 215 | - | m.Down() | |||
| 216 | - | return 3, true | |||
| 217 | - | } | |||
| 218 | - | } | |||
| 219 | - | return 0, false | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | func isPickerEscapePrefix(data []byte) bool { | |||
| 223 | - | return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') | |||
| 224 | - | } | |||
| 225 | - | ||||
| 226 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | 176 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | ||
| 227 | if ac == nil || ac.overlays == nil { | 177 | if ac == nil || ac.overlays == nil { | ||
| 228 | return | 178 | return | ||
| @@ -364,7 +314,7 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||||
| 364 | ac.overlays.pickerMu.Lock() | 314 | ac.overlays.pickerMu.Lock() | ||
| 365 | ac.overlays.picker = nil | 315 | ac.overlays.picker = nil | ||
| 366 | ac.overlays.pickerPending = nil | 316 | ac.overlays.pickerPending = nil | ||
| 367 | - | d.stopPickerPendingTimerLocked(ac) | 317 | + | ac.overlays.pickerESC.stop() |
| 368 | generation := ac.overlays.pickerPreviewGeneration | 318 | generation := ac.overlays.pickerPreviewGeneration | ||
| 369 | ac.overlays.pickerMu.Unlock() | 319 | ac.overlays.pickerMu.Unlock() | ||
| 370 | d.clearPreviewGeneration(ac, generation) | 320 | d.clearPreviewGeneration(ac, generation) | ||
| @@ -394,19 +344,20 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 394 | // switchToTarget resolves named lifecycle targets and commits their transition | 344 | // switchToTarget resolves named lifecycle targets and commits their transition | ||
| 395 | // while d.mu is held. A named palette result is allowed to cross an | 345 | // while d.mu is held. A named palette result is allowed to cross an | ||
| 396 | // active/stopped transition, but it never follows a same-name replacement. | 346 | // active/stopped transition, but it never follows a same-name replacement. | ||
| 397 | - | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool { | 347 | + | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error { |
| 398 | d.mu.Lock() | 348 | d.mu.Lock() | ||
| 399 | var ( | 349 | var ( | ||
| 400 | targetSess *session | 350 | targetSess *session | ||
| 401 | old *attachedClient | 351 | old *attachedClient | ||
| 402 | cleanups []renderLifecycleCleanup | 352 | cleanups []renderLifecycleCleanup | ||
| 403 | switched bool | 353 | switched bool | ||
| 354 | + | cause error | |||
| 404 | ) | 355 | ) | ||
| 405 | if target.Name != "" { | 356 | if target.Name != "" { | ||
| 406 | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | 357 | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | ||
| 407 | if ok { | 358 | if ok { | ||
| 408 | if isStopped { | 359 | if isStopped { | ||
| 409 | - | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | 360 | + | targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) |
| 410 | } else { | 361 | } else { | ||
| 411 | // The snapshot ID may describe the stopped representation. The | 362 | // The snapshot ID may describe the stopped representation. The | ||
| 412 | // locked name/lifecycle resolver chose this active incarnation. | 363 | // locked name/lifecycle resolver chose this active incarnation. | ||
| @@ -422,14 +373,14 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||||
| 422 | 373 | ||||
| 423 | if !switched { | 374 | if !switched { | ||
| 424 | d.invalidateRender(from, ac, true, "picker.go") | 375 | d.invalidateRender(from, ac, true, "picker.go") | ||
| 425 | - | return false | 376 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) |
| 426 | } | 377 | } | ||
| 427 | finishRenderLifecycleCleanups(cleanups) | 378 | finishRenderLifecycleCleanups(cleanups) | ||
| 428 | if old != nil && old != ac { | 379 | if old != nil && old != ac { | ||
| 429 | d.unregisterPreview(old) | 380 | d.unregisterPreview(old) | ||
| 430 | old.clearPreviousSession() | 381 | old.clearPreviousSession() | ||
| 431 | old.setSession(nil) | 382 | old.setSession(nil) | ||
| 432 | - | d.notifyDetachedAsync(old, ports.ReasonDetach) | 383 | + | d.notifyDetachedAsync(old, ports.ReasonReplaced) |
| 433 | } | 384 | } | ||
| 434 | if targetSess == from { | 385 | if targetSess == from { | ||
| 435 | d.activateTab(from, from.activeTab()) | 386 | d.activateTab(from, from.activeTab()) | ||
| @@ -437,7 +388,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||||
| 437 | } else { | 388 | } else { | ||
| 438 | d.firstPaint(targetSess, ac, ac.size) | 389 | d.firstPaint(targetSess, ac, ac.size) | ||
| 439 | } | 390 | } | ||
| 440 | - | return true | 391 | + | return nil |
| 441 | } | 392 | } | ||
| 442 | 393 | ||||
| 443 | // resolveNamedLifecycleTargetLocked chooses exactly one current representation | 394 | // resolveNamedLifecycleTargetLocked chooses exactly one current representation | ||
| @@ -463,6 +414,10 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||||
| 463 | // switchToActiveTargetLocked commits an active target handoff. Caller holds | 414 | // switchToActiveTargetLocked commits an active target handoff. Caller holds | ||
| 464 | // d.mu and releases it before completing render cleanup or first paint. | 415 | // d.mu and releases it before completing render cleanup or first paint. | ||
| 465 | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | 416 | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | ||
| 417 | + | // The caller already holds d.mu. Keep handoff ownership atomic with global | |||
| 418 | + | // routing so it cannot select an attachment midway between sessions. | |||
| 419 | + | d.notices.routingMu.Lock() | |||
| 420 | + | defer d.notices.routingMu.Unlock() | |||
| 466 | if d.sessions[target.Session] != targetSess { | 421 | if d.sessions[target.Session] != targetSess { | ||
| 467 | return nil, nil, nil, false | 422 | return nil, nil, nil, false | ||
| 468 | } | 423 | } | ||
| @@ -517,11 +472,15 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 517 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | 472 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | ||
| 518 | // the handoff while d.mu is held. Creation failure leaves the source client | 473 | // the handoff while d.mu is held. Creation failure leaves the source client | ||
| 519 | // and stopped record untouched. | 474 | // and stopped record untouched. | ||
| 520 | - | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) { | 475 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) { |
| 476 | + | // The caller already holds d.mu. Keep handoff ownership atomic with global | |||
| 477 | + | // routing so it cannot select an attachment midway between sessions. | |||
| 478 | + | d.notices.routingMu.Lock() | |||
| 479 | + | defer d.notices.routingMu.Unlock() | |||
| 521 | from.mu.Lock() | 480 | from.mu.Lock() | ||
| 522 | if from.client != ac { | 481 | if from.client != ac { | ||
| 523 | from.mu.Unlock() | 482 | from.mu.Unlock() | ||
| 524 | - | return nil, nil, false | 483 | + | return nil, nil, false, nil |
| 525 | } | 484 | } | ||
| 526 | term := from.terminal | 485 | term := from.terminal | ||
| 527 | env := copyEnvironment(from.env) | 486 | env := copyEnvironment(from.env) | ||
| @@ -530,7 +489,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 530 | if err != nil { | 489 | if err != nil { | ||
| 531 | from.mu.Unlock() | 490 | from.mu.Unlock() | ||
| 532 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | 491 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | ||
| 533 | - | return nil, nil, false | 492 | + | return nil, nil, false, err |
| 534 | } | 493 | } | ||
| 535 | from.client = nil | 494 | from.client = nil | ||
| 536 | ac.setSession(nil) | 495 | ac.setSession(nil) | ||
| @@ -543,7 +502,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 543 | targetSess.mu.Unlock() | 502 | targetSess.mu.Unlock() | ||
| 544 | d.touchMRU(targetSess) | 503 | d.touchMRU(targetSess) | ||
| 545 | ac.recordPreviousSession(from) | 504 | ac.recordPreviousSession(from) | ||
| 546 | - | return targetSess, cleanups, true | 505 | + | return targetSess, cleanups, true, nil |
| 547 | } | 506 | } | ||
| 548 | 507 | ||||
| 549 | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | 508 | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | ||
| @@ -574,7 +533,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 574 | switched bool | 533 | switched bool | ||
| 575 | ) | 534 | ) | ||
| 576 | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | 535 | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | ||
| 577 | - | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | 536 | + | targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) |
| 578 | } | 537 | } | ||
| 579 | d.mu.Unlock() | 538 | d.mu.Unlock() | ||
| 580 | if !switched { | 539 | if !switched { | ||
| @@ -586,7 +545,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 586 | return true | 545 | return true | ||
| 587 | } | 546 | } | ||
| 588 | 547 | ||||
| 589 | - | func (d *Daemon) killPickerTarget(target picker.Target) { | 548 | + | func (d *Daemon) killPickerTarget(target picker.Target) error { |
| 590 | if target.Stopped { | 549 | if target.Stopped { | ||
| 591 | d.mu.Lock() | 550 | d.mu.Lock() | ||
| 592 | stopped, ok := d.stopped[target.Name] | 551 | stopped, ok := d.stopped[target.Name] | ||
| @@ -594,19 +553,20 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||||
| 594 | if err := d.persist.Delete(target.Name); err != nil { | 553 | if err := d.persist.Delete(target.Name); err != nil { | ||
| 595 | d.mu.Unlock() | 554 | d.mu.Unlock() | ||
| 596 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | 555 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | ||
| 597 | - | return | 556 | + | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) |
| 598 | } | 557 | } | ||
| 599 | delete(d.stopped, target.Name) | 558 | delete(d.stopped, target.Name) | ||
| 600 | } | 559 | } | ||
| 601 | d.mu.Unlock() | 560 | d.mu.Unlock() | ||
| 602 | - | return | 561 | + | return nil |
| 603 | } | 562 | } | ||
| 604 | d.mu.Lock() | 563 | d.mu.Lock() | ||
| 605 | targetSess := d.sessions[target.Session] | 564 | targetSess := d.sessions[target.Session] | ||
| 606 | d.mu.Unlock() | 565 | d.mu.Unlock() | ||
| 607 | if targetSess != nil { | 566 | if targetSess != nil { | ||
| 608 | - | _ = d.killSession(targetSess, ports.ReasonSessionKilled, true) | 567 | + | return d.killSession(targetSess, ports.ReasonSessionKilled, true) |
| 609 | } | 568 | } | ||
| 569 | + | return nil | |||
| 610 | } | 570 | } | ||
| 611 | 571 | ||||
| 612 | func (d *Daemon) refreshPicker(ac *attachedClient) { | 572 | func (d *Daemon) refreshPicker(ac *attachedClient) { | ||
M internal/usecase/daemon/picker_test.go +121 -3
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -22,6 +23,24 @@ import ( | |||||
| 22 | 23 | ||||
| 23 | // --- test doubles ----------------------------------------------------------- | 24 | // --- test doubles ----------------------------------------------------------- | ||
| 24 | 25 | ||||
| 26 | + | // failingDeleteStore is a ports.Store whose Delete always fails, used to | |||
| 27 | + | // exercise the picker's kill-target error path without disturbing the | |||
| 28 | + | // generated mock's default Delete expectation. | |||
| 29 | + | type failingDeleteStore struct{ err error } | |||
| 30 | + | ||||
| 31 | + | func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false } | |||
| 32 | + | func (failingDeleteStore) Set([]byte, []byte) error { return nil } | |||
| 33 | + | func (s failingDeleteStore) Delete([]byte) error { return s.err } | |||
| 34 | + | func (failingDeleteStore) Range(func(k, v []byte) bool) {} | |||
| 35 | + | func (failingDeleteStore) Sync() error { return nil } | |||
| 36 | + | func (failingDeleteStore) Close() error { return nil } | |||
| 37 | + | ||||
| 38 | + | type refusingSnapshotDeleteStore struct{ err error } | |||
| 39 | + | ||||
| 40 | + | func (refusingSnapshotDeleteStore) Write(string, []byte) error { return nil } | |||
| 41 | + | func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |||
| 42 | + | func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err } | |||
| 43 | + | ||||
| 25 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | 44 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | ||
| 26 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | 45 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | ||
| 27 | tb.ctx, tb.cancel = ctx, cancel | 46 | tb.ctx, tb.cancel = ctx, cancel | ||
| @@ -140,7 +159,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||||
| 140 | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | 159 | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | ||
| 141 | d.sessions[replacement.id] = replacement | 160 | d.sessions[replacement.id] = replacement | ||
| 142 | 161 | ||||
| 143 | - | require.False(t, d.switchToTarget(current, ac, target)) | 162 | + | require.Error(t, d.switchToTarget(current, ac, target)) |
| 144 | require.Same(t, current, ac.currentSession()) | 163 | require.Same(t, current, ac.currentSession()) | ||
| 145 | } | 164 | } | ||
| 146 | 165 | ||||
| @@ -152,7 +171,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |||||
| 152 | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | 171 | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | ||
| 153 | current.client = &attachedClient{} | 172 | current.client = &attachedClient{} | ||
| 154 | 173 | ||||
| 155 | - | require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | 174 | + | require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) |
| 156 | require.Equal(t, 0, current.active) | 175 | require.Equal(t, 0, current.active) | ||
| 157 | } | 176 | } | ||
| 158 | 177 | ||||
| @@ -386,7 +405,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||||
| 386 | det := awaitFrame(t, sends2, ports.MsgDetached) | 405 | det := awaitFrame(t, sends2, ports.MsgDetached) | ||
| 387 | dm, err := ports.UnmarshalDetached(det.Payload) | 406 | dm, err := ports.UnmarshalDetached(det.Payload) | ||
| 388 | require.NoError(t, err) | 407 | require.NoError(t, err) | ||
| 389 | - | require.Equal(t, ports.ReasonDetach, dm.Reason) | 408 | + | require.Equal(t, ports.ReasonReplaced, dm.Reason) |
| 390 | awaitFrame(t, sends1, ports.MsgOutput) | 409 | awaitFrame(t, sends1, ports.MsgOutput) | ||
| 391 | } | 410 | } | ||
| 392 | 411 | ||||
| @@ -945,3 +964,102 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 945 | t.Fatal("floating prewarm PTY was not closed") | 964 | t.Fatal("floating prewarm PTY was not closed") | ||
| 946 | } | 965 | } | ||
| 947 | } | 966 | } | ||
| 967 | + | ||||
| 968 | + | // TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut drives | |||
| 969 | + | // Enter on a stopped picker entry whose restore spawn fails (mock PTY Open | |||
| 970 | + | // error). The failure must reach the user as a NoticeSessionUnavailable | |||
| 971 | + | // notice and toast, and the client must remain attached to its origin | |||
| 972 | + | // session rather than being left in limbo. | |||
| 973 | + | func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *testing.T) { | |||
| 974 | + | p, release := newBlockingPTY(t) | |||
| 975 | + | defer release() | |||
| 976 | + | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 977 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7} | |||
| 978 | + | cause := errors.New("open failed") | |||
| 979 | + | ptys := portsmocks.NewMockPTYFactory(t) | |||
| 980 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once() | |||
| 981 | + | d.ptys = ptys | |||
| 982 | + | ||||
| 983 | + | d.enterPicker(from, ac) | |||
| 984 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 985 | + | d.handleInput(from, ac, []byte("j")) | |||
| 986 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 987 | + | d.handleInput(from, ac, []byte("\r")) | |||
| 988 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 989 | + | ||||
| 990 | + | history := d.notices.history() | |||
| 991 | + | require.Len(t, history, 1, "failed stopped-session restore must record exactly one notice") | |||
| 992 | + | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |||
| 993 | + | require.Equal(t, domain.NoticeError, history[0].Severity) | |||
| 994 | + | ||||
| 995 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 996 | + | require.Equal(t, domain.NoticeSessionUnavailable, toasts[0].Code) | |||
| 997 | + | ||||
| 998 | + | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | |||
| 999 | + | } | |||
| 1000 | + | ||||
| 1001 | + | func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) { | |||
| 1002 | + | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 1003 | + | defer releaseAll(releases) | |||
| 1004 | + | cause := errors.New("snapshot delete refused") | |||
| 1005 | + | WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d) | |||
| 1006 | + | target := d.sessions[domain.SessionID("recent")] | |||
| 1007 | + | ||||
| 1008 | + | d.enterPicker(from, ac) | |||
| 1009 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1010 | + | d.handleInput(from, ac, []byte("k")) | |||
| 1011 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1012 | + | d.handleInput(from, ac, []byte("x")) | |||
| 1013 | + | ||||
| 1014 | + | history := d.notices.history() | |||
| 1015 | + | require.Len(t, history, 1, "the refused purge must be reported exactly once") | |||
| 1016 | + | require.Equal(t, domain.NoticeInternal, history[0].Code) | |||
| 1017 | + | require.Contains(t, history[0].Details, cause.Error()) | |||
| 1018 | + | require.Len(t, awaitToastCount(t, ac, 1), 1, "the refusal must produce one visible notice") | |||
| 1019 | + | ||||
| 1020 | + | require.Same(t, from, ac.currentSession(), "purging another session must preserve the attachment") | |||
| 1021 | + | require.True(t, ac.overlays.pickerActive(), "x refreshes the picker instead of closing it") | |||
| 1022 | + | d.mu.Lock() | |||
| 1023 | + | _, stillActive := d.sessions[target.id] | |||
| 1024 | + | d.mu.Unlock() | |||
| 1025 | + | require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure") | |||
| 1026 | + | } | |||
| 1027 | + | ||||
| 1028 | + | // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1029 | + | // drives 'x' on a stopped picker entry whose persisted-record delete fails | |||
| 1030 | + | // (mock store error). The failure must reach the user as a | |||
| 1031 | + | // NoticePersistDelete notice and toast, and the entry must remain listed | |||
| 1032 | + | // after the picker refreshes rather than silently vanishing from the daemon's | |||
| 1033 | + | // bookkeeping while still present on disk. | |||
| 1034 | + | func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry(t *testing.T) { | |||
| 1035 | + | p, release := newBlockingPTY(t) | |||
| 1036 | + | defer release() | |||
| 1037 | + | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1038 | + | cause := errors.New("delete failed") | |||
| 1039 | + | WithStore(failingDeleteStore{err: cause})(d) | |||
| 1040 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7} | |||
| 1041 | + | ||||
| 1042 | + | d.enterPicker(from, ac) | |||
| 1043 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1044 | + | d.handleInput(from, ac, []byte("j")) | |||
| 1045 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1046 | + | d.handleInput(from, ac, []byte("x")) | |||
| 1047 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1048 | + | ||||
| 1049 | + | history := d.notices.history() | |||
| 1050 | + | require.NotEmpty(t, history, "failed persisted-record delete must record a notice") | |||
| 1051 | + | require.Equal(t, domain.NoticePersistDelete, history[0].Code) | |||
| 1052 | + | require.Equal(t, domain.NoticeError, history[0].Severity) | |||
| 1053 | + | ||||
| 1054 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 1055 | + | require.Equal(t, domain.NoticePersistDelete, toasts[0].Code) | |||
| 1056 | + | ||||
| 1057 | + | views, _ := d.pickerViews(from) | |||
| 1058 | + | var stillListed bool | |||
| 1059 | + | for _, v := range views { | |||
| 1060 | + | if v.Stopped && v.Name == "stopped" { | |||
| 1061 | + | stillListed = true | |||
| 1062 | + | } | |||
| 1063 | + | } | |||
| 1064 | + | require.True(t, stillListed, "entry must remain listed after refreshPicker when the delete failed") | |||
| 1065 | + | } | |||
M internal/usecase/daemon/prompt.go +12 -0
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| @@ -30,6 +31,14 @@ func (d *Daemon) closePrompt(ac *attachedClient) { | |||||
| 30 | ac.overlays.promptMu.Unlock() | 31 | ac.overlays.promptMu.Unlock() | ||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| 34 | + | // promptValidationError identifies expected prompt feedback that should stay | |||
| 35 | + | // inline rather than creating a history entry or toast. | |||
| 36 | + | func promptValidationError(err error) bool { | |||
| 37 | + | return errors.Is(err, domain.ErrInvalidSessionName) || | |||
| 38 | + | errors.Is(err, errSessionNameRequired) || | |||
| 39 | + | errors.Is(err, errSessionNameInUse) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 33 | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | 42 | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | ||
| 34 | sess := ac.currentSession() | 43 | sess := ac.currentSession() | ||
| 35 | if sess == nil { | 44 | if sess == nil { | ||
| @@ -76,6 +85,9 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||||
| 76 | ac.overlays.prompt.SetError(err.Error()) | 85 | ac.overlays.prompt.SetError(err.Error()) | ||
| 77 | } | 86 | } | ||
| 78 | ac.overlays.promptMu.Unlock() | 87 | ac.overlays.promptMu.Unlock() | ||
| 88 | + | if !promptValidationError(err) { | |||
| 89 | + | d.reportError(sess, err) | |||
| 90 | + | } | |||
| 79 | d.invalidateRender(sess, ac, true, "prompt.go") | 91 | d.invalidateRender(sess, ac, true, "prompt.go") | ||
| 80 | return | 92 | return | ||
| 81 | } | 93 | } | ||
M internal/usecase/daemon/prompt_test.go +82 -1
| @@ -2,13 +2,21 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 9 | - | "github.com/stretchr/testify/require" | |||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| 14 | + | type failingPTYFactory struct{ err error } | |||
| 15 | + | ||||
| 16 | + | func (f failingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) { | |||
| 17 | + | return nil, f.err | |||
| 18 | + | } | |||
| 19 | + | ||||
| 12 | func TestPromptModalGeometry(t *testing.T) { | 20 | func TestPromptModalGeometry(t *testing.T) { | ||
| 13 | base := domain.Size{Cols: 100, Rows: 40} | 21 | base := domain.Size{Cols: 100, Rows: 40} | ||
| 14 | 22 | ||||
| @@ -72,6 +80,79 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) { | |||||
| 72 | require.Contains(t, string(out.Data), "name already in use") | 80 | require.Contains(t, string(out.Data), "name already in use") | ||
| 73 | } | 81 | } | ||
| 74 | 82 | ||||
| 83 | + | func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) { | |||
| 84 | + | p, release := newBlockingPTY(t) | |||
| 85 | + | defer release() | |||
| 86 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 87 | + | cause := errors.New("fork/exec /bin/sh: permission denied") | |||
| 88 | + | d.ptys = failingPTYFactory{err: cause} | |||
| 89 | + | ||||
| 90 | + | // Submit through the actual prompt input boundary rather than calling its | |||
| 91 | + | // callback directly. | |||
| 92 | + | d.enterPrompt(sess, ac, " Create session ", "", func(name string) error { | |||
| 93 | + | return d.createSessionAndSwitch(sess, ac, name) | |||
| 94 | + | }) | |||
| 95 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 96 | + | d.handlePromptInput(ac, []byte("new\r")) | |||
| 97 | + | repaint := awaitFrame(t, sends, ports.MsgOutput) | |||
| 98 | + | output, err := ports.UnmarshalOutput(repaint.Payload) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | require.Contains(t, string(output.Data), "couldn't create session: shell failed to start") | |||
| 101 | + | ||||
| 102 | + | history := d.notices.history() | |||
| 103 | + | require.Len(t, history, 1, "the prompt boundary must report the failure once") | |||
| 104 | + | notice := history[0] | |||
| 105 | + | require.Equal(t, domain.NoticeSessionSpawn, notice.Code) | |||
| 106 | + | require.Equal(t, "couldn't create session: shell failed to start", notice.Message) | |||
| 107 | + | require.NotContains(t, notice.Message, cause.Error()) | |||
| 108 | + | require.Contains(t, notice.Details, cause.Error()) | |||
| 109 | + | require.True(t, ac.overlays.promptActive(), "failed submission stays inline") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) { | |||
| 113 | + | tests := []struct { | |||
| 114 | + | name string | |||
| 115 | + | input []byte | |||
| 116 | + | wantErr error | |||
| 117 | + | setup func(*Daemon) | |||
| 118 | + | }{ | |||
| 119 | + | {name: "invalid name", input: []byte("invalid name\r"), wantErr: domain.ErrInvalidSessionName}, | |||
| 120 | + | {name: "required name", input: []byte("\r"), wantErr: errSessionNameRequired}, | |||
| 121 | + | { | |||
| 122 | + | name: "name in use", | |||
| 123 | + | input: []byte("taken\r"), | |||
| 124 | + | wantErr: errSessionNameInUse, | |||
| 125 | + | setup: func(d *Daemon) { | |||
| 126 | + | d.sessions["taken"] = &session{id: "taken", name: "taken"} | |||
| 127 | + | }, | |||
| 128 | + | }, | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | for _, tt := range tests { | |||
| 132 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 133 | + | p, release := newBlockingPTY(t) | |||
| 134 | + | defer release() | |||
| 135 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 136 | + | if tt.setup != nil { | |||
| 137 | + | tt.setup(d) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | d.enterPrompt(sess, ac, " Create session ", "", func(name string) error { | |||
| 141 | + | return d.createSessionAndSwitch(sess, ac, name) | |||
| 142 | + | }) | |||
| 143 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 144 | + | d.handlePromptInput(ac, tt.input) | |||
| 145 | + | repaint := awaitFrame(t, sends, ports.MsgOutput) | |||
| 146 | + | output, err := ports.UnmarshalOutput(repaint.Payload) | |||
| 147 | + | require.NoError(t, err) | |||
| 148 | + | require.Contains(t, string(output.Data), tt.wantErr.Error()) | |||
| 149 | + | ||||
| 150 | + | require.True(t, ac.overlays.promptActive()) | |||
| 151 | + | require.Empty(t, d.notices.history()) | |||
| 152 | + | }) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 75 | func TestPromptEscapeCancelsWithoutRename(t *testing.T) { | 156 | func TestPromptEscapeCancelsWithoutRename(t *testing.T) { | ||
| 76 | p, release := newBlockingPTY(t) | 157 | p, release := newBlockingPTY(t) | ||
| 77 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 158 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
M internal/usecase/daemon/render.go +6 -2
| @@ -101,6 +101,10 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 101 | d.processPTYData(sess, tb, p, buf[:n], true) | 101 | d.processPTYData(sess, tb, p, buf[:n], true) | ||
| 102 | } | 102 | } | ||
| 103 | if err != nil { | 103 | if err != nil { | ||
| 104 | + | sess.mu.Lock() | |||
| 105 | + | sessionName := sess.name | |||
| 106 | + | sess.mu.Unlock() | |||
| 107 | + | d.log.Info("pane pty closed", "err", err, "session", sessionName) | |||
| 104 | if p.onExit != nil { | 108 | if p.onExit != nil { | ||
| 105 | p.onExit() | 109 | p.onExit() | ||
| 106 | } else { | 110 | } else { | ||
| @@ -294,7 +298,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 294 | d.refreshSessionFocusedTitles(sess) | 298 | d.refreshSessionFocusedTitles(sess) | ||
| 295 | // Contextual ranks are completely captured under paletteMu. Compose them | 299 | // Contextual ranks are completely captured under paletteMu. Compose them | ||
| 296 | // without reading the live MRU, whose order may have changed mid-interaction. | 300 | // without reading the live MRU, whose order may have changed mid-interaction. | ||
| 297 | - | bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent) | 301 | + | bars := d.barStateForPaletteHints(sess, overlays.statusFeedback, overlays.paletteHints, overlays.paletteRecent) |
| 298 | applied := ac.getAppliedTheme() | 302 | applied := ac.getAppliedTheme() | ||
| 299 | bars.theme = applied.Raw | 303 | bars.theme = applied.Raw | ||
| 300 | attentionVisible := pulseVisible(bars.attentionFrame) | 304 | attentionVisible := pulseVisible(bars.attentionFrame) | ||
| @@ -323,7 +327,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 323 | capturedOverlays := capturedOverlayRenderState{ | 327 | capturedOverlays := capturedOverlayRenderState{ | ||
| 324 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | 328 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | ||
| 325 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | 329 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | ||
| 326 | - | copyFeedback: overlays.copyFeedback, | 330 | + | statusFeedback: overlays.statusFeedback, |
| 327 | } | 331 | } | ||
| 328 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | 332 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | ||
| 329 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | 333 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | ||
M internal/usecase/daemon/render_capture.go +7 -4
| @@ -86,11 +86,14 @@ type capturedFloatingRenderState struct { | |||||
| 86 | 86 | ||||
| 87 | type capturedOverlayRenderState struct { | 87 | type capturedOverlayRenderState struct { | ||
| 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | ||
| 89 | + | noticesOverlayActive bool | |||
| 89 | copyMode *scopy.Mode | 90 | copyMode *scopy.Mode | ||
| 90 | copyPaneID layout.PaneID | 91 | copyPaneID layout.PaneID | ||
| 91 | - | copyFeedback string | 92 | + | statusFeedback string |
| 92 | paletteGuidance string | 93 | paletteGuidance string | ||
| 93 | - | copySearch, picker, palette, prompt capturedModal | 94 | + | copySearch, picker, palette, prompt, noticesOverlay capturedModal |
| 95 | + | notices []domain.Notification | |||
| 96 | + | noticeOverflow int | |||
| 94 | } | 97 | } | ||
| 95 | 98 | ||||
| 96 | type capturedModal struct { | 99 | type capturedModal struct { | ||
| @@ -100,7 +103,7 @@ type capturedModal struct { | |||||
| 100 | } | 103 | } | ||
| 101 | 104 | ||||
| 102 | func (o capturedOverlayRenderState) active() bool { | 105 | func (o capturedOverlayRenderState) active() bool { | ||
| 103 | - | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive | 106 | + | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive |
| 104 | } | 107 | } | ||
| 105 | 108 | ||||
| 106 | type capturedCursorInputs struct { | 109 | type capturedCursorInputs struct { | ||
| @@ -329,7 +332,7 @@ func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) | |||||
| 329 | 332 | ||||
| 330 | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | 333 | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | ||
| 331 | style, hasStyle := p.screen.CursorStyle() | 334 | style, hasStyle := p.screen.CursorStyle() | ||
| 332 | - | hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | 335 | + | hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive |
| 333 | return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content} | 336 | return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content} | ||
| 334 | } | 337 | } | ||
| 335 | 338 | ||||
M internal/usecase/daemon/render_damage_receipt_test.go +65 -0
| @@ -104,6 +104,10 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||||
| 104 | } { | 104 | } { | ||
| 105 | t.Run(tt.name, func(t *testing.T) { | 105 | t.Run(tt.name, func(t *testing.T) { | ||
| 106 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | 106 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | ||
| 107 | + | // Prepare failures now schedule recovery through the coordinator; | |||
| 108 | + | // retain the failed transaction here until this test explicitly retries. | |||
| 109 | + | d.clock = newNoticeClock() | |||
| 110 | + | d.attachCoordinator(sess, nil, ac, true) | |||
| 107 | healthy := ac.transport() | 111 | healthy := ac.transport() | ||
| 108 | p := sess.tabs[0].focusedPane() | 112 | p := sess.tabs[0].focusedPane() | ||
| 109 | 113 | ||||
| @@ -136,6 +140,67 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||||
| 136 | } | 140 | } | ||
| 137 | } | 141 | } | ||
| 138 | 142 | ||||
| 143 | + | func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) { | |||
| 144 | + | clock := newNoticeClock() | |||
| 145 | + | d, sess, ac, _ := newNoticeFixture(t, clock) | |||
| 146 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 147 | + | var producers []string | |||
| 148 | + | rc.opts.onInvalidate = func(inv renderInvalidation) { | |||
| 149 | + | // Both the notice repaint and the explicit recovery must re-enter only | |||
| 150 | + | // after emitFrame releases its attachment locks. | |||
| 151 | + | require.True(t, ac.sendMu.TryLock(), "invalidation must not run under sendMu") | |||
| 152 | + | ac.sendMu.Unlock() | |||
| 153 | + | producers = append(producers, inv.producer) | |||
| 154 | + | } | |||
| 155 | + | state, composed := captureComposeForReceiptTest(t, sess, ac) | |||
| 156 | + | // An invalid frame makes output.prepare fail after capture. emitFrame owns | |||
| 157 | + | // sendMu on entry and releases it before reporting and rescheduling. | |||
| 158 | + | composed.frame = renderer.Frame{Width: 1} | |||
| 159 | + | ||||
| 160 | + | require.True(t, d.emitFrame(sess, ac, state, composed)) | |||
| 161 | + | ||||
| 162 | + | history := d.notices.history() | |||
| 163 | + | require.Len(t, history, 1) | |||
| 164 | + | require.Equal(t, domain.NoticeInternal, history[0].Code) | |||
| 165 | + | require.Equal(t, "display update failed", history[0].Message) | |||
| 166 | + | toasts, _ := visibleToasts(ac) | |||
| 167 | + | require.Len(t, toasts, 1) | |||
| 168 | + | require.Equal(t, "display update failed", toasts[0].Message) | |||
| 169 | + | ||||
| 170 | + | rc.mu.Lock() | |||
| 171 | + | pending, reset := rc.pending, rc.pendingReset | |||
| 172 | + | rc.mu.Unlock() | |||
| 173 | + | require.True(t, pending, "prepare failure must schedule a recovery render") | |||
| 174 | + | require.True(t, reset, "recovery must invalidate the full render state") | |||
| 175 | + | require.Contains(t, producers, "render_pipeline.go:prepare-failed", "prepare failure must explicitly schedule recovery") | |||
| 176 | + | require.Contains(t, clock.durations(), urgentRenderDeadline, "notice/recovery invalidation must arm the coordinator") | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestPrepareFailureFallbackOnlySuppressesRecursiveNoticePaint(t *testing.T) { | |||
| 180 | + | d, sess, ac, sends := newNoticeFixture(t, newNoticeClock()) | |||
| 181 | + | ||||
| 182 | + | // A failure while reportError is repainting its own toast is intentionally | |||
| 183 | + | // ignored. It must not start another notification/repaint cycle. | |||
| 184 | + | ac.prepareFailureFallback.Store(true) | |||
| 185 | + | state, composed := captureComposeForReceiptTest(t, sess, ac) | |||
| 186 | + | composed.frame = renderer.Frame{Width: 1} | |||
| 187 | + | require.True(t, d.emitFrame(sess, ac, state, composed)) | |||
| 188 | + | require.Empty(t, d.notices.history()) | |||
| 189 | + | ac.prepareFailureFallback.Store(false) | |||
| 190 | + | ||||
| 191 | + | // Later outer failures each notify and repaint after sendMu has been | |||
| 192 | + | // released; neither may be hidden by the prior recursive guard. | |||
| 193 | + | for want := 1; want <= 2; want++ { | |||
| 194 | + | state, composed = captureComposeForReceiptTest(t, sess, ac) | |||
| 195 | + | composed.frame = renderer.Frame{Width: 1} | |||
| 196 | + | require.True(t, d.emitFrame(sess, ac, state, composed)) | |||
| 197 | + | require.Len(t, d.notices.history(), want) | |||
| 198 | + | frame := <-sends | |||
| 199 | + | require.Equal(t, ports.MsgOutput, frame.Type, "outer failure %d must repaint its notice", want) | |||
| 200 | + | require.False(t, ac.prepareFailureFallback.Load()) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 139 | func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) { | 204 | func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) { | ||
| 140 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | 205 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | ||
| 141 | d.paint(sess, ac, true, nil) | 206 | d.paint(sess, ac, true, nil) | ||
M internal/usecase/daemon/render_pipeline.go +78 -5
| @@ -7,6 +7,7 @@ import ( | |||||
| 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/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | + | "github.com/bnema/vev/internal/usecase/notices" | |||
| 10 | "github.com/bnema/vev/internal/usecase/palette" | 11 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 11 | "github.com/bnema/vev/internal/usecase/picker" | 12 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 12 | "github.com/bnema/vev/internal/usecase/prompt" | 13 | "github.com/bnema/vev/internal/usecase/prompt" | ||
| @@ -43,6 +44,7 @@ type composeCacheInput struct { | |||||
| 43 | floatingGeometry floatingGeometry | 44 | floatingGeometry floatingGeometry | ||
| 44 | floatingTitleGeneration uint64 | 45 | floatingTitleGeneration uint64 | ||
| 45 | bars barCache | 46 | bars barCache | ||
| 47 | + | toastFootprints []domain.Rect | |||
| 46 | theme themeui.Theme | 48 | theme themeui.Theme | ||
| 47 | styleGeneration uint64 | 49 | styleGeneration uint64 | ||
| 48 | } | 50 | } | ||
| @@ -151,7 +153,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 151 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | 153 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | ||
| 152 | } | 154 | } | ||
| 153 | } | 155 | } | ||
| 154 | - | if state.overlays.active() { | 156 | + | // The cache stays toast-free. The terminal shadow, in contrast, includes |
| 157 | + | // toasts, so every render damages both the last and current toast coverage. | |||
| 158 | + | // This restores cells exposed by dismissal and redraws a stable toast over | |||
| 159 | + | // any underlying pane update without promoting either case to a full frame. | |||
| 160 | + | overlaysActive := state.overlays.active() | |||
| 161 | + | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |||
| 162 | + | if overlaysActive || toastsVisible { | |||
| 155 | // Without a floating frame, overlay composition would otherwise mutate | 163 | // Without a floating frame, overlay composition would otherwise mutate | ||
| 156 | // the base cache in place. Floating composition already owns a clone. | 164 | // the base cache in place. Floating composition already owns a clone. | ||
| 157 | if !state.floating.visible { | 165 | if !state.floating.visible { | ||
| @@ -159,13 +167,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 159 | } | 167 | } | ||
| 160 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | 168 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | ||
| 161 | } | 169 | } | ||
| 162 | - | if full || state.overlays.active() { | 170 | + | toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles) |
| 171 | + | if full || overlaysActive { | |||
| 163 | damage = []renderer.Damage{renderer.FullRedraw()} | 172 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 173 | + | } else { | |||
| 174 | + | damage = appendToastDamage(damage, in.toastFootprints) | |||
| 175 | + | damage = appendToastDamage(damage, toastFootprints) | |||
| 164 | } | 176 | } | ||
| 165 | cursorInputs := state.cursor | 177 | cursorInputs := state.cursor | ||
| 166 | - | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | 178 | + | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive |
| 167 | cursor := desiredCapturedCursor(cursorInputs) | 179 | cursor := desiredCapturedCursor(cursorInputs) | ||
| 168 | - | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | 180 | + | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} |
| 169 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | 181 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | ||
| 170 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 182 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 171 | } | 183 | } | ||
| @@ -198,7 +210,9 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 198 | } | 210 | } | ||
| 199 | o := &state.overlays | 211 | o := &state.overlays | ||
| 200 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | 212 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | ||
| 213 | + | o.noticesOverlayActive = snap.noticesOverlayActive | |||
| 201 | o.copyMode = snap.copyMode | 214 | o.copyMode = snap.copyMode | ||
| 215 | + | o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow | |||
| 202 | if snap.copyPane != nil { | 216 | if snap.copyPane != nil { | ||
| 203 | o.copyPaneID = snap.copyPane.id | 217 | o.copyPaneID = snap.copyPane.id | ||
| 204 | } | 218 | } | ||
| @@ -218,6 +232,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 218 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator} | 232 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator} | ||
| 219 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | 233 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | ||
| 220 | } | 234 | } | ||
| 235 | + | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |||
| 236 | + | o.noticesOverlay.modal = noticesModal | |||
| 237 | + | o.noticesOverlay.focused = true | |||
| 238 | + | renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.SurfaceInactive, Selection: styles.PickerSelection, Text: styles.SurfaceInactive, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} | |||
| 239 | + | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles) | |||
| 240 | + | } | |||
| 221 | if snap.paletteActive && snap.paletteModel != nil { | 241 | if snap.paletteActive && snap.paletteModel != nil { | ||
| 222 | o.palette.modal = paletteModalFor(size, paletteCfg) | 242 | o.palette.modal = paletteModalFor(size, paletteCfg) | ||
| 223 | o.palette.focused = true | 243 | o.palette.focused = true | ||
| @@ -262,7 +282,15 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||||
| 262 | if o.paletteActive && !state.floating.visible { | 282 | if o.paletteActive && !state.floating.visible { | ||
| 263 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | 283 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | ||
| 264 | } | 284 | } | ||
| 265 | - | for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} { | 285 | + | // This paint order intentionally differs from HandleInput's keyboard |
| 286 | + | // priority (prompt > palette > picker > notices > copy, see | |||
| 287 | + | // overlay_runtime.go), which paints the picker under notices instead of | |||
| 288 | + | // over it. Currently unreachable: notices only opens via the palette, and | |||
| 289 | + | // HandleInput short-circuits to the first active overlay, so picker and | |||
| 290 | + | // notices are never simultaneously active. If that ever changes, this | |||
| 291 | + | // mismatch would let the picker own the keyboard while notices visually | |||
| 292 | + | // covers it. | |||
| 293 | + | for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} { | |||
| 266 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | 294 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | ||
| 267 | continue | 295 | continue | ||
| 268 | } | 296 | } | ||
| @@ -279,6 +307,38 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||||
| 279 | return frame, damage | 307 | return frame, damage | ||
| 280 | } | 308 | } | ||
| 281 | 309 | ||||
| 310 | + | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | |||
| 311 | + | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | |||
| 312 | + | return nil | |||
| 313 | + | } | |||
| 314 | + | views := make([]ui.NoticeView, len(overlays.notices)) | |||
| 315 | + | for i, n := range overlays.notices { | |||
| 316 | + | views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count} | |||
| 317 | + | } | |||
| 318 | + | return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles)) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage { | |||
| 322 | + | for _, footprint := range footprints { | |||
| 323 | + | if footprint.Width > 0 && footprint.Height > 0 { | |||
| 324 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height}) | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | return damage | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // noticeStylesFrom picks the toast box color per severity from the theme's | |||
| 331 | + | // chrome roles. Warn uses the dedicated BorderWarn role so it reads as | |||
| 332 | + | // distinct from Info instead of sharing BorderMuted with it. | |||
| 333 | + | func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles { | |||
| 334 | + | return ui.NoticeStyles{ | |||
| 335 | + | Text: styles.PickerBase, | |||
| 336 | + | BoxError: styles.BorderActive, | |||
| 337 | + | BoxWarn: styles.BorderWarn, | |||
| 338 | + | BoxInfo: styles.BorderMuted, | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | ||||
| 282 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | 342 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | ||
| 283 | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | 343 | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | ||
| 284 | if !changed { | 344 | if !changed { | ||
| @@ -368,6 +428,19 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||||
| 368 | ac.sess.mu.Unlock() | 428 | ac.sess.mu.Unlock() | ||
| 369 | ac.sendMu.Unlock() | 429 | ac.sendMu.Unlock() | ||
| 370 | d.log.Error("render draw failed", "err", err, "session", sess.name) | 430 | d.log.Error("render draw failed", "err", err, "session", sess.name) | ||
| 431 | + | // Without a coordinator reportError repaints synchronously. Suppress only | |||
| 432 | + | // that nested notice repaint; leave the guard before returning so a later, | |||
| 433 | + | // independent failed transaction can still notify the user. | |||
| 434 | + | if sess.renderCoordinator() == nil { | |||
| 435 | + | if !ac.prepareFailureFallback.CompareAndSwap(false, true) { | |||
| 436 | + | return true | |||
| 437 | + | } | |||
| 438 | + | d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err)) | |||
| 439 | + | ac.prepareFailureFallback.Store(false) | |||
| 440 | + | return true | |||
| 441 | + | } | |||
| 442 | + | d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err)) | |||
| 443 | + | d.invalidateRender(sess, ac, true, "render_pipeline.go:prepare-failed") | |||
| 371 | return true | 444 | return true | ||
| 372 | } | 445 | } | ||
| 373 | data := append([]byte(nil), prepared.data...) | 446 | data := append([]byte(nil), prepared.data...) | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -0
| @@ -300,6 +300,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput { | |||||
| 300 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | 300 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | ||
| 301 | maps.Copy(out.titleGenerations, in.titleGenerations) | 301 | maps.Copy(out.titleGenerations, in.titleGenerations) | ||
| 302 | out.damage = append([]renderer.Damage(nil), in.damage...) | 302 | out.damage = append([]renderer.Damage(nil), in.damage...) | ||
| 303 | + | out.toastFootprints = append([]domain.Rect(nil), in.toastFootprints...) | |||
| 303 | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | 304 | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | ||
| 304 | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | 305 | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | ||
| 305 | return out | 306 | return out | ||
M internal/usecase/daemon/render_pipeline_test.go +17 -0
| @@ -319,3 +319,20 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||||
| 319 | }) | 319 | }) | ||
| 320 | } | 320 | } | ||
| 321 | } | 321 | } | ||
| 322 | + | ||||
| 323 | + | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | |||
| 324 | + | muted := renderer.Style{Foreground: 1} | |||
| 325 | + | active := renderer.Style{Foreground: 2} | |||
| 326 | + | warn := renderer.Style{Foreground: 3} | |||
| 327 | + | styles := themeui.Styles{BorderMuted: muted, BorderActive: active, BorderWarn: warn} | |||
| 328 | + | ||||
| 329 | + | got := noticeStylesFrom(styles) | |||
| 330 | + | ||||
| 331 | + | require.Equal(t, active, got.BoxError) | |||
| 332 | + | require.Equal(t, muted, got.BoxInfo) | |||
| 333 | + | // BoxWarn must use the dedicated BorderWarn role, not fall back to the | |||
| 334 | + | // same muted role BoxInfo uses - that was the pre-existing bug this | |||
| 335 | + | // role fixes (Warn and Info toasts were visually identical). | |||
| 336 | + | require.Equal(t, warn, got.BoxWarn) | |||
| 337 | + | require.NotEqual(t, got.BoxInfo, got.BoxWarn) | |||
| 338 | + | } | |||
M internal/usecase/daemon/render_test.go +90 -0
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "log/slog" | 8 | "log/slog" | ||
| 9 | "strconv" | 9 | "strconv" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | + | "sync" | |||
| 11 | "sync/atomic" | 12 | "sync/atomic" | ||
| 12 | "testing" | 13 | "testing" | ||
| 13 | "time" | 14 | "time" | ||
| @@ -218,6 +219,84 @@ func fireCoordinatorTimer(t *testing.T, rc *renderCoordinator, timers []*coordin | |||||
| 218 | t.Fatalf("coordinator did not arm %s timer", duration) | 219 | t.Fatalf("coordinator did not arm %s timer", duration) | ||
| 219 | } | 220 | } | ||
| 220 | 221 | ||||
| 222 | + | func TestPTYReaderLogsClosureWithoutNotification(t *testing.T) { | |||
| 223 | + | pty := portsmocks.NewMockPTY(t) | |||
| 224 | + | readErr := errors.New("pty master closed") | |||
| 225 | + | pty.EXPECT().Read(mock.Anything).Return(0, readErr).Once() | |||
| 226 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |||
| 227 | + | var logs bytes.Buffer | |||
| 228 | + | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |||
| 229 | + | pane := sess.tabs[0].focusedPane() | |||
| 230 | + | pane.onExit = func() {} | |||
| 231 | + | ||||
| 232 | + | d.sessWg.Add(1) | |||
| 233 | + | d.ptyReader(sess, sess.tabs[0], pane) | |||
| 234 | + | ||||
| 235 | + | require.Contains(t, logs.String(), "level=INFO") | |||
| 236 | + | require.Contains(t, logs.String(), "msg=\"pane pty closed\"") | |||
| 237 | + | require.Contains(t, logs.String(), "err=\"pty master closed\"") | |||
| 238 | + | require.Contains(t, logs.String(), "session=work") | |||
| 239 | + | require.Empty(t, d.notices.history(), "PTY closure is diagnostic only") | |||
| 240 | + | toasts, _ := visibleToasts(ac) | |||
| 241 | + | require.Empty(t, toasts, "PTY closure must not notify the user") | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | type concurrentExitPTY struct { | |||
| 245 | + | started chan struct{} | |||
| 246 | + | release chan struct{} | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func newConcurrentExitPTY() *concurrentExitPTY { | |||
| 250 | + | return &concurrentExitPTY{started: make(chan struct{}), release: make(chan struct{})} | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func (p *concurrentExitPTY) Read([]byte) (int, error) { | |||
| 254 | + | close(p.started) | |||
| 255 | + | <-p.release | |||
| 256 | + | return 0, io.EOF | |||
| 257 | + | } | |||
| 258 | + | func (*concurrentExitPTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 259 | + | func (*concurrentExitPTY) Close() error { return nil } | |||
| 260 | + | func (*concurrentExitPTY) Resize(domain.Size) error { return nil } | |||
| 261 | + | func (*concurrentExitPTY) Pid() int { return 0 } | |||
| 262 | + | func (*concurrentExitPTY) ForegroundPgid() (int, error) { | |||
| 263 | + | return 0, nil | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func TestPTYReaderReadsSessionNameUnderLockDuringConcurrentRename(t *testing.T) { | |||
| 267 | + | // Repeated synchronized exits make the reader's closure log contend with a | |||
| 268 | + | // rename. Running this under -race proves the name snapshot uses sess.mu. | |||
| 269 | + | for range 100 { | |||
| 270 | + | pty := newConcurrentExitPTY() | |||
| 271 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 272 | + | pane := sess.tabs[0].focusedPane() | |||
| 273 | + | pane.onExit = func() {} | |||
| 274 | + | ||||
| 275 | + | d.sessWg.Add(1) | |||
| 276 | + | go d.ptyReader(sess, sess.tabs[0], pane) | |||
| 277 | + | <-pty.started | |||
| 278 | + | ||||
| 279 | + | start := make(chan struct{}) | |||
| 280 | + | renameErr := make(chan error, 1) | |||
| 281 | + | var wg sync.WaitGroup | |||
| 282 | + | wg.Add(2) | |||
| 283 | + | go func() { | |||
| 284 | + | defer wg.Done() | |||
| 285 | + | <-start | |||
| 286 | + | close(pty.release) | |||
| 287 | + | }() | |||
| 288 | + | go func() { | |||
| 289 | + | defer wg.Done() | |||
| 290 | + | <-start | |||
| 291 | + | renameErr <- d.renameSession(sess, "renamed") | |||
| 292 | + | }() | |||
| 293 | + | close(start) | |||
| 294 | + | wg.Wait() | |||
| 295 | + | require.NoError(t, <-renameErr) | |||
| 296 | + | d.sessWg.Wait() | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 221 | func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | 300 | func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | ||
| 222 | t.Run("inactive synchronized pane activates only after complete urgent frame", func(t *testing.T) { | 301 | t.Run("inactive synchronized pane activates only after complete urgent frame", func(t *testing.T) { | ||
| 223 | inactivePTY, inactiveSteps, inactiveProcessed := newChannelPTY(t) | 302 | inactivePTY, inactiveSteps, inactiveProcessed := newChannelPTY(t) | ||
| @@ -1066,6 +1145,17 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||||
| 1066 | } | 1145 | } | ||
| 1067 | } | 1146 | } | ||
| 1068 | 1147 | ||||
| 1148 | + | func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) { | |||
| 1149 | + | _, sess, _, _ := newManualSessionWithPTYs(t, nil) | |||
| 1150 | + | p := sess.tabs[0].focusedPane() | |||
| 1151 | + | ||||
| 1152 | + | p.mu.Lock() | |||
| 1153 | + | cursor := captureCursorInputsLocked(p, domain.Rect{Width: 80, Height: 23}, capturedOverlayRenderState{noticesOverlayActive: true}) | |||
| 1154 | + | p.mu.Unlock() | |||
| 1155 | + | ||||
| 1156 | + | require.True(t, cursor.hiddenByOverlay) | |||
| 1157 | + | } | |||
| 1158 | + | ||||
| 1069 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | 1159 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | ||
| 1070 | p, _ := newBlockingPTY(t) | 1160 | p, _ := newBlockingPTY(t) | ||
| 1071 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1161 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
M internal/usecase/daemon/session.go +43 -11
| @@ -19,6 +19,11 @@ import ( | |||||
| 19 | "github.com/bnema/vev/internal/usecase/layout" | 19 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | + | var ( | |||
| 23 | + | errSessionNameRequired = errors.New("name required") | |||
| 24 | + | errSessionNameInUse = errors.New("name already in use") | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 22 | type terminalEnv struct { | 27 | type terminalEnv struct { | ||
| 23 | TrueColor bool | 28 | TrueColor bool | ||
| 24 | } | 29 | } | ||
| @@ -145,7 +150,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 145 | var err error | 150 | var err error | ||
| 146 | createdAt, err = d.allocateLifecycleCreatedAtLocked() | 151 | createdAt, err = d.allocateLifecycleCreatedAtLocked() | ||
| 147 | if err != nil { | 152 | if err != nil { | ||
| 148 | - | return nil, err | 153 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) |
| 149 | } | 154 | } | ||
| 150 | } | 155 | } | ||
| 151 | } | 156 | } | ||
| @@ -160,14 +165,14 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 160 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | 165 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | ||
| 161 | if err != nil { | 166 | if err != nil { | ||
| 162 | closeTabs(tabs) | 167 | closeTabs(tabs) | ||
| 163 | - | return nil, err | 168 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) |
| 164 | } | 169 | } | ||
| 165 | command, args := d.ptyCommand(env) | 170 | command, args := d.ptyCommand(env) | ||
| 166 | pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize) | 171 | pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize) | ||
| 167 | if err != nil { | 172 | if err != nil { | ||
| 168 | closeTabs(tabs) | 173 | closeTabs(tabs) | ||
| 169 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | 174 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | ||
| 170 | - | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | 175 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err) |
| 171 | } | 176 | } | ||
| 172 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | 177 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | ||
| 173 | if i < len(names) { | 178 | if i < len(names) { | ||
| @@ -207,7 +212,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 207 | if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil { | 212 | if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil { | ||
| 208 | closeTabs(tabs) | 213 | closeTabs(tabs) | ||
| 209 | cancel() | 214 | cancel() | ||
| 210 | - | return nil, err | 215 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) |
| 211 | } | 216 | } | ||
| 212 | delete(d.stopped, name) | 217 | delete(d.stopped, name) | ||
| 213 | } | 218 | } | ||
| @@ -233,7 +238,7 @@ func closeTabs(tabs []*tab) { | |||||
| 233 | 238 | ||||
| 234 | func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error { | 239 | func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error { | ||
| 235 | if name == "" { | 240 | if name == "" { | ||
| 236 | - | return errors.New("name required") | 241 | + | return errSessionNameRequired |
| 237 | } | 242 | } | ||
| 238 | if err := domain.ValidateSessionName(name); err != nil { | 243 | if err := domain.ValidateSessionName(name); err != nil { | ||
| 239 | return err | 244 | return err | ||
| @@ -246,14 +251,19 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 246 | } | 251 | } | ||
| 247 | if d.nameLiveOrStoppedLocked(name) { | 252 | if d.nameLiveOrStoppedLocked(name) { | ||
| 248 | d.mu.Unlock() | 253 | d.mu.Unlock() | ||
| 249 | - | return errors.New("name already in use") | 254 | + | return errSessionNameInUse |
| 250 | } | 255 | } | ||
| 256 | + | // The caller owns d.mu. Make the entire ownership handoff atomic with | |||
| 257 | + | // global routing so it cannot select a client between source removal and | |||
| 258 | + | // destination publication. | |||
| 259 | + | d.notices.routingMu.Lock() | |||
| 251 | from.mu.Lock() | 260 | from.mu.Lock() | ||
| 252 | cwd := from.cwd | 261 | cwd := from.cwd | ||
| 253 | term := from.terminal | 262 | term := from.terminal | ||
| 254 | env := copyEnvironment(from.env) | 263 | env := copyEnvironment(from.env) | ||
| 255 | if from.client != ac { | 264 | if from.client != ac { | ||
| 256 | from.mu.Unlock() | 265 | from.mu.Unlock() | ||
| 266 | + | d.notices.routingMu.Unlock() | |||
| 257 | d.mu.Unlock() | 267 | d.mu.Unlock() | ||
| 258 | return errors.New("client detached") | 268 | return errors.New("client detached") | ||
| 259 | } | 269 | } | ||
| @@ -261,12 +271,14 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 261 | 271 | ||||
| 262 | newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env) | 272 | newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env) | ||
| 263 | if err != nil { | 273 | if err != nil { | ||
| 274 | + | d.notices.routingMu.Unlock() | |||
| 264 | d.mu.Unlock() | 275 | d.mu.Unlock() | ||
| 265 | return err | 276 | return err | ||
| 266 | } | 277 | } | ||
| 267 | from.mu.Lock() | 278 | from.mu.Lock() | ||
| 268 | if from.client != ac { | 279 | if from.client != ac { | ||
| 269 | from.mu.Unlock() | 280 | from.mu.Unlock() | ||
| 281 | + | d.notices.routingMu.Unlock() | |||
| 270 | d.mu.Unlock() | 282 | d.mu.Unlock() | ||
| 271 | _ = d.killSession(newSess, ports.ReasonSessionKilled, true) | 283 | _ = d.killSession(newSess, ports.ReasonSessionKilled, true) | ||
| 272 | return errors.New("client detached") | 284 | return errors.New("client detached") | ||
| @@ -286,6 +298,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 286 | d.touchMRU(newSess) | 298 | d.touchMRU(newSess) | ||
| 287 | ac.recordPreviousSession(from) | 299 | ac.recordPreviousSession(from) | ||
| 288 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | 300 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | ||
| 301 | + | d.notices.routingMu.Unlock() | |||
| 289 | d.mu.Unlock() | 302 | d.mu.Unlock() | ||
| 290 | finishRenderLifecycleCleanups(cleanups) | 303 | finishRenderLifecycleCleanups(cleanups) | ||
| 291 | d.firstPaint(newSess, ac, sz) | 304 | d.firstPaint(newSess, ac, sz) | ||
| @@ -309,7 +322,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 309 | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize) | 322 | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize) | ||
| 310 | if err != nil { | 323 | if err != nil { | ||
| 311 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | 324 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | ||
| 312 | - | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | 325 | + | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) |
| 313 | } | 326 | } | ||
| 314 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | 327 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | ||
| 315 | if client != nil { | 328 | if client != nil { | ||
| @@ -459,6 +472,15 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool { | |||||
| 459 | return false | 472 | return false | ||
| 460 | } | 473 | } | ||
| 461 | 474 | ||||
| 475 | + | // detachIfCurrent serializes client removal with global notice routing. The | |||
| 476 | + | // routing lock is released before the caller performs teardown, so it covers | |||
| 477 | + | // only attachment ownership and is never held across transport work. | |||
| 478 | + | func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool { | |||
| 479 | + | d.notices.routingMu.Lock() | |||
| 480 | + | defer d.notices.routingMu.Unlock() | |||
| 481 | + | return sess.detachIfCurrent(ac) | |||
| 482 | + | } | |||
| 483 | + | ||||
| 462 | func (s *session) activeTab() *tab { | 484 | func (s *session) activeTab() *tab { | ||
| 463 | s.mu.Lock() | 485 | s.mu.Lock() | ||
| 464 | defer s.mu.Unlock() | 486 | defer s.mu.Unlock() | ||
| @@ -494,7 +516,7 @@ func (s *session) switchRelative(delta int) bool { | |||||
| 494 | 516 | ||||
| 495 | func (d *Daemon) renameSession(sess *session, name string) error { | 517 | func (d *Daemon) renameSession(sess *session, name string) error { | ||
| 496 | if name == "" { | 518 | if name == "" { | ||
| 497 | - | return errors.New("name required") | 519 | + | return errSessionNameRequired |
| 498 | } | 520 | } | ||
| 499 | if err := domain.ValidateSessionName(name); err != nil { | 521 | if err := domain.ValidateSessionName(name); err != nil { | ||
| 500 | return err | 522 | return err | ||
| @@ -502,10 +524,10 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 502 | d.mu.Lock() | 524 | d.mu.Lock() | ||
| 503 | defer d.mu.Unlock() | 525 | defer d.mu.Unlock() | ||
| 504 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | 526 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | ||
| 505 | - | return errors.New("name already in use") | 527 | + | return errSessionNameInUse |
| 506 | } | 528 | } | ||
| 507 | if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name { | 529 | if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name { | ||
| 508 | - | return errors.New("name already in use") | 530 | + | return errSessionNameInUse |
| 509 | } | 531 | } | ||
| 510 | sess.mu.Lock() | 532 | sess.mu.Lock() | ||
| 511 | oldName := sess.name | 533 | oldName := sess.name | ||
| @@ -632,8 +654,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 632 | name := sess.name | 654 | name := sess.name | ||
| 633 | sess.mu.Unlock() | 655 | sess.mu.Unlock() | ||
| 634 | d.mu.Unlock() | 656 | d.mu.Unlock() | ||
| 657 | + | if err := d.killSession(sess, ports.ReasonSessionKilled, false); err != nil { | |||
| 658 | + | d.log.Warn("closing last tab failed", "session", name, "err", err) | |||
| 659 | + | d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated, | |||
| 660 | + | "couldn't close tab: session state not yet saved; try again", err)) | |||
| 661 | + | return | |||
| 662 | + | } | |||
| 635 | d.log.Info("tab closed", "session", name, "last", true) | 663 | d.log.Info("tab closed", "session", name, "last", true) | ||
| 636 | - | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |||
| 637 | return | 664 | return | ||
| 638 | } | 665 | } | ||
| 639 | ringing := tb.attention | 666 | ringing := tb.attention | ||
| @@ -718,6 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 718 | if reason != ports.ReasonServerShutdown { | 745 | if reason != ports.ReasonServerShutdown { | ||
| 719 | return terminalSnapshotErr | 746 | return terminalSnapshotErr | ||
| 720 | } | 747 | } | ||
| 748 | + | d.persistShutdownSnapshotFailure(name, terminalSnapshotErr) | |||
| 721 | } | 749 | } | ||
| 722 | } | 750 | } | ||
| 723 | } | 751 | } | ||
| @@ -770,10 +798,14 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 770 | } | 798 | } | ||
| 771 | } | 799 | } | ||
| 772 | 800 | ||||
| 801 | + | // A global route that selected this attachment must publish before terminal | |||
| 802 | + | // teardown makes it stale. | |||
| 803 | + | d.notices.routingMu.Lock() | |||
| 773 | sess.mu.Lock() | 804 | sess.mu.Lock() | ||
| 774 | ac := sess.client | 805 | ac := sess.client | ||
| 775 | sess.client = nil | 806 | sess.client = nil | ||
| 776 | sess.mu.Unlock() | 807 | sess.mu.Unlock() | ||
| 808 | + | d.notices.routingMu.Unlock() | |||
| 777 | if rc := sess.renderCoordinator(); rc != nil { | 809 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 778 | // Terminal teardown has two phases: this session owner first prevents any | 810 | // Terminal teardown has two phases: this session owner first prevents any | ||
| 779 | // new worker registration and detaches all tokens, then stops and waits | 811 | // new worker registration and detaches all tokens, then stops and waits | ||
M internal/usecase/daemon/session_back.go +3 -1
| @@ -14,7 +14,9 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||||
| 14 | d.invalidateRender(current, ac, true, "session_back.go") | 14 | d.invalidateRender(current, ac, true, "session_back.go") | ||
| 15 | return | 15 | return | ||
| 16 | } | 16 | } | ||
| 17 | - | d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}) | 17 | + | if err := d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil { |
| 18 | + | d.reportError(current, err) | |||
| 19 | + | } | |||
| 18 | } | 20 | } | ||
| 19 | 21 | ||||
| 20 | // clearPreviousSessionIf clears target only if it has not been replaced since | 22 | // clearPreviousSessionIf clears target only if it has not been replaced since | ||
M internal/usecase/daemon/session_test.go +31 -7
| @@ -304,7 +304,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) { | |||||
| 304 | // A is notified it was detached. | 304 | // A is notified it was detached. | ||
| 305 | dA := awaitFrame(t, sendsA, ports.MsgDetached) | 305 | dA := awaitFrame(t, sendsA, ports.MsgDetached) | ||
| 306 | det, _ := ports.UnmarshalDetached(dA.Payload) | 306 | det, _ := ports.UnmarshalDetached(dA.Payload) | ||
| 307 | - | require.Equal(t, ports.ReasonDetach, det.Reason) | 307 | + | require.Equal(t, ports.ReasonReplaced, det.Reason) |
| 308 | 308 | ||||
| 309 | // B is now the current client. | 309 | // B is now the current client. | ||
| 310 | sess.mu.Lock() | 310 | sess.mu.Lock() | ||
| @@ -1079,7 +1079,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin | |||||
| 1079 | require.NoError(t, d.renameSession(second, "named")) | 1079 | require.NoError(t, d.renameSession(second, "named")) | ||
| 1080 | require.NotEqual(t, staleCreatedAt, second.createdAt) | 1080 | require.NotEqual(t, staleCreatedAt, second.createdAt) | ||
| 1081 | 1081 | ||||
| 1082 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt})) | 1082 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt})) |
| 1083 | require.Same(t, from, ac.currentSession()) | 1083 | require.Same(t, from, ac.currentSession()) | ||
| 1084 | releaseSecond() | 1084 | releaseSecond() | ||
| 1085 | d.sessWg.Wait() | 1085 | d.sessWg.Wait() | ||
| @@ -1211,7 +1211,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||||
| 1211 | WithStore(store)(d) | 1211 | WithStore(store)(d) | ||
| 1212 | require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | 1212 | require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | ||
| 1213 | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1} | 1213 | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1} | ||
| 1214 | - | d.killPickerTarget(picker.Target{Name: "old", Stopped: true}) | 1214 | + | require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true})) |
| 1215 | require.False(t, state.has("old")) | 1215 | require.False(t, state.has("old")) | ||
| 1216 | d.mu.Lock() | 1216 | d.mu.Lock() | ||
| 1217 | _, ok := d.stopped["old"] | 1217 | _, ok := d.stopped["old"] | ||
| @@ -1304,6 +1304,30 @@ func TestRenameSessionUnsafeNameRejected(t *testing.T) { | |||||
| 1304 | require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName) | 1304 | require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName) | ||
| 1305 | } | 1305 | } | ||
| 1306 | 1306 | ||||
| 1307 | + | func TestCreateTabPtyFailureIsUserError(t *testing.T) { | |||
| 1308 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 1309 | + | p, release := newBlockingPTY(t) | |||
| 1310 | + | defer release() | |||
| 1311 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 1312 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv) | |||
| 1313 | + | require.NoError(t, err) | |||
| 1314 | + | ||||
| 1315 | + | cause := errors.New("fork/exec: no such file") | |||
| 1316 | + | failing := portsmocks.NewMockPTYFactory(t) | |||
| 1317 | + | failing.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once() | |||
| 1318 | + | d.ptys = failing | |||
| 1319 | + | ||||
| 1320 | + | err = d.createTab(sess, sz) | |||
| 1321 | + | ||||
| 1322 | + | var ue *domain.UserError | |||
| 1323 | + | require.ErrorAs(t, err, &ue) | |||
| 1324 | + | require.Equal(t, domain.NoticeTabSpawn, ue.Code) | |||
| 1325 | + | require.Equal(t, domain.NoticeError, ue.Severity) | |||
| 1326 | + | require.Equal(t, "couldn't open tab: shell failed to start", ue.Msg) | |||
| 1327 | + | require.ErrorIs(t, err, cause) | |||
| 1328 | + | require.NotContains(t, ue.Msg, cause.Error()) | |||
| 1329 | + | } | |||
| 1330 | + | ||||
| 1307 | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | 1331 | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | ||
| 1308 | sz := domain.Size{Cols: 80, Rows: 24} | 1332 | sz := domain.Size{Cols: 80, Rows: 24} | ||
| 1309 | p1, release1 := newBlockingPTY(t) | 1333 | p1, release1 := newBlockingPTY(t) | ||
| @@ -1705,7 +1729,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1705 | target.createdAt = 22 | 1729 | target.createdAt = 22 | ||
| 1706 | expected := int64(21) | 1730 | expected := int64(21) | ||
| 1707 | 1731 | ||||
| 1708 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected})) | 1732 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected})) |
| 1709 | require.Same(t, from, ac.currentSession()) | 1733 | require.Same(t, from, ac.currentSession()) | ||
| 1710 | }) | 1734 | }) | ||
| 1711 | 1735 | ||||
| @@ -1719,7 +1743,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1719 | expected := int64(31) | 1743 | expected := int64(31) | ||
| 1720 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected} | 1744 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected} | ||
| 1721 | 1745 | ||||
| 1722 | - | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) | 1746 | + | require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) |
| 1723 | require.Equal(t, "target", ac.currentSession().name) | 1747 | require.Equal(t, "target", ac.currentSession().name) | ||
| 1724 | require.Equal(t, expected, ac.currentSession().createdAt) | 1748 | require.Equal(t, expected, ac.currentSession().createdAt) | ||
| 1725 | }) | 1749 | }) | ||
| @@ -1731,7 +1755,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1731 | target.createdAt = 41 | 1755 | target.createdAt = 41 | ||
| 1732 | expected := int64(41) | 1756 | expected := int64(41) | ||
| 1733 | 1757 | ||||
| 1734 | - | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | 1758 | + | require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) |
| 1735 | require.Same(t, target, ac.currentSession()) | 1759 | require.Same(t, target, ac.currentSession()) | ||
| 1736 | }) | 1760 | }) | ||
| 1737 | 1761 | ||||
| @@ -1742,7 +1766,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1742 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52} | 1766 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52} | ||
| 1743 | expected := int64(51) | 1767 | expected := int64(51) | ||
| 1744 | 1768 | ||||
| 1745 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | 1769 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) |
| 1746 | require.Same(t, from, ac.currentSession()) | 1770 | require.Same(t, from, ac.currentSession()) | ||
| 1747 | }) | 1771 | }) | ||
| 1748 | } | 1772 | } | ||
M internal/usecase/daemon/snapshot.go +35 -1
| @@ -62,13 +62,34 @@ func (d *Daemon) closeRestoreDone() { | |||||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | func (d *Daemon) restoreSnapshots(ctx context.Context) { | 64 | func (d *Daemon) restoreSnapshots(ctx context.Context) { | ||
| 65 | + | if d == nil { | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 65 | defer d.closeRestoreDone() | 68 | defer d.closeRestoreDone() | ||
| 66 | - | if d == nil || !d.snapsEnabled || d.snaps == nil { | 69 | + | if ns := d.noticeStore; ns != nil { |
| 70 | + | claimed, err := ns.Claim() | |||
| 71 | + | if err != nil { | |||
| 72 | + | d.log.Warn("claiming pending notices failed", "err", err) | |||
| 73 | + | } else { | |||
| 74 | + | for _, n := range claimed { | |||
| 75 | + | d.notices.record(n) | |||
| 76 | + | d.notices.queueGlobal(n) | |||
| 77 | + | } | |||
| 78 | + | // Do not discard the claim until every notice has been recorded and | |||
| 79 | + | // queued. An Ack failure is safe: the claim is replayed at startup. | |||
| 80 | + | if err := ns.Ack(); err != nil { | |||
| 81 | + | d.log.Warn("acknowledging pending notices failed", "err", err) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | if !d.snapsEnabled || d.snaps == nil { | |||
| 67 | return | 86 | return | ||
| 68 | } | 87 | } | ||
| 69 | blobs, err := d.snaps.Load() | 88 | blobs, err := d.snaps.Load() | ||
| 70 | if err != nil { | 89 | if err != nil { | ||
| 71 | d.log.Warn("loading session snapshots failed", "err", err) | 90 | d.log.Warn("loading session snapshots failed", "err", err) | ||
| 91 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |||
| 92 | + | "couldn't load saved sessions after restart", err) | |||
| 72 | return | 93 | return | ||
| 73 | } | 94 | } | ||
| 74 | for _, blob := range blobs { | 95 | for _, blob := range blobs { | ||
| @@ -80,10 +101,16 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) { | |||||
| 80 | snap, err := snapcodec.Unmarshal(blob.Data) | 101 | snap, err := snapcodec.Unmarshal(blob.Data) | ||
| 81 | if err != nil { | 102 | if err != nil { | ||
| 82 | d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name) | 103 | d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name) | ||
| 104 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |||
| 105 | + | "couldn't restore session "+blob.Name+" after restart", err) | |||
| 83 | continue | 106 | continue | ||
| 84 | } | 107 | } | ||
| 85 | if err := d.restoreSession(ctx, snap); err != nil { | 108 | if err := d.restoreSession(ctx, snap); err != nil { | ||
| 86 | d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name) | 109 | d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name) | ||
| 110 | + | if ctx.Err() == nil { | |||
| 111 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |||
| 112 | + | "couldn't restore session "+snap.Name+" after restart", err) | |||
| 113 | + | } | |||
| 87 | } | 114 | } | ||
| 88 | } | 115 | } | ||
| 89 | } | 116 | } | ||
| @@ -209,6 +236,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 209 | if restoreCommand != "" { | 236 | if restoreCommand != "" { | ||
| 210 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | 237 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | ||
| 211 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | 238 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | ||
| 239 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, | |||
| 240 | + | "couldn't restore the running program in session "+snap.Name, err) | |||
| 212 | } | 241 | } | ||
| 213 | } | 242 | } | ||
| 214 | tb.panes[paneSnap.ID] = p | 243 | tb.panes[paneSnap.ID] = p | ||
| @@ -579,6 +608,11 @@ func (d *Daemon) startSnapshotEncodeWorker() { | |||||
| 579 | d.clearSnapshotWorkerInFlight(workerID, capture) | 608 | d.clearSnapshotWorkerInFlight(workerID, capture) | ||
| 580 | if err != nil && workerCtx.Err() == nil { | 609 | if err != nil && workerCtx.Err() == nil { | ||
| 581 | d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name) | 610 | d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name) | ||
| 611 | + | // Global, not session-scoped: by now the session may already be | |||
| 612 | + | // torn down, so a session notice would be dead-on-arrival. No lock | |||
| 613 | + | // is held here (clearSnapshotWorkerInFlight released its own). | |||
| 614 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, | |||
| 615 | + | "couldn't save session "+capture.name+"; recent state may be lost on restart", err) | |||
| 582 | } | 616 | } | ||
| 583 | d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil) | 617 | d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil) | ||
| 584 | return workerCtx.Err() == nil | 618 | return workerCtx.Err() == nil | ||
A internal/usecase/daemon/snapshot_notice_test.go +121 -0
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 14 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces | |||
| 18 | + | // as a GLOBAL notice: the failing session is never registered (it stands in for a | |||
| 19 | + | // session already torn down by the time its async write fails), yet a client | |||
| 20 | + | // attached to a DIFFERENT, surviving session still receives the toast. This is the | |||
| 21 | + | // whole point of routing through NotifyGlobal instead of a session-scoped notice. | |||
| 22 | + | func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) { | |||
| 23 | + | // newNoticeFixture registers a survivor session "work"/"manual" owned by ac. | |||
| 24 | + | d, _, survivorClient, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 25 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 26 | + | WithSnapshotStore(store)(d) | |||
| 27 | + | d.procCwd = func(int) (string, error) { return "/pane", nil } | |||
| 28 | + | startSnapshotEncodeWorker(t, d) | |||
| 29 | + | ||||
| 30 | + | // The failing session is deliberately NOT added to d.sessions. | |||
| 31 | + | failing := newSnapshotTestSession(t, "failing", false, "/fallback") | |||
| 32 | + | ||||
| 33 | + | store.EXPECT().Write("failing", mock.Anything).RunAndReturn(func(string, []byte) error { | |||
| 34 | + | return errors.New("disk full") | |||
| 35 | + | }).Once() | |||
| 36 | + | ||||
| 37 | + | require.True(t, d.captureSession(failing)) | |||
| 38 | + | ||||
| 39 | + | // The write fails on the worker goroutine; awaitToastCount is the bounded, | |||
| 40 | + | // poll-only completion signal. No require runs on the worker goroutine. | |||
| 41 | + | toasts := awaitToastCount(t, survivorClient, 1) | |||
| 42 | + | require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code) | |||
| 43 | + | require.Equal(t, "couldn't save session failing; recent state may be lost on restart", toasts[0].Message) | |||
| 44 | + | require.Equal(t, domain.NoticeError, toasts[0].Severity) | |||
| 45 | + | require.Equal(t, domain.SessionID(""), toasts[0].SessionID, | |||
| 46 | + | "snapshot write failures are global, never scoped to the (possibly gone) failing session") | |||
| 47 | + | require.Contains(t, toasts[0].Details, "disk full") | |||
| 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/daemon/snapshot_restore_test.go +126 -1
| @@ -59,6 +59,11 @@ func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T | |||||
| 59 | } | 59 | } | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | + | func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) { | |||
| 63 | + | var d *Daemon | |||
| 64 | + | require.NotPanics(t, func() { d.restoreSnapshots(t.Context()) }) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 62 | func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) { | 67 | func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) { | ||
| 63 | factory := &restorePTYFactory{} | 68 | factory := &restorePTYFactory{} | ||
| 64 | d := newTestDaemon(t, factory, stubClock{}) | 69 | d := newTestDaemon(t, factory, stubClock{}) | ||
| @@ -497,6 +502,121 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) { | |||||
| 497 | require.Nil(t, restored) | 502 | require.Nil(t, restored) | ||
| 498 | } | 503 | } | ||
| 499 | 504 | ||||
| 505 | + | // TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots proves each | |||
| 506 | + | // unrestorable snapshot is recorded to history individually, while the | |||
| 507 | + | // pending-global queue dedups them by code into a single toast with the | |||
| 508 | + | // combined Count. Both blobs decode fine (valid snapcodec bytes, Marshal | |||
| 509 | + | // itself validates Active/tab range) but fail in restoreSession itself: an | |||
| 510 | + | // empty embedded session name is rejected deterministically, before any lock | |||
| 511 | + | // is taken or PTY is opened. | |||
| 512 | + | func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) { | |||
| 513 | + | badSnap := mustSnapshotBytes(t, snapcodec.Session{Name: ""}) | |||
| 514 | + | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{ | |||
| 515 | + | {Name: "bad-1", Data: badSnap}, | |||
| 516 | + | {Name: "bad-2", Data: badSnap}, | |||
| 517 | + | }} | |||
| 518 | + | factory := &restorePTYFactory{} | |||
| 519 | + | d := newTestDaemon(t, factory, newNoticeClock()) | |||
| 520 | + | WithSnapshotStore(store)(d) | |||
| 521 | + | ||||
| 522 | + | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |||
| 523 | + | // caller goroutine and signals completion by closing d.restoreDone. | |||
| 524 | + | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |||
| 525 | + | ||||
| 526 | + | select { | |||
| 527 | + | case <-d.restoreDone: | |||
| 528 | + | case <-time.After(2 * time.Second): | |||
| 529 | + | t.Fatal("restoreSnapshots did not close restoreDone in time") | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened") | |||
| 533 | + | require.Len(t, d.notices.history(), 2, "each failure is recorded individually") | |||
| 534 | + | for _, n := range d.notices.history() { | |||
| 535 | + | require.Equal(t, domain.NoticeSnapshotRestore, n.Code) | |||
| 536 | + | require.Equal(t, domain.NoticeError, n.Severity) | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | tr, _ := newCapturingTransport(t) | |||
| 540 | + | sess, ac, err := d.route(ports.Hello{ | |||
| 541 | + | Version: ports.ProtocolVersion, | |||
| 542 | + | Intent: ports.IntentEphemeral, | |||
| 543 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 544 | + | }, tr) | |||
| 545 | + | require.NoError(t, err) | |||
| 546 | + | d.firstPaint(sess, ac, ac.size) | |||
| 547 | + | ||||
| 548 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 549 | + | require.Equal(t, domain.NoticeSnapshotRestore, toasts[0].Code) | |||
| 550 | + | require.Equal(t, 2, toasts[0].Count, "two same-code failures dedup into one toast counted twice") | |||
| 551 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) { | |||
| 555 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 556 | + | factory := &restorePTYFactory{onOpen: cancel} | |||
| 557 | + | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 558 | + | Name: "cancelled", | |||
| 559 | + | Tabs: []snapcodec.Tab{{ | |||
| 560 | + | Cols: 80, Rows: 24, | |||
| 561 | + | Tree: &layout.Tree{Focus: "one", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 562 | + | layout.NewLeaf("one"), layout.NewLeaf("two"), | |||
| 563 | + | }}}, | |||
| 564 | + | Panes: []snapcodec.Pane{ | |||
| 565 | + | emptyTerminalPane(t, "one", "/one"), | |||
| 566 | + | emptyTerminalPane(t, "two", "/two"), | |||
| 567 | + | }, | |||
| 568 | + | }}, | |||
| 569 | + | })}}} | |||
| 570 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 571 | + | WithSnapshotStore(store)(d) | |||
| 572 | + | ||||
| 573 | + | d.restoreSnapshots(ctx) | |||
| 574 | + | ||||
| 575 | + | require.Len(t, factory.snapshotOpens(), 1, "cancellation stops restore before the next pane") | |||
| 576 | + | require.Empty(t, d.notices.history(), "shutdown cancellation is not a user-visible restore failure") | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) { | |||
| 580 | + | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |||
| 581 | + | store := processRestoreTestStore(t, "agent-session", proc) | |||
| 582 | + | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |||
| 583 | + | d := newTestDaemon(t, factory, newNoticeClock()) | |||
| 584 | + | WithSnapshotStore(store)(d) | |||
| 585 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |||
| 586 | + | ||||
| 587 | + | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |||
| 588 | + | // caller goroutine and signals completion by closing d.restoreDone. | |||
| 589 | + | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |||
| 590 | + | ||||
| 591 | + | select { | |||
| 592 | + | case <-d.restoreDone: | |||
| 593 | + | case <-time.After(2 * time.Second): | |||
| 594 | + | t.Fatal("restoreSnapshots did not close restoreDone in time") | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails") | |||
| 598 | + | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |||
| 599 | + | ||||
| 600 | + | history := d.notices.history() | |||
| 601 | + | require.Len(t, history, 1) | |||
| 602 | + | require.Equal(t, domain.NoticeAutoResume, history[0].Code) | |||
| 603 | + | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |||
| 604 | + | require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message) | |||
| 605 | + | ||||
| 606 | + | tr, _ := newCapturingTransport(t) | |||
| 607 | + | sess, ac, err := d.route(ports.Hello{ | |||
| 608 | + | Version: ports.ProtocolVersion, | |||
| 609 | + | Intent: ports.IntentEphemeral, | |||
| 610 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 611 | + | }, tr) | |||
| 612 | + | require.NoError(t, err) | |||
| 613 | + | d.firstPaint(sess, ac, ac.size) | |||
| 614 | + | ||||
| 615 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 616 | + | require.Equal(t, domain.NoticeAutoResume, toasts[0].Code) | |||
| 617 | + | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 618 | + | } | |||
| 619 | + | ||||
| 500 | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | 620 | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | ||
| 501 | releaseLoad := make(chan struct{}) | 621 | releaseLoad := make(chan struct{}) | ||
| 502 | loaded := make(chan struct{}) | 622 | loaded := make(chan struct{}) | ||
| @@ -599,6 +719,7 @@ type restorePTYFactory struct { | |||||
| 599 | mu sync.Mutex | 719 | mu sync.Mutex | ||
| 600 | opens []restorePTYOpen | 720 | opens []restorePTYOpen | ||
| 601 | writeErr error | 721 | writeErr error | ||
| 722 | + | onOpen func() | |||
| 602 | } | 723 | } | ||
| 603 | 724 | ||||
| 604 | type restorePTYOpen struct { | 725 | type restorePTYOpen struct { | ||
| @@ -612,10 +733,14 @@ type restorePTYOpen struct { | |||||
| 612 | 733 | ||||
| 613 | func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | 734 | func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | ||
| 614 | f.mu.Lock() | 735 | f.mu.Lock() | ||
| 615 | - | defer f.mu.Unlock() | |||
| 616 | pty := newRestorePTY() | 736 | pty := newRestorePTY() | ||
| 617 | pty.writeErr = f.writeErr | 737 | pty.writeErr = f.writeErr | ||
| 618 | f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | 738 | f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | ||
| 739 | + | onOpen := f.onOpen | |||
| 740 | + | f.mu.Unlock() | |||
| 741 | + | if onOpen != nil { | |||
| 742 | + | onOpen() | |||
| 743 | + | } | |||
| 619 | return pty, nil | 744 | return pty, nil | ||
| 620 | } | 745 | } | ||
| 621 | 746 | ||||
M internal/usecase/daemon/status.go +15 -15
| @@ -72,7 +72,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||||
| 72 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { | 72 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { | ||
| 73 | clearStatusRow(row, styles.SurfaceBar) | 73 | clearStatusRow(row, styles.SurfaceBar) | ||
| 74 | x := 0 | 74 | x := 0 | ||
| 75 | - | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | 75 | + | rightText := composeBottomRightText(state.bottomRight, state.statusFeedback) |
| 76 | writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive) | 76 | writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive) | ||
| 77 | if state.rankedRecent != nil { | 77 | if state.rankedRecent != nil { | ||
| 78 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | 78 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | ||
| @@ -120,14 +120,14 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, attention | |||||
| 120 | writeStatusText(row, x, " ", style) | 120 | writeStatusText(row, x, " ", style) | ||
| 121 | } | 121 | } | ||
| 122 | 122 | ||||
| 123 | - | func composeBottomRightText(scriptText, copyFeedback string) string { | 123 | + | func composeBottomRightText(scriptText, statusFeedback string) string { |
| 124 | if scriptText == "" { | 124 | if scriptText == "" { | ||
| 125 | - | return copyFeedback | 125 | + | return statusFeedback |
| 126 | } | 126 | } | ||
| 127 | - | if copyFeedback == "" { | 127 | + | if statusFeedback == "" { |
| 128 | return scriptText | 128 | return scriptText | ||
| 129 | } | 129 | } | ||
| 130 | - | return scriptText + " " + copyFeedback | 130 | + | return scriptText + " " + statusFeedback |
| 131 | } | 131 | } | ||
| 132 | 132 | ||||
| 133 | func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) { | 133 | func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) { | ||
| @@ -158,7 +158,7 @@ type barState struct { | |||||
| 158 | status statusSnapshot | 158 | status statusSnapshot | ||
| 159 | topRight string | 159 | topRight string | ||
| 160 | bottomRight string | 160 | bottomRight string | ||
| 161 | - | copyFeedback string | 161 | + | statusFeedback string |
| 162 | mru []recentSession | 162 | mru []recentSession | ||
| 163 | rankedRecent []rankedRecent | 163 | rankedRecent []rankedRecent | ||
| 164 | attentionFrame int | 164 | attentionFrame int | ||
| @@ -224,31 +224,31 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession | |||||
| 224 | // barStateForPaletteHints selects snapshot-only contextual composition when a | 224 | // barStateForPaletteHints selects snapshot-only contextual composition when a | ||
| 225 | // palette interaction has captured recent-session hints. Normal palette state | 225 | // palette interaction has captured recent-session hints. Normal palette state | ||
| 226 | // continues to use the canonical, live MRU list. | 226 | // continues to use the canonical, live MRU list. | ||
| 227 | - | func (d *Daemon) barStateForPaletteHints(cur *session, copyFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | 227 | + | func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { |
| 228 | ranked := rankedRecentForHints(hints, recent) | 228 | ranked := rankedRecentForHints(hints, recent) | ||
| 229 | if ranked != nil { | 229 | if ranked != nil { | ||
| 230 | - | return d.barStateForContextual(cur, copyFeedback, ranked) | 230 | + | return d.barStateForContextual(cur, statusFeedback, ranked) |
| 231 | } | 231 | } | ||
| 232 | - | return d.barStateFor(cur, copyFeedback) | 232 | + | return d.barStateFor(cur, statusFeedback) |
| 233 | } | 233 | } | ||
| 234 | 234 | ||||
| 235 | // barStateForContextual composes ranks captured for the palette interaction. | 235 | // barStateForContextual composes ranks captured for the palette interaction. | ||
| 236 | // It deliberately does not call recentSessions: live MRU changes must not | 236 | // It deliberately does not call recentSessions: live MRU changes must not | ||
| 237 | // affect contextual rendering, and palette rendering must not acquire d.mu. | 237 | // affect contextual rendering, and palette rendering must not acquire d.mu. | ||
| 238 | - | func (d *Daemon) barStateForContextual(cur *session, copyFeedback string, ranked []rankedRecent) barState { | 238 | + | func (d *Daemon) barStateForContextual(cur *session, statusFeedback string, ranked []rankedRecent) barState { |
| 239 | - | state := d.barStateBase(cur, copyFeedback) | 239 | + | state := d.barStateBase(cur, statusFeedback) |
| 240 | state.rankedRecent = ranked | 240 | state.rankedRecent = ranked | ||
| 241 | return state | 241 | return state | ||
| 242 | } | 242 | } | ||
| 243 | 243 | ||||
| 244 | - | func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | 244 | + | func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { |
| 245 | - | state := d.barStateBase(cur, copyFeedback) | 245 | + | state := d.barStateBase(cur, statusFeedback) |
| 246 | state.mru = d.recentSessions(cur) | 246 | state.mru = d.recentSessions(cur) | ||
| 247 | return state | 247 | return state | ||
| 248 | } | 248 | } | ||
| 249 | 249 | ||||
| 250 | - | func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState { | 250 | + | func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState { |
| 251 | - | state := barState{copyFeedback: copyFeedback} | 251 | + | state := barState{statusFeedback: statusFeedback} |
| 252 | if d != nil { | 252 | if d != nil { | ||
| 253 | state.attentionFrame = d.attentionFrame() | 253 | state.attentionFrame = d.attentionFrame() | ||
| 254 | } | 254 | } | ||
M internal/usecase/daemon/status_test.go +8 -8
| @@ -868,17 +868,17 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |||||
| 868 | win.size = domain.Size{Cols: 30, Rows: 2} | 868 | win.size = domain.Size{Cols: 30, Rows: 2} | ||
| 869 | 869 | ||||
| 870 | frame := composeFrame(capturedRenderState{ | 870 | frame := composeFrame(capturedRenderState{ | ||
| 871 | - | reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "ok"}, | 871 | + | reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "ok"}, |
| 872 | }, composeCacheInput{}).frame | 872 | }, composeCacheInput{}).frame | ||
| 873 | require.Equal(t, " work ok", rowText(frame.Row(3))) | 873 | require.Equal(t, " work ok", rowText(frame.Row(3))) | ||
| 874 | 874 | ||||
| 875 | frame = composeFrame(capturedRenderState{ | 875 | frame = composeFrame(capturedRenderState{ | ||
| 876 | - | reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "1234567890123456789"}, | 876 | + | reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "1234567890123456789"}, |
| 877 | }, composeCacheInput{}).frame | 877 | }, composeCacheInput{}).frame | ||
| 878 | require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3))) | 878 | require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3))) | ||
| 879 | 879 | ||||
| 880 | frame = composeFrame(capturedRenderState{ | 880 | frame = composeFrame(capturedRenderState{ | ||
| 881 | - | reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "selection too large to copy"}, | 881 | + | reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "selection too large to copy"}, |
| 882 | }, composeCacheInput{}).frame | 882 | }, composeCacheInput{}).frame | ||
| 883 | require.Equal(t, " work ", rowText(frame.Row(3))) | 883 | require.Equal(t, " work ", rowText(frame.Row(3))) | ||
| 884 | } | 884 | } | ||
| @@ -1295,21 +1295,21 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||||
| 1295 | { | 1295 | { | ||
| 1296 | name: "script text and copy feedback", | 1296 | name: "script text and copy feedback", | ||
| 1297 | width: 32, | 1297 | width: 32, | ||
| 1298 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "a"}}}, | 1298 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "a"}}}, |
| 1299 | wantContains: []string{" a"}, | 1299 | wantContains: []string{" a"}, | ||
| 1300 | wantSuffix: " main ↑3 * copied", | 1300 | wantSuffix: " main ↑3 * copied", | ||
| 1301 | }, | 1301 | }, | ||
| 1302 | { | 1302 | { | ||
| 1303 | name: "hide on overlap", | 1303 | name: "hide on overlap", | ||
| 1304 | width: 16, | 1304 | width: 16, | ||
| 1305 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}}, | 1305 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}}, |
| 1306 | want: " cur ", | 1306 | want: " cur ", | ||
| 1307 | wantNotContains: []string{"main", "copied"}, | 1307 | wantNotContains: []string{"main", "copied"}, | ||
| 1308 | }, | 1308 | }, | ||
| 1309 | { | 1309 | { | ||
| 1310 | name: "empty script keeps copy feedback behavior", | 1310 | name: "empty script keeps copy feedback behavior", | ||
| 1311 | width: 12, | 1311 | width: 12, | ||
| 1312 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}}, | 1312 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}}, |
| 1313 | want: " cur copied", | 1313 | want: " cur copied", | ||
| 1314 | }, | 1314 | }, | ||
| 1315 | { | 1315 | { | ||
| @@ -1364,7 +1364,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||||
| 1364 | } | 1364 | } | ||
| 1365 | 1365 | ||||
| 1366 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | 1366 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | ||
| 1367 | - | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 1367 | + | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} |
| 1368 | row := make([]renderer.Cell, 20) | 1368 | row := make([]renderer.Cell, 20) | ||
| 1369 | 1369 | ||||
| 1370 | drawStatusBarState(row, state, resolveStyles(nil)) | 1370 | drawStatusBarState(row, state, resolveStyles(nil)) | ||
| @@ -1377,7 +1377,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |||||
| 1377 | } | 1377 | } | ||
| 1378 | 1378 | ||||
| 1379 | func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | 1379 | func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | ||
| 1380 | - | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}} | 1380 | + | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}} |
| 1381 | tests := []struct { | 1381 | tests := []struct { | ||
| 1382 | name string | 1382 | name string | ||
| 1383 | cols int | 1383 | cols int | ||
A internal/usecase/daemon/toast_damage_test.go +156 -0
| @@ -0,0 +1,156 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing.T) { | |||
| 14 | + | baseState := toastDamageState(nil, nil, true) | |||
| 15 | + | base := composeFrame(baseState, composeCacheInput{}) | |||
| 16 | + | stream := newOutputStateStream() | |||
| 17 | + | terminal := vt.NewScreen(base.frame.Width, base.frame.Height) | |||
| 18 | + | replayToastFrame(t, stream, terminal, base) | |||
| 19 | + | ||||
| 20 | + | one := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1} | |||
| 21 | + | shownState := toastDamageState([]domain.Notification{one}, nil, false) | |||
| 22 | + | shown := composeFrame(shownState, base.cache) | |||
| 23 | + | requireNoFullRedraw(t, shown.damage) | |||
| 24 | + | requireToastFootprintsDamaged(t, shown.damage, shown.cache.toastFootprints) | |||
| 25 | + | replayToastFrame(t, stream, terminal, shown) | |||
| 26 | + | ||||
| 27 | + | // A one-cell PTY update while the toast is stable redraws only the pane cell | |||
| 28 | + | // and toast footprint, never the whole terminal. | |||
| 29 | + | updatedState := toastDamageState([]domain.Notification{one}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |||
| 30 | + | updatedState.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |||
| 31 | + | updated := composeFrame(updatedState, shown.cache) | |||
| 32 | + | requireNoFullRedraw(t, updated.damage) | |||
| 33 | + | require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1}) | |||
| 34 | + | requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints) | |||
| 35 | + | requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints) | |||
| 36 | + | incremental, err := stream.render(updated.frame, updated.damage, updated.reset) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | fullRenderer := renderer.New(renderer.Capabilities{}) | |||
| 39 | + | full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()}) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output") | |||
| 42 | + | terminal.Write(incremental) | |||
| 43 | + | require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame)) | |||
| 44 | + | ||||
| 45 | + | // Stacking and overflow stay visually identical while their complete old and | |||
| 46 | + | // new coverage remains incremental damage. | |||
| 47 | + | two := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "second", Count: 1} | |||
| 48 | + | three := domain.Notification{Code: domain.NoticeConfigReload, Severity: domain.NoticeError, Message: "third", Count: 1} | |||
| 49 | + | stackedState := toastDamageState([]domain.Notification{two, one, three}, nil, false) | |||
| 50 | + | stackedState.overlays.noticeOverflow = 2 | |||
| 51 | + | stacked := composeFrame(stackedState, updated.cache) | |||
| 52 | + | requireNoFullRedraw(t, stacked.damage) | |||
| 53 | + | requireToastFootprintsDamaged(t, stacked.damage, updated.cache.toastFootprints) | |||
| 54 | + | requireToastFootprintsDamaged(t, stacked.damage, stacked.cache.toastFootprints) | |||
| 55 | + | replayToastFrame(t, stream, terminal, stacked) | |||
| 56 | + | ||||
| 57 | + | // Coalescing changes the title count in place. Both old and new coverage is | |||
| 58 | + | // damaged even where their geometry happens to be identical. | |||
| 59 | + | coalescedTwo := two | |||
| 60 | + | coalescedTwo.Count = 12 | |||
| 61 | + | coalescedState := toastDamageState([]domain.Notification{coalescedTwo, one, three}, nil, false) | |||
| 62 | + | coalescedState.overlays.noticeOverflow = 2 | |||
| 63 | + | coalesced := composeFrame(coalescedState, stacked.cache) | |||
| 64 | + | requireNoFullRedraw(t, coalesced.damage) | |||
| 65 | + | requireToastFootprintsDamaged(t, coalesced.damage, stacked.cache.toastFootprints) | |||
| 66 | + | requireToastFootprintsDamaged(t, coalesced.damage, coalesced.cache.toastFootprints) | |||
| 67 | + | replayToastFrame(t, stream, terminal, coalesced) | |||
| 68 | + | ||||
| 69 | + | // Dismissal and expiry have the same rendering transition: no current toast | |||
| 70 | + | // exists, so the previous footprint alone must restore every exposed cell. | |||
| 71 | + | for _, tt := range []struct { | |||
| 72 | + | name string | |||
| 73 | + | from composedRenderFrame | |||
| 74 | + | }{ | |||
| 75 | + | {name: "dismiss", from: coalesced}, | |||
| 76 | + | {name: "expiry", from: shown}, | |||
| 77 | + | } { | |||
| 78 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 79 | + | cleared := composeFrame(toastDamageState(nil, nil, false), tt.from.cache) | |||
| 80 | + | requireNoFullRedraw(t, cleared.damage) | |||
| 81 | + | requireToastFootprintsDamaged(t, cleared.damage, tt.from.cache.toastFootprints) | |||
| 82 | + | require.Empty(t, cleared.cache.toastFootprints) | |||
| 83 | + | }) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | cleared := composeFrame(toastDamageState(nil, nil, false), coalesced.cache) | |||
| 87 | + | replayToastFrame(t, stream, terminal, cleared) | |||
| 88 | + | require.Equal(t, frameRows(coalesced.cache.frame), frameRows(cleared.frame), "dismissal must restore the toast-free base without artifacts") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func BenchmarkComposeFrameStableToastOneCellUpdate(b *testing.B) { | |||
| 92 | + | base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{}) | |||
| 93 | + | notice := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1} | |||
| 94 | + | shown := composeFrame(toastDamageState([]domain.Notification{notice}, nil, false), base.cache, composeCacheInput{}) | |||
| 95 | + | cache, scratch := shown.cache, base.cache | |||
| 96 | + | state := toastDamageState([]domain.Notification{notice}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |||
| 97 | + | ||||
| 98 | + | b.ReportAllocs() | |||
| 99 | + | for i := 0; b.Loop(); i++ { | |||
| 100 | + | cell := renderer.Cell{Rune: rune('A' + i%26), Style: renderer.DefaultStyle()} | |||
| 101 | + | state.panes[0].frame.Set(0, 0, cell) | |||
| 102 | + | out := composeFrame(state, cache, scratch) | |||
| 103 | + | if hasFullRedraw(out.damage) { | |||
| 104 | + | b.Fatal("stable toast forced a full redraw") | |||
| 105 | + | } | |||
| 106 | + | scratch, cache = cache, out.cache | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func toastDamageState(notices []domain.Notification, damage []renderer.Damage, reset bool) capturedRenderState { | |||
| 111 | + | paneFrame := renderer.NewFrame(80, 20) | |||
| 112 | + | for y := 0; y < paneFrame.Height; y++ { | |||
| 113 | + | for x := 0; x < paneFrame.Width; x++ { | |||
| 114 | + | paneFrame.Set(x, y, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()}) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 20}} | |||
| 118 | + | return capturedRenderState{ | |||
| 119 | + | reset: reset, | |||
| 120 | + | layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 20}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "toast-damage", valid: true}, | |||
| 121 | + | panes: []capturedPaneRenderState{{id: "pane", frame: paneFrame, placement: placement, focused: true, damage: damage}}, | |||
| 122 | + | overlays: capturedOverlayRenderState{notices: notices}, | |||
| 123 | + | styles: resolveStyles(nil), | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) { | |||
| 128 | + | t.Helper() | |||
| 129 | + | data, err := stream.render(composed.frame, composed.damage, composed.reset) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | terminal.Write(data) | |||
| 132 | + | require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame)) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) { | |||
| 136 | + | t.Helper() | |||
| 137 | + | for _, d := range damage { | |||
| 138 | + | require.NotEqual(t, renderer.DamageFullRedraw, d.Kind) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func requireToastFootprintsDamaged(t *testing.T, damage []renderer.Damage, footprints []domain.Rect) { | |||
| 143 | + | t.Helper() | |||
| 144 | + | for _, footprint := range footprints { | |||
| 145 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height}) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func hasFullRedraw(damage []renderer.Damage) bool { | |||
| 150 | + | for _, d := range damage { | |||
| 151 | + | if d.Kind == renderer.DamageFullRedraw { | |||
| 152 | + | return true | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | return false | |||
| 156 | + | } | |||
M internal/usecase/daemon/transactional_resize.go +17 -0
| @@ -71,8 +71,22 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | |||||
| 71 | // applyResize deliberately holds only a pane's resizeMu around its PTY call; | 71 | // applyResize deliberately holds only a pane's resizeMu around its PTY call; | ||
| 72 | // no daemon, session, tab, or pane lock crosses the external boundary. | 72 | // no daemon, session, tab, or pane lock crosses the external boundary. | ||
| 73 | func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | 73 | func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | ||
| 74 | + | // Collect degradation across the loop and report once from the explicit | |||
| 75 | + | // lock-free return points. notify acquires sess.mu/d.mu and repaints, so it | |||
| 76 | + | // must never run while a pane's resizeMu is held. | |||
| 77 | + | failed := 0 | |||
| 78 | + | var lastErr error | |||
| 79 | + | reportFailure := func() { | |||
| 80 | + | if failed == 0 { | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | // A failure can only come from a plan member, so members[0] is safe. | |||
| 84 | + | d.notify(plan.members[0].session, domain.NoticeWarn, domain.NoticeResizeFailed, | |||
| 85 | + | "pane resize failed; retrying in background", lastErr) | |||
| 86 | + | } | |||
| 74 | for i := range plan.members { | 87 | for i := range plan.members { | ||
| 75 | if len(current) != 0 && !current[0]() { | 88 | if len(current) != 0 && !current[0]() { | ||
| 89 | + | reportFailure() | |||
| 76 | return false | 90 | return false | ||
| 77 | } | 91 | } | ||
| 78 | m := &plan.members[i] | 92 | m := &plan.members[i] | ||
| @@ -91,6 +105,8 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | |||||
| 91 | m.ok = true | 105 | m.ok = true | ||
| 92 | } else if err := pty.Resize(rectSize(m.rect)); err != nil { | 106 | } else if err := pty.Resize(rectSize(m.rect)); err != nil { | ||
| 93 | d.log.Warn("pty resize failed", "err", err) | 107 | d.log.Warn("pty resize failed", "err", err) | ||
| 108 | + | failed++ | |||
| 109 | + | lastErr = err | |||
| 94 | d.replayResizePending(m.session, m.tab, m.pane, false, m.rect) | 110 | d.replayResizePending(m.session, m.tab, m.pane, false, m.rect) | ||
| 95 | } else { | 111 | } else { | ||
| 96 | m.ok = true | 112 | m.ok = true | ||
| @@ -99,6 +115,7 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | |||||
| 99 | } | 115 | } | ||
| 100 | m.pane.resizeMu.Unlock() | 116 | m.pane.resizeMu.Unlock() | ||
| 101 | } | 117 | } | ||
| 118 | + | reportFailure() | |||
| 102 | return true | 119 | return true | ||
| 103 | } | 120 | } | ||
| 104 | 121 | ||||
M internal/usecase/daemon/transactional_resize_test.go +96 -0
| @@ -204,6 +204,102 @@ func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) { | |||||
| 204 | require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu") | 204 | require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu") | ||
| 205 | } | 205 | } | ||
| 206 | 206 | ||||
| 207 | + | // P3 degradation: a failing PTY resize is surfaced as exactly one warn notice | |||
| 208 | + | // per applyResize call, collected inside the resizeMu loop and reported once | |||
| 209 | + | // after it returns to lock-free code. The count is the meaningful assertion: | |||
| 210 | + | // notify acquires sess.mu/d.mu and must never run while any resizeMu is held. | |||
| 211 | + | func TestTransactionalResizeApplyReportsFailureOncePerCall(t *testing.T) { | |||
| 212 | + | countResizeFailed := func(d *Daemon) (int, domain.Notification) { | |||
| 213 | + | var last domain.Notification | |||
| 214 | + | count := 0 | |||
| 215 | + | for _, h := range d.notices.history() { | |||
| 216 | + | if h.Code == domain.NoticeResizeFailed { | |||
| 217 | + | count++ | |||
| 218 | + | last = h | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | return count, last | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | t.Run("one failed member yields one notice after return", func(t *testing.T) { | |||
| 225 | + | failing := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}} | |||
| 226 | + | d, sess, _, _ := newManualSessionWithPTYs(t, failing) | |||
| 227 | + | tb := sess.activeTab() | |||
| 228 | + | p := tb.focusedPane() | |||
| 229 | + | plan := resizePlan{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: domain.Rect{Width: 100, Height: 20}, retry: true}}} | |||
| 230 | + | ||||
| 231 | + | require.Empty(t, d.notices.history(), "no notice is recorded before applyResize") | |||
| 232 | + | require.True(t, d.applyResize(&plan)) | |||
| 233 | + | ||||
| 234 | + | count, n := countResizeFailed(d) | |||
| 235 | + | require.Equal(t, 1, count, "exactly one NoticeResizeFailed per applyResize call") | |||
| 236 | + | require.Equal(t, domain.NoticeWarn, n.Severity) | |||
| 237 | + | require.Equal(t, "pane resize failed; retrying in background", n.Message) | |||
| 238 | + | require.False(t, plan.members[0].ok, "failed member is not marked ok") | |||
| 239 | + | }) | |||
| 240 | + | ||||
| 241 | + | for _, tt := range []struct { | |||
| 242 | + | name string | |||
| 243 | + | firstErr error | |||
| 244 | + | secondErr error | |||
| 245 | + | current func() bool | |||
| 246 | + | wantResult bool | |||
| 247 | + | wantCount int | |||
| 248 | + | }{ | |||
| 249 | + | { | |||
| 250 | + | name: "normal completion reports one combined failure", | |||
| 251 | + | firstErr: errors.New("first fails"), | |||
| 252 | + | secondErr: errors.New("second fails"), | |||
| 253 | + | wantResult: true, | |||
| 254 | + | wantCount: 1, | |||
| 255 | + | }, | |||
| 256 | + | { | |||
| 257 | + | name: "stale current after failure still reports once", | |||
| 258 | + | firstErr: errors.New("first fails"), | |||
| 259 | + | secondErr: errors.New("must not be resized"), | |||
| 260 | + | current: func() func() bool { | |||
| 261 | + | checks := 0 | |||
| 262 | + | return func() bool { | |||
| 263 | + | checks++ | |||
| 264 | + | return checks == 1 | |||
| 265 | + | } | |||
| 266 | + | }(), | |||
| 267 | + | wantResult: false, | |||
| 268 | + | wantCount: 1, | |||
| 269 | + | }, | |||
| 270 | + | { | |||
| 271 | + | name: "normal completion without failure does not report", | |||
| 272 | + | wantResult: true, | |||
| 273 | + | wantCount: 0, | |||
| 274 | + | }, | |||
| 275 | + | } { | |||
| 276 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 277 | + | first := &transactionalResizePTY{errs: []error{tt.firstErr}} | |||
| 278 | + | second := &transactionalResizePTY{errs: []error{tt.secondErr}} | |||
| 279 | + | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | |||
| 280 | + | tabs := sess.tabs | |||
| 281 | + | plan := resizePlan{members: []resizeMember{ | |||
| 282 | + | {session: sess, tab: tabs[0], pane: tabs[0].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true}, | |||
| 283 | + | {session: sess, tab: tabs[1], pane: tabs[1].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true}, | |||
| 284 | + | }} | |||
| 285 | + | ||||
| 286 | + | var got bool | |||
| 287 | + | if tt.current == nil { | |||
| 288 | + | got = d.applyResize(&plan) | |||
| 289 | + | } else { | |||
| 290 | + | got = d.applyResize(&plan, tt.current) | |||
| 291 | + | } | |||
| 292 | + | require.Equal(t, tt.wantResult, got) | |||
| 293 | + | ||||
| 294 | + | count, _ := countResizeFailed(d) | |||
| 295 | + | require.Equal(t, tt.wantCount, count, "one notice per applyResize call, including stale returns") | |||
| 296 | + | if !tt.wantResult { | |||
| 297 | + | require.Equal(t, []domain.Size(nil), second.requested(), "stale current check must stop before the next member") | |||
| 298 | + | } | |||
| 299 | + | }) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 207 | // S3 acceptance: a failed member never publishes speculative parser/screen or | 303 | // S3 acceptance: a failed member never publishes speculative parser/screen or | ||
| 208 | // rectangle state. Successful members and the client layout commit together; | 304 | // rectangle state. Successful members and the client layout commit together; | ||
| 209 | // composition clips/pads the retained failed screen against that new layout. | 305 | // composition clips/pads the retained failed screen against that new layout. | ||
A internal/usecase/notices/model.go +180 -0
| @@ -0,0 +1,180 @@ | |||||
| 1 | + | // Package notices renders the notification history overlay: a scrollable, | |||
| 2 | + | // newest-first list of the daemon's recorded notifications. | |||
| 3 | + | package notices | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // RenderStyles are the styles Render uses to draw list rows. The zero value | |||
| 15 | + | // is never used directly by daemon composition, which always supplies a | |||
| 16 | + | // theme-derived RenderStyles the way picker.RenderStyles is supplied. | |||
| 17 | + | type RenderStyles struct { | |||
| 18 | + | Background renderer.Style // fills unused interior rows | |||
| 19 | + | Base renderer.Style // non-selected row fill | |||
| 20 | + | Selection renderer.Style // selected row fill | |||
| 21 | + | Text renderer.Style // non-selected message segment | |||
| 22 | + | SelectionText renderer.Style // selected row message segment | |||
| 23 | + | Muted renderer.Style // non-selected severity/time/slug segment | |||
| 24 | + | SelectionMuted renderer.Style // selected row severity/time/slug segment | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // Model is the history overlay's list state: a snapshot of notifications, | |||
| 28 | + | // newest first, with a navigable selection. now anchors the relative | |||
| 29 | + | // timestamps rendered next to each row and is fixed at construction so the | |||
| 30 | + | // overlay's times don't drift while it stays open. | |||
| 31 | + | type Model struct { | |||
| 32 | + | rows []domain.Notification | |||
| 33 | + | selected int | |||
| 34 | + | now time.Time | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // New builds a Model from entries, expected newest-first as returned by the | |||
| 38 | + | // notice center's history(). now is the instant relative row times render | |||
| 39 | + | // against. | |||
| 40 | + | func New(entries []domain.Notification, now time.Time) *Model { | |||
| 41 | + | m := &Model{rows: append([]domain.Notification(nil), entries...), now: now, selected: -1} | |||
| 42 | + | if len(m.rows) > 0 { | |||
| 43 | + | m.selected = 0 | |||
| 44 | + | } | |||
| 45 | + | return m | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (m *Model) Up() { | |||
| 49 | + | m.move(-1) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (m *Model) Down() { | |||
| 53 | + | m.move(1) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (m *Model) move(delta int) { | |||
| 57 | + | if m == nil || len(m.rows) == 0 { | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | next := m.selected + delta | |||
| 61 | + | if next < 0 { | |||
| 62 | + | next = 0 | |||
| 63 | + | } | |||
| 64 | + | if next >= len(m.rows) { | |||
| 65 | + | next = len(m.rows) - 1 | |||
| 66 | + | } | |||
| 67 | + | m.selected = next | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Selected returns the highlighted notification, or false when history is | |||
| 71 | + | // empty. | |||
| 72 | + | func (m *Model) Selected() (domain.Notification, bool) { | |||
| 73 | + | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | |||
| 74 | + | return domain.Notification{}, false | |||
| 75 | + | } | |||
| 76 | + | return m.rows[m.selected], true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Clone returns an independent copy safe to render outside the lock that | |||
| 80 | + | // guards the live model. | |||
| 81 | + | func (m *Model) Clone() *Model { | |||
| 82 | + | if m == nil { | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | clone := *m | |||
| 86 | + | clone.rows = append([]domain.Notification(nil), m.rows...) | |||
| 87 | + | return &clone | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // Render draws the history list into inner, scrolling to keep the selection | |||
| 91 | + | // visible. Degenerate dimensions draw nothing; an empty history draws a | |||
| 92 | + | // centered placeholder instead of a blank box. | |||
| 93 | + | func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 94 | + | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |||
| 95 | + | if frame.Width <= 0 || frame.Height <= 0 { | |||
| 96 | + | return frame | |||
| 97 | + | } | |||
| 98 | + | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Background}) | |||
| 99 | + | if m == nil || len(m.rows) == 0 { | |||
| 100 | + | text := "No notifications" | |||
| 101 | + | x := max(0, (frame.Width-textWidth(text))/2) | |||
| 102 | + | ui.DrawText(frame, x, frame.Height/2, frame.Width, text, styles.Text) | |||
| 103 | + | return frame | |||
| 104 | + | } | |||
| 105 | + | offset := m.scrollOffset(frame.Height) | |||
| 106 | + | for y := 0; y < frame.Height; y++ { | |||
| 107 | + | idx := offset + y | |||
| 108 | + | if idx >= len(m.rows) { | |||
| 109 | + | break | |||
| 110 | + | } | |||
| 111 | + | n := m.rows[idx] | |||
| 112 | + | base, mutedStyle, textStyle := styles.Base, styles.Muted, styles.Text | |||
| 113 | + | if idx == m.selected { | |||
| 114 | + | base, mutedStyle, textStyle = styles.Selection, styles.SelectionMuted, styles.SelectionText | |||
| 115 | + | } | |||
| 116 | + | ui.FillRect(frame, domain.Rect{X: 0, Y: y, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |||
| 117 | + | meta := fmt.Sprintf("[%c] %s %s ", severityLetter(n.Severity), relativeTime(m.now, n.Time), n.Code.String()) | |||
| 118 | + | x := ui.DrawText(frame, 0, y, frame.Width, meta, mutedStyle) | |||
| 119 | + | ui.DrawText(frame, x, y, frame.Width, rowMessage(n), textStyle) | |||
| 120 | + | } | |||
| 121 | + | return frame | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // rowMessage appends a "×N" coalesce count suffix. The format string matches | |||
| 125 | + | // the toast overlay's own count formatting (internal/usecase/ui.ComposeNotices), | |||
| 126 | + | // but the field it is appended to differs: the toast appends to Title, while | |||
| 127 | + | // this row appends to Message, matching this overlay's row layout. | |||
| 128 | + | func rowMessage(n domain.Notification) string { | |||
| 129 | + | if n.Count > 1 { | |||
| 130 | + | return fmt.Sprintf("%s ×%d", n.Message, n.Count) | |||
| 131 | + | } | |||
| 132 | + | return n.Message | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (m *Model) scrollOffset(visible int) int { | |||
| 136 | + | if visible <= 0 || len(m.rows) <= visible || m.selected < 0 { | |||
| 137 | + | return 0 | |||
| 138 | + | } | |||
| 139 | + | if m.selected < visible { | |||
| 140 | + | return 0 | |||
| 141 | + | } | |||
| 142 | + | offset := m.selected - visible + 1 | |||
| 143 | + | return min(offset, len(m.rows)-visible) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func severityLetter(sev domain.NoticeSeverity) byte { | |||
| 147 | + | switch sev { | |||
| 148 | + | case domain.NoticeWarn: | |||
| 149 | + | return 'W' | |||
| 150 | + | case domain.NoticeError: | |||
| 151 | + | return 'E' | |||
| 152 | + | default: | |||
| 153 | + | return 'I' | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // relativeTime renders how long ago t was, relative to now, in the coarsest | |||
| 158 | + | // unit that keeps the row compact. A non-positive delta (including clock | |||
| 159 | + | // skew that puts t in the future) reads as "just now" rather than negative. | |||
| 160 | + | func relativeTime(now, t time.Time) string { | |||
| 161 | + | d := now.Sub(t) | |||
| 162 | + | switch { | |||
| 163 | + | case d < time.Minute: | |||
| 164 | + | return "just now" | |||
| 165 | + | case d < time.Hour: | |||
| 166 | + | return fmt.Sprintf("%dm ago", int(d/time.Minute)) | |||
| 167 | + | case d < 24*time.Hour: | |||
| 168 | + | return fmt.Sprintf("%dh ago", int(d/time.Hour)) | |||
| 169 | + | default: | |||
| 170 | + | return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func textWidth(text string) int { | |||
| 175 | + | width := 0 | |||
| 176 | + | for _, r := range text { | |||
| 177 | + | width += renderer.RuneWidth(r) | |||
| 178 | + | } | |||
| 179 | + | return width | |||
| 180 | + | } | |||
A internal/usecase/notices/model_test.go +224 -0
| @@ -0,0 +1,224 @@ | |||||
| 1 | + | package notices | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func testStyles() RenderStyles { | |||
| 14 | + | selection := renderer.DefaultStyle() | |||
| 15 | + | selection.Inverse = true | |||
| 16 | + | base := renderer.DefaultStyle() | |||
| 17 | + | return RenderStyles{Background: base, Base: base, Selection: selection, Text: base, SelectionText: selection, Muted: base, SelectionMuted: selection} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // rowText flattens one rendered row back to text, skipping wide-rune | |||
| 21 | + | // continuation cells, so assertions can check substrings. | |||
| 22 | + | func rowText(f renderer.Frame, y int) string { | |||
| 23 | + | var b strings.Builder | |||
| 24 | + | for _, c := range f.Row(y) { | |||
| 25 | + | if c.Continuation { | |||
| 26 | + | continue | |||
| 27 | + | } | |||
| 28 | + | if c.Rune == 0 { | |||
| 29 | + | b.WriteRune(' ') | |||
| 30 | + | continue | |||
| 31 | + | } | |||
| 32 | + | b.WriteRune(c.Rune) | |||
| 33 | + | } | |||
| 34 | + | return b.String() | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestNewSelectsNewestFirstEntry(t *testing.T) { | |||
| 38 | + | now := time.Unix(1000, 0) | |||
| 39 | + | entries := []domain.Notification{ | |||
| 40 | + | {Code: domain.NoticePaneSpawn, Message: "newest", Time: now}, | |||
| 41 | + | {Code: domain.NoticeTabSpawn, Message: "oldest", Time: now.Add(-time.Hour)}, | |||
| 42 | + | } | |||
| 43 | + | m := New(entries, now) | |||
| 44 | + | got, ok := m.Selected() | |||
| 45 | + | if !ok || got.Message != "newest" { | |||
| 46 | + | t.Fatalf("Selected() = %+v, %v, want newest, true", got, ok) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestNavigationClampsAtBothEnds(t *testing.T) { | |||
| 51 | + | tests := []struct { | |||
| 52 | + | name string | |||
| 53 | + | moves func(m *Model) | |||
| 54 | + | want string | |||
| 55 | + | }{ | |||
| 56 | + | {"up from top stays at top", func(m *Model) { m.Up(); m.Up() }, "a"}, | |||
| 57 | + | {"down past bottom stays at bottom", func(m *Model) { m.Down(); m.Down(); m.Down() }, "c"}, | |||
| 58 | + | {"down then up returns to top", func(m *Model) { m.Down(); m.Down(); m.Up(); m.Up() }, "a"}, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | now := time.Unix(1000, 0) | |||
| 63 | + | m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}, {Message: "c", Time: now}}, now) | |||
| 64 | + | tt.moves(m) | |||
| 65 | + | got, ok := m.Selected() | |||
| 66 | + | if !ok || got.Message != tt.want { | |||
| 67 | + | t.Fatalf("Selected() = %q, %v, want %q, true", got.Message, ok, tt.want) | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestSelectedAndNavigationOnEmptyHistory(t *testing.T) { | |||
| 74 | + | m := New(nil, time.Unix(1000, 0)) | |||
| 75 | + | if _, ok := m.Selected(); ok { | |||
| 76 | + | t.Fatal("Selected() ok = true on empty history, want false") | |||
| 77 | + | } | |||
| 78 | + | m.Up() | |||
| 79 | + | m.Down() | |||
| 80 | + | if _, ok := m.Selected(); ok { | |||
| 81 | + | t.Fatal("Selected() ok = true after navigating empty history, want false") | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestRelativeTime(t *testing.T) { | |||
| 86 | + | now := time.Unix(10000, 0) | |||
| 87 | + | tests := []struct { | |||
| 88 | + | name string | |||
| 89 | + | at time.Time | |||
| 90 | + | want string | |||
| 91 | + | }{ | |||
| 92 | + | {"seconds ago", now.Add(-30 * time.Second), "just now"}, | |||
| 93 | + | {"two minutes ago", now.Add(-2 * time.Minute), "2m ago"}, | |||
| 94 | + | {"fifty nine minutes ago", now.Add(-59 * time.Minute), "59m ago"}, | |||
| 95 | + | {"two hours ago", now.Add(-2 * time.Hour), "2h ago"}, | |||
| 96 | + | {"two days ago", now.Add(-48 * time.Hour), "2d ago"}, | |||
| 97 | + | {"future clock skew", now.Add(time.Minute), "just now"}, | |||
| 98 | + | } | |||
| 99 | + | for _, tt := range tests { | |||
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 101 | + | if got := relativeTime(now, tt.at); got != tt.want { | |||
| 102 | + | t.Errorf("relativeTime(now, %v) = %q, want %q", tt.at, got, tt.want) | |||
| 103 | + | } | |||
| 104 | + | }) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestRenderRowContainsSeverityCodeSlugAndCount(t *testing.T) { | |||
| 109 | + | now := time.Unix(10000, 0) | |||
| 110 | + | entries := []domain.Notification{ | |||
| 111 | + | {Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Count: 3, Time: now.Add(-2 * time.Minute)}, | |||
| 112 | + | } | |||
| 113 | + | m := New(entries, now) | |||
| 114 | + | frame := m.Render(domain.Size{Cols: 60, Rows: 5}, testStyles()) | |||
| 115 | + | row := rowText(frame, 0) | |||
| 116 | + | for _, want := range []string{"[E]", "2m ago", "pane-spawn", "couldn't open pane", "×3"} { | |||
| 117 | + | if !strings.Contains(row, want) { | |||
| 118 | + | t.Errorf("row %q does not contain %q", row, want) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestRenderSeverityLetterPerSeverity(t *testing.T) { | |||
| 124 | + | now := time.Unix(10000, 0) | |||
| 125 | + | tests := []struct { | |||
| 126 | + | sev domain.NoticeSeverity | |||
| 127 | + | want string | |||
| 128 | + | }{ | |||
| 129 | + | {domain.NoticeInfo, "[I]"}, | |||
| 130 | + | {domain.NoticeWarn, "[W]"}, | |||
| 131 | + | {domain.NoticeError, "[E]"}, | |||
| 132 | + | } | |||
| 133 | + | for _, tt := range tests { | |||
| 134 | + | m := New([]domain.Notification{{Severity: tt.sev, Message: "m", Time: now}}, now) | |||
| 135 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 3}, testStyles()) | |||
| 136 | + | row := rowText(frame, 0) | |||
| 137 | + | if !strings.Contains(row, tt.want) { | |||
| 138 | + | t.Errorf("severity %v: row %q does not contain %q", tt.sev, row, tt.want) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestRenderEmptyHistoryShowsPlaceholderNotBlank(t *testing.T) { | |||
| 144 | + | m := New(nil, time.Unix(0, 0)) | |||
| 145 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles()) | |||
| 146 | + | blank := true | |||
| 147 | + | for y := 0; y < frame.Height; y++ { | |||
| 148 | + | if strings.TrimSpace(rowText(frame, y)) != "" { | |||
| 149 | + | blank = false | |||
| 150 | + | break | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | if blank { | |||
| 154 | + | t.Fatal("Render() on empty history drew nothing, want a placeholder") | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestRenderDegenerateDimensionsDoesNotPanic(t *testing.T) { | |||
| 159 | + | m := New([]domain.Notification{{Message: "x", Time: time.Unix(0, 0)}}, time.Unix(0, 0)) | |||
| 160 | + | tests := []struct { | |||
| 161 | + | size domain.Size | |||
| 162 | + | wantWidth int | |||
| 163 | + | wantHeight int | |||
| 164 | + | }{ | |||
| 165 | + | // Render clamps each negative dimension to 0 independently via | |||
| 166 | + | // max(inner.Cols, 0) / max(inner.Rows, 0); it never clamps the pair | |||
| 167 | + | // together, so a negative Cols with a valid Rows still yields a | |||
| 168 | + | // zero-width, full-height frame (and vice versa). | |||
| 169 | + | {domain.Size{Cols: 0, Rows: 0}, 0, 0}, | |||
| 170 | + | {domain.Size{Cols: -1, Rows: 5}, 0, 5}, | |||
| 171 | + | {domain.Size{Cols: 5, Rows: -1}, 5, 0}, | |||
| 172 | + | {domain.Size{Cols: 0, Rows: 5}, 0, 5}, | |||
| 173 | + | {domain.Size{Cols: 1, Rows: 1}, 1, 1}, | |||
| 174 | + | } | |||
| 175 | + | for _, tt := range tests { | |||
| 176 | + | frame := m.Render(tt.size, testStyles()) | |||
| 177 | + | if frame.Width != tt.wantWidth || frame.Height != tt.wantHeight { | |||
| 178 | + | t.Fatalf("Render(%+v) = frame{Width: %d, Height: %d}, want {Width: %d, Height: %d}", tt.size, frame.Width, frame.Height, tt.wantWidth, tt.wantHeight) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestRenderScrollsToKeepSelectionVisible(t *testing.T) { | |||
| 184 | + | now := time.Unix(10000, 0) | |||
| 185 | + | entries := make([]domain.Notification, 200) | |||
| 186 | + | for i := range entries { | |||
| 187 | + | entries[i] = domain.Notification{Message: fmt.Sprintf("n%d", i), Time: now} | |||
| 188 | + | } | |||
| 189 | + | m := New(entries, now) | |||
| 190 | + | for range 150 { | |||
| 191 | + | m.Down() | |||
| 192 | + | } | |||
| 193 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles()) | |||
| 194 | + | found := false | |||
| 195 | + | for y := 0; y < frame.Height; y++ { | |||
| 196 | + | if strings.Contains(rowText(frame, y), "n150") { | |||
| 197 | + | found = true | |||
| 198 | + | break | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | if !found { | |||
| 202 | + | t.Fatal("Render() scrolled the selected row out of view") | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestCloneIsIndependentOfOriginal(t *testing.T) { | |||
| 207 | + | now := time.Unix(10000, 0) | |||
| 208 | + | m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}}, now) | |||
| 209 | + | clone := m.Clone() | |||
| 210 | + | m.Down() | |||
| 211 | + | if got, _ := clone.Selected(); got.Message != "a" { | |||
| 212 | + | t.Fatalf("clone.Selected() = %q, want a (clone must not observe later mutation)", got.Message) | |||
| 213 | + | } | |||
| 214 | + | if got, _ := m.Selected(); got.Message != "b" { | |||
| 215 | + | t.Fatalf("m.Selected() = %q, want b", got.Message) | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func TestCloneOfNilIsNil(t *testing.T) { | |||
| 220 | + | var m *Model | |||
| 221 | + | if clone := m.Clone(); clone != nil { | |||
| 222 | + | t.Fatalf("Clone() of nil = %v, want nil", clone) | |||
| 223 | + | } | |||
| 224 | + | } | |||
M internal/usecase/theme/color_oklab.go +34 -0
| @@ -52,6 +52,40 @@ func okLabChroma(color oklab) float64 { | |||||
| 52 | return math.Hypot(color.A, color.B) | 52 | return math.Hypot(color.A, color.B) | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | + | // oklch is the polar (lightness, chroma, hue) form of oklab. Hue is degrees | |||
| 56 | + | // in [0, 360). | |||
| 57 | + | type oklch struct { | |||
| 58 | + | L float64 | |||
| 59 | + | C float64 | |||
| 60 | + | H float64 | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // oklabToOKLCh converts to the polar form used to isolate hue from | |||
| 64 | + | // lightness and chroma. | |||
| 65 | + | func oklabToOKLCh(color oklab) oklch { | |||
| 66 | + | hue := math.Atan2(color.B, color.A) * 180 / math.Pi | |||
| 67 | + | if hue < 0 { | |||
| 68 | + | hue += 360 | |||
| 69 | + | } | |||
| 70 | + | return oklch{L: color.L, C: okLabChroma(color), H: hue} | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // oklchToOKLab converts back from the polar form. | |||
| 74 | + | func oklchToOKLab(color oklch) oklab { | |||
| 75 | + | rad := color.H * math.Pi / 180 | |||
| 76 | + | return oklab{L: color.L, A: color.C * math.Cos(rad), B: color.C * math.Sin(rad)} | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // shiftHue replaces color's OKLCh hue with targetDegrees while preserving | |||
| 80 | + | // its lightness and chroma, so the result stays in the same value/intensity | |||
| 81 | + | // band as the source. A near-neutral source (chroma close to zero) yields a | |||
| 82 | + | // near-neutral result regardless of the target: hue is not meaningful there. | |||
| 83 | + | func shiftHue(color renderer.RGB, targetDegrees float64) renderer.RGB { | |||
| 84 | + | lch := oklabToOKLCh(rgbToOKLab(color)) | |||
| 85 | + | lch.H = targetDegrees | |||
| 86 | + | return okLabToRGB(oklchToOKLab(lch)) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 55 | // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid | 89 | // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid | ||
| 56 | // round-trip quantization changing terminal-provided colors. | 90 | // round-trip quantization changing terminal-provided colors. | ||
| 57 | func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB { | 91 | func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB { | ||
M internal/usecase/theme/color_oklab_test.go +75 -0
| @@ -93,6 +93,81 @@ func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) { | |||||
| 93 | } | 93 | } | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | func TestOKLabOKLChRoundTrip(t *testing.T) { | |||
| 97 | + | colors := []oklab{ | |||
| 98 | + | rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}), | |||
| 99 | + | rgbToOKLab(renderer.RGB{R: 0xff}), | |||
| 100 | + | rgbToOKLab(renderer.RGB{B: 0xff}), | |||
| 101 | + | rgbToOKLab(renderer.RGB{R: 128, G: 128, B: 128}), | |||
| 102 | + | } | |||
| 103 | + | for _, want := range colors { | |||
| 104 | + | got := oklchToOKLab(oklabToOKLCh(want)) | |||
| 105 | + | if math.Abs(got.L-want.L) > 1e-9 || math.Abs(got.A-want.A) > 1e-9 || math.Abs(got.B-want.B) > 1e-9 { | |||
| 106 | + | t.Fatalf("oklch round trip %+v = %+v, want %+v", want, got, want) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestOKLabToOKLChHueIsDegreesInRange(t *testing.T) { | |||
| 112 | + | tests := []struct { | |||
| 113 | + | name string | |||
| 114 | + | rgb renderer.RGB | |||
| 115 | + | want float64 | |||
| 116 | + | }{ | |||
| 117 | + | // Known reference hues, computed with the same conversion this | |||
| 118 | + | // package uses: red sits near 29 degrees, blue near 264, amber | |||
| 119 | + | // (#FFBF00) near 84. | |||
| 120 | + | {name: "red", rgb: renderer.RGB{R: 255}, want: 29.2}, | |||
| 121 | + | {name: "amber #FFBF00", rgb: renderer.RGB{R: 0xff, G: 0xbf, B: 0x00}, want: 84.1}, | |||
| 122 | + | } | |||
| 123 | + | for _, tt := range tests { | |||
| 124 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 125 | + | got := oklabToOKLCh(rgbToOKLab(tt.rgb)) | |||
| 126 | + | if got.H < 0 || got.H >= 360 { | |||
| 127 | + | t.Fatalf("hue %v out of [0, 360) range", got.H) | |||
| 128 | + | } | |||
| 129 | + | if math.Abs(got.H-tt.want) > 0.5 { | |||
| 130 | + | t.Fatalf("hue = %v, want ~%v", got.H, tt.want) | |||
| 131 | + | } | |||
| 132 | + | }) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestShiftHuePreservesLightnessAndChromaButReplacesHue(t *testing.T) { | |||
| 137 | + | tests := []struct { | |||
| 138 | + | name string | |||
| 139 | + | source renderer.RGB | |||
| 140 | + | }{ | |||
| 141 | + | {name: "blue accent", source: renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, | |||
| 142 | + | {name: "red accent", source: renderer.RGB{R: 0xcc, G: 0x66, B: 0x66}}, | |||
| 143 | + | {name: "green accent", source: renderer.RGB{R: 0x6c, G: 0xae, B: 0x6c}}, | |||
| 144 | + | } | |||
| 145 | + | const target = warnHueDegrees | |||
| 146 | + | ||||
| 147 | + | for _, tt := range tests { | |||
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | + | sourceLCh := oklabToOKLCh(rgbToOKLab(tt.source)) | |||
| 150 | + | shifted := shiftHue(tt.source, target) | |||
| 151 | + | shiftedLCh := oklabToOKLCh(rgbToOKLab(shifted)) | |||
| 152 | + | ||||
| 153 | + | // sRGB gamut clamping in okLabToRGB can perturb L/C slightly for | |||
| 154 | + | // out-of-gamut combinations; allow a small tolerance. These three | |||
| 155 | + | // sources sit safely inside the gamut at the amber target hue | |||
| 156 | + | // (verified independently), so a tight tolerance still catches a | |||
| 157 | + | // broken implementation. | |||
| 158 | + | if math.Abs(shiftedLCh.L-sourceLCh.L) > 0.03 { | |||
| 159 | + | t.Fatalf("lightness drifted: got %v, want ~%v", shiftedLCh.L, sourceLCh.L) | |||
| 160 | + | } | |||
| 161 | + | if math.Abs(shiftedLCh.C-sourceLCh.C) > 0.03 { | |||
| 162 | + | t.Fatalf("chroma drifted: got %v, want ~%v", shiftedLCh.C, sourceLCh.C) | |||
| 163 | + | } | |||
| 164 | + | if math.Abs(shiftedLCh.H-target) > 1 { | |||
| 165 | + | t.Fatalf("hue = %v, want ~%v", shiftedLCh.H, target) | |||
| 166 | + | } | |||
| 167 | + | }) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 96 | func channelDelta(a, b uint8) uint8 { | 171 | func channelDelta(a, b uint8) uint8 { | ||
| 97 | if a > b { | 172 | if a > b { | ||
| 98 | return a - b | 173 | return a - b | ||
M internal/usecase/theme/ramp.go +28 -0
| @@ -5,6 +5,15 @@ import "github.com/bnema/vev/pkg/renderer" | |||||
| 5 | const ( | 5 | const ( | ||
| 6 | normalTextContrast = 4.5 | 6 | normalTextContrast = 4.5 | ||
| 7 | borderContrast = 3.0 | 7 | borderContrast = 3.0 | ||
| 8 | + | ||||
| 9 | + | // warnHueDegrees is the fixed OKLCh hue target for the Warn border role. | |||
| 10 | + | // Reference amber hues under this package's own rgbToOKLab: conventional | |||
| 11 | + | // "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at | |||
| 12 | + | // ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in | |||
| 13 | + | // that band, so a warn border reads as amber/gold regardless of which | |||
| 14 | + | // hue the user's single accent happens to occupy (blue ~256, red ~29, | |||
| 15 | + | // green ~145 under the same conversion). | |||
| 16 | + | warnHueDegrees = 75.0 | |||
| 8 | ) | 17 | ) | ||
| 9 | 18 | ||||
| 10 | // Ramp is the contrast-safe set of semantic terminal chrome colors. | 19 | // Ramp is the contrast-safe set of semantic terminal chrome colors. | ||
| @@ -15,6 +24,7 @@ type Ramp struct { | |||||
| 15 | SurfaceActive renderer.Style | 24 | SurfaceActive renderer.Style | ||
| 16 | BorderMuted renderer.Style | 25 | BorderMuted renderer.Style | ||
| 17 | BorderActive renderer.Style | 26 | BorderActive renderer.Style | ||
| 27 | + | BorderWarn renderer.Style | |||
| 18 | 28 | ||||
| 19 | background renderer.RGB | 29 | background renderer.RGB | ||
| 20 | accent renderer.RGB | 30 | accent renderer.RGB | ||
| @@ -52,6 +62,7 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||||
| 52 | SurfaceActive: active, | 62 | SurfaceActive: active, | ||
| 53 | BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), | 63 | BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), | ||
| 54 | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | 64 | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | ||
| 65 | + | BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB), | |||
| 55 | background: t.Background, | 66 | background: t.Background, | ||
| 56 | accent: accent.RGB, | 67 | accent: accent.RGB, | ||
| 57 | rgb: true, | 68 | rgb: true, | ||
| @@ -130,6 +141,22 @@ func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||||
| 130 | return neutralBorder(t) | 141 | return neutralBorder(t) | ||
| 131 | } | 142 | } | ||
| 132 | 143 | ||||
| 144 | + | // warnBorder searches the same accent-derived weight scale activeBorder | |||
| 145 | + | // uses, but replaces each candidate's hue with the fixed amber target before | |||
| 146 | + | // checking contrast: the result keeps the accent's lightness/chroma band | |||
| 147 | + | // (so it belongs to the ramp) while always reading as amber, regardless of | |||
| 148 | + | // the user's accent hue. | |||
| 149 | + | func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||
| 150 | + | for weight := 100; weight >= 0; weight-- { | |||
| 151 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |||
| 152 | + | amber := shiftHue(candidate, warnHueDegrees) | |||
| 153 | + | if ContrastRatio(amber, adjacent) >= borderContrast { | |||
| 154 | + | return foregroundStyle(amber) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | return neutralBorder(t) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 133 | func foregroundStyle(color renderer.RGB) renderer.Style { | 160 | func foregroundStyle(color renderer.RGB) renderer.Style { | ||
| 134 | style := renderer.DefaultStyle() | 161 | style := renderer.DefaultStyle() | ||
| 135 | style.HasForegroundRGB = true | 162 | style.HasForegroundRGB = true | ||
| @@ -146,6 +173,7 @@ func neutralRamp(t Theme) Ramp { | |||||
| 146 | SurfaceActive: styles.SurfaceActive, | 173 | SurfaceActive: styles.SurfaceActive, | ||
| 147 | BorderMuted: styles.BorderMuted, | 174 | BorderMuted: styles.BorderMuted, | ||
| 148 | BorderActive: styles.BorderActive, | 175 | BorderActive: styles.BorderActive, | ||
| 176 | + | BorderWarn: styles.BorderWarn, | |||
| 149 | } | 177 | } | ||
| 150 | } | 178 | } | ||
| 151 | 179 | ||||
M internal/usecase/theme/ramp_test.go +50 -0
| @@ -1,6 +1,7 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| @@ -74,6 +75,52 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) { | |||||
| 74 | require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB) | 75 | require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB) | ||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| 78 | + | func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) { | |||
| 79 | + | theme := rampTheme(false) | |||
| 80 | + | ||||
| 81 | + | for name, accentRGB := range map[string]renderer.RGB{ | |||
| 82 | + | "blue accent": {R: 0x6c, G: 0x9b, B: 0xd9}, | |||
| 83 | + | "red accent": {R: 0xcc, G: 0x66, B: 0x66}, | |||
| 84 | + | } { | |||
| 85 | + | t.Run(name, func(t *testing.T) { | |||
| 86 | + | accent := Accent{RGB: accentRGB, Known: true} | |||
| 87 | + | ramp := BuildRamp(theme, accent) | |||
| 88 | + | ||||
| 89 | + | require.True(t, ramp.BorderWarn.HasForegroundRGB) | |||
| 90 | + | require.False(t, ramp.BorderWarn.HasBackgroundRGB) | |||
| 91 | + | ||||
| 92 | + | accentHue := oklabToOKLCh(rgbToOKLab(accentRGB)).H | |||
| 93 | + | warnHue := oklabToOKLCh(rgbToOKLab(ramp.BorderWarn.ForegroundRGB)).H | |||
| 94 | + | distanceToTarget := func(h float64) float64 { | |||
| 95 | + | d := math.Abs(h - warnHueDegrees) | |||
| 96 | + | if d > 180 { | |||
| 97 | + | d = 360 - d | |||
| 98 | + | } | |||
| 99 | + | return d | |||
| 100 | + | } | |||
| 101 | + | // Warn must land measurably closer to the amber target than the | |||
| 102 | + | // accent itself sits, in both directions (blue and the | |||
| 103 | + | // adversarial case of a red accent, which sits close to zero | |||
| 104 | + | // degrees and could otherwise get confused with an | |||
| 105 | + | // under-rotated "warm" accent border). | |||
| 106 | + | require.Less(t, distanceToTarget(warnHue), distanceToTarget(accentHue)) | |||
| 107 | + | require.Less(t, distanceToTarget(warnHue), 5.0) | |||
| 108 | + | ||||
| 109 | + | // Distinct from the sibling borders by more than a rounding | |||
| 110 | + | // difference: use the same OKLab distance scale accent | |||
| 111 | + | // clustering already treats as "different colors" | |||
| 112 | + | // (accentClusterDistance groups colors within 0.04). | |||
| 113 | + | warnLab := rgbToOKLab(ramp.BorderWarn.ForegroundRGB) | |||
| 114 | + | mutedLab := rgbToOKLab(ramp.BorderMuted.ForegroundRGB) | |||
| 115 | + | activeLab := rgbToOKLab(ramp.BorderActive.ForegroundRGB) | |||
| 116 | + | require.Greater(t, okLabDistance(warnLab, mutedLab), accentClusterDistance) | |||
| 117 | + | require.Greater(t, okLabDistance(warnLab, activeLab), accentClusterDistance) | |||
| 118 | + | ||||
| 119 | + | require.GreaterOrEqual(t, ContrastRatio(ramp.BorderWarn.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast) | |||
| 120 | + | }) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 77 | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | 124 | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | ||
| 78 | theme := rampTheme(false) | 125 | theme := rampTheme(false) | ||
| 79 | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | 126 | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | ||
| @@ -94,6 +141,7 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||||
| 94 | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | 141 | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | ||
| 95 | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | 142 | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | ||
| 96 | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | 143 | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | ||
| 144 | + | require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn) | |||
| 97 | require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder) | 145 | require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder) | ||
| 98 | require.True(t, resolved.Styles.TabActive.Bold) | 146 | require.True(t, resolved.Styles.TabActive.Bold) | ||
| 99 | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | 147 | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | ||
| @@ -112,6 +160,8 @@ func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) { | |||||
| 112 | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | 160 | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | ||
| 113 | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | 161 | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | ||
| 114 | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | 162 | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | ||
| 163 | + | require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground) | |||
| 164 | + | require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB) | |||
| 115 | 165 | ||||
| 116 | theme.TrueColor = true | 166 | theme.TrueColor = true | ||
| 117 | theme.UsePalette = false | 167 | theme.UsePalette = false | ||
M internal/usecase/theme/resolved_styles.go +3 -0
| @@ -45,6 +45,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||||
| 45 | SurfaceActive: ramp.SurfaceActive, | 45 | SurfaceActive: ramp.SurfaceActive, | ||
| 46 | BorderMuted: ramp.BorderMuted, | 46 | BorderMuted: ramp.BorderMuted, | ||
| 47 | BorderActive: ramp.BorderActive, | 47 | BorderActive: ramp.BorderActive, | ||
| 48 | + | BorderWarn: ramp.BorderWarn, | |||
| 48 | NeutralBorder: neutralBorder(t), | 49 | NeutralBorder: neutralBorder(t), | ||
| 49 | 50 | ||||
| 50 | TabInactive: ramp.SurfaceInactive, | 51 | TabInactive: ramp.SurfaceInactive, | ||
| @@ -81,6 +82,7 @@ func secondarySurface(base renderer.Style) renderer.Style { | |||||
| 81 | func indexedStyles(styles Styles, slot uint8) Styles { | 82 | func indexedStyles(styles Styles, slot uint8) Styles { | ||
| 82 | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | 83 | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | ||
| 83 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | 84 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | ||
| 85 | + | styles.BorderWarn = indexedForeground(styles.BorderWarn, slot) | |||
| 84 | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | 86 | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | ||
| 85 | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | 87 | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | ||
| 86 | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | 88 | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | ||
| @@ -131,6 +133,7 @@ func neutralStyles(t Theme) Styles { | |||||
| 131 | SurfaceActive: accent, | 133 | SurfaceActive: accent, | ||
| 132 | BorderMuted: border, | 134 | BorderMuted: border, | ||
| 133 | BorderActive: border, | 135 | BorderActive: border, | ||
| 136 | + | BorderWarn: border, | |||
| 134 | NeutralBorder: border, | 137 | NeutralBorder: border, | ||
| 135 | 138 | ||||
| 136 | TabInactive: EmphasisStyle(status, t), | 139 | TabInactive: EmphasisStyle(status, t), | ||
M internal/usecase/theme/styles.go +6 -0
| @@ -72,6 +72,12 @@ type Styles struct { | |||||
| 72 | SurfaceActive renderer.Style | 72 | SurfaceActive renderer.Style | ||
| 73 | BorderMuted renderer.Style | 73 | BorderMuted renderer.Style | ||
| 74 | BorderActive renderer.Style | 74 | BorderActive renderer.Style | ||
| 75 | + | // BorderWarn is the amber-hued warn-severity border. Unlike every other | |||
| 76 | + | // role in this camaïeu palette it does not blend toward the resolved | |||
| 77 | + | // accent color: it keeps the accent's lightness/chroma band but shifts | |||
| 78 | + | // hue to a fixed amber target (see warnHueDegrees), so Warn reads as | |||
| 79 | + | // amber and stays distinct from Error regardless of the accent hue. | |||
| 80 | + | BorderWarn renderer.Style | |||
| 75 | // NeutralBorder is the non-accent structural border for pane dividers and | 81 | // NeutralBorder is the non-accent structural border for pane dividers and | ||
| 76 | // unfocused pane title bars. It is resolved with the rest of the immutable | 82 | // unfocused pane title bars. It is resolved with the rest of the immutable | ||
| 77 | // chrome snapshot so composition never derives theme colors. | 83 | // chrome snapshot so composition never derives theme colors. | ||
A internal/usecase/ui/notice.go +164 -0
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | noticeMargin = 1 | |||
| 13 | + | noticeMaxWidth = 60 | |||
| 14 | + | noticeMinWidth = 24 | |||
| 15 | + | noticeMaxLines = 3 | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // NoticeView is a plain rendering view of one notification. | |||
| 19 | + | type NoticeView struct { | |||
| 20 | + | Severity domain.NoticeSeverity | |||
| 21 | + | Title string | |||
| 22 | + | Message string | |||
| 23 | + | Count int | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // NoticeStyles selects severity-colored styles for each box's border and title. | |||
| 27 | + | // Text styles the wrapped message body and overflow indicator; it excludes the | |||
| 28 | + | // title, which is drawn with the box's severity style. | |||
| 29 | + | type NoticeStyles struct { | |||
| 30 | + | Text renderer.Style | |||
| 31 | + | BoxInfo renderer.Style | |||
| 32 | + | BoxWarn renderer.Style | |||
| 33 | + | BoxError renderer.Style | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // ComposeNotices stacks notice boxes top-right of frame, newest first. Each | |||
| 37 | + | // box's width starts at frame.Width*2/5, is capped at noticeMaxWidth (60), | |||
| 38 | + | // then floored at noticeMinWidth (24), then clamped down to frame.Width so it | |||
| 39 | + | // never exceeds the frame — on frames narrower than the floor, that final | |||
| 40 | + | // clamp overrides the floor. Each box is titled with the notice's slug and an | |||
| 41 | + | // optional " ×N" count suffix, and its message is word-wrapped to at most 3 | |||
| 42 | + | // lines. Boxes stack downward with one blank row between them; once the | |||
| 43 | + | // count behind overflow is nonzero, a right-aligned "+N more" line follows | |||
| 44 | + | // the last box. | |||
| 45 | + | func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) []domain.Rect { | |||
| 46 | + | if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) { | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | footprints := make([]domain.Rect, 0, len(notices)+1) | |||
| 51 | + | ||||
| 52 | + | width := frame.Width * 2 / 5 | |||
| 53 | + | if width > noticeMaxWidth { | |||
| 54 | + | width = noticeMaxWidth | |||
| 55 | + | } | |||
| 56 | + | if width < noticeMinWidth { | |||
| 57 | + | width = noticeMinWidth | |||
| 58 | + | } | |||
| 59 | + | if width > frame.Width { | |||
| 60 | + | width = frame.Width | |||
| 61 | + | } | |||
| 62 | + | if width <= 0 { | |||
| 63 | + | return nil | |||
| 64 | + | } | |||
| 65 | + | x := frame.Width - noticeMargin - width | |||
| 66 | + | y := noticeMargin | |||
| 67 | + | ||||
| 68 | + | for _, n := range notices { | |||
| 69 | + | if y >= frame.Height { | |||
| 70 | + | break | |||
| 71 | + | } | |||
| 72 | + | innerWidth := width - 2 | |||
| 73 | + | lines := wrapNoticeText(n.Message, innerWidth, noticeMaxLines) | |||
| 74 | + | if len(lines) == 0 { | |||
| 75 | + | lines = []string{""} | |||
| 76 | + | } | |||
| 77 | + | height := len(lines) + 2 | |||
| 78 | + | bounds := domain.Rect{X: x, Y: y, Width: width, Height: height} | |||
| 79 | + | footprints = append(footprints, bounds) | |||
| 80 | + | box := noticeBoxStyle(styles, n.Severity) | |||
| 81 | + | DrawBox(frame, bounds, box) | |||
| 82 | + | ||||
| 83 | + | title := n.Title | |||
| 84 | + | if n.Count > 1 { | |||
| 85 | + | title = fmt.Sprintf("%s ×%d", n.Title, n.Count) | |||
| 86 | + | } | |||
| 87 | + | if title != "" && bounds.Width > 2 { | |||
| 88 | + | left := bounds.X + 1 | |||
| 89 | + | right := bounds.X + bounds.Width - 1 | |||
| 90 | + | start := max(left, bounds.X+(bounds.Width-textWidth(title))/2) | |||
| 91 | + | DrawText(frame, start, bounds.Y, right, title, box) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)} | |||
| 95 | + | if inner.Width > 0 && inner.Height > 0 { | |||
| 96 | + | fill := renderer.BlankCell() | |||
| 97 | + | fill.Style = styles.Text | |||
| 98 | + | FillRect(frame, inner, fill) | |||
| 99 | + | for i, line := range lines { | |||
| 100 | + | if i >= inner.Height { | |||
| 101 | + | break | |||
| 102 | + | } | |||
| 103 | + | DrawText(frame, inner.X, inner.Y+i, inner.X+inner.Width, line, styles.Text) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | y += height + 1 | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | if overflow > 0 && y < frame.Height { | |||
| 111 | + | text := fmt.Sprintf("+%d more", overflow) | |||
| 112 | + | start := x + width - textWidth(text) | |||
| 113 | + | if start < x { | |||
| 114 | + | start = x | |||
| 115 | + | } | |||
| 116 | + | DrawText(frame, start, y, x+width, text, styles.Text) | |||
| 117 | + | footprints = append(footprints, domain.Rect{X: start, Y: y, Width: x + width - start, Height: 1}) | |||
| 118 | + | } | |||
| 119 | + | return footprints | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func noticeBoxStyle(styles NoticeStyles, severity domain.NoticeSeverity) renderer.Style { | |||
| 123 | + | switch severity { | |||
| 124 | + | case domain.NoticeWarn: | |||
| 125 | + | return styles.BoxWarn | |||
| 126 | + | case domain.NoticeError: | |||
| 127 | + | return styles.BoxError | |||
| 128 | + | default: | |||
| 129 | + | return styles.BoxInfo | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // wrapNoticeText greedily fills lines up to width, then caps at maxLines by | |||
| 134 | + | // collapsing any remainder into the last line and truncating it, so an | |||
| 135 | + | // overlong message is clipped visibly rather than silently dropped mid-word. | |||
| 136 | + | func wrapNoticeText(message string, width, maxLines int) []string { | |||
| 137 | + | if width <= 0 || maxLines <= 0 { | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | words := strings.Fields(message) | |||
| 141 | + | if len(words) == 0 { | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | lines := make([]string, 0, maxLines) | |||
| 146 | + | current := words[0] | |||
| 147 | + | for _, w := range words[1:] { | |||
| 148 | + | candidate := current + " " + w | |||
| 149 | + | if textWidth(candidate) <= width { | |||
| 150 | + | current = candidate | |||
| 151 | + | continue | |||
| 152 | + | } | |||
| 153 | + | lines = append(lines, current) | |||
| 154 | + | current = w | |||
| 155 | + | } | |||
| 156 | + | lines = append(lines, current) | |||
| 157 | + | ||||
| 158 | + | if len(lines) <= maxLines { | |||
| 159 | + | return lines | |||
| 160 | + | } | |||
| 161 | + | rest := strings.Join(lines[maxLines-1:], " ") | |||
| 162 | + | lines = lines[:maxLines-1] | |||
| 163 | + | return append(lines, TruncateText(rest, width)) | |||
| 164 | + | } | |||
A internal/usecase/ui/notice_test.go +280 -0
| @@ -0,0 +1,280 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // frameText joins every row of frame into one newline-separated string so | |||
| 13 | + | // tests can search for substrings without caring which cells they land on. | |||
| 14 | + | func frameText(f renderer.Frame) string { | |||
| 15 | + | rows := make([]string, f.Height) | |||
| 16 | + | for y := 0; y < f.Height; y++ { | |||
| 17 | + | var b strings.Builder | |||
| 18 | + | for x := 0; x < f.Width; x++ { | |||
| 19 | + | cell := f.At(x, y) | |||
| 20 | + | if cell.Continuation { | |||
| 21 | + | continue | |||
| 22 | + | } | |||
| 23 | + | if cell.Rune == 0 { | |||
| 24 | + | b.WriteByte(' ') | |||
| 25 | + | continue | |||
| 26 | + | } | |||
| 27 | + | b.WriteRune(cell.Rune) | |||
| 28 | + | } | |||
| 29 | + | rows[y] = b.String() | |||
| 30 | + | } | |||
| 31 | + | return strings.Join(rows, "\n") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // firstRowContaining returns the index of the first row in f whose rendered | |||
| 35 | + | // text contains s, or -1 if no row does. | |||
| 36 | + | func firstRowContaining(f renderer.Frame, s string) int { | |||
| 37 | + | for y, row := range strings.Split(frameText(f), "\n") { | |||
| 38 | + | if strings.Contains(row, s) { | |||
| 39 | + | return y | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | return -1 | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestComposeNotices(t *testing.T) { | |||
| 46 | + | styles := NoticeStyles{ | |||
| 47 | + | Text: renderer.Style{Foreground: 7, Background: -1}, | |||
| 48 | + | BoxInfo: renderer.Style{Foreground: 4, Background: -1}, | |||
| 49 | + | BoxWarn: renderer.Style{Foreground: 3, Background: -1}, | |||
| 50 | + | BoxError: renderer.Style{Foreground: 1, Background: -1}, | |||
| 51 | + | } | |||
| 52 | + | isBorderRune := func(r rune) bool { | |||
| 53 | + | switch r { | |||
| 54 | + | case '┌', '┐', '└', '┘', '─', '│': | |||
| 55 | + | return true | |||
| 56 | + | default: | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | tests := []struct { | |||
| 62 | + | name string | |||
| 63 | + | notices []NoticeView | |||
| 64 | + | overflow int | |||
| 65 | + | check func(t *testing.T, f renderer.Frame) | |||
| 66 | + | }{ | |||
| 67 | + | { | |||
| 68 | + | name: "single error notice draws a bordered box top-right with title and message", | |||
| 69 | + | notices: []NoticeView{ | |||
| 70 | + | {Severity: domain.NoticeError, Title: "pane-spawn", Message: "couldn't open pane", Count: 3}, | |||
| 71 | + | }, | |||
| 72 | + | check: func(t *testing.T, f renderer.Frame) { | |||
| 73 | + | found := false | |||
| 74 | + | for y := 0; y <= 8 && !found; y++ { | |||
| 75 | + | for x := 40; x < f.Width; x++ { | |||
| 76 | + | if isBorderRune(f.At(x, y).Rune) { | |||
| 77 | + | found = true | |||
| 78 | + | break | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | if !found { | |||
| 83 | + | t.Fatalf("no box border found in top-right quadrant (x>=40, y<=8):\n%s", frameText(f)) | |||
| 84 | + | } | |||
| 85 | + | text := frameText(f) | |||
| 86 | + | if !strings.Contains(text, "pane-spawn ×3") { | |||
| 87 | + | t.Fatalf("frame missing title %q:\n%s", "pane-spawn ×3", text) | |||
| 88 | + | } | |||
| 89 | + | if !strings.Contains(text, "couldn't open pane") { | |||
| 90 | + | t.Fatalf("frame missing message %q:\n%s", "couldn't open pane", text) | |||
| 91 | + | } | |||
| 92 | + | }, | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "three notices with overflow renders a +N more line", | |||
| 96 | + | notices: []NoticeView{ | |||
| 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 | + | }, | |||
| 101 | + | overflow: 2, | |||
| 102 | + | check: func(t *testing.T, f renderer.Frame) { | |||
| 103 | + | text := frameText(f) | |||
| 104 | + | if !strings.Contains(text, "+2 more") { | |||
| 105 | + | t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text) | |||
| 106 | + | } | |||
| 107 | + | rowOne := firstRowContaining(f, "one") | |||
| 108 | + | rowTwo := firstRowContaining(f, "two") | |||
| 109 | + | rowThree := firstRowContaining(f, "three") | |||
| 110 | + | if rowOne < 0 || rowTwo < 0 || rowThree < 0 { | |||
| 111 | + | t.Fatalf("could not locate all three notice messages in frame: one=%d two=%d three=%d\n%s", rowOne, rowTwo, rowThree, text) | |||
| 112 | + | } | |||
| 113 | + | if rowOne >= rowTwo || rowTwo >= rowThree { | |||
| 114 | + | t.Fatalf("notices not stacked newest-first top-down: one=%d two=%d three=%d\n%s", rowOne, rowTwo, rowThree, text) | |||
| 115 | + | } | |||
| 116 | + | }, | |||
| 117 | + | }, | |||
| 118 | + | { | |||
| 119 | + | name: "no notices and no overflow draws nothing", | |||
| 120 | + | notices: nil, | |||
| 121 | + | overflow: 0, | |||
| 122 | + | check: func(t *testing.T, f renderer.Frame) { | |||
| 123 | + | want := renderer.NewFrame(f.Width, f.Height) | |||
| 124 | + | if frameText(f) != frameText(want) { | |||
| 125 | + | t.Fatalf("frame changed with no notices and no overflow:\n%s", frameText(f)) | |||
| 126 | + | } | |||
| 127 | + | }, | |||
| 128 | + | }, | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | for _, tt := range tests { | |||
| 132 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 133 | + | f := renderer.NewFrame(80, 24) | |||
| 134 | + | ComposeNotices(f, tt.notices, tt.overflow, styles) | |||
| 135 | + | tt.check(t, f) | |||
| 136 | + | }) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) { | |||
| 141 | + | styles := NoticeStyles{ | |||
| 142 | + | Text: renderer.Style{Foreground: 7, Background: -1}, | |||
| 143 | + | BoxInfo: renderer.Style{Foreground: 4, Background: -1}, | |||
| 144 | + | BoxWarn: renderer.Style{Foreground: 3, Background: -1}, | |||
| 145 | + | BoxError: renderer.Style{Foreground: 1, Background: -1}, | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | tests := []struct { | |||
| 149 | + | name string | |||
| 150 | + | sev domain.NoticeSeverity | |||
| 151 | + | want renderer.Style | |||
| 152 | + | }{ | |||
| 153 | + | {"info", domain.NoticeInfo, styles.BoxInfo}, | |||
| 154 | + | {"warn", domain.NoticeWarn, styles.BoxWarn}, | |||
| 155 | + | {"error", domain.NoticeError, styles.BoxError}, | |||
| 156 | + | } | |||
| 157 | + | for _, tt := range tests { | |||
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | + | f := renderer.NewFrame(80, 24) | |||
| 160 | + | ComposeNotices(f, []NoticeView{{Severity: tt.sev, Title: "x", Message: "y"}}, 0, styles) | |||
| 161 | + | corner := findRune(f, '┌') | |||
| 162 | + | if corner == nil { | |||
| 163 | + | t.Fatalf("no box top-left corner found:\n%s", frameText(f)) | |||
| 164 | + | } | |||
| 165 | + | if !corner.Style.Equal(tt.want) { | |||
| 166 | + | t.Fatalf("box border style = %+v, want %s style %+v", corner.Style, tt.name, tt.want) | |||
| 167 | + | } | |||
| 168 | + | }) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // findRune returns the first cell in f carrying r, or nil if none does. | |||
| 173 | + | func findRune(f renderer.Frame, r rune) *renderer.Cell { | |||
| 174 | + | for y := 0; y < f.Height; y++ { | |||
| 175 | + | for x := 0; x < f.Width; x++ { | |||
| 176 | + | if cell := f.At(x, y); cell.Rune == r { | |||
| 177 | + | return &cell | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // findRunePos returns the coordinates of the first cell in f carrying r, or | |||
| 185 | + | // (-1, -1) if no cell does. | |||
| 186 | + | func findRunePos(f renderer.Frame, r rune) (int, int) { | |||
| 187 | + | for y := 0; y < f.Height; y++ { | |||
| 188 | + | for x := 0; x < f.Width; x++ { | |||
| 189 | + | if f.At(x, y).Rune == r { | |||
| 190 | + | return x, y | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | return -1, -1 | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // TestComposeNoticesWidth pins the box's actual width and left edge across | |||
| 198 | + | // representative terminal widths, so the test fails if the *2/5 ratio, the | |||
| 199 | + | // 60-column cap, the 24-column floor, or the final frame-width clamp changes. | |||
| 200 | + | // Each expected x and width below is worked out by hand from the formula | |||
| 201 | + | // documented on ComposeNotices, not recomputed from the implementation. | |||
| 202 | + | func TestComposeNoticesWidth(t *testing.T) { | |||
| 203 | + | styles := NoticeStyles{ | |||
| 204 | + | Text: renderer.Style{Foreground: 7, Background: -1}, | |||
| 205 | + | BoxInfo: renderer.Style{Foreground: 4, Background: -1}, | |||
| 206 | + | BoxWarn: renderer.Style{Foreground: 3, Background: -1}, | |||
| 207 | + | BoxError: renderer.Style{Foreground: 1, Background: -1}, | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | tests := []struct { | |||
| 211 | + | name string | |||
| 212 | + | frameWidth int | |||
| 213 | + | wantWidth int | |||
| 214 | + | wantX int // -1 means the box's left edge falls off-frame (x < 0) | |||
| 215 | + | }{ | |||
| 216 | + | // 200 cols: 200*2/5=80, capped at 60. x = 200 - 1(margin) - 60 = 139. | |||
| 217 | + | {name: "60-column cap binds on a wide terminal", frameWidth: 200, wantWidth: 60, wantX: 139}, | |||
| 218 | + | // 100 cols: 100*2/5=40, under the cap and floor. x = 100 - 1 - 40 = 59. | |||
| 219 | + | {name: "cols*2/5 ratio binds on a mid terminal", frameWidth: 100, wantWidth: 40, wantX: 59}, | |||
| 220 | + | // 40 cols: 40*2/5=16, floored to 24. x = 40 - 1 - 24 = 15. | |||
| 221 | + | {name: "24-column floor binds on a narrow terminal", frameWidth: 40, wantWidth: 24, wantX: 15}, | |||
| 222 | + | // 20 cols: 20*2/5=8, floored to 24, then clamped down to frame.Width | |||
| 223 | + | // (20) since 24 > 20. x = 20 - 1(margin) - 20 = -1, so the left edge | |||
| 224 | + | // (and its corner rune) fall one column off-frame. | |||
| 225 | + | {name: "frame-width clamp overrides the floor on a tiny terminal", frameWidth: 20, wantWidth: 20, wantX: -1}, | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | for _, tt := range tests { | |||
| 229 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 230 | + | f := renderer.NewFrame(tt.frameWidth, 10) | |||
| 231 | + | ComposeNotices(f, []NoticeView{{Severity: domain.NoticeInfo, Title: "x", Message: "y"}}, 0, styles) | |||
| 232 | + | ||||
| 233 | + | wantRight := tt.wantX + tt.wantWidth - 1 | |||
| 234 | + | rightX, rightY := findRunePos(f, '┐') | |||
| 235 | + | if rightX < 0 { | |||
| 236 | + | t.Fatalf("no top-right corner found:\n%s", frameText(f)) | |||
| 237 | + | } | |||
| 238 | + | if rightY != noticeMargin || rightX != wantRight { | |||
| 239 | + | t.Fatalf("top-right corner at (%d,%d), want (%d,%d):\n%s", rightX, rightY, wantRight, noticeMargin, frameText(f)) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // The message body starts one column right of the box's left | |||
| 243 | + | // edge (wantX+1) and one row below its top edge. Pinning this | |||
| 244 | + | // rune's exact column catches width/x regressions that the | |||
| 245 | + | // top-right corner can't: right = x+width-1 = frame.Width-margin-1 | |||
| 246 | + | // no matter what width is, so the right corner alone can't tell | |||
| 247 | + | // a correctly clamped box from one whose x drifted further | |||
| 248 | + | // off-frame (both still land off-screen). | |||
| 249 | + | msgX, msgY := tt.wantX+1, noticeMargin+1 | |||
| 250 | + | if got := f.At(msgX, msgY).Rune; got != 'y' { | |||
| 251 | + | t.Fatalf("message rune at (%d,%d) = %q, want 'y':\n%s", msgX, msgY, got, frameText(f)) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | leftX, leftY := findRunePos(f, '┌') | |||
| 255 | + | if tt.wantX < 0 { | |||
| 256 | + | if leftX >= 0 { | |||
| 257 | + | t.Fatalf("expected left corner to be clipped off-frame, found one at (%d,%d):\n%s", leftX, leftY, frameText(f)) | |||
| 258 | + | } | |||
| 259 | + | return | |||
| 260 | + | } | |||
| 261 | + | if leftX != tt.wantX || leftY != noticeMargin { | |||
| 262 | + | t.Fatalf("top-left corner at (%d,%d), want (%d,%d):\n%s", leftX, leftY, tt.wantX, noticeMargin, frameText(f)) | |||
| 263 | + | } | |||
| 264 | + | if gotWidth := rightX - leftX + 1; gotWidth != tt.wantWidth { | |||
| 265 | + | t.Fatalf("box width = %d, want %d:\n%s", gotWidth, tt.wantWidth, frameText(f)) | |||
| 266 | + | } | |||
| 267 | + | }) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func TestComposeNoticesHandlesTinyFrames(t *testing.T) { | |||
| 272 | + | styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()} | |||
| 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) { | |||
| 275 | + | f := renderer.NewFrame(size.Cols, size.Rows) | |||
| 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) | |||
| 278 | + | }) | |||
| 279 | + | } | |||
| 280 | + | } | |||