fix(notices): preserve claim ownership
- commit
- d558968f84f1d3b6124d72a46290b64d09133682
- author
- brice · · 2026-07-21 13:19 UTC
- parent
- ed50af9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +176 -68
M internal/adapters/noticefile/noticefile.go +95 -9
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "io" | |
| 12 | 12 | "os" | |
| 13 | 13 | "path/filepath" | |
| 14 | + | "sync" | |
| 14 | 15 | "syscall" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/vev/internal/domain" | |
| @@ -22,6 +23,7 @@ const ( | |||
| 22 | 23 | fileName = "pending-notices.jsonl" | |
| 23 | 24 | inFlightFileName = "pending-notices.inflight.jsonl" | |
| 24 | 25 | lockFileName = "pending-notices.lock" | |
| 26 | + | claimLockFileName = "pending-notices.claim.lock" | |
| 25 | 27 | maxNoticeRecordSize = 4 << 20 | |
| 26 | 28 | noticeReadBufferSize = 64 << 10 | |
| 27 | 29 | ) | |
| @@ -30,12 +32,25 @@ const ( | |||
| 30 | 32 | type Store struct { | |
| 31 | 33 | dir string | |
| 32 | 34 | ||
| 33 | - | // afterClaimRead is used by package tests to pause a claim after its reader | |
| 34 | - | // reached EOF while it still owns the interprocess lock. It is nil in | |
| 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 | |
| 35 | 40 | // production. | |
| 36 | - | afterClaimRead func() | |
| 41 | + | afterAppendOpen func() | |
| 37 | 42 | } | |
| 38 | 43 | ||
| 44 | + | var ( | |
| 45 | + | // ErrClaimInProgress means another live Store owns the in-flight claim. | |
| 46 | + | // Callers must leave it for that owner rather than replaying or acknowledging | |
| 47 | + | // its notices. | |
| 48 | + | ErrClaimInProgress = errors.New("pending notice claim in progress") | |
| 49 | + | // ErrNoClaimOwner means Ack was called by a Store that did not make the | |
| 50 | + | // current claim. | |
| 51 | + | ErrNoClaimOwner = errors.New("pending notice claim is not owned by this store") | |
| 52 | + | ) | |
| 53 | + | ||
| 39 | 54 | var _ ports.NoticeStore = (*Store)(nil) | |
| 40 | 55 | ||
| 41 | 56 | // New creates a file-backed notice store rooted at dir. | |
| @@ -55,6 +70,10 @@ func (s *Store) lockPath() string { | |||
| 55 | 70 | return filepath.Join(s.dir, lockFileName) | |
| 56 | 71 | } | |
| 57 | 72 | ||
| 73 | + | func (s *Store) claimLockPath() string { | |
| 74 | + | return filepath.Join(s.dir, claimLockFileName) | |
| 75 | + | } | |
| 76 | + | ||
| 58 | 77 | // withLock serializes Append, Claim, and Ack across Store instances and | |
| 59 | 78 | // processes. Holding this lock while Claim rotates and reads the file prevents | |
| 60 | 79 | // an appender with an already-open file descriptor from writing into a claim | |
| @@ -78,6 +97,52 @@ func (s *Store) withLock(fn func() error) error { | |||
| 78 | 97 | return fn() | |
| 79 | 98 | } | |
| 80 | 99 | ||
| 100 | + | // acquireClaimLock reserves the current in-flight file for this Store until | |
| 101 | + | // Ack. The flock is released by the kernel if this process exits, allowing a | |
| 102 | + | // later daemon to recover an abandoned claim. | |
| 103 | + | func (s *Store) acquireClaimLock() error { | |
| 104 | + | s.claimMu.Lock() | |
| 105 | + | defer s.claimMu.Unlock() | |
| 106 | + | if s.claimLock != nil { | |
| 107 | + | return ErrClaimInProgress | |
| 108 | + | } | |
| 109 | + | f, err := os.OpenFile(s.claimLockPath(), os.O_CREATE|os.O_RDWR, 0o600) | |
| 110 | + | if err != nil { | |
| 111 | + | return fmt.Errorf("open notice claim lock: %w", err) | |
| 112 | + | } | |
| 113 | + | if err := f.Chmod(0o600); err != nil { | |
| 114 | + | _ = f.Close() | |
| 115 | + | return fmt.Errorf("secure notice claim lock: %w", err) | |
| 116 | + | } | |
| 117 | + | if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |
| 118 | + | _ = f.Close() | |
| 119 | + | if errors.Is(err, syscall.EWOULDBLOCK) { | |
| 120 | + | return ErrClaimInProgress | |
| 121 | + | } | |
| 122 | + | return fmt.Errorf("lock pending notice claim: %w", err) | |
| 123 | + | } | |
| 124 | + | s.claimLock = f | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (s *Store) releaseClaimLock() { | |
| 129 | + | s.claimMu.Lock() | |
| 130 | + | f := s.claimLock | |
| 131 | + | s.claimLock = nil | |
| 132 | + | s.claimMu.Unlock() | |
| 133 | + | if f == nil { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | |
| 137 | + | _ = f.Close() | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (s *Store) ownsClaim() bool { | |
| 141 | + | s.claimMu.Lock() | |
| 142 | + | defer s.claimMu.Unlock() | |
| 143 | + | return s.claimLock != nil | |
| 144 | + | } | |
| 145 | + | ||
| 81 | 146 | func syncDir(dir string) error { | |
| 82 | 147 | f, err := os.Open(dir) | |
| 83 | 148 | if err != nil { | |
| @@ -105,6 +170,9 @@ func (s *Store) Append(n domain.Notification) error { | |||
| 105 | 170 | _ = f.Close() | |
| 106 | 171 | return fmt.Errorf("secure pending notices: %w", err) | |
| 107 | 172 | } | |
| 173 | + | if s.afterAppendOpen != nil { | |
| 174 | + | s.afterAppendOpen() | |
| 175 | + | } | |
| 108 | 176 | if _, err := f.Write(data); err != nil { | |
| 109 | 177 | _ = f.Close() | |
| 110 | 178 | return fmt.Errorf("write notice: %w", err) | |
| @@ -124,12 +192,23 @@ func (s *Store) Append(n domain.Notification) error { | |||
| 124 | 192 | } | |
| 125 | 193 | ||
| 126 | 194 | // Claim atomically moves pending notices into an in-flight file and returns | |
| 127 | - | // their valid JSONL entries in order. The in-flight file is intentionally kept | |
| 128 | - | // until Ack, so an unacknowledged import is replayed after a crash. A missing | |
| 129 | - | // pending file means there is nothing to claim. | |
| 195 | + | // their valid JSONL entries in order. Its Store owns the claim until Ack, so a | |
| 196 | + | // concurrent Store cannot replay or acknowledge it. The ownership flock is | |
| 197 | + | // released on process exit, allowing an unacknowledged import to be replayed | |
| 198 | + | // after a crash. A missing pending file means there is nothing to claim. | |
| 130 | 199 | func (s *Store) Claim() ([]domain.Notification, error) { | |
| 131 | 200 | var notices []domain.Notification | |
| 132 | 201 | err := s.withLock(func() error { | |
| 202 | + | if err := s.acquireClaimLock(); err != nil { | |
| 203 | + | return err | |
| 204 | + | } | |
| 205 | + | keepClaim := false | |
| 206 | + | defer func() { | |
| 207 | + | if !keepClaim { | |
| 208 | + | s.releaseClaimLock() | |
| 209 | + | } | |
| 210 | + | }() | |
| 211 | + | ||
| 133 | 212 | inFlight := s.inFlightPath() | |
| 134 | 213 | if _, err := os.Stat(inFlight); err != nil { | |
| 135 | 214 | if !errors.Is(err, os.ErrNotExist) { | |
| @@ -150,8 +229,8 @@ func (s *Store) Claim() ([]domain.Notification, error) { | |||
| 150 | 229 | ||
| 151 | 230 | var err error | |
| 152 | 231 | notices, err = readNotices(inFlight) | |
| 153 | - | if err == nil && s.afterClaimRead != nil { | |
| 154 | - | s.afterClaimRead() | |
| 232 | + | if err == nil { | |
| 233 | + | keepClaim = true | |
| 155 | 234 | } | |
| 156 | 235 | return err | |
| 157 | 236 | }) | |
| @@ -233,16 +312,23 @@ func appendNoticeFragment(line, fragment []byte) ([]byte, bool) { | |||
| 233 | 312 | return append(line, fragment...), false | |
| 234 | 313 | } | |
| 235 | 314 | ||
| 236 | - | // Ack permanently removes the current in-flight claim. It is only called once | |
| 315 | + | // Ack permanently removes this Store's in-flight claim. It is only called once | |
| 237 | 316 | // the daemon has recorded and queued every claimed notice. | |
| 238 | 317 | func (s *Store) Ack() error { | |
| 318 | + | if !s.ownsClaim() { | |
| 319 | + | return ErrNoClaimOwner | |
| 320 | + | } | |
| 239 | 321 | return s.withLock(func() error { | |
| 322 | + | if !s.ownsClaim() { | |
| 323 | + | return ErrNoClaimOwner | |
| 324 | + | } | |
| 240 | 325 | if err := os.Remove(s.inFlightPath()); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 241 | 326 | return fmt.Errorf("ack claimed notices: %w", err) | |
| 242 | 327 | } | |
| 243 | 328 | if err := syncDir(s.dir); err != nil { | |
| 244 | 329 | return fmt.Errorf("sync acknowledged notices: %w", err) | |
| 245 | 330 | } | |
| 331 | + | s.releaseClaimLock() | |
| 246 | 332 | return nil | |
| 247 | 333 | }) | |
| 248 | 334 | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "io" | 11 | "io" | ||
| 12 | "os" | 12 | "os" | ||
| 13 | "path/filepath" | 13 | "path/filepath" | ||
| 14 | + | "sync" | |||
| 14 | "syscall" | 15 | "syscall" | ||
| 15 | 16 | ||||
| 16 | "github.com/bnema/vev/internal/domain" | 17 | "github.com/bnema/vev/internal/domain" | ||
| @@ -22,6 +23,7 @@ const ( | |||||
| 22 | fileName = "pending-notices.jsonl" | 23 | fileName = "pending-notices.jsonl" | ||
| 23 | inFlightFileName = "pending-notices.inflight.jsonl" | 24 | inFlightFileName = "pending-notices.inflight.jsonl" | ||
| 24 | lockFileName = "pending-notices.lock" | 25 | lockFileName = "pending-notices.lock" | ||
| 26 | + | claimLockFileName = "pending-notices.claim.lock" | |||
| 25 | maxNoticeRecordSize = 4 << 20 | 27 | maxNoticeRecordSize = 4 << 20 | ||
| 26 | noticeReadBufferSize = 64 << 10 | 28 | noticeReadBufferSize = 64 << 10 | ||
| 27 | ) | 29 | ) | ||
| @@ -30,12 +32,25 @@ const ( | |||||
| 30 | type Store struct { | 32 | type Store struct { | ||
| 31 | dir string | 33 | dir string | ||
| 32 | 34 | ||||
| 33 | - | // afterClaimRead is used by package tests to pause a claim after its reader | 35 | + | claimMu sync.Mutex |
| 34 | - | // reached EOF while it still owns the interprocess lock. It is nil in | 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 | |||
| 35 | // production. | 40 | // production. | ||
| 36 | - | afterClaimRead func() | 41 | + | afterAppendOpen func() |
| 37 | } | 42 | } | ||
| 38 | 43 | ||||
| 44 | + | var ( | |||
| 45 | + | // ErrClaimInProgress means another live Store owns the in-flight claim. | |||
| 46 | + | // Callers must leave it for that owner rather than replaying or acknowledging | |||
| 47 | + | // its notices. | |||
| 48 | + | ErrClaimInProgress = errors.New("pending notice claim in progress") | |||
| 49 | + | // ErrNoClaimOwner means Ack was called by a Store that did not make the | |||
| 50 | + | // current claim. | |||
| 51 | + | ErrNoClaimOwner = errors.New("pending notice claim is not owned by this store") | |||
| 52 | + | ) | |||
| 53 | + | ||||
| 39 | var _ ports.NoticeStore = (*Store)(nil) | 54 | var _ ports.NoticeStore = (*Store)(nil) | ||
| 40 | 55 | ||||
| 41 | // New creates a file-backed notice store rooted at dir. | 56 | // New creates a file-backed notice store rooted at dir. | ||
| @@ -55,6 +70,10 @@ func (s *Store) lockPath() string { | |||||
| 55 | return filepath.Join(s.dir, lockFileName) | 70 | return filepath.Join(s.dir, lockFileName) | ||
| 56 | } | 71 | } | ||
| 57 | 72 | ||||
| 73 | + | func (s *Store) claimLockPath() string { | |||
| 74 | + | return filepath.Join(s.dir, claimLockFileName) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 58 | // withLock serializes Append, Claim, and Ack across Store instances and | 77 | // withLock serializes Append, Claim, and Ack across Store instances and | ||
| 59 | // processes. Holding this lock while Claim rotates and reads the file prevents | 78 | // processes. Holding this lock while Claim rotates and reads the file prevents | ||
| 60 | // an appender with an already-open file descriptor from writing into a claim | 79 | // an appender with an already-open file descriptor from writing into a claim | ||
| @@ -78,6 +97,52 @@ func (s *Store) withLock(fn func() error) error { | |||||
| 78 | return fn() | 97 | return fn() | ||
| 79 | } | 98 | } | ||
| 80 | 99 | ||||
| 100 | + | // acquireClaimLock reserves the current in-flight file for this Store until | |||
| 101 | + | // Ack. The flock is released by the kernel if this process exits, allowing a | |||
| 102 | + | // later daemon to recover an abandoned claim. | |||
| 103 | + | func (s *Store) acquireClaimLock() error { | |||
| 104 | + | s.claimMu.Lock() | |||
| 105 | + | defer s.claimMu.Unlock() | |||
| 106 | + | if s.claimLock != nil { | |||
| 107 | + | return ErrClaimInProgress | |||
| 108 | + | } | |||
| 109 | + | f, err := os.OpenFile(s.claimLockPath(), os.O_CREATE|os.O_RDWR, 0o600) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return fmt.Errorf("open notice claim lock: %w", err) | |||
| 112 | + | } | |||
| 113 | + | if err := f.Chmod(0o600); err != nil { | |||
| 114 | + | _ = f.Close() | |||
| 115 | + | return fmt.Errorf("secure notice claim lock: %w", err) | |||
| 116 | + | } | |||
| 117 | + | if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |||
| 118 | + | _ = f.Close() | |||
| 119 | + | if errors.Is(err, syscall.EWOULDBLOCK) { | |||
| 120 | + | return ErrClaimInProgress | |||
| 121 | + | } | |||
| 122 | + | return fmt.Errorf("lock pending notice claim: %w", err) | |||
| 123 | + | } | |||
| 124 | + | s.claimLock = f | |||
| 125 | + | return nil | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (s *Store) releaseClaimLock() { | |||
| 129 | + | s.claimMu.Lock() | |||
| 130 | + | f := s.claimLock | |||
| 131 | + | s.claimLock = nil | |||
| 132 | + | s.claimMu.Unlock() | |||
| 133 | + | if f == nil { | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | |||
| 137 | + | _ = f.Close() | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (s *Store) ownsClaim() bool { | |||
| 141 | + | s.claimMu.Lock() | |||
| 142 | + | defer s.claimMu.Unlock() | |||
| 143 | + | return s.claimLock != nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 81 | func syncDir(dir string) error { | 146 | func syncDir(dir string) error { | ||
| 82 | f, err := os.Open(dir) | 147 | f, err := os.Open(dir) | ||
| 83 | if err != nil { | 148 | if err != nil { | ||
| @@ -105,6 +170,9 @@ func (s *Store) Append(n domain.Notification) error { | |||||
| 105 | _ = f.Close() | 170 | _ = f.Close() | ||
| 106 | return fmt.Errorf("secure pending notices: %w", err) | 171 | return fmt.Errorf("secure pending notices: %w", err) | ||
| 107 | } | 172 | } | ||
| 173 | + | if s.afterAppendOpen != nil { | |||
| 174 | + | s.afterAppendOpen() | |||
| 175 | + | } | |||
| 108 | if _, err := f.Write(data); err != nil { | 176 | if _, err := f.Write(data); err != nil { | ||
| 109 | _ = f.Close() | 177 | _ = f.Close() | ||
| 110 | return fmt.Errorf("write notice: %w", err) | 178 | return fmt.Errorf("write notice: %w", err) | ||
| @@ -124,12 +192,23 @@ func (s *Store) Append(n domain.Notification) error { | |||||
| 124 | } | 192 | } | ||
| 125 | 193 | ||||
| 126 | // Claim atomically moves pending notices into an in-flight file and returns | 194 | // Claim atomically moves pending notices into an in-flight file and returns | ||
| 127 | - | // their valid JSONL entries in order. The in-flight file is intentionally kept | 195 | + | // their valid JSONL entries in order. Its Store owns the claim until Ack, so a |
| 128 | - | // until Ack, so an unacknowledged import is replayed after a crash. A missing | 196 | + | // concurrent Store cannot replay or acknowledge it. The ownership flock is |
| 129 | - | // pending file means there is nothing to claim. | 197 | + | // released on process exit, allowing an unacknowledged import to be replayed |
| 198 | + | // after a crash. A missing pending file means there is nothing to claim. | |||
| 130 | func (s *Store) Claim() ([]domain.Notification, error) { | 199 | func (s *Store) Claim() ([]domain.Notification, error) { | ||
| 131 | var notices []domain.Notification | 200 | var notices []domain.Notification | ||
| 132 | err := s.withLock(func() error { | 201 | err := s.withLock(func() error { | ||
| 202 | + | if err := s.acquireClaimLock(); err != nil { | |||
| 203 | + | return err | |||
| 204 | + | } | |||
| 205 | + | keepClaim := false | |||
| 206 | + | defer func() { | |||
| 207 | + | if !keepClaim { | |||
| 208 | + | s.releaseClaimLock() | |||
| 209 | + | } | |||
| 210 | + | }() | |||
| 211 | + | ||||
| 133 | inFlight := s.inFlightPath() | 212 | inFlight := s.inFlightPath() | ||
| 134 | if _, err := os.Stat(inFlight); err != nil { | 213 | if _, err := os.Stat(inFlight); err != nil { | ||
| 135 | if !errors.Is(err, os.ErrNotExist) { | 214 | if !errors.Is(err, os.ErrNotExist) { | ||
| @@ -150,8 +229,8 @@ func (s *Store) Claim() ([]domain.Notification, error) { | |||||
| 150 | 229 | ||||
| 151 | var err error | 230 | var err error | ||
| 152 | notices, err = readNotices(inFlight) | 231 | notices, err = readNotices(inFlight) | ||
| 153 | - | if err == nil && s.afterClaimRead != nil { | 232 | + | if err == nil { |
| 154 | - | s.afterClaimRead() | 233 | + | keepClaim = true |
| 155 | } | 234 | } | ||
| 156 | return err | 235 | return err | ||
| 157 | }) | 236 | }) | ||
| @@ -233,16 +312,23 @@ func appendNoticeFragment(line, fragment []byte) ([]byte, bool) { | |||||
| 233 | return append(line, fragment...), false | 312 | return append(line, fragment...), false | ||
| 234 | } | 313 | } | ||
| 235 | 314 | ||||
| 236 | - | // Ack permanently removes the current in-flight claim. It is only called once | 315 | + | // Ack permanently removes this Store's in-flight claim. It is only called once |
| 237 | // the daemon has recorded and queued every claimed notice. | 316 | // the daemon has recorded and queued every claimed notice. | ||
| 238 | func (s *Store) Ack() error { | 317 | func (s *Store) Ack() error { | ||
| 318 | + | if !s.ownsClaim() { | |||
| 319 | + | return ErrNoClaimOwner | |||
| 320 | + | } | |||
| 239 | return s.withLock(func() error { | 321 | return s.withLock(func() error { | ||
| 322 | + | if !s.ownsClaim() { | |||
| 323 | + | return ErrNoClaimOwner | |||
| 324 | + | } | |||
| 240 | if err := os.Remove(s.inFlightPath()); err != nil && !errors.Is(err, os.ErrNotExist) { | 325 | if err := os.Remove(s.inFlightPath()); err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 241 | return fmt.Errorf("ack claimed notices: %w", err) | 326 | return fmt.Errorf("ack claimed notices: %w", err) | ||
| 242 | } | 327 | } | ||
| 243 | if err := syncDir(s.dir); err != nil { | 328 | if err := syncDir(s.dir); err != nil { | ||
| 244 | return fmt.Errorf("sync acknowledged notices: %w", err) | 329 | return fmt.Errorf("sync acknowledged notices: %w", err) | ||
| 245 | } | 330 | } | ||
| 331 | + | s.releaseClaimLock() | |||
| 246 | return nil | 332 | return nil | ||
| 247 | }) | 333 | }) | ||
| 248 | } | 334 | } | ||
M internal/adapters/noticefile/noticefile_test.go +40 -20
| @@ -41,7 +41,9 @@ func TestStoreClaimAckRoundTrip(t *testing.T) { | |||
| 41 | 41 | require.NoError(t, err) | |
| 42 | 42 | require.Equal(t, []domain.Notification{first, second}, claimed) | |
| 43 | 43 | ||
| 44 | - | // A fresh store simulates a daemon crash after Claim but before Ack. | |
| 44 | + | // Releasing the claim lock simulates the kernel closing it when the daemon | |
| 45 | + | // crashes. The in-flight file remains and a fresh process/store replays it. | |
| 46 | + | store.releaseClaimLock() | |
| 45 | 47 | recovered := New(dir) | |
| 46 | 48 | replayed, err := recovered.Claim() | |
| 47 | 49 | require.NoError(t, err) | |
| @@ -53,41 +55,59 @@ func TestStoreClaimAckRoundTrip(t *testing.T) { | |||
| 53 | 55 | require.Empty(t, again) | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 56 | - | func TestStoreClaimDoesNotLoseAppendAfterRotation(t *testing.T) { | |
| 58 | + | func TestStoreClaimWaitsForAppendInCriticalSection(t *testing.T) { | |
| 57 | 59 | t.Parallel() | |
| 58 | 60 | dir := filepath.Join(t.TempDir(), "vev") | |
| 59 | 61 | store := New(dir) | |
| 60 | 62 | first := domain.Notification{Message: "before claim"} | |
| 61 | - | second := domain.Notification{Message: "after claim rotation"} | |
| 63 | + | second := domain.Notification{Message: "append holds pending file open"} | |
| 62 | 64 | require.NoError(t, store.Append(first)) | |
| 63 | 65 | ||
| 64 | - | rotated := make(chan struct{}) | |
| 65 | - | allowRead := make(chan struct{}) | |
| 66 | - | store.afterClaimRead = func() { | |
| 67 | - | close(rotated) | |
| 68 | - | <-allowRead | |
| 66 | + | appendEntered := make(chan struct{}) | |
| 67 | + | allowAppend := make(chan struct{}) | |
| 68 | + | store.afterAppendOpen = func() { | |
| 69 | + | close(appendEntered) | |
| 70 | + | <-allowAppend | |
| 69 | 71 | } | |
| 72 | + | appendDone := make(chan error, 1) | |
| 73 | + | go func() { appendDone <- store.Append(second) }() | |
| 74 | + | <-appendEntered // Append owns the operation lock and an open pending file. | |
| 75 | + | ||
| 76 | + | claimant := New(dir) | |
| 70 | 77 | claimDone := make(chan []domain.Notification, 1) | |
| 71 | 78 | errDone := make(chan error, 1) | |
| 72 | 79 | go func() { | |
| 73 | - | claimed, err := store.Claim() | |
| 80 | + | claimed, err := claimant.Claim() | |
| 74 | 81 | claimDone <- claimed | |
| 75 | 82 | errDone <- err | |
| 76 | 83 | }() | |
| 77 | - | <-rotated | |
| 78 | - | ||
| 79 | - | appendDone := make(chan error, 1) | |
| 80 | - | go func() { appendDone <- New(dir).Append(second) }() | |
| 81 | - | close(allowRead) | |
| 82 | - | require.NoError(t, <-errDone) | |
| 83 | - | require.Equal(t, []domain.Notification{first}, <-claimDone) | |
| 84 | + | close(allowAppend) | |
| 84 | 85 | require.NoError(t, <-appendDone) | |
| 86 | + | require.NoError(t, <-errDone) | |
| 87 | + | require.Equal(t, []domain.Notification{first, second}, <-claimDone) | |
| 88 | + | require.NoError(t, claimant.Ack()) | |
| 89 | + | } | |
| 85 | 90 | ||
| 86 | - | require.NoError(t, store.Ack()) | |
| 87 | - | claimed, err := New(dir).Claim() | |
| 91 | + | func TestStoreClaimOwnershipPreventsLiveReplayOrAck(t *testing.T) { | |
| 92 | + | t.Parallel() | |
| 93 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 94 | + | owner := New(dir) | |
| 95 | + | require.NoError(t, owner.Append(domain.Notification{Message: "owned"})) | |
| 96 | + | claimed, err := owner.Claim() | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | require.Len(t, claimed, 1) | |
| 99 | + | ||
| 100 | + | other := New(dir) | |
| 101 | + | replayed, err := other.Claim() | |
| 102 | + | require.ErrorIs(t, err, ErrClaimInProgress) | |
| 103 | + | require.Nil(t, replayed) | |
| 104 | + | require.ErrorIs(t, other.Ack(), ErrNoClaimOwner) | |
| 105 | + | ||
| 106 | + | // The rejected Ack must leave the owner's in-flight record intact. | |
| 107 | + | require.NoError(t, owner.Ack()) | |
| 108 | + | empty, err := New(dir).Claim() | |
| 88 | 109 | require.NoError(t, err) | |
| 89 | - | require.Equal(t, []domain.Notification{second}, claimed) | |
| 90 | - | require.NoError(t, New(dir).Ack()) | |
| 110 | + | require.Empty(t, empty) | |
| 91 | 111 | } | |
| 92 | 112 | ||
| 93 | 113 | func TestStoreClaimMissingFileReturnsNil(t *testing.T) { | |
| @@ -41,7 +41,9 @@ func TestStoreClaimAckRoundTrip(t *testing.T) { | |||||
| 41 | require.NoError(t, err) | 41 | require.NoError(t, err) | ||
| 42 | require.Equal(t, []domain.Notification{first, second}, claimed) | 42 | require.Equal(t, []domain.Notification{first, second}, claimed) | ||
| 43 | 43 | ||||
| 44 | - | // A fresh store simulates a daemon crash after Claim but before Ack. | 44 | + | // Releasing the claim lock simulates the kernel closing it when the daemon |
| 45 | + | // crashes. The in-flight file remains and a fresh process/store replays it. | |||
| 46 | + | store.releaseClaimLock() | |||
| 45 | recovered := New(dir) | 47 | recovered := New(dir) | ||
| 46 | replayed, err := recovered.Claim() | 48 | replayed, err := recovered.Claim() | ||
| 47 | require.NoError(t, err) | 49 | require.NoError(t, err) | ||
| @@ -53,41 +55,59 @@ func TestStoreClaimAckRoundTrip(t *testing.T) { | |||||
| 53 | require.Empty(t, again) | 55 | require.Empty(t, again) | ||
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
| 56 | - | func TestStoreClaimDoesNotLoseAppendAfterRotation(t *testing.T) { | 58 | + | func TestStoreClaimWaitsForAppendInCriticalSection(t *testing.T) { |
| 57 | t.Parallel() | 59 | t.Parallel() | ||
| 58 | dir := filepath.Join(t.TempDir(), "vev") | 60 | dir := filepath.Join(t.TempDir(), "vev") | ||
| 59 | store := New(dir) | 61 | store := New(dir) | ||
| 60 | first := domain.Notification{Message: "before claim"} | 62 | first := domain.Notification{Message: "before claim"} | ||
| 61 | - | second := domain.Notification{Message: "after claim rotation"} | 63 | + | second := domain.Notification{Message: "append holds pending file open"} |
| 62 | require.NoError(t, store.Append(first)) | 64 | require.NoError(t, store.Append(first)) | ||
| 63 | 65 | ||||
| 64 | - | rotated := make(chan struct{}) | 66 | + | appendEntered := make(chan struct{}) |
| 65 | - | allowRead := make(chan struct{}) | 67 | + | allowAppend := make(chan struct{}) |
| 66 | - | store.afterClaimRead = func() { | 68 | + | store.afterAppendOpen = func() { |
| 67 | - | close(rotated) | 69 | + | close(appendEntered) |
| 68 | - | <-allowRead | 70 | + | <-allowAppend |
| 69 | } | 71 | } | ||
| 72 | + | appendDone := make(chan error, 1) | |||
| 73 | + | go func() { appendDone <- store.Append(second) }() | |||
| 74 | + | <-appendEntered // Append owns the operation lock and an open pending file. | |||
| 75 | + | ||||
| 76 | + | claimant := New(dir) | |||
| 70 | claimDone := make(chan []domain.Notification, 1) | 77 | claimDone := make(chan []domain.Notification, 1) | ||
| 71 | errDone := make(chan error, 1) | 78 | errDone := make(chan error, 1) | ||
| 72 | go func() { | 79 | go func() { | ||
| 73 | - | claimed, err := store.Claim() | 80 | + | claimed, err := claimant.Claim() |
| 74 | claimDone <- claimed | 81 | claimDone <- claimed | ||
| 75 | errDone <- err | 82 | errDone <- err | ||
| 76 | }() | 83 | }() | ||
| 77 | - | <-rotated | 84 | + | close(allowAppend) |
| 78 | - | ||||
| 79 | - | appendDone := make(chan error, 1) | |||
| 80 | - | go func() { appendDone <- New(dir).Append(second) }() | |||
| 81 | - | close(allowRead) | |||
| 82 | - | require.NoError(t, <-errDone) | |||
| 83 | - | require.Equal(t, []domain.Notification{first}, <-claimDone) | |||
| 84 | require.NoError(t, <-appendDone) | 85 | require.NoError(t, <-appendDone) | ||
| 86 | + | require.NoError(t, <-errDone) | |||
| 87 | + | require.Equal(t, []domain.Notification{first, second}, <-claimDone) | |||
| 88 | + | require.NoError(t, claimant.Ack()) | |||
| 89 | + | } | |||
| 85 | 90 | ||||
| 86 | - | require.NoError(t, store.Ack()) | 91 | + | func TestStoreClaimOwnershipPreventsLiveReplayOrAck(t *testing.T) { |
| 87 | - | claimed, err := New(dir).Claim() | 92 | + | t.Parallel() |
| 93 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 94 | + | owner := New(dir) | |||
| 95 | + | require.NoError(t, owner.Append(domain.Notification{Message: "owned"})) | |||
| 96 | + | claimed, err := owner.Claim() | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | require.Len(t, claimed, 1) | |||
| 99 | + | ||||
| 100 | + | other := New(dir) | |||
| 101 | + | replayed, err := other.Claim() | |||
| 102 | + | require.ErrorIs(t, err, ErrClaimInProgress) | |||
| 103 | + | require.Nil(t, replayed) | |||
| 104 | + | require.ErrorIs(t, other.Ack(), ErrNoClaimOwner) | |||
| 105 | + | ||||
| 106 | + | // The rejected Ack must leave the owner's in-flight record intact. | |||
| 107 | + | require.NoError(t, owner.Ack()) | |||
| 108 | + | empty, err := New(dir).Claim() | |||
| 88 | require.NoError(t, err) | 109 | require.NoError(t, err) | ||
| 89 | - | require.Equal(t, []domain.Notification{second}, claimed) | 110 | + | require.Empty(t, empty) |
| 90 | - | require.NoError(t, New(dir).Ack()) | |||
| 91 | } | 111 | } | ||
| 92 | 112 | ||||
| 93 | func TestStoreClaimMissingFileReturnsNil(t *testing.T) { | 113 | func TestStoreClaimMissingFileReturnsNil(t *testing.T) { | ||
M internal/ports/notice.go +4 -3
| @@ -5,9 +5,10 @@ import "github.com/bnema/vev/internal/domain" | |||
| 5 | 5 | // NoticeStore persists undeliverable notices across daemon restarts. | |
| 6 | 6 | type NoticeStore interface { | |
| 7 | 7 | Append(n domain.Notification) error | |
| 8 | - | // Claim atomically reserves pending notices for import. A claim remains | |
| 9 | - | // recoverable until Ack is called, including across process crashes. | |
| 8 | + | // Claim atomically reserves pending notices for import. A live claim cannot | |
| 9 | + | // be replayed or acknowledged by another store; it remains recoverable after | |
| 10 | + | // the claimant process crashes, until its owning store calls Ack. | |
| 10 | 11 | Claim() ([]domain.Notification, error) | |
| 11 | - | // Ack permanently removes the notices returned by the current claim. | |
| 12 | + | // Ack permanently removes the notices returned by this store's claim. | |
| 12 | 13 | Ack() error | |
| 13 | 14 | } | |
| @@ -5,9 +5,10 @@ import "github.com/bnema/vev/internal/domain" | |||||
| 5 | // NoticeStore persists undeliverable notices across daemon restarts. | 5 | // NoticeStore persists undeliverable notices across daemon restarts. | ||
| 6 | type NoticeStore interface { | 6 | type NoticeStore interface { | ||
| 7 | Append(n domain.Notification) error | 7 | Append(n domain.Notification) error | ||
| 8 | - | // Claim atomically reserves pending notices for import. A claim remains | 8 | + | // Claim atomically reserves pending notices for import. A live claim cannot |
| 9 | - | // recoverable until Ack is called, including across process crashes. | 9 | + | // be replayed or acknowledged by another store; it remains recoverable after |
| 10 | + | // the claimant process crashes, until its owning store calls Ack. | |||
| 10 | Claim() ([]domain.Notification, error) | 11 | Claim() ([]domain.Notification, error) | ||
| 11 | - | // Ack permanently removes the notices returned by the current claim. | 12 | + | // Ack permanently removes the notices returned by this store's claim. |
| 12 | Ack() error | 13 | Ack() error | ||
| 13 | } | 14 | } | ||
M internal/usecase/daemon/copymode.go +2 -2
| @@ -327,9 +327,9 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 327 | 327 | } | |
| 328 | 328 | rt.copyMu.Lock() | |
| 329 | 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 | 331 | } else { | |
| 332 | - | rt.copyFeedback = "selection too large to copy" | |
| 332 | + | rt.statusFeedback = "selection too large to copy" | |
| 333 | 333 | } | |
| 334 | 334 | rt.copyMu.Unlock() | |
| 335 | 335 | } | |
| @@ -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/floating_test.go +1 -0
| @@ -670,6 +670,7 @@ func TestToggleFloatingStructuralErrorsAreUserErrors(t *testing.T) { | |||
| 670 | 670 | require.Equal(t, tc.wantMsg, ue.Msg) | |
| 671 | 671 | if tc.cause != nil { | |
| 672 | 672 | require.NotContains(t, ue.Msg, tc.cause.Error()) | |
| 673 | + | require.ErrorIs(t, ue, tc.cause) | |
| 673 | 674 | } | |
| 674 | 675 | }) | |
| 675 | 676 | } | |
| @@ -670,6 +670,7 @@ func TestToggleFloatingStructuralErrorsAreUserErrors(t *testing.T) { | |||||
| 670 | require.Equal(t, tc.wantMsg, ue.Msg) | 670 | require.Equal(t, tc.wantMsg, ue.Msg) | ||
| 671 | if tc.cause != nil { | 671 | if tc.cause != nil { | ||
| 672 | require.NotContains(t, ue.Msg, tc.cause.Error()) | 672 | require.NotContains(t, ue.Msg, tc.cause.Error()) | ||
| 673 | + | require.ErrorIs(t, ue, tc.cause) | |||
| 673 | } | 674 | } | ||
| 674 | }) | 675 | }) | ||
| 675 | } | 676 | } | ||
M internal/usecase/daemon/notices_overlay.go +2 -2
| @@ -225,9 +225,9 @@ func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notifica | |||
| 225 | 225 | } | |
| 226 | 226 | ac.overlays.copyMu.Lock() | |
| 227 | 227 | if len(chunks) > 0 { | |
| 228 | - | ac.overlays.copyFeedback = "copied notification details" | |
| 228 | + | ac.overlays.statusFeedback = "copied notification details" | |
| 229 | 229 | } else { | |
| 230 | - | ac.overlays.copyFeedback = "notification too large to copy" | |
| 230 | + | ac.overlays.statusFeedback = "notification too large to copy" | |
| 231 | 231 | } | |
| 232 | 232 | ac.overlays.copyMu.Unlock() | |
| 233 | 233 | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |
| @@ -225,9 +225,9 @@ func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notifica | |||||
| 225 | } | 225 | } | ||
| 226 | ac.overlays.copyMu.Lock() | 226 | ac.overlays.copyMu.Lock() | ||
| 227 | if len(chunks) > 0 { | 227 | if len(chunks) > 0 { | ||
| 228 | - | ac.overlays.copyFeedback = "copied notification details" | 228 | + | ac.overlays.statusFeedback = "copied notification details" |
| 229 | } else { | 229 | } else { | ||
| 230 | - | ac.overlays.copyFeedback = "notification too large to copy" | 230 | + | ac.overlays.statusFeedback = "notification too large to copy" |
| 231 | } | 231 | } | ||
| 232 | ac.overlays.copyMu.Unlock() | 232 | ac.overlays.copyMu.Unlock() | ||
| 233 | d.invalidateRender(sess, ac, true, "notices_overlay.go") | 233 | d.invalidateRender(sess, ac, true, "notices_overlay.go") | ||
M internal/usecase/daemon/overlay_runtime.go +5 -5
| @@ -46,7 +46,7 @@ type overlayRuntime struct { | |||
| 46 | 46 | copyESC pendingByteTimer | |
| 47 | 47 | copySearch *visualsearch.Model | |
| 48 | 48 | copySearchPending []byte | |
| 49 | - | copyFeedback string | |
| 49 | + | statusFeedback string | |
| 50 | 50 | copyPointer copyPointerState | |
| 51 | 51 | copyClick copyClickCandidate | |
| 52 | 52 | copyPointerEpoch uint64 | |
| @@ -249,7 +249,7 @@ type overlayRenderSnapshot struct { | |||
| 249 | 249 | copyMode *scopy.Mode | |
| 250 | 250 | copyPane *pane | |
| 251 | 251 | copySearchModel *visualsearch.Model | |
| 252 | - | copyFeedback string | |
| 252 | + | statusFeedback string | |
| 253 | 253 | ||
| 254 | 254 | pickerActive bool | |
| 255 | 255 | pickerModel *picker.Model | |
| @@ -310,9 +310,9 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 310 | 310 | snap.copyMode = ©ModeValue | |
| 311 | 311 | } | |
| 312 | 312 | snap.copySearchModel = rt.copySearch.Clone() | |
| 313 | - | snap.copyFeedback = rt.copyFeedback | |
| 314 | - | if snap.copyFeedback != "" && !snap.copyActive { | |
| 315 | - | rt.copyFeedback = "" | |
| 313 | + | snap.statusFeedback = rt.statusFeedback | |
| 314 | + | if snap.statusFeedback != "" && !snap.copyActive { | |
| 315 | + | rt.statusFeedback = "" | |
| 316 | 316 | } | |
| 317 | 317 | rt.copyMu.Unlock() | |
| 318 | 318 | ||
| @@ -46,7 +46,7 @@ type overlayRuntime struct { | |||||
| 46 | copyESC pendingByteTimer | 46 | copyESC pendingByteTimer | ||
| 47 | copySearch *visualsearch.Model | 47 | copySearch *visualsearch.Model | ||
| 48 | copySearchPending []byte | 48 | copySearchPending []byte | ||
| 49 | - | copyFeedback string | 49 | + | statusFeedback string |
| 50 | copyPointer copyPointerState | 50 | copyPointer copyPointerState | ||
| 51 | copyClick copyClickCandidate | 51 | copyClick copyClickCandidate | ||
| 52 | copyPointerEpoch uint64 | 52 | copyPointerEpoch uint64 | ||
| @@ -249,7 +249,7 @@ type overlayRenderSnapshot struct { | |||||
| 249 | copyMode *scopy.Mode | 249 | copyMode *scopy.Mode | ||
| 250 | copyPane *pane | 250 | copyPane *pane | ||
| 251 | copySearchModel *visualsearch.Model | 251 | copySearchModel *visualsearch.Model | ||
| 252 | - | copyFeedback string | 252 | + | statusFeedback string |
| 253 | 253 | ||||
| 254 | pickerActive bool | 254 | pickerActive bool | ||
| 255 | pickerModel *picker.Model | 255 | pickerModel *picker.Model | ||
| @@ -310,9 +310,9 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 310 | snap.copyMode = ©ModeValue | 310 | snap.copyMode = ©ModeValue | ||
| 311 | } | 311 | } | ||
| 312 | snap.copySearchModel = rt.copySearch.Clone() | 312 | snap.copySearchModel = rt.copySearch.Clone() | ||
| 313 | - | snap.copyFeedback = rt.copyFeedback | 313 | + | snap.statusFeedback = rt.statusFeedback |
| 314 | - | if snap.copyFeedback != "" && !snap.copyActive { | 314 | + | if snap.statusFeedback != "" && !snap.copyActive { |
| 315 | - | rt.copyFeedback = "" | 315 | + | rt.statusFeedback = "" |
| 316 | } | 316 | } | ||
| 317 | rt.copyMu.Unlock() | 317 | rt.copyMu.Unlock() | ||
| 318 | 318 | ||||
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -988,7 +988,7 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t | |||
| 988 | 988 | awaitFrame(t, sends, ports.MsgOutput) | |
| 989 | 989 | ||
| 990 | 990 | history := d.notices.history() | |
| 991 | - | require.NotEmpty(t, history, "failed stopped-session restore must record a notice") | |
| 991 | + | require.Len(t, history, 1, "failed stopped-session restore must record exactly one notice") | |
| 992 | 992 | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |
| 993 | 993 | require.Equal(t, domain.NoticeError, history[0].Severity) | |
| 994 | 994 | ||
| @@ -988,7 +988,7 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t | |||||
| 988 | awaitFrame(t, sends, ports.MsgOutput) | 988 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 989 | 989 | ||||
| 990 | history := d.notices.history() | 990 | history := d.notices.history() | ||
| 991 | - | require.NotEmpty(t, history, "failed stopped-session restore must record a notice") | 991 | + | require.Len(t, history, 1, "failed stopped-session restore must record exactly one notice") |
| 992 | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | 992 | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | ||
| 993 | require.Equal(t, domain.NoticeError, history[0].Severity) | 993 | require.Equal(t, domain.NoticeError, history[0].Severity) | ||
| 994 | 994 | ||||
M internal/usecase/daemon/render.go +2 -2
| @@ -298,7 +298,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 298 | 298 | d.refreshSessionFocusedTitles(sess) | |
| 299 | 299 | // Contextual ranks are completely captured under paletteMu. Compose them | |
| 300 | 300 | // without reading the live MRU, whose order may have changed mid-interaction. | |
| 301 | - | bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 301 | + | bars := d.barStateForPaletteHints(sess, overlays.statusFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 302 | 302 | applied := ac.getAppliedTheme() | |
| 303 | 303 | bars.theme = applied.Raw | |
| 304 | 304 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| @@ -327,7 +327,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 327 | 327 | capturedOverlays := capturedOverlayRenderState{ | |
| 328 | 328 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | |
| 329 | 329 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |
| 330 | - | copyFeedback: overlays.copyFeedback, | |
| 330 | + | statusFeedback: overlays.statusFeedback, | |
| 331 | 331 | } | |
| 332 | 332 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 333 | 333 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | |
| @@ -298,7 +298,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 298 | d.refreshSessionFocusedTitles(sess) | 298 | d.refreshSessionFocusedTitles(sess) | ||
| 299 | // Contextual ranks are completely captured under paletteMu. Compose them | 299 | // Contextual ranks are completely captured under paletteMu. Compose them | ||
| 300 | // 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. | ||
| 301 | - | bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent) | 301 | + | bars := d.barStateForPaletteHints(sess, overlays.statusFeedback, overlays.paletteHints, overlays.paletteRecent) |
| 302 | applied := ac.getAppliedTheme() | 302 | applied := ac.getAppliedTheme() | ||
| 303 | bars.theme = applied.Raw | 303 | bars.theme = applied.Raw | ||
| 304 | attentionVisible := pulseVisible(bars.attentionFrame) | 304 | attentionVisible := pulseVisible(bars.attentionFrame) | ||
| @@ -327,7 +327,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 327 | capturedOverlays := capturedOverlayRenderState{ | 327 | capturedOverlays := capturedOverlayRenderState{ | ||
| 328 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | 328 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | ||
| 329 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | 329 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | ||
| 330 | - | copyFeedback: overlays.copyFeedback, | 330 | + | statusFeedback: overlays.statusFeedback, |
| 331 | } | 331 | } | ||
| 332 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | 332 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | ||
| 333 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | 333 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | ||
M internal/usecase/daemon/render_capture.go +1 -1
| @@ -89,7 +89,7 @@ type capturedOverlayRenderState struct { | |||
| 89 | 89 | noticesOverlayActive bool | |
| 90 | 90 | copyMode *scopy.Mode | |
| 91 | 91 | copyPaneID layout.PaneID | |
| 92 | - | copyFeedback string | |
| 92 | + | statusFeedback string | |
| 93 | 93 | paletteGuidance string | |
| 94 | 94 | copySearch, picker, palette, prompt, noticesOverlay capturedModal | |
| 95 | 95 | notices []domain.Notification | |
| @@ -89,7 +89,7 @@ type capturedOverlayRenderState struct { | |||||
| 89 | noticesOverlayActive bool | 89 | noticesOverlayActive bool | ||
| 90 | copyMode *scopy.Mode | 90 | copyMode *scopy.Mode | ||
| 91 | copyPaneID layout.PaneID | 91 | copyPaneID layout.PaneID | ||
| 92 | - | copyFeedback string | 92 | + | statusFeedback string |
| 93 | paletteGuidance string | 93 | paletteGuidance string | ||
| 94 | copySearch, picker, palette, prompt, noticesOverlay capturedModal | 94 | copySearch, picker, palette, prompt, noticesOverlay capturedModal | ||
| 95 | notices []domain.Notification | 95 | notices []domain.Notification | ||
M internal/usecase/daemon/status.go +15 -15
| @@ -72,7 +72,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 72 | 72 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { | |
| 73 | 73 | clearStatusRow(row, styles.SurfaceBar) | |
| 74 | 74 | x := 0 | |
| 75 | - | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | |
| 75 | + | rightText := composeBottomRightText(state.bottomRight, state.statusFeedback) | |
| 76 | 76 | writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive) | |
| 77 | 77 | if state.rankedRecent != nil { | |
| 78 | 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 | 120 | writeStatusText(row, x, " ", style) | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | - | func composeBottomRightText(scriptText, copyFeedback string) string { | |
| 123 | + | func composeBottomRightText(scriptText, statusFeedback string) string { | |
| 124 | 124 | if scriptText == "" { | |
| 125 | - | return copyFeedback | |
| 125 | + | return statusFeedback | |
| 126 | 126 | } | |
| 127 | - | if copyFeedback == "" { | |
| 127 | + | if statusFeedback == "" { | |
| 128 | 128 | return scriptText | |
| 129 | 129 | } | |
| 130 | - | return scriptText + " " + copyFeedback | |
| 130 | + | return scriptText + " " + statusFeedback | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | 133 | func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) { | |
| @@ -158,7 +158,7 @@ type barState struct { | |||
| 158 | 158 | status statusSnapshot | |
| 159 | 159 | topRight string | |
| 160 | 160 | bottomRight string | |
| 161 | - | copyFeedback string | |
| 161 | + | statusFeedback string | |
| 162 | 162 | mru []recentSession | |
| 163 | 163 | rankedRecent []rankedRecent | |
| 164 | 164 | attentionFrame int | |
| @@ -224,31 +224,31 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession | |||
| 224 | 224 | // barStateForPaletteHints selects snapshot-only contextual composition when a | |
| 225 | 225 | // palette interaction has captured recent-session hints. Normal palette state | |
| 226 | 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 | 228 | ranked := rankedRecentForHints(hints, recent) | |
| 229 | 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 | 235 | // barStateForContextual composes ranks captured for the palette interaction. | |
| 236 | 236 | // It deliberately does not call recentSessions: live MRU changes must not | |
| 237 | 237 | // affect contextual rendering, and palette rendering must not acquire d.mu. | |
| 238 | - | func (d *Daemon) barStateForContextual(cur *session, copyFeedback string, ranked []rankedRecent) barState { | |
| 239 | - | state := d.barStateBase(cur, copyFeedback) | |
| 238 | + | func (d *Daemon) barStateForContextual(cur *session, statusFeedback string, ranked []rankedRecent) barState { | |
| 239 | + | state := d.barStateBase(cur, statusFeedback) | |
| 240 | 240 | state.rankedRecent = ranked | |
| 241 | 241 | return state | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | - | func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |
| 245 | - | state := d.barStateBase(cur, copyFeedback) | |
| 244 | + | func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { | |
| 245 | + | state := d.barStateBase(cur, statusFeedback) | |
| 246 | 246 | state.mru = d.recentSessions(cur) | |
| 247 | 247 | return state | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 250 | - | func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState { | |
| 251 | - | state := barState{copyFeedback: copyFeedback} | |
| 250 | + | func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState { | |
| 251 | + | state := barState{statusFeedback: statusFeedback} | |
| 252 | 252 | if d != nil { | |
| 253 | 253 | state.attentionFrame = d.attentionFrame() | |
| 254 | 254 | } | |
| @@ -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 | 868 | win.size = domain.Size{Cols: 30, Rows: 2} | |
| 869 | 869 | ||
| 870 | 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 | 872 | }, composeCacheInput{}).frame | |
| 873 | 873 | require.Equal(t, " work ok", rowText(frame.Row(3))) | |
| 874 | 874 | ||
| 875 | 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 | 877 | }, composeCacheInput{}).frame | |
| 878 | 878 | require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3))) | |
| 879 | 879 | ||
| 880 | 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 | 882 | }, composeCacheInput{}).frame | |
| 883 | 883 | require.Equal(t, " work ", rowText(frame.Row(3))) | |
| 884 | 884 | } | |
| @@ -1295,21 +1295,21 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 1295 | 1295 | { | |
| 1296 | 1296 | name: "script text and copy feedback", | |
| 1297 | 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 | 1299 | wantContains: []string{" a"}, | |
| 1300 | 1300 | wantSuffix: " main ↑3 * copied", | |
| 1301 | 1301 | }, | |
| 1302 | 1302 | { | |
| 1303 | 1303 | name: "hide on overlap", | |
| 1304 | 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 | 1306 | want: " cur ", | |
| 1307 | 1307 | wantNotContains: []string{"main", "copied"}, | |
| 1308 | 1308 | }, | |
| 1309 | 1309 | { | |
| 1310 | 1310 | name: "empty script keeps copy feedback behavior", | |
| 1311 | 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 | 1313 | want: " cur copied", | |
| 1314 | 1314 | }, | |
| 1315 | 1315 | { | |
| @@ -1364,7 +1364,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 1364 | 1364 | } | |
| 1365 | 1365 | ||
| 1366 | 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 | 1368 | row := make([]renderer.Cell, 20) | |
| 1369 | 1369 | ||
| 1370 | 1370 | drawStatusBarState(row, state, resolveStyles(nil)) | |
| @@ -1377,7 +1377,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |||
| 1377 | 1377 | } | |
| 1378 | 1378 | ||
| 1379 | 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 | 1381 | tests := []struct { | |
| 1382 | 1382 | name string | |
| 1383 | 1383 | cols int | |
| @@ -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 | ||