detached d558968

fix(notices): preserve claim ownership

commit
d558968f84f1d3b6124d72a46290b64d09133682
author
brice · · 2026-07-21 13:19 UTC
parent
ed50af9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +176 -68
M internal/adapters/noticefile/noticefile.go +95 -9
@@ -11,6 +11,7 @@ import (
1111 "io"
1212 "os"
1313 "path/filepath"
14+ "sync"
1415 "syscall"
1516
1617 "github.com/bnema/vev/internal/domain"
@@ -22,6 +23,7 @@ const (
2223 fileName = "pending-notices.jsonl"
2324 inFlightFileName = "pending-notices.inflight.jsonl"
2425 lockFileName = "pending-notices.lock"
26+ claimLockFileName = "pending-notices.claim.lock"
2527 maxNoticeRecordSize = 4 << 20
2628 noticeReadBufferSize = 64 << 10
2729 )
@@ -30,12 +32,25 @@ const (
3032 type Store struct {
3133 dir string
3234
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
3540 // production.
36- afterClaimRead func()
41+ afterAppendOpen func()
3742 }
3843
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+
3954 var _ ports.NoticeStore = (*Store)(nil)
4055
4156 // New creates a file-backed notice store rooted at dir.
@@ -55,6 +70,10 @@ func (s *Store) lockPath() string {
5570 return filepath.Join(s.dir, lockFileName)
5671 }
5772
73+func (s *Store) claimLockPath() string {
74+ return filepath.Join(s.dir, claimLockFileName)
75+}
76+
5877 // withLock serializes Append, Claim, and Ack across Store instances and
5978 // processes. Holding this lock while Claim rotates and reads the file prevents
6079 // 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 {
7897 return fn()
7998 }
8099
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+
81146 func syncDir(dir string) error {
82147 f, err := os.Open(dir)
83148 if err != nil {
@@ -105,6 +170,9 @@ func (s *Store) Append(n domain.Notification) error {
105170 _ = f.Close()
106171 return fmt.Errorf("secure pending notices: %w", err)
107172 }
173+ if s.afterAppendOpen != nil {
174+ s.afterAppendOpen()
175+ }
108176 if _, err := f.Write(data); err != nil {
109177 _ = f.Close()
110178 return fmt.Errorf("write notice: %w", err)
@@ -124,12 +192,23 @@ func (s *Store) Append(n domain.Notification) error {
124192 }
125193
126194 // 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.
130199 func (s *Store) Claim() ([]domain.Notification, error) {
131200 var notices []domain.Notification
132201 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+
133212 inFlight := s.inFlightPath()
134213 if _, err := os.Stat(inFlight); err != nil {
135214 if !errors.Is(err, os.ErrNotExist) {
@@ -150,8 +229,8 @@ func (s *Store) Claim() ([]domain.Notification, error) {
150229
151230 var err error
152231 notices, err = readNotices(inFlight)
153- if err == nil && s.afterClaimRead != nil {
154- s.afterClaimRead()
232+ if err == nil {
233+ keepClaim = true
155234 }
156235 return err
157236 })
@@ -233,16 +312,23 @@ func appendNoticeFragment(line, fragment []byte) ([]byte, bool) {
233312 return append(line, fragment...), false
234313 }
235314
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
237316 // the daemon has recorded and queued every claimed notice.
238317 func (s *Store) Ack() error {
318+ if !s.ownsClaim() {
319+ return ErrNoClaimOwner
320+ }
239321 return s.withLock(func() error {
322+ if !s.ownsClaim() {
323+ return ErrNoClaimOwner
324+ }
240325 if err := os.Remove(s.inFlightPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
241326 return fmt.Errorf("ack claimed notices: %w", err)
242327 }
243328 if err := syncDir(s.dir); err != nil {
244329 return fmt.Errorf("sync acknowledged notices: %w", err)
245330 }
331+ s.releaseClaimLock()
246332 return nil
247333 })
248334 }
@@ -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 << 2027 maxNoticeRecordSize = 4 << 20
26 noticeReadBufferSize = 64 << 1028 noticeReadBufferSize = 64 << 10
27 )29 )
@@ -30,12 +32,25 @@ const (
30 type Store struct {32 type Store struct {
31 dir string33 dir string
32 34
33- // afterClaimRead is used by package tests to pause a claim after its reader35+ claimMu sync.Mutex
34- // reached EOF while it still owns the interprocess lock. It is nil in36+ 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 and77 // withLock serializes Append, Claim, and Ack across Store instances and
59 // processes. Holding this lock while Claim rotates and reads the file prevents78 // 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 claim79 // 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 returns194 // 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 kept195+// 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 missing196+// 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.Notification200 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 error230 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 err235 return err
157 })236 })
@@ -233,16 +312,23 @@ func appendNoticeFragment(line, fragment []byte) ([]byte, bool) {
233 return append(line, fragment...), false312 return append(line, fragment...), false
234 }313 }
235 314
236-// Ack permanently removes the current in-flight claim. It is only called once315+// 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 nil332 return nil
247 })333 })
248 }334 }
M internal/adapters/noticefile/noticefile_test.go +40 -20
@@ -41,7 +41,9 @@ func TestStoreClaimAckRoundTrip(t *testing.T) {
4141 require.NoError(t, err)
4242 require.Equal(t, []domain.Notification{first, second}, claimed)
4343
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()
4547 recovered := New(dir)
4648 replayed, err := recovered.Claim()
4749 require.NoError(t, err)
@@ -53,41 +55,59 @@ func TestStoreClaimAckRoundTrip(t *testing.T) {
5355 require.Empty(t, again)
5456 }
5557
56-func TestStoreClaimDoesNotLoseAppendAfterRotation(t *testing.T) {
58+func TestStoreClaimWaitsForAppendInCriticalSection(t *testing.T) {
5759 t.Parallel()
5860 dir := filepath.Join(t.TempDir(), "vev")
5961 store := New(dir)
6062 first := domain.Notification{Message: "before claim"}
61- second := domain.Notification{Message: "after claim rotation"}
63+ second := domain.Notification{Message: "append holds pending file open"}
6264 require.NoError(t, store.Append(first))
6365
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
6971 }
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)
7077 claimDone := make(chan []domain.Notification, 1)
7178 errDone := make(chan error, 1)
7279 go func() {
73- claimed, err := store.Claim()
80+ claimed, err := claimant.Claim()
7481 claimDone <- claimed
7582 errDone <- err
7683 }()
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)
8485 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+}
8590
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()
88109 require.NoError(t, err)
89- require.Equal(t, []domain.Notification{second}, claimed)
90- require.NoError(t, New(dir).Ack())
110+ require.Empty(t, empty)
91111 }
92112
93113 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- <-allowRead70+ <-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 <- claimed81 claimDone <- claimed
75 errDone <- err82 errDone <- err
76 }()83 }()
77- <-rotated84+ 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"
55 // NoticeStore persists undeliverable notices across daemon restarts.
66 type NoticeStore interface {
77 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.
1011 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.
1213 Ack() error
1314 }
@@ -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) error7 Append(n domain.Notification) error
8- // Claim atomically reserves pending notices for import. A claim remains8+ // 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() error13 Ack() error
13 }14 }
M internal/usecase/daemon/copymode.go +2 -2
@@ -327,9 +327,9 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
327327 }
328328 rt.copyMu.Lock()
329329 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"
331331 } else {
332- rt.copyFeedback = "selection too large to copy"
332+ rt.statusFeedback = "selection too large to copy"
333333 }
334334 rt.copyMu.Unlock()
335335 }
@@ -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) {
670670 require.Equal(t, tc.wantMsg, ue.Msg)
671671 if tc.cause != nil {
672672 require.NotContains(t, ue.Msg, tc.cause.Error())
673+ require.ErrorIs(t, ue, tc.cause)
673674 }
674675 })
675676 }
@@ -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
225225 }
226226 ac.overlays.copyMu.Lock()
227227 if len(chunks) > 0 {
228- ac.overlays.copyFeedback = "copied notification details"
228+ ac.overlays.statusFeedback = "copied notification details"
229229 } else {
230- ac.overlays.copyFeedback = "notification too large to copy"
230+ ac.overlays.statusFeedback = "notification too large to copy"
231231 }
232232 ac.overlays.copyMu.Unlock()
233233 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 {
4646 copyESC pendingByteTimer
4747 copySearch *visualsearch.Model
4848 copySearchPending []byte
49- copyFeedback string
49+ statusFeedback string
5050 copyPointer copyPointerState
5151 copyClick copyClickCandidate
5252 copyPointerEpoch uint64
@@ -249,7 +249,7 @@ type overlayRenderSnapshot struct {
249249 copyMode *scopy.Mode
250250 copyPane *pane
251251 copySearchModel *visualsearch.Model
252- copyFeedback string
252+ statusFeedback string
253253
254254 pickerActive bool
255255 pickerModel *picker.Model
@@ -310,9 +310,9 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
310310 snap.copyMode = &copyModeValue
311311 }
312312 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 = ""
316316 }
317317 rt.copyMu.Unlock()
318318
@@ -46,7 +46,7 @@ type overlayRuntime struct {
46 copyESC pendingByteTimer46 copyESC pendingByteTimer
47 copySearch *visualsearch.Model47 copySearch *visualsearch.Model
48 copySearchPending []byte48 copySearchPending []byte
49- copyFeedback string49+ statusFeedback string
50 copyPointer copyPointerState50 copyPointer copyPointerState
51 copyClick copyClickCandidate51 copyClick copyClickCandidate
52 copyPointerEpoch uint6452 copyPointerEpoch uint64
@@ -249,7 +249,7 @@ type overlayRenderSnapshot struct {
249 copyMode *scopy.Mode249 copyMode *scopy.Mode
250 copyPane *pane250 copyPane *pane
251 copySearchModel *visualsearch.Model251 copySearchModel *visualsearch.Model
252- copyFeedback string252+ statusFeedback string
253 253
254 pickerActive bool254 pickerActive bool
255 pickerModel *picker.Model255 pickerModel *picker.Model
@@ -310,9 +310,9 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
310 snap.copyMode = &copyModeValue310 snap.copyMode = &copyModeValue
311 }311 }
312 snap.copySearchModel = rt.copySearch.Clone()312 snap.copySearchModel = rt.copySearch.Clone()
313- snap.copyFeedback = rt.copyFeedback313+ 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
988988 awaitFrame(t, sends, ports.MsgOutput)
989989
990990 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")
992992 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
993993 require.Equal(t, domain.NoticeError, history[0].Severity)
994994
@@ -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
298298 d.refreshSessionFocusedTitles(sess)
299299 // Contextual ranks are completely captured under paletteMu. Compose them
300300 // 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)
302302 applied := ac.getAppliedTheme()
303303 bars.theme = applied.Raw
304304 attentionVisible := pulseVisible(bars.attentionFrame)
@@ -327,7 +327,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
327327 capturedOverlays := capturedOverlayRenderState{
328328 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
329329 pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
330- copyFeedback: overlays.copyFeedback,
330+ statusFeedback: overlays.statusFeedback,
331331 }
332332 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
333333 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 them299 // 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.Raw303 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 {
8989 noticesOverlayActive bool
9090 copyMode *scopy.Mode
9191 copyPaneID layout.PaneID
92- copyFeedback string
92+ statusFeedback string
9393 paletteGuidance string
9494 copySearch, picker, palette, prompt, noticesOverlay capturedModal
9595 notices []domain.Notification
@@ -89,7 +89,7 @@ type capturedOverlayRenderState struct {
89 noticesOverlayActive bool89 noticesOverlayActive bool
90 copyMode *scopy.Mode90 copyMode *scopy.Mode
91 copyPaneID layout.PaneID91 copyPaneID layout.PaneID
92- copyFeedback string92+ statusFeedback string
93 paletteGuidance string93 paletteGuidance string
94 copySearch, picker, palette, prompt, noticesOverlay capturedModal94 copySearch, picker, palette, prompt, noticesOverlay capturedModal
95 notices []domain.Notification95 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
7272 func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
7373 clearStatusRow(row, styles.SurfaceBar)
7474 x := 0
75- rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
75+ rightText := composeBottomRightText(state.bottomRight, state.statusFeedback)
7676 writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive)
7777 if state.rankedRecent != nil {
7878 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
@@ -120,14 +120,14 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, attention
120120 writeStatusText(row, x, " ", style)
121121 }
122122
123-func composeBottomRightText(scriptText, copyFeedback string) string {
123+func composeBottomRightText(scriptText, statusFeedback string) string {
124124 if scriptText == "" {
125- return copyFeedback
125+ return statusFeedback
126126 }
127- if copyFeedback == "" {
127+ if statusFeedback == "" {
128128 return scriptText
129129 }
130- return scriptText + " " + copyFeedback
130+ return scriptText + " " + statusFeedback
131131 }
132132
133133 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
@@ -158,7 +158,7 @@ type barState struct {
158158 status statusSnapshot
159159 topRight string
160160 bottomRight string
161- copyFeedback string
161+ statusFeedback string
162162 mru []recentSession
163163 rankedRecent []rankedRecent
164164 attentionFrame int
@@ -224,31 +224,31 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
224224 // barStateForPaletteHints selects snapshot-only contextual composition when a
225225 // palette interaction has captured recent-session hints. Normal palette state
226226 // 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 {
228228 ranked := rankedRecentForHints(hints, recent)
229229 if ranked != nil {
230- return d.barStateForContextual(cur, copyFeedback, ranked)
230+ return d.barStateForContextual(cur, statusFeedback, ranked)
231231 }
232- return d.barStateFor(cur, copyFeedback)
232+ return d.barStateFor(cur, statusFeedback)
233233 }
234234
235235 // barStateForContextual composes ranks captured for the palette interaction.
236236 // It deliberately does not call recentSessions: live MRU changes must not
237237 // 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)
240240 state.rankedRecent = ranked
241241 return state
242242 }
243243
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)
246246 state.mru = d.recentSessions(cur)
247247 return state
248248 }
249249
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}
252252 if d != nil {
253253 state.attentionFrame = d.attentionFrame()
254254 }
@@ -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 := 074 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 copyFeedback125+ return statusFeedback
126 }126 }
127- if copyFeedback == "" {127+ if statusFeedback == "" {
128 return scriptText128 return scriptText
129 }129 }
130- return scriptText + " " + copyFeedback130+ 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 statusSnapshot158 status statusSnapshot
159 topRight string159 topRight string
160 bottomRight string160 bottomRight string
161- copyFeedback string161+ statusFeedback string
162 mru []recentSession162 mru []recentSession
163 rankedRecent []rankedRecent163 rankedRecent []rankedRecent
164 attentionFrame int164 attentionFrame int
@@ -224,31 +224,31 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
224 // barStateForPaletteHints selects snapshot-only contextual composition when a224 // barStateForPaletteHints selects snapshot-only contextual composition when a
225 // palette interaction has captured recent-session hints. Normal palette state225 // 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 not236 // 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 = ranked240 state.rankedRecent = ranked
241 return state241 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 state247 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) {
868868 win.size = domain.Size{Cols: 30, Rows: 2}
869869
870870 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"},
872872 }, composeCacheInput{}).frame
873873 require.Equal(t, " work ok", rowText(frame.Row(3)))
874874
875875 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"},
877877 }, composeCacheInput{}).frame
878878 require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))
879879
880880 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"},
882882 }, composeCacheInput{}).frame
883883 require.Equal(t, " work ", rowText(frame.Row(3)))
884884 }
@@ -1295,21 +1295,21 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
12951295 {
12961296 name: "script text and copy feedback",
12971297 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"}}},
12991299 wantContains: []string{" a"},
13001300 wantSuffix: " main ↑3 * copied",
13011301 },
13021302 {
13031303 name: "hide on overlap",
13041304 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"}}},
13061306 want: " cur ",
13071307 wantNotContains: []string{"main", "copied"},
13081308 },
13091309 {
13101310 name: "empty script keeps copy feedback behavior",
13111311 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"}}},
13131313 want: " cur copied",
13141314 },
13151315 {
@@ -1364,7 +1364,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
13641364 }
13651365
13661366 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"}}}
13681368 row := make([]renderer.Cell, 20)
13691369
13701370 drawStatusBarState(row, state, resolveStyles(nil))
@@ -1377,7 +1377,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
13771377 }
13781378
13791379 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"}}}
13811381 tests := []struct {
13821382 name string
13831383 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{}).frame872 }, 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{}).frame877 }, 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{}).frame882 }, 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 string1382 name string
1383 cols int1383 cols int

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close