v0.2.0 80c61e3

remote_picker.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"encoding/base64"
6	"errors"
7	"fmt"
8	"sort"
9	"strings"
10	"time"
11
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/ports"
14	"github.com/bnema/vev/internal/usecase/picker"
15)
16
17type remotePickerCatalogEntry struct {
18	entry  ports.RemoteCatalogCacheEntry
19	status remoteHostStatus
20}
21
22// remotePickerInstance is an immutable ownership capability for one published
23// picker lifecycle. The model identity prevents delayed registration from
24// attaching ownership to a replacement within the same client.
25type remotePickerInstance struct {
26	ac         *attachedClient
27	generation uint64
28	model      *picker.Model
29}
30
31// remotePickerCatalogSnapshot copies the cache-derived state needed for a
32// picker before any row sorting begins. No remote-state lock survives this
33// snapshot.
34const remotePickerAttachTTL = 30 * time.Second
35
36func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
37	if d == nil {
38		return nil
39	}
40	now := d.clock.Now()
41	d.remoteCatalog.mu.Lock()
42	for host, entry := range d.remoteCatalog.cache {
43		if d.remoteCatalog.status[host] == remoteHostFresh && remoteCatalogExpired(entry.FetchedAt, now) {
44			d.remoteCatalog.status[host] = remoteHostStale
45		}
46	}
47	entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
48	seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
49	for host, entry := range d.remoteCatalog.cache {
50		entries = append(entries, remotePickerCatalogEntry{
51			entry:  cloneRemoteCatalogEntry(entry),
52			status: d.remoteCatalog.status[host],
53		})
54		seen[host] = struct{}{}
55	}
56	for host, status := range d.remoteCatalog.status {
57		if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) {
58			continue
59		}
60		entries = append(entries, remotePickerCatalogEntry{
61			entry:  ports.RemoteCatalogCacheEntry{Host: host},
62			status: status,
63		})
64	}
65	d.remoteCatalog.mu.Unlock()
66	return entries
67}
68
69func (d *Daemon) remotePickerHostRanks() map[string]int {
70	ranks := make(map[string]int)
71	if d == nil || d.remoteHostStore == nil {
72		return ranks
73	}
74	pinned, learned, err := d.remoteHostStore.Hosts()
75	if err != nil {
76		d.log.Warn("listing remote hosts for picker failed", "err", err)
77		return ranks
78	}
79	for _, host := range append(pinned, learned...) {
80		if _, exists := ranks[host]; !exists {
81			ranks[host] = len(ranks)
82		}
83	}
84	return ranks
85}
86
87func remoteCatalogExpired(fetchedAt, now time.Time) bool {
88	return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) >= remotePickerAttachTTL)
89}
90
91func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
92	switch status {
93	case remoteHostFresh:
94		return picker.RemoteFresh
95	case remoteHostStale, remoteHostUnreachable, remoteHostMalformed:
96		return picker.RemoteStale
97	case remoteHostVersionMismatch:
98		return picker.RemoteVersionMismatch
99	default:
100		return picker.RemoteCached
101	}
102}
103
104func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
105	switch status {
106	case remoteHostStale:
107		if !fetchedAt.IsZero() {
108			return "catalog stale since " + fetchedAt.Format(time.RFC3339)
109		}
110		return "catalog stale"
111	case remoteHostUnreachable:
112		if !fetchedAt.IsZero() {
113			return "stale since " + fetchedAt.Format(time.RFC3339)
114		}
115		return "unreachable"
116	case remoteHostVersionMismatch:
117		return "version mismatch"
118	case remoteHostMalformed:
119		return "catalog malformed"
120	default:
121		return ""
122	}
123}
124
125func remoteSessionOrigin(host string) string {
126	if i := strings.LastIndexByte(host, '@'); i >= 0 && i+1 < len(host) {
127		return host[i+1:]
128	}
129	return host
130}
131
132func remoteSessionStateStopped(state string) bool {
133	return state == "down" || state == "stopped"
134}
135
136func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
137	key.LifecycleID = session.LifecycleID
138	key.DisplayOrigin = remoteSessionOrigin(key.Host)
139	availability := remotePickerAvailability(status)
140	stopped := remoteSessionStateStopped(session.State)
141	broken := session.State == "broken"
142	tabs := ports.CatalogTabs(session)
143	viewTabs := make([]picker.TabEntry, 0, len(tabs))
144	active := 0
145	for i, tab := range tabs {
146		if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
147			active = i
148		}
149		name := tab.Name
150		if name == "" {
151			name = fmt.Sprintf("%d", int(tab.Index)+1)
152		}
153		viewTabs = append(viewTabs, picker.TabEntry{
154			TabID:     domain.TabStableID(tab.ID),
155			Name:      name,
156			RawName:   tab.Name,
157			Detail:    tab.Detail,
158			Attention: tab.Attention,
159		})
160	}
161	legacy := session.LifecycleID == (domain.SessionLifecycleID{})
162	var remoteTarget *domain.RemoteSessionTarget
163	if !legacy {
164		target := domain.RemoteSessionTarget{
165			Endpoint:      key.Host,
166			DisplayOrigin: key.DisplayOrigin,
167			LifecycleID:   session.LifecycleID,
168			SessionName:   session.Name,
169			Stopped:       stopped,
170		}
171		if len(tabs) != 0 {
172			first := tabs[0]
173			if stopped {
174				if first.ID != "" {
175					target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
176				} else {
177					target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs)))
178				}
179			} else {
180				target.LiveTabID = domain.TabStableID(tabs[active].ID)
181			}
182		}
183		// Keep the structured session identity on rows even when one tab ID is
184		// malformed or a broken session cannot be activated. Cursor navigation
185		// must remain possible, while sendRemoteAttachTargetForAttachment will
186		// fail closed on the invalid target.
187		remoteTarget = &target
188	}
189	reason := ""
190	if !legacy && status != remoteHostFresh {
191		reason = remoteReasonForStatus(status)
192	}
193	targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
194	if broken {
195		reason = "session_broken"
196	} else if !legacy && !targetValid {
197		reason = "identity_changed"
198	}
199	ready := status == remoteHostFresh && targetValid && !broken
200	if legacy {
201		// Count-only peers cannot prove lifecycle/tab identity. Preserve the
202		// historical row for compatibility, but make it read-only until a fresh
203		// catalog response authorizes a name attach.
204		ready = status == remoteHostFresh && !broken && !stopped
205		if status == remoteHostVersionMismatch {
206			reason = remoteReasonForStatus(status)
207		}
208	}
209	detail := remotePickerStatusDetail(status, fetchedAt)
210	if detail == "" {
211		switch session.State {
212		case "up":
213			detail = "up"
214		case "down", "stopped":
215			detail = "stopped"
216		case "broken":
217			detail = "broken"
218		}
219	}
220	if broken {
221		detail = "session broken"
222	}
223	return picker.SessionView{
224		ID:                 key.ID(),
225		Name:               key.Display(),
226		RemoteKey:          &key,
227		RemoteTarget:       remoteTarget,
228		RemoteHost:         key.Host,
229		RemoteAvailability: availability,
230		RemoteDetail:       detail,
231		RemoteReason:       reason,
232		Tabs:               viewTabs,
233		Active:             active,
234		Stopped:            stopped,
235		ConnectOnly:        legacy,
236		RemoteAttachReady:  ready,
237		CannotAcceptMoves:  true,
238	}
239}
240
241func remoteReasonForStatus(status remoteHostStatus) string {
242	switch status {
243	case remoteHostRefreshing:
244		return "refreshing"
245	case remoteHostStale:
246		return "catalog_stale"
247	case remoteHostUnreachable:
248		return "host_unreachable"
249	case remoteHostVersionMismatch:
250		return "version_mismatch"
251	case remoteHostMalformed:
252		return "malformed"
253	case remoteHostCached:
254		return "refreshing"
255	default:
256		return ""
257	}
258}
259
260func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView {
261	return picker.SessionView{
262		ID:                 domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))),
263		Name:               host,
264		RemoteHost:         host,
265		RemoteReason:       remoteReasonForStatus(status),
266		RemoteAvailability: remotePickerAvailability(status),
267		RemoteDetail:       remotePickerStatusDetail(status, time.Time{}),
268		ConnectOnly:        true,
269		CannotAcceptMoves:  true,
270	}
271}
272
273func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
274	for i := range entries {
275		sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
276			l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]
277			if l.LastUsedSeq != r.LastUsedSeq {
278				return l.LastUsedSeq > r.LastUsedSeq
279			}
280			return l.Name < r.Name
281		})
282	}
283	sort.Slice(entries, func(i, j int) bool {
284		left, right := entries[i].entry.Host, entries[j].entry.Host
285		leftRank, leftKnown := ranks[left]
286		rightRank, rightKnown := ranks[right]
287		if leftKnown != rightKnown {
288			return leftKnown
289		}
290		if leftKnown && leftRank != rightRank {
291			return leftRank < rightRank
292		}
293		return left < right
294	})
295}
296
297func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
298	if d.registerRemotePicker(instance) {
299		d.startRemotePickerRefresh(instance)
300	}
301}
302
303// registerRemotePicker installs one exact picker generation as a refresh owner.
304// Keeping this step separate gives refresh startup a second atomic validation
305// point: a close can win between registration and startup without being undone
306// by a delayed opener.
307func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
308	if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
309		return false
310	}
311	rt := instance.ac.overlays
312	// Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
313	// acquire pickerMu while retaining the catalog lock.
314	rt.pickerMu.Lock()
315	defer rt.pickerMu.Unlock()
316	if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
317		return false
318	}
319	d.remoteCatalog.mu.Lock()
320	d.remoteCatalog.pickers[instance.ac] = struct{}{}
321	d.remoteCatalog.mu.Unlock()
322	return true
323}
324
325func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
326	if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
327		return
328	}
329	rt := instance.ac.overlays
330	rt.pickerMu.Lock()
331	current := rt.pickerGeneration == instance.generation && rt.picker == nil
332	if !current {
333		rt.pickerMu.Unlock()
334		return
335	}
336	d.remoteCatalog.mu.Lock()
337	delete(d.remoteCatalog.pickers, instance.ac)
338	if len(d.remoteCatalog.pickers) != 0 {
339		d.remoteCatalog.mu.Unlock()
340		rt.pickerMu.Unlock()
341		return
342	}
343	d.remoteCatalog.refresh++
344	cancel := d.remoteCatalog.cancel
345	d.remoteCatalog.cancel = nil
346	d.remoteCatalog.mu.Unlock()
347	rt.pickerMu.Unlock()
348	if cancel != nil {
349		cancel()
350	}
351}
352
353func (d *Daemon) cancelRemotePickerRefresh() {
354	if d == nil {
355		return
356	}
357	d.remoteCatalog.mu.Lock()
358	d.remoteCatalog.refresh++
359	cancel := d.remoteCatalog.cancel
360	d.remoteCatalog.cancel = nil
361	d.remoteCatalog.mu.Unlock()
362	if cancel != nil {
363		cancel()
364	}
365}
366
367func (d *Daemon) remotePickerHosts() ([]string, error) {
368	if d == nil || d.remoteHostStore == nil {
369		return nil, nil
370	}
371	pinned, learned, err := d.remoteHostStore.Hosts()
372	if err != nil {
373		return nil, err
374	}
375	hosts := make([]string, 0, len(pinned)+len(learned))
376	seen := make(map[string]struct{}, cap(hosts))
377	add := func(candidates []string) {
378		for _, host := range candidates {
379			if _, exists := seen[host]; exists {
380				continue
381			}
382			seen[host] = struct{}{}
383			hosts = append(hosts, host)
384		}
385	}
386	add(pinned)
387	add(learned)
388	return hosts, nil
389}
390
391// startRemotePickerRefresh replaces the current refresh generation and launches
392// one bounded catalog call per known host. Installing the generation is gated
393// by the exact picker generation that registered ownership. Host-store and
394// remote I/O happen after every architecture lock has been released.
395func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
396	if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
397		return 0
398	}
399	root := context.Background()
400	if d.serveCtx != nil {
401		root = d.serveCtx
402	}
403	ctx, cancel := context.WithCancel(root)
404
405	rt := instance.ac.overlays
406	rt.pickerMu.Lock()
407	d.remoteCatalog.mu.Lock()
408	_, owner := d.remoteCatalog.pickers[instance.ac]
409	current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
410	if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
411		d.remoteCatalog.mu.Unlock()
412		rt.pickerMu.Unlock()
413		cancel()
414		return 0
415	}
416	previous := d.remoteCatalog.cancel
417	d.remoteCatalog.refresh++
418	generation := d.remoteCatalog.refresh
419	d.remoteCatalog.cancel = cancel
420	d.remoteCatalog.mu.Unlock()
421	rt.pickerMu.Unlock()
422	if previous != nil {
423		previous()
424	}
425	if d.remoteCatalogClient == nil {
426		return generation
427	}
428
429	hosts, err := d.remotePickerHosts()
430	if err != nil {
431		d.log.Warn("listing remote hosts for refresh failed", "err", err)
432		return generation
433	}
434	known := make(map[string]struct{}, len(hosts))
435	for _, host := range hosts {
436		known[host] = struct{}{}
437	}
438
439	d.remoteCatalog.mu.Lock()
440	if d.remoteCatalog.refresh != generation {
441		d.remoteCatalog.mu.Unlock()
442		cancel()
443		return generation
444	}
445	cacheChanged := false
446	for host := range d.remoteCatalog.cache {
447		if _, exists := known[host]; exists {
448			continue
449		}
450		delete(d.remoteCatalog.cache, host)
451		delete(d.remoteCatalog.status, host)
452		delete(d.remoteCatalog.failure, host)
453		cacheChanged = true
454	}
455	for _, host := range hosts {
456		d.remoteCatalog.status[host] = remoteHostRefreshing
457	}
458	d.remoteCatalog.mu.Unlock()
459	if cacheChanged {
460		d.persistRemoteCatalogCache()
461	}
462	d.refreshRemoteOpenPickers()
463
464	for _, host := range hosts {
465		d.sessWg.Go(func() {
466			catalog, listErr := d.remoteCatalogClient.List(ctx, host)
467			d.applyRemoteRefreshResult(generation, host, catalog, listErr)
468		})
469	}
470	return generation
471}
472
473func containsRemotePickerHost(hosts []string, target string) bool {
474	for _, host := range hosts {
475		if host == target {
476			return true
477		}
478	}
479	return false
480}
481
482// applyRemoteRefreshResult publishes one host completion only if both its
483// refresh generation and current host-registry membership remain authoritative.
484// Its return reports whether durable cache state changed.
485func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
486	if d == nil {
487		return false
488	}
489	d.remoteCatalog.mu.Lock()
490	current := d.remoteCatalog.refresh == generation
491	d.remoteCatalog.mu.Unlock()
492	if !current {
493		return false
494	}
495
496	hosts, hostsErr := d.remotePickerHosts()
497	if hostsErr != nil {
498		d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
499		return false
500	}
501	stillKnown := containsRemotePickerHost(hosts, host)
502	now := d.clock.Now()
503	if listErr == nil {
504		if err := ports.ValidateRemoteCatalog(catalog); err != nil {
505			listErr = err
506		}
507	}
508
509	d.remoteCatalog.mu.Lock()
510	if d.remoteCatalog.refresh != generation {
511		d.remoteCatalog.mu.Unlock()
512		return false
513	}
514	if !stillKnown {
515		_, cacheChanged := d.remoteCatalog.cache[host]
516		delete(d.remoteCatalog.cache, host)
517		delete(d.remoteCatalog.status, host)
518		delete(d.remoteCatalog.failure, host)
519		d.remoteCatalog.mu.Unlock()
520		if cacheChanged {
521			d.persistRemoteCatalogCache()
522			d.refreshRemoteOpenPickers()
523		}
524		return cacheChanged
525	}
526	if listErr != nil {
527		status := remoteHostMalformed
528		var mismatch *ports.RemoteCatalogVersionMismatchError
529		if errors.As(listErr, &mismatch) {
530			status = remoteHostVersionMismatch
531		} else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) &&
532			!errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) &&
533			!errors.Is(listErr, ports.ErrRemoteCatalogUnknownState) &&
534			!errors.Is(listErr, ports.ErrRemoteCatalogInvalidReason) {
535			status = remoteHostUnreachable
536		}
537		d.remoteCatalog.status[host] = status
538		d.remoteCatalog.failure[host] = now
539		d.remoteCatalog.mu.Unlock()
540		d.refreshRemoteOpenPickers()
541		return false
542	}
543	d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
544		Host:      host,
545		FetchedAt: now,
546		Sessions:  catalog.Sessions,
547	})
548	d.remoteCatalog.status[host] = remoteHostFresh
549	delete(d.remoteCatalog.failure, host)
550	d.remoteCatalog.mu.Unlock()
551
552	d.persistRemoteCatalogCache()
553	d.refreshRemoteOpenPickers()
554	return true
555}
556
557func (d *Daemon) refreshRemoteOpenPickers() {
558	if d == nil {
559		return
560	}
561	d.remoteCatalog.mu.Lock()
562	clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
563	for ac := range d.remoteCatalog.pickers {
564		clients = append(clients, ac)
565	}
566	d.remoteCatalog.mu.Unlock()
567
568	for _, ac := range clients {
569		if ac == nil || ac.overlays == nil {
570			continue
571		}
572		d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
573		if entry := ac.currentAttachmentSession(); entry != nil {
574			d.invalidateRender(entry, ac, true, "remote_picker.go")
575		}
576	}
577}

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