detached 973d2af

Merge remote-tracking branch 'origin/main' into feature/config-live-reload-theme

# Conflicts:
#	internal/usecase/command/registry.go
commit
973d2af1c6f7b2fdb5700dd1a6edbd6ce21a03db
author
brice · · 2026-07-05 17:38 UTC
merge of
2a105ed + 6812bac · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +661 -59
M README.md +2 -1
@@ -87,6 +87,7 @@ Successful commands are promoted near the top the next time the palette opens.
8787 | CLP | close focused pane |
8888 | FPL / FPR / FPU / FPD | focus pane left / right / up / down |
8989 | NXT / PVT | switch to next / previous tab |
90+| BSK / FSK | move back / forward through recent sessions |
9091 | SSP | open session picker |
9192 | VIS | enter visual mode |
9293 | RNS | rename session |
@@ -109,7 +110,7 @@ Open it with `SSP` in the palette. It lists sessions and tabs, previews the sele
109110
110111 Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused by direction, and closed from the command palette. Clicking a pane focuses it.
111112
112-The top bar shows numbered tabs. The bottom bar shows the session name; ephemeral sessions are marked with `*`, for example `0*`.
113+The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
113114
114115 ## Visual mode and copy
115116
@@ -87,6 +87,7 @@ Successful commands are promoted near the top the next time the palette opens.
87 | CLP | close focused pane |87 | CLP | close focused pane |
88 | FPL / FPR / FPU / FPD | focus pane left / right / up / down |88 | FPL / FPR / FPU / FPD | focus pane left / right / up / down |
89 | NXT / PVT | switch to next / previous tab |89 | NXT / PVT | switch to next / previous tab |
90+| BSK / FSK | move back / forward through recent sessions |
90 | SSP | open session picker |91 | SSP | open session picker |
91 | VIS | enter visual mode |92 | VIS | enter visual mode |
92 | RNS | rename session |93 | RNS | rename session |
@@ -109,7 +110,7 @@ Open it with `SSP` in the palette. It lists sessions and tabs, previews the sele
109 110
110 Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused by direction, and closed from the command palette. Clicking a pane focuses it.111 Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused by direction, and closed from the command palette. Clicking a pane focuses it.
111 112
112-The top bar shows numbered tabs. The bottom bar shows the session name; ephemeral sessions are marked with `*`, for example `0*`.113+The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
113 114
114 ## Visual mode and copy115 ## Visual mode and copy
115 116
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/command/command.go +2 -0
@@ -18,6 +18,8 @@ type Context interface {
1818 FocusPaneDown() error
1919 NextTab() error
2020 PrevTab() error
21+ BackSession() error
22+ ForwardSession() error
2123 Detach() error
2224 EnterVisualMode() error
2325 RenameSession() error
@@ -18,6 +18,8 @@ type Context interface {
18 FocusPaneDown() error18 FocusPaneDown() error
19 NextTab() error19 NextTab() error
20 PrevTab() error20 PrevTab() error
21+ BackSession() error
22+ ForwardSession() error
21 Detach() error23 Detach() error
22 EnterVisualMode() error24 EnterVisualMode() error
23 RenameSession() error25 RenameSession() error
M internal/usecase/command/context_mock_test.go +88 -0
@@ -35,6 +35,50 @@ func (_m *MockContext) EXPECT() *MockContext_Expecter {
3535 return &MockContext_Expecter{mock: &_m.Mock}
3636 }
3737
38+// BackSession provides a mock function for the type MockContext
39+func (_mock *MockContext) BackSession() error {
40+ ret := _mock.Called()
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for BackSession")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func() error); ok {
48+ r0 = returnFunc()
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockContext_BackSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BackSession'
56+type MockContext_BackSession_Call struct {
57+ *mock.Call
58+}
59+
60+// BackSession is a helper method to define mock.On call
61+func (_e *MockContext_Expecter) BackSession() *MockContext_BackSession_Call {
62+ return &MockContext_BackSession_Call{Call: _e.mock.On("BackSession")}
63+}
64+
65+func (_c *MockContext_BackSession_Call) Run(run func()) *MockContext_BackSession_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ run()
68+ })
69+ return _c
70+}
71+
72+func (_c *MockContext_BackSession_Call) Return(err error) *MockContext_BackSession_Call {
73+ _c.Call.Return(err)
74+ return _c
75+}
76+
77+func (_c *MockContext_BackSession_Call) RunAndReturn(run func() error) *MockContext_BackSession_Call {
78+ _c.Call.Return(run)
79+ return _c
80+}
81+
3882 // ClosePane provides a mock function for the type MockContext
3983 func (_mock *MockContext) ClosePane() error {
4084 ret := _mock.Called()
@@ -475,6 +519,50 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
475519 return _c
476520 }
477521
522+// ForwardSession provides a mock function for the type MockContext
523+func (_mock *MockContext) ForwardSession() error {
524+ ret := _mock.Called()
525+
526+ if len(ret) == 0 {
527+ panic("no return value specified for ForwardSession")
528+ }
529+
530+ var r0 error
531+ if returnFunc, ok := ret.Get(0).(func() error); ok {
532+ r0 = returnFunc()
533+ } else {
534+ r0 = ret.Error(0)
535+ }
536+ return r0
537+}
538+
539+// MockContext_ForwardSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForwardSession'
540+type MockContext_ForwardSession_Call struct {
541+ *mock.Call
542+}
543+
544+// ForwardSession is a helper method to define mock.On call
545+func (_e *MockContext_Expecter) ForwardSession() *MockContext_ForwardSession_Call {
546+ return &MockContext_ForwardSession_Call{Call: _e.mock.On("ForwardSession")}
547+}
548+
549+func (_c *MockContext_ForwardSession_Call) Run(run func()) *MockContext_ForwardSession_Call {
550+ _c.Call.Run(func(args mock.Arguments) {
551+ run()
552+ })
553+ return _c
554+}
555+
556+func (_c *MockContext_ForwardSession_Call) Return(err error) *MockContext_ForwardSession_Call {
557+ _c.Call.Return(err)
558+ return _c
559+}
560+
561+func (_c *MockContext_ForwardSession_Call) RunAndReturn(run func() error) *MockContext_ForwardSession_Call {
562+ _c.Call.Return(run)
563+ return _c
564+}
565+
478566 // NextTab provides a mock function for the type MockContext
479567 func (_mock *MockContext) NextTab() error {
480568 ret := _mock.Called()
@@ -35,6 +35,50 @@ func (_m *MockContext) EXPECT() *MockContext_Expecter {
35 return &MockContext_Expecter{mock: &_m.Mock}35 return &MockContext_Expecter{mock: &_m.Mock}
36 }36 }
37 37
38+// BackSession provides a mock function for the type MockContext
39+func (_mock *MockContext) BackSession() error {
40+ ret := _mock.Called()
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for BackSession")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func() error); ok {
48+ r0 = returnFunc()
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockContext_BackSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BackSession'
56+type MockContext_BackSession_Call struct {
57+ *mock.Call
58+}
59+
60+// BackSession is a helper method to define mock.On call
61+func (_e *MockContext_Expecter) BackSession() *MockContext_BackSession_Call {
62+ return &MockContext_BackSession_Call{Call: _e.mock.On("BackSession")}
63+}
64+
65+func (_c *MockContext_BackSession_Call) Run(run func()) *MockContext_BackSession_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ run()
68+ })
69+ return _c
70+}
71+
72+func (_c *MockContext_BackSession_Call) Return(err error) *MockContext_BackSession_Call {
73+ _c.Call.Return(err)
74+ return _c
75+}
76+
77+func (_c *MockContext_BackSession_Call) RunAndReturn(run func() error) *MockContext_BackSession_Call {
78+ _c.Call.Return(run)
79+ return _c
80+}
81+
38 // ClosePane provides a mock function for the type MockContext82 // ClosePane provides a mock function for the type MockContext
39 func (_mock *MockContext) ClosePane() error {83 func (_mock *MockContext) ClosePane() error {
40 ret := _mock.Called()84 ret := _mock.Called()
@@ -475,6 +519,50 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
475 return _c519 return _c
476 }520 }
477 521
522+// ForwardSession provides a mock function for the type MockContext
523+func (_mock *MockContext) ForwardSession() error {
524+ ret := _mock.Called()
525+
526+ if len(ret) == 0 {
527+ panic("no return value specified for ForwardSession")
528+ }
529+
530+ var r0 error
531+ if returnFunc, ok := ret.Get(0).(func() error); ok {
532+ r0 = returnFunc()
533+ } else {
534+ r0 = ret.Error(0)
535+ }
536+ return r0
537+}
538+
539+// MockContext_ForwardSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForwardSession'
540+type MockContext_ForwardSession_Call struct {
541+ *mock.Call
542+}
543+
544+// ForwardSession is a helper method to define mock.On call
545+func (_e *MockContext_Expecter) ForwardSession() *MockContext_ForwardSession_Call {
546+ return &MockContext_ForwardSession_Call{Call: _e.mock.On("ForwardSession")}
547+}
548+
549+func (_c *MockContext_ForwardSession_Call) Run(run func()) *MockContext_ForwardSession_Call {
550+ _c.Call.Run(func(args mock.Arguments) {
551+ run()
552+ })
553+ return _c
554+}
555+
556+func (_c *MockContext_ForwardSession_Call) Return(err error) *MockContext_ForwardSession_Call {
557+ _c.Call.Return(err)
558+ return _c
559+}
560+
561+func (_c *MockContext_ForwardSession_Call) RunAndReturn(run func() error) *MockContext_ForwardSession_Call {
562+ _c.Call.Return(run)
563+ return _c
564+}
565+
478 // NextTab provides a mock function for the type MockContext566 // NextTab provides a mock function for the type MockContext
479 func (_mock *MockContext) NextTab() error {567 func (_mock *MockContext) NextTab() error {
480 ret := _mock.Called()568 ret := _mock.Called()
M internal/usecase/command/registry.go +2 -0
@@ -21,6 +21,8 @@ func Registry() []Command {
2121 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
2222 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
2323 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24+ {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},
25+ {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},
2426 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
2527 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
2628 {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() }},
@@ -21,6 +21,8 @@ func Registry() []Command {
21 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},21 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
22 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},22 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
23 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},23 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24+ {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},
25+ {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},
24 {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() }},
25 {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() }},
26 {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() }},
M internal/usecase/command/registry_test.go +3 -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", "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", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -74,6 +74,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
7474 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
7575 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
7676 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
77+ {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
78+ {code: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().Return(nil).Once() }},
7779 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
7880 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
7981 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
@@ -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", "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", "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))
@@ -74,6 +74,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
74 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},74 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
75 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},75 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
76 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},76 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
77+ {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
78+ {code: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().Return(nil).Once() }},
77 {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() }},
78 {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() }},
79 {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() }},
M internal/usecase/daemon/client.go +1 -0
@@ -57,6 +57,7 @@ type attachedClient struct {
5757 theme themeui.Theme
5858 clientTheme themeui.Theme
5959 lastCursor cursorOut
60+ recentNav recentSessionNavigator
6061 linkMu sync.Mutex
6162 sendMu sync.Mutex
6263 }
@@ -57,6 +57,7 @@ type attachedClient struct {
57 theme themeui.Theme57 theme themeui.Theme
58 clientTheme themeui.Theme58 clientTheme themeui.Theme
59 lastCursor cursorOut59 lastCursor cursorOut
60+ recentNav recentSessionNavigator
60 linkMu sync.Mutex61 linkMu sync.Mutex
61 sendMu sync.Mutex62 sendMu sync.Mutex
62 }63 }
M internal/usecase/daemon/daemon.go +11 -5
@@ -140,10 +140,11 @@ type parkedAttachment struct {
140140 // session is a single multiplexed session. It owns one or more full-screen
141141
142142 type stoppedSession struct {
143- name string
144- cwd string
145- createdAt int64
146- purging bool
143+ name string
144+ cwd string
145+ createdAt int64
146+ lastUsedSeq uint64
147+ purging bool
147148 }
148149
149150 type Option func(*Daemon)
@@ -239,9 +240,14 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
239240 if records, err := d.persist.LoadAll(); err != nil {
240241 d.log.Warn("loading persisted sessions failed", "err", err)
241242 } else {
243+ var maxSeq uint64
242244 for _, r := range records {
243- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}
245+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
246+ if r.LastUsedSeq > maxSeq {
247+ maxSeq = r.LastUsedSeq
248+ }
244249 }
250+ d.mruSeq.Store(maxSeq)
245251 }
246252 return d
247253 }
@@ -140,10 +140,11 @@ type parkedAttachment struct {
140 // session is a single multiplexed session. It owns one or more full-screen140 // session is a single multiplexed session. It owns one or more full-screen
141 141
142 type stoppedSession struct {142 type stoppedSession struct {
143- name string143+ name string
144- cwd string144+ cwd string
145- createdAt int64145+ createdAt int64
146- purging bool146+ lastUsedSeq uint64
147+ purging bool
147 }148 }
148 149
149 type Option func(*Daemon)150 type Option func(*Daemon)
@@ -239,9 +240,14 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
239 if records, err := d.persist.LoadAll(); err != nil {240 if records, err := d.persist.LoadAll(); err != nil {
240 d.log.Warn("loading persisted sessions failed", "err", err)241 d.log.Warn("loading persisted sessions failed", "err", err)
241 } else {242 } else {
243+ var maxSeq uint64
242 for _, r := range records {244 for _, r := range records {
243- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}245+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
246+ if r.LastUsedSeq > maxSeq {
247+ maxSeq = r.LastUsedSeq
248+ }
244 }249 }
250+ d.mruSeq.Store(maxSeq)
245 }251 }
246 return d252 return d
247 }253 }
M internal/usecase/daemon/input_test.go +130 -0
@@ -236,6 +236,136 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
236236 }
237237 }
238238
239+func TestPaletteBackForwardSession(t *testing.T) {
240+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
241+ defer releaseAll(releases)
242+ recent := d.sessions[domain.SessionID("recent")]
243+ older := d.sessions[domain.SessionID("older")]
244+
245+ runPaletteCommand(t, d, current, ac, "BSK")
246+ require.Same(t, recent, ac.currentSession())
247+
248+ runPaletteCommand(t, d, recent, ac, "BSK")
249+ require.Same(t, older, ac.currentSession())
250+
251+ runPaletteCommand(t, d, older, ac, "FSK")
252+ require.Same(t, recent, ac.currentSession())
253+
254+ runPaletteCommand(t, d, recent, ac, "FSK")
255+ require.Same(t, current, ac.currentSession())
256+}
257+
258+func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
259+ t.Run("forward from current does not wrap", func(t *testing.T) {
260+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
261+ defer releaseAll(releases)
262+
263+ runPaletteCommand(t, d, current, ac, "FSK")
264+ require.Same(t, current, ac.currentSession())
265+ })
266+
267+ t.Run("back with no MRU does not move", func(t *testing.T) {
268+ p, release := newBlockingPTY(t)
269+ defer release()
270+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
271+
272+ runPaletteCommand(t, d, sess, ac, "BSK")
273+ require.Same(t, sess, ac.currentSession())
274+ })
275+
276+ t.Run("back at oldest does not wrap", func(t *testing.T) {
277+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
278+ defer releaseAll(releases)
279+ older := d.sessions[domain.SessionID("older")]
280+
281+ runPaletteCommand(t, d, current, ac, "BSK")
282+ runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
283+ require.Same(t, older, ac.currentSession())
284+
285+ runPaletteCommand(t, d, older, ac, "BSK")
286+ require.Same(t, older, ac.currentSession())
287+ })
288+}
289+
290+func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
291+ t.Run("forward skips a killed intermediate session", func(t *testing.T) {
292+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
293+ defer releaseAll(releases)
294+ recent := d.sessions[domain.SessionID("recent")]
295+ older := d.sessions[domain.SessionID("older")]
296+
297+ runPaletteCommand(t, d, current, ac, "BSK")
298+ runPaletteCommand(t, d, recent, ac, "BSK")
299+ require.Same(t, older, ac.currentSession())
300+
301+ d.mu.Lock()
302+ delete(d.sessions, recent.id)
303+ d.mu.Unlock()
304+
305+ runPaletteCommand(t, d, older, ac, "FSK")
306+ require.Same(t, current, ac.currentSession())
307+ })
308+
309+ t.Run("back from current refreshes newly recent sessions", func(t *testing.T) {
310+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
311+ defer releaseAll(releases)
312+ p, release := newBlockingPTY(t)
313+ defer release()
314+ newer := &session{id: "newer", name: "newer", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p, domain.Size{Cols: 80, Rows: 23})}}
315+ newer.mruAt.Store(25)
316+ d.mu.Lock()
317+ d.sessions[newer.id] = newer
318+ d.mu.Unlock()
319+
320+ runPaletteCommand(t, d, current, ac, "BSK")
321+ require.Same(t, newer, ac.currentSession())
322+ })
323+
324+ t.Run("failed switch does not advance navigator index", func(t *testing.T) {
325+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
326+ defer releaseAll(releases)
327+ current.mu.Lock()
328+ current.client = nil
329+ current.mu.Unlock()
330+
331+ runPaletteCommand(t, d, current, ac, "BSK")
332+
333+ require.Same(t, current, ac.currentSession())
334+ require.Zero(t, ac.recentNav.index)
335+ })
336+}
337+
338+func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
339+ t.Helper()
340+ p1, release1 := newBlockingPTY(t)
341+ p2, release2 := newBlockingPTY(t)
342+ p3, release3 := newBlockingPTY(t)
343+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
344+ current.id = "current"
345+ delete(d.sessions, domain.SessionID("manual"))
346+ d.sessions[current.id] = current
347+ recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
348+ older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
349+ d.sessions[recent.id] = recent
350+ d.sessions[older.id] = older
351+ current.mruAt.Store(30)
352+ recent.mruAt.Store(20)
353+ older.mruAt.Store(10)
354+ return d, current, ac, sends, []func(){release1, release2, release3}
355+}
356+
357+func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
358+ t.Helper()
359+ d.handleInput(sess, ac, []byte("\x1b "))
360+ d.handleInput(sess, ac, []byte(code+"\r"))
361+}
362+
363+func releaseAll(releases []func()) {
364+ for _, release := range releases {
365+ release()
366+ }
367+}
368+
239369 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
240370 writes := make(chan []byte, 1)
241371 p1, releasePTY1 := newBlockingPTY(t)
@@ -236,6 +236,136 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
236 }236 }
237 }237 }
238 238
239+func TestPaletteBackForwardSession(t *testing.T) {
240+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
241+ defer releaseAll(releases)
242+ recent := d.sessions[domain.SessionID("recent")]
243+ older := d.sessions[domain.SessionID("older")]
244+
245+ runPaletteCommand(t, d, current, ac, "BSK")
246+ require.Same(t, recent, ac.currentSession())
247+
248+ runPaletteCommand(t, d, recent, ac, "BSK")
249+ require.Same(t, older, ac.currentSession())
250+
251+ runPaletteCommand(t, d, older, ac, "FSK")
252+ require.Same(t, recent, ac.currentSession())
253+
254+ runPaletteCommand(t, d, recent, ac, "FSK")
255+ require.Same(t, current, ac.currentSession())
256+}
257+
258+func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
259+ t.Run("forward from current does not wrap", func(t *testing.T) {
260+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
261+ defer releaseAll(releases)
262+
263+ runPaletteCommand(t, d, current, ac, "FSK")
264+ require.Same(t, current, ac.currentSession())
265+ })
266+
267+ t.Run("back with no MRU does not move", func(t *testing.T) {
268+ p, release := newBlockingPTY(t)
269+ defer release()
270+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
271+
272+ runPaletteCommand(t, d, sess, ac, "BSK")
273+ require.Same(t, sess, ac.currentSession())
274+ })
275+
276+ t.Run("back at oldest does not wrap", func(t *testing.T) {
277+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
278+ defer releaseAll(releases)
279+ older := d.sessions[domain.SessionID("older")]
280+
281+ runPaletteCommand(t, d, current, ac, "BSK")
282+ runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
283+ require.Same(t, older, ac.currentSession())
284+
285+ runPaletteCommand(t, d, older, ac, "BSK")
286+ require.Same(t, older, ac.currentSession())
287+ })
288+}
289+
290+func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
291+ t.Run("forward skips a killed intermediate session", func(t *testing.T) {
292+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
293+ defer releaseAll(releases)
294+ recent := d.sessions[domain.SessionID("recent")]
295+ older := d.sessions[domain.SessionID("older")]
296+
297+ runPaletteCommand(t, d, current, ac, "BSK")
298+ runPaletteCommand(t, d, recent, ac, "BSK")
299+ require.Same(t, older, ac.currentSession())
300+
301+ d.mu.Lock()
302+ delete(d.sessions, recent.id)
303+ d.mu.Unlock()
304+
305+ runPaletteCommand(t, d, older, ac, "FSK")
306+ require.Same(t, current, ac.currentSession())
307+ })
308+
309+ t.Run("back from current refreshes newly recent sessions", func(t *testing.T) {
310+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
311+ defer releaseAll(releases)
312+ p, release := newBlockingPTY(t)
313+ defer release()
314+ newer := &session{id: "newer", name: "newer", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p, domain.Size{Cols: 80, Rows: 23})}}
315+ newer.mruAt.Store(25)
316+ d.mu.Lock()
317+ d.sessions[newer.id] = newer
318+ d.mu.Unlock()
319+
320+ runPaletteCommand(t, d, current, ac, "BSK")
321+ require.Same(t, newer, ac.currentSession())
322+ })
323+
324+ t.Run("failed switch does not advance navigator index", func(t *testing.T) {
325+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
326+ defer releaseAll(releases)
327+ current.mu.Lock()
328+ current.client = nil
329+ current.mu.Unlock()
330+
331+ runPaletteCommand(t, d, current, ac, "BSK")
332+
333+ require.Same(t, current, ac.currentSession())
334+ require.Zero(t, ac.recentNav.index)
335+ })
336+}
337+
338+func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
339+ t.Helper()
340+ p1, release1 := newBlockingPTY(t)
341+ p2, release2 := newBlockingPTY(t)
342+ p3, release3 := newBlockingPTY(t)
343+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
344+ current.id = "current"
345+ delete(d.sessions, domain.SessionID("manual"))
346+ d.sessions[current.id] = current
347+ recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
348+ older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
349+ d.sessions[recent.id] = recent
350+ d.sessions[older.id] = older
351+ current.mruAt.Store(30)
352+ recent.mruAt.Store(20)
353+ older.mruAt.Store(10)
354+ return d, current, ac, sends, []func(){release1, release2, release3}
355+}
356+
357+func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
358+ t.Helper()
359+ d.handleInput(sess, ac, []byte("\x1b "))
360+ d.handleInput(sess, ac, []byte(code+"\r"))
361+}
362+
363+func releaseAll(releases []func()) {
364+ for _, release := range releases {
365+ release()
366+ }
367+}
368+
239 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {369 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
240 writes := make(chan []byte, 1)370 writes := make(chan []byte, 1)
241 p1, releasePTY1 := newBlockingPTY(t)371 p1, releasePTY1 := newBlockingPTY(t)
M internal/usecase/daemon/palette.go +10 -0
@@ -218,6 +218,16 @@ func (e paletteExec) PrevTab() error {
218218 return nil
219219 }
220220
221+func (e paletteExec) BackSession() error {
222+ e.d.navigateRecentSession(e.sess, e.ac, 1)
223+ return nil
224+}
225+
226+func (e paletteExec) ForwardSession() error {
227+ e.d.navigateRecentSession(e.sess, e.ac, -1)
228+ return nil
229+}
230+
221231 func (e paletteExec) Detach() error {
222232 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
223233 return nil
@@ -218,6 +218,16 @@ func (e paletteExec) PrevTab() error {
218 return nil218 return nil
219 }219 }
220 220
221+func (e paletteExec) BackSession() error {
222+ e.d.navigateRecentSession(e.sess, e.ac, 1)
223+ return nil
224+}
225+
226+func (e paletteExec) ForwardSession() error {
227+ e.d.navigateRecentSession(e.sess, e.ac, -1)
228+ return nil
229+}
230+
221 func (e paletteExec) Detach() error {231 func (e paletteExec) Detach() error {
222 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)232 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
223 return nil233 return nil
M internal/usecase/daemon/palette_test.go +2 -2
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112112 for i, cmd := range commands {
113113 codes[i] = cmd.Code
114114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "VIS", "RNS", "DET"}, codes)
115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "DET"}, codes)
116116 }
117117
118118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
121121
122122 var wg sync.WaitGroup
123123 for range 50 {
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112 for i, cmd := range commands {112 for i, cmd := range commands {
113 codes[i] = cmd.Code113 codes[i] = cmd.Code
114 }114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "VIS", "RNS", "DET"}, codes)115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "DET"}, codes)
116 }116 }
117 117
118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119 d := &Daemon{}119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
121 121
122 var wg sync.WaitGroup122 var wg sync.WaitGroup
123 for range 50 {123 for range 50 {
A internal/usecase/daemon/recent_nav.go +95 -0
@@ -0,0 +1,95 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
7+
8+type recentSessionNavigator struct {
9+ ids []domain.SessionID
10+ index int
11+}
12+
13+func recentSessionTrail(cur *session, state barState) []domain.SessionID {
14+ if cur == nil {
15+ return nil
16+ }
17+ ids := make([]domain.SessionID, 0, 1+len(state.mru))
18+ ids = append(ids, cur.id)
19+ for _, entry := range state.mru {
20+ if entry.id != "" {
21+ ids = append(ids, entry.id)
22+ }
23+ }
24+ return ids
25+}
26+
27+func (n *recentSessionNavigator) reset(ids []domain.SessionID) {
28+ n.ids = append(n.ids[:0], ids...)
29+ n.index = 0
30+}
31+
32+func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta int) {
33+ if d == nil || sess == nil || ac == nil || delta == 0 {
34+ return
35+ }
36+ trail := recentSessionTrail(sess, d.barStateFor(sess, ""))
37+ if ac.recentNav.index == 0 && !sameSessionTrail(ac.recentNav.ids, trail) {
38+ ac.recentNav.reset(trail)
39+ }
40+ if !d.pruneRecentNav(&ac.recentNav) || ac.recentNav.ids[ac.recentNav.index] != sess.id {
41+ ac.recentNav.reset(trail)
42+ }
43+
44+ next := ac.recentNav.index + delta
45+ if next < 0 || next >= len(ac.recentNav.ids) {
46+ d.paint(sess, ac, true)
47+ return
48+ }
49+ targetID := ac.recentNav.ids[next]
50+ targetSess := d.sessionByID(targetID)
51+ if targetSess == nil {
52+ d.paint(sess, ac, true)
53+ return
54+ }
55+ d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
56+ if ac.currentSession() == targetSess {
57+ ac.recentNav.index = next
58+ }
59+}
60+
61+func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
62+ if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {
63+ return false
64+ }
65+ current := n.ids[n.index]
66+ kept := n.ids[:0]
67+ nextIndex := -1
68+ for _, id := range n.ids {
69+ if d.sessionByID(id) == nil {
70+ continue
71+ }
72+ if id == current {
73+ nextIndex = len(kept)
74+ }
75+ kept = append(kept, id)
76+ }
77+ n.ids = kept
78+ if nextIndex == -1 {
79+ return false
80+ }
81+ n.index = nextIndex
82+ return true
83+}
84+
85+func sameSessionTrail(a, b []domain.SessionID) bool {
86+ if len(a) != len(b) {
87+ return false
88+ }
89+ for i := range a {
90+ if a[i] != b[i] {
91+ return false
92+ }
93+ }
94+ return true
95+}
@@ -0,0 +1,95 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
7+
8+type recentSessionNavigator struct {
9+ ids []domain.SessionID
10+ index int
11+}
12+
13+func recentSessionTrail(cur *session, state barState) []domain.SessionID {
14+ if cur == nil {
15+ return nil
16+ }
17+ ids := make([]domain.SessionID, 0, 1+len(state.mru))
18+ ids = append(ids, cur.id)
19+ for _, entry := range state.mru {
20+ if entry.id != "" {
21+ ids = append(ids, entry.id)
22+ }
23+ }
24+ return ids
25+}
26+
27+func (n *recentSessionNavigator) reset(ids []domain.SessionID) {
28+ n.ids = append(n.ids[:0], ids...)
29+ n.index = 0
30+}
31+
32+func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta int) {
33+ if d == nil || sess == nil || ac == nil || delta == 0 {
34+ return
35+ }
36+ trail := recentSessionTrail(sess, d.barStateFor(sess, ""))
37+ if ac.recentNav.index == 0 && !sameSessionTrail(ac.recentNav.ids, trail) {
38+ ac.recentNav.reset(trail)
39+ }
40+ if !d.pruneRecentNav(&ac.recentNav) || ac.recentNav.ids[ac.recentNav.index] != sess.id {
41+ ac.recentNav.reset(trail)
42+ }
43+
44+ next := ac.recentNav.index + delta
45+ if next < 0 || next >= len(ac.recentNav.ids) {
46+ d.paint(sess, ac, true)
47+ return
48+ }
49+ targetID := ac.recentNav.ids[next]
50+ targetSess := d.sessionByID(targetID)
51+ if targetSess == nil {
52+ d.paint(sess, ac, true)
53+ return
54+ }
55+ d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
56+ if ac.currentSession() == targetSess {
57+ ac.recentNav.index = next
58+ }
59+}
60+
61+func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
62+ if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {
63+ return false
64+ }
65+ current := n.ids[n.index]
66+ kept := n.ids[:0]
67+ nextIndex := -1
68+ for _, id := range n.ids {
69+ if d.sessionByID(id) == nil {
70+ continue
71+ }
72+ if id == current {
73+ nextIndex = len(kept)
74+ }
75+ kept = append(kept, id)
76+ }
77+ n.ids = kept
78+ if nextIndex == -1 {
79+ return false
80+ }
81+ n.index = nextIndex
82+ return true
83+}
84+
85+func sameSessionTrail(a, b []domain.SessionID) bool {
86+ if len(a) != len(b) {
87+ return false
88+ }
89+ for i := range a {
90+ if a[i] != b[i] {
91+ return false
92+ }
93+ }
94+ return true
95+}
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 +73 -10
@@ -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) {
@@ -671,9 +705,7 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
671705 var wg sync.WaitGroup
672706 var observer sync.WaitGroup
673707
674- observer.Add(1)
675- go func() {
676- defer observer.Done()
708+ observer.Go(func() {
677709 previous := sess.mruAt.Load()
678710 for {
679711 select {
@@ -688,17 +720,15 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
688720 }
689721 previous = current
690722 }
691- }()
723+ })
692724
693725 for range goroutines {
694- wg.Add(1)
695- go func() {
696- defer wg.Done()
726+ wg.Go(func() {
697727 <-start
698728 for range iterations {
699729 d.touchMRU(sess)
700730 }
701- }()
731+ })
702732 }
703733 close(start)
704734 wg.Wait()
@@ -709,6 +739,39 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
709739 require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())
710740 }
711741
742+func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
743+ store, state := newMockStore(t)
744+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
745+ WithStore(store)(d)
746+ named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1}
747+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
748+
749+ d.touchMRU(named)
750+ require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
751+ setsAfterNamed := state.sets
752+
753+ ephemeral := &session{name: "0", ephemeral: true, tabs: []*tab{{}}}
754+ d.touchMRU(ephemeral)
755+ require.False(t, state.has("0"))
756+ require.Equal(t, setsAfterNamed, state.sets)
757+}
758+
759+func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
760+ sz := domain.Size{Cols: 80, Rows: 24}
761+ p, release := newBlockingPTY(t)
762+ defer release()
763+ store, state := newMockStore(t)
764+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
765+ WithStore(store)(d)
766+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
767+ d.mruSeq.Store(42)
768+
769+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
770+ require.NoError(t, err)
771+ require.Equal(t, uint64(42), sess.mruAt.Load())
772+ require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
773+}
774+
712775 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
713776 sz := domain.Size{Cols: 80, Rows: 24}
714777 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) {
@@ -671,9 +705,7 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
671 var wg sync.WaitGroup705 var wg sync.WaitGroup
672 var observer sync.WaitGroup706 var observer sync.WaitGroup
673 707
674- observer.Add(1)708+ observer.Go(func() {
675- go func() {
676- defer observer.Done()
677 previous := sess.mruAt.Load()709 previous := sess.mruAt.Load()
678 for {710 for {
679 select {711 select {
@@ -688,17 +720,15 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
688 }720 }
689 previous = current721 previous = current
690 }722 }
691- }()723+ })
692 724
693 for range goroutines {725 for range goroutines {
694- wg.Add(1)726+ wg.Go(func() {
695- go func() {
696- defer wg.Done()
697 <-start727 <-start
698 for range iterations {728 for range iterations {
699 d.touchMRU(sess)729 d.touchMRU(sess)
700 }730 }
701- }()731+ })
702 }732 }
703 close(start)733 close(start)
704 wg.Wait()734 wg.Wait()
@@ -709,6 +739,39 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
709 require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())739 require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())
710 }740 }
711 741
742+func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
743+ store, state := newMockStore(t)
744+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
745+ WithStore(store)(d)
746+ named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1}
747+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
748+
749+ d.touchMRU(named)
750+ require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
751+ setsAfterNamed := state.sets
752+
753+ ephemeral := &session{name: "0", ephemeral: true, tabs: []*tab{{}}}
754+ d.touchMRU(ephemeral)
755+ require.False(t, state.has("0"))
756+ require.Equal(t, setsAfterNamed, state.sets)
757+}
758+
759+func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
760+ sz := domain.Size{Cols: 80, Rows: 24}
761+ p, release := newBlockingPTY(t)
762+ defer release()
763+ store, state := newMockStore(t)
764+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
765+ WithStore(store)(d)
766+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
767+ d.mruSeq.Store(42)
768+
769+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
770+ require.NoError(t, err)
771+ require.Equal(t, uint64(42), sess.mruAt.Load())
772+ require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
773+}
774+
712 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {775 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
713 sz := domain.Size{Cols: 80, Rows: 24}776 sz := domain.Size{Cols: 80, Rows: 24}
714 p, release := newBlockingPTY(t)777 p, release := newBlockingPTY(t)
M internal/usecase/daemon/status.go +44 -6
@@ -29,6 +29,7 @@ import (
2929 "strconv"
3030 "time"
3131
32+ "github.com/bnema/vev/internal/domain"
3233 themeui "github.com/bnema/vev/internal/usecase/theme"
3334 "github.com/bnema/vev/pkg/renderer"
3435 )
@@ -77,7 +78,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s
7778 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
7879 clearStatusRow(row)
7980 x := 0
80- writeStatusText(row, &x, " "+state.status.session+" ", styles.statusBar)
81+ writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
8182
8283 fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback)
8384 for i, sess := range fittedMRU {
@@ -91,6 +92,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
9192 writeStatusText(row, &x, " ", style)
9293 writeBell(row, &x, state.attentionFrame)
9394 }
95+ writeStatusText(row, &x, " ", style)
9496 }
9597 drawRightPlainText(row, state.copyFeedback, x, styles.statusBar)
9698 }
@@ -121,6 +123,7 @@ type barState struct {
121123 }
122124
123125 type mruSession struct {
126+ id domain.SessionID
124127 name string
125128 ephemeral bool
126129 attention bool
@@ -173,7 +176,7 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
173176 }
174177 at := sess.mruAt.Load()
175178 sess.mu.Lock()
176- entry := mruSession{name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
179+ entry := mruSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
177180 for _, tb := range sess.tabs {
178181 if tb.attention {
179182 entry.attention = true
@@ -204,12 +207,12 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
204207 if feedback != "" {
205208 copyReserve = len([]rune(feedback)) + 2
206209 }
207- budget := rowLen - leftUsed - copyReserve
208- if budget <= 0 || len(entries) == 0 {
210+ physicalBudget := rowLen - leftUsed - copyReserve
211+ if physicalBudget <= 0 || len(entries) == 0 {
209212 return nil
210213 }
211214 cost := func(e mruSession) int {
212- n := 1 + len([]rune(e.name))
215+ n := 2 + len([]rune(e.name))
213216 if e.ephemeral {
214217 n++
215218 }
@@ -218,6 +221,19 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
218221 }
219222 return n
220223 }
224+
225+ budget := physicalBudget
226+ if feedback == "" {
227+ budget -= mruFutureRightReserve(rowLen)
228+ // Keep at least one recent session when it physically fits; the reserved
229+ // right side is only a budget preference, not a reason to hide all recents.
230+ if firstCost := cost(entries[0]); firstCost <= physicalBudget && budget < firstCost {
231+ budget = firstCost
232+ }
233+ }
234+ if budget <= 0 {
235+ return nil
236+ }
221237 used := 0
222238 for i, e := range entries {
223239 used += cost(e)
@@ -228,12 +244,34 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
228244 return entries
229245 }
230246
247+const (
248+ mruReserveMinRow = 40
249+ mruReserveDivisor = 4
250+ mruReserveMin = 12
251+ mruReserveMax = 24
252+)
253+
254+func mruFutureRightReserve(rowLen int) int {
255+ if rowLen < mruReserveMinRow {
256+ return 0
257+ }
258+ reserve := rowLen / mruReserveDivisor
259+ if reserve < mruReserveMin {
260+ return mruReserveMin
261+ }
262+ if reserve > mruReserveMax {
263+ return mruReserveMax
264+ }
265+ return reserve
266+}
267+
231268 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
232- if count <= 1 || !base.HasForegroundRGB {
269+ if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {
233270 return base
234271 }
235272 amount := (float64(i) / float64(count-1)) * 0.6
236273 base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)
274+ base.BackgroundRGB = themeui.Blend(base.BackgroundRGB, t.Background, amount)
237275 return base
238276 }
239277
@@ -29,6 +29,7 @@ import (
29 "strconv"29 "strconv"
30 "time"30 "time"
31 31
32+ "github.com/bnema/vev/internal/domain"
32 themeui "github.com/bnema/vev/internal/usecase/theme"33 themeui "github.com/bnema/vev/internal/usecase/theme"
33 "github.com/bnema/vev/pkg/renderer"34 "github.com/bnema/vev/pkg/renderer"
34 )35 )
@@ -77,7 +78,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s
77 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {78 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
78 clearStatusRow(row)79 clearStatusRow(row)
79 x := 080 x := 0
80- writeStatusText(row, &x, " "+state.status.session+" ", styles.statusBar)81+ writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
81 82
82 fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback)83 fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback)
83 for i, sess := range fittedMRU {84 for i, sess := range fittedMRU {
@@ -91,6 +92,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
91 writeStatusText(row, &x, " ", style)92 writeStatusText(row, &x, " ", style)
92 writeBell(row, &x, state.attentionFrame)93 writeBell(row, &x, state.attentionFrame)
93 }94 }
95+ writeStatusText(row, &x, " ", style)
94 }96 }
95 drawRightPlainText(row, state.copyFeedback, x, styles.statusBar)97 drawRightPlainText(row, state.copyFeedback, x, styles.statusBar)
96 }98 }
@@ -121,6 +123,7 @@ type barState struct {
121 }123 }
122 124
123 type mruSession struct {125 type mruSession struct {
126+ id domain.SessionID
124 name string127 name string
125 ephemeral bool128 ephemeral bool
126 attention bool129 attention bool
@@ -173,7 +176,7 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
173 }176 }
174 at := sess.mruAt.Load()177 at := sess.mruAt.Load()
175 sess.mu.Lock()178 sess.mu.Lock()
176- entry := mruSession{name: sess.name, ephemeral: sess.ephemeral, mruAt: at}179+ entry := mruSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
177 for _, tb := range sess.tabs {180 for _, tb := range sess.tabs {
178 if tb.attention {181 if tb.attention {
179 entry.attention = true182 entry.attention = true
@@ -204,12 +207,12 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
204 if feedback != "" {207 if feedback != "" {
205 copyReserve = len([]rune(feedback)) + 2208 copyReserve = len([]rune(feedback)) + 2
206 }209 }
207- budget := rowLen - leftUsed - copyReserve210+ physicalBudget := rowLen - leftUsed - copyReserve
208- if budget <= 0 || len(entries) == 0 {211+ if physicalBudget <= 0 || len(entries) == 0 {
209 return nil212 return nil
210 }213 }
211 cost := func(e mruSession) int {214 cost := func(e mruSession) int {
212- n := 1 + len([]rune(e.name))215+ n := 2 + len([]rune(e.name))
213 if e.ephemeral {216 if e.ephemeral {
214 n++217 n++
215 }218 }
@@ -218,6 +221,19 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
218 }221 }
219 return n222 return n
220 }223 }
224+
225+ budget := physicalBudget
226+ if feedback == "" {
227+ budget -= mruFutureRightReserve(rowLen)
228+ // Keep at least one recent session when it physically fits; the reserved
229+ // right side is only a budget preference, not a reason to hide all recents.
230+ if firstCost := cost(entries[0]); firstCost <= physicalBudget && budget < firstCost {
231+ budget = firstCost
232+ }
233+ }
234+ if budget <= 0 {
235+ return nil
236+ }
221 used := 0237 used := 0
222 for i, e := range entries {238 for i, e := range entries {
223 used += cost(e)239 used += cost(e)
@@ -228,12 +244,34 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
228 return entries244 return entries
229 }245 }
230 246
247+const (
248+ mruReserveMinRow = 40
249+ mruReserveDivisor = 4
250+ mruReserveMin = 12
251+ mruReserveMax = 24
252+)
253+
254+func mruFutureRightReserve(rowLen int) int {
255+ if rowLen < mruReserveMinRow {
256+ return 0
257+ }
258+ reserve := rowLen / mruReserveDivisor
259+ if reserve < mruReserveMin {
260+ return mruReserveMin
261+ }
262+ if reserve > mruReserveMax {
263+ return mruReserveMax
264+ }
265+ return reserve
266+}
267+
231 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {268 func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
232- if count <= 1 || !base.HasForegroundRGB {269+ if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG {
233 return base270 return base
234 }271 }
235 amount := (float64(i) / float64(count-1)) * 0.6272 amount := (float64(i) / float64(count-1)) * 0.6
236 base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)273 base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)
274+ base.BackgroundRGB = themeui.Blend(base.BackgroundRGB, t.Background, amount)
237 return base275 return base
238 }276 }
239 277
M internal/usecase/daemon/status_test.go +102 -9
@@ -466,7 +466,7 @@ func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T)
466466 defer releasePTY()
467467 sess.name = "current"
468468 sess.mruAt.Store(100)
469- for i := 0; i < 10; i++ {
469+ for i := range 10 {
470470 other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}}
471471 other.mruAt.Store(uint64(i + 1))
472472 d.sessions[other.id] = other
@@ -497,6 +497,29 @@ func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
497497 require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
498498 }
499499
500+func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
501+ sz := domain.Size{Cols: 80, Rows: 24}
502+ p1, releasePTY1 := newBlockingPTY(t)
503+ defer releasePTY1()
504+ p2, releasePTY2 := newBlockingPTY(t)
505+ defer releasePTY2()
506+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
507+ d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
508+ d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
509+ d.mruSeq.Store(30)
510+ cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
511+ d.sessions[cur.id] = cur
512+
513+ _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz)
514+ require.NoError(t, err)
515+ _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz)
516+ require.NoError(t, err)
517+
518+ state := d.barStateFor(cur, "")
519+ require.GreaterOrEqual(t, len(state.mru), 2)
520+ require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
521+}
522+
500523 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
501524 state := barState{
502525 status: statusSnapshot{session: "cur"},
@@ -510,7 +533,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
510533
511534 drawStatusBarState(row, state, resolveThemeStyles(nil))
512535
513- require.Equal(t, " cur fresh tmp*  ", rowText(row))
536+ require.Equal(t, " cur fresh tmp*  ", rowText(row))
514537 for _, c := range row {
515538 if c.Rune == attentionGlyph {
516539 require.True(t, c.Style.Bold)
@@ -520,6 +543,19 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
520543 t.Fatalf("inline bell not rendered: %q", rowText(row))
521544 }
522545
546+func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
547+ theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
548+ styles := newThemeStyles(theme)
549+ row := make([]renderer.Cell, 16)
550+
551+ drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles)
552+
553+ for _, idx := range []int{0, 1, 2, 3, 4} {
554+ require.True(t, row[idx].Style.Equal(styles.accent), "cell %d should use accent style", idx)
555+ }
556+ require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB)
557+}
558+
523559 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
524560 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
525561 styles := newThemeStyles(theme)
@@ -528,20 +564,28 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
528564
529565 drawStatusBarState(row, state, styles)
530566
531- first := row[3].Style.ForegroundRGB.R
532- second := row[5].Style.ForegroundRGB.R
533- third := row[7].Style.ForegroundRGB.R
534- require.Equal(t, styles.statusBar.ForegroundRGB.R, first)
535- require.Greater(t, first, second)
536- require.Greater(t, second, third)
567+ firstFG := row[4].Style.ForegroundRGB.R
568+ secondFG := row[7].Style.ForegroundRGB.R
569+ thirdFG := row[10].Style.ForegroundRGB.R
570+ require.Equal(t, styles.statusBar.ForegroundRGB.R, firstFG)
571+ require.Greater(t, firstFG, secondFG)
572+ require.Greater(t, secondFG, thirdFG)
573+
574+ firstBG := row[4].Style.BackgroundRGB.R
575+ secondBG := row[7].Style.BackgroundRGB.R
576+ thirdBG := row[10].Style.BackgroundRGB.R
577+ require.Equal(t, styles.statusBar.BackgroundRGB.R, firstBG)
578+ require.Greater(t, firstBG, secondBG)
579+ require.Greater(t, secondBG, thirdBG)
537580
538581 plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3)
539582 require.False(t, plain.HasForegroundRGB)
583+ require.False(t, plain.HasBackgroundRGB)
540584 }
541585
542586 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
543587 state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
544- row := make([]renderer.Cell, 19)
588+ row := make([]renderer.Cell, 21)
545589
546590 drawStatusBarState(row, state, resolveThemeStyles(nil))
547591
@@ -551,6 +595,55 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
551595 require.NotContains(t, text, "old")
552596 }
553597
598+func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
599+ mru := make([]mruSession, 0, maxMRUSessions)
600+ for i := 1; i <= maxMRUSessions; i++ {
601+ mru = append(mru, mruSession{name: "recent" + strconv.Itoa(i)})
602+ }
603+ state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
604+
605+ tests := []struct {
606+ name string
607+ cols int
608+ wantShown []string
609+ wantHidden []string
610+ }{
611+ {
612+ name: "narrow keeps first recent when it physically fits",
613+ cols: 15,
614+ wantShown: []string{"cur", "recent1"},
615+ wantHidden: []string{"recent2"},
616+ },
617+ {
618+ name: "medium reserves right-side room instead of filling all recents",
619+ cols: 80,
620+ wantShown: []string{"recent1", "recent6"},
621+ wantHidden: []string{"recent7", "recent9"},
622+ },
623+ {
624+ name: "wide shows more recents while still keeping a right-side reserve",
625+ cols: 120,
626+ wantShown: []string{"recent1", "recent9"},
627+ wantHidden: nil,
628+ },
629+ }
630+ for _, tt := range tests {
631+ t.Run(tt.name, func(t *testing.T) {
632+ row := make([]renderer.Cell, tt.cols)
633+
634+ drawStatusBarState(row, state, resolveThemeStyles(nil))
635+
636+ text := rowText(row)
637+ for _, want := range tt.wantShown {
638+ require.Contains(t, text, want)
639+ }
640+ for _, hidden := range tt.wantHidden {
641+ require.NotContains(t, text, hidden)
642+ }
643+ })
644+ }
645+}
646+
554647 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
555648 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
556649 row := make([]renderer.Cell, 20)
@@ -466,7 +466,7 @@ func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T)
466 defer releasePTY()466 defer releasePTY()
467 sess.name = "current"467 sess.name = "current"
468 sess.mruAt.Store(100)468 sess.mruAt.Store(100)
469- for i := 0; i < 10; i++ {469+ for i := range 10 {
470 other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}}470 other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}}
471 other.mruAt.Store(uint64(i + 1))471 other.mruAt.Store(uint64(i + 1))
472 d.sessions[other.id] = other472 d.sessions[other.id] = other
@@ -497,6 +497,29 @@ func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
497 require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})497 require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
498 }498 }
499 499
500+func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
501+ sz := domain.Size{Cols: 80, Rows: 24}
502+ p1, releasePTY1 := newBlockingPTY(t)
503+ defer releasePTY1()
504+ p2, releasePTY2 := newBlockingPTY(t)
505+ defer releasePTY2()
506+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
507+ d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
508+ d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
509+ d.mruSeq.Store(30)
510+ cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
511+ d.sessions[cur.id] = cur
512+
513+ _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz)
514+ require.NoError(t, err)
515+ _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz)
516+ require.NoError(t, err)
517+
518+ state := d.barStateFor(cur, "")
519+ require.GreaterOrEqual(t, len(state.mru), 2)
520+ require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
521+}
522+
500 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {523 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
501 state := barState{524 state := barState{
502 status: statusSnapshot{session: "cur"},525 status: statusSnapshot{session: "cur"},
@@ -510,7 +533,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
510 533
511 drawStatusBarState(row, state, resolveThemeStyles(nil))534 drawStatusBarState(row, state, resolveThemeStyles(nil))
512 535
513- require.Equal(t, " cur fresh tmp*  ", rowText(row))536+ require.Equal(t, " cur fresh tmp*  ", rowText(row))
514 for _, c := range row {537 for _, c := range row {
515 if c.Rune == attentionGlyph {538 if c.Rune == attentionGlyph {
516 require.True(t, c.Style.Bold)539 require.True(t, c.Style.Bold)
@@ -520,6 +543,19 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
520 t.Fatalf("inline bell not rendered: %q", rowText(row))543 t.Fatalf("inline bell not rendered: %q", rowText(row))
521 }544 }
522 545
546+func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
547+ theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
548+ styles := newThemeStyles(theme)
549+ row := make([]renderer.Cell, 16)
550+
551+ drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles)
552+
553+ for _, idx := range []int{0, 1, 2, 3, 4} {
554+ require.True(t, row[idx].Style.Equal(styles.accent), "cell %d should use accent style", idx)
555+ }
556+ require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB)
557+}
558+
523 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {559 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
524 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}560 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
525 styles := newThemeStyles(theme)561 styles := newThemeStyles(theme)
@@ -528,20 +564,28 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
528 564
529 drawStatusBarState(row, state, styles)565 drawStatusBarState(row, state, styles)
530 566
531- first := row[3].Style.ForegroundRGB.R567+ firstFG := row[4].Style.ForegroundRGB.R
532- second := row[5].Style.ForegroundRGB.R568+ secondFG := row[7].Style.ForegroundRGB.R
533- third := row[7].Style.ForegroundRGB.R569+ thirdFG := row[10].Style.ForegroundRGB.R
534- require.Equal(t, styles.statusBar.ForegroundRGB.R, first)570+ require.Equal(t, styles.statusBar.ForegroundRGB.R, firstFG)
535- require.Greater(t, first, second)571+ require.Greater(t, firstFG, secondFG)
536- require.Greater(t, second, third)572+ require.Greater(t, secondFG, thirdFG)
573+
574+ firstBG := row[4].Style.BackgroundRGB.R
575+ secondBG := row[7].Style.BackgroundRGB.R
576+ thirdBG := row[10].Style.BackgroundRGB.R
577+ require.Equal(t, styles.statusBar.BackgroundRGB.R, firstBG)
578+ require.Greater(t, firstBG, secondBG)
579+ require.Greater(t, secondBG, thirdBG)
537 580
538 plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3)581 plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3)
539 require.False(t, plain.HasForegroundRGB)582 require.False(t, plain.HasForegroundRGB)
583+ require.False(t, plain.HasBackgroundRGB)
540 }584 }
541 585
542 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {586 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
543 state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}587 state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
544- row := make([]renderer.Cell, 19)588+ row := make([]renderer.Cell, 21)
545 589
546 drawStatusBarState(row, state, resolveThemeStyles(nil))590 drawStatusBarState(row, state, resolveThemeStyles(nil))
547 591
@@ -551,6 +595,55 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
551 require.NotContains(t, text, "old")595 require.NotContains(t, text, "old")
552 }596 }
553 597
598+func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
599+ mru := make([]mruSession, 0, maxMRUSessions)
600+ for i := 1; i <= maxMRUSessions; i++ {
601+ mru = append(mru, mruSession{name: "recent" + strconv.Itoa(i)})
602+ }
603+ state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
604+
605+ tests := []struct {
606+ name string
607+ cols int
608+ wantShown []string
609+ wantHidden []string
610+ }{
611+ {
612+ name: "narrow keeps first recent when it physically fits",
613+ cols: 15,
614+ wantShown: []string{"cur", "recent1"},
615+ wantHidden: []string{"recent2"},
616+ },
617+ {
618+ name: "medium reserves right-side room instead of filling all recents",
619+ cols: 80,
620+ wantShown: []string{"recent1", "recent6"},
621+ wantHidden: []string{"recent7", "recent9"},
622+ },
623+ {
624+ name: "wide shows more recents while still keeping a right-side reserve",
625+ cols: 120,
626+ wantShown: []string{"recent1", "recent9"},
627+ wantHidden: nil,
628+ },
629+ }
630+ for _, tt := range tests {
631+ t.Run(tt.name, func(t *testing.T) {
632+ row := make([]renderer.Cell, tt.cols)
633+
634+ drawStatusBarState(row, state, resolveThemeStyles(nil))
635+
636+ text := rowText(row)
637+ for _, want := range tt.wantShown {
638+ require.Contains(t, text, want)
639+ }
640+ for _, hidden := range tt.wantHidden {
641+ require.NotContains(t, text, hidden)
642+ }
643+ })
644+ }
645+}
646+
554 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {647 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
555 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}648 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
556 row := make([]renderer.Cell, 20)649 row := make([]renderer.Cell, 20)

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