feat(tabs): persist custom tab names
- commit
- 81e6be1793604a659582334077b77dcc466d72a8
- author
- brice · · 2026-07-04 01:55 UTC
- parent
- ebfcd26
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +349 -37
M README.md +2 -1
| @@ -65,9 +65,10 @@ The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral se | |||
| 65 | 65 | | SSP | switch session or tab | | |
| 66 | 66 | | VIS | enter visual mode | | |
| 67 | 67 | | RNS | rename session | | |
| 68 | + | | RNT | rename current tab | | |
| 68 | 69 | | DET | detach | | |
| 69 | 70 | ||
| 70 | - | Rename prompts are prefilled with the current session name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty names and names already used by another session are rejected; the prompt stays open and shows the validation error. | |
| 71 | + | Rename prompts are prefilled with the current session or tab name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty session names and names already used by another session are rejected; the prompt stays open and shows the validation error. Custom tab names are saved for named sessions and are not persisted for ephemeral sessions. | |
| 71 | 72 | ||
| 72 | 73 | ## Visual mode | |
| 73 | 74 | ||
| @@ -65,9 +65,10 @@ The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral se | |||||
| 65 | | SSP | switch session or tab | | 65 | | SSP | switch session or tab | | ||
| 66 | | VIS | enter visual mode | | 66 | | VIS | enter visual mode | | ||
| 67 | | RNS | rename session | | 67 | | RNS | rename session | | ||
| 68 | + | | RNT | rename current tab | | |||
| 68 | | DET | detach | | 69 | | DET | detach | | ||
| 69 | 70 | ||||
| 70 | - | Rename prompts are prefilled with the current session name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty names and names already used by another session are rejected; the prompt stays open and shows the validation error. | 71 | + | Rename prompts are prefilled with the current session or tab name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty session names and names already used by another session are rejected; the prompt stays open and shows the validation error. Custom tab names are saved for named sessions and are not persisted for ephemeral sessions. |
| 71 | 72 | ||||
| 72 | ## Visual mode | 73 | ## Visual mode | ||
| 73 | 74 | ||||
M internal/persist/persist.go +3 -1
| @@ -70,13 +70,15 @@ func (p *Persister) Touch(name, cwd string, at int64) error { | |||
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | createdAt := at | |
| 73 | + | var tabNames []string | |
| 73 | 74 | if v, ok := p.store.Get([]byte(name)); ok { | |
| 74 | 75 | if r, err := decodeRecordValue(name, v); err == nil { | |
| 75 | 76 | createdAt = r.CreatedAt | |
| 77 | + | tabNames = r.TabNames | |
| 76 | 78 | } | |
| 77 | 79 | } | |
| 78 | 80 | ||
| 79 | - | value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at}) | |
| 81 | + | value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, TabNames: tabNames}) | |
| 80 | 82 | if err != nil { | |
| 81 | 83 | return err | |
| 82 | 84 | } | |
| @@ -70,13 +70,15 @@ func (p *Persister) Touch(name, cwd string, at int64) error { | |||||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | createdAt := at | 72 | createdAt := at | ||
| 73 | + | var tabNames []string | |||
| 73 | if v, ok := p.store.Get([]byte(name)); ok { | 74 | if v, ok := p.store.Get([]byte(name)); ok { | ||
| 74 | if r, err := decodeRecordValue(name, v); err == nil { | 75 | if r, err := decodeRecordValue(name, v); err == nil { | ||
| 75 | createdAt = r.CreatedAt | 76 | createdAt = r.CreatedAt | ||
| 77 | + | tabNames = r.TabNames | |||
| 76 | } | 78 | } | ||
| 77 | } | 79 | } | ||
| 78 | 80 | ||||
| 79 | - | value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at}) | 81 | + | value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, TabNames: tabNames}) |
| 80 | if err != nil { | 82 | if err != nil { | ||
| 81 | return err | 83 | return err | ||
| 82 | } | 84 | } | ||
M internal/persist/persist_test.go +20 -4
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package persist | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/binary" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "testing" | |
| @@ -12,7 +13,7 @@ import ( | |||
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | func TestRecordCodecRoundTrip(t *testing.T) { | |
| 15 | - | r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22} | |
| 16 | + | r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, TabNames: []string{"shell", "logs"}} | |
| 16 | 17 | value, err := encodeRecordValue(r) | |
| 17 | 18 | require.NoError(t, err) | |
| 18 | 19 | ||
| @@ -21,6 +22,20 @@ func TestRecordCodecRoundTrip(t *testing.T) { | |||
| 21 | 22 | require.Equal(t, r, got) | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 25 | + | func TestRecordCodecReadsLegacyRecordWithoutTabNames(t *testing.T) { | |
| 26 | + | cwd := "/tmp/project" | |
| 27 | + | value := make([]byte, 4+len(cwd)+8+8) | |
| 28 | + | binary.BigEndian.PutUint32(value[:4], uint32(len(cwd))) | |
| 29 | + | copy(value[4:], cwd) | |
| 30 | + | off := 4 + len(cwd) | |
| 31 | + | binary.BigEndian.PutUint64(value[off:off+8], 11) | |
| 32 | + | binary.BigEndian.PutUint64(value[off+8:off+16], 22) | |
| 33 | + | ||
| 34 | + | got, err := decodeRecordValue("work", value) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | require.Equal(t, Record{Name: "work", Cwd: cwd, CreatedAt: 11, UpdatedAt: 22}, got) | |
| 37 | + | } | |
| 38 | + | ||
| 24 | 39 | func TestRecordCodecRejectsMalformedData(t *testing.T) { | |
| 25 | 40 | _, err := encodeRecordValue(Record{Cwd: "/tmp"}) | |
| 26 | 41 | require.ErrorIs(t, err, errEmptyName) | |
| @@ -32,6 +47,7 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) { | |||
| 32 | 47 | {0, 0, 0, 1, 'a', 0}, | |
| 33 | 48 | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | |
| 34 | 49 | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | |
| 50 | + | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255}, | |
| 35 | 51 | } | |
| 36 | 52 | for _, value := range badValues { | |
| 37 | 53 | _, err := decodeRecordValue("name", value) | |
| @@ -67,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||
| 67 | 83 | p, err := Open(dir) | |
| 68 | 84 | require.NoError(t, err) | |
| 69 | 85 | ||
| 70 | - | require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10})) | |
| 86 | + | require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, TabNames: []string{"shell", "logs"}})) | |
| 71 | 87 | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20})) | |
| 72 | 88 | require.NoError(t, p.Touch("b", "/b/next", 30)) | |
| 73 | 89 | ||
| @@ -75,13 +91,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||
| 75 | 91 | require.NoError(t, err) | |
| 76 | 92 | require.Equal(t, []Record{ | |
| 77 | 93 | {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}, | |
| 78 | - | {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}, | |
| 94 | + | {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}, | |
| 79 | 95 | }, records) | |
| 80 | 96 | ||
| 81 | 97 | require.NoError(t, p.Delete("a")) | |
| 82 | 98 | records, err = p.LoadAll() | |
| 83 | 99 | require.NoError(t, err) | |
| 84 | - | require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records) | |
| 100 | + | require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}}, records) | |
| 85 | 101 | require.NoError(t, p.Close()) | |
| 86 | 102 | } | |
| 87 | 103 | ||
| @@ -1,6 +1,7 @@ | |||||
| 1 | package persist | 1 | package persist | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "encoding/binary" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -12,7 +13,7 @@ import ( | |||||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | func TestRecordCodecRoundTrip(t *testing.T) { | 15 | func TestRecordCodecRoundTrip(t *testing.T) { | ||
| 15 | - | r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22} | 16 | + | r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, TabNames: []string{"shell", "logs"}} |
| 16 | value, err := encodeRecordValue(r) | 17 | value, err := encodeRecordValue(r) | ||
| 17 | require.NoError(t, err) | 18 | require.NoError(t, err) | ||
| 18 | 19 | ||||
| @@ -21,6 +22,20 @@ func TestRecordCodecRoundTrip(t *testing.T) { | |||||
| 21 | require.Equal(t, r, got) | 22 | require.Equal(t, r, got) | ||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 25 | + | func TestRecordCodecReadsLegacyRecordWithoutTabNames(t *testing.T) { | |||
| 26 | + | cwd := "/tmp/project" | |||
| 27 | + | value := make([]byte, 4+len(cwd)+8+8) | |||
| 28 | + | binary.BigEndian.PutUint32(value[:4], uint32(len(cwd))) | |||
| 29 | + | copy(value[4:], cwd) | |||
| 30 | + | off := 4 + len(cwd) | |||
| 31 | + | binary.BigEndian.PutUint64(value[off:off+8], 11) | |||
| 32 | + | binary.BigEndian.PutUint64(value[off+8:off+16], 22) | |||
| 33 | + | ||||
| 34 | + | got, err := decodeRecordValue("work", value) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | require.Equal(t, Record{Name: "work", Cwd: cwd, CreatedAt: 11, UpdatedAt: 22}, got) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 24 | func TestRecordCodecRejectsMalformedData(t *testing.T) { | 39 | func TestRecordCodecRejectsMalformedData(t *testing.T) { | ||
| 25 | _, err := encodeRecordValue(Record{Cwd: "/tmp"}) | 40 | _, err := encodeRecordValue(Record{Cwd: "/tmp"}) | ||
| 26 | require.ErrorIs(t, err, errEmptyName) | 41 | require.ErrorIs(t, err, errEmptyName) | ||
| @@ -32,6 +47,7 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) { | |||||
| 32 | {0, 0, 0, 1, 'a', 0}, | 47 | {0, 0, 0, 1, 'a', 0}, | ||
| 33 | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | 48 | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | ||
| 34 | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | 49 | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | ||
| 50 | + | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255}, | |||
| 35 | } | 51 | } | ||
| 36 | for _, value := range badValues { | 52 | for _, value := range badValues { | ||
| 37 | _, err := decodeRecordValue("name", value) | 53 | _, err := decodeRecordValue("name", value) | ||
| @@ -67,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||||
| 67 | p, err := Open(dir) | 83 | p, err := Open(dir) | ||
| 68 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 69 | 85 | ||||
| 70 | - | require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10})) | 86 | + | require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, TabNames: []string{"shell", "logs"}})) |
| 71 | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20})) | 87 | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20})) | ||
| 72 | require.NoError(t, p.Touch("b", "/b/next", 30)) | 88 | require.NoError(t, p.Touch("b", "/b/next", 30)) | ||
| 73 | 89 | ||||
| @@ -75,13 +91,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||||
| 75 | require.NoError(t, err) | 91 | require.NoError(t, err) | ||
| 76 | require.Equal(t, []Record{ | 92 | require.Equal(t, []Record{ | ||
| 77 | {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}, | 93 | {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}, | ||
| 78 | - | {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}, | 94 | + | {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}, |
| 79 | }, records) | 95 | }, records) | ||
| 80 | 96 | ||||
| 81 | require.NoError(t, p.Delete("a")) | 97 | require.NoError(t, p.Delete("a")) | ||
| 82 | records, err = p.LoadAll() | 98 | records, err = p.LoadAll() | ||
| 83 | require.NoError(t, err) | 99 | require.NoError(t, err) | ||
| 84 | - | require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records) | 100 | + | require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}}, records) |
| 85 | require.NoError(t, p.Close()) | 101 | require.NoError(t, p.Close()) | ||
| 86 | } | 102 | } | ||
| 87 | 103 | ||||
M internal/persist/record.go +58 -4
| @@ -13,6 +13,7 @@ type Record struct { | |||
| 13 | 13 | Cwd string | |
| 14 | 14 | CreatedAt int64 | |
| 15 | 15 | UpdatedAt int64 | |
| 16 | + | TabNames []string | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | var ( | |
| @@ -27,13 +28,32 @@ func encodeRecordValue(r Record) ([]byte, error) { | |||
| 27 | 28 | if uint64(len(r.Cwd)) > math.MaxUint32 { | |
| 28 | 29 | return nil, fmt.Errorf("persist: cwd too large") | |
| 29 | 30 | } | |
| 31 | + | if uint64(len(r.TabNames)) > math.MaxUint32 { | |
| 32 | + | return nil, fmt.Errorf("persist: too many tab names") | |
| 33 | + | } | |
| 34 | + | tabNamesSize := 4 | |
| 35 | + | for _, name := range r.TabNames { | |
| 36 | + | if uint64(len(name)) > math.MaxUint32 { | |
| 37 | + | return nil, fmt.Errorf("persist: tab name too large") | |
| 38 | + | } | |
| 39 | + | tabNamesSize += 4 + len(name) | |
| 40 | + | } | |
| 30 | 41 | ||
| 31 | - | buf := make([]byte, 4+len(r.Cwd)+8+8) | |
| 42 | + | buf := make([]byte, 4+len(r.Cwd)+8+8+tabNamesSize) | |
| 32 | 43 | binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd))) | |
| 33 | 44 | copy(buf[4:], r.Cwd) | |
| 34 | 45 | off := 4 + len(r.Cwd) | |
| 35 | 46 | binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt)) | |
| 36 | 47 | binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt)) | |
| 48 | + | off += 16 | |
| 49 | + | binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames))) | |
| 50 | + | off += 4 | |
| 51 | + | for _, name := range r.TabNames { | |
| 52 | + | binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name))) | |
| 53 | + | off += 4 | |
| 54 | + | copy(buf[off:], name) | |
| 55 | + | off += len(name) | |
| 56 | + | } | |
| 37 | 57 | return buf, nil | |
| 38 | 58 | } | |
| 39 | 59 | ||
| @@ -50,14 +70,48 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||
| 50 | 70 | return Record{}, errMalformedRecord | |
| 51 | 71 | } | |
| 52 | 72 | cwdLen := int(cwdLen32) | |
| 53 | - | if len(value) != 4+cwdLen+8+8 { | |
| 73 | + | baseLen := 4 + cwdLen + 8 + 8 | |
| 74 | + | if len(value) < baseLen { | |
| 54 | 75 | return Record{}, errMalformedRecord | |
| 55 | 76 | } | |
| 56 | 77 | off := 4 + cwdLen | |
| 57 | - | return Record{ | |
| 78 | + | r := Record{ | |
| 58 | 79 | Name: name, | |
| 59 | 80 | Cwd: string(value[4:off]), | |
| 60 | 81 | CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])), | |
| 61 | 82 | UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])), | |
| 62 | - | }, nil | |
| 83 | + | } | |
| 84 | + | off += 16 | |
| 85 | + | if len(value) == baseLen { | |
| 86 | + | return r, nil | |
| 87 | + | } | |
| 88 | + | if len(value)-off < 4 { | |
| 89 | + | return Record{}, errMalformedRecord | |
| 90 | + | } | |
| 91 | + | tabCount32 := binary.BigEndian.Uint32(value[off : off+4]) | |
| 92 | + | off += 4 | |
| 93 | + | if uint64(tabCount32) > uint64(len(value)-off)/4 { | |
| 94 | + | return Record{}, errMalformedRecord | |
| 95 | + | } | |
| 96 | + | tabCount := int(tabCount32) | |
| 97 | + | if tabCount > 0 { | |
| 98 | + | r.TabNames = make([]string, 0, tabCount) | |
| 99 | + | } | |
| 100 | + | for range tabCount { | |
| 101 | + | if len(value)-off < 4 { | |
| 102 | + | return Record{}, errMalformedRecord | |
| 103 | + | } | |
| 104 | + | nameLen32 := binary.BigEndian.Uint32(value[off : off+4]) | |
| 105 | + | off += 4 | |
| 106 | + | if uint64(nameLen32) > uint64(len(value)-off) { | |
| 107 | + | return Record{}, errMalformedRecord | |
| 108 | + | } | |
| 109 | + | nameLen := int(nameLen32) | |
| 110 | + | r.TabNames = append(r.TabNames, string(value[off:off+nameLen])) | |
| 111 | + | off += nameLen | |
| 112 | + | } | |
| 113 | + | if off != len(value) { | |
| 114 | + | return Record{}, errMalformedRecord | |
| 115 | + | } | |
| 116 | + | return r, nil | |
| 63 | 117 | } | |
| @@ -13,6 +13,7 @@ type Record struct { | |||||
| 13 | Cwd string | 13 | Cwd string | ||
| 14 | CreatedAt int64 | 14 | CreatedAt int64 | ||
| 15 | UpdatedAt int64 | 15 | UpdatedAt int64 | ||
| 16 | + | TabNames []string | |||
| 16 | } | 17 | } | ||
| 17 | 18 | ||||
| 18 | var ( | 19 | var ( | ||
| @@ -27,13 +28,32 @@ func encodeRecordValue(r Record) ([]byte, error) { | |||||
| 27 | if uint64(len(r.Cwd)) > math.MaxUint32 { | 28 | if uint64(len(r.Cwd)) > math.MaxUint32 { | ||
| 28 | return nil, fmt.Errorf("persist: cwd too large") | 29 | return nil, fmt.Errorf("persist: cwd too large") | ||
| 29 | } | 30 | } | ||
| 31 | + | if uint64(len(r.TabNames)) > math.MaxUint32 { | |||
| 32 | + | return nil, fmt.Errorf("persist: too many tab names") | |||
| 33 | + | } | |||
| 34 | + | tabNamesSize := 4 | |||
| 35 | + | for _, name := range r.TabNames { | |||
| 36 | + | if uint64(len(name)) > math.MaxUint32 { | |||
| 37 | + | return nil, fmt.Errorf("persist: tab name too large") | |||
| 38 | + | } | |||
| 39 | + | tabNamesSize += 4 + len(name) | |||
| 40 | + | } | |||
| 30 | 41 | ||||
| 31 | - | buf := make([]byte, 4+len(r.Cwd)+8+8) | 42 | + | buf := make([]byte, 4+len(r.Cwd)+8+8+tabNamesSize) |
| 32 | binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd))) | 43 | binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd))) | ||
| 33 | copy(buf[4:], r.Cwd) | 44 | copy(buf[4:], r.Cwd) | ||
| 34 | off := 4 + len(r.Cwd) | 45 | off := 4 + len(r.Cwd) | ||
| 35 | binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt)) | 46 | binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt)) | ||
| 36 | binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt)) | 47 | binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt)) | ||
| 48 | + | off += 16 | |||
| 49 | + | binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames))) | |||
| 50 | + | off += 4 | |||
| 51 | + | for _, name := range r.TabNames { | |||
| 52 | + | binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name))) | |||
| 53 | + | off += 4 | |||
| 54 | + | copy(buf[off:], name) | |||
| 55 | + | off += len(name) | |||
| 56 | + | } | |||
| 37 | return buf, nil | 57 | return buf, nil | ||
| 38 | } | 58 | } | ||
| 39 | 59 | ||||
| @@ -50,14 +70,48 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||||
| 50 | return Record{}, errMalformedRecord | 70 | return Record{}, errMalformedRecord | ||
| 51 | } | 71 | } | ||
| 52 | cwdLen := int(cwdLen32) | 72 | cwdLen := int(cwdLen32) | ||
| 53 | - | if len(value) != 4+cwdLen+8+8 { | 73 | + | baseLen := 4 + cwdLen + 8 + 8 |
| 74 | + | if len(value) < baseLen { | |||
| 54 | return Record{}, errMalformedRecord | 75 | return Record{}, errMalformedRecord | ||
| 55 | } | 76 | } | ||
| 56 | off := 4 + cwdLen | 77 | off := 4 + cwdLen | ||
| 57 | - | return Record{ | 78 | + | r := Record{ |
| 58 | Name: name, | 79 | Name: name, | ||
| 59 | Cwd: string(value[4:off]), | 80 | Cwd: string(value[4:off]), | ||
| 60 | CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])), | 81 | CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])), | ||
| 61 | UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])), | 82 | UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])), | ||
| 62 | - | }, nil | 83 | + | } |
| 84 | + | off += 16 | |||
| 85 | + | if len(value) == baseLen { | |||
| 86 | + | return r, nil | |||
| 87 | + | } | |||
| 88 | + | if len(value)-off < 4 { | |||
| 89 | + | return Record{}, errMalformedRecord | |||
| 90 | + | } | |||
| 91 | + | tabCount32 := binary.BigEndian.Uint32(value[off : off+4]) | |||
| 92 | + | off += 4 | |||
| 93 | + | if uint64(tabCount32) > uint64(len(value)-off)/4 { | |||
| 94 | + | return Record{}, errMalformedRecord | |||
| 95 | + | } | |||
| 96 | + | tabCount := int(tabCount32) | |||
| 97 | + | if tabCount > 0 { | |||
| 98 | + | r.TabNames = make([]string, 0, tabCount) | |||
| 99 | + | } | |||
| 100 | + | for range tabCount { | |||
| 101 | + | if len(value)-off < 4 { | |||
| 102 | + | return Record{}, errMalformedRecord | |||
| 103 | + | } | |||
| 104 | + | nameLen32 := binary.BigEndian.Uint32(value[off : off+4]) | |||
| 105 | + | off += 4 | |||
| 106 | + | if uint64(nameLen32) > uint64(len(value)-off) { | |||
| 107 | + | return Record{}, errMalformedRecord | |||
| 108 | + | } | |||
| 109 | + | nameLen := int(nameLen32) | |||
| 110 | + | r.TabNames = append(r.TabNames, string(value[off:off+nameLen])) | |||
| 111 | + | off += nameLen | |||
| 112 | + | } | |||
| 113 | + | if off != len(value) { | |||
| 114 | + | return Record{}, errMalformedRecord | |||
| 115 | + | } | |||
| 116 | + | return r, nil | |||
| 63 | } | 117 | } | ||
M internal/usecase/command/command.go +1 -0
| @@ -10,6 +10,7 @@ type Context interface { | |||
| 10 | 10 | Detach() error | |
| 11 | 11 | EnterVisualMode() error | |
| 12 | 12 | RenameSession() error | |
| 13 | + | RenameTab() error | |
| 13 | 14 | OpenSessionPicker() error | |
| 14 | 15 | } | |
| 15 | 16 | ||
| @@ -10,6 +10,7 @@ type Context interface { | |||||
| 10 | Detach() error | 10 | Detach() error | ||
| 11 | EnterVisualMode() error | 11 | EnterVisualMode() error | ||
| 12 | RenameSession() error | 12 | RenameSession() error | ||
| 13 | + | RenameTab() error | |||
| 13 | OpenSessionPicker() error | 14 | OpenSessionPicker() error | ||
| 14 | } | 15 | } | ||
| 15 | 16 | ||||
M internal/usecase/command/registry.go +1 -0
| @@ -13,6 +13,7 @@ func Registry() []Command { | |||
| 13 | 13 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 14 | 14 | {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | |
| 15 | 15 | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | |
| 16 | + | {Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }}, | |
| 16 | 17 | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 17 | 18 | } | |
| 18 | 19 | ||
| @@ -13,6 +13,7 @@ func Registry() []Command { | |||||
| 13 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | 13 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | ||
| 14 | {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | 14 | {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | ||
| 15 | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | 15 | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | ||
| 16 | + | {Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }}, | |||
| 16 | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | 17 | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | ||
| 17 | } | 18 | } | ||
| 18 | 19 | ||||
M internal/usecase/command/registry_test.go +3 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |
| 10 | 10 | commands := Registry() | |
| 11 | - | wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 11 | + | wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "RNT", "DET"} | |
| 12 | 12 | ||
| 13 | 13 | if len(commands) != len(wantCodes) { | |
| 14 | 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -59,6 +59,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 59 | 59 | {code: "SSP", want: "OpenSessionPicker"}, | |
| 60 | 60 | {code: "VIS", want: "EnterVisualMode"}, | |
| 61 | 61 | {code: "RNS", want: "RenameSession"}, | |
| 62 | + | {code: "RNT", want: "RenameTab"}, | |
| 62 | 63 | {code: "DET", want: "Detach"}, | |
| 63 | 64 | } | |
| 64 | 65 | ||
| @@ -104,4 +105,5 @@ func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | |||
| 104 | 105 | func (s *spyContext) Detach() error { return s.record("Detach") } | |
| 105 | 106 | func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") } | |
| 106 | 107 | func (s *spyContext) RenameSession() error { return s.record("RenameSession") } | |
| 108 | + | func (s *spyContext) RenameTab() error { return s.record("RenameTab") } | |
| 107 | 109 | func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | ||
| 10 | commands := Registry() | 10 | commands := Registry() | ||
| 11 | - | wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | 11 | + | wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "RNT", "DET"} |
| 12 | 12 | ||||
| 13 | if len(commands) != len(wantCodes) { | 13 | if len(commands) != len(wantCodes) { | ||
| 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -59,6 +59,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 59 | {code: "SSP", want: "OpenSessionPicker"}, | 59 | {code: "SSP", want: "OpenSessionPicker"}, | ||
| 60 | {code: "VIS", want: "EnterVisualMode"}, | 60 | {code: "VIS", want: "EnterVisualMode"}, | ||
| 61 | {code: "RNS", want: "RenameSession"}, | 61 | {code: "RNS", want: "RenameSession"}, | ||
| 62 | + | {code: "RNT", want: "RenameTab"}, | |||
| 62 | {code: "DET", want: "Detach"}, | 63 | {code: "DET", want: "Detach"}, | ||
| 63 | } | 64 | } | ||
| 64 | 65 | ||||
| @@ -104,4 +105,5 @@ func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | |||||
| 104 | func (s *spyContext) Detach() error { return s.record("Detach") } | 105 | func (s *spyContext) Detach() error { return s.record("Detach") } | ||
| 105 | func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") } | 106 | func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") } | ||
| 106 | func (s *spyContext) RenameSession() error { return s.record("RenameSession") } | 107 | func (s *spyContext) RenameSession() error { return s.record("RenameSession") } | ||
| 108 | + | func (s *spyContext) RenameTab() error { return s.record("RenameTab") } | |||
| 107 | func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") } | 109 | func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") } | ||
M internal/usecase/daemon/daemon.go +15 -5
| @@ -30,6 +30,7 @@ import ( | |||
| 30 | 30 | "errors" | |
| 31 | 31 | "log/slog" | |
| 32 | 32 | "os" | |
| 33 | + | "slices" | |
| 33 | 34 | "sort" | |
| 34 | 35 | "sync" | |
| 35 | 36 | "time" | |
| @@ -120,9 +121,18 @@ type stoppedSession struct { | |||
| 120 | 121 | name string | |
| 121 | 122 | cwd string | |
| 122 | 123 | createdAt int64 | |
| 124 | + | tabNames []string | |
| 123 | 125 | purging bool | |
| 124 | 126 | } | |
| 125 | 127 | ||
| 128 | + | func (s stoppedSession) same(other stoppedSession) bool { | |
| 129 | + | return s.name == other.name && | |
| 130 | + | s.cwd == other.cwd && | |
| 131 | + | s.createdAt == other.createdAt && | |
| 132 | + | s.purging == other.purging && | |
| 133 | + | slices.Equal(s.tabNames, other.tabNames) | |
| 134 | + | } | |
| 135 | + | ||
| 126 | 136 | type Option func(*Daemon) | |
| 127 | 137 | ||
| 128 | 138 | // WithShell overrides the command (and its args) each session spawns. The | |
| @@ -189,7 +199,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 189 | 199 | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 190 | 200 | } else { | |
| 191 | 201 | for _, r := range records { | |
| 192 | - | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt} | |
| 202 | + | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, tabNames: r.TabNames} | |
| 193 | 203 | } | |
| 194 | 204 | } | |
| 195 | 205 | return d | |
| @@ -370,7 +380,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 370 | 380 | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed")) | |
| 371 | 381 | return | |
| 372 | 382 | } | |
| 373 | - | if cur, ok := d.stopped[k.Name]; ok && cur == stopped { | |
| 383 | + | if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) { | |
| 374 | 384 | delete(d.stopped, k.Name) | |
| 375 | 385 | } | |
| 376 | 386 | d.mu.Unlock() | |
| @@ -455,7 +465,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 455 | 465 | switch h.Intent { | |
| 456 | 466 | case ports.IntentEphemeral: | |
| 457 | 467 | name := d.allocEphemeralNameLocked() | |
| 458 | - | sess, err := d.createSessionLocked(name, true, h.Cwd, sz) | |
| 468 | + | sess, err := d.createSessionLocked(name, true, h.Cwd, sz, nil) | |
| 459 | 469 | if err != nil { | |
| 460 | 470 | d.mu.Unlock() | |
| 461 | 471 | return nil, nil, err | |
| @@ -474,7 +484,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 474 | 484 | d.mu.Unlock() | |
| 475 | 485 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | |
| 476 | 486 | } | |
| 477 | - | sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz) | |
| 487 | + | sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, nil) | |
| 478 | 488 | if err != nil { | |
| 479 | 489 | d.mu.Unlock() | |
| 480 | 490 | return nil, nil, err | |
| @@ -494,7 +504,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 494 | 504 | } | |
| 495 | 505 | cwd := platform.DirOrHome(stopped.cwd) | |
| 496 | 506 | var err error | |
| 497 | - | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |
| 507 | + | sess, err = d.createSessionLocked(h.Name, false, cwd, sz, stopped.tabNames) | |
| 498 | 508 | if err != nil { | |
| 499 | 509 | d.mu.Unlock() | |
| 500 | 510 | return nil, nil, err | |
| @@ -30,6 +30,7 @@ import ( | |||||
| 30 | "errors" | 30 | "errors" | ||
| 31 | "log/slog" | 31 | "log/slog" | ||
| 32 | "os" | 32 | "os" | ||
| 33 | + | "slices" | |||
| 33 | "sort" | 34 | "sort" | ||
| 34 | "sync" | 35 | "sync" | ||
| 35 | "time" | 36 | "time" | ||
| @@ -120,9 +121,18 @@ type stoppedSession struct { | |||||
| 120 | name string | 121 | name string | ||
| 121 | cwd string | 122 | cwd string | ||
| 122 | createdAt int64 | 123 | createdAt int64 | ||
| 124 | + | tabNames []string | |||
| 123 | purging bool | 125 | purging bool | ||
| 124 | } | 126 | } | ||
| 125 | 127 | ||||
| 128 | + | func (s stoppedSession) same(other stoppedSession) bool { | |||
| 129 | + | return s.name == other.name && | |||
| 130 | + | s.cwd == other.cwd && | |||
| 131 | + | s.createdAt == other.createdAt && | |||
| 132 | + | s.purging == other.purging && | |||
| 133 | + | slices.Equal(s.tabNames, other.tabNames) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 126 | type Option func(*Daemon) | 136 | type Option func(*Daemon) | ||
| 127 | 137 | ||||
| 128 | // WithShell overrides the command (and its args) each session spawns. The | 138 | // WithShell overrides the command (and its args) each session spawns. The | ||
| @@ -189,7 +199,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 189 | d.log.Warn("loading persisted sessions failed", "err", err) | 199 | d.log.Warn("loading persisted sessions failed", "err", err) | ||
| 190 | } else { | 200 | } else { | ||
| 191 | for _, r := range records { | 201 | for _, r := range records { | ||
| 192 | - | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt} | 202 | + | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, tabNames: r.TabNames} |
| 193 | } | 203 | } | ||
| 194 | } | 204 | } | ||
| 195 | return d | 205 | return d | ||
| @@ -370,7 +380,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||||
| 370 | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed")) | 380 | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed")) | ||
| 371 | return | 381 | return | ||
| 372 | } | 382 | } | ||
| 373 | - | if cur, ok := d.stopped[k.Name]; ok && cur == stopped { | 383 | + | if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) { |
| 374 | delete(d.stopped, k.Name) | 384 | delete(d.stopped, k.Name) | ||
| 375 | } | 385 | } | ||
| 376 | d.mu.Unlock() | 386 | d.mu.Unlock() | ||
| @@ -455,7 +465,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 455 | switch h.Intent { | 465 | switch h.Intent { | ||
| 456 | case ports.IntentEphemeral: | 466 | case ports.IntentEphemeral: | ||
| 457 | name := d.allocEphemeralNameLocked() | 467 | name := d.allocEphemeralNameLocked() | ||
| 458 | - | sess, err := d.createSessionLocked(name, true, h.Cwd, sz) | 468 | + | sess, err := d.createSessionLocked(name, true, h.Cwd, sz, nil) |
| 459 | if err != nil { | 469 | if err != nil { | ||
| 460 | d.mu.Unlock() | 470 | d.mu.Unlock() | ||
| 461 | return nil, nil, err | 471 | return nil, nil, err | ||
| @@ -474,7 +484,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 474 | d.mu.Unlock() | 484 | d.mu.Unlock() | ||
| 475 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | 485 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | ||
| 476 | } | 486 | } | ||
| 477 | - | sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz) | 487 | + | sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, nil) |
| 478 | if err != nil { | 488 | if err != nil { | ||
| 479 | d.mu.Unlock() | 489 | d.mu.Unlock() | ||
| 480 | return nil, nil, err | 490 | return nil, nil, err | ||
| @@ -494,7 +504,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 494 | } | 504 | } | ||
| 495 | cwd := platform.DirOrHome(stopped.cwd) | 505 | cwd := platform.DirOrHome(stopped.cwd) | ||
| 496 | var err error | 506 | var err error | ||
| 497 | - | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | 507 | + | sess, err = d.createSessionLocked(h.Name, false, cwd, sz, stopped.tabNames) |
| 498 | if err != nil { | 508 | if err != nil { | ||
| 499 | d.mu.Unlock() | 509 | d.mu.Unlock() | ||
| 500 | return nil, nil, err | 510 | return nil, nil, err | ||
M internal/usecase/daemon/input_test.go +23 -0
| @@ -164,6 +164,29 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) { | |||
| 164 | 164 | require.Equal(t, "0", sess.name) | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | + | func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) { | |
| 168 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 169 | + | defer func() { | |
| 170 | + | for _, release := range releases { | |
| 171 | + | release() | |
| 172 | + | } | |
| 173 | + | }() | |
| 174 | + | sess.active = 1 | |
| 175 | + | ||
| 176 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 177 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 178 | + | d.handleInput(sess, ac, []byte("RNT\r")) | |
| 179 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 180 | + | require.True(t, ac.promptActive()) | |
| 181 | + | ||
| 182 | + | d.handleInput(sess, ac, []byte("logs\r")) | |
| 183 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 184 | + | ||
| 185 | + | require.False(t, ac.promptActive()) | |
| 186 | + | require.Equal(t, "logs", sess.tabs[1].name) | |
| 187 | + | require.Empty(t, sess.tabs[0].name) | |
| 188 | + | } | |
| 189 | + | ||
| 167 | 190 | func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |
| 168 | 191 | writes := make(chan []byte, 4) | |
| 169 | 192 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| @@ -164,6 +164,29 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) { | |||||
| 164 | require.Equal(t, "0", sess.name) | 164 | require.Equal(t, "0", sess.name) | ||
| 165 | } | 165 | } | ||
| 166 | 166 | ||||
| 167 | + | func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) { | |||
| 168 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 169 | + | defer func() { | |||
| 170 | + | for _, release := range releases { | |||
| 171 | + | release() | |||
| 172 | + | } | |||
| 173 | + | }() | |||
| 174 | + | sess.active = 1 | |||
| 175 | + | ||||
| 176 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 177 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 178 | + | d.handleInput(sess, ac, []byte("RNT\r")) | |||
| 179 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 180 | + | require.True(t, ac.promptActive()) | |||
| 181 | + | ||||
| 182 | + | d.handleInput(sess, ac, []byte("logs\r")) | |||
| 183 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 184 | + | ||||
| 185 | + | require.False(t, ac.promptActive()) | |||
| 186 | + | require.Equal(t, "logs", sess.tabs[1].name) | |||
| 187 | + | require.Empty(t, sess.tabs[0].name) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 167 | func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | 190 | func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | ||
| 168 | writes := make(chan []byte, 4) | 191 | writes := make(chan []byte, 4) | ||
| 169 | p, _ := newBlockingPTYWithWrites(t, writes) | 192 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
M internal/usecase/daemon/palette.go +14 -0
| @@ -147,6 +147,20 @@ func (e paletteExec) RenameSession() error { | |||
| 147 | 147 | return nil | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | + | func (e paletteExec) RenameTab() error { | |
| 151 | + | tb := e.sess.activeTab() | |
| 152 | + | if tb == nil { | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | e.sess.mu.Lock() | |
| 156 | + | currentName := tb.name | |
| 157 | + | e.sess.mu.Unlock() | |
| 158 | + | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | |
| 159 | + | return e.d.renameTab(e.sess, tb, name) | |
| 160 | + | }) | |
| 161 | + | return nil | |
| 162 | + | } | |
| 163 | + | ||
| 150 | 164 | func (e paletteExec) OpenSessionPicker() error { | |
| 151 | 165 | e.d.enterPicker(e.sess, e.ac) | |
| 152 | 166 | return nil | |
| @@ -147,6 +147,20 @@ func (e paletteExec) RenameSession() error { | |||||
| 147 | return nil | 147 | return nil | ||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | + | func (e paletteExec) RenameTab() error { | |||
| 151 | + | tb := e.sess.activeTab() | |||
| 152 | + | if tb == nil { | |||
| 153 | + | return nil | |||
| 154 | + | } | |||
| 155 | + | e.sess.mu.Lock() | |||
| 156 | + | currentName := tb.name | |||
| 157 | + | e.sess.mu.Unlock() | |||
| 158 | + | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | |||
| 159 | + | return e.d.renameTab(e.sess, tb, name) | |||
| 160 | + | }) | |||
| 161 | + | return nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 150 | func (e paletteExec) OpenSessionPicker() error { | 164 | func (e paletteExec) OpenSessionPicker() error { | ||
| 151 | e.d.enterPicker(e.sess, e.ac) | 165 | e.d.enterPicker(e.sess, e.ac) | ||
| 152 | return nil | 166 | return nil | ||
M internal/usecase/daemon/picker.go +2 -2
| @@ -420,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 420 | 420 | return | |
| 421 | 421 | } | |
| 422 | 422 | cwd := platform.DirOrHome(stopped.cwd) | |
| 423 | - | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | |
| 423 | + | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, stopped.tabNames) | |
| 424 | 424 | if err != nil { | |
| 425 | 425 | from.mu.Unlock() | |
| 426 | 426 | d.mu.Unlock() | |
| @@ -449,7 +449,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||
| 449 | 449 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |
| 450 | 450 | return | |
| 451 | 451 | } | |
| 452 | - | if cur, ok := d.stopped[target.Name]; ok && cur == stopped { | |
| 452 | + | if cur, ok := d.stopped[target.Name]; ok && cur.same(stopped) { | |
| 453 | 453 | delete(d.stopped, target.Name) | |
| 454 | 454 | } | |
| 455 | 455 | } | |
| @@ -420,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 420 | return | 420 | return | ||
| 421 | } | 421 | } | ||
| 422 | cwd := platform.DirOrHome(stopped.cwd) | 422 | cwd := platform.DirOrHome(stopped.cwd) | ||
| 423 | - | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | 423 | + | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, stopped.tabNames) |
| 424 | if err != nil { | 424 | if err != nil { | ||
| 425 | from.mu.Unlock() | 425 | from.mu.Unlock() | ||
| 426 | d.mu.Unlock() | 426 | d.mu.Unlock() | ||
| @@ -449,7 +449,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||||
| 449 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | 449 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | ||
| 450 | return | 450 | return | ||
| 451 | } | 451 | } | ||
| 452 | - | if cur, ok := d.stopped[target.Name]; ok && cur == stopped { | 452 | + | if cur, ok := d.stopped[target.Name]; ok && cur.same(stopped) { |
| 453 | delete(d.stopped, target.Name) | 453 | delete(d.stopped, target.Name) | ||
| 454 | } | 454 | } | ||
| 455 | } | 455 | } | ||
M internal/usecase/daemon/picker_test.go +23 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | 13 | "github.com/bnema/vev/internal/usecase/keys" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 14 | 15 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 16 | "github.com/bnema/vev/pkg/vt" | |
| 16 | 17 | ) | |
| @@ -55,6 +56,28 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 55 | 56 | require.Equal(t, []string{"1", "2 "}, views[1].Tabs) | |
| 56 | 57 | } | |
| 57 | 58 | ||
| 59 | + | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |
| 60 | + | p1, release1 := newBlockingPTY(t) | |
| 61 | + | p2, release2 := newBlockingPTY(t) | |
| 62 | + | p3, release3 := newBlockingPTY(t) | |
| 63 | + | defer release1() | |
| 64 | + | defer release2() | |
| 65 | + | defer release3() | |
| 66 | + | d, from, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 67 | + | d.ptys = newFactorySeq(t, p2, p3) | |
| 68 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}} | |
| 69 | + | ||
| 70 | + | d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true}) | |
| 71 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 72 | + | ||
| 73 | + | target := ac.currentSession() | |
| 74 | + | require.NotNil(t, target) | |
| 75 | + | require.Equal(t, "work", target.name) | |
| 76 | + | require.Len(t, target.tabs, 2) | |
| 77 | + | require.Equal(t, "shell", target.tabs[0].name) | |
| 78 | + | require.Equal(t, "logs", target.tabs[1].name) | |
| 79 | + | } | |
| 80 | + | ||
| 58 | 81 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |
| 59 | 82 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 60 | 83 | defer func() { | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 13 | "github.com/bnema/vev/internal/usecase/keys" | 13 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 14 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 14 | "github.com/bnema/vev/pkg/renderer" | 15 | "github.com/bnema/vev/pkg/renderer" | ||
| 15 | "github.com/bnema/vev/pkg/vt" | 16 | "github.com/bnema/vev/pkg/vt" | ||
| 16 | ) | 17 | ) | ||
| @@ -55,6 +56,28 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||||
| 55 | require.Equal(t, []string{"1", "2 "}, views[1].Tabs) | 56 | require.Equal(t, []string{"1", "2 "}, views[1].Tabs) | ||
| 56 | } | 57 | } | ||
| 57 | 58 | ||||
| 59 | + | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |||
| 60 | + | p1, release1 := newBlockingPTY(t) | |||
| 61 | + | p2, release2 := newBlockingPTY(t) | |||
| 62 | + | p3, release3 := newBlockingPTY(t) | |||
| 63 | + | defer release1() | |||
| 64 | + | defer release2() | |||
| 65 | + | defer release3() | |||
| 66 | + | d, from, ac, sends := newManualSessionWithPTYs(t, p1) | |||
| 67 | + | d.ptys = newFactorySeq(t, p2, p3) | |||
| 68 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}} | |||
| 69 | + | ||||
| 70 | + | d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true}) | |||
| 71 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 72 | + | ||||
| 73 | + | target := ac.currentSession() | |||
| 74 | + | require.NotNil(t, target) | |||
| 75 | + | require.Equal(t, "work", target.name) | |||
| 76 | + | require.Len(t, target.tabs, 2) | |||
| 77 | + | require.Equal(t, "shell", target.tabs[0].name) | |||
| 78 | + | require.Equal(t, "logs", target.tabs[1].name) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 58 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | 81 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | ||
| 59 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | 82 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | ||
| 60 | defer func() { | 83 | defer func() { | ||
M internal/usecase/daemon/session.go +117 -13
| @@ -61,6 +61,7 @@ type session struct { | |||
| 61 | 61 | // already pending"), never by blocking the reader. | |
| 62 | 62 | type tab struct { | |
| 63 | 63 | pty ports.PTY | |
| 64 | + | name string | |
| 64 | 65 | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | |
| 65 | 66 | screen *vt.Screen | |
| 66 | 67 | scrollback *scopy.Scrollback | |
| @@ -83,40 +84,58 @@ type tab struct { | |||
| 83 | 84 | // what it has actually seen). sendMu serialises the two senders — the render | |
| 84 | 85 | // scheduler and the connection handler — so the transport's single-writer | |
| 85 | 86 | ||
| 86 | - | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) { | |
| 87 | + | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, tabNames []string) (*session, error) { | |
| 87 | 88 | tbSize := tabSize(sz) | |
| 88 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 89 | - | if err != nil { | |
| 90 | - | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | |
| 89 | + | names := append([]string(nil), tabNames...) | |
| 90 | + | tabCount := max(1, len(names)) | |
| 91 | + | tabs := make([]*tab, 0, tabCount) | |
| 92 | + | for i := range tabCount { | |
| 93 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 94 | + | if err != nil { | |
| 95 | + | for _, tb := range tabs { | |
| 96 | + | _ = tb.pty.Close() | |
| 97 | + | } | |
| 98 | + | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | |
| 99 | + | } | |
| 100 | + | tb := newTab(pty, tbSize) | |
| 101 | + | if i < len(names) { | |
| 102 | + | tb.name = names[i] | |
| 103 | + | } | |
| 104 | + | tabs = append(tabs, tb) | |
| 91 | 105 | } | |
| 92 | 106 | ||
| 93 | 107 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 94 | 108 | d.nextID++ | |
| 95 | 109 | createdAt := time.Now().UnixNano() | |
| 96 | 110 | ||
| 97 | - | tb := newTab(pty, tbSize) | |
| 98 | 111 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 99 | - | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 112 | + | for _, tb := range tabs { | |
| 113 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 114 | + | } | |
| 100 | 115 | sess := &session{ | |
| 101 | 116 | id: id, | |
| 102 | 117 | name: name, | |
| 103 | 118 | ephemeral: ephemeral, | |
| 104 | 119 | ctx: sctx, | |
| 105 | 120 | cancel: cancel, | |
| 106 | - | tabs: []*tab{tb}, | |
| 121 | + | tabs: tabs, | |
| 107 | 122 | cwd: cwd, | |
| 108 | 123 | createdAt: createdAt, | |
| 109 | 124 | } | |
| 110 | 125 | if !ephemeral { | |
| 111 | - | if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil { | |
| 112 | - | _ = pty.Close() | |
| 126 | + | if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, TabNames: names}); err != nil { | |
| 127 | + | for _, tb := range tabs { | |
| 128 | + | _ = tb.pty.Close() | |
| 129 | + | } | |
| 113 | 130 | cancel() | |
| 114 | 131 | return nil, err | |
| 115 | 132 | } | |
| 116 | 133 | delete(d.stopped, name) | |
| 117 | 134 | } | |
| 118 | 135 | d.sessions[id] = sess | |
| 119 | - | d.startTabGoroutines(sess, tb) | |
| 136 | + | for _, tb := range tabs { | |
| 137 | + | d.startTabGoroutines(sess, tb) | |
| 138 | + | } | |
| 120 | 139 | return sess, nil | |
| 121 | 140 | } | |
| 122 | 141 | ||
| @@ -143,7 +162,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 143 | 162 | } | |
| 144 | 163 | from.mu.Unlock() | |
| 145 | 164 | ||
| 146 | - | newSess, err := d.createSessionLocked(name, false, cwd, sz) | |
| 165 | + | newSess, err := d.createSessionLocked(name, false, cwd, sz, nil) | |
| 147 | 166 | if err != nil { | |
| 148 | 167 | d.mu.Unlock() | |
| 149 | 168 | return err | |
| @@ -193,8 +212,21 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 193 | 212 | } | |
| 194 | 213 | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) | |
| 195 | 214 | sess.mu.Lock() | |
| 215 | + | oldActive := sess.active | |
| 196 | 216 | sess.tabs = append(sess.tabs, tb) | |
| 197 | 217 | sess.active = len(sess.tabs) - 1 | |
| 218 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) | |
| 219 | + | ephemeral := sess.ephemeral | |
| 220 | + | if !ephemeral { | |
| 221 | + | if err := d.persist.Save(record); err != nil { | |
| 222 | + | sess.tabs = sess.tabs[:len(sess.tabs)-1] | |
| 223 | + | sess.active = oldActive | |
| 224 | + | sess.mu.Unlock() | |
| 225 | + | d.mu.Unlock() | |
| 226 | + | _ = pty.Close() | |
| 227 | + | return err | |
| 228 | + | } | |
| 229 | + | } | |
| 198 | 230 | sess.mu.Unlock() | |
| 199 | 231 | d.startTabGoroutines(sess, tb) | |
| 200 | 232 | d.mu.Unlock() | |
| @@ -294,7 +326,10 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 294 | 326 | sess.createdAt = createdAt | |
| 295 | 327 | } | |
| 296 | 328 | if wasEphemeral || oldName != name { | |
| 297 | - | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil { | |
| 329 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) | |
| 330 | + | record.Name = name | |
| 331 | + | record.CreatedAt = createdAt | |
| 332 | + | if err := d.persist.Save(record); err != nil { | |
| 298 | 333 | return err | |
| 299 | 334 | } | |
| 300 | 335 | } | |
| @@ -313,6 +348,67 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 313 | 348 | return nil | |
| 314 | 349 | } | |
| 315 | 350 | ||
| 351 | + | func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | |
| 352 | + | if tb == nil { | |
| 353 | + | return errors.New("tab required") | |
| 354 | + | } | |
| 355 | + | d.mu.Lock() | |
| 356 | + | if d.sessions[sess.id] != sess { | |
| 357 | + | d.mu.Unlock() | |
| 358 | + | return errors.New("daemon: session closed") | |
| 359 | + | } | |
| 360 | + | sess.mu.Lock() | |
| 361 | + | found := false | |
| 362 | + | for _, candidate := range sess.tabs { | |
| 363 | + | if candidate == tb { | |
| 364 | + | found = true | |
| 365 | + | break | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | if !found { | |
| 369 | + | sess.mu.Unlock() | |
| 370 | + | d.mu.Unlock() | |
| 371 | + | return errors.New("tab not found") | |
| 372 | + | } | |
| 373 | + | oldName := tb.name | |
| 374 | + | tb.name = name | |
| 375 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) | |
| 376 | + | ephemeral := sess.ephemeral | |
| 377 | + | if !ephemeral { | |
| 378 | + | if err := d.persist.Save(record); err != nil { | |
| 379 | + | tb.name = oldName | |
| 380 | + | sess.mu.Unlock() | |
| 381 | + | d.mu.Unlock() | |
| 382 | + | return err | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | sess.mu.Unlock() | |
| 386 | + | d.mu.Unlock() | |
| 387 | + | return nil | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | |
| 391 | + | createdAt := s.createdAt | |
| 392 | + | if createdAt == 0 { | |
| 393 | + | createdAt = updatedAt | |
| 394 | + | s.createdAt = createdAt | |
| 395 | + | } | |
| 396 | + | tabNames := make([]string, len(s.tabs)) | |
| 397 | + | lastCustom := -1 | |
| 398 | + | for i, tb := range s.tabs { | |
| 399 | + | tabNames[i] = tb.name | |
| 400 | + | if tb.name != "" { | |
| 401 | + | lastCustom = i | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | if lastCustom == -1 { | |
| 405 | + | tabNames = nil | |
| 406 | + | } else { | |
| 407 | + | tabNames = tabNames[:lastCustom+1] | |
| 408 | + | } | |
| 409 | + | return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, TabNames: tabNames} | |
| 410 | + | } | |
| 411 | + | ||
| 316 | 412 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |
| 317 | 413 | sess.mu.Lock() | |
| 318 | 414 | idx := -1 | |
| @@ -339,8 +435,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 339 | 435 | sess.active-- | |
| 340 | 436 | } | |
| 341 | 437 | ac := sess.client | |
| 438 | + | ephemeral := sess.ephemeral | |
| 439 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) | |
| 342 | 440 | sess.mu.Unlock() | |
| 343 | 441 | ||
| 442 | + | if !ephemeral { | |
| 443 | + | if err := d.persist.Save(record); err != nil { | |
| 444 | + | d.log.Warn("persisting closed tab failed", "err", err, "session", record.Name) | |
| 445 | + | } | |
| 446 | + | } | |
| 344 | 447 | d.clearDestroyedTabPreview(tb) | |
| 345 | 448 | if tb.cancel != nil { | |
| 346 | 449 | tb.cancel() | |
| @@ -375,10 +478,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 375 | 478 | stoppedName := sess.name | |
| 376 | 479 | stoppedCwd := sess.cwd | |
| 377 | 480 | createdAt := sess.createdAt | |
| 481 | + | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |
| 378 | 482 | ephemeral := sess.ephemeral | |
| 379 | 483 | sess.mu.Unlock() | |
| 380 | 484 | if !ephemeral { | |
| 381 | - | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, purging: purge} | |
| 485 | + | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, tabNames: tabNames, purging: purge} | |
| 382 | 486 | d.stopped[stoppedName] = stopped | |
| 383 | 487 | } | |
| 384 | 488 | empty := len(d.sessions) == 0 | |
| @@ -61,6 +61,7 @@ type session struct { | |||||
| 61 | // already pending"), never by blocking the reader. | 61 | // already pending"), never by blocking the reader. | ||
| 62 | type tab struct { | 62 | type tab struct { | ||
| 63 | pty ports.PTY | 63 | pty ports.PTY | ||
| 64 | + | name string | |||
| 64 | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | 65 | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | ||
| 65 | screen *vt.Screen | 66 | screen *vt.Screen | ||
| 66 | scrollback *scopy.Scrollback | 67 | scrollback *scopy.Scrollback | ||
| @@ -83,40 +84,58 @@ type tab struct { | |||||
| 83 | // what it has actually seen). sendMu serialises the two senders — the render | 84 | // what it has actually seen). sendMu serialises the two senders — the render | ||
| 84 | // scheduler and the connection handler — so the transport's single-writer | 85 | // scheduler and the connection handler — so the transport's single-writer | ||
| 85 | 86 | ||||
| 86 | - | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) { | 87 | + | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, tabNames []string) (*session, error) { |
| 87 | tbSize := tabSize(sz) | 88 | tbSize := tabSize(sz) | ||
| 88 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | 89 | + | names := append([]string(nil), tabNames...) |
| 89 | - | if err != nil { | 90 | + | tabCount := max(1, len(names)) |
| 90 | - | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | 91 | + | tabs := make([]*tab, 0, tabCount) |
| 92 | + | for i := range tabCount { | |||
| 93 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |||
| 94 | + | if err != nil { | |||
| 95 | + | for _, tb := range tabs { | |||
| 96 | + | _ = tb.pty.Close() | |||
| 97 | + | } | |||
| 98 | + | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | |||
| 99 | + | } | |||
| 100 | + | tb := newTab(pty, tbSize) | |||
| 101 | + | if i < len(names) { | |||
| 102 | + | tb.name = names[i] | |||
| 103 | + | } | |||
| 104 | + | tabs = append(tabs, tb) | |||
| 91 | } | 105 | } | ||
| 92 | 106 | ||||
| 93 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | 107 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | ||
| 94 | d.nextID++ | 108 | d.nextID++ | ||
| 95 | createdAt := time.Now().UnixNano() | 109 | createdAt := time.Now().UnixNano() | ||
| 96 | 110 | ||||
| 97 | - | tb := newTab(pty, tbSize) | |||
| 98 | sctx, cancel := context.WithCancel(d.serveCtx) | 111 | sctx, cancel := context.WithCancel(d.serveCtx) | ||
| 99 | - | tb.ctx, tb.cancel = context.WithCancel(sctx) | 112 | + | for _, tb := range tabs { |
| 113 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) | |||
| 114 | + | } | |||
| 100 | sess := &session{ | 115 | sess := &session{ | ||
| 101 | id: id, | 116 | id: id, | ||
| 102 | name: name, | 117 | name: name, | ||
| 103 | ephemeral: ephemeral, | 118 | ephemeral: ephemeral, | ||
| 104 | ctx: sctx, | 119 | ctx: sctx, | ||
| 105 | cancel: cancel, | 120 | cancel: cancel, | ||
| 106 | - | tabs: []*tab{tb}, | 121 | + | tabs: tabs, |
| 107 | cwd: cwd, | 122 | cwd: cwd, | ||
| 108 | createdAt: createdAt, | 123 | createdAt: createdAt, | ||
| 109 | } | 124 | } | ||
| 110 | if !ephemeral { | 125 | if !ephemeral { | ||
| 111 | - | if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil { | 126 | + | if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, TabNames: names}); err != nil { |
| 112 | - | _ = pty.Close() | 127 | + | for _, tb := range tabs { |
| 128 | + | _ = tb.pty.Close() | |||
| 129 | + | } | |||
| 113 | cancel() | 130 | cancel() | ||
| 114 | return nil, err | 131 | return nil, err | ||
| 115 | } | 132 | } | ||
| 116 | delete(d.stopped, name) | 133 | delete(d.stopped, name) | ||
| 117 | } | 134 | } | ||
| 118 | d.sessions[id] = sess | 135 | d.sessions[id] = sess | ||
| 119 | - | d.startTabGoroutines(sess, tb) | 136 | + | for _, tb := range tabs { |
| 137 | + | d.startTabGoroutines(sess, tb) | |||
| 138 | + | } | |||
| 120 | return sess, nil | 139 | return sess, nil | ||
| 121 | } | 140 | } | ||
| 122 | 141 | ||||
| @@ -143,7 +162,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 143 | } | 162 | } | ||
| 144 | from.mu.Unlock() | 163 | from.mu.Unlock() | ||
| 145 | 164 | ||||
| 146 | - | newSess, err := d.createSessionLocked(name, false, cwd, sz) | 165 | + | newSess, err := d.createSessionLocked(name, false, cwd, sz, nil) |
| 147 | if err != nil { | 166 | if err != nil { | ||
| 148 | d.mu.Unlock() | 167 | d.mu.Unlock() | ||
| 149 | return err | 168 | return err | ||
| @@ -193,8 +212,21 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 193 | } | 212 | } | ||
| 194 | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) | 213 | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) | ||
| 195 | sess.mu.Lock() | 214 | sess.mu.Lock() | ||
| 215 | + | oldActive := sess.active | |||
| 196 | sess.tabs = append(sess.tabs, tb) | 216 | sess.tabs = append(sess.tabs, tb) | ||
| 197 | sess.active = len(sess.tabs) - 1 | 217 | sess.active = len(sess.tabs) - 1 | ||
| 218 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) | |||
| 219 | + | ephemeral := sess.ephemeral | |||
| 220 | + | if !ephemeral { | |||
| 221 | + | if err := d.persist.Save(record); err != nil { | |||
| 222 | + | sess.tabs = sess.tabs[:len(sess.tabs)-1] | |||
| 223 | + | sess.active = oldActive | |||
| 224 | + | sess.mu.Unlock() | |||
| 225 | + | d.mu.Unlock() | |||
| 226 | + | _ = pty.Close() | |||
| 227 | + | return err | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 198 | sess.mu.Unlock() | 230 | sess.mu.Unlock() | ||
| 199 | d.startTabGoroutines(sess, tb) | 231 | d.startTabGoroutines(sess, tb) | ||
| 200 | d.mu.Unlock() | 232 | d.mu.Unlock() | ||
| @@ -294,7 +326,10 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 294 | sess.createdAt = createdAt | 326 | sess.createdAt = createdAt | ||
| 295 | } | 327 | } | ||
| 296 | if wasEphemeral || oldName != name { | 328 | if wasEphemeral || oldName != name { | ||
| 297 | - | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil { | 329 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) |
| 330 | + | record.Name = name | |||
| 331 | + | record.CreatedAt = createdAt | |||
| 332 | + | if err := d.persist.Save(record); err != nil { | |||
| 298 | return err | 333 | return err | ||
| 299 | } | 334 | } | ||
| 300 | } | 335 | } | ||
| @@ -313,6 +348,67 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 313 | return nil | 348 | return nil | ||
| 314 | } | 349 | } | ||
| 315 | 350 | ||||
| 351 | + | func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | |||
| 352 | + | if tb == nil { | |||
| 353 | + | return errors.New("tab required") | |||
| 354 | + | } | |||
| 355 | + | d.mu.Lock() | |||
| 356 | + | if d.sessions[sess.id] != sess { | |||
| 357 | + | d.mu.Unlock() | |||
| 358 | + | return errors.New("daemon: session closed") | |||
| 359 | + | } | |||
| 360 | + | sess.mu.Lock() | |||
| 361 | + | found := false | |||
| 362 | + | for _, candidate := range sess.tabs { | |||
| 363 | + | if candidate == tb { | |||
| 364 | + | found = true | |||
| 365 | + | break | |||
| 366 | + | } | |||
| 367 | + | } | |||
| 368 | + | if !found { | |||
| 369 | + | sess.mu.Unlock() | |||
| 370 | + | d.mu.Unlock() | |||
| 371 | + | return errors.New("tab not found") | |||
| 372 | + | } | |||
| 373 | + | oldName := tb.name | |||
| 374 | + | tb.name = name | |||
| 375 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) | |||
| 376 | + | ephemeral := sess.ephemeral | |||
| 377 | + | if !ephemeral { | |||
| 378 | + | if err := d.persist.Save(record); err != nil { | |||
| 379 | + | tb.name = oldName | |||
| 380 | + | sess.mu.Unlock() | |||
| 381 | + | d.mu.Unlock() | |||
| 382 | + | return err | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | sess.mu.Unlock() | |||
| 386 | + | d.mu.Unlock() | |||
| 387 | + | return nil | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | |||
| 391 | + | createdAt := s.createdAt | |||
| 392 | + | if createdAt == 0 { | |||
| 393 | + | createdAt = updatedAt | |||
| 394 | + | s.createdAt = createdAt | |||
| 395 | + | } | |||
| 396 | + | tabNames := make([]string, len(s.tabs)) | |||
| 397 | + | lastCustom := -1 | |||
| 398 | + | for i, tb := range s.tabs { | |||
| 399 | + | tabNames[i] = tb.name | |||
| 400 | + | if tb.name != "" { | |||
| 401 | + | lastCustom = i | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | if lastCustom == -1 { | |||
| 405 | + | tabNames = nil | |||
| 406 | + | } else { | |||
| 407 | + | tabNames = tabNames[:lastCustom+1] | |||
| 408 | + | } | |||
| 409 | + | return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, TabNames: tabNames} | |||
| 410 | + | } | |||
| 411 | + | ||||
| 316 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | 412 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | ||
| 317 | sess.mu.Lock() | 413 | sess.mu.Lock() | ||
| 318 | idx := -1 | 414 | idx := -1 | ||
| @@ -339,8 +435,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 339 | sess.active-- | 435 | sess.active-- | ||
| 340 | } | 436 | } | ||
| 341 | ac := sess.client | 437 | ac := sess.client | ||
| 438 | + | ephemeral := sess.ephemeral | |||
| 439 | + | record := sess.persistRecordLocked(time.Now().UnixNano()) | |||
| 342 | sess.mu.Unlock() | 440 | sess.mu.Unlock() | ||
| 343 | 441 | ||||
| 442 | + | if !ephemeral { | |||
| 443 | + | if err := d.persist.Save(record); err != nil { | |||
| 444 | + | d.log.Warn("persisting closed tab failed", "err", err, "session", record.Name) | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 344 | d.clearDestroyedTabPreview(tb) | 447 | d.clearDestroyedTabPreview(tb) | ||
| 345 | if tb.cancel != nil { | 448 | if tb.cancel != nil { | ||
| 346 | tb.cancel() | 449 | tb.cancel() | ||
| @@ -375,10 +478,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 375 | stoppedName := sess.name | 478 | stoppedName := sess.name | ||
| 376 | stoppedCwd := sess.cwd | 479 | stoppedCwd := sess.cwd | ||
| 377 | createdAt := sess.createdAt | 480 | createdAt := sess.createdAt | ||
| 481 | + | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |||
| 378 | ephemeral := sess.ephemeral | 482 | ephemeral := sess.ephemeral | ||
| 379 | sess.mu.Unlock() | 483 | sess.mu.Unlock() | ||
| 380 | if !ephemeral { | 484 | if !ephemeral { | ||
| 381 | - | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, purging: purge} | 485 | + | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, tabNames: tabNames, purging: purge} |
| 382 | d.stopped[stoppedName] = stopped | 486 | d.stopped[stoppedName] = stopped | ||
| 383 | } | 487 | } | ||
| 384 | empty := len(d.sessions) == 0 | 488 | empty := len(d.sessions) == 0 | ||
M internal/usecase/daemon/session_test.go +63 -5
| @@ -667,7 +667,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||
| 667 | 667 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 668 | 668 | WithStore(store)(d) | |
| 669 | 669 | ||
| 670 | - | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |
| 670 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil) | |
| 671 | 671 | require.NoError(t, err) | |
| 672 | 672 | require.True(t, state.has("work")) | |
| 673 | 673 | ||
| @@ -679,6 +679,64 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||
| 679 | 679 | require.False(t, state.has("renamed")) | |
| 680 | 680 | } | |
| 681 | 681 | ||
| 682 | + | func TestRenameTabPersistsForNamedSession(t *testing.T) { | |
| 683 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 684 | + | p, release := newBlockingPTY(t) | |
| 685 | + | defer release() | |
| 686 | + | store, _ := newMockStore(t) | |
| 687 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 688 | + | WithStore(store)(d) | |
| 689 | + | ||
| 690 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil) | |
| 691 | + | require.NoError(t, err) | |
| 692 | + | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |
| 693 | + | ||
| 694 | + | records, err := d.persist.LoadAll() | |
| 695 | + | require.NoError(t, err) | |
| 696 | + | require.Equal(t, []persist.Record{{Name: "work", Cwd: "/tmp/work", CreatedAt: sess.createdAt, UpdatedAt: records[0].UpdatedAt, TabNames: []string{"shell"}}}, records) | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) { | |
| 700 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 701 | + | p, release := newBlockingPTY(t) | |
| 702 | + | defer release() | |
| 703 | + | store, state := newMockStore(t) | |
| 704 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 705 | + | WithStore(store)(d) | |
| 706 | + | ||
| 707 | + | sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, nil) | |
| 708 | + | require.NoError(t, err) | |
| 709 | + | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |
| 710 | + | ||
| 711 | + | require.False(t, state.has("0")) | |
| 712 | + | records, err := d.persist.LoadAll() | |
| 713 | + | require.NoError(t, err) | |
| 714 | + | require.Empty(t, records) | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | func TestAttachRestoresPersistedTabNames(t *testing.T) { | |
| 718 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 719 | + | p1, release1 := newBlockingPTY(t) | |
| 720 | + | p2, release2 := newBlockingPTY(t) | |
| 721 | + | defer release1() | |
| 722 | + | defer release2() | |
| 723 | + | store, _ := newMockStore(t) | |
| 724 | + | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 725 | + | WithStore(store)(d) | |
| 726 | + | require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}})) | |
| 727 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}} | |
| 728 | + | tr := portsmocks.NewMockTransport(t) | |
| 729 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 730 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 731 | + | ||
| 732 | + | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr) | |
| 733 | + | require.NoError(t, err) | |
| 734 | + | require.NotNil(t, ac) | |
| 735 | + | require.Len(t, sess.tabs, 2) | |
| 736 | + | require.Equal(t, "shell", sess.tabs[0].name) | |
| 737 | + | require.Equal(t, "logs", sess.tabs[1].name) | |
| 738 | + | } | |
| 739 | + | ||
| 682 | 740 | func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |
| 683 | 741 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 684 | 742 | p, release := newBlockingPTY(t) | |
| @@ -688,7 +746,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |||
| 688 | 746 | WithStore(store)(d) | |
| 689 | 747 | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | |
| 690 | 748 | ||
| 691 | - | sess, err := d.createSessionLocked("0", true, "/tmp/e", sz) | |
| 749 | + | sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, nil) | |
| 692 | 750 | require.NoError(t, err) | |
| 693 | 751 | require.False(t, state.has("0")) | |
| 694 | 752 | require.EqualError(t, d.renameSession(sess, "taken"), "name already in use") | |
| @@ -706,7 +764,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | |||
| 706 | 764 | cwd := "/tmp/work" | |
| 707 | 765 | WithCwdReader(func(int) (string, error) { return cwd, nil })(d) | |
| 708 | 766 | ||
| 709 | - | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |
| 767 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil) | |
| 710 | 768 | require.NoError(t, err) | |
| 711 | 769 | state.mu.Lock() | |
| 712 | 770 | setsAfterCreate := state.sets | |
| @@ -798,9 +856,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |||
| 798 | 856 | WithStore(store)(d) | |
| 799 | 857 | WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d) | |
| 800 | 858 | ||
| 801 | - | natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz) | |
| 859 | + | natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, nil) | |
| 802 | 860 | require.NoError(t, err) | |
| 803 | - | other, err := d.createSessionLocked("other", false, "/tmp/other", sz) | |
| 861 | + | other, err := d.createSessionLocked("other", false, "/tmp/other", sz, nil) | |
| 804 | 862 | require.NoError(t, err) | |
| 805 | 863 | _ = d.killSession(natural, ports.ReasonSessionKilled, false) | |
| 806 | 864 | require.True(t, state.has("natural")) | |
| @@ -667,7 +667,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||||
| 667 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | 667 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | ||
| 668 | WithStore(store)(d) | 668 | WithStore(store)(d) | ||
| 669 | 669 | ||||
| 670 | - | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | 670 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil) |
| 671 | require.NoError(t, err) | 671 | require.NoError(t, err) | ||
| 672 | require.True(t, state.has("work")) | 672 | require.True(t, state.has("work")) | ||
| 673 | 673 | ||||
| @@ -679,6 +679,64 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||||
| 679 | require.False(t, state.has("renamed")) | 679 | require.False(t, state.has("renamed")) | ||
| 680 | } | 680 | } | ||
| 681 | 681 | ||||
| 682 | + | func TestRenameTabPersistsForNamedSession(t *testing.T) { | |||
| 683 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 684 | + | p, release := newBlockingPTY(t) | |||
| 685 | + | defer release() | |||
| 686 | + | store, _ := newMockStore(t) | |||
| 687 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 688 | + | WithStore(store)(d) | |||
| 689 | + | ||||
| 690 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil) | |||
| 691 | + | require.NoError(t, err) | |||
| 692 | + | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |||
| 693 | + | ||||
| 694 | + | records, err := d.persist.LoadAll() | |||
| 695 | + | require.NoError(t, err) | |||
| 696 | + | require.Equal(t, []persist.Record{{Name: "work", Cwd: "/tmp/work", CreatedAt: sess.createdAt, UpdatedAt: records[0].UpdatedAt, TabNames: []string{"shell"}}}, records) | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) { | |||
| 700 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 701 | + | p, release := newBlockingPTY(t) | |||
| 702 | + | defer release() | |||
| 703 | + | store, state := newMockStore(t) | |||
| 704 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 705 | + | WithStore(store)(d) | |||
| 706 | + | ||||
| 707 | + | sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, nil) | |||
| 708 | + | require.NoError(t, err) | |||
| 709 | + | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |||
| 710 | + | ||||
| 711 | + | require.False(t, state.has("0")) | |||
| 712 | + | records, err := d.persist.LoadAll() | |||
| 713 | + | require.NoError(t, err) | |||
| 714 | + | require.Empty(t, records) | |||
| 715 | + | } | |||
| 716 | + | ||||
| 717 | + | func TestAttachRestoresPersistedTabNames(t *testing.T) { | |||
| 718 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 719 | + | p1, release1 := newBlockingPTY(t) | |||
| 720 | + | p2, release2 := newBlockingPTY(t) | |||
| 721 | + | defer release1() | |||
| 722 | + | defer release2() | |||
| 723 | + | store, _ := newMockStore(t) | |||
| 724 | + | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |||
| 725 | + | WithStore(store)(d) | |||
| 726 | + | require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}})) | |||
| 727 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}} | |||
| 728 | + | tr := portsmocks.NewMockTransport(t) | |||
| 729 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |||
| 730 | + | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 731 | + | ||||
| 732 | + | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr) | |||
| 733 | + | require.NoError(t, err) | |||
| 734 | + | require.NotNil(t, ac) | |||
| 735 | + | require.Len(t, sess.tabs, 2) | |||
| 736 | + | require.Equal(t, "shell", sess.tabs[0].name) | |||
| 737 | + | require.Equal(t, "logs", sess.tabs[1].name) | |||
| 738 | + | } | |||
| 739 | + | ||||
| 682 | func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | 740 | func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | ||
| 683 | sz := domain.Size{Cols: 80, Rows: 24} | 741 | sz := domain.Size{Cols: 80, Rows: 24} | ||
| 684 | p, release := newBlockingPTY(t) | 742 | p, release := newBlockingPTY(t) | ||
| @@ -688,7 +746,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |||||
| 688 | WithStore(store)(d) | 746 | WithStore(store)(d) | ||
| 689 | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | 747 | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | ||
| 690 | 748 | ||||
| 691 | - | sess, err := d.createSessionLocked("0", true, "/tmp/e", sz) | 749 | + | sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, nil) |
| 692 | require.NoError(t, err) | 750 | require.NoError(t, err) | ||
| 693 | require.False(t, state.has("0")) | 751 | require.False(t, state.has("0")) | ||
| 694 | require.EqualError(t, d.renameSession(sess, "taken"), "name already in use") | 752 | require.EqualError(t, d.renameSession(sess, "taken"), "name already in use") | ||
| @@ -706,7 +764,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | |||||
| 706 | cwd := "/tmp/work" | 764 | cwd := "/tmp/work" | ||
| 707 | WithCwdReader(func(int) (string, error) { return cwd, nil })(d) | 765 | WithCwdReader(func(int) (string, error) { return cwd, nil })(d) | ||
| 708 | 766 | ||||
| 709 | - | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | 767 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil) |
| 710 | require.NoError(t, err) | 768 | require.NoError(t, err) | ||
| 711 | state.mu.Lock() | 769 | state.mu.Lock() | ||
| 712 | setsAfterCreate := state.sets | 770 | setsAfterCreate := state.sets | ||
| @@ -798,9 +856,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |||||
| 798 | WithStore(store)(d) | 856 | WithStore(store)(d) | ||
| 799 | WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d) | 857 | WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d) | ||
| 800 | 858 | ||||
| 801 | - | natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz) | 859 | + | natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, nil) |
| 802 | require.NoError(t, err) | 860 | require.NoError(t, err) | ||
| 803 | - | other, err := d.createSessionLocked("other", false, "/tmp/other", sz) | 861 | + | other, err := d.createSessionLocked("other", false, "/tmp/other", sz, nil) |
| 804 | require.NoError(t, err) | 862 | require.NoError(t, err) | ||
| 805 | _ = d.killSession(natural, ports.ReasonSessionKilled, false) | 863 | _ = d.killSession(natural, ports.ReasonSessionKilled, false) | ||
| 806 | require.True(t, state.has("natural")) | 864 | require.True(t, state.has("natural")) | ||
M internal/usecase/daemon/status.go +4 -1
| @@ -141,7 +141,10 @@ func (s *session) statusSegments() statusSnapshot { | |||
| 141 | 141 | } | |
| 142 | 142 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 143 | 143 | for i, tb := range s.tabs { | |
| 144 | - | name := strconv.Itoa(i + 1) | |
| 144 | + | name := tb.name | |
| 145 | + | if name == "" { | |
| 146 | + | name = strconv.Itoa(i + 1) | |
| 147 | + | } | |
| 145 | 148 | snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention} | |
| 146 | 149 | } | |
| 147 | 150 | return snap | |
| @@ -141,7 +141,10 @@ func (s *session) statusSegments() statusSnapshot { | |||||
| 141 | } | 141 | } | ||
| 142 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | 142 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | ||
| 143 | for i, tb := range s.tabs { | 143 | for i, tb := range s.tabs { | ||
| 144 | - | name := strconv.Itoa(i + 1) | 144 | + | name := tb.name |
| 145 | + | if name == "" { | |||
| 146 | + | name = strconv.Itoa(i + 1) | |||
| 147 | + | } | |||
| 145 | snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention} | 148 | snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention} | ||
| 146 | } | 149 | } | ||
| 147 | return snap | 150 | return snap | ||