detached 0620ced

fix(tabs): preserve names across persistence paths

commit
0620ced8c24220ae0922bfbdfd7f81a9bd080fc4
author
brice · · 2026-07-09 18:43 UTC
parent
ce49e6a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +143 -9
M internal/persist/persist.go +14 -0
@@ -4,6 +4,7 @@ import (
44 "log/slog"
55 "path/filepath"
66 "sort"
7+ "sync"
78
89 "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/pkg/kv"
@@ -20,6 +21,7 @@ func StorePath(dir string) string {
2021 // operations no-ops, so callers can keep persistence optional.
2122 type Persister struct {
2223 store ports.Store
24+ mu sync.Mutex
2325 }
2426
2527 // Open opens the session persister under dir.
@@ -50,6 +52,8 @@ func (p *Persister) Save(r Record) error {
5052 if p == nil || p.store == nil {
5153 return nil
5254 }
55+ p.mu.Lock()
56+ defer p.mu.Unlock()
5357 value, err := encodeRecordValue(r)
5458 if err != nil {
5559 return err
@@ -68,6 +72,8 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
6872 if name == "" {
6973 return errEmptyName
7074 }
75+ p.mu.Lock()
76+ defer p.mu.Unlock()
7177
7278 createdAt := at
7379 lastUsedSeq := uint64(0)
@@ -95,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
95101 if name == "" {
96102 return errEmptyName
97103 }
104+ p.mu.Lock()
105+ defer p.mu.Unlock()
98106
99107 r := Record{Name: name, LastUsedSeq: lastUsedSeq}
100108 if v, ok := p.store.Get([]byte(name)); ok {
@@ -118,6 +126,8 @@ func (p *Persister) Delete(name string) error {
118126 if name == "" {
119127 return errEmptyName
120128 }
129+ p.mu.Lock()
130+ defer p.mu.Unlock()
121131 if err := p.store.Delete([]byte(name)); err != nil {
122132 return err
123133 }
@@ -129,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) {
129139 if p == nil || p.store == nil {
130140 return nil, nil
131141 }
142+ p.mu.Lock()
143+ defer p.mu.Unlock()
132144 data := make(map[string][]byte)
133145 p.store.Range(func(k, v []byte) bool {
134146 data[string(k)] = append([]byte(nil), v...)
@@ -142,6 +154,8 @@ func (p *Persister) Close() error {
142154 if p == nil || p.store == nil {
143155 return nil
144156 }
157+ p.mu.Lock()
158+ defer p.mu.Unlock()
145159 return p.store.Close()
146160 }
147161
@@ -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.Store23 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 nil53 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 err59 return err
@@ -68,6 +72,8 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
68 if name == "" {72 if name == "" {
69 return errEmptyName73 return errEmptyName
70 }74 }
75+ p.mu.Lock()
76+ defer p.mu.Unlock()
71 77
72 createdAt := at78 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 errEmptyName102 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 errEmptyName127 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 err132 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, nil140 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 nil155 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
33 import (
44 "errors"
55 "path/filepath"
6+ "sync"
67 "testing"
8+ "time"
79
810 "github.com/stretchr/testify/mock"
911 "github.com/stretchr/testify/require"
@@ -151,6 +153,34 @@ func TestSyncBehavior(t *testing.T) {
151153 require.Equal(t, 2, state.syncs)
152154 }
153155
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+
154184 func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {
155185 store, state := newMockStore(t)
156186 state.errSet = errors.New("set failed")
@@ -170,6 +200,68 @@ type mockStoreState struct {
170200 errSet error
171201 }
172202
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+
173265 func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
174266 t.Helper()
175267 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 error200 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 {
480480 }
481481 lastUsedSeq := sess.mruAt.Load()
482482 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 {
484488 sess.mu.Unlock()
485489 return err
486490 }
@@ -568,6 +572,11 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
568572 }
569573
570574 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+ }
571580 sess.mu.Lock()
572581 idx := -1
573582 for i, w := range sess.tabs {
@@ -578,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
578587 }
579588 if idx == -1 {
580589 sess.mu.Unlock()
590+ d.mu.Unlock()
581591 return
582592 }
583593 if len(sess.tabs) == 1 {
584594 name := sess.name
585595 sess.mu.Unlock()
596+ d.mu.Unlock()
586597 d.log.Info("tab closed", "session", name, "last", true)
587598 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
588599 return
@@ -604,6 +615,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
604615 }
605616 }
606617 sess.mu.Unlock()
618+ d.mu.Unlock()
607619 d.log.Info("tab closed", "session", name)
608620 markSnapshotDirty(sess)
609621
@@ -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 err489 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 := -1581 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 return591 return
582 }592 }
583 if len(sess.tabs) == 1 {593 if len(sess.tabs) == 1 {
584 name := sess.name594 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 return599 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) {
799799
800800 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
801801 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()
804806 store, state := newMockStore(t)
805- d := newTestDaemon(t, newFactory(t, p), stubClock{})
807+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
806808 WithStore(store)(d)
807809
808810 sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
809811 require.NoError(t, err)
810812 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"))
811816
812817 require.NoError(t, d.renameSession(sess, "renamed"))
813818 require.False(t, state.has("work"))
814819 require.True(t, state.has("renamed"))
820+ require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames)
815821
816- _ = d.killSession(sess, ports.ReasonSessionKilled, true)
822+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
817823 require.False(t, state.has("renamed"))
818824 }
819825
@@ -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 (
77 "time"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/persist"
1110 scopy "github.com/bnema/vev/internal/usecase/copy"
1211 "github.com/bnema/vev/internal/usecase/layout"
1312 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -78,7 +77,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
7877 cancel()
7978 }
8079 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 {
8284 if err := ctx.Err(); err != nil {
8385 closeOpened()
8486 return err
@@ -107,6 +109,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
107109 }
108110 }
109111 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+ }
110115 if tb.nextPaneID <= 0 {
111116 tb.nextPaneID = 1
112117 }
@@ -171,8 +176,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
171176 sess.mruAt.Store(stopped.lastUsedSeq)
172177 }
173178 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 {
176181 closeOpened()
177182 return err
178183 }
@@ -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 err86 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 = 1116 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 err182 return err
178 }183 }
M internal/usecase/daemon/snapshot_restore_test.go +5 -0
@@ -44,7 +44,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
4444 })}}
4545 factory := &restorePTYFactory{}
4646 d := newTestDaemon(t, factory, stubClock{})
47+ persistStore, persistState := newMockStore(t)
48+ WithStore(persistStore)(d)
4749 WithSnapshotStore(store)(d)
50+ d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}}
4851
4952 d.restoreSnapshots(context.Background())
5053
@@ -66,6 +69,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
6669 require.Len(t, restored.tabs, 1)
6770 tb := restored.tabs[0]
6871 restored.mu.Unlock()
72+ require.Equal(t, "logs", tb.name)
73+ require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames)
6974 tb.mu.Lock()
7075 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
7176 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)

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