detached 717a6bd

feat(session): persist recent session order

commit
717a6bd66d15cef799768925a488b1fcbe928083
author
brice · · 2026-07-05 14:45 UTC
parent
f92d01c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +199 -32
M internal/persist/persist.go +26 -1
@@ -70,13 +70,38 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
7070 }
7171
7272 createdAt := at
73+ lastUsedSeq := uint64(0)
7374 if v, ok := p.store.Get([]byte(name)); ok {
7475 if r, err := decodeRecordValue(name, v); err == nil {
7576 createdAt = r.CreatedAt
77+ lastUsedSeq = r.LastUsedSeq
7678 }
7779 }
7880
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, LastUsedSeq: lastUsedSeq})
82+ if err != nil {
83+ return err
84+ }
85+ return p.store.Set([]byte(name), value)
86+}
87+
88+// TouchMRU updates hot-path session recency without forcing an fsync.
89+func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
90+ if p == nil || p.store == nil {
91+ return nil
92+ }
93+ if name == "" {
94+ return errEmptyName
95+ }
96+
97+ r := Record{Name: name, LastUsedSeq: lastUsedSeq}
98+ if v, ok := p.store.Get([]byte(name)); ok {
99+ if decoded, err := decodeRecordValue(name, v); err == nil {
100+ r = decoded
101+ r.LastUsedSeq = lastUsedSeq
102+ }
103+ }
104+ value, err := encodeRecordValue(r)
80105 if err != nil {
81106 return err
82107 }
@@ -70,13 +70,38 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
70 }70 }
71 71
72 createdAt := at72 createdAt := at
73+ lastUsedSeq := uint64(0)
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.CreatedAt76 createdAt = r.CreatedAt
77+ lastUsedSeq = r.LastUsedSeq
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, LastUsedSeq: lastUsedSeq})
82+ if err != nil {
83+ return err
84+ }
85+ return p.store.Set([]byte(name), value)
86+}
87+
88+// TouchMRU updates hot-path session recency without forcing an fsync.
89+func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
90+ if p == nil || p.store == nil {
91+ return nil
92+ }
93+ if name == "" {
94+ return errEmptyName
95+ }
96+
97+ r := Record{Name: name, LastUsedSeq: lastUsedSeq}
98+ if v, ok := p.store.Get([]byte(name)); ok {
99+ if decoded, err := decodeRecordValue(name, v); err == nil {
100+ r = decoded
101+ r.LastUsedSeq = lastUsedSeq
102+ }
103+ }
104+ value, err := encodeRecordValue(r)
80 if err != nil {105 if err != nil {
81 return err106 return err
82 }107 }
M internal/persist/persist_test.go +25 -11
@@ -12,7 +12,7 @@ import (
1212 )
1313
1414 func TestRecordCodecRoundTrip(t *testing.T) {
15- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22}
15+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
1616 value, err := encodeRecordValue(r)
1717 require.NoError(t, err)
1818
@@ -21,6 +21,16 @@ func TestRecordCodecRoundTrip(t *testing.T) {
2121 require.Equal(t, r, got)
2222 }
2323
24+func TestRecordCodecOldRecordDefaultsLastUsed(t *testing.T) {
25+ old := []byte{0, 0, 0, 4, '/', 't', 'm', 'p'}
26+ old = append(old, 0, 0, 0, 0, 0, 0, 0, 11)
27+ old = append(old, 0, 0, 0, 0, 0, 0, 0, 22)
28+
29+ got, err := decodeRecordValue("work", old)
30+ require.NoError(t, err)
31+ require.Equal(t, Record{Name: "work", Cwd: "/tmp", CreatedAt: 11, UpdatedAt: 22}, got)
32+}
33+
2434 func TestRecordCodecRejectsMalformedData(t *testing.T) {
2535 _, err := encodeRecordValue(Record{Cwd: "/tmp"})
2636 require.ErrorIs(t, err, errEmptyName)
@@ -31,7 +41,6 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) {
3141 {0, 0, 0, 5, 'a'},
3242 {0, 0, 0, 1, 'a', 0},
3343 {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},
3544 }
3645 for _, value := range badValues {
3746 _, err := decodeRecordValue("name", value)
@@ -67,26 +76,27 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
6776 p, err := Open(dir)
6877 require.NoError(t, err)
6978
70- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10}))
71- require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))
79+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))
80+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
7281 require.NoError(t, p.Touch("b", "/b/next", 30))
82+ require.NoError(t, p.TouchMRU("b", 60))
7383
7484 records, err := p.LoadAll()
7585 require.NoError(t, err)
7686 require.Equal(t, []Record{
77- {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},
78- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30},
87+ {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
88+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},
7989 }, records)
8090
8191 require.NoError(t, p.Delete("a"))
8292 records, err = p.LoadAll()
8393 require.NoError(t, err)
84- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records)
94+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)
8595 require.NoError(t, p.Close())
8696 }
8797
8898 func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
89- good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2})
99+ good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3})
90100 require.NoError(t, err)
91101
92102 records, err := decodeAll(map[string][]byte{
@@ -94,19 +104,19 @@ func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
94104 "good": good,
95105 })
96106 require.NoError(t, err)
97- require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2}}, records)
107+ require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
98108 }
99109
100110 func TestLoadReadOnlyUsesReplay(t *testing.T) {
101111 dir := t.TempDir()
102112 p, err := Open(dir)
103113 require.NoError(t, err)
104- require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}))
114+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}))
105115 require.NoError(t, p.Close())
106116
107117 records, err := LoadReadOnly(dir)
108118 require.NoError(t, err)
109- require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records)
119+ require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
110120
111121 // Missing stores replay as empty rather than creating the WAL.
112122 missing := filepath.Join(t.TempDir(), "missing")
@@ -127,6 +137,10 @@ func TestSyncBehavior(t *testing.T) {
127137 require.Equal(t, 2, state.sets)
128138 require.Equal(t, 1, state.syncs)
129139
140+ require.NoError(t, p.TouchMRU("work", 3))
141+ require.Equal(t, 3, state.sets)
142+ require.Equal(t, 1, state.syncs)
143+
130144 require.NoError(t, p.Delete("work"))
131145 require.Equal(t, 1, state.deletes)
132146 require.Equal(t, 2, state.syncs)
@@ -12,7 +12,7 @@ import (
12 )12 )
13 13
14 func TestRecordCodecRoundTrip(t *testing.T) {14 func TestRecordCodecRoundTrip(t *testing.T) {
15- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22}15+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
16 value, err := encodeRecordValue(r)16 value, err := encodeRecordValue(r)
17 require.NoError(t, err)17 require.NoError(t, err)
18 18
@@ -21,6 +21,16 @@ func TestRecordCodecRoundTrip(t *testing.T) {
21 require.Equal(t, r, got)21 require.Equal(t, r, got)
22 }22 }
23 23
24+func TestRecordCodecOldRecordDefaultsLastUsed(t *testing.T) {
25+ old := []byte{0, 0, 0, 4, '/', 't', 'm', 'p'}
26+ old = append(old, 0, 0, 0, 0, 0, 0, 0, 11)
27+ old = append(old, 0, 0, 0, 0, 0, 0, 0, 22)
28+
29+ got, err := decodeRecordValue("work", old)
30+ require.NoError(t, err)
31+ require.Equal(t, Record{Name: "work", Cwd: "/tmp", CreatedAt: 11, UpdatedAt: 22}, got)
32+}
33+
24 func TestRecordCodecRejectsMalformedData(t *testing.T) {34 func TestRecordCodecRejectsMalformedData(t *testing.T) {
25 _, err := encodeRecordValue(Record{Cwd: "/tmp"})35 _, err := encodeRecordValue(Record{Cwd: "/tmp"})
26 require.ErrorIs(t, err, errEmptyName)36 require.ErrorIs(t, err, errEmptyName)
@@ -31,7 +41,6 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) {
31 {0, 0, 0, 5, 'a'},41 {0, 0, 0, 5, 'a'},
32 {0, 0, 0, 1, 'a', 0},42 {0, 0, 0, 1, 'a', 0},
33 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},43 {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},
35 }44 }
36 for _, value := range badValues {45 for _, value := range badValues {
37 _, err := decodeRecordValue("name", value)46 _, err := decodeRecordValue("name", value)
@@ -67,26 +76,27 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
67 p, err := Open(dir)76 p, err := Open(dir)
68 require.NoError(t, err)77 require.NoError(t, err)
69 78
70- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10}))79+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))
71- require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))80+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
72 require.NoError(t, p.Touch("b", "/b/next", 30))81 require.NoError(t, p.Touch("b", "/b/next", 30))
82+ require.NoError(t, p.TouchMRU("b", 60))
73 83
74 records, err := p.LoadAll()84 records, err := p.LoadAll()
75 require.NoError(t, err)85 require.NoError(t, err)
76 require.Equal(t, []Record{86 require.Equal(t, []Record{
77- {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},87+ {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
78- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30},88+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},
79 }, records)89 }, records)
80 90
81 require.NoError(t, p.Delete("a"))91 require.NoError(t, p.Delete("a"))
82 records, err = p.LoadAll()92 records, err = p.LoadAll()
83 require.NoError(t, err)93 require.NoError(t, err)
84- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records)94+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)
85 require.NoError(t, p.Close())95 require.NoError(t, p.Close())
86 }96 }
87 97
88 func TestDecodeAllSkipsMalformedRecords(t *testing.T) {98 func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
89- good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2})99+ good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3})
90 require.NoError(t, err)100 require.NoError(t, err)
91 101
92 records, err := decodeAll(map[string][]byte{102 records, err := decodeAll(map[string][]byte{
@@ -94,19 +104,19 @@ func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
94 "good": good,104 "good": good,
95 })105 })
96 require.NoError(t, err)106 require.NoError(t, err)
97- require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2}}, records)107+ require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
98 }108 }
99 109
100 func TestLoadReadOnlyUsesReplay(t *testing.T) {110 func TestLoadReadOnlyUsesReplay(t *testing.T) {
101 dir := t.TempDir()111 dir := t.TempDir()
102 p, err := Open(dir)112 p, err := Open(dir)
103 require.NoError(t, err)113 require.NoError(t, err)
104- require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}))114+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}))
105 require.NoError(t, p.Close())115 require.NoError(t, p.Close())
106 116
107 records, err := LoadReadOnly(dir)117 records, err := LoadReadOnly(dir)
108 require.NoError(t, err)118 require.NoError(t, err)
109- require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records)119+ require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
110 120
111 // Missing stores replay as empty rather than creating the WAL.121 // Missing stores replay as empty rather than creating the WAL.
112 missing := filepath.Join(t.TempDir(), "missing")122 missing := filepath.Join(t.TempDir(), "missing")
@@ -127,6 +137,10 @@ func TestSyncBehavior(t *testing.T) {
127 require.Equal(t, 2, state.sets)137 require.Equal(t, 2, state.sets)
128 require.Equal(t, 1, state.syncs)138 require.Equal(t, 1, state.syncs)
129 139
140+ require.NoError(t, p.TouchMRU("work", 3))
141+ require.Equal(t, 3, state.sets)
142+ require.Equal(t, 1, state.syncs)
143+
130 require.NoError(t, p.Delete("work"))144 require.NoError(t, p.Delete("work"))
131 require.Equal(t, 1, state.deletes)145 require.Equal(t, 1, state.deletes)
132 require.Equal(t, 2, state.syncs)146 require.Equal(t, 2, state.syncs)
M internal/persist/record.go +14 -8
@@ -9,10 +9,11 @@ import (
99
1010 // Record is the persisted metadata for a named session.
1111 type Record struct {
12- Name string
13- Cwd string
14- CreatedAt int64
15- UpdatedAt int64
12+ Name string
13+ Cwd string
14+ CreatedAt int64
15+ UpdatedAt int64
16+ LastUsedSeq uint64
1617 }
1718
1819 var (
@@ -28,12 +29,13 @@ func encodeRecordValue(r Record) ([]byte, error) {
2829 return nil, fmt.Errorf("persist: cwd too large")
2930 }
3031
31- buf := make([]byte, 4+len(r.Cwd)+8+8)
32+ buf := make([]byte, 4+len(r.Cwd)+8+8+8)
3233 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
3334 copy(buf[4:], r.Cwd)
3435 off := 4 + len(r.Cwd)
3536 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
3637 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
38+ binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
3739 return buf, nil
3840 }
3941
@@ -50,14 +52,18 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
5052 return Record{}, errMalformedRecord
5153 }
5254 cwdLen := int(cwdLen32)
53- if len(value) != 4+cwdLen+8+8 {
55+ if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 {
5456 return Record{}, errMalformedRecord
5557 }
5658 off := 4 + cwdLen
57- return Record{
59+ r := Record{
5860 Name: name,
5961 Cwd: string(value[4:off]),
6062 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
6163 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
62- }, nil
64+ }
65+ if len(value) == 4+cwdLen+8+8+8 {
66+ r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
67+ }
68+ return r, nil
6369 }
@@ -9,10 +9,11 @@ import (
9 9
10 // Record is the persisted metadata for a named session.10 // Record is the persisted metadata for a named session.
11 type Record struct {11 type Record struct {
12- Name string12+ Name string
13- Cwd string13+ Cwd string
14- CreatedAt int6414+ CreatedAt int64
15- UpdatedAt int6415+ UpdatedAt int64
16+ LastUsedSeq uint64
16 }17 }
17 18
18 var (19 var (
@@ -28,12 +29,13 @@ func encodeRecordValue(r Record) ([]byte, error) {
28 return nil, fmt.Errorf("persist: cwd too large")29 return nil, fmt.Errorf("persist: cwd too large")
29 }30 }
30 31
31- buf := make([]byte, 4+len(r.Cwd)+8+8)32+ buf := make([]byte, 4+len(r.Cwd)+8+8+8)
32 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))33 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
33 copy(buf[4:], r.Cwd)34 copy(buf[4:], r.Cwd)
34 off := 4 + len(r.Cwd)35 off := 4 + len(r.Cwd)
35 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))36 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
36 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))37 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
38+ binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
37 return buf, nil39 return buf, nil
38 }40 }
39 41
@@ -50,14 +52,18 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
50 return Record{}, errMalformedRecord52 return Record{}, errMalformedRecord
51 }53 }
52 cwdLen := int(cwdLen32)54 cwdLen := int(cwdLen32)
53- if len(value) != 4+cwdLen+8+8 {55+ if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 {
54 return Record{}, errMalformedRecord56 return Record{}, errMalformedRecord
55 }57 }
56 off := 4 + cwdLen58 off := 4 + cwdLen
57- return Record{59+ r := Record{
58 Name: name,60 Name: name,
59 Cwd: string(value[4:off]),61 Cwd: string(value[4:off]),
60 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),62 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
61 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),63 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
62- }, nil64+ }
65+ if len(value) == 4+cwdLen+8+8+8 {
66+ r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
67+ }
68+ return r, nil
63 }69 }
M internal/usecase/daemon/daemon.go +11 -5
@@ -136,10 +136,11 @@ type parkedAttachment struct {
136136 // session is a single multiplexed session. It owns one or more full-screen
137137
138138 type stoppedSession struct {
139- name string
140- cwd string
141- createdAt int64
142- purging bool
139+ name string
140+ cwd string
141+ createdAt int64
142+ lastUsedSeq uint64
143+ purging bool
143144 }
144145
145146 type Option func(*Daemon)
@@ -221,9 +222,14 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
221222 if records, err := d.persist.LoadAll(); err != nil {
222223 d.log.Warn("loading persisted sessions failed", "err", err)
223224 } else {
225+ var maxSeq uint64
224226 for _, r := range records {
225- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}
227+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
228+ if r.LastUsedSeq > maxSeq {
229+ maxSeq = r.LastUsedSeq
230+ }
226231 }
232+ d.mruSeq.Store(maxSeq)
227233 }
228234 return d
229235 }
@@ -136,10 +136,11 @@ type parkedAttachment struct {
136 // session is a single multiplexed session. It owns one or more full-screen136 // session is a single multiplexed session. It owns one or more full-screen
137 137
138 type stoppedSession struct {138 type stoppedSession struct {
139- name string139+ name string
140- cwd string140+ cwd string
141- createdAt int64141+ createdAt int64
142- purging bool142+ lastUsedSeq uint64
143+ purging bool
143 }144 }
144 145
145 type Option func(*Daemon)146 type Option func(*Daemon)
@@ -221,9 +222,14 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
221 if records, err := d.persist.LoadAll(); err != nil {222 if records, err := d.persist.LoadAll(); err != nil {
222 d.log.Warn("loading persisted sessions failed", "err", err)223 d.log.Warn("loading persisted sessions failed", "err", err)
223 } else {224 } else {
225+ var maxSeq uint64
224 for _, r := range records {226 for _, r := range records {
225- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}227+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
228+ if r.LastUsedSeq > maxSeq {
229+ maxSeq = r.LastUsedSeq
230+ }
226 }231 }
232+ d.mruSeq.Store(maxSeq)
227 }233 }
228 return d234 return d
229 }235 }
M internal/usecase/daemon/session.go +31 -5
@@ -93,13 +93,27 @@ func (d *Daemon) touchMRU(sess *session) {
9393 return
9494 }
9595 seq := d.mruSeq.Add(1)
96+ updated := false
9697 for {
9798 old := sess.mruAt.Load()
9899 if old >= seq {
99100 return
100101 }
101102 if sess.mruAt.CompareAndSwap(old, seq) {
102- return
103+ updated = true
104+ break
105+ }
106+ }
107+ if !updated {
108+ return
109+ }
110+ sess.mu.Lock()
111+ name := sess.name
112+ ephemeral := sess.ephemeral
113+ sess.mu.Unlock()
114+ if !ephemeral && d.persist != nil {
115+ if err := d.persist.TouchMRU(name, seq); err != nil {
116+ d.log.Warn("touching persisted session recency failed", "err", err, "session", name)
103117 }
104118 }
105119 }
@@ -119,6 +133,12 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
119133 tb := newTab(pty, tbSize)
120134 sctx, cancel := context.WithCancel(d.serveCtx)
121135 tb.ctx, tb.cancel = context.WithCancel(sctx)
136+ lastUsedSeq := uint64(0)
137+ if !ephemeral {
138+ if stopped, ok := d.stopped[name]; ok {
139+ lastUsedSeq = stopped.lastUsedSeq
140+ }
141+ }
122142 sess := &session{
123143 id: id,
124144 name: name,
@@ -129,8 +149,11 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
129149 cwd: cwd,
130150 createdAt: createdAt,
131151 }
152+ if lastUsedSeq > 0 {
153+ sess.mruAt.Store(lastUsedSeq)
154+ }
132155 if !ephemeral {
133- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil {
156+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
134157 _ = pty.Close()
135158 cancel()
136159 return nil, err
@@ -138,7 +161,9 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
138161 delete(d.stopped, name)
139162 }
140163 d.sessions[id] = sess
141- d.touchMRU(sess)
164+ if lastUsedSeq == 0 {
165+ d.touchMRU(sess)
166+ }
142167 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
143168 d.log.Info("tab created", "session", name, "tab", 0)
144169 d.startTabGoroutines(sess, tb)
@@ -374,8 +399,9 @@ func (d *Daemon) renameSession(sess *session, name string) error {
374399 createdAt = time.Now().UnixNano()
375400 sess.createdAt = createdAt
376401 }
402+ lastUsedSeq := sess.mruAt.Load()
377403 if wasEphemeral || oldName != name {
378- if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil {
404+ if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {
379405 return err
380406 }
381407 }
@@ -464,7 +490,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
464490 ephemeral := sess.ephemeral
465491 sess.mu.Unlock()
466492 if !ephemeral {
467- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, purging: purge}
493+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), purging: purge}
468494 d.stopped[stoppedName] = stopped
469495 }
470496 empty := len(d.sessions) == 0
@@ -93,13 +93,27 @@ func (d *Daemon) touchMRU(sess *session) {
93 return93 return
94 }94 }
95 seq := d.mruSeq.Add(1)95 seq := d.mruSeq.Add(1)
96+ updated := false
96 for {97 for {
97 old := sess.mruAt.Load()98 old := sess.mruAt.Load()
98 if old >= seq {99 if old >= seq {
99 return100 return
100 }101 }
101 if sess.mruAt.CompareAndSwap(old, seq) {102 if sess.mruAt.CompareAndSwap(old, seq) {
102- return103+ updated = true
104+ break
105+ }
106+ }
107+ if !updated {
108+ return
109+ }
110+ sess.mu.Lock()
111+ name := sess.name
112+ ephemeral := sess.ephemeral
113+ sess.mu.Unlock()
114+ if !ephemeral && d.persist != nil {
115+ if err := d.persist.TouchMRU(name, seq); err != nil {
116+ d.log.Warn("touching persisted session recency failed", "err", err, "session", name)
103 }117 }
104 }118 }
105 }119 }
@@ -119,6 +133,12 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
119 tb := newTab(pty, tbSize)133 tb := newTab(pty, tbSize)
120 sctx, cancel := context.WithCancel(d.serveCtx)134 sctx, cancel := context.WithCancel(d.serveCtx)
121 tb.ctx, tb.cancel = context.WithCancel(sctx)135 tb.ctx, tb.cancel = context.WithCancel(sctx)
136+ lastUsedSeq := uint64(0)
137+ if !ephemeral {
138+ if stopped, ok := d.stopped[name]; ok {
139+ lastUsedSeq = stopped.lastUsedSeq
140+ }
141+ }
122 sess := &session{142 sess := &session{
123 id: id,143 id: id,
124 name: name,144 name: name,
@@ -129,8 +149,11 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
129 cwd: cwd,149 cwd: cwd,
130 createdAt: createdAt,150 createdAt: createdAt,
131 }151 }
152+ if lastUsedSeq > 0 {
153+ sess.mruAt.Store(lastUsedSeq)
154+ }
132 if !ephemeral {155 if !ephemeral {
133- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil {156+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
134 _ = pty.Close()157 _ = pty.Close()
135 cancel()158 cancel()
136 return nil, err159 return nil, err
@@ -138,7 +161,9 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
138 delete(d.stopped, name)161 delete(d.stopped, name)
139 }162 }
140 d.sessions[id] = sess163 d.sessions[id] = sess
141- d.touchMRU(sess)164+ if lastUsedSeq == 0 {
165+ d.touchMRU(sess)
166+ }
142 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)167 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
143 d.log.Info("tab created", "session", name, "tab", 0)168 d.log.Info("tab created", "session", name, "tab", 0)
144 d.startTabGoroutines(sess, tb)169 d.startTabGoroutines(sess, tb)
@@ -374,8 +399,9 @@ func (d *Daemon) renameSession(sess *session, name string) error {
374 createdAt = time.Now().UnixNano()399 createdAt = time.Now().UnixNano()
375 sess.createdAt = createdAt400 sess.createdAt = createdAt
376 }401 }
402+ lastUsedSeq := sess.mruAt.Load()
377 if wasEphemeral || oldName != name {403 if wasEphemeral || oldName != name {
378- if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil {404+ if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {
379 return err405 return err
380 }406 }
381 }407 }
@@ -464,7 +490,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
464 ephemeral := sess.ephemeral490 ephemeral := sess.ephemeral
465 sess.mu.Unlock()491 sess.mu.Unlock()
466 if !ephemeral {492 if !ephemeral {
467- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, purging: purge}493+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), purging: purge}
468 d.stopped[stoppedName] = stopped494 d.stopped[stoppedName] = stopped
469 }495 }
470 empty := len(d.sessions) == 0496 empty := len(d.sessions) == 0
M internal/usecase/daemon/session_test.go +69 -2
@@ -648,16 +648,50 @@ func (s *mockStoreState) has(name string) bool {
648648 return ok
649649 }
650650
651+func (s *mockStoreState) record(t *testing.T, name string) persist.Record {
652+ t.Helper()
653+ s.mu.Lock()
654+ defer s.mu.Unlock()
655+ records, err := persist.New(newStaticStore(s.data)).LoadAll()
656+ require.NoError(t, err)
657+ for _, r := range records {
658+ if r.Name == name {
659+ return r
660+ }
661+ }
662+ t.Fatalf("record %q not found", name)
663+ return persist.Record{}
664+}
665+
666+type newStaticStore map[string][]byte
667+
668+func (s newStaticStore) Get(key []byte) ([]byte, bool) {
669+ v, ok := s[string(key)]
670+ return append([]byte(nil), v...), ok
671+}
672+func (s newStaticStore) Set(_, _ []byte) error { return nil }
673+func (s newStaticStore) Delete(_ []byte) error { return nil }
674+func (s newStaticStore) Range(fn func(k, v []byte) bool) {
675+ for k, v := range s {
676+ if !fn([]byte(k), append([]byte(nil), v...)) {
677+ return
678+ }
679+ }
680+}
681+func (s newStaticStore) Sync() error { return nil }
682+func (s newStaticStore) Close() error { return nil }
683+
651684 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
652685 store, _ := newMockStore(t)
653686 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
654- require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8}))
687+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))
655688
656689 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
657690 d.mu.Lock()
658691 stopped := d.stopped["work"]
659692 d.mu.Unlock()
660- require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)
693+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, lastUsedSeq: 9}, stopped)
694+ require.Equal(t, uint64(9), d.mruSeq.Load())
661695 }
662696
663697 func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
@@ -709,6 +743,39 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
709743 require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())
710744 }
711745
746+func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
747+ store, state := newMockStore(t)
748+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
749+ WithStore(store)(d)
750+ named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1}
751+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
752+
753+ d.touchMRU(named)
754+ require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
755+ setsAfterNamed := state.sets
756+
757+ ephemeral := &session{name: "0", ephemeral: true, tabs: []*tab{{}}}
758+ d.touchMRU(ephemeral)
759+ require.False(t, state.has("0"))
760+ require.Equal(t, setsAfterNamed, state.sets)
761+}
762+
763+func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
764+ sz := domain.Size{Cols: 80, Rows: 24}
765+ p, release := newBlockingPTY(t)
766+ defer release()
767+ store, state := newMockStore(t)
768+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
769+ WithStore(store)(d)
770+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
771+ d.mruSeq.Store(42)
772+
773+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
774+ require.NoError(t, err)
775+ require.Equal(t, uint64(42), sess.mruAt.Load())
776+ require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
777+}
778+
712779 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
713780 sz := domain.Size{Cols: 80, Rows: 24}
714781 p, release := newBlockingPTY(t)
@@ -648,16 +648,50 @@ func (s *mockStoreState) has(name string) bool {
648 return ok648 return ok
649 }649 }
650 650
651+func (s *mockStoreState) record(t *testing.T, name string) persist.Record {
652+ t.Helper()
653+ s.mu.Lock()
654+ defer s.mu.Unlock()
655+ records, err := persist.New(newStaticStore(s.data)).LoadAll()
656+ require.NoError(t, err)
657+ for _, r := range records {
658+ if r.Name == name {
659+ return r
660+ }
661+ }
662+ t.Fatalf("record %q not found", name)
663+ return persist.Record{}
664+}
665+
666+type newStaticStore map[string][]byte
667+
668+func (s newStaticStore) Get(key []byte) ([]byte, bool) {
669+ v, ok := s[string(key)]
670+ return append([]byte(nil), v...), ok
671+}
672+func (s newStaticStore) Set(_, _ []byte) error { return nil }
673+func (s newStaticStore) Delete(_ []byte) error { return nil }
674+func (s newStaticStore) Range(fn func(k, v []byte) bool) {
675+ for k, v := range s {
676+ if !fn([]byte(k), append([]byte(nil), v...)) {
677+ return
678+ }
679+ }
680+}
681+func (s newStaticStore) Sync() error { return nil }
682+func (s newStaticStore) Close() error { return nil }
683+
651 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {684 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
652 store, _ := newMockStore(t)685 store, _ := newMockStore(t)
653 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))686 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
654- require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8}))687+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))
655 688
656 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))689 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
657 d.mu.Lock()690 d.mu.Lock()
658 stopped := d.stopped["work"]691 stopped := d.stopped["work"]
659 d.mu.Unlock()692 d.mu.Unlock()
660- require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)693+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, lastUsedSeq: 9}, stopped)
694+ require.Equal(t, uint64(9), d.mruSeq.Load())
661 }695 }
662 696
663 func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {697 func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
@@ -709,6 +743,39 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
709 require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())743 require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())
710 }744 }
711 745
746+func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
747+ store, state := newMockStore(t)
748+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
749+ WithStore(store)(d)
750+ named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1}
751+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
752+
753+ d.touchMRU(named)
754+ require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
755+ setsAfterNamed := state.sets
756+
757+ ephemeral := &session{name: "0", ephemeral: true, tabs: []*tab{{}}}
758+ d.touchMRU(ephemeral)
759+ require.False(t, state.has("0"))
760+ require.Equal(t, setsAfterNamed, state.sets)
761+}
762+
763+func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
764+ sz := domain.Size{Cols: 80, Rows: 24}
765+ p, release := newBlockingPTY(t)
766+ defer release()
767+ store, state := newMockStore(t)
768+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
769+ WithStore(store)(d)
770+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
771+ d.mruSeq.Store(42)
772+
773+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
774+ require.NoError(t, err)
775+ require.Equal(t, uint64(42), sess.mruAt.Load())
776+ require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
777+}
778+
712 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {779 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
713 sz := domain.Size{Cols: 80, Rows: 24}780 sz := domain.Size{Cols: 80, Rows: 24}
714 p, release := newBlockingPTY(t)781 p, release := newBlockingPTY(t)
M internal/usecase/daemon/status_test.go +23 -0
@@ -321,6 +321,29 @@ func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
321321 require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
322322 }
323323
324+func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
325+ sz := domain.Size{Cols: 80, Rows: 24}
326+ p1, releasePTY1 := newBlockingPTY(t)
327+ defer releasePTY1()
328+ p2, releasePTY2 := newBlockingPTY(t)
329+ defer releasePTY2()
330+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
331+ d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
332+ d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
333+ d.mruSeq.Store(30)
334+ cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
335+ d.sessions[cur.id] = cur
336+
337+ _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz)
338+ require.NoError(t, err)
339+ _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz)
340+ require.NoError(t, err)
341+
342+ state := d.barStateFor(cur, "")
343+ require.GreaterOrEqual(t, len(state.mru), 2)
344+ require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
345+}
346+
324347 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
325348 state := barState{
326349 status: statusSnapshot{session: "cur"},
@@ -321,6 +321,29 @@ func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
321 require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})321 require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
322 }322 }
323 323
324+func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
325+ sz := domain.Size{Cols: 80, Rows: 24}
326+ p1, releasePTY1 := newBlockingPTY(t)
327+ defer releasePTY1()
328+ p2, releasePTY2 := newBlockingPTY(t)
329+ defer releasePTY2()
330+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
331+ d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
332+ d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
333+ d.mruSeq.Store(30)
334+ cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
335+ d.sessions[cur.id] = cur
336+
337+ _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz)
338+ require.NoError(t, err)
339+ _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz)
340+ require.NoError(t, err)
341+
342+ state := d.barStateFor(cur, "")
343+ require.GreaterOrEqual(t, len(state.mru), 2)
344+ require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
345+}
346+
324 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {347 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
325 state := barState{348 state := barState{
326 status: statusSnapshot{session: "cur"},349 status: statusSnapshot{session: "cur"},

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