detached 0a056ef

Merge pull request #8 from bnema/feat/rnt-rename-tab

feat(tabs): add rename-tab action
commit
0a056ef3332ac766ea7dfeef695510121b65ef93
author
bnema · · 2026-07-09 19:03 UTC
committer
GitHub · · 2026-07-09 19:03 UTC
merge of
7cc07d9 + aeb442f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +596 -50
M README.md +1 -0
@@ -173,6 +173,7 @@ Successful commands are promoted near the top the next time the palette opens.
173173 | SSP | open session picker |
174174 | VIS | enter visual mode |
175175 | RNS | rename session |
176+| RNT | rename current tab |
176177 | DET | detach |
177178
178179 Rename prompts are prefilled with the current session name. Empty names and names already used by another session are rejected.
@@ -173,6 +173,7 @@ Successful commands are promoted near the top the next time the palette opens.
173 | SSP | open session picker |173 | SSP | open session picker |
174 | VIS | enter visual mode |174 | VIS | enter visual mode |
175 | RNS | rename session |175 | RNS | rename session |
176+| RNT | rename current tab |
176 | DET | detach |177 | DET | detach |
177 178
178 Rename prompts are prefilled with the current session name. Empty names and names already used by another session are rejected.179 Rename prompts are prefilled with the current session name. Empty names and names already used by another session are rejected.
M internal/persist/persist.go +17 -1
@@ -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,17 +72,21 @@ 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)
80+ var tabNames []string
7481 if v, ok := p.store.Get([]byte(name)); ok {
7582 if r, err := decodeRecordValue(name, v); err == nil {
7683 createdAt = r.CreatedAt
7784 lastUsedSeq = r.LastUsedSeq
85+ tabNames = r.TabNames
7886 }
7987 }
8088
81- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq})
89+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq, TabNames: tabNames})
8290 if err != nil {
8391 return err
8492 }
@@ -93,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
93101 if name == "" {
94102 return errEmptyName
95103 }
104+ p.mu.Lock()
105+ defer p.mu.Unlock()
96106
97107 r := Record{Name: name, LastUsedSeq: lastUsedSeq}
98108 if v, ok := p.store.Get([]byte(name)); ok {
@@ -116,6 +126,8 @@ func (p *Persister) Delete(name string) error {
116126 if name == "" {
117127 return errEmptyName
118128 }
129+ p.mu.Lock()
130+ defer p.mu.Unlock()
119131 if err := p.store.Delete([]byte(name)); err != nil {
120132 return err
121133 }
@@ -127,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) {
127139 if p == nil || p.store == nil {
128140 return nil, nil
129141 }
142+ p.mu.Lock()
143+ defer p.mu.Unlock()
130144 data := make(map[string][]byte)
131145 p.store.Range(func(k, v []byte) bool {
132146 data[string(k)] = append([]byte(nil), v...)
@@ -140,6 +154,8 @@ func (p *Persister) Close() error {
140154 if p == nil || p.store == nil {
141155 return nil
142156 }
157+ p.mu.Lock()
158+ defer p.mu.Unlock()
143159 return p.store.Close()
144160 }
145161
@@ -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,17 +72,21 @@ 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)
80+ var tabNames []string
74 if v, ok := p.store.Get([]byte(name)); ok {81 if v, ok := p.store.Get([]byte(name)); ok {
75 if r, err := decodeRecordValue(name, v); err == nil {82 if r, err := decodeRecordValue(name, v); err == nil {
76 createdAt = r.CreatedAt83 createdAt = r.CreatedAt
77 lastUsedSeq = r.LastUsedSeq84 lastUsedSeq = r.LastUsedSeq
85+ tabNames = r.TabNames
78 }86 }
79 }87 }
80 88
81- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq})89+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq, TabNames: tabNames})
82 if err != nil {90 if err != nil {
83 return err91 return err
84 }92 }
@@ -93,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
93 if name == "" {101 if name == "" {
94 return errEmptyName102 return errEmptyName
95 }103 }
104+ p.mu.Lock()
105+ defer p.mu.Unlock()
96 106
97 r := Record{Name: name, LastUsedSeq: lastUsedSeq}107 r := Record{Name: name, LastUsedSeq: lastUsedSeq}
98 if v, ok := p.store.Get([]byte(name)); ok {108 if v, ok := p.store.Get([]byte(name)); ok {
@@ -116,6 +126,8 @@ func (p *Persister) Delete(name string) error {
116 if name == "" {126 if name == "" {
117 return errEmptyName127 return errEmptyName
118 }128 }
129+ p.mu.Lock()
130+ defer p.mu.Unlock()
119 if err := p.store.Delete([]byte(name)); err != nil {131 if err := p.store.Delete([]byte(name)); err != nil {
120 return err132 return err
121 }133 }
@@ -127,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) {
127 if p == nil || p.store == nil {139 if p == nil || p.store == nil {
128 return nil, nil140 return nil, nil
129 }141 }
142+ p.mu.Lock()
143+ defer p.mu.Unlock()
130 data := make(map[string][]byte)144 data := make(map[string][]byte)
131 p.store.Range(func(k, v []byte) bool {145 p.store.Range(func(k, v []byte) bool {
132 data[string(k)] = append([]byte(nil), v...)146 data[string(k)] = append([]byte(nil), v...)
@@ -140,6 +154,8 @@ func (p *Persister) Close() error {
140 if p == nil || p.store == nil {154 if p == nil || p.store == nil {
141 return nil155 return nil
142 }156 }
157+ p.mu.Lock()
158+ defer p.mu.Unlock()
143 return p.store.Close()159 return p.store.Close()
144 }160 }
145 161
M internal/persist/persist_test.go +96 -4
@@ -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"
@@ -17,7 +19,7 @@ func privateDir(t *testing.T) string {
1719 }
1820
1921 func TestRecordCodecRoundTrip(t *testing.T) {
20- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
22+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33, TabNames: []string{"shell", "logs"}}
2123 value, err := encodeRecordValue(r)
2224 require.NoError(t, err)
2325
@@ -81,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
8183 p, err := Open(dir)
8284 require.NoError(t, err)
8385
84- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))
86+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40, TabNames: []string{"shell"}}))
8587 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
8688 require.NoError(t, p.Touch("b", "/b/next", 30))
8789 require.NoError(t, p.TouchMRU("b", 60))
@@ -90,13 +92,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
9092 require.NoError(t, err)
9193 require.Equal(t, []Record{
9294 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
93- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},
95+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}},
9496 }, records)
9597
9698 require.NoError(t, p.Delete("a"))
9799 records, err = p.LoadAll()
98100 require.NoError(t, err)
99- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)
101+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}}}, records)
100102 require.NoError(t, p.Close())
101103 }
102104
@@ -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"
@@ -17,7 +19,7 @@ func privateDir(t *testing.T) string {
17 }19 }
18 20
19 func TestRecordCodecRoundTrip(t *testing.T) {21 func TestRecordCodecRoundTrip(t *testing.T) {
20- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}22+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33, TabNames: []string{"shell", "logs"}}
21 value, err := encodeRecordValue(r)23 value, err := encodeRecordValue(r)
22 require.NoError(t, err)24 require.NoError(t, err)
23 25
@@ -81,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
81 p, err := Open(dir)83 p, err := Open(dir)
82 require.NoError(t, err)84 require.NoError(t, err)
83 85
84- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))86+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40, TabNames: []string{"shell"}}))
85 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))87 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
86 require.NoError(t, p.Touch("b", "/b/next", 30))88 require.NoError(t, p.Touch("b", "/b/next", 30))
87 require.NoError(t, p.TouchMRU("b", 60))89 require.NoError(t, p.TouchMRU("b", 60))
@@ -90,13 +92,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
90 require.NoError(t, err)92 require.NoError(t, err)
91 require.Equal(t, []Record{93 require.Equal(t, []Record{
92 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},94 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
93- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},95+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}},
94 }, records)96 }, records)
95 97
96 require.NoError(t, p.Delete("a"))98 require.NoError(t, p.Delete("a"))
97 records, err = p.LoadAll()99 records, err = p.LoadAll()
98 require.NoError(t, err)100 require.NoError(t, err)
99- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)101+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}}}, records)
100 require.NoError(t, p.Close())102 require.NoError(t, p.Close())
101 }103 }
102 104
@@ -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/persist/record.go +60 -3
@@ -14,6 +14,7 @@ type Record struct {
1414 CreatedAt int64
1515 UpdatedAt int64
1616 LastUsedSeq uint64
17+ TabNames []string
1718 }
1819
1920 var (
@@ -28,14 +29,33 @@ func encodeRecordValue(r Record) ([]byte, error) {
2829 if uint64(len(r.Cwd)) > math.MaxUint32 {
2930 return nil, fmt.Errorf("persist: cwd too large")
3031 }
32+ if uint64(len(r.TabNames)) > math.MaxUint32 {
33+ return nil, fmt.Errorf("persist: too many tab names")
34+ }
35+ tabNamesSize := 4
36+ for _, name := range r.TabNames {
37+ if uint64(len(name)) > math.MaxUint32 {
38+ return nil, fmt.Errorf("persist: tab name too large")
39+ }
40+ tabNamesSize += 4 + len(name)
41+ }
3142
32- buf := make([]byte, 4+len(r.Cwd)+8+8+8)
43+ buf := make([]byte, 4+len(r.Cwd)+8+8+8+tabNamesSize)
3344 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
3445 copy(buf[4:], r.Cwd)
3546 off := 4 + len(r.Cwd)
3647 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
3748 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
3849 binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
50+ off += 24
51+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
52+ off += 4
53+ for _, name := range r.TabNames {
54+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
55+ off += 4
56+ copy(buf[off:], name)
57+ off += len(name)
58+ }
3959 return buf, nil
4060 }
4161
@@ -52,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
5272 return Record{}, errMalformedRecord
5373 }
5474 cwdLen := int(cwdLen32)
55- if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 {
75+ baseLen := 4 + cwdLen + 8 + 8
76+ if len(value) < baseLen {
5677 return Record{}, errMalformedRecord
5778 }
5879 off := 4 + cwdLen
@@ -62,8 +83,44 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
6283 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
6384 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
6485 }
65- if len(value) == 4+cwdLen+8+8+8 {
86+ if len(value) == baseLen {
87+ return r, nil
88+ }
89+ if len(value) < baseLen+8 {
90+ return Record{}, errMalformedRecord
91+ }
92+ if len(value) == baseLen+8 {
6693 r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
94+ return r, nil
95+ }
96+ r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
97+ off += 24
98+ if len(value)-off < 4 {
99+ return Record{}, errMalformedRecord
100+ }
101+ tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
102+ off += 4
103+ if uint64(tabCount32) > uint64(len(value)-off)/4 {
104+ return Record{}, errMalformedRecord
105+ }
106+ if tabCount32 > 0 {
107+ r.TabNames = make([]string, 0, int(tabCount32))
108+ }
109+ for range tabCount32 {
110+ if len(value)-off < 4 {
111+ return Record{}, errMalformedRecord
112+ }
113+ nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
114+ off += 4
115+ if uint64(nameLen32) > uint64(len(value)-off) {
116+ return Record{}, errMalformedRecord
117+ }
118+ nameLen := int(nameLen32)
119+ r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
120+ off += nameLen
121+ }
122+ if off != len(value) {
123+ return Record{}, errMalformedRecord
67124 }
68125 return r, nil
69126 }
@@ -14,6 +14,7 @@ type Record struct {
14 CreatedAt int6414 CreatedAt int64
15 UpdatedAt int6415 UpdatedAt int64
16 LastUsedSeq uint6416 LastUsedSeq uint64
17+ TabNames []string
17 }18 }
18 19
19 var (20 var (
@@ -28,14 +29,33 @@ func encodeRecordValue(r Record) ([]byte, error) {
28 if uint64(len(r.Cwd)) > math.MaxUint32 {29 if uint64(len(r.Cwd)) > math.MaxUint32 {
29 return nil, fmt.Errorf("persist: cwd too large")30 return nil, fmt.Errorf("persist: cwd too large")
30 }31 }
32+ if uint64(len(r.TabNames)) > math.MaxUint32 {
33+ return nil, fmt.Errorf("persist: too many tab names")
34+ }
35+ tabNamesSize := 4
36+ for _, name := range r.TabNames {
37+ if uint64(len(name)) > math.MaxUint32 {
38+ return nil, fmt.Errorf("persist: tab name too large")
39+ }
40+ tabNamesSize += 4 + len(name)
41+ }
31 42
32- buf := make([]byte, 4+len(r.Cwd)+8+8+8)43+ buf := make([]byte, 4+len(r.Cwd)+8+8+8+tabNamesSize)
33 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))44 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
34 copy(buf[4:], r.Cwd)45 copy(buf[4:], r.Cwd)
35 off := 4 + len(r.Cwd)46 off := 4 + len(r.Cwd)
36 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))47 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
37 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))48 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
38 binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)49 binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
50+ off += 24
51+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
52+ off += 4
53+ for _, name := range r.TabNames {
54+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
55+ off += 4
56+ copy(buf[off:], name)
57+ off += len(name)
58+ }
39 return buf, nil59 return buf, nil
40 }60 }
41 61
@@ -52,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
52 return Record{}, errMalformedRecord72 return Record{}, errMalformedRecord
53 }73 }
54 cwdLen := int(cwdLen32)74 cwdLen := int(cwdLen32)
55- if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 {75+ baseLen := 4 + cwdLen + 8 + 8
76+ if len(value) < baseLen {
56 return Record{}, errMalformedRecord77 return Record{}, errMalformedRecord
57 }78 }
58 off := 4 + cwdLen79 off := 4 + cwdLen
@@ -62,8 +83,44 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
62 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),83 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
63 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),84 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
64 }85 }
65- if len(value) == 4+cwdLen+8+8+8 {86+ if len(value) == baseLen {
87+ return r, nil
88+ }
89+ if len(value) < baseLen+8 {
90+ return Record{}, errMalformedRecord
91+ }
92+ if len(value) == baseLen+8 {
66 r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])93 r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
94+ return r, nil
95+ }
96+ r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
97+ off += 24
98+ if len(value)-off < 4 {
99+ return Record{}, errMalformedRecord
100+ }
101+ tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
102+ off += 4
103+ if uint64(tabCount32) > uint64(len(value)-off)/4 {
104+ return Record{}, errMalformedRecord
105+ }
106+ if tabCount32 > 0 {
107+ r.TabNames = make([]string, 0, int(tabCount32))
108+ }
109+ for range tabCount32 {
110+ if len(value)-off < 4 {
111+ return Record{}, errMalformedRecord
112+ }
113+ nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
114+ off += 4
115+ if uint64(nameLen32) > uint64(len(value)-off) {
116+ return Record{}, errMalformedRecord
117+ }
118+ nameLen := int(nameLen32)
119+ r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
120+ off += nameLen
121+ }
122+ if off != len(value) {
123+ return Record{}, errMalformedRecord
67 }124 }
68 return r, nil125 return r, nil
69 }126 }
M internal/usecase/command/command.go +1 -0
@@ -23,6 +23,7 @@ type Context interface {
2323 Detach() error
2424 EnterVisualMode() error
2525 RenameSession() error
26+ RenameTab() error
2627 OpenSessionPicker() error
2728 }
2829
@@ -23,6 +23,7 @@ type Context interface {
23 Detach() error23 Detach() error
24 EnterVisualMode() error24 EnterVisualMode() error
25 RenameSession() error25 RenameSession() error
26+ RenameTab() error
26 OpenSessionPicker() error27 OpenSessionPicker() error
27 }28 }
28 29
M internal/usecase/command/context_mock_test.go +44 -0
@@ -739,6 +739,50 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo
739739 return _c
740740 }
741741
742+// RenameTab provides a mock function for the type MockContext
743+func (_mock *MockContext) RenameTab() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for RenameTab")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_RenameTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameTab'
760+type MockContext_RenameTab_Call struct {
761+ *mock.Call
762+}
763+
764+// RenameTab is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) RenameTab() *MockContext_RenameTab_Call {
766+ return &MockContext_RenameTab_Call{Call: _e.mock.On("RenameTab")}
767+}
768+
769+func (_c *MockContext_RenameTab_Call) Run(run func()) *MockContext_RenameTab_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_RenameTab_Call) Return(err error) *MockContext_RenameTab_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContext_RenameTab_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
742786 // SplitDown provides a mock function for the type MockContext
743787 func (_mock *MockContext) SplitDown() error {
744788 ret := _mock.Called()
@@ -739,6 +739,50 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo
739 return _c739 return _c
740 }740 }
741 741
742+// RenameTab provides a mock function for the type MockContext
743+func (_mock *MockContext) RenameTab() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for RenameTab")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_RenameTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameTab'
760+type MockContext_RenameTab_Call struct {
761+ *mock.Call
762+}
763+
764+// RenameTab is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) RenameTab() *MockContext_RenameTab_Call {
766+ return &MockContext_RenameTab_Call{Call: _e.mock.On("RenameTab")}
767+}
768+
769+func (_c *MockContext_RenameTab_Call) Run(run func()) *MockContext_RenameTab_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_RenameTab_Call) Return(err error) *MockContext_RenameTab_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContext_RenameTab_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
742 // SplitDown provides a mock function for the type MockContext786 // SplitDown provides a mock function for the type MockContext
743 func (_mock *MockContext) SplitDown() error {787 func (_mock *MockContext) SplitDown() error {
744 ret := _mock.Called()788 ret := _mock.Called()
M internal/usecase/command/registry.go +1 -0
@@ -26,6 +26,7 @@ func Registry() []Command {
2626 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
2727 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
2828 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
2930 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
3031 }
3132
@@ -26,6 +26,7 @@ func Registry() []Command {
26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
29 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},30 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
30 }31 }
31 32
M internal/usecase/command/registry_test.go +2 -1
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -79,6 +79,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
7979 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
8080 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
8181 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
82+ {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
8283 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
8384 }
8485
@@ -7,7 +7,7 @@ import (
7 7
8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9 commands := Registry()9 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
11 11
12 if len(commands) != len(wantCodes) {12 if len(commands) != len(wantCodes) {
13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -79,6 +79,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
79 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},79 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
80 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},80 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
81 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},81 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
82+ {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
82 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},83 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
83 }84 }
84 85
M internal/usecase/daemon/daemon.go +15 -4
@@ -31,6 +31,7 @@ import (
3131 "errors"
3232 "log/slog"
3333 "os"
34+ "slices"
3435 "sort"
3536 "sync"
3637 "sync/atomic"
@@ -163,9 +164,19 @@ type stoppedSession struct {
163164 cwd string
164165 createdAt int64
165166 lastUsedSeq uint64
167+ tabNames []string
166168 purging bool
167169 }
168170
171+func (s stoppedSession) same(other stoppedSession) bool {
172+ return s.name == other.name &&
173+ s.cwd == other.cwd &&
174+ s.createdAt == other.createdAt &&
175+ s.lastUsedSeq == other.lastUsedSeq &&
176+ s.purging == other.purging &&
177+ slices.Equal(s.tabNames, other.tabNames)
178+}
179+
169180 type Option func(*Daemon)
170181
171182 // WithShell overrides the command (and its args) each session spawns. The
@@ -320,7 +331,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
320331 } else {
321332 var maxSeq uint64
322333 for _, r := range records {
323- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
334+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
324335 if r.LastUsedSeq > maxSeq {
325336 maxSeq = r.LastUsedSeq
326337 }
@@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
537548 return
538549 }
539550 d.mu.Lock()
540- if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
551+ if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
541552 delete(d.stopped, k.Name)
542553 }
543554 d.mu.Unlock()
@@ -647,7 +658,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
647658 }
648659 cwd := d.dirOrHome(stopped.cwd)
649660 var err error
650- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
661+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
651662 if err != nil {
652663 d.mu.Unlock()
653664 return nil, nil, err
@@ -715,7 +726,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
715726 }
716727 cwd := d.dirOrHome(stopped.cwd)
717728 var err error
718- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
729+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
719730 if err != nil {
720731 d.mu.Unlock()
721732 return nil, nil, err
@@ -31,6 +31,7 @@ import (
31 "errors"31 "errors"
32 "log/slog"32 "log/slog"
33 "os"33 "os"
34+ "slices"
34 "sort"35 "sort"
35 "sync"36 "sync"
36 "sync/atomic"37 "sync/atomic"
@@ -163,9 +164,19 @@ type stoppedSession struct {
163 cwd string164 cwd string
164 createdAt int64165 createdAt int64
165 lastUsedSeq uint64166 lastUsedSeq uint64
167+ tabNames []string
166 purging bool168 purging bool
167 }169 }
168 170
171+func (s stoppedSession) same(other stoppedSession) bool {
172+ return s.name == other.name &&
173+ s.cwd == other.cwd &&
174+ s.createdAt == other.createdAt &&
175+ s.lastUsedSeq == other.lastUsedSeq &&
176+ s.purging == other.purging &&
177+ slices.Equal(s.tabNames, other.tabNames)
178+}
179+
169 type Option func(*Daemon)180 type Option func(*Daemon)
170 181
171 // WithShell overrides the command (and its args) each session spawns. The182 // WithShell overrides the command (and its args) each session spawns. The
@@ -320,7 +331,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
320 } else {331 } else {
321 var maxSeq uint64332 var maxSeq uint64
322 for _, r := range records {333 for _, r := range records {
323- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}334+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
324 if r.LastUsedSeq > maxSeq {335 if r.LastUsedSeq > maxSeq {
325 maxSeq = r.LastUsedSeq336 maxSeq = r.LastUsedSeq
326 }337 }
@@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
537 return548 return
538 }549 }
539 d.mu.Lock()550 d.mu.Lock()
540- if cur, ok := d.stopped[k.Name]; ok && cur == stopped {551+ if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
541 delete(d.stopped, k.Name)552 delete(d.stopped, k.Name)
542 }553 }
543 d.mu.Unlock()554 d.mu.Unlock()
@@ -647,7 +658,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
647 }658 }
648 cwd := d.dirOrHome(stopped.cwd)659 cwd := d.dirOrHome(stopped.cwd)
649 var err error660 var err error
650- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)661+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
651 if err != nil {662 if err != nil {
652 d.mu.Unlock()663 d.mu.Unlock()
653 return nil, nil, err664 return nil, nil, err
@@ -715,7 +726,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
715 }726 }
716 cwd := d.dirOrHome(stopped.cwd)727 cwd := d.dirOrHome(stopped.cwd)
717 var err error728 var err error
718- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)729+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
719 if err != nil {730 if err != nil {
720 d.mu.Unlock()731 d.mu.Unlock()
721 return nil, nil, err732 return nil, nil, err
M internal/usecase/daemon/input_test.go +26 -0
@@ -641,6 +641,32 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
641641 require.Equal(t, "0", sess.name)
642642 }
643643
644+func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
645+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
646+ defer func() {
647+ for _, release := range releases {
648+ release()
649+ }
650+ }()
651+ sess.active = 1
652+
653+ d.handleInput(sess, ac, []byte("\x1b "))
654+ awaitFrame(t, sends, ports.MsgOutput)
655+ d.handleInput(sess, ac, []byte("RNT\r"))
656+ out := awaitFrame(t, sends, ports.MsgOutput)
657+ require.True(t, ac.overlays.promptActive())
658+ msg, err := ports.UnmarshalOutput(out.Payload)
659+ require.NoError(t, err)
660+ require.Contains(t, string(msg.Data), "> 2")
661+
662+ d.handleInput(sess, ac, []byte("\x7flogs\r"))
663+ awaitFrame(t, sends, ports.MsgOutput)
664+
665+ require.False(t, ac.overlays.promptActive())
666+ require.Equal(t, "logs", sess.tabs[1].name)
667+ require.Empty(t, sess.tabs[0].name)
668+}
669+
644670 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
645671 writes := make(chan []byte, 4)
646672 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -641,6 +641,32 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
641 require.Equal(t, "0", sess.name)641 require.Equal(t, "0", sess.name)
642 }642 }
643 643
644+func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
645+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
646+ defer func() {
647+ for _, release := range releases {
648+ release()
649+ }
650+ }()
651+ sess.active = 1
652+
653+ d.handleInput(sess, ac, []byte("\x1b "))
654+ awaitFrame(t, sends, ports.MsgOutput)
655+ d.handleInput(sess, ac, []byte("RNT\r"))
656+ out := awaitFrame(t, sends, ports.MsgOutput)
657+ require.True(t, ac.overlays.promptActive())
658+ msg, err := ports.UnmarshalOutput(out.Payload)
659+ require.NoError(t, err)
660+ require.Contains(t, string(msg.Data), "> 2")
661+
662+ d.handleInput(sess, ac, []byte("\x7flogs\r"))
663+ awaitFrame(t, sends, ports.MsgOutput)
664+
665+ require.False(t, ac.overlays.promptActive())
666+ require.Equal(t, "logs", sess.tabs[1].name)
667+ require.Empty(t, sess.tabs[0].name)
668+}
669+
644 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {670 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
645 writes := make(chan []byte, 4)671 writes := make(chan []byte, 4)
646 p, _ := newBlockingPTYWithWrites(t, writes)672 p, _ := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/nil_pty_test.go +1 -0
@@ -18,6 +18,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
1818 ctx, cancel := context.WithCancel(context.Background())
1919 defer cancel()
2020 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ d.sessions[sess.id] = sess
2122 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
2223 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
2324 sess.tabs = []*tab{first, second}
@@ -18,6 +18,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
18 ctx, cancel := context.WithCancel(context.Background())18 ctx, cancel := context.WithCancel(context.Background())
19 defer cancel()19 defer cancel()
20 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}20 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ d.sessions[sess.id] = sess
21 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})22 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
22 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})23 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
23 sess.tabs = []*tab{first, second}24 sess.tabs = []*tab{first, second}
M internal/usecase/daemon/palette.go +14 -0
@@ -256,6 +256,20 @@ func (e paletteExec) RenameSession() error {
256256 return nil
257257 }
258258
259+func (e paletteExec) RenameTab() error {
260+ tb := e.sess.activeTab()
261+ if tb == nil {
262+ return nil
263+ }
264+ e.sess.mu.Lock()
265+ currentName := tabDisplayName(tb, e.sess.active)
266+ e.sess.mu.Unlock()
267+ e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
268+ return e.d.renameTab(e.sess, tb, name)
269+ })
270+ return nil
271+}
272+
259273 func (e paletteExec) OpenSessionPicker() error {
260274 e.d.enterPicker(e.sess, e.ac)
261275 return nil
@@ -256,6 +256,20 @@ func (e paletteExec) RenameSession() error {
256 return nil256 return nil
257 }257 }
258 258
259+func (e paletteExec) RenameTab() error {
260+ tb := e.sess.activeTab()
261+ if tb == nil {
262+ return nil
263+ }
264+ e.sess.mu.Lock()
265+ currentName := tabDisplayName(tb, e.sess.active)
266+ e.sess.mu.Unlock()
267+ e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
268+ return e.d.renameTab(e.sess, tb, name)
269+ })
270+ return nil
271+}
272+
259 func (e paletteExec) OpenSessionPicker() error {273 func (e paletteExec) OpenSessionPicker() error {
260 e.d.enterPicker(e.sess, e.ac)274 e.d.enterPicker(e.sess, e.ac)
261 return nil275 return nil
M internal/usecase/daemon/palette_test.go +2 -2
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112112 for i, cmd := range commands {
113113 codes[i] = cmd.Code
114114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "DET"}, codes)
115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
116116 }
117117
118118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
121121
122122 var wg sync.WaitGroup
123123 for range 50 {
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112 for i, cmd := range commands {112 for i, cmd := range commands {
113 codes[i] = cmd.Code113 codes[i] = cmd.Code
114 }114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "DET"}, codes)115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
116 }116 }
117 117
118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119 d := &Daemon{}119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
121 121
122 var wg sync.WaitGroup122 var wg sync.WaitGroup
123 for range 50 {123 for range 50 {
M internal/usecase/daemon/pane_actions_test.go +1 -0
@@ -219,6 +219,7 @@ func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
219219 otherPTY := portsmocks.NewMockPTY(t)
220220 other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})
221221 sess.tabs = append(sess.tabs, other)
222+ d.sessions[sess.id] = sess
222223 oldPTY.EXPECT().Close().Return(nil).Once()
223224
224225 require.NoError(t, d.closeFocusedPane(sess, nil))
@@ -219,6 +219,7 @@ func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
219 otherPTY := portsmocks.NewMockPTY(t)219 otherPTY := portsmocks.NewMockPTY(t)
220 other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})220 other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})
221 sess.tabs = append(sess.tabs, other)221 sess.tabs = append(sess.tabs, other)
222+ d.sessions[sess.id] = sess
222 oldPTY.EXPECT().Close().Return(nil).Once()223 oldPTY.EXPECT().Close().Return(nil).Once()
223 224
224 require.NoError(t, d.closeFocusedPane(sess, nil))225 require.NoError(t, d.closeFocusedPane(sess, nil))
M internal/usecase/daemon/picker.go +3 -6
@@ -26,7 +26,6 @@ package daemon
2626
2727 import (
2828 "sort"
29- "strconv"
3029
3130 "github.com/bnema/vev/internal/domain"
3231 "github.com/bnema/vev/internal/ports"
@@ -71,7 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7170 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
7271 sessionAttention := false
7372 for i, tb := range s.tabs {
74- label := strconv.Itoa(i + 1)
73+ label := tabDisplayName(tb, i)
7574 if tb.attention {
7675 label = attentionSuffix(label)
7776 sessionAttention = true
@@ -425,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425424 }
426425 term := from.terminal
427426 cwd := d.dirOrHome(stopped.cwd)
428- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term)
427+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, stopped.tabNames)
429428 if err != nil {
430429 from.mu.Unlock()
431430 d.mu.Unlock()
@@ -455,9 +454,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
455454 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
456455 return
457456 }
458- if cur, ok := d.stopped[target.Name]; ok && cur == stopped {
459- delete(d.stopped, target.Name)
460- }
457+ delete(d.stopped, target.Name)
461458 }
462459 d.mu.Unlock()
463460 return
@@ -26,7 +26,6 @@ package daemon
26 26
27 import (27 import (
28 "sort"28 "sort"
29- "strconv"
30 29
31 "github.com/bnema/vev/internal/domain"30 "github.com/bnema/vev/internal/domain"
32 "github.com/bnema/vev/internal/ports"31 "github.com/bnema/vev/internal/ports"
@@ -71,7 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
71 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}70 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
72 sessionAttention := false71 sessionAttention := false
73 for i, tb := range s.tabs {72 for i, tb := range s.tabs {
74- label := strconv.Itoa(i + 1)73+ label := tabDisplayName(tb, i)
75 if tb.attention {74 if tb.attention {
76 label = attentionSuffix(label)75 label = attentionSuffix(label)
77 sessionAttention = true76 sessionAttention = true
@@ -425,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425 }424 }
426 term := from.terminal425 term := from.terminal
427 cwd := d.dirOrHome(stopped.cwd)426 cwd := d.dirOrHome(stopped.cwd)
428- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term)427+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, stopped.tabNames)
429 if err != nil {428 if err != nil {
430 from.mu.Unlock()429 from.mu.Unlock()
431 d.mu.Unlock()430 d.mu.Unlock()
@@ -455,9 +454,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
455 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)454 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
456 return455 return
457 }456 }
458- if cur, ok := d.stopped[target.Name]; ok && cur == stopped {457+ delete(d.stopped, target.Name)
459- delete(d.stopped, target.Name)
460- }
461 }458 }
462 d.mu.Unlock()459 d.mu.Unlock()
463 return460 return
M internal/usecase/daemon/picker_test.go +24 -2
@@ -49,7 +49,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
4949 ctx, cancel := context.WithCancel(d.serveCtx)
5050 defer cancel()
5151 current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
52- ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
52+ ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
5353 ringing.mu.Lock()
5454 ringing.tabs[1].attention = true
5555 ringing.tabs[1].attentionAt = time.Unix(10, 0)
@@ -64,7 +64,29 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
6464 require.Equal(t, "alpha", views[0].Name)
6565 require.Equal(t, []string{"1", "2"}, views[0].Tabs)
6666 require.Equal(t, "beta ", views[1].Name)
67- require.Equal(t, []string{"1", "2 "}, views[1].Tabs)
67+ require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
68+}
69+
70+func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
71+ p1, release1 := newBlockingPTY(t)
72+ p2, release2 := newBlockingPTY(t)
73+ p3, release3 := newBlockingPTY(t)
74+ defer release1()
75+ defer release2()
76+ defer release3()
77+ d, from, ac, sends := newManualSessionWithPTYs(t, p1)
78+ d.ptys = newFactorySeq(t, p2, p3)
79+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
80+
81+ d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
82+ awaitFrame(t, sends, ports.MsgOutput)
83+
84+ target := ac.currentSession()
85+ require.NotNil(t, target)
86+ require.Equal(t, "work", target.name)
87+ require.Len(t, target.tabs, 2)
88+ require.Equal(t, "shell", target.tabs[0].name)
89+ require.Equal(t, "logs", target.tabs[1].name)
6890 }
6991
7092 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
@@ -49,7 +49,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
49 ctx, cancel := context.WithCancel(d.serveCtx)49 ctx, cancel := context.WithCancel(d.serveCtx)
50 defer cancel()50 defer cancel()
51 current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}51 current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
52- ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}52+ ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
53 ringing.mu.Lock()53 ringing.mu.Lock()
54 ringing.tabs[1].attention = true54 ringing.tabs[1].attention = true
55 ringing.tabs[1].attentionAt = time.Unix(10, 0)55 ringing.tabs[1].attentionAt = time.Unix(10, 0)
@@ -64,7 +64,29 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
64 require.Equal(t, "alpha", views[0].Name)64 require.Equal(t, "alpha", views[0].Name)
65 require.Equal(t, []string{"1", "2"}, views[0].Tabs)65 require.Equal(t, []string{"1", "2"}, views[0].Tabs)
66 require.Equal(t, "beta ", views[1].Name)66 require.Equal(t, "beta ", views[1].Name)
67- require.Equal(t, []string{"1", "2 "}, views[1].Tabs)67+ require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
68+}
69+
70+func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
71+ p1, release1 := newBlockingPTY(t)
72+ p2, release2 := newBlockingPTY(t)
73+ p3, release3 := newBlockingPTY(t)
74+ defer release1()
75+ defer release2()
76+ defer release3()
77+ d, from, ac, sends := newManualSessionWithPTYs(t, p1)
78+ d.ptys = newFactorySeq(t, p2, p3)
79+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
80+
81+ d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
82+ awaitFrame(t, sends, ports.MsgOutput)
83+
84+ target := ac.currentSession()
85+ require.NotNil(t, target)
86+ require.Equal(t, "work", target.name)
87+ require.Len(t, target.tabs, 2)
88+ require.Equal(t, "shell", target.tabs[0].name)
89+ require.Equal(t, "logs", target.tabs[1].name)
68 }90 }
69 91
70 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {92 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
M internal/usecase/daemon/session.go +135 -18
@@ -29,6 +29,7 @@ import (
2929 "errors"
3030 "fmt"
3131 "os"
32+ "slices"
3233 "strconv"
3334 "strings"
3435 "sync"
@@ -76,6 +77,7 @@ type tab struct {
7677 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
7778
7879 stableID string
80+ name string
7981 tree *layout.Tree
8082 panes map[layout.PaneID]*pane
8183 nextPaneID int
@@ -127,25 +129,40 @@ func (d *Daemon) touchMRU(sess *session) {
127129 }
128130 }
129131
130-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv) (*session, error) {
132+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {
131133 tbSize := tabSize(sz)
132- tabStableID, paneStableID, err := d.newTabPaneStableIDs()
133- if err != nil {
134- return nil, err
135- }
136- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
137- if err != nil {
138- d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
139- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
134+ var names []string
135+ if len(restoredTabNames) > 0 {
136+ names = append([]string(nil), restoredTabNames[0]...)
137+ }
138+ tabs := make([]*tab, 0, max(1, len(names)))
139+ for i := range max(1, len(names)) {
140+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
141+ if err != nil {
142+ closeTabs(tabs)
143+ return nil, err
144+ }
145+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
146+ if err != nil {
147+ closeTabs(tabs)
148+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
149+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
150+ }
151+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
152+ if i < len(names) {
153+ tb.name = names[i]
154+ }
155+ tabs = append(tabs, tb)
140156 }
141157
142158 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
143159 d.nextID++
144160 createdAt := time.Now().UnixNano()
145161
146- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
147162 sctx, cancel := context.WithCancel(d.serveCtx)
148- tb.ctx, tb.cancel = context.WithCancel(sctx)
163+ for _, tb := range tabs {
164+ tb.ctx, tb.cancel = context.WithCancel(sctx)
165+ }
149166 lastUsedSeq := uint64(0)
150167 if !ephemeral {
151168 if stopped, ok := d.stopped[name]; ok {
@@ -158,7 +175,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
158175 ephemeral: ephemeral,
159176 ctx: sctx,
160177 cancel: cancel,
161- tabs: []*tab{tb},
178+ tabs: tabs,
162179 cwd: cwd,
163180 terminal: term,
164181 createdAt: createdAt,
@@ -168,8 +185,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
168185 }
169186 sess.snapEligible.Store(!ephemeral && name != "")
170187 if !ephemeral {
171- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
172- _ = pty.Close()
188+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
189+ closeTabs(tabs)
173190 cancel()
174191 return nil, err
175192 }
@@ -180,11 +197,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
180197 d.touchMRU(sess)
181198 }
182199 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
183- d.log.Info("tab created", "session", name, "tab", 0)
184- d.startTabGoroutines(sess, tb)
200+ for i, tb := range tabs {
201+ d.log.Info("tab created", "session", name, "tab", i)
202+ d.startTabGoroutines(sess, tb)
203+ }
185204 return sess, nil
186205 }
187206
207+func closeTabs(tabs []*tab) {
208+ for _, tb := range tabs {
209+ for _, p := range tb.panes {
210+ _ = p.pty.Close()
211+ }
212+ }
213+}
214+
188215 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
189216 if name == "" {
190217 return errors.New("name required")
@@ -280,9 +307,25 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
280307 p.ctx, p.cancel = context.WithCancel(tb.ctx)
281308 }
282309 sess.mu.Lock()
310+ oldActive := sess.active
283311 sess.tabs = append(sess.tabs, tb)
284312 sess.active = len(sess.tabs) - 1
285313 tabIndex := sess.active
314+ record := sess.persistRecordLocked(time.Now().UnixNano())
315+ ephemeral := sess.ephemeral
316+ if !ephemeral {
317+ if err := d.persist.Save(record); err != nil {
318+ sess.tabs = sess.tabs[:len(sess.tabs)-1]
319+ sess.active = oldActive
320+ if tb.cancel != nil {
321+ tb.cancel()
322+ }
323+ sess.mu.Unlock()
324+ d.mu.Unlock()
325+ _ = pty.Close()
326+ return err
327+ }
328+ }
286329 sess.mu.Unlock()
287330 d.log.Info("tab created", "session", name, "tab", tabIndex)
288331 d.startTabGoroutines(sess, tb)
@@ -437,7 +480,11 @@ func (d *Daemon) renameSession(sess *session, name string) error {
437480 }
438481 lastUsedSeq := sess.mruAt.Load()
439482 if wasEphemeral || oldName != name {
440- 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 {
441488 sess.mu.Unlock()
442489 return err
443490 }
@@ -470,7 +517,66 @@ func (d *Daemon) renameSession(sess *session, name string) error {
470517 return nil
471518 }
472519
520+func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
521+ if tb == nil {
522+ return errors.New("tab required")
523+ }
524+ d.mu.Lock()
525+ if d.sessions[sess.id] != sess {
526+ d.mu.Unlock()
527+ return errors.New("daemon: session closed")
528+ }
529+ sess.mu.Lock()
530+ if !slices.Contains(sess.tabs, tb) {
531+ sess.mu.Unlock()
532+ d.mu.Unlock()
533+ return errors.New("tab not found")
534+ }
535+ oldName := tb.name
536+ tb.name = name
537+ record := sess.persistRecordLocked(time.Now().UnixNano())
538+ ephemeral := sess.ephemeral
539+ if !ephemeral {
540+ if err := d.persist.Save(record); err != nil {
541+ tb.name = oldName
542+ sess.mu.Unlock()
543+ d.mu.Unlock()
544+ return err
545+ }
546+ }
547+ sess.mu.Unlock()
548+ d.mu.Unlock()
549+ return nil
550+}
551+
552+func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
553+ createdAt := s.createdAt
554+ if createdAt == 0 {
555+ createdAt = updatedAt
556+ s.createdAt = createdAt
557+ }
558+ tabNames := make([]string, len(s.tabs))
559+ lastCustom := -1
560+ for i, tb := range s.tabs {
561+ tabNames[i] = tb.name
562+ if tb.name != "" {
563+ lastCustom = i
564+ }
565+ }
566+ if lastCustom == -1 {
567+ tabNames = nil
568+ } else {
569+ tabNames = tabNames[:lastCustom+1]
570+ }
571+ return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
572+}
573+
473574 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+ }
474580 sess.mu.Lock()
475581 idx := -1
476582 for i, w := range sess.tabs {
@@ -481,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
481587 }
482588 if idx == -1 {
483589 sess.mu.Unlock()
590+ d.mu.Unlock()
484591 return
485592 }
486593 if len(sess.tabs) == 1 {
487594 name := sess.name
488595 sess.mu.Unlock()
596+ d.mu.Unlock()
489597 d.log.Info("tab closed", "session", name, "last", true)
490598 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
491599 return
@@ -499,7 +607,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
499607 }
500608 ac := sess.client
501609 name := sess.name
610+ record := sess.persistRecordLocked(time.Now().UnixNano())
611+ ephemeral := sess.ephemeral
612+ if !ephemeral {
613+ if err := d.persist.Save(record); err != nil {
614+ d.log.Warn("persisting closed tab failed", "err", err, "session", name)
615+ }
616+ }
502617 sess.mu.Unlock()
618+ d.mu.Unlock()
503619 d.log.Info("tab closed", "session", name)
504620 markSnapshotDirty(sess)
505621
@@ -553,10 +669,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
553669 stoppedName := sess.name
554670 stoppedCwd := sess.cwd
555671 createdAt := sess.createdAt
672+ tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
556673 ephemeral := sess.ephemeral
557674 sess.mu.Unlock()
558675 if !ephemeral {
559- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), purging: purge}
676+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
560677 d.stopped[stoppedName] = stopped
561678 }
562679 empty := len(d.sessions) == 0
@@ -29,6 +29,7 @@ import (
29 "errors"29 "errors"
30 "fmt"30 "fmt"
31 "os"31 "os"
32+ "slices"
32 "strconv"33 "strconv"
33 "strings"34 "strings"
34 "sync"35 "sync"
@@ -76,6 +77,7 @@ type tab struct {
76 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership77 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
77 78
78 stableID string79 stableID string
80+ name string
79 tree *layout.Tree81 tree *layout.Tree
80 panes map[layout.PaneID]*pane82 panes map[layout.PaneID]*pane
81 nextPaneID int83 nextPaneID int
@@ -127,25 +129,40 @@ func (d *Daemon) touchMRU(sess *session) {
127 }129 }
128 }130 }
129 131
130-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv) (*session, error) {132+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {
131 tbSize := tabSize(sz)133 tbSize := tabSize(sz)
132- tabStableID, paneStableID, err := d.newTabPaneStableIDs()134+ var names []string
133- if err != nil {135+ if len(restoredTabNames) > 0 {
134- return nil, err136+ names = append([]string(nil), restoredTabNames[0]...)
135- }137+ }
136- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)138+ tabs := make([]*tab, 0, max(1, len(names)))
137- if err != nil {139+ for i := range max(1, len(names)) {
138- d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")140+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
139- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)141+ if err != nil {
142+ closeTabs(tabs)
143+ return nil, err
144+ }
145+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
146+ if err != nil {
147+ closeTabs(tabs)
148+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
149+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
150+ }
151+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
152+ if i < len(names) {
153+ tb.name = names[i]
154+ }
155+ tabs = append(tabs, tb)
140 }156 }
141 157
142 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))158 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
143 d.nextID++159 d.nextID++
144 createdAt := time.Now().UnixNano()160 createdAt := time.Now().UnixNano()
145 161
146- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
147 sctx, cancel := context.WithCancel(d.serveCtx)162 sctx, cancel := context.WithCancel(d.serveCtx)
148- tb.ctx, tb.cancel = context.WithCancel(sctx)163+ for _, tb := range tabs {
164+ tb.ctx, tb.cancel = context.WithCancel(sctx)
165+ }
149 lastUsedSeq := uint64(0)166 lastUsedSeq := uint64(0)
150 if !ephemeral {167 if !ephemeral {
151 if stopped, ok := d.stopped[name]; ok {168 if stopped, ok := d.stopped[name]; ok {
@@ -158,7 +175,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
158 ephemeral: ephemeral,175 ephemeral: ephemeral,
159 ctx: sctx,176 ctx: sctx,
160 cancel: cancel,177 cancel: cancel,
161- tabs: []*tab{tb},178+ tabs: tabs,
162 cwd: cwd,179 cwd: cwd,
163 terminal: term,180 terminal: term,
164 createdAt: createdAt,181 createdAt: createdAt,
@@ -168,8 +185,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
168 }185 }
169 sess.snapEligible.Store(!ephemeral && name != "")186 sess.snapEligible.Store(!ephemeral && name != "")
170 if !ephemeral {187 if !ephemeral {
171- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {188+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
172- _ = pty.Close()189+ closeTabs(tabs)
173 cancel()190 cancel()
174 return nil, err191 return nil, err
175 }192 }
@@ -180,11 +197,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
180 d.touchMRU(sess)197 d.touchMRU(sess)
181 }198 }
182 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)199 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
183- d.log.Info("tab created", "session", name, "tab", 0)200+ for i, tb := range tabs {
184- d.startTabGoroutines(sess, tb)201+ d.log.Info("tab created", "session", name, "tab", i)
202+ d.startTabGoroutines(sess, tb)
203+ }
185 return sess, nil204 return sess, nil
186 }205 }
187 206
207+func closeTabs(tabs []*tab) {
208+ for _, tb := range tabs {
209+ for _, p := range tb.panes {
210+ _ = p.pty.Close()
211+ }
212+ }
213+}
214+
188 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {215 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
189 if name == "" {216 if name == "" {
190 return errors.New("name required")217 return errors.New("name required")
@@ -280,9 +307,25 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
280 p.ctx, p.cancel = context.WithCancel(tb.ctx)307 p.ctx, p.cancel = context.WithCancel(tb.ctx)
281 }308 }
282 sess.mu.Lock()309 sess.mu.Lock()
310+ oldActive := sess.active
283 sess.tabs = append(sess.tabs, tb)311 sess.tabs = append(sess.tabs, tb)
284 sess.active = len(sess.tabs) - 1312 sess.active = len(sess.tabs) - 1
285 tabIndex := sess.active313 tabIndex := sess.active
314+ record := sess.persistRecordLocked(time.Now().UnixNano())
315+ ephemeral := sess.ephemeral
316+ if !ephemeral {
317+ if err := d.persist.Save(record); err != nil {
318+ sess.tabs = sess.tabs[:len(sess.tabs)-1]
319+ sess.active = oldActive
320+ if tb.cancel != nil {
321+ tb.cancel()
322+ }
323+ sess.mu.Unlock()
324+ d.mu.Unlock()
325+ _ = pty.Close()
326+ return err
327+ }
328+ }
286 sess.mu.Unlock()329 sess.mu.Unlock()
287 d.log.Info("tab created", "session", name, "tab", tabIndex)330 d.log.Info("tab created", "session", name, "tab", tabIndex)
288 d.startTabGoroutines(sess, tb)331 d.startTabGoroutines(sess, tb)
@@ -437,7 +480,11 @@ func (d *Daemon) renameSession(sess *session, name string) error {
437 }480 }
438 lastUsedSeq := sess.mruAt.Load()481 lastUsedSeq := sess.mruAt.Load()
439 if wasEphemeral || oldName != name {482 if wasEphemeral || oldName != name {
440- 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 {
441 sess.mu.Unlock()488 sess.mu.Unlock()
442 return err489 return err
443 }490 }
@@ -470,7 +517,66 @@ func (d *Daemon) renameSession(sess *session, name string) error {
470 return nil517 return nil
471 }518 }
472 519
520+func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
521+ if tb == nil {
522+ return errors.New("tab required")
523+ }
524+ d.mu.Lock()
525+ if d.sessions[sess.id] != sess {
526+ d.mu.Unlock()
527+ return errors.New("daemon: session closed")
528+ }
529+ sess.mu.Lock()
530+ if !slices.Contains(sess.tabs, tb) {
531+ sess.mu.Unlock()
532+ d.mu.Unlock()
533+ return errors.New("tab not found")
534+ }
535+ oldName := tb.name
536+ tb.name = name
537+ record := sess.persistRecordLocked(time.Now().UnixNano())
538+ ephemeral := sess.ephemeral
539+ if !ephemeral {
540+ if err := d.persist.Save(record); err != nil {
541+ tb.name = oldName
542+ sess.mu.Unlock()
543+ d.mu.Unlock()
544+ return err
545+ }
546+ }
547+ sess.mu.Unlock()
548+ d.mu.Unlock()
549+ return nil
550+}
551+
552+func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
553+ createdAt := s.createdAt
554+ if createdAt == 0 {
555+ createdAt = updatedAt
556+ s.createdAt = createdAt
557+ }
558+ tabNames := make([]string, len(s.tabs))
559+ lastCustom := -1
560+ for i, tb := range s.tabs {
561+ tabNames[i] = tb.name
562+ if tb.name != "" {
563+ lastCustom = i
564+ }
565+ }
566+ if lastCustom == -1 {
567+ tabNames = nil
568+ } else {
569+ tabNames = tabNames[:lastCustom+1]
570+ }
571+ return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
572+}
573+
473 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+ }
474 sess.mu.Lock()580 sess.mu.Lock()
475 idx := -1581 idx := -1
476 for i, w := range sess.tabs {582 for i, w := range sess.tabs {
@@ -481,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
481 }587 }
482 if idx == -1 {588 if idx == -1 {
483 sess.mu.Unlock()589 sess.mu.Unlock()
590+ d.mu.Unlock()
484 return591 return
485 }592 }
486 if len(sess.tabs) == 1 {593 if len(sess.tabs) == 1 {
487 name := sess.name594 name := sess.name
488 sess.mu.Unlock()595 sess.mu.Unlock()
596+ d.mu.Unlock()
489 d.log.Info("tab closed", "session", name, "last", true)597 d.log.Info("tab closed", "session", name, "last", true)
490 _ = d.killSession(sess, ports.ReasonSessionKilled, false)598 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
491 return599 return
@@ -499,7 +607,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
499 }607 }
500 ac := sess.client608 ac := sess.client
501 name := sess.name609 name := sess.name
610+ record := sess.persistRecordLocked(time.Now().UnixNano())
611+ ephemeral := sess.ephemeral
612+ if !ephemeral {
613+ if err := d.persist.Save(record); err != nil {
614+ d.log.Warn("persisting closed tab failed", "err", err, "session", name)
615+ }
616+ }
502 sess.mu.Unlock()617 sess.mu.Unlock()
618+ d.mu.Unlock()
503 d.log.Info("tab closed", "session", name)619 d.log.Info("tab closed", "session", name)
504 markSnapshotDirty(sess)620 markSnapshotDirty(sess)
505 621
@@ -553,10 +669,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
553 stoppedName := sess.name669 stoppedName := sess.name
554 stoppedCwd := sess.cwd670 stoppedCwd := sess.cwd
555 createdAt := sess.createdAt671 createdAt := sess.createdAt
672+ tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
556 ephemeral := sess.ephemeral673 ephemeral := sess.ephemeral
557 sess.mu.Unlock()674 sess.mu.Unlock()
558 if !ephemeral {675 if !ephemeral {
559- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), purging: purge}676+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
560 d.stopped[stoppedName] = stopped677 d.stopped[stoppedName] = stopped
561 }678 }
562 empty := len(d.sessions) == 0679 empty := len(d.sessions) == 0
M internal/usecase/daemon/session_test.go +114 -4
@@ -799,24 +799,134 @@ 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
826+func TestRenameTabPersistsForNamedSession(t *testing.T) {
827+ sz := domain.Size{Cols: 80, Rows: 24}
828+ p, release := newBlockingPTY(t)
829+ defer release()
830+ store, _ := newMockStore(t)
831+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
832+ WithStore(store)(d)
833+
834+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
835+ require.NoError(t, err)
836+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
837+
838+ records, err := d.persist.LoadAll()
839+ require.NoError(t, err)
840+ require.Len(t, records, 1)
841+ require.Equal(t, "work", records[0].Name)
842+ require.Equal(t, "/tmp/work", records[0].Cwd)
843+ require.Equal(t, sess.createdAt, records[0].CreatedAt)
844+ require.GreaterOrEqual(t, records[0].UpdatedAt, records[0].CreatedAt)
845+ require.Equal(t, []string{"shell"}, records[0].TabNames)
846+}
847+
848+func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
849+ tests := []struct {
850+ name string
851+ tabNames []string
852+ closeIndex int
853+ want []string
854+ }{
855+ {name: "close before named tab", tabNames: []string{"shell", "", "logs"}, closeIndex: 0, want: []string{"", "logs"}},
856+ {name: "close after named tab", tabNames: []string{"shell", "logs", ""}, closeIndex: 2, want: []string{"shell", "logs"}},
857+ }
858+ for _, tt := range tests {
859+ t.Run(tt.name, func(t *testing.T) {
860+ ptys := make([]ports.PTY, 3)
861+ for i := range ptys {
862+ p, release := newBlockingPTY(t)
863+ ptys[i] = p
864+ t.Cleanup(release)
865+ }
866+ store, _ := newMockStore(t)
867+ d := newTestDaemon(t, newFactorySeq(t, ptys...), stubClock{})
868+ WithStore(store)(d)
869+ sz := domain.Size{Cols: 80, Rows: 24}
870+
871+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
872+ require.NoError(t, err)
873+ require.NoError(t, d.createTab(sess, sz))
874+ require.NoError(t, d.createTab(sess, sz))
875+ for i, name := range tt.tabNames {
876+ require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
877+ }
878+
879+ d.closeTab(sess, sess.tabs[tt.closeIndex], false)
880+ records, err := d.persist.LoadAll()
881+ require.NoError(t, err)
882+ require.Len(t, records, 1)
883+ require.Equal(t, tt.want, records[0].TabNames)
884+ })
885+ }
886+}
887+
888+func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
889+ sz := domain.Size{Cols: 80, Rows: 24}
890+ p, release := newBlockingPTY(t)
891+ defer release()
892+ store, state := newMockStore(t)
893+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
894+ WithStore(store)(d)
895+
896+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{})
897+ require.NoError(t, err)
898+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
899+ require.Equal(t, "shell", sess.tabs[0].name)
900+
901+ require.False(t, state.has("0"))
902+ records, err := d.persist.LoadAll()
903+ require.NoError(t, err)
904+ require.Empty(t, records)
905+}
906+
907+func TestAttachRestoresPersistedTabNames(t *testing.T) {
908+ sz := domain.Size{Cols: 80, Rows: 24}
909+ p1, release1 := newBlockingPTY(t)
910+ p2, release2 := newBlockingPTY(t)
911+ defer release1()
912+ defer release2()
913+ store, _ := newMockStore(t)
914+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
915+ WithStore(store)(d)
916+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
917+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
918+ tr := portsmocks.NewMockTransport(t)
919+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
920+ tr.EXPECT().Close().Return(nil).Maybe()
921+
922+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
923+ require.NoError(t, err)
924+ require.NotNil(t, ac)
925+ require.Len(t, sess.tabs, 2)
926+ require.Equal(t, "shell", sess.tabs[0].name)
927+ require.Equal(t, "logs", sess.tabs[1].name)
928+}
929+
820930 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
821931 sz := domain.Size{Cols: 80, Rows: 24}
822932 p, release := newBlockingPTY(t)
@@ -799,24 +799,134 @@ 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
826+func TestRenameTabPersistsForNamedSession(t *testing.T) {
827+ sz := domain.Size{Cols: 80, Rows: 24}
828+ p, release := newBlockingPTY(t)
829+ defer release()
830+ store, _ := newMockStore(t)
831+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
832+ WithStore(store)(d)
833+
834+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
835+ require.NoError(t, err)
836+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
837+
838+ records, err := d.persist.LoadAll()
839+ require.NoError(t, err)
840+ require.Len(t, records, 1)
841+ require.Equal(t, "work", records[0].Name)
842+ require.Equal(t, "/tmp/work", records[0].Cwd)
843+ require.Equal(t, sess.createdAt, records[0].CreatedAt)
844+ require.GreaterOrEqual(t, records[0].UpdatedAt, records[0].CreatedAt)
845+ require.Equal(t, []string{"shell"}, records[0].TabNames)
846+}
847+
848+func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
849+ tests := []struct {
850+ name string
851+ tabNames []string
852+ closeIndex int
853+ want []string
854+ }{
855+ {name: "close before named tab", tabNames: []string{"shell", "", "logs"}, closeIndex: 0, want: []string{"", "logs"}},
856+ {name: "close after named tab", tabNames: []string{"shell", "logs", ""}, closeIndex: 2, want: []string{"shell", "logs"}},
857+ }
858+ for _, tt := range tests {
859+ t.Run(tt.name, func(t *testing.T) {
860+ ptys := make([]ports.PTY, 3)
861+ for i := range ptys {
862+ p, release := newBlockingPTY(t)
863+ ptys[i] = p
864+ t.Cleanup(release)
865+ }
866+ store, _ := newMockStore(t)
867+ d := newTestDaemon(t, newFactorySeq(t, ptys...), stubClock{})
868+ WithStore(store)(d)
869+ sz := domain.Size{Cols: 80, Rows: 24}
870+
871+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
872+ require.NoError(t, err)
873+ require.NoError(t, d.createTab(sess, sz))
874+ require.NoError(t, d.createTab(sess, sz))
875+ for i, name := range tt.tabNames {
876+ require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
877+ }
878+
879+ d.closeTab(sess, sess.tabs[tt.closeIndex], false)
880+ records, err := d.persist.LoadAll()
881+ require.NoError(t, err)
882+ require.Len(t, records, 1)
883+ require.Equal(t, tt.want, records[0].TabNames)
884+ })
885+ }
886+}
887+
888+func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
889+ sz := domain.Size{Cols: 80, Rows: 24}
890+ p, release := newBlockingPTY(t)
891+ defer release()
892+ store, state := newMockStore(t)
893+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
894+ WithStore(store)(d)
895+
896+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{})
897+ require.NoError(t, err)
898+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
899+ require.Equal(t, "shell", sess.tabs[0].name)
900+
901+ require.False(t, state.has("0"))
902+ records, err := d.persist.LoadAll()
903+ require.NoError(t, err)
904+ require.Empty(t, records)
905+}
906+
907+func TestAttachRestoresPersistedTabNames(t *testing.T) {
908+ sz := domain.Size{Cols: 80, Rows: 24}
909+ p1, release1 := newBlockingPTY(t)
910+ p2, release2 := newBlockingPTY(t)
911+ defer release1()
912+ defer release2()
913+ store, _ := newMockStore(t)
914+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
915+ WithStore(store)(d)
916+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
917+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
918+ tr := portsmocks.NewMockTransport(t)
919+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
920+ tr.EXPECT().Close().Return(nil).Maybe()
921+
922+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
923+ require.NoError(t, err)
924+ require.NotNil(t, ac)
925+ require.Len(t, sess.tabs, 2)
926+ require.Equal(t, "shell", sess.tabs[0].name)
927+ require.Equal(t, "logs", sess.tabs[1].name)
928+}
929+
820 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {930 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
821 sz := domain.Size{Cols: 80, Rows: 24}931 sz := domain.Size{Cols: 80, Rows: 24}
822 p, release := newBlockingPTY(t)932 p, release := newBlockingPTY(t)
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)
M internal/usecase/daemon/status.go +8 -1
@@ -204,7 +204,7 @@ func (s *session) statusSegments() statusSnapshot {
204204 }
205205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
206206 for i, tb := range s.tabs {
207- name := strconv.Itoa(i + 1)
207+ name := tabDisplayName(tb, i)
208208 active := i == s.active
209209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
210210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}
@@ -212,6 +212,13 @@ func (s *session) statusSegments() statusSnapshot {
212212 return snap
213213 }
214214
215+func tabDisplayName(tb *tab, index int) string {
216+ if tb.name != "" {
217+ return tb.name
218+ }
219+ return strconv.Itoa(index + 1)
220+}
221+
215222 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
216223 state := d.barStateFor(cur, copyFeedback)
217224 state.history = d.historyNavBarState(ac)
@@ -204,7 +204,7 @@ func (s *session) statusSegments() statusSnapshot {
204 }204 }
205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
206 for i, tb := range s.tabs {206 for i, tb := range s.tabs {
207- name := strconv.Itoa(i + 1)207+ name := tabDisplayName(tb, i)
208 active := i == s.active208 active := i == s.active
209 attention := tb.attention && (!active || tb.attentionVisiblePaint)209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}
@@ -212,6 +212,13 @@ func (s *session) statusSegments() statusSnapshot {
212 return snap212 return snap
213 }213 }
214 214
215+func tabDisplayName(tb *tab, index int) string {
216+ if tb.name != "" {
217+ return tb.name
218+ }
219+ return strconv.Itoa(index + 1)
220+}
221+
215 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {222 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
216 state := d.barStateFor(cur, copyFeedback)223 state := d.barStateFor(cur, copyFeedback)
217 state.history = d.historyNavBarState(ac)224 state.history = d.historyNavBarState(ac)
M internal/usecase/daemon/status_test.go +17 -0
@@ -97,6 +97,23 @@ func TestStatusCompositionGolden(t *testing.T) {
9797 }
9898 }
9999
100+func TestTabDisplayName(t *testing.T) {
101+ tests := []struct {
102+ name string
103+ tab *tab
104+ index int
105+ want string
106+ }{
107+ {name: "custom name", tab: &tab{name: "logs"}, index: 3, want: "logs"},
108+ {name: "numeric fallback", tab: &tab{}, index: 3, want: "4"},
109+ }
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ require.Equal(t, tt.want, tabDisplayName(tt.tab, tt.index))
113+ })
114+ }
115+}
116+
100117 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
101118 p, release := newBlockingPTY(t)
102119 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -97,6 +97,23 @@ func TestStatusCompositionGolden(t *testing.T) {
97 }97 }
98 }98 }
99 99
100+func TestTabDisplayName(t *testing.T) {
101+ tests := []struct {
102+ name string
103+ tab *tab
104+ index int
105+ want string
106+ }{
107+ {name: "custom name", tab: &tab{name: "logs"}, index: 3, want: "logs"},
108+ {name: "numeric fallback", tab: &tab{}, index: 3, want: "4"},
109+ }
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ require.Equal(t, tt.want, tabDisplayName(tt.tab, tt.index))
113+ })
114+ }
115+}
116+
100 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {117 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
101 p, release := newBlockingPTY(t)118 p, release := newBlockingPTY(t)
102 _, sess, ac, _ := newManualSessionWithPTYs(t, p)119 _, sess, ac, _ := newManualSessionWithPTYs(t, p)

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