Merge pull request #8 from bnema/feat/rnt-rename-tab
feat(tabs): add rename-tab action
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. | |||
| 173 | 173 | | SSP | open session picker | | |
| 174 | 174 | | VIS | enter visual mode | | |
| 175 | 175 | | RNS | rename session | | |
| 176 | + | | RNT | rename current tab | | |
| 176 | 177 | | DET | detach | | |
| 177 | 178 | ||
| 178 | 179 | 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 ( | |||
| 4 | 4 | "log/slog" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "sort" | |
| 7 | + | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/pkg/kv" | |
| @@ -20,6 +21,7 @@ func StorePath(dir string) string { | |||
| 20 | 21 | // operations no-ops, so callers can keep persistence optional. | |
| 21 | 22 | type Persister struct { | |
| 22 | 23 | store ports.Store | |
| 24 | + | mu sync.Mutex | |
| 23 | 25 | } | |
| 24 | 26 | ||
| 25 | 27 | // Open opens the session persister under dir. | |
| @@ -50,6 +52,8 @@ func (p *Persister) Save(r Record) error { | |||
| 50 | 52 | if p == nil || p.store == nil { | |
| 51 | 53 | return nil | |
| 52 | 54 | } | |
| 55 | + | p.mu.Lock() | |
| 56 | + | defer p.mu.Unlock() | |
| 53 | 57 | value, err := encodeRecordValue(r) | |
| 54 | 58 | if err != nil { | |
| 55 | 59 | return err | |
| @@ -68,17 +72,21 @@ func (p *Persister) Touch(name, cwd string, at int64) error { | |||
| 68 | 72 | if name == "" { | |
| 69 | 73 | return errEmptyName | |
| 70 | 74 | } | |
| 75 | + | p.mu.Lock() | |
| 76 | + | defer p.mu.Unlock() | |
| 71 | 77 | ||
| 72 | 78 | createdAt := at | |
| 73 | 79 | lastUsedSeq := uint64(0) | |
| 80 | + | var tabNames []string | |
| 74 | 81 | if v, ok := p.store.Get([]byte(name)); ok { | |
| 75 | 82 | if r, err := decodeRecordValue(name, v); err == nil { | |
| 76 | 83 | createdAt = r.CreatedAt | |
| 77 | 84 | 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 | 90 | if err != nil { | |
| 83 | 91 | return err | |
| 84 | 92 | } | |
| @@ -93,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error { | |||
| 93 | 101 | if name == "" { | |
| 94 | 102 | return errEmptyName | |
| 95 | 103 | } | |
| 104 | + | p.mu.Lock() | |
| 105 | + | defer p.mu.Unlock() | |
| 96 | 106 | ||
| 97 | 107 | r := Record{Name: name, LastUsedSeq: lastUsedSeq} | |
| 98 | 108 | if v, ok := p.store.Get([]byte(name)); ok { | |
| @@ -116,6 +126,8 @@ func (p *Persister) Delete(name string) error { | |||
| 116 | 126 | if name == "" { | |
| 117 | 127 | return errEmptyName | |
| 118 | 128 | } | |
| 129 | + | p.mu.Lock() | |
| 130 | + | defer p.mu.Unlock() | |
| 119 | 131 | if err := p.store.Delete([]byte(name)); err != nil { | |
| 120 | 132 | return err | |
| 121 | 133 | } | |
| @@ -127,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) { | |||
| 127 | 139 | if p == nil || p.store == nil { | |
| 128 | 140 | return nil, nil | |
| 129 | 141 | } | |
| 142 | + | p.mu.Lock() | |
| 143 | + | defer p.mu.Unlock() | |
| 130 | 144 | data := make(map[string][]byte) | |
| 131 | 145 | p.store.Range(func(k, v []byte) bool { | |
| 132 | 146 | data[string(k)] = append([]byte(nil), v...) | |
| @@ -140,6 +154,8 @@ func (p *Persister) Close() error { | |||
| 140 | 154 | if p == nil || p.store == nil { | |
| 141 | 155 | return nil | |
| 142 | 156 | } | |
| 157 | + | p.mu.Lock() | |
| 158 | + | defer p.mu.Unlock() | |
| 143 | 159 | return p.store.Close() | |
| 144 | 160 | } | |
| 145 | 161 | ||
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "log/slog" | 4 | "log/slog" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | "sort" | 6 | "sort" | ||
| 7 | + | "sync" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | "github.com/bnema/vev/pkg/kv" | 10 | "github.com/bnema/vev/pkg/kv" | ||
| @@ -20,6 +21,7 @@ func StorePath(dir string) string { | |||||
| 20 | // operations no-ops, so callers can keep persistence optional. | 21 | // operations no-ops, so callers can keep persistence optional. | ||
| 21 | type Persister struct { | 22 | type Persister struct { | ||
| 22 | store ports.Store | 23 | store ports.Store | ||
| 24 | + | mu sync.Mutex | |||
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| 25 | // Open opens the session persister under dir. | 27 | // Open opens the session persister under dir. | ||
| @@ -50,6 +52,8 @@ func (p *Persister) Save(r Record) error { | |||||
| 50 | if p == nil || p.store == nil { | 52 | if p == nil || p.store == nil { | ||
| 51 | return nil | 53 | return nil | ||
| 52 | } | 54 | } | ||
| 55 | + | p.mu.Lock() | |||
| 56 | + | defer p.mu.Unlock() | |||
| 53 | value, err := encodeRecordValue(r) | 57 | value, err := encodeRecordValue(r) | ||
| 54 | if err != nil { | 58 | if err != nil { | ||
| 55 | return err | 59 | return err | ||
| @@ -68,17 +72,21 @@ func (p *Persister) Touch(name, cwd string, at int64) error { | |||||
| 68 | if name == "" { | 72 | if name == "" { | ||
| 69 | return errEmptyName | 73 | return errEmptyName | ||
| 70 | } | 74 | } | ||
| 75 | + | p.mu.Lock() | |||
| 76 | + | defer p.mu.Unlock() | |||
| 71 | 77 | ||||
| 72 | createdAt := at | 78 | createdAt := at | ||
| 73 | lastUsedSeq := uint64(0) | 79 | lastUsedSeq := uint64(0) | ||
| 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.CreatedAt | 83 | createdAt = r.CreatedAt | ||
| 77 | lastUsedSeq = r.LastUsedSeq | 84 | 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 err | 91 | 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 errEmptyName | 102 | 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 errEmptyName | 127 | 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 err | 132 | 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, nil | 140 | 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 nil | 155 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/stretchr/testify/mock" | |
| 9 | 11 | "github.com/stretchr/testify/require" | |
| @@ -17,7 +19,7 @@ func privateDir(t *testing.T) string { | |||
| 17 | 19 | } | |
| 18 | 20 | ||
| 19 | 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 | 23 | value, err := encodeRecordValue(r) | |
| 22 | 24 | require.NoError(t, err) | |
| 23 | 25 | ||
| @@ -81,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||
| 81 | 83 | p, err := Open(dir) | |
| 82 | 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 | 87 | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50})) | |
| 86 | 88 | require.NoError(t, p.Touch("b", "/b/next", 30)) | |
| 87 | 89 | require.NoError(t, p.TouchMRU("b", 60)) | |
| @@ -90,13 +92,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||
| 90 | 92 | require.NoError(t, err) | |
| 91 | 93 | require.Equal(t, []Record{ | |
| 92 | 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 | 96 | }, records) | |
| 95 | 97 | ||
| 96 | 98 | require.NoError(t, p.Delete("a")) | |
| 97 | 99 | records, err = p.LoadAll() | |
| 98 | 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 | 102 | require.NoError(t, p.Close()) | |
| 101 | 103 | } | |
| 102 | 104 | ||
| @@ -151,6 +153,34 @@ func TestSyncBehavior(t *testing.T) { | |||
| 151 | 153 | require.Equal(t, 2, state.syncs) | |
| 152 | 154 | } | |
| 153 | 155 | ||
| 156 | + | func TestPersisterSerializesReadModifyWriteWithStructuralSave(t *testing.T) { | |
| 157 | + | store := newBlockingGetStore() | |
| 158 | + | p := New(store) | |
| 159 | + | require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, TabNames: []string{"old"}})) | |
| 160 | + | store.blockNextGet() | |
| 161 | + | ||
| 162 | + | touchDone := make(chan error, 1) | |
| 163 | + | go func() { touchDone <- p.TouchMRU("work", 2) }() | |
| 164 | + | <-store.getStarted | |
| 165 | + | ||
| 166 | + | saveDone := make(chan error, 1) | |
| 167 | + | go func() { | |
| 168 | + | saveDone <- p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 3, LastUsedSeq: 2, TabNames: []string{"new"}}) | |
| 169 | + | }() | |
| 170 | + | select { | |
| 171 | + | case err := <-saveDone: | |
| 172 | + | t.Fatalf("structural save interleaved with TouchMRU: %v", err) | |
| 173 | + | case <-time.After(20 * time.Millisecond): | |
| 174 | + | } | |
| 175 | + | close(store.releaseGet) | |
| 176 | + | require.NoError(t, <-touchDone) | |
| 177 | + | require.NoError(t, <-saveDone) | |
| 178 | + | ||
| 179 | + | records, err := p.LoadAll() | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | require.Equal(t, []string{"new"}, records[0].TabNames) | |
| 182 | + | } | |
| 183 | + | ||
| 154 | 184 | func TestSavePropagatesSetErrorWithoutSync(t *testing.T) { | |
| 155 | 185 | store, state := newMockStore(t) | |
| 156 | 186 | state.errSet = errors.New("set failed") | |
| @@ -170,6 +200,68 @@ type mockStoreState struct { | |||
| 170 | 200 | errSet error | |
| 171 | 201 | } | |
| 172 | 202 | ||
| 203 | + | type blockingGetStore struct { | |
| 204 | + | mu sync.Mutex | |
| 205 | + | data map[string][]byte | |
| 206 | + | block bool | |
| 207 | + | getStarted chan struct{} | |
| 208 | + | releaseGet chan struct{} | |
| 209 | + | getStartOnce sync.Once | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func newBlockingGetStore() *blockingGetStore { | |
| 213 | + | return &blockingGetStore{data: make(map[string][]byte), getStarted: make(chan struct{}), releaseGet: make(chan struct{})} | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (s *blockingGetStore) blockNextGet() { | |
| 217 | + | s.mu.Lock() | |
| 218 | + | s.block = true | |
| 219 | + | s.mu.Unlock() | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func (s *blockingGetStore) Get(key []byte) ([]byte, bool) { | |
| 223 | + | s.mu.Lock() | |
| 224 | + | v, ok := s.data[string(key)] | |
| 225 | + | v = append([]byte(nil), v...) | |
| 226 | + | block := s.block | |
| 227 | + | if block { | |
| 228 | + | s.block = false | |
| 229 | + | } | |
| 230 | + | s.mu.Unlock() | |
| 231 | + | if block { | |
| 232 | + | s.getStartOnce.Do(func() { close(s.getStarted) }) | |
| 233 | + | <-s.releaseGet | |
| 234 | + | } | |
| 235 | + | return v, ok | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (s *blockingGetStore) Set(key, value []byte) error { | |
| 239 | + | s.mu.Lock() | |
| 240 | + | s.data[string(key)] = append([]byte(nil), value...) | |
| 241 | + | s.mu.Unlock() | |
| 242 | + | return nil | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func (s *blockingGetStore) Delete(key []byte) error { | |
| 246 | + | s.mu.Lock() | |
| 247 | + | delete(s.data, string(key)) | |
| 248 | + | s.mu.Unlock() | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func (s *blockingGetStore) Range(fn func(k, v []byte) bool) { | |
| 253 | + | s.mu.Lock() | |
| 254 | + | defer s.mu.Unlock() | |
| 255 | + | for key, value := range s.data { | |
| 256 | + | if !fn([]byte(key), append([]byte(nil), value...)) { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (*blockingGetStore) Sync() error { return nil } | |
| 263 | + | func (*blockingGetStore) Close() error { return nil } | |
| 264 | + | ||
| 173 | 265 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | |
| 174 | 266 | t.Helper() | |
| 175 | 267 | state := &mockStoreState{data: make(map[string][]byte)} | |
| @@ -3,7 +3,9 @@ package persist | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | + | "sync" | |||
| 6 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 7 | 9 | ||||
| 8 | "github.com/stretchr/testify/mock" | 10 | "github.com/stretchr/testify/mock" | ||
| 9 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| @@ -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 error | 200 | errSet error | ||
| 171 | } | 201 | } | ||
| 172 | 202 | ||||
| 203 | + | type blockingGetStore struct { | |||
| 204 | + | mu sync.Mutex | |||
| 205 | + | data map[string][]byte | |||
| 206 | + | block bool | |||
| 207 | + | getStarted chan struct{} | |||
| 208 | + | releaseGet chan struct{} | |||
| 209 | + | getStartOnce sync.Once | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func newBlockingGetStore() *blockingGetStore { | |||
| 213 | + | return &blockingGetStore{data: make(map[string][]byte), getStarted: make(chan struct{}), releaseGet: make(chan struct{})} | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (s *blockingGetStore) blockNextGet() { | |||
| 217 | + | s.mu.Lock() | |||
| 218 | + | s.block = true | |||
| 219 | + | s.mu.Unlock() | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func (s *blockingGetStore) Get(key []byte) ([]byte, bool) { | |||
| 223 | + | s.mu.Lock() | |||
| 224 | + | v, ok := s.data[string(key)] | |||
| 225 | + | v = append([]byte(nil), v...) | |||
| 226 | + | block := s.block | |||
| 227 | + | if block { | |||
| 228 | + | s.block = false | |||
| 229 | + | } | |||
| 230 | + | s.mu.Unlock() | |||
| 231 | + | if block { | |||
| 232 | + | s.getStartOnce.Do(func() { close(s.getStarted) }) | |||
| 233 | + | <-s.releaseGet | |||
| 234 | + | } | |||
| 235 | + | return v, ok | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (s *blockingGetStore) Set(key, value []byte) error { | |||
| 239 | + | s.mu.Lock() | |||
| 240 | + | s.data[string(key)] = append([]byte(nil), value...) | |||
| 241 | + | s.mu.Unlock() | |||
| 242 | + | return nil | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func (s *blockingGetStore) Delete(key []byte) error { | |||
| 246 | + | s.mu.Lock() | |||
| 247 | + | delete(s.data, string(key)) | |||
| 248 | + | s.mu.Unlock() | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func (s *blockingGetStore) Range(fn func(k, v []byte) bool) { | |||
| 253 | + | s.mu.Lock() | |||
| 254 | + | defer s.mu.Unlock() | |||
| 255 | + | for key, value := range s.data { | |||
| 256 | + | if !fn([]byte(key), append([]byte(nil), value...)) { | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func (*blockingGetStore) Sync() error { return nil } | |||
| 263 | + | func (*blockingGetStore) Close() error { return nil } | |||
| 264 | + | ||||
| 173 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | 265 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | ||
| 174 | t.Helper() | 266 | t.Helper() | ||
| 175 | state := &mockStoreState{data: make(map[string][]byte)} | 267 | state := &mockStoreState{data: make(map[string][]byte)} | ||
M internal/persist/record.go +60 -3
| @@ -14,6 +14,7 @@ type Record struct { | |||
| 14 | 14 | CreatedAt int64 | |
| 15 | 15 | UpdatedAt int64 | |
| 16 | 16 | LastUsedSeq uint64 | |
| 17 | + | TabNames []string | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | 20 | var ( | |
| @@ -28,14 +29,33 @@ func encodeRecordValue(r Record) ([]byte, error) { | |||
| 28 | 29 | if uint64(len(r.Cwd)) > math.MaxUint32 { | |
| 29 | 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 | 44 | binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd))) | |
| 34 | 45 | copy(buf[4:], r.Cwd) | |
| 35 | 46 | off := 4 + len(r.Cwd) | |
| 36 | 47 | binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt)) | |
| 37 | 48 | binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt)) | |
| 38 | 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 | 59 | return buf, nil | |
| 40 | 60 | } | |
| 41 | 61 | ||
| @@ -52,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||
| 52 | 72 | return Record{}, errMalformedRecord | |
| 53 | 73 | } | |
| 54 | 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 | 77 | return Record{}, errMalformedRecord | |
| 57 | 78 | } | |
| 58 | 79 | off := 4 + cwdLen | |
| @@ -62,8 +83,44 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||
| 62 | 83 | CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])), | |
| 63 | 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 | 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 | 125 | return r, nil | |
| 69 | 126 | } | |
| @@ -14,6 +14,7 @@ type Record struct { | |||||
| 14 | CreatedAt int64 | 14 | CreatedAt int64 | ||
| 15 | UpdatedAt int64 | 15 | UpdatedAt int64 | ||
| 16 | LastUsedSeq uint64 | 16 | 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, nil | 59 | return buf, nil | ||
| 40 | } | 60 | } | ||
| 41 | 61 | ||||
| @@ -52,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||||
| 52 | return Record{}, errMalformedRecord | 72 | 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{}, errMalformedRecord | 77 | return Record{}, errMalformedRecord | ||
| 57 | } | 78 | } | ||
| 58 | off := 4 + cwdLen | 79 | 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, nil | 125 | return r, nil | ||
| 69 | } | 126 | } | ||
M internal/usecase/command/command.go +1 -0
| @@ -23,6 +23,7 @@ type Context interface { | |||
| 23 | 23 | Detach() error | |
| 24 | 24 | EnterVisualMode() error | |
| 25 | 25 | RenameSession() error | |
| 26 | + | RenameTab() error | |
| 26 | 27 | OpenSessionPicker() error | |
| 27 | 28 | } | |
| 28 | 29 | ||
| @@ -23,6 +23,7 @@ type Context interface { | |||||
| 23 | Detach() error | 23 | Detach() error | ||
| 24 | EnterVisualMode() error | 24 | EnterVisualMode() error | ||
| 25 | RenameSession() error | 25 | RenameSession() error | ||
| 26 | + | RenameTab() error | |||
| 26 | OpenSessionPicker() error | 27 | 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 | |||
| 739 | 739 | 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 | 786 | // SplitDown provides a mock function for the type MockContext | |
| 743 | 787 | func (_mock *MockContext) SplitDown() error { | |
| 744 | 788 | ret := _mock.Called() | |
| @@ -739,6 +739,50 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo | |||||
| 739 | return _c | 739 | 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 MockContext | 786 | // 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 { | |||
| 26 | 26 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 27 | 27 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | |
| 28 | 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 | 30 | {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 30 | 31 | } | |
| 31 | 32 | ||
| @@ -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 ( | |||
| 7 | 7 | ||
| 8 | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | 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 | 12 | if len(commands) != len(wantCodes) { | |
| 13 | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -79,6 +79,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 79 | 79 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | |
| 80 | 80 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | |
| 81 | 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 | 83 | {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }}, | |
| 83 | 84 | } | |
| 84 | 85 | ||
| @@ -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 ( | |||
| 31 | 31 | "errors" | |
| 32 | 32 | "log/slog" | |
| 33 | 33 | "os" | |
| 34 | + | "slices" | |
| 34 | 35 | "sort" | |
| 35 | 36 | "sync" | |
| 36 | 37 | "sync/atomic" | |
| @@ -163,9 +164,19 @@ type stoppedSession struct { | |||
| 163 | 164 | cwd string | |
| 164 | 165 | createdAt int64 | |
| 165 | 166 | lastUsedSeq uint64 | |
| 167 | + | tabNames []string | |
| 166 | 168 | 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 | 180 | type Option func(*Daemon) | |
| 170 | 181 | ||
| 171 | 182 | // 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 | 331 | } else { | |
| 321 | 332 | var maxSeq uint64 | |
| 322 | 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 | 335 | if r.LastUsedSeq > maxSeq { | |
| 325 | 336 | maxSeq = r.LastUsedSeq | |
| 326 | 337 | } | |
| @@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 537 | 548 | return | |
| 538 | 549 | } | |
| 539 | 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 | 552 | delete(d.stopped, k.Name) | |
| 542 | 553 | } | |
| 543 | 554 | d.mu.Unlock() | |
| @@ -647,7 +658,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 647 | 658 | } | |
| 648 | 659 | cwd := d.dirOrHome(stopped.cwd) | |
| 649 | 660 | 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 | 662 | if err != nil { | |
| 652 | 663 | d.mu.Unlock() | |
| 653 | 664 | return nil, nil, err | |
| @@ -715,7 +726,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 715 | 726 | } | |
| 716 | 727 | cwd := d.dirOrHome(stopped.cwd) | |
| 717 | 728 | 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 | 730 | if err != nil { | |
| 720 | 731 | d.mu.Unlock() | |
| 721 | 732 | 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 string | 164 | cwd string | ||
| 164 | createdAt int64 | 165 | createdAt int64 | ||
| 165 | lastUsedSeq uint64 | 166 | lastUsedSeq uint64 | ||
| 167 | + | tabNames []string | |||
| 166 | purging bool | 168 | 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. The | 182 | // 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 uint64 | 332 | 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.LastUsedSeq | 336 | maxSeq = r.LastUsedSeq | ||
| 326 | } | 337 | } | ||
| @@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||||
| 537 | return | 548 | 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 error | 660 | 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, err | 664 | 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 error | 728 | 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, err | 732 | return nil, nil, err | ||
M internal/usecase/daemon/input_test.go +26 -0
| @@ -641,6 +641,32 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) { | |||
| 641 | 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 | 670 | func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |
| 645 | 671 | writes := make(chan []byte, 4) | |
| 646 | 672 | 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) { | |||
| 18 | 18 | ctx, cancel := context.WithCancel(context.Background()) | |
| 19 | 19 | defer cancel() | |
| 20 | 20 | sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel} | |
| 21 | + | d.sessions[sess.id] = sess | |
| 21 | 22 | first := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 22 | 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 23 | 24 | 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 { | |||
| 256 | 256 | 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 | 273 | func (e paletteExec) OpenSessionPicker() error { | |
| 260 | 274 | e.d.enterPicker(e.sess, e.ac) | |
| 261 | 275 | return nil | |
| @@ -256,6 +256,20 @@ func (e paletteExec) RenameSession() error { | |||||
| 256 | return nil | 256 | 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 nil | 275 | return nil | ||
M internal/usecase/daemon/palette_test.go +2 -2
| @@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 112 | 112 | for i, cmd := range commands { | |
| 113 | 113 | 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 | 118 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 119 | 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 | 122 | var wg sync.WaitGroup | |
| 123 | 123 | 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.Code | 113 | 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.WaitGroup | 122 | 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) { | |||
| 219 | 219 | otherPTY := portsmocks.NewMockPTY(t) | |
| 220 | 220 | other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10}) | |
| 221 | 221 | sess.tabs = append(sess.tabs, other) | |
| 222 | + | d.sessions[sess.id] = sess | |
| 222 | 223 | oldPTY.EXPECT().Close().Return(nil).Once() | |
| 223 | 224 | ||
| 224 | 225 | 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 | |||
| 26 | 26 | ||
| 27 | 27 | import ( | |
| 28 | 28 | "sort" | |
| 29 | - | "strconv" | |
| 30 | 29 | ||
| 31 | 30 | "github.com/bnema/vev/internal/domain" | |
| 32 | 31 | "github.com/bnema/vev/internal/ports" | |
| @@ -71,7 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 71 | 70 | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | |
| 72 | 71 | sessionAttention := false | |
| 73 | 72 | for i, tb := range s.tabs { | |
| 74 | - | label := strconv.Itoa(i + 1) | |
| 73 | + | label := tabDisplayName(tb, i) | |
| 75 | 74 | if tb.attention { | |
| 76 | 75 | label = attentionSuffix(label) | |
| 77 | 76 | sessionAttention = true | |
| @@ -425,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 425 | 424 | } | |
| 426 | 425 | term := from.terminal | |
| 427 | 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 | 428 | if err != nil { | |
| 430 | 429 | from.mu.Unlock() | |
| 431 | 430 | d.mu.Unlock() | |
| @@ -455,9 +454,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||
| 455 | 454 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |
| 456 | 455 | return | |
| 457 | 456 | } | |
| 458 | - | if cur, ok := d.stopped[target.Name]; ok && cur == stopped { | |
| 459 | - | delete(d.stopped, target.Name) | |
| 460 | - | } | |
| 457 | + | delete(d.stopped, target.Name) | |
| 461 | 458 | } | |
| 462 | 459 | d.mu.Unlock() | |
| 463 | 460 | 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 := false | 71 | 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 = true | 76 | sessionAttention = true | ||
| @@ -425,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 425 | } | 424 | } | ||
| 426 | term := from.terminal | 425 | 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 | return | 455 | 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 | return | 460 | return | ||
M internal/usecase/daemon/picker_test.go +24 -2
| @@ -49,7 +49,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 49 | 49 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 50 | 50 | defer cancel() | |
| 51 | 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 | 53 | ringing.mu.Lock() | |
| 54 | 54 | ringing.tabs[1].attention = true | |
| 55 | 55 | ringing.tabs[1].attentionAt = time.Unix(10, 0) | |
| @@ -64,7 +64,29 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 64 | 64 | require.Equal(t, "alpha", views[0].Name) | |
| 65 | 65 | require.Equal(t, []string{"1", "2"}, views[0].Tabs) | |
| 66 | 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 | 92 | 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 = true | 54 | 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 ( | |||
| 29 | 29 | "errors" | |
| 30 | 30 | "fmt" | |
| 31 | 31 | "os" | |
| 32 | + | "slices" | |
| 32 | 33 | "strconv" | |
| 33 | 34 | "strings" | |
| 34 | 35 | "sync" | |
| @@ -76,6 +77,7 @@ type tab struct { | |||
| 76 | 77 | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | |
| 77 | 78 | ||
| 78 | 79 | stableID string | |
| 80 | + | name string | |
| 79 | 81 | tree *layout.Tree | |
| 80 | 82 | panes map[layout.PaneID]*pane | |
| 81 | 83 | 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 | 133 | 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) | |
| 140 | 156 | } | |
| 141 | 157 | ||
| 142 | 158 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 143 | 159 | d.nextID++ | |
| 144 | 160 | createdAt := time.Now().UnixNano() | |
| 145 | 161 | ||
| 146 | - | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | |
| 147 | 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 | 166 | lastUsedSeq := uint64(0) | |
| 150 | 167 | if !ephemeral { | |
| 151 | 168 | if stopped, ok := d.stopped[name]; ok { | |
| @@ -158,7 +175,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 158 | 175 | ephemeral: ephemeral, | |
| 159 | 176 | ctx: sctx, | |
| 160 | 177 | cancel: cancel, | |
| 161 | - | tabs: []*tab{tb}, | |
| 178 | + | tabs: tabs, | |
| 162 | 179 | cwd: cwd, | |
| 163 | 180 | terminal: term, | |
| 164 | 181 | createdAt: createdAt, | |
| @@ -168,8 +185,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 168 | 185 | } | |
| 169 | 186 | sess.snapEligible.Store(!ephemeral && name != "") | |
| 170 | 187 | 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) | |
| 173 | 190 | cancel() | |
| 174 | 191 | return nil, err | |
| 175 | 192 | } | |
| @@ -180,11 +197,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 180 | 197 | d.touchMRU(sess) | |
| 181 | 198 | } | |
| 182 | 199 | 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 | + | } | |
| 185 | 204 | 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 | 215 | func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error { | |
| 189 | 216 | if name == "" { | |
| 190 | 217 | return errors.New("name required") | |
| @@ -280,9 +307,25 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 280 | 307 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 281 | 308 | } | |
| 282 | 309 | sess.mu.Lock() | |
| 310 | + | oldActive := sess.active | |
| 283 | 311 | sess.tabs = append(sess.tabs, tb) | |
| 284 | 312 | sess.active = len(sess.tabs) - 1 | |
| 285 | 313 | 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 | 329 | sess.mu.Unlock() | |
| 287 | 330 | d.log.Info("tab created", "session", name, "tab", tabIndex) | |
| 288 | 331 | d.startTabGoroutines(sess, tb) | |
| @@ -437,7 +480,11 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 437 | 480 | } | |
| 438 | 481 | lastUsedSeq := sess.mruAt.Load() | |
| 439 | 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 | 488 | sess.mu.Unlock() | |
| 442 | 489 | return err | |
| 443 | 490 | } | |
| @@ -470,7 +517,66 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 470 | 517 | 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 | 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 | 580 | sess.mu.Lock() | |
| 475 | 581 | idx := -1 | |
| 476 | 582 | for i, w := range sess.tabs { | |
| @@ -481,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 481 | 587 | } | |
| 482 | 588 | if idx == -1 { | |
| 483 | 589 | sess.mu.Unlock() | |
| 590 | + | d.mu.Unlock() | |
| 484 | 591 | return | |
| 485 | 592 | } | |
| 486 | 593 | if len(sess.tabs) == 1 { | |
| 487 | 594 | name := sess.name | |
| 488 | 595 | sess.mu.Unlock() | |
| 596 | + | d.mu.Unlock() | |
| 489 | 597 | d.log.Info("tab closed", "session", name, "last", true) | |
| 490 | 598 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 491 | 599 | return | |
| @@ -499,7 +607,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 499 | 607 | } | |
| 500 | 608 | ac := sess.client | |
| 501 | 609 | 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 | 617 | sess.mu.Unlock() | |
| 618 | + | d.mu.Unlock() | |
| 503 | 619 | d.log.Info("tab closed", "session", name) | |
| 504 | 620 | markSnapshotDirty(sess) | |
| 505 | 621 | ||
| @@ -553,10 +669,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 553 | 669 | stoppedName := sess.name | |
| 554 | 670 | stoppedCwd := sess.cwd | |
| 555 | 671 | createdAt := sess.createdAt | |
| 672 | + | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |
| 556 | 673 | ephemeral := sess.ephemeral | |
| 557 | 674 | sess.mu.Unlock() | |
| 558 | 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 | 677 | d.stopped[stoppedName] = stopped | |
| 561 | 678 | } | |
| 562 | 679 | 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 membership | 77 | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | ||
| 77 | 78 | ||||
| 78 | stableID string | 79 | stableID string | ||
| 80 | + | name string | |||
| 79 | tree *layout.Tree | 81 | tree *layout.Tree | ||
| 80 | panes map[layout.PaneID]*pane | 82 | panes map[layout.PaneID]*pane | ||
| 81 | nextPaneID int | 83 | 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, err | 136 | + | 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, err | 191 | 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, nil | 204 | 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) - 1 | 312 | sess.active = len(sess.tabs) - 1 | ||
| 285 | tabIndex := sess.active | 313 | 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 err | 489 | return err | ||
| 443 | } | 490 | } | ||
| @@ -470,7 +517,66 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 470 | return nil | 517 | 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 := -1 | 581 | 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 | return | 591 | return | ||
| 485 | } | 592 | } | ||
| 486 | if len(sess.tabs) == 1 { | 593 | if len(sess.tabs) == 1 { | ||
| 487 | name := sess.name | 594 | 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 | return | 599 | return | ||
| @@ -499,7 +607,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 499 | } | 607 | } | ||
| 500 | ac := sess.client | 608 | ac := sess.client | ||
| 501 | name := sess.name | 609 | 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.name | 669 | stoppedName := sess.name | ||
| 554 | stoppedCwd := sess.cwd | 670 | stoppedCwd := sess.cwd | ||
| 555 | createdAt := sess.createdAt | 671 | createdAt := sess.createdAt | ||
| 672 | + | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |||
| 556 | ephemeral := sess.ephemeral | 673 | 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] = stopped | 677 | d.stopped[stoppedName] = stopped | ||
| 561 | } | 678 | } | ||
| 562 | empty := len(d.sessions) == 0 | 679 | empty := len(d.sessions) == 0 | ||
M internal/usecase/daemon/session_test.go +114 -4
| @@ -799,24 +799,134 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) { | |||
| 799 | 799 | ||
| 800 | 800 | func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |
| 801 | 801 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 802 | - | p, release := newBlockingPTY(t) | |
| 803 | - | defer release() | |
| 802 | + | p1, release1 := newBlockingPTY(t) | |
| 803 | + | p2, release2 := newBlockingPTY(t) | |
| 804 | + | defer release1() | |
| 805 | + | defer release2() | |
| 804 | 806 | store, state := newMockStore(t) | |
| 805 | - | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 807 | + | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 806 | 808 | WithStore(store)(d) | |
| 807 | 809 | ||
| 808 | 810 | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}) | |
| 809 | 811 | require.NoError(t, err) | |
| 810 | 812 | require.True(t, state.has("work")) | |
| 813 | + | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |
| 814 | + | require.NoError(t, d.createTab(sess, sz)) | |
| 815 | + | require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs")) | |
| 811 | 816 | ||
| 812 | 817 | require.NoError(t, d.renameSession(sess, "renamed")) | |
| 813 | 818 | require.False(t, state.has("work")) | |
| 814 | 819 | require.True(t, state.has("renamed")) | |
| 820 | + | require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames) | |
| 815 | 821 | ||
| 816 | - | _ = d.killSession(sess, ports.ReasonSessionKilled, true) | |
| 822 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 817 | 823 | require.False(t, state.has("renamed")) | |
| 818 | 824 | } | |
| 819 | 825 | ||
| 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 | 930 | func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |
| 821 | 931 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 822 | 932 | 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 ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/internal/persist" | |
| 11 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 12 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| @@ -78,7 +77,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 78 | 77 | cancel() | |
| 79 | 78 | } | |
| 80 | 79 | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 81 | - | for _, tabSnap := range snap.Tabs { | |
| 80 | + | d.mu.Lock() | |
| 81 | + | persisted := d.stopped[snap.Name] | |
| 82 | + | d.mu.Unlock() | |
| 83 | + | for tabIndex, tabSnap := range snap.Tabs { | |
| 82 | 84 | if err := ctx.Err(); err != nil { | |
| 83 | 85 | closeOpened() | |
| 84 | 86 | return err | |
| @@ -107,6 +109,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 107 | 109 | } | |
| 108 | 110 | } | |
| 109 | 111 | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 112 | + | if tabIndex < len(persisted.tabNames) { | |
| 113 | + | tb.name = persisted.tabNames[tabIndex] | |
| 114 | + | } | |
| 110 | 115 | if tb.nextPaneID <= 0 { | |
| 111 | 116 | tb.nextPaneID = 1 | |
| 112 | 117 | } | |
| @@ -171,8 +176,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 171 | 176 | sess.mruAt.Store(stopped.lastUsedSeq) | |
| 172 | 177 | } | |
| 173 | 178 | d.mu.Unlock() | |
| 174 | - | lastUsedSeq := sess.mruAt.Load() | |
| 175 | - | if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil { | |
| 179 | + | record := sess.persistRecordLocked(createdAt) | |
| 180 | + | if err := d.persist.Save(record); err != nil { | |
| 176 | 181 | closeOpened() | |
| 177 | 182 | return err | |
| 178 | 183 | } | |
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | "github.com/bnema/vev/internal/persist" | |||
| 11 | scopy "github.com/bnema/vev/internal/usecase/copy" | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 12 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 13 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| @@ -78,7 +77,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 78 | cancel() | 77 | cancel() | ||
| 79 | } | 78 | } | ||
| 80 | allowlist := d.restoreProcessAllowlistSnapshot() | 79 | allowlist := d.restoreProcessAllowlistSnapshot() | ||
| 81 | - | for _, tabSnap := range snap.Tabs { | 80 | + | d.mu.Lock() |
| 81 | + | persisted := d.stopped[snap.Name] | |||
| 82 | + | d.mu.Unlock() | |||
| 83 | + | for tabIndex, tabSnap := range snap.Tabs { | |||
| 82 | if err := ctx.Err(); err != nil { | 84 | if err := ctx.Err(); err != nil { | ||
| 83 | closeOpened() | 85 | closeOpened() | ||
| 84 | return err | 86 | return err | ||
| @@ -107,6 +109,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 107 | } | 109 | } | ||
| 108 | } | 110 | } | ||
| 109 | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | 111 | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | ||
| 112 | + | if tabIndex < len(persisted.tabNames) { | |||
| 113 | + | tb.name = persisted.tabNames[tabIndex] | |||
| 114 | + | } | |||
| 110 | if tb.nextPaneID <= 0 { | 115 | if tb.nextPaneID <= 0 { | ||
| 111 | tb.nextPaneID = 1 | 116 | tb.nextPaneID = 1 | ||
| 112 | } | 117 | } | ||
| @@ -171,8 +176,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 171 | sess.mruAt.Store(stopped.lastUsedSeq) | 176 | sess.mruAt.Store(stopped.lastUsedSeq) | ||
| 172 | } | 177 | } | ||
| 173 | d.mu.Unlock() | 178 | d.mu.Unlock() | ||
| 174 | - | lastUsedSeq := sess.mruAt.Load() | 179 | + | record := sess.persistRecordLocked(createdAt) |
| 175 | - | if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil { | 180 | + | if err := d.persist.Save(record); err != nil { |
| 176 | closeOpened() | 181 | closeOpened() | ||
| 177 | return err | 182 | return err | ||
| 178 | } | 183 | } | ||
M internal/usecase/daemon/snapshot_restore_test.go +5 -0
| @@ -44,7 +44,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 44 | 44 | })}} | |
| 45 | 45 | factory := &restorePTYFactory{} | |
| 46 | 46 | d := newTestDaemon(t, factory, stubClock{}) | |
| 47 | + | persistStore, persistState := newMockStore(t) | |
| 48 | + | WithStore(persistStore)(d) | |
| 47 | 49 | WithSnapshotStore(store)(d) | |
| 50 | + | d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}} | |
| 48 | 51 | ||
| 49 | 52 | d.restoreSnapshots(context.Background()) | |
| 50 | 53 | ||
| @@ -66,6 +69,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 66 | 69 | require.Len(t, restored.tabs, 1) | |
| 67 | 70 | tb := restored.tabs[0] | |
| 68 | 71 | restored.mu.Unlock() | |
| 72 | + | require.Equal(t, "logs", tb.name) | |
| 73 | + | require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames) | |
| 69 | 74 | tb.mu.Lock() | |
| 70 | 75 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 71 | 76 | require.Equal(t, 3, tb.nextPaneID) | |
| @@ -44,7 +44,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 44 | })}} | 44 | })}} | ||
| 45 | factory := &restorePTYFactory{} | 45 | factory := &restorePTYFactory{} | ||
| 46 | d := newTestDaemon(t, factory, stubClock{}) | 46 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 47 | + | persistStore, persistState := newMockStore(t) | |||
| 48 | + | WithStore(persistStore)(d) | |||
| 47 | WithSnapshotStore(store)(d) | 49 | WithSnapshotStore(store)(d) | ||
| 50 | + | d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}} | |||
| 48 | 51 | ||||
| 49 | d.restoreSnapshots(context.Background()) | 52 | d.restoreSnapshots(context.Background()) | ||
| 50 | 53 | ||||
| @@ -66,6 +69,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 66 | require.Len(t, restored.tabs, 1) | 69 | require.Len(t, restored.tabs, 1) | ||
| 67 | tb := restored.tabs[0] | 70 | tb := restored.tabs[0] | ||
| 68 | restored.mu.Unlock() | 71 | restored.mu.Unlock() | ||
| 72 | + | require.Equal(t, "logs", tb.name) | |||
| 73 | + | require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames) | |||
| 69 | tb.mu.Lock() | 74 | tb.mu.Lock() | ||
| 70 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | 75 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | ||
| 71 | require.Equal(t, 3, tb.nextPaneID) | 76 | require.Equal(t, 3, tb.nextPaneID) | ||
M internal/usecase/daemon/status.go +8 -1
| @@ -204,7 +204,7 @@ func (s *session) statusSegments() statusSnapshot { | |||
| 204 | 204 | } | |
| 205 | 205 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 206 | 206 | for i, tb := range s.tabs { | |
| 207 | - | name := strconv.Itoa(i + 1) | |
| 207 | + | name := tabDisplayName(tb, i) | |
| 208 | 208 | active := i == s.active | |
| 209 | 209 | attention := tb.attention && (!active || tb.attentionVisiblePaint) | |
| 210 | 210 | snap.tabs[i] = statusTab{name: name, active: active, attention: attention} | |
| @@ -212,6 +212,13 @@ func (s *session) statusSegments() statusSnapshot { | |||
| 212 | 212 | 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 | 222 | func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState { | |
| 216 | 223 | state := d.barStateFor(cur, copyFeedback) | |
| 217 | 224 | 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.active | 208 | 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 snap | 212 | 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) { | |||
| 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 | 117 | func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |
| 101 | 118 | p, release := newBlockingPTY(t) | |
| 102 | 119 | _, 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) | ||