detached bc467a6

fix(tabs): reconcile rename flow with current main

commit
bc467a6b69a284b13db72518fdb2685ab1a12023
author
brice · · 2026-07-09 18:34 UTC
parent
dee642a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +199 -38
M internal/persist/persist.go +3 -1
@@ -71,14 +71,16 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
7171
7272 createdAt := at
7373 lastUsedSeq := uint64(0)
74+ var tabNames []string
7475 if v, ok := p.store.Get([]byte(name)); ok {
7576 if r, err := decodeRecordValue(name, v); err == nil {
7677 createdAt = r.CreatedAt
7778 lastUsedSeq = r.LastUsedSeq
79+ tabNames = r.TabNames
7880 }
7981 }
8082
81- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq})
83+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq, TabNames: tabNames})
8284 if err != nil {
8385 return err
8486 }
@@ -71,14 +71,16 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
71 71
72 createdAt := at72 createdAt := at
73 lastUsedSeq := uint64(0)73 lastUsedSeq := uint64(0)
74+ var tabNames []string
74 if v, ok := p.store.Get([]byte(name)); ok {75 if v, ok := p.store.Get([]byte(name)); ok {
75 if r, err := decodeRecordValue(name, v); err == nil {76 if r, err := decodeRecordValue(name, v); err == nil {
76 createdAt = r.CreatedAt77 createdAt = r.CreatedAt
77 lastUsedSeq = r.LastUsedSeq78 lastUsedSeq = r.LastUsedSeq
79+ tabNames = r.TabNames
78 }80 }
79 }81 }
80 82
81- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq})83+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq, TabNames: tabNames})
82 if err != nil {84 if err != nil {
83 return err85 return err
84 }86 }
M internal/persist/persist_test.go +4 -5
@@ -1,7 +1,6 @@
11 package persist
22
33 import (
4- "encoding/binary"
54 "errors"
65 "path/filepath"
76 "testing"
@@ -18,7 +17,7 @@ func privateDir(t *testing.T) string {
1817 }
1918
2019 func TestRecordCodecRoundTrip(t *testing.T) {
21- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
20+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33, TabNames: []string{"shell", "logs"}}
2221 value, err := encodeRecordValue(r)
2322 require.NoError(t, err)
2423
@@ -82,7 +81,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
8281 p, err := Open(dir)
8382 require.NoError(t, err)
8483
85- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))
84+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40, TabNames: []string{"shell"}}))
8685 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
8786 require.NoError(t, p.Touch("b", "/b/next", 30))
8887 require.NoError(t, p.TouchMRU("b", 60))
@@ -91,13 +90,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
9190 require.NoError(t, err)
9291 require.Equal(t, []Record{
9392 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
94- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},
93+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}},
9594 }, records)
9695
9796 require.NoError(t, p.Delete("a"))
9897 records, err = p.LoadAll()
9998 require.NoError(t, err)
100- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)
99+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}}}, records)
101100 require.NoError(t, p.Close())
102101 }
103102
@@ -1,7 +1,6 @@
1 package persist1 package persist
2 2
3 import (3 import (
4- "encoding/binary"
5 "errors"4 "errors"
6 "path/filepath"5 "path/filepath"
7 "testing"6 "testing"
@@ -18,7 +17,7 @@ func privateDir(t *testing.T) string {
18 }17 }
19 18
20 func TestRecordCodecRoundTrip(t *testing.T) {19 func TestRecordCodecRoundTrip(t *testing.T) {
21- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}20+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33, TabNames: []string{"shell", "logs"}}
22 value, err := encodeRecordValue(r)21 value, err := encodeRecordValue(r)
23 require.NoError(t, err)22 require.NoError(t, err)
24 23
@@ -82,7 +81,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
82 p, err := Open(dir)81 p, err := Open(dir)
83 require.NoError(t, err)82 require.NoError(t, err)
84 83
85- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))84+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40, TabNames: []string{"shell"}}))
86 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))85 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
87 require.NoError(t, p.Touch("b", "/b/next", 30))86 require.NoError(t, p.Touch("b", "/b/next", 30))
88 require.NoError(t, p.TouchMRU("b", 60))87 require.NoError(t, p.TouchMRU("b", 60))
@@ -91,13 +90,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
91 require.NoError(t, err)90 require.NoError(t, err)
92 require.Equal(t, []Record{91 require.Equal(t, []Record{
93 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},92 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
94- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},93+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}},
95 }, records)94 }, records)
96 95
97 require.NoError(t, p.Delete("a"))96 require.NoError(t, p.Delete("a"))
98 records, err = p.LoadAll()97 records, err = p.LoadAll()
99 require.NoError(t, err)98 require.NoError(t, err)
100- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)99+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}}}, records)
101 require.NoError(t, p.Close())100 require.NoError(t, p.Close())
102 }101 }
103 102
M internal/persist/record.go +50 -3
@@ -14,6 +14,7 @@ type Record struct {
1414 CreatedAt int64
1515 UpdatedAt int64
1616 LastUsedSeq uint64
17+ TabNames []string
1718 }
1819
1920 var (
@@ -39,13 +40,22 @@ func encodeRecordValue(r Record) ([]byte, error) {
3940 tabNamesSize += 4 + len(name)
4041 }
4142
42- buf := make([]byte, 4+len(r.Cwd)+8+8+8)
43+ buf := make([]byte, 4+len(r.Cwd)+8+8+8+tabNamesSize)
4344 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
4445 copy(buf[4:], r.Cwd)
4546 off := 4 + len(r.Cwd)
4647 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
4748 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
4849 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+ }
4959 return buf, nil
5060 }
5161
@@ -62,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
6272 return Record{}, errMalformedRecord
6373 }
6474 cwdLen := int(cwdLen32)
65- 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 {
6677 return Record{}, errMalformedRecord
6778 }
6879 off := 4 + cwdLen
@@ -72,8 +83,44 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
7283 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
7384 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
7485 }
75- 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 {
7693 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
77124 }
78125 return r, nil
79126 }
@@ -14,6 +14,7 @@ type Record struct {
14 CreatedAt int6414 CreatedAt int64
15 UpdatedAt int6415 UpdatedAt int64
16 LastUsedSeq uint6416 LastUsedSeq uint64
17+ TabNames []string
17 }18 }
18 19
19 var (20 var (
@@ -39,13 +40,22 @@ func encodeRecordValue(r Record) ([]byte, error) {
39 tabNamesSize += 4 + len(name)40 tabNamesSize += 4 + len(name)
40 }41 }
41 42
42- buf := make([]byte, 4+len(r.Cwd)+8+8+8)43+ buf := make([]byte, 4+len(r.Cwd)+8+8+8+tabNamesSize)
43 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))44 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
44 copy(buf[4:], r.Cwd)45 copy(buf[4:], r.Cwd)
45 off := 4 + len(r.Cwd)46 off := 4 + len(r.Cwd)
46 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))47 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
47 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))48 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
48 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+ }
49 return buf, nil59 return buf, nil
50 }60 }
51 61
@@ -62,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
62 return Record{}, errMalformedRecord72 return Record{}, errMalformedRecord
63 }73 }
64 cwdLen := int(cwdLen32)74 cwdLen := int(cwdLen32)
65- 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 {
66 return Record{}, errMalformedRecord77 return Record{}, errMalformedRecord
67 }78 }
68 off := 4 + cwdLen79 off := 4 + cwdLen
@@ -72,8 +83,44 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
72 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),83 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
73 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),84 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
74 }85 }
75- 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 {
76 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
77 }124 }
78 return r, nil125 return r, nil
79 }126 }
M internal/usecase/command/context_mock_test.go +44 -0
@@ -739,6 +739,50 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo
739739 return _c
740740 }
741741
742+// RenameTab provides a mock function for the type MockContext
743+func (_mock *MockContext) RenameTab() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for RenameTab")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_RenameTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameTab'
760+type MockContext_RenameTab_Call struct {
761+ *mock.Call
762+}
763+
764+// RenameTab is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) RenameTab() *MockContext_RenameTab_Call {
766+ return &MockContext_RenameTab_Call{Call: _e.mock.On("RenameTab")}
767+}
768+
769+func (_c *MockContext_RenameTab_Call) Run(run func()) *MockContext_RenameTab_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_RenameTab_Call) Return(err error) *MockContext_RenameTab_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContext_RenameTab_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
742786 // SplitDown provides a mock function for the type MockContext
743787 func (_mock *MockContext) SplitDown() error {
744788 ret := _mock.Called()
@@ -739,6 +739,50 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo
739 return _c739 return _c
740 }740 }
741 741
742+// RenameTab provides a mock function for the type MockContext
743+func (_mock *MockContext) RenameTab() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for RenameTab")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_RenameTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameTab'
760+type MockContext_RenameTab_Call struct {
761+ *mock.Call
762+}
763+
764+// RenameTab is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) RenameTab() *MockContext_RenameTab_Call {
766+ return &MockContext_RenameTab_Call{Call: _e.mock.On("RenameTab")}
767+}
768+
769+func (_c *MockContext_RenameTab_Call) Run(run func()) *MockContext_RenameTab_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_RenameTab_Call) Return(err error) *MockContext_RenameTab_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContext_RenameTab_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
742 // SplitDown provides a mock function for the type MockContext786 // SplitDown provides a mock function for the type MockContext
743 func (_mock *MockContext) SplitDown() error {787 func (_mock *MockContext) SplitDown() error {
744 ret := _mock.Called()788 ret := _mock.Called()
M internal/usecase/command/registry.go +1 -0
@@ -26,6 +26,7 @@ func Registry() []Command {
2626 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
2727 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
2828 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
2930 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
3031 }
3132
@@ -26,6 +26,7 @@ func Registry() []Command {
26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
29 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},30 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
30 }31 }
31 32
M internal/usecase/command/registry_test.go +2 -1
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -79,6 +79,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
7979 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
8080 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
8181 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
82+ {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
8283 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
8384 }
8485
@@ -7,7 +7,7 @@ import (
7 7
8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9 commands := Registry()9 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
11 11
12 if len(commands) != len(wantCodes) {12 if len(commands) != len(wantCodes) {
13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -79,6 +79,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
79 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},79 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
80 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},80 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
81 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},81 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
82+ {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
82 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},83 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
83 }84 }
84 85
M internal/usecase/daemon/daemon.go +15 -4
@@ -31,6 +31,7 @@ import (
3131 "errors"
3232 "log/slog"
3333 "os"
34+ "slices"
3435 "sort"
3536 "sync"
3637 "sync/atomic"
@@ -163,9 +164,19 @@ type stoppedSession struct {
163164 cwd string
164165 createdAt int64
165166 lastUsedSeq uint64
167+ tabNames []string
166168 purging bool
167169 }
168170
171+func (s stoppedSession) same(other stoppedSession) bool {
172+ return s.name == other.name &&
173+ s.cwd == other.cwd &&
174+ s.createdAt == other.createdAt &&
175+ s.lastUsedSeq == other.lastUsedSeq &&
176+ s.purging == other.purging &&
177+ slices.Equal(s.tabNames, other.tabNames)
178+}
179+
169180 type Option func(*Daemon)
170181
171182 // WithShell overrides the command (and its args) each session spawns. The
@@ -320,7 +331,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
320331 } else {
321332 var maxSeq uint64
322333 for _, r := range records {
323- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
334+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
324335 if r.LastUsedSeq > maxSeq {
325336 maxSeq = r.LastUsedSeq
326337 }
@@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
537548 return
538549 }
539550 d.mu.Lock()
540- if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
551+ if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
541552 delete(d.stopped, k.Name)
542553 }
543554 d.mu.Unlock()
@@ -647,7 +658,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
647658 }
648659 cwd := d.dirOrHome(stopped.cwd)
649660 var err error
650- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
661+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
651662 if err != nil {
652663 d.mu.Unlock()
653664 return nil, nil, err
@@ -715,7 +726,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
715726 }
716727 cwd := d.dirOrHome(stopped.cwd)
717728 var err error
718- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
729+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
719730 if err != nil {
720731 d.mu.Unlock()
721732 return nil, nil, err
@@ -31,6 +31,7 @@ import (
31 "errors"31 "errors"
32 "log/slog"32 "log/slog"
33 "os"33 "os"
34+ "slices"
34 "sort"35 "sort"
35 "sync"36 "sync"
36 "sync/atomic"37 "sync/atomic"
@@ -163,9 +164,19 @@ type stoppedSession struct {
163 cwd string164 cwd string
164 createdAt int64165 createdAt int64
165 lastUsedSeq uint64166 lastUsedSeq uint64
167+ tabNames []string
166 purging bool168 purging bool
167 }169 }
168 170
171+func (s stoppedSession) same(other stoppedSession) bool {
172+ return s.name == other.name &&
173+ s.cwd == other.cwd &&
174+ s.createdAt == other.createdAt &&
175+ s.lastUsedSeq == other.lastUsedSeq &&
176+ s.purging == other.purging &&
177+ slices.Equal(s.tabNames, other.tabNames)
178+}
179+
169 type Option func(*Daemon)180 type Option func(*Daemon)
170 181
171 // WithShell overrides the command (and its args) each session spawns. The182 // WithShell overrides the command (and its args) each session spawns. The
@@ -320,7 +331,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
320 } else {331 } else {
321 var maxSeq uint64332 var maxSeq uint64
322 for _, r := range records {333 for _, r := range records {
323- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}334+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
324 if r.LastUsedSeq > maxSeq {335 if r.LastUsedSeq > maxSeq {
325 maxSeq = r.LastUsedSeq336 maxSeq = r.LastUsedSeq
326 }337 }
@@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
537 return548 return
538 }549 }
539 d.mu.Lock()550 d.mu.Lock()
540- if cur, ok := d.stopped[k.Name]; ok && cur == stopped {551+ if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
541 delete(d.stopped, k.Name)552 delete(d.stopped, k.Name)
542 }553 }
543 d.mu.Unlock()554 d.mu.Unlock()
@@ -647,7 +658,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
647 }658 }
648 cwd := d.dirOrHome(stopped.cwd)659 cwd := d.dirOrHome(stopped.cwd)
649 var err error660 var err error
650- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)661+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
651 if err != nil {662 if err != nil {
652 d.mu.Unlock()663 d.mu.Unlock()
653 return nil, nil, err664 return nil, nil, err
@@ -715,7 +726,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
715 }726 }
716 cwd := d.dirOrHome(stopped.cwd)727 cwd := d.dirOrHome(stopped.cwd)
717 var err error728 var err error
718- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)729+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
719 if err != nil {730 if err != nil {
720 d.mu.Unlock()731 d.mu.Unlock()
721 return nil, nil, err732 return nil, nil, err
M internal/usecase/daemon/input_test.go +2 -2
@@ -654,7 +654,7 @@ func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
654654 awaitFrame(t, sends, ports.MsgOutput)
655655 d.handleInput(sess, ac, []byte("RNT\r"))
656656 out := awaitFrame(t, sends, ports.MsgOutput)
657- require.True(t, ac.promptActive())
657+ require.True(t, ac.overlays.promptActive())
658658 msg, err := ports.UnmarshalOutput(out.Payload)
659659 require.NoError(t, err)
660660 require.Contains(t, string(msg.Data), "> 2")
@@ -662,7 +662,7 @@ func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
662662 d.handleInput(sess, ac, []byte("\x7flogs\r"))
663663 awaitFrame(t, sends, ports.MsgOutput)
664664
665- require.False(t, ac.promptActive())
665+ require.False(t, ac.overlays.promptActive())
666666 require.Equal(t, "logs", sess.tabs[1].name)
667667 require.Empty(t, sess.tabs[0].name)
668668 }
@@ -654,7 +654,7 @@ func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
654 awaitFrame(t, sends, ports.MsgOutput)654 awaitFrame(t, sends, ports.MsgOutput)
655 d.handleInput(sess, ac, []byte("RNT\r"))655 d.handleInput(sess, ac, []byte("RNT\r"))
656 out := awaitFrame(t, sends, ports.MsgOutput)656 out := awaitFrame(t, sends, ports.MsgOutput)
657- require.True(t, ac.promptActive())657+ require.True(t, ac.overlays.promptActive())
658 msg, err := ports.UnmarshalOutput(out.Payload)658 msg, err := ports.UnmarshalOutput(out.Payload)
659 require.NoError(t, err)659 require.NoError(t, err)
660 require.Contains(t, string(msg.Data), "> 2")660 require.Contains(t, string(msg.Data), "> 2")
@@ -662,7 +662,7 @@ func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
662 d.handleInput(sess, ac, []byte("\x7flogs\r"))662 d.handleInput(sess, ac, []byte("\x7flogs\r"))
663 awaitFrame(t, sends, ports.MsgOutput)663 awaitFrame(t, sends, ports.MsgOutput)
664 664
665- require.False(t, ac.promptActive())665+ require.False(t, ac.overlays.promptActive())
666 require.Equal(t, "logs", sess.tabs[1].name)666 require.Equal(t, "logs", sess.tabs[1].name)
667 require.Empty(t, sess.tabs[0].name)667 require.Empty(t, sess.tabs[0].name)
668 }668 }
M internal/usecase/daemon/picker.go +1 -1
@@ -424,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
424424 }
425425 term := from.terminal
426426 cwd := d.dirOrHome(stopped.cwd)
427- 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)
428428 if err != nil {
429429 from.mu.Unlock()
430430 d.mu.Unlock()
@@ -424,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
424 }424 }
425 term := from.terminal425 term := from.terminal
426 cwd := d.dirOrHome(stopped.cwd)426 cwd := d.dirOrHome(stopped.cwd)
427- 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)
428 if err != nil {428 if err != nil {
429 from.mu.Unlock()429 from.mu.Unlock()
430 d.mu.Unlock()430 d.mu.Unlock()
M internal/usecase/daemon/session.go +66 -17
@@ -29,6 +29,7 @@ import (
2929 "errors"
3030 "fmt"
3131 "os"
32+ "slices"
3233 "strconv"
3334 "strings"
3435 "sync"
@@ -76,6 +77,7 @@ type tab struct {
7677 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
7778
7879 stableID string
80+ name string
7981 tree *layout.Tree
8082 panes map[layout.PaneID]*pane
8183 nextPaneID int
@@ -127,25 +129,40 @@ func (d *Daemon) touchMRU(sess *session) {
127129 }
128130 }
129131
130-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv) (*session, error) {
132+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {
131133 tbSize := tabSize(sz)
132- tabStableID, paneStableID, err := d.newTabPaneStableIDs()
133- if err != nil {
134- return nil, err
135- }
136- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
137- if err != nil {
138- d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
139- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
134+ var names []string
135+ if len(restoredTabNames) > 0 {
136+ names = append([]string(nil), restoredTabNames[0]...)
137+ }
138+ tabs := make([]*tab, 0, max(1, len(names)))
139+ for i := range max(1, len(names)) {
140+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
141+ if err != nil {
142+ closeTabs(tabs)
143+ return nil, err
144+ }
145+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
146+ if err != nil {
147+ closeTabs(tabs)
148+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
149+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
150+ }
151+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
152+ if i < len(names) {
153+ tb.name = names[i]
154+ }
155+ tabs = append(tabs, tb)
140156 }
141157
142158 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
143159 d.nextID++
144160 createdAt := time.Now().UnixNano()
145161
146- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
147162 sctx, cancel := context.WithCancel(d.serveCtx)
148- tb.ctx, tb.cancel = context.WithCancel(sctx)
163+ for _, tb := range tabs {
164+ tb.ctx, tb.cancel = context.WithCancel(sctx)
165+ }
149166 lastUsedSeq := uint64(0)
150167 if !ephemeral {
151168 if stopped, ok := d.stopped[name]; ok {
@@ -168,8 +185,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
168185 }
169186 sess.snapEligible.Store(!ephemeral && name != "")
170187 if !ephemeral {
171- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
172- _ = pty.Close()
188+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
189+ closeTabs(tabs)
173190 cancel()
174191 return nil, err
175192 }
@@ -180,11 +197,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
180197 d.touchMRU(sess)
181198 }
182199 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
183- d.log.Info("tab created", "session", name, "tab", 0)
184- d.startTabGoroutines(sess, tb)
200+ for i, tb := range tabs {
201+ d.log.Info("tab created", "session", name, "tab", i)
202+ d.startTabGoroutines(sess, tb)
203+ }
185204 return sess, nil
186205 }
187206
207+func closeTabs(tabs []*tab) {
208+ for _, tb := range tabs {
209+ for _, p := range tb.panes {
210+ _ = p.pty.Close()
211+ }
212+ }
213+}
214+
188215 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
189216 if name == "" {
190217 return errors.New("name required")
@@ -284,6 +311,21 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
284311 sess.tabs = append(sess.tabs, tb)
285312 sess.active = len(sess.tabs) - 1
286313 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+ }
287329 sess.mu.Unlock()
288330 d.log.Info("tab created", "session", name, "tab", tabIndex)
289331 d.startTabGoroutines(sess, tb)
@@ -522,7 +564,7 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
522564 } else {
523565 tabNames = tabNames[:lastCustom+1]
524566 }
525- return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, TabNames: tabNames}
567+ return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
526568 }
527569
528570 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
@@ -560,6 +602,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
560602 }
561603 ac := sess.client
562604 name := sess.name
605+ record := sess.persistRecordLocked(time.Now().UnixNano())
606+ ephemeral := sess.ephemeral
607+ if !ephemeral {
608+ if err := d.persist.Save(record); err != nil {
609+ d.log.Warn("persisting closed tab failed", "err", err, "session", name)
610+ }
611+ }
563612 sess.mu.Unlock()
564613 d.log.Info("tab closed", "session", name)
565614 markSnapshotDirty(sess)
@@ -618,7 +667,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
618667 ephemeral := sess.ephemeral
619668 sess.mu.Unlock()
620669 if !ephemeral {
621- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), purging: purge}
670+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
622671 d.stopped[stoppedName] = stopped
623672 }
624673 empty := len(d.sessions) == 0
@@ -29,6 +29,7 @@ import (
29 "errors"29 "errors"
30 "fmt"30 "fmt"
31 "os"31 "os"
32+ "slices"
32 "strconv"33 "strconv"
33 "strings"34 "strings"
34 "sync"35 "sync"
@@ -76,6 +77,7 @@ type tab struct {
76 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership77 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
77 78
78 stableID string79 stableID string
80+ name string
79 tree *layout.Tree81 tree *layout.Tree
80 panes map[layout.PaneID]*pane82 panes map[layout.PaneID]*pane
81 nextPaneID int83 nextPaneID int
@@ -127,25 +129,40 @@ func (d *Daemon) touchMRU(sess *session) {
127 }129 }
128 }130 }
129 131
130-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv) (*session, error) {132+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {
131 tbSize := tabSize(sz)133 tbSize := tabSize(sz)
132- tabStableID, paneStableID, err := d.newTabPaneStableIDs()134+ var names []string
133- if err != nil {135+ if len(restoredTabNames) > 0 {
134- return nil, err136+ names = append([]string(nil), restoredTabNames[0]...)
135- }137+ }
136- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)138+ tabs := make([]*tab, 0, max(1, len(names)))
137- if err != nil {139+ for i := range max(1, len(names)) {
138- d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")140+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
139- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)141+ if err != nil {
142+ closeTabs(tabs)
143+ return nil, err
144+ }
145+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
146+ if err != nil {
147+ closeTabs(tabs)
148+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
149+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
150+ }
151+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
152+ if i < len(names) {
153+ tb.name = names[i]
154+ }
155+ tabs = append(tabs, tb)
140 }156 }
141 157
142 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))158 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
143 d.nextID++159 d.nextID++
144 createdAt := time.Now().UnixNano()160 createdAt := time.Now().UnixNano()
145 161
146- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
147 sctx, cancel := context.WithCancel(d.serveCtx)162 sctx, cancel := context.WithCancel(d.serveCtx)
148- tb.ctx, tb.cancel = context.WithCancel(sctx)163+ for _, tb := range tabs {
164+ tb.ctx, tb.cancel = context.WithCancel(sctx)
165+ }
149 lastUsedSeq := uint64(0)166 lastUsedSeq := uint64(0)
150 if !ephemeral {167 if !ephemeral {
151 if stopped, ok := d.stopped[name]; ok {168 if stopped, ok := d.stopped[name]; ok {
@@ -168,8 +185,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
168 }185 }
169 sess.snapEligible.Store(!ephemeral && name != "")186 sess.snapEligible.Store(!ephemeral && name != "")
170 if !ephemeral {187 if !ephemeral {
171- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {188+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
172- _ = pty.Close()189+ closeTabs(tabs)
173 cancel()190 cancel()
174 return nil, err191 return nil, err
175 }192 }
@@ -180,11 +197,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
180 d.touchMRU(sess)197 d.touchMRU(sess)
181 }198 }
182 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)199 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
183- d.log.Info("tab created", "session", name, "tab", 0)200+ for i, tb := range tabs {
184- d.startTabGoroutines(sess, tb)201+ d.log.Info("tab created", "session", name, "tab", i)
202+ d.startTabGoroutines(sess, tb)
203+ }
185 return sess, nil204 return sess, nil
186 }205 }
187 206
207+func closeTabs(tabs []*tab) {
208+ for _, tb := range tabs {
209+ for _, p := range tb.panes {
210+ _ = p.pty.Close()
211+ }
212+ }
213+}
214+
188 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {215 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
189 if name == "" {216 if name == "" {
190 return errors.New("name required")217 return errors.New("name required")
@@ -284,6 +311,21 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
284 sess.tabs = append(sess.tabs, tb)311 sess.tabs = append(sess.tabs, tb)
285 sess.active = len(sess.tabs) - 1312 sess.active = len(sess.tabs) - 1
286 tabIndex := sess.active313 tabIndex := sess.active
314+ record := sess.persistRecordLocked(time.Now().UnixNano())
315+ ephemeral := sess.ephemeral
316+ if !ephemeral {
317+ if err := d.persist.Save(record); err != nil {
318+ sess.tabs = sess.tabs[:len(sess.tabs)-1]
319+ sess.active = oldActive
320+ if tb.cancel != nil {
321+ tb.cancel()
322+ }
323+ sess.mu.Unlock()
324+ d.mu.Unlock()
325+ _ = pty.Close()
326+ return err
327+ }
328+ }
287 sess.mu.Unlock()329 sess.mu.Unlock()
288 d.log.Info("tab created", "session", name, "tab", tabIndex)330 d.log.Info("tab created", "session", name, "tab", tabIndex)
289 d.startTabGoroutines(sess, tb)331 d.startTabGoroutines(sess, tb)
@@ -522,7 +564,7 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
522 } else {564 } else {
523 tabNames = tabNames[:lastCustom+1]565 tabNames = tabNames[:lastCustom+1]
524 }566 }
525- return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, TabNames: tabNames}567+ return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
526 }568 }
527 569
528 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {570 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
@@ -560,6 +602,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
560 }602 }
561 ac := sess.client603 ac := sess.client
562 name := sess.name604 name := sess.name
605+ record := sess.persistRecordLocked(time.Now().UnixNano())
606+ ephemeral := sess.ephemeral
607+ if !ephemeral {
608+ if err := d.persist.Save(record); err != nil {
609+ d.log.Warn("persisting closed tab failed", "err", err, "session", name)
610+ }
611+ }
563 sess.mu.Unlock()612 sess.mu.Unlock()
564 d.log.Info("tab closed", "session", name)613 d.log.Info("tab closed", "session", name)
565 markSnapshotDirty(sess)614 markSnapshotDirty(sess)
@@ -618,7 +667,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
618 ephemeral := sess.ephemeral667 ephemeral := sess.ephemeral
619 sess.mu.Unlock()668 sess.mu.Unlock()
620 if !ephemeral {669 if !ephemeral {
621- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), purging: purge}670+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
622 d.stopped[stoppedName] = stopped671 d.stopped[stoppedName] = stopped
623 }672 }
624 empty := len(d.sessions) == 0673 empty := len(d.sessions) == 0
M internal/usecase/daemon/session_test.go +3 -3
@@ -825,7 +825,7 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
825825 d := newTestDaemon(t, newFactory(t, p), stubClock{})
826826 WithStore(store)(d)
827827
828- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
828+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
829829 require.NoError(t, err)
830830 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
831831
@@ -862,7 +862,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
862862 WithStore(store)(d)
863863 sz := domain.Size{Cols: 80, Rows: 24}
864864
865- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
865+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
866866 require.NoError(t, err)
867867 require.NoError(t, d.createTab(sess, sz))
868868 require.NoError(t, d.createTab(sess, sz))
@@ -887,7 +887,7 @@ func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
887887 d := newTestDaemon(t, newFactory(t, p), stubClock{})
888888 WithStore(store)(d)
889889
890- sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, nil)
890+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{})
891891 require.NoError(t, err)
892892 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
893893 require.Equal(t, "shell", sess.tabs[0].name)
@@ -825,7 +825,7 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
825 d := newTestDaemon(t, newFactory(t, p), stubClock{})825 d := newTestDaemon(t, newFactory(t, p), stubClock{})
826 WithStore(store)(d)826 WithStore(store)(d)
827 827
828- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)828+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
829 require.NoError(t, err)829 require.NoError(t, err)
830 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))830 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
831 831
@@ -862,7 +862,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
862 WithStore(store)(d)862 WithStore(store)(d)
863 sz := domain.Size{Cols: 80, Rows: 24}863 sz := domain.Size{Cols: 80, Rows: 24}
864 864
865- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)865+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
866 require.NoError(t, err)866 require.NoError(t, err)
867 require.NoError(t, d.createTab(sess, sz))867 require.NoError(t, d.createTab(sess, sz))
868 require.NoError(t, d.createTab(sess, sz))868 require.NoError(t, d.createTab(sess, sz))
@@ -887,7 +887,7 @@ func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
887 d := newTestDaemon(t, newFactory(t, p), stubClock{})887 d := newTestDaemon(t, newFactory(t, p), stubClock{})
888 WithStore(store)(d)888 WithStore(store)(d)
889 889
890- sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, nil)890+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{})
891 require.NoError(t, err)891 require.NoError(t, err)
892 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))892 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
893 require.Equal(t, "shell", sess.tabs[0].name)893 require.Equal(t, "shell", sess.tabs[0].name)
M internal/usecase/daemon/status.go +8 -1
@@ -204,7 +204,7 @@ func (s *session) statusSegments() statusSnapshot {
204204 }
205205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
206206 for i, tb := range s.tabs {
207- name := strconv.Itoa(i + 1)
207+ name := tabDisplayName(tb, i)
208208 active := i == s.active
209209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
210210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}
@@ -212,6 +212,13 @@ func (s *session) statusSegments() statusSnapshot {
212212 return snap
213213 }
214214
215+func tabDisplayName(tb *tab, index int) string {
216+ if tb.name != "" {
217+ return tb.name
218+ }
219+ return strconv.Itoa(index + 1)
220+}
221+
215222 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
216223 state := d.barStateFor(cur, copyFeedback)
217224 state.history = d.historyNavBarState(ac)
@@ -204,7 +204,7 @@ func (s *session) statusSegments() statusSnapshot {
204 }204 }
205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
206 for i, tb := range s.tabs {206 for i, tb := range s.tabs {
207- name := strconv.Itoa(i + 1)207+ name := tabDisplayName(tb, i)
208 active := i == s.active208 active := i == s.active
209 attention := tb.attention && (!active || tb.attentionVisiblePaint)209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}
@@ -212,6 +212,13 @@ func (s *session) statusSegments() statusSnapshot {
212 return snap212 return snap
213 }213 }
214 214
215+func tabDisplayName(tb *tab, index int) string {
216+ if tb.name != "" {
217+ return tb.name
218+ }
219+ return strconv.Itoa(index + 1)
220+}
221+
215 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {222 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
216 state := d.barStateFor(cur, copyFeedback)223 state := d.barStateFor(cur, copyFeedback)
217 state.history = d.historyNavBarState(ac)224 state.history = d.historyNavBarState(ac)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close