fix(tabs): preserve names across persistence paths
- commit
- 0620ced8c24220ae0922bfbdfd7f81a9bd080fc4
- author
- brice · · 2026-07-09 18:43 UTC
- parent
- ce49e6a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +143 -9
M internal/persist/persist.go +14 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "log/slog" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "sort" | |
| 7 | + | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/pkg/kv" | |
| @@ -20,6 +21,7 @@ func StorePath(dir string) string { | |||
| 20 | 21 | // operations no-ops, so callers can keep persistence optional. | |
| 21 | 22 | type Persister struct { | |
| 22 | 23 | store ports.Store | |
| 24 | + | mu sync.Mutex | |
| 23 | 25 | } | |
| 24 | 26 | ||
| 25 | 27 | // Open opens the session persister under dir. | |
| @@ -50,6 +52,8 @@ func (p *Persister) Save(r Record) error { | |||
| 50 | 52 | if p == nil || p.store == nil { | |
| 51 | 53 | return nil | |
| 52 | 54 | } | |
| 55 | + | p.mu.Lock() | |
| 56 | + | defer p.mu.Unlock() | |
| 53 | 57 | value, err := encodeRecordValue(r) | |
| 54 | 58 | if err != nil { | |
| 55 | 59 | return err | |
| @@ -68,6 +72,8 @@ func (p *Persister) Touch(name, cwd string, at int64) error { | |||
| 68 | 72 | if name == "" { | |
| 69 | 73 | return errEmptyName | |
| 70 | 74 | } | |
| 75 | + | p.mu.Lock() | |
| 76 | + | defer p.mu.Unlock() | |
| 71 | 77 | ||
| 72 | 78 | createdAt := at | |
| 73 | 79 | lastUsedSeq := uint64(0) | |
| @@ -95,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error { | |||
| 95 | 101 | if name == "" { | |
| 96 | 102 | return errEmptyName | |
| 97 | 103 | } | |
| 104 | + | p.mu.Lock() | |
| 105 | + | defer p.mu.Unlock() | |
| 98 | 106 | ||
| 99 | 107 | r := Record{Name: name, LastUsedSeq: lastUsedSeq} | |
| 100 | 108 | if v, ok := p.store.Get([]byte(name)); ok { | |
| @@ -118,6 +126,8 @@ func (p *Persister) Delete(name string) error { | |||
| 118 | 126 | if name == "" { | |
| 119 | 127 | return errEmptyName | |
| 120 | 128 | } | |
| 129 | + | p.mu.Lock() | |
| 130 | + | defer p.mu.Unlock() | |
| 121 | 131 | if err := p.store.Delete([]byte(name)); err != nil { | |
| 122 | 132 | return err | |
| 123 | 133 | } | |
| @@ -129,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) { | |||
| 129 | 139 | if p == nil || p.store == nil { | |
| 130 | 140 | return nil, nil | |
| 131 | 141 | } | |
| 142 | + | p.mu.Lock() | |
| 143 | + | defer p.mu.Unlock() | |
| 132 | 144 | data := make(map[string][]byte) | |
| 133 | 145 | p.store.Range(func(k, v []byte) bool { | |
| 134 | 146 | data[string(k)] = append([]byte(nil), v...) | |
| @@ -142,6 +154,8 @@ func (p *Persister) Close() error { | |||
| 142 | 154 | if p == nil || p.store == nil { | |
| 143 | 155 | return nil | |
| 144 | 156 | } | |
| 157 | + | p.mu.Lock() | |
| 158 | + | defer p.mu.Unlock() | |
| 145 | 159 | return p.store.Close() | |
| 146 | 160 | } | |
| 147 | 161 | ||
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "log/slog" | 4 | "log/slog" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | "sort" | 6 | "sort" | ||
| 7 | + | "sync" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | "github.com/bnema/vev/pkg/kv" | 10 | "github.com/bnema/vev/pkg/kv" | ||
| @@ -20,6 +21,7 @@ func StorePath(dir string) string { | |||||
| 20 | // operations no-ops, so callers can keep persistence optional. | 21 | // operations no-ops, so callers can keep persistence optional. | ||
| 21 | type Persister struct { | 22 | type Persister struct { | ||
| 22 | store ports.Store | 23 | store ports.Store | ||
| 24 | + | mu sync.Mutex | |||
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| 25 | // Open opens the session persister under dir. | 27 | // Open opens the session persister under dir. | ||
| @@ -50,6 +52,8 @@ func (p *Persister) Save(r Record) error { | |||||
| 50 | if p == nil || p.store == nil { | 52 | if p == nil || p.store == nil { | ||
| 51 | return nil | 53 | return nil | ||
| 52 | } | 54 | } | ||
| 55 | + | p.mu.Lock() | |||
| 56 | + | defer p.mu.Unlock() | |||
| 53 | value, err := encodeRecordValue(r) | 57 | value, err := encodeRecordValue(r) | ||
| 54 | if err != nil { | 58 | if err != nil { | ||
| 55 | return err | 59 | return err | ||
| @@ -68,6 +72,8 @@ func (p *Persister) Touch(name, cwd string, at int64) error { | |||||
| 68 | if name == "" { | 72 | if name == "" { | ||
| 69 | return errEmptyName | 73 | return errEmptyName | ||
| 70 | } | 74 | } | ||
| 75 | + | p.mu.Lock() | |||
| 76 | + | defer p.mu.Unlock() | |||
| 71 | 77 | ||||
| 72 | createdAt := at | 78 | createdAt := at | ||
| 73 | lastUsedSeq := uint64(0) | 79 | lastUsedSeq := uint64(0) | ||
| @@ -95,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error { | |||||
| 95 | if name == "" { | 101 | if name == "" { | ||
| 96 | return errEmptyName | 102 | return errEmptyName | ||
| 97 | } | 103 | } | ||
| 104 | + | p.mu.Lock() | |||
| 105 | + | defer p.mu.Unlock() | |||
| 98 | 106 | ||||
| 99 | r := Record{Name: name, LastUsedSeq: lastUsedSeq} | 107 | r := Record{Name: name, LastUsedSeq: lastUsedSeq} | ||
| 100 | if v, ok := p.store.Get([]byte(name)); ok { | 108 | if v, ok := p.store.Get([]byte(name)); ok { | ||
| @@ -118,6 +126,8 @@ func (p *Persister) Delete(name string) error { | |||||
| 118 | if name == "" { | 126 | if name == "" { | ||
| 119 | return errEmptyName | 127 | return errEmptyName | ||
| 120 | } | 128 | } | ||
| 129 | + | p.mu.Lock() | |||
| 130 | + | defer p.mu.Unlock() | |||
| 121 | if err := p.store.Delete([]byte(name)); err != nil { | 131 | if err := p.store.Delete([]byte(name)); err != nil { | ||
| 122 | return err | 132 | return err | ||
| 123 | } | 133 | } | ||
| @@ -129,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) { | |||||
| 129 | if p == nil || p.store == nil { | 139 | if p == nil || p.store == nil { | ||
| 130 | return nil, nil | 140 | return nil, nil | ||
| 131 | } | 141 | } | ||
| 142 | + | p.mu.Lock() | |||
| 143 | + | defer p.mu.Unlock() | |||
| 132 | data := make(map[string][]byte) | 144 | data := make(map[string][]byte) | ||
| 133 | p.store.Range(func(k, v []byte) bool { | 145 | p.store.Range(func(k, v []byte) bool { | ||
| 134 | data[string(k)] = append([]byte(nil), v...) | 146 | data[string(k)] = append([]byte(nil), v...) | ||
| @@ -142,6 +154,8 @@ func (p *Persister) Close() error { | |||||
| 142 | if p == nil || p.store == nil { | 154 | if p == nil || p.store == nil { | ||
| 143 | return nil | 155 | return nil | ||
| 144 | } | 156 | } | ||
| 157 | + | p.mu.Lock() | |||
| 158 | + | defer p.mu.Unlock() | |||
| 145 | return p.store.Close() | 159 | return p.store.Close() | ||
| 146 | } | 160 | } | ||
| 147 | 161 | ||||
M internal/persist/persist_test.go +92 -0
| @@ -3,7 +3,9 @@ package persist | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/stretchr/testify/mock" | |
| 9 | 11 | "github.com/stretchr/testify/require" | |
| @@ -151,6 +153,34 @@ func TestSyncBehavior(t *testing.T) { | |||
| 151 | 153 | require.Equal(t, 2, state.syncs) | |
| 152 | 154 | } | |
| 153 | 155 | ||
| 156 | + | func TestPersisterSerializesReadModifyWriteWithStructuralSave(t *testing.T) { | |
| 157 | + | store := newBlockingGetStore() | |
| 158 | + | p := New(store) | |
| 159 | + | require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, TabNames: []string{"old"}})) | |
| 160 | + | store.blockNextGet() | |
| 161 | + | ||
| 162 | + | touchDone := make(chan error, 1) | |
| 163 | + | go func() { touchDone <- p.TouchMRU("work", 2) }() | |
| 164 | + | <-store.getStarted | |
| 165 | + | ||
| 166 | + | saveDone := make(chan error, 1) | |
| 167 | + | go func() { | |
| 168 | + | saveDone <- p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 3, LastUsedSeq: 2, TabNames: []string{"new"}}) | |
| 169 | + | }() | |
| 170 | + | select { | |
| 171 | + | case err := <-saveDone: | |
| 172 | + | t.Fatalf("structural save interleaved with TouchMRU: %v", err) | |
| 173 | + | case <-time.After(20 * time.Millisecond): | |
| 174 | + | } | |
| 175 | + | close(store.releaseGet) | |
| 176 | + | require.NoError(t, <-touchDone) | |
| 177 | + | require.NoError(t, <-saveDone) | |
| 178 | + | ||
| 179 | + | records, err := p.LoadAll() | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | require.Equal(t, []string{"new"}, records[0].TabNames) | |
| 182 | + | } | |
| 183 | + | ||
| 154 | 184 | func TestSavePropagatesSetErrorWithoutSync(t *testing.T) { | |
| 155 | 185 | store, state := newMockStore(t) | |
| 156 | 186 | state.errSet = errors.New("set failed") | |
| @@ -170,6 +200,68 @@ type mockStoreState struct { | |||
| 170 | 200 | errSet error | |
| 171 | 201 | } | |
| 172 | 202 | ||
| 203 | + | type blockingGetStore struct { | |
| 204 | + | mu sync.Mutex | |
| 205 | + | data map[string][]byte | |
| 206 | + | block bool | |
| 207 | + | getStarted chan struct{} | |
| 208 | + | releaseGet chan struct{} | |
| 209 | + | getStartOnce sync.Once | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func newBlockingGetStore() *blockingGetStore { | |
| 213 | + | return &blockingGetStore{data: make(map[string][]byte), getStarted: make(chan struct{}), releaseGet: make(chan struct{})} | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (s *blockingGetStore) blockNextGet() { | |
| 217 | + | s.mu.Lock() | |
| 218 | + | s.block = true | |
| 219 | + | s.mu.Unlock() | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func (s *blockingGetStore) Get(key []byte) ([]byte, bool) { | |
| 223 | + | s.mu.Lock() | |
| 224 | + | v, ok := s.data[string(key)] | |
| 225 | + | v = append([]byte(nil), v...) | |
| 226 | + | block := s.block | |
| 227 | + | if block { | |
| 228 | + | s.block = false | |
| 229 | + | } | |
| 230 | + | s.mu.Unlock() | |
| 231 | + | if block { | |
| 232 | + | s.getStartOnce.Do(func() { close(s.getStarted) }) | |
| 233 | + | <-s.releaseGet | |
| 234 | + | } | |
| 235 | + | return v, ok | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (s *blockingGetStore) Set(key, value []byte) error { | |
| 239 | + | s.mu.Lock() | |
| 240 | + | s.data[string(key)] = append([]byte(nil), value...) | |
| 241 | + | s.mu.Unlock() | |
| 242 | + | return nil | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func (s *blockingGetStore) Delete(key []byte) error { | |
| 246 | + | s.mu.Lock() | |
| 247 | + | delete(s.data, string(key)) | |
| 248 | + | s.mu.Unlock() | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func (s *blockingGetStore) Range(fn func(k, v []byte) bool) { | |
| 253 | + | s.mu.Lock() | |
| 254 | + | defer s.mu.Unlock() | |
| 255 | + | for key, value := range s.data { | |
| 256 | + | if !fn([]byte(key), append([]byte(nil), value...)) { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (*blockingGetStore) Sync() error { return nil } | |
| 263 | + | func (*blockingGetStore) Close() error { return nil } | |
| 264 | + | ||
| 173 | 265 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | |
| 174 | 266 | t.Helper() | |
| 175 | 267 | state := &mockStoreState{data: make(map[string][]byte)} | |
| @@ -3,7 +3,9 @@ package persist | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | + | "sync" | |||
| 6 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 7 | 9 | ||||
| 8 | "github.com/stretchr/testify/mock" | 10 | "github.com/stretchr/testify/mock" | ||
| 9 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| @@ -151,6 +153,34 @@ func TestSyncBehavior(t *testing.T) { | |||||
| 151 | require.Equal(t, 2, state.syncs) | 153 | require.Equal(t, 2, state.syncs) | ||
| 152 | } | 154 | } | ||
| 153 | 155 | ||||
| 156 | + | func TestPersisterSerializesReadModifyWriteWithStructuralSave(t *testing.T) { | |||
| 157 | + | store := newBlockingGetStore() | |||
| 158 | + | p := New(store) | |||
| 159 | + | require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, TabNames: []string{"old"}})) | |||
| 160 | + | store.blockNextGet() | |||
| 161 | + | ||||
| 162 | + | touchDone := make(chan error, 1) | |||
| 163 | + | go func() { touchDone <- p.TouchMRU("work", 2) }() | |||
| 164 | + | <-store.getStarted | |||
| 165 | + | ||||
| 166 | + | saveDone := make(chan error, 1) | |||
| 167 | + | go func() { | |||
| 168 | + | saveDone <- p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 3, LastUsedSeq: 2, TabNames: []string{"new"}}) | |||
| 169 | + | }() | |||
| 170 | + | select { | |||
| 171 | + | case err := <-saveDone: | |||
| 172 | + | t.Fatalf("structural save interleaved with TouchMRU: %v", err) | |||
| 173 | + | case <-time.After(20 * time.Millisecond): | |||
| 174 | + | } | |||
| 175 | + | close(store.releaseGet) | |||
| 176 | + | require.NoError(t, <-touchDone) | |||
| 177 | + | require.NoError(t, <-saveDone) | |||
| 178 | + | ||||
| 179 | + | records, err := p.LoadAll() | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | require.Equal(t, []string{"new"}, records[0].TabNames) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 154 | func TestSavePropagatesSetErrorWithoutSync(t *testing.T) { | 184 | func TestSavePropagatesSetErrorWithoutSync(t *testing.T) { | ||
| 155 | store, state := newMockStore(t) | 185 | store, state := newMockStore(t) | ||
| 156 | state.errSet = errors.New("set failed") | 186 | state.errSet = errors.New("set failed") | ||
| @@ -170,6 +200,68 @@ type mockStoreState struct { | |||||
| 170 | errSet error | 200 | errSet error | ||
| 171 | } | 201 | } | ||
| 172 | 202 | ||||
| 203 | + | type blockingGetStore struct { | |||
| 204 | + | mu sync.Mutex | |||
| 205 | + | data map[string][]byte | |||
| 206 | + | block bool | |||
| 207 | + | getStarted chan struct{} | |||
| 208 | + | releaseGet chan struct{} | |||
| 209 | + | getStartOnce sync.Once | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func newBlockingGetStore() *blockingGetStore { | |||
| 213 | + | return &blockingGetStore{data: make(map[string][]byte), getStarted: make(chan struct{}), releaseGet: make(chan struct{})} | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (s *blockingGetStore) blockNextGet() { | |||
| 217 | + | s.mu.Lock() | |||
| 218 | + | s.block = true | |||
| 219 | + | s.mu.Unlock() | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func (s *blockingGetStore) Get(key []byte) ([]byte, bool) { | |||
| 223 | + | s.mu.Lock() | |||
| 224 | + | v, ok := s.data[string(key)] | |||
| 225 | + | v = append([]byte(nil), v...) | |||
| 226 | + | block := s.block | |||
| 227 | + | if block { | |||
| 228 | + | s.block = false | |||
| 229 | + | } | |||
| 230 | + | s.mu.Unlock() | |||
| 231 | + | if block { | |||
| 232 | + | s.getStartOnce.Do(func() { close(s.getStarted) }) | |||
| 233 | + | <-s.releaseGet | |||
| 234 | + | } | |||
| 235 | + | return v, ok | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (s *blockingGetStore) Set(key, value []byte) error { | |||
| 239 | + | s.mu.Lock() | |||
| 240 | + | s.data[string(key)] = append([]byte(nil), value...) | |||
| 241 | + | s.mu.Unlock() | |||
| 242 | + | return nil | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func (s *blockingGetStore) Delete(key []byte) error { | |||
| 246 | + | s.mu.Lock() | |||
| 247 | + | delete(s.data, string(key)) | |||
| 248 | + | s.mu.Unlock() | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func (s *blockingGetStore) Range(fn func(k, v []byte) bool) { | |||
| 253 | + | s.mu.Lock() | |||
| 254 | + | defer s.mu.Unlock() | |||
| 255 | + | for key, value := range s.data { | |||
| 256 | + | if !fn([]byte(key), append([]byte(nil), value...)) { | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func (*blockingGetStore) Sync() error { return nil } | |||
| 263 | + | func (*blockingGetStore) Close() error { return nil } | |||
| 264 | + | ||||
| 173 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | 265 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | ||
| 174 | t.Helper() | 266 | t.Helper() | ||
| 175 | state := &mockStoreState{data: make(map[string][]byte)} | 267 | state := &mockStoreState{data: make(map[string][]byte)} | ||
M internal/usecase/daemon/session.go +13 -1
| @@ -480,7 +480,11 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 480 | 480 | } | |
| 481 | 481 | lastUsedSeq := sess.mruAt.Load() | |
| 482 | 482 | if wasEphemeral || oldName != name { | |
| 483 | - | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil { | |
| 483 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) | |
| 484 | + | record.Name = name | |
| 485 | + | record.CreatedAt = createdAt | |
| 486 | + | record.LastUsedSeq = lastUsedSeq | |
| 487 | + | if err := d.persist.Save(record); err != nil { | |
| 484 | 488 | sess.mu.Unlock() | |
| 485 | 489 | return err | |
| 486 | 490 | } | |
| @@ -568,6 +572,11 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | |||
| 568 | 572 | } | |
| 569 | 573 | ||
| 570 | 574 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |
| 575 | + | d.mu.Lock() | |
| 576 | + | if d.sessions[sess.id] != sess { | |
| 577 | + | d.mu.Unlock() | |
| 578 | + | return | |
| 579 | + | } | |
| 571 | 580 | sess.mu.Lock() | |
| 572 | 581 | idx := -1 | |
| 573 | 582 | for i, w := range sess.tabs { | |
| @@ -578,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 578 | 587 | } | |
| 579 | 588 | if idx == -1 { | |
| 580 | 589 | sess.mu.Unlock() | |
| 590 | + | d.mu.Unlock() | |
| 581 | 591 | return | |
| 582 | 592 | } | |
| 583 | 593 | if len(sess.tabs) == 1 { | |
| 584 | 594 | name := sess.name | |
| 585 | 595 | sess.mu.Unlock() | |
| 596 | + | d.mu.Unlock() | |
| 586 | 597 | d.log.Info("tab closed", "session", name, "last", true) | |
| 587 | 598 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 588 | 599 | return | |
| @@ -604,6 +615,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 604 | 615 | } | |
| 605 | 616 | } | |
| 606 | 617 | sess.mu.Unlock() | |
| 618 | + | d.mu.Unlock() | |
| 607 | 619 | d.log.Info("tab closed", "session", name) | |
| 608 | 620 | markSnapshotDirty(sess) | |
| 609 | 621 | ||
| @@ -480,7 +480,11 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 480 | } | 480 | } | ||
| 481 | lastUsedSeq := sess.mruAt.Load() | 481 | lastUsedSeq := sess.mruAt.Load() | ||
| 482 | if wasEphemeral || oldName != name { | 482 | if wasEphemeral || oldName != name { | ||
| 483 | - | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil { | 483 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) |
| 484 | + | record.Name = name | |||
| 485 | + | record.CreatedAt = createdAt | |||
| 486 | + | record.LastUsedSeq = lastUsedSeq | |||
| 487 | + | if err := d.persist.Save(record); err != nil { | |||
| 484 | sess.mu.Unlock() | 488 | sess.mu.Unlock() | ||
| 485 | return err | 489 | return err | ||
| 486 | } | 490 | } | ||
| @@ -568,6 +572,11 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | |||||
| 568 | } | 572 | } | ||
| 569 | 573 | ||||
| 570 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | 574 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | ||
| 575 | + | d.mu.Lock() | |||
| 576 | + | if d.sessions[sess.id] != sess { | |||
| 577 | + | d.mu.Unlock() | |||
| 578 | + | return | |||
| 579 | + | } | |||
| 571 | sess.mu.Lock() | 580 | sess.mu.Lock() | ||
| 572 | idx := -1 | 581 | idx := -1 | ||
| 573 | for i, w := range sess.tabs { | 582 | for i, w := range sess.tabs { | ||
| @@ -578,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 578 | } | 587 | } | ||
| 579 | if idx == -1 { | 588 | if idx == -1 { | ||
| 580 | sess.mu.Unlock() | 589 | sess.mu.Unlock() | ||
| 590 | + | d.mu.Unlock() | |||
| 581 | return | 591 | return | ||
| 582 | } | 592 | } | ||
| 583 | if len(sess.tabs) == 1 { | 593 | if len(sess.tabs) == 1 { | ||
| 584 | name := sess.name | 594 | name := sess.name | ||
| 585 | sess.mu.Unlock() | 595 | sess.mu.Unlock() | ||
| 596 | + | d.mu.Unlock() | |||
| 586 | d.log.Info("tab closed", "session", name, "last", true) | 597 | d.log.Info("tab closed", "session", name, "last", true) | ||
| 587 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | 598 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | ||
| 588 | return | 599 | return | ||
| @@ -604,6 +615,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 604 | } | 615 | } | ||
| 605 | } | 616 | } | ||
| 606 | sess.mu.Unlock() | 617 | sess.mu.Unlock() | ||
| 618 | + | d.mu.Unlock() | |||
| 607 | d.log.Info("tab closed", "session", name) | 619 | d.log.Info("tab closed", "session", name) | ||
| 608 | markSnapshotDirty(sess) | 620 | markSnapshotDirty(sess) | ||
| 609 | 621 | ||||
M internal/usecase/daemon/session_test.go +10 -4
| @@ -799,21 +799,27 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) { | |||
| 799 | 799 | ||
| 800 | 800 | func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |
| 801 | 801 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 802 | - | p, release := newBlockingPTY(t) | |
| 803 | - | defer release() | |
| 802 | + | p1, release1 := newBlockingPTY(t) | |
| 803 | + | p2, release2 := newBlockingPTY(t) | |
| 804 | + | defer release1() | |
| 805 | + | defer release2() | |
| 804 | 806 | store, state := newMockStore(t) | |
| 805 | - | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 807 | + | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 806 | 808 | WithStore(store)(d) | |
| 807 | 809 | ||
| 808 | 810 | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}) | |
| 809 | 811 | require.NoError(t, err) | |
| 810 | 812 | require.True(t, state.has("work")) | |
| 813 | + | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |
| 814 | + | require.NoError(t, d.createTab(sess, sz)) | |
| 815 | + | require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs")) | |
| 811 | 816 | ||
| 812 | 817 | require.NoError(t, d.renameSession(sess, "renamed")) | |
| 813 | 818 | require.False(t, state.has("work")) | |
| 814 | 819 | require.True(t, state.has("renamed")) | |
| 820 | + | require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames) | |
| 815 | 821 | ||
| 816 | - | _ = d.killSession(sess, ports.ReasonSessionKilled, true) | |
| 822 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 817 | 823 | require.False(t, state.has("renamed")) | |
| 818 | 824 | } | |
| 819 | 825 | ||
| @@ -799,21 +799,27 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) { | |||||
| 799 | 799 | ||||
| 800 | func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | 800 | func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | ||
| 801 | sz := domain.Size{Cols: 80, Rows: 24} | 801 | sz := domain.Size{Cols: 80, Rows: 24} | ||
| 802 | - | p, release := newBlockingPTY(t) | 802 | + | p1, release1 := newBlockingPTY(t) |
| 803 | - | defer release() | 803 | + | p2, release2 := newBlockingPTY(t) |
| 804 | + | defer release1() | |||
| 805 | + | defer release2() | |||
| 804 | store, state := newMockStore(t) | 806 | store, state := newMockStore(t) | ||
| 805 | - | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | 807 | + | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) |
| 806 | WithStore(store)(d) | 808 | WithStore(store)(d) | ||
| 807 | 809 | ||||
| 808 | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}) | 810 | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}) | ||
| 809 | require.NoError(t, err) | 811 | require.NoError(t, err) | ||
| 810 | require.True(t, state.has("work")) | 812 | require.True(t, state.has("work")) | ||
| 813 | + | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |||
| 814 | + | require.NoError(t, d.createTab(sess, sz)) | |||
| 815 | + | require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs")) | |||
| 811 | 816 | ||||
| 812 | require.NoError(t, d.renameSession(sess, "renamed")) | 817 | require.NoError(t, d.renameSession(sess, "renamed")) | ||
| 813 | require.False(t, state.has("work")) | 818 | require.False(t, state.has("work")) | ||
| 814 | require.True(t, state.has("renamed")) | 819 | require.True(t, state.has("renamed")) | ||
| 820 | + | require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames) | |||
| 815 | 821 | ||||
| 816 | - | _ = d.killSession(sess, ports.ReasonSessionKilled, true) | 822 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) |
| 817 | require.False(t, state.has("renamed")) | 823 | require.False(t, state.has("renamed")) | ||
| 818 | } | 824 | } | ||
| 819 | 825 | ||||
M internal/usecase/daemon/snapshot.go +9 -4
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/internal/persist" | |
| 11 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 12 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| @@ -78,7 +77,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 78 | 77 | cancel() | |
| 79 | 78 | } | |
| 80 | 79 | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 81 | - | for _, tabSnap := range snap.Tabs { | |
| 80 | + | d.mu.Lock() | |
| 81 | + | persisted := d.stopped[snap.Name] | |
| 82 | + | d.mu.Unlock() | |
| 83 | + | for tabIndex, tabSnap := range snap.Tabs { | |
| 82 | 84 | if err := ctx.Err(); err != nil { | |
| 83 | 85 | closeOpened() | |
| 84 | 86 | return err | |
| @@ -107,6 +109,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 107 | 109 | } | |
| 108 | 110 | } | |
| 109 | 111 | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 112 | + | if tabIndex < len(persisted.tabNames) { | |
| 113 | + | tb.name = persisted.tabNames[tabIndex] | |
| 114 | + | } | |
| 110 | 115 | if tb.nextPaneID <= 0 { | |
| 111 | 116 | tb.nextPaneID = 1 | |
| 112 | 117 | } | |
| @@ -171,8 +176,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 171 | 176 | sess.mruAt.Store(stopped.lastUsedSeq) | |
| 172 | 177 | } | |
| 173 | 178 | d.mu.Unlock() | |
| 174 | - | lastUsedSeq := sess.mruAt.Load() | |
| 175 | - | if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil { | |
| 179 | + | record := sess.persistRecordLocked(createdAt) | |
| 180 | + | if err := d.persist.Save(record); err != nil { | |
| 176 | 181 | closeOpened() | |
| 177 | 182 | return err | |
| 178 | 183 | } | |
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | "github.com/bnema/vev/internal/persist" | |||
| 11 | scopy "github.com/bnema/vev/internal/usecase/copy" | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 12 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 13 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| @@ -78,7 +77,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 78 | cancel() | 77 | cancel() | ||
| 79 | } | 78 | } | ||
| 80 | allowlist := d.restoreProcessAllowlistSnapshot() | 79 | allowlist := d.restoreProcessAllowlistSnapshot() | ||
| 81 | - | for _, tabSnap := range snap.Tabs { | 80 | + | d.mu.Lock() |
| 81 | + | persisted := d.stopped[snap.Name] | |||
| 82 | + | d.mu.Unlock() | |||
| 83 | + | for tabIndex, tabSnap := range snap.Tabs { | |||
| 82 | if err := ctx.Err(); err != nil { | 84 | if err := ctx.Err(); err != nil { | ||
| 83 | closeOpened() | 85 | closeOpened() | ||
| 84 | return err | 86 | return err | ||
| @@ -107,6 +109,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 107 | } | 109 | } | ||
| 108 | } | 110 | } | ||
| 109 | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | 111 | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | ||
| 112 | + | if tabIndex < len(persisted.tabNames) { | |||
| 113 | + | tb.name = persisted.tabNames[tabIndex] | |||
| 114 | + | } | |||
| 110 | if tb.nextPaneID <= 0 { | 115 | if tb.nextPaneID <= 0 { | ||
| 111 | tb.nextPaneID = 1 | 116 | tb.nextPaneID = 1 | ||
| 112 | } | 117 | } | ||
| @@ -171,8 +176,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 171 | sess.mruAt.Store(stopped.lastUsedSeq) | 176 | sess.mruAt.Store(stopped.lastUsedSeq) | ||
| 172 | } | 177 | } | ||
| 173 | d.mu.Unlock() | 178 | d.mu.Unlock() | ||
| 174 | - | lastUsedSeq := sess.mruAt.Load() | 179 | + | record := sess.persistRecordLocked(createdAt) |
| 175 | - | if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil { | 180 | + | if err := d.persist.Save(record); err != nil { |
| 176 | closeOpened() | 181 | closeOpened() | ||
| 177 | return err | 182 | return err | ||
| 178 | } | 183 | } | ||
M internal/usecase/daemon/snapshot_restore_test.go +5 -0
| @@ -44,7 +44,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 44 | 44 | })}} | |
| 45 | 45 | factory := &restorePTYFactory{} | |
| 46 | 46 | d := newTestDaemon(t, factory, stubClock{}) | |
| 47 | + | persistStore, persistState := newMockStore(t) | |
| 48 | + | WithStore(persistStore)(d) | |
| 47 | 49 | WithSnapshotStore(store)(d) | |
| 50 | + | d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}} | |
| 48 | 51 | ||
| 49 | 52 | d.restoreSnapshots(context.Background()) | |
| 50 | 53 | ||
| @@ -66,6 +69,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 66 | 69 | require.Len(t, restored.tabs, 1) | |
| 67 | 70 | tb := restored.tabs[0] | |
| 68 | 71 | restored.mu.Unlock() | |
| 72 | + | require.Equal(t, "logs", tb.name) | |
| 73 | + | require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames) | |
| 69 | 74 | tb.mu.Lock() | |
| 70 | 75 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 71 | 76 | require.Equal(t, 3, tb.nextPaneID) | |
| @@ -44,7 +44,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 44 | })}} | 44 | })}} | ||
| 45 | factory := &restorePTYFactory{} | 45 | factory := &restorePTYFactory{} | ||
| 46 | d := newTestDaemon(t, factory, stubClock{}) | 46 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 47 | + | persistStore, persistState := newMockStore(t) | |||
| 48 | + | WithStore(persistStore)(d) | |||
| 47 | WithSnapshotStore(store)(d) | 49 | WithSnapshotStore(store)(d) | ||
| 50 | + | d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}} | |||
| 48 | 51 | ||||
| 49 | d.restoreSnapshots(context.Background()) | 52 | d.restoreSnapshots(context.Background()) | ||
| 50 | 53 | ||||
| @@ -66,6 +69,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 66 | require.Len(t, restored.tabs, 1) | 69 | require.Len(t, restored.tabs, 1) | ||
| 67 | tb := restored.tabs[0] | 70 | tb := restored.tabs[0] | ||
| 68 | restored.mu.Unlock() | 71 | restored.mu.Unlock() | ||
| 72 | + | require.Equal(t, "logs", tb.name) | |||
| 73 | + | require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames) | |||
| 69 | tb.mu.Lock() | 74 | tb.mu.Lock() | ||
| 70 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | 75 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | ||
| 71 | require.Equal(t, 3, tb.nextPaneID) | 76 | require.Equal(t, 3, tb.nextPaneID) | ||