v0.3.0 99dc266

catalog_cache.go RawHistoryFolder

1package remote
2
3import (
4	"bytes"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"os"
9	"path/filepath"
10	"sort"
11	"time"
12	"unicode/utf8"
13
14	"github.com/bnema/vev/internal/domain"
15	"github.com/bnema/vev/internal/ports"
16)
17
18const (
19	catalogCacheFileName    = "remote-catalog-cache.json"
20	catalogCacheFileVersion = 2
21)
22
23// CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
24func CatalogCachePath(stateDir string) string { return filepath.Join(stateDir, catalogCacheFileName) }
25
26type catalogCacheFile struct {
27	Version *int                `json:"version"`
28	Hosts   *[]catalogCacheHost `json:"hosts"`
29}
30
31type catalogCacheHost struct {
32	Target            *string                `json:"target"`
33	FetchedAtUnixNano *int64                 `json:"fetched_at_unix_nano"`
34	Sessions          *[]catalogCacheSession `json:"sessions"`
35}
36
37type catalogCacheSession struct {
38	LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
39	Name        *string                    `json:"name"`
40	State       *string                    `json:"state"`
41	Ephemeral   *bool                      `json:"ephemeral"`
42	LastUsedSeq *uint64                    `json:"last_used_seq,omitempty"`
43	Tabs        *uint16                    `json:"tabs,omitempty"`
44	TabList     *[]ports.RemoteCatalogTab  `json:"tab_list,omitempty"`
45	ActiveTabID *string                    `json:"active_tab_id,omitempty"`
46	Attached    *bool                      `json:"attached"`
47}
48
49type fileCatalogCache struct {
50	path string
51}
52
53// NewFileCatalogCache returns a durable remote catalog cache backed by path.
54func NewFileCatalogCache(path string) ports.RemoteCatalogCache {
55	return &fileCatalogCache{path: path}
56}
57
58var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)
59
60// Load reads and validates one complete cache snapshot. A missing cache is an
61// empty snapshot; malformed cache data is never changed by Load.
62func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
63	raw, err := os.ReadFile(c.path)
64	if errors.Is(err, os.ErrNotExist) {
65		return []ports.RemoteCatalogCacheEntry{}, nil
66	}
67	if err != nil {
68		return nil, err
69	}
70	if !utf8.Valid(raw) {
71		return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
72	}
73
74	dec := json.NewDecoder(bytes.NewReader(raw))
75	dec.DisallowUnknownFields()
76	var file catalogCacheFile
77	if err := dec.Decode(&file); err != nil {
78		return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
79	}
80	if err := rejectTrailingJSON(dec); err != nil {
81		return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
82	}
83	if file.Version == nil {
84		return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
85	}
86	if *file.Version != catalogCacheFileVersion {
87		return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version)
88	}
89	if file.Hosts == nil {
90		return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
91	}
92
93	entries := make([]ports.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
94	for _, host := range *file.Hosts {
95		if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
96			return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
97		}
98		if !utf8.ValidString(*host.Target) {
99			return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
100		}
101		if *host.FetchedAtUnixNano <= 0 {
102			return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
103		}
104		entry := ports.RemoteCatalogCacheEntry{
105			Host:      *host.Target,
106			FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
107			Sessions:  make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),
108		}
109		for _, session := range *host.Sessions {
110			if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil && session.TabList == nil {
111				return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
112			}
113			if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) {
114				return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
115			}
116			decoded := ports.RemoteCatalogSession{
117				Name:      *session.Name,
118				State:     *session.State,
119				Ephemeral: *session.Ephemeral,
120				Attached:  *session.Attached,
121			}
122			if session.LastUsedSeq != nil {
123				decoded.LastUsedSeq = *session.LastUsedSeq
124			}
125			if session.LifecycleID != nil {
126				decoded.LifecycleID = *session.LifecycleID
127			}
128			if session.ActiveTabID != nil {
129				decoded.ActiveTabID = *session.ActiveTabID
130			}
131			if session.TabList != nil {
132				decoded.Tabs = append([]ports.RemoteCatalogTab(nil), (*session.TabList)...)
133			} else {
134				decoded.Tabs = int(*session.Tabs)
135			}
136			entry.Sessions = append(entry.Sessions, decoded)
137		}
138		entries = append(entries, entry)
139	}
140	normalized, err := normalizeCatalogCacheEntries(entries)
141	if err != nil {
142		return nil, err
143	}
144	return normalized, nil
145}
146
147// Store atomically replaces the cache with a complete normalized snapshot.
148func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
149	normalized, err := normalizeCatalogCacheEntries(entries)
150	if err != nil {
151		return err
152	}
153	hosts := make([]catalogCacheHost, 0, len(normalized))
154	for _, entry := range normalized {
155		target := entry.Host
156		fetchedAt := entry.FetchedAt.UnixNano()
157		sessions := make([]catalogCacheSession, 0, len(entry.Sessions))
158		for _, session := range entry.Sessions {
159			name := session.Name
160			state := session.State
161			ephemeral := session.Ephemeral
162			attached := session.Attached
163			row := catalogCacheSession{Name: &name, State: &state, Ephemeral: &ephemeral, Attached: &attached}
164			if session.LastUsedSeq != 0 {
165				lastUsed := session.LastUsedSeq
166				row.LastUsedSeq = &lastUsed
167			}
168			if session.LifecycleID != (domain.SessionLifecycleID{}) {
169				id := session.LifecycleID
170				row.LifecycleID = &id
171			}
172			if session.ActiveTabID != "" {
173				active := session.ActiveTabID
174				row.ActiveTabID = &active
175			}
176			if tabs := ports.CatalogTabs(session); tabs != nil {
177				copyTabs := append([]ports.RemoteCatalogTab(nil), tabs...)
178				row.TabList = &copyTabs
179			} else {
180				count := ports.SaturateUint16(ports.CatalogTabCount(session))
181				row.Tabs = &count
182			}
183			sessions = append(sessions, row)
184		}
185		hosts = append(hosts, catalogCacheHost{
186			Target:            &target,
187			FetchedAtUnixNano: &fetchedAt,
188			Sessions:          &sessions,
189		})
190	}
191	version := catalogCacheFileVersion
192	payload, err := json.Marshal(catalogCacheFile{Version: &version, Hosts: &hosts})
193	if err != nil {
194		return err
195	}
196	return atomicReplacePrivateFile(c.path, append(payload, '\n'))
197}
198
199func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) {
200	if err := ports.ValidateRemoteCatalogCacheEntries(entries); err != nil {
201		return nil, err
202	}
203	normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries))
204	hosts := make(map[string]struct{}, len(entries))
205	for _, entry := range entries {
206		if err := validateHostTarget(entry.Host); err != nil {
207			return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err)
208		}
209		if entry.FetchedAt.UnixNano() <= 0 {
210			return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host)
211		}
212		if _, duplicate := hosts[entry.Host]; duplicate {
213			return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host)
214		}
215		hosts[entry.Host] = struct{}{}
216
217		copyEntry := ports.RemoteCatalogCacheEntry{
218			Host:      entry.Host,
219			FetchedAt: entry.FetchedAt,
220			Sessions:  make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)),
221		}
222		sessions := make(map[string]struct{}, len(entry.Sessions))
223		for _, session := range entry.Sessions {
224			if !utf8.ValidString(session.Name) || !utf8.ValidString(session.State) {
225				return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
226			}
227			if err := domain.ValidateSessionName(session.Name); err != nil {
228				return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
229			}
230			if !validCatalogState(session.State) {
231				return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
232			}
233			if _, duplicate := sessions[session.Name]; duplicate {
234				return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host)
235			}
236			sessions[session.Name] = struct{}{}
237			copySession := session
238			if tabs := ports.CatalogTabs(session); tabs != nil {
239				copyTabs := make([]ports.RemoteCatalogTab, len(tabs))
240				for i, tab := range tabs {
241					copyTabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
242				}
243				copySession.Tabs = copyTabs
244			}
245			copyEntry.Sessions = append(copyEntry.Sessions, copySession)
246		}
247		sort.Slice(copyEntry.Sessions, func(i, j int) bool {
248			return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name
249		})
250		normalized = append(normalized, copyEntry)
251	}
252	sort.Slice(normalized, func(i, j int) bool {
253		return normalized[i].Host < normalized[j].Host
254	})
255	return normalized, nil
256}

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