v0.3.0 99dc266

remote_picker.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"encoding/base64"
6	"errors"
7	"fmt"
8	"math"
9	"sort"
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 remoteHostCached, remoteHostRefreshing:
107		return "checking remote…"
108	case remoteHostStale:
109		if !fetchedAt.IsZero() {
110			return "catalog stale since " + fetchedAt.Format(time.RFC3339)
111		}
112		return "catalog stale"
113	case remoteHostUnreachable:
114		if !fetchedAt.IsZero() {
115			return "stale since " + fetchedAt.Format(time.RFC3339)
116		}
117		return "unreachable"
118	case remoteHostVersionMismatch:
119		return "version mismatch"
120	case remoteHostMalformed:
121		return "catalog malformed"
122	default:
123		return ""
124	}
125}
126
127func remoteSessionStateStopped(state string) bool {
128	return state == "down" || state == "stopped"
129}
130
131func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
132	key.LifecycleID = session.LifecycleID
133	key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
134	availability := remotePickerAvailability(status)
135	stopped := remoteSessionStateStopped(session.State)
136	broken := session.State == "broken"
137	tabs := ports.CatalogTabs(session)
138	viewTabs := make([]picker.TabEntry, 0, len(tabs))
139	active := 0
140	for i, tab := range tabs {
141		if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
142			active = i
143		}
144		name := tab.Name
145		if name == "" {
146			name = fmt.Sprintf("%d", int(tab.Index)+1)
147		}
148		viewTabs = append(viewTabs, picker.TabEntry{
149			TabID:     domain.TabStableID(tab.ID),
150			Name:      name,
151			RawName:   tab.Name,
152			Detail:    tab.Detail,
153			Attention: tab.Attention,
154		})
155	}
156	legacy := session.LifecycleID == (domain.SessionLifecycleID{})
157	target := domain.RemoteSessionTarget{
158		Endpoint:      key.Host,
159		DisplayOrigin: key.DisplayOrigin,
160		LifecycleID:   session.LifecycleID,
161		SessionName:   session.Name,
162		Stopped:       stopped,
163	}
164	if len(tabs) != 0 {
165		first := tabs[0]
166		if stopped {
167			if first.ID != "" {
168				target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
169			} else {
170				tabCount := min(len(tabs), math.MaxUint16)
171				target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount))
172			}
173		} else {
174			target.LiveTabID = domain.TabStableID(tabs[active].ID)
175		}
176	}
177	// Keep the structured session identity on rows even when one tab ID is
178	// malformed or a broken session cannot be activated. Cursor navigation
179	// must remain possible, while sendRemoteAttachTargetForAttachment will
180	// fail closed on the invalid target.
181	var remoteTarget *domain.RemoteSessionTarget
182	if !legacy {
183		remoteTarget = &target
184	}
185	reason := remoteReasonForStatus(status)
186	targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
187	activation := picker.RemoteUnavailable
188	if broken {
189		reason = "session_broken"
190	} else if !legacy && !targetValid {
191		reason = "identity_changed"
192	} else if status == remoteHostFresh {
193		if legacy {
194			if !stopped {
195				activation = picker.RemoteAttach
196			}
197		} else if stopped {
198			activation = picker.RemoteRestart
199		} else {
200			activation = picker.RemoteAttach
201		}
202	}
203
204	detail := remotePickerStatusDetail(status, fetchedAt)
205	if detail == "" {
206		switch {
207		case broken:
208			detail = "session broken"
209		case !legacy && !targetValid:
210			detail = "identity changed"
211		case legacy && stopped:
212			detail = "stopped — exact identity required"
213		case activation == picker.RemoteRestart:
214			detail = "stopped — Enter to restart"
215		case session.State == "up":
216			detail = "up"
217		case stopped:
218			detail = "stopped"
219		default:
220			detail = session.State
221		}
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		RemoteActivation:   activation,
233		Tabs:               viewTabs,
234		Active:             active,
235		Stopped:            stopped,
236		ConnectOnly:        legacy,
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		RemoteActivation:   picker.RemoteUnavailable,
269		ConnectOnly:        true,
270		CannotAcceptMoves:  true,
271	}
272}
273
274func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
275	for i := range entries {
276		sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
277			l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]
278			if l.LastUsedSeq != r.LastUsedSeq {
279				return l.LastUsedSeq > r.LastUsedSeq
280			}
281			return l.Name < r.Name
282		})
283	}
284	sort.Slice(entries, func(i, j int) bool {
285		left, right := entries[i].entry.Host, entries[j].entry.Host
286		leftRank, leftKnown := ranks[left]
287		rightRank, rightKnown := ranks[right]
288		if leftKnown != rightKnown {
289			return leftKnown
290		}
291		if leftKnown && leftRank != rightRank {
292			return leftRank < rightRank
293		}
294		return left < right
295	})
296}
297
298func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
299	if d.registerRemotePicker(instance) {
300		d.startRemotePickerRefresh(instance)
301	}
302}
303
304// registerRemotePicker installs one exact picker generation as a refresh owner.
305// Keeping this step separate gives refresh startup a second atomic validation
306// point: a close can win between registration and startup without being undone
307// by a delayed opener.
308func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
309	if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
310		return false
311	}
312	rt := instance.ac.overlays
313	// Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
314	// acquire pickerMu while retaining the catalog lock.
315	rt.pickerMu.Lock()
316	defer rt.pickerMu.Unlock()
317	if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
318		return false
319	}
320	d.remoteCatalog.mu.Lock()
321	d.remoteCatalog.pickers[instance.ac] = struct{}{}
322	d.remoteCatalog.mu.Unlock()
323	return true
324}
325
326func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
327	if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
328		return
329	}
330	rt := instance.ac.overlays
331	rt.pickerMu.Lock()
332	current := rt.pickerGeneration == instance.generation && rt.picker == nil
333	if !current {
334		rt.pickerMu.Unlock()
335		return
336	}
337	d.remoteCatalog.mu.Lock()
338	delete(d.remoteCatalog.pickers, instance.ac)
339	if len(d.remoteCatalog.pickers) != 0 {
340		d.remoteCatalog.mu.Unlock()
341		rt.pickerMu.Unlock()
342		return
343	}
344	d.remoteCatalog.refresh++
345	cancel := d.remoteCatalog.cancel
346	d.remoteCatalog.cancel = nil
347	d.remoteCatalog.mu.Unlock()
348	rt.pickerMu.Unlock()
349	if cancel != nil {
350		cancel()
351	}
352}
353
354func (d *Daemon) cancelRemotePickerRefresh() {
355	if d == nil {
356		return
357	}
358	d.remoteCatalog.mu.Lock()
359	d.remoteCatalog.refresh++
360	cancel := d.remoteCatalog.cancel
361	d.remoteCatalog.cancel = nil
362	d.remoteCatalog.mu.Unlock()
363	if cancel != nil {
364		cancel()
365	}
366}
367
368func (d *Daemon) remotePickerHosts() ([]string, error) {
369	if d == nil || d.remoteHostStore == nil {
370		return nil, nil
371	}
372	pinned, learned, err := d.remoteHostStore.Hosts()
373	if err != nil {
374		return nil, err
375	}
376	hosts := make([]string, 0, len(pinned)+len(learned))
377	seen := make(map[string]struct{}, cap(hosts))
378	add := func(candidates []string) {
379		for _, host := range candidates {
380			if _, exists := seen[host]; exists {
381				continue
382			}
383			seen[host] = struct{}{}
384			hosts = append(hosts, host)
385		}
386	}
387	add(pinned)
388	add(learned)
389	return hosts, nil
390}
391
392// startRemotePickerRefresh replaces the current refresh generation and launches
393// one bounded catalog call per known host. Installing the generation is gated
394// by the exact picker generation that registered ownership. Host-store and
395// remote I/O happen after every architecture lock has been released.
396func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
397	if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
398		return 0
399	}
400	root := context.Background()
401	if d.serveCtx != nil {
402		root = d.serveCtx
403	}
404	ctx, cancel := context.WithCancel(root)
405
406	rt := instance.ac.overlays
407	rt.pickerMu.Lock()
408	d.remoteCatalog.mu.Lock()
409	_, owner := d.remoteCatalog.pickers[instance.ac]
410	current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
411	if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
412		d.remoteCatalog.mu.Unlock()
413		rt.pickerMu.Unlock()
414		cancel()
415		return 0
416	}
417	previous := d.remoteCatalog.cancel
418	d.remoteCatalog.refresh++
419	generation := d.remoteCatalog.refresh
420	d.remoteCatalog.cancel = cancel
421	d.remoteCatalog.mu.Unlock()
422	rt.pickerMu.Unlock()
423	if previous != nil {
424		previous()
425	}
426	if d.remoteCatalogClient == nil {
427		return generation
428	}
429
430	hosts, err := d.remotePickerHosts()
431	if err != nil {
432		d.log.Warn("listing remote hosts for refresh failed", "err", err)
433		return generation
434	}
435	known := make(map[string]struct{}, len(hosts))
436	for _, host := range hosts {
437		known[host] = struct{}{}
438	}
439
440	d.remoteCatalog.mu.Lock()
441	if d.remoteCatalog.refresh != generation {
442		d.remoteCatalog.mu.Unlock()
443		cancel()
444		return generation
445	}
446	cacheChanged := false
447	for host := range d.remoteCatalog.cache {
448		if _, exists := known[host]; exists {
449			continue
450		}
451		delete(d.remoteCatalog.cache, host)
452		delete(d.remoteCatalog.status, host)
453		delete(d.remoteCatalog.failure, host)
454		cacheChanged = true
455	}
456	for _, host := range hosts {
457		d.remoteCatalog.status[host] = remoteHostRefreshing
458	}
459	d.remoteCatalog.mu.Unlock()
460	if cacheChanged {
461		d.persistRemoteCatalogCache()
462	}
463	d.refreshRemoteOpenPickers()
464
465	for _, host := range hosts {
466		d.sessWg.Go(func() {
467			catalog, listErr := d.remoteCatalogClient.List(ctx, host)
468			d.applyRemoteRefreshResult(generation, host, catalog, listErr)
469		})
470	}
471	return generation
472}
473
474func containsRemotePickerHost(hosts []string, target string) bool {
475	for _, host := range hosts {
476		if host == target {
477			return true
478		}
479	}
480	return false
481}
482
483// applyRemoteRefreshResult publishes one host completion only if both its
484// refresh generation and current host-registry membership remain authoritative.
485// Its return reports whether durable cache state changed.
486func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
487	if d == nil {
488		return false
489	}
490	d.remoteCatalog.mu.Lock()
491	current := d.remoteCatalog.refresh == generation
492	d.remoteCatalog.mu.Unlock()
493	if !current {
494		return false
495	}
496
497	hosts, hostsErr := d.remotePickerHosts()
498	if hostsErr != nil {
499		d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
500		return false
501	}
502	stillKnown := containsRemotePickerHost(hosts, host)
503	now := d.clock.Now()
504	if listErr == nil {
505		if err := ports.ValidateRemoteCatalog(catalog); err != nil {
506			listErr = err
507		}
508	}
509
510	d.remoteCatalog.mu.Lock()
511	if d.remoteCatalog.refresh != generation {
512		d.remoteCatalog.mu.Unlock()
513		return false
514	}
515	if !stillKnown {
516		_, cacheChanged := d.remoteCatalog.cache[host]
517		delete(d.remoteCatalog.cache, host)
518		delete(d.remoteCatalog.status, host)
519		delete(d.remoteCatalog.failure, host)
520		d.remoteCatalog.mu.Unlock()
521		if cacheChanged {
522			d.persistRemoteCatalogCache()
523			d.refreshRemoteOpenPickers()
524		}
525		return cacheChanged
526	}
527	if listErr != nil {
528		status := remoteHostMalformed
529		var mismatch *ports.RemoteCatalogVersionMismatchError
530		if errors.As(listErr, &mismatch) {
531			status = remoteHostVersionMismatch
532		} else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) &&
533			!errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) &&
534			!errors.Is(listErr, ports.ErrRemoteCatalogUnknownState) &&
535			!errors.Is(listErr, ports.ErrRemoteCatalogInvalidReason) {
536			status = remoteHostUnreachable
537		}
538		d.remoteCatalog.status[host] = status
539		d.remoteCatalog.failure[host] = now
540		d.remoteCatalog.mu.Unlock()
541		d.refreshRemoteOpenPickers()
542		return false
543	}
544	d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
545		Host:      host,
546		FetchedAt: now,
547		Sessions:  catalog.Sessions,
548	})
549	d.remoteCatalog.status[host] = remoteHostFresh
550	delete(d.remoteCatalog.failure, host)
551	d.remoteCatalog.mu.Unlock()
552
553	d.persistRemoteCatalogCache()
554	d.refreshRemoteOpenPickers()
555	return true
556}
557
558func (d *Daemon) refreshRemoteOpenPickers() {
559	if d == nil {
560		return
561	}
562	d.remoteCatalog.mu.Lock()
563	clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
564	for ac := range d.remoteCatalog.pickers {
565		clients = append(clients, ac)
566	}
567	d.remoteCatalog.mu.Unlock()
568
569	for _, ac := range clients {
570		if ac == nil || ac.overlays == nil {
571			continue
572		}
573		d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
574		if entry := ac.currentAttachmentSession(); entry != nil {
575			d.invalidateRender(entry, ac, true, "remote_picker.go")
576		}
577	}
578}

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