detached a20dc2b

Merge pull request #214 from bnema/fix/remote-quick-switch-listing

fix(remote): retain catalog sessions across cache upgrade
commit
a20dc2bad622080c5f694bdcc12b5c80ef8df0bf
author
bnema · · 2026-08-26 04:25 UTC
committer
GitHub · · 2026-08-26 04:25 UTC
parent
45e8d41
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +144 -16
M internal/adapters/remote/catalog_cache.go +103 -15
@@ -16,8 +16,9 @@ import (
1616 )
1717
1818 const (
19- catalogCacheFileName = "remote-catalog-cache.json"
20- catalogCacheFileVersion = 3
19+ catalogCacheFileName = "remote-catalog-cache.json"
20+ catalogCacheExactFileVersion = 2
21+ catalogCacheFileVersion = 3
2122 )
2223
2324 // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
@@ -28,6 +29,10 @@ type catalogCacheFile struct {
2829 Hosts *[]catalogCacheHost `json:"hosts"`
2930 }
3031
32+type catalogCacheVersion struct {
33+ Version *int `json:"version"`
34+}
35+
3136 type catalogCacheHost struct {
3237 Target *string `json:"target"`
3338 FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
@@ -45,6 +50,29 @@ type catalogCacheSession struct {
4550 Attached *bool `json:"attached"`
4651 }
4752
53+type catalogCacheFileV2 struct {
54+ Version *int `json:"version"`
55+ Hosts *[]catalogCacheHostV2 `json:"hosts"`
56+}
57+
58+type catalogCacheHostV2 struct {
59+ Target *string `json:"target"`
60+ FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
61+ Sessions *[]catalogCacheSessionV2 `json:"sessions"`
62+}
63+
64+type catalogCacheSessionV2 struct {
65+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
66+ Name *string `json:"name"`
67+ State *ports.RemoteCatalogSessionState `json:"state"`
68+ Ephemeral *bool `json:"ephemeral"`
69+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
70+ Tabs *uint16 `json:"tabs,omitempty"`
71+ TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"`
72+ ActiveTabID *string `json:"active_tab_id,omitempty"`
73+ Attached *bool `json:"attached"`
74+}
75+
4876 type fileCatalogCache struct {
4977 path string
5078 }
@@ -56,8 +84,9 @@ func NewFileCatalogCache(path string) ports.RemoteCatalogCache {
5684
5785 var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)
5886
59-// Load reads and validates one complete cache snapshot. A missing cache is an
60-// empty snapshot; malformed cache data is never changed by Load.
87+// Load reads and validates one complete cache snapshot. Exact v2 tab-list
88+// snapshots are upgraded in memory; ambiguous count-only v2 data is rejected.
89+// A missing cache is an empty snapshot, and Load never changes cache data.
6190 func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
6291 raw, err := os.ReadFile(c.path)
6392 if errors.Is(err, os.ErrNotExist) {
@@ -70,20 +99,32 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
7099 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
71100 }
72101
73- dec := json.NewDecoder(bytes.NewReader(raw))
74- dec.DisallowUnknownFields()
75- var file catalogCacheFile
76- if err := dec.Decode(&file); err != nil {
77- return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
78- }
79- if err := rejectTrailingJSON(dec); err != nil {
80- return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
102+ var version catalogCacheVersion
103+ if err := decodeCatalogCacheFile(raw, false, &version); err != nil {
104+ return nil, err
81105 }
82- if file.Version == nil {
106+ if version.Version == nil {
83107 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
84108 }
85- if *file.Version != catalogCacheFileVersion {
86- return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version)
109+
110+ var file catalogCacheFile
111+ switch *version.Version {
112+ case catalogCacheFileVersion:
113+ if err := decodeCatalogCacheFile(raw, true, &file); err != nil {
114+ return nil, err
115+ }
116+ case catalogCacheExactFileVersion:
117+ var legacy catalogCacheFileV2
118+ if err := decodeCatalogCacheFile(raw, true, &legacy); err != nil {
119+ return nil, err
120+ }
121+ migrated, err := migrateExactCatalogCacheV2(legacy)
122+ if err != nil {
123+ return nil, err
124+ }
125+ file = migrated
126+ default:
127+ return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version)
87128 }
88129 if file.Hosts == nil {
89130 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
@@ -139,6 +180,53 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
139180 return normalized, nil
140181 }
141182
183+func decodeCatalogCacheFile(raw []byte, strict bool, dst any) error {
184+ dec := json.NewDecoder(bytes.NewReader(raw))
185+ if strict {
186+ dec.DisallowUnknownFields()
187+ }
188+ if err := dec.Decode(dst); err != nil {
189+ return fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
190+ }
191+ if err := rejectTrailingJSON(dec); err != nil {
192+ return fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
193+ }
194+ return nil
195+}
196+
197+func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) {
198+ if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil {
199+ return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file")
200+ }
201+ hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts))
202+ for _, host := range *legacy.Hosts {
203+ if host.Sessions == nil {
204+ return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
205+ }
206+ sessions := make([]catalogCacheSession, 0, len(*host.Sessions))
207+ for _, session := range *host.Sessions {
208+ if session.Tabs != nil || session.TabList == nil {
209+ return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count")
210+ }
211+ sessions = append(sessions, catalogCacheSession{
212+ LifecycleID: session.LifecycleID,
213+ Name: session.Name,
214+ State: session.State,
215+ Ephemeral: session.Ephemeral,
216+ LastUsedSeq: session.LastUsedSeq,
217+ Tabs: session.TabList,
218+ ActiveTabID: session.ActiveTabID,
219+ Attached: session.Attached,
220+ })
221+ }
222+ hosts = append(hosts, catalogCacheHost{
223+ Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions,
224+ })
225+ }
226+ version := catalogCacheFileVersion
227+ return catalogCacheFile{Version: &version, Hosts: &hosts}, nil
228+}
229+
142230 // Store atomically replaces the cache with a complete normalized snapshot.
143231 func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
144232 normalized, err := normalizeCatalogCacheEntries(entries)
@@ -16,8 +16,9 @@ import (
16 )16 )
17 17
18 const (18 const (
19- catalogCacheFileName = "remote-catalog-cache.json"19+ catalogCacheFileName = "remote-catalog-cache.json"
20- catalogCacheFileVersion = 320+ catalogCacheExactFileVersion = 2
21+ catalogCacheFileVersion = 3
21 )22 )
22 23
23 // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.24 // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
@@ -28,6 +29,10 @@ type catalogCacheFile struct {
28 Hosts *[]catalogCacheHost `json:"hosts"`29 Hosts *[]catalogCacheHost `json:"hosts"`
29 }30 }
30 31
32+type catalogCacheVersion struct {
33+ Version *int `json:"version"`
34+}
35+
31 type catalogCacheHost struct {36 type catalogCacheHost struct {
32 Target *string `json:"target"`37 Target *string `json:"target"`
33 FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`38 FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
@@ -45,6 +50,29 @@ type catalogCacheSession struct {
45 Attached *bool `json:"attached"`50 Attached *bool `json:"attached"`
46 }51 }
47 52
53+type catalogCacheFileV2 struct {
54+ Version *int `json:"version"`
55+ Hosts *[]catalogCacheHostV2 `json:"hosts"`
56+}
57+
58+type catalogCacheHostV2 struct {
59+ Target *string `json:"target"`
60+ FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
61+ Sessions *[]catalogCacheSessionV2 `json:"sessions"`
62+}
63+
64+type catalogCacheSessionV2 struct {
65+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
66+ Name *string `json:"name"`
67+ State *ports.RemoteCatalogSessionState `json:"state"`
68+ Ephemeral *bool `json:"ephemeral"`
69+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
70+ Tabs *uint16 `json:"tabs,omitempty"`
71+ TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"`
72+ ActiveTabID *string `json:"active_tab_id,omitempty"`
73+ Attached *bool `json:"attached"`
74+}
75+
48 type fileCatalogCache struct {76 type fileCatalogCache struct {
49 path string77 path string
50 }78 }
@@ -56,8 +84,9 @@ func NewFileCatalogCache(path string) ports.RemoteCatalogCache {
56 84
57 var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)85 var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)
58 86
59-// Load reads and validates one complete cache snapshot. A missing cache is an87+// Load reads and validates one complete cache snapshot. Exact v2 tab-list
60-// empty snapshot; malformed cache data is never changed by Load.88+// snapshots are upgraded in memory; ambiguous count-only v2 data is rejected.
89+// A missing cache is an empty snapshot, and Load never changes cache data.
61 func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {90 func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
62 raw, err := os.ReadFile(c.path)91 raw, err := os.ReadFile(c.path)
63 if errors.Is(err, os.ErrNotExist) {92 if errors.Is(err, os.ErrNotExist) {
@@ -70,20 +99,32 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
70 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")99 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
71 }100 }
72 101
73- dec := json.NewDecoder(bytes.NewReader(raw))102+ var version catalogCacheVersion
74- dec.DisallowUnknownFields()103+ if err := decodeCatalogCacheFile(raw, false, &version); err != nil {
75- var file catalogCacheFile104+ return nil, err
76- if err := dec.Decode(&file); err != nil {
77- return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
78- }
79- if err := rejectTrailingJSON(dec); err != nil {
80- return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
81 }105 }
82- if file.Version == nil {106+ if version.Version == nil {
83 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")107 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
84 }108 }
85- if *file.Version != catalogCacheFileVersion {109+
86- return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version)110+ var file catalogCacheFile
111+ switch *version.Version {
112+ case catalogCacheFileVersion:
113+ if err := decodeCatalogCacheFile(raw, true, &file); err != nil {
114+ return nil, err
115+ }
116+ case catalogCacheExactFileVersion:
117+ var legacy catalogCacheFileV2
118+ if err := decodeCatalogCacheFile(raw, true, &legacy); err != nil {
119+ return nil, err
120+ }
121+ migrated, err := migrateExactCatalogCacheV2(legacy)
122+ if err != nil {
123+ return nil, err
124+ }
125+ file = migrated
126+ default:
127+ return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version)
87 }128 }
88 if file.Hosts == nil {129 if file.Hosts == nil {
89 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")130 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
@@ -139,6 +180,53 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
139 return normalized, nil180 return normalized, nil
140 }181 }
141 182
183+func decodeCatalogCacheFile(raw []byte, strict bool, dst any) error {
184+ dec := json.NewDecoder(bytes.NewReader(raw))
185+ if strict {
186+ dec.DisallowUnknownFields()
187+ }
188+ if err := dec.Decode(dst); err != nil {
189+ return fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
190+ }
191+ if err := rejectTrailingJSON(dec); err != nil {
192+ return fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
193+ }
194+ return nil
195+}
196+
197+func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) {
198+ if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil {
199+ return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file")
200+ }
201+ hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts))
202+ for _, host := range *legacy.Hosts {
203+ if host.Sessions == nil {
204+ return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
205+ }
206+ sessions := make([]catalogCacheSession, 0, len(*host.Sessions))
207+ for _, session := range *host.Sessions {
208+ if session.Tabs != nil || session.TabList == nil {
209+ return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count")
210+ }
211+ sessions = append(sessions, catalogCacheSession{
212+ LifecycleID: session.LifecycleID,
213+ Name: session.Name,
214+ State: session.State,
215+ Ephemeral: session.Ephemeral,
216+ LastUsedSeq: session.LastUsedSeq,
217+ Tabs: session.TabList,
218+ ActiveTabID: session.ActiveTabID,
219+ Attached: session.Attached,
220+ })
221+ }
222+ hosts = append(hosts, catalogCacheHost{
223+ Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions,
224+ })
225+ }
226+ version := catalogCacheFileVersion
227+ return catalogCacheFile{Version: &version, Hosts: &hosts}, nil
228+}
229+
142 // Store atomically replaces the cache with a complete normalized snapshot.230 // Store atomically replaces the cache with a complete normalized snapshot.
143 func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error {231 func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
144 normalized, err := normalizeCatalogCacheEntries(entries)232 normalized, err := normalizeCatalogCacheEntries(entries)
M internal/adapters/remote/catalog_cache_test.go +41 -1
@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/pkg/safedir"
1314 )
1415
1516 func TestCatalogCachePath(t *testing.T) {
@@ -116,6 +117,45 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
116117 }
117118 }
118119
120+func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) {
121+ t.Parallel()
122+
123+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
124+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
125+ t.Fatalf("mkdir: %v", err)
126+ }
127+ raw := []byte(`{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"dumber","state":"down","ephemeral":false,"tab_list":[],"attached":false},{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"last_used_seq":42,"tab_list":[{"id":"t_work","index":0,"name":"shell"}],"active_tab_id":"t_work","attached":false}]}]}`)
128+ if err := os.WriteFile(path, raw, 0o600); err != nil {
129+ t.Fatalf("write cache: %v", err)
130+ }
131+
132+ got, err := NewFileCatalogCache(path).Load()
133+ if err != nil {
134+ t.Fatalf("Load() error = %v", err)
135+ }
136+ want := []ports.RemoteCatalogCacheEntry{{
137+ Host: "user@arch",
138+ FetchedAt: time.Unix(0, 1780000000000000000),
139+ Sessions: []ports.RemoteCatalogSession{
140+ {LifecycleID: [16]byte{2}, Name: "dumber", State: ports.RemoteCatalogSessionDown, Tabs: []ports.RemoteCatalogTab{}},
141+ {
142+ LifecycleID: [16]byte{1}, Name: "work", State: ports.RemoteCatalogSessionUp,
143+ LastUsedSeq: 42, Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work",
144+ },
145+ },
146+ }}
147+ if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) {
148+ t.Fatalf("Load() = %#v, want %#v", got, want)
149+ }
150+ after, err := os.ReadFile(path)
151+ if err != nil {
152+ t.Fatalf("read cache after load: %v", err)
153+ }
154+ if !slices.Equal(after, raw) {
155+ t.Fatalf("Load changed v2 cache: got %q, want %q", after, raw)
156+ }
157+}
158+
119159 func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
120160 t.Parallel()
121161
@@ -125,7 +165,7 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
125165 }{
126166 {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)},
127167 {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)},
128- {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[]}`)},
168+ {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"active_tab_id":"t_work","attached":false}]}]}`)},
129169 {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)},
130170 {name: "missing hosts", raw: []byte(`{"version":3}`)},
131171 {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)},
@@ -10,6 +10,7 @@ import (
10 "time"10 "time"
11 11
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/pkg/safedir"
13 )14 )
14 15
15 func TestCatalogCachePath(t *testing.T) {16 func TestCatalogCachePath(t *testing.T) {
@@ -116,6 +117,45 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
116 }117 }
117 }118 }
118 119
120+func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) {
121+ t.Parallel()
122+
123+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
124+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
125+ t.Fatalf("mkdir: %v", err)
126+ }
127+ raw := []byte(`{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"dumber","state":"down","ephemeral":false,"tab_list":[],"attached":false},{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"last_used_seq":42,"tab_list":[{"id":"t_work","index":0,"name":"shell"}],"active_tab_id":"t_work","attached":false}]}]}`)
128+ if err := os.WriteFile(path, raw, 0o600); err != nil {
129+ t.Fatalf("write cache: %v", err)
130+ }
131+
132+ got, err := NewFileCatalogCache(path).Load()
133+ if err != nil {
134+ t.Fatalf("Load() error = %v", err)
135+ }
136+ want := []ports.RemoteCatalogCacheEntry{{
137+ Host: "user@arch",
138+ FetchedAt: time.Unix(0, 1780000000000000000),
139+ Sessions: []ports.RemoteCatalogSession{
140+ {LifecycleID: [16]byte{2}, Name: "dumber", State: ports.RemoteCatalogSessionDown, Tabs: []ports.RemoteCatalogTab{}},
141+ {
142+ LifecycleID: [16]byte{1}, Name: "work", State: ports.RemoteCatalogSessionUp,
143+ LastUsedSeq: 42, Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work",
144+ },
145+ },
146+ }}
147+ if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) {
148+ t.Fatalf("Load() = %#v, want %#v", got, want)
149+ }
150+ after, err := os.ReadFile(path)
151+ if err != nil {
152+ t.Fatalf("read cache after load: %v", err)
153+ }
154+ if !slices.Equal(after, raw) {
155+ t.Fatalf("Load changed v2 cache: got %q, want %q", after, raw)
156+ }
157+}
158+
119 func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {159 func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
120 t.Parallel()160 t.Parallel()
121 161
@@ -125,7 +165,7 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
125 }{165 }{
126 {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)},166 {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)},
127 {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)},167 {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)},
128- {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[]}`)},168+ {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"active_tab_id":"t_work","attached":false}]}]}`)},
129 {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)},169 {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)},
130 {name: "missing hosts", raw: []byte(`{"version":3}`)},170 {name: "missing hosts", raw: []byte(`{"version":3}`)},
131 {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)},171 {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)},

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