picker_catalogue_projection.go RawHistoryFolder

1package client
2
3import (
4	"fmt"
5	"sort"
6	"strconv"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/ports"
10	"github.com/bnema/vev/internal/protocol"
11	"github.com/bnema/vev/internal/protocol/catalogue"
12)
13
14// Picker projections, ported from the daemon-built picker
15// on main (picker.go, picker_lines.go).
16//
17// The catalogue publishes two projections of the same rows, keys, and
18// actions:
19//
20//   - Recent is flat: live local sessions by recency (LastUsedSeq), then each
21//     remote host's sessions in catalogue order, then stopped local sessions
22//     by recency. Remote rows carry their origin in the detail because no
23//     section names it.
24//   - Grouped is sectioned by daemon: the local section (live by recency,
25//     then stopped), then one section per remote host.
26//
27// A session with tabs is a non-focusable header; its tab rows are the
28// destinations, each resolving to its exact tab. A session without tab
29// metadata is its own destination. A host status row is published only when
30// it adds information: the host lists no session and is either not reachable
31// or known to have no daemon.
32//
33// The cursor starts on the attachment the picker is presented over (its tab,
34// else its session), otherwise on the most recent session's active tab.
35
36// pickerCurrent names the attachment the picker is presented over. Only its
37// presence changes the cursor hint; it is never a selection authority.
38type pickerCurrent struct {
39	known     bool
40	local     bool
41	endpoint  string
42	lifecycle domain.SessionLifecycleID
43	tab       domain.TabStableID
44}
45
46// pickerRow is one projected line plus the facts its cursor hint needs.
47type pickerRow struct {
48	line          protocol.PickerLine
49	ref           pickerSelectionRef
50	hasRef        bool
51	defaultCursor bool
52}
53
54// pickerSessionBlock is one session header and its tab rows.
55type pickerSessionBlock struct {
56	rows        []pickerRow
57	lastUsedSeq uint64
58	name        string
59	lifecycle   domain.SessionLifecycleID
60}
61
62// projectLocked rebuilds both projections and their opaque keys from the
63// current host state. It is deterministic: the same inputs always produce the
64// same keys, order, and actions.
65func (c *pickerCatalogue) projectLocked() {
66	now := c.clock.Now()
67	refs := make(map[string]pickerSelectionRef)
68	var localLive, localStopped []pickerSessionBlock
69	localOrigin := ""
70	localStatus := protocol.PickerLineStatusNone
71	hasLocal := false
72	type remoteGroup struct {
73		origin string
74		status protocol.PickerLineStatus
75		rows   []pickerRow
76	}
77	var remotes []remoteGroup
78	var localHost []pickerRow
79	for _, key := range c.order {
80		host, ok := c.hosts[key]
81		if !ok {
82			continue
83		}
84		observation := host.observation
85		fresh := c.freshLocked(observation, now)
86		origin := pickerOriginLabel(observation)
87		var hostRows []pickerRow
88		for i := range observation.Sessions {
89			session := observation.Sessions[i]
90			if session.LifecycleID == (domain.SessionLifecycleID{}) {
91				// A session without a lifecycle cannot be addressed exactly and
92				// is never invented into one.
93				continue
94			}
95			block := c.sessionBlockLocked(observation, session, fresh, refs)
96			switch {
97			case !observation.Local:
98				hostRows = append(hostRows, block.rows...)
99			case session.State == catalogue.RemoteCatalogSessionUp:
100				localLive = append(localLive, block)
101			default:
102				localStopped = append(localStopped, block)
103			}
104		}
105		hasHostRow := len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable
106		if hasHostRow {
107			hostRef := pickerSelectionRef{
108				kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch,
109				local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration,
110			}
111			hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{
112				Key:          pickerHostRowKey(observation),
113				Kind:         protocol.PickerLineHost,
114				Label:        origin,
115				Detail:       pickerHostDetail(observation),
116				Status:       pickerObservationStatus(observation, fresh),
117				StatusDetail: pickerObservationReason(observation, fresh),
118				Dim:          true,
119				// A host row is focusable and offers creation only when no daemon
120				// exists. The endpoint registration is revalidated at commit.
121				Focusable: true,
122				Actions: func() protocol.PickerLineActions {
123					if observation.Availability == domain.RemoteAvailabilityNoDaemon {
124						return protocol.PickerCanNavigate
125					}
126					return 0
127				}(),
128			}, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon})
129		}
130		// The section carries the host's problem dot unless a host row below
131		// it already does. The local host never shows a transient stale dot:
132		// no toast would explain it.
133		sectionStatus := pickerObservationStatus(observation, fresh)
134		if hasHostRow || observation.Local && sectionStatus == protocol.PickerLineStatusStale {
135			sectionStatus = protocol.PickerLineStatusNone
136		}
137		if observation.Local {
138			hasLocal = true
139			localOrigin = origin
140			localStatus = sectionStatus
141			localHost = hostRows
142			continue
143		}
144		if len(hostRows) != 0 {
145			remotes = append(remotes, remoteGroup{origin: origin, status: sectionStatus, rows: hostRows})
146		}
147	}
148	sortPickerBlocks(localLive)
149	sortPickerBlocks(localStopped)
150
151	var recent, grouped []pickerRow
152	localRows := make([]pickerRow, 0)
153	for _, block := range localLive {
154		localRows = append(localRows, block.rows...)
155	}
156	localRows = append(localRows, localHost...)
157	for _, row := range localHost {
158		if row.hasRef {
159			refs[row.line.Key] = row.ref
160		}
161	}
162	recent = append(recent, localRows...)
163	for _, remote := range remotes {
164		for _, row := range remote.rows {
165			if row.line.Kind == protocol.PickerLineHost && row.hasRef {
166				refs[row.line.Key] = row.ref
167			}
168			if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" {
169				row.line.Detail = "@" + remote.origin
170			} else if row.line.Kind == protocol.PickerLineSession {
171				row.line.Detail = "@" + remote.origin + " · " + row.line.Detail
172			}
173			recent = append(recent, row)
174		}
175	}
176	stoppedRows := make([]pickerRow, 0)
177	for _, block := range localStopped {
178		stoppedRows = append(stoppedRows, block.rows...)
179	}
180	recent = append(recent, stoppedRows...)
181
182	localRows = append(localRows, stoppedRows...)
183	if hasLocal && len(localRows) != 0 {
184		grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Status: localStatus, Dim: true}})
185		grouped = append(grouped, localRows...)
186	}
187	for _, remote := range remotes {
188		grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Status: remote.status, Dim: true}})
189		grouped = append(grouped, remote.rows...)
190	}
191
192	c.moveRetiredLocked(refs)
193	c.refs = refs
194	c.lines, c.cursor = c.pickerProjectionLocked(grouped)
195	c.recent, c.recentCursor = c.pickerProjectionLocked(recent)
196}
197
198// sessionBlockLocked projects one session into its header and tab rows.
199func (c *pickerCatalogue) sessionBlockLocked(observation ports.BrokerDaemonObservation, session catalogue.RemoteCatalogSession, fresh bool, refs map[string]pickerSelectionRef) pickerSessionBlock {
200	compatible := pickerObservationCompatible(observation)
201	available := observation.Availability == domain.RemoteAvailabilityReachable
202	eligible := compatible && available && fresh
203	// Admission is deliberately more permissive than eligibility: a stale or
204	// not-yet-observed daemon is still selectable, because only the
205	// destination revalidates the exact identity and only it may start a
206	// stopped target under the resolved policy. A known incompatibility and a
207	// broken session refuse immediately; a remote host known to be failing
208	// refuses at resolution with a notice instead of a slow attempt.
209	admissible := !pickerObservationKnownIncompatible(observation)
210	selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken
211	stopped := session.State == catalogue.RemoteCatalogSessionDown
212	reason := pickerObservationReason(observation, fresh)
213	if reason == "" {
214		reason = session.Reason
215	}
216	ref := pickerSelectionRef{
217		kind:         pickerSelectionExact,
218		epoch:        c.snapshot.Epoch,
219		local:        observation.Local,
220		endpoint:     observation.Endpoint,
221		registration: observation.Registration,
222		lifecycle:    session.LifecycleID,
223		name:         session.Name,
224	}
225	actions := pickerRowActions(selectable, observation.Local)
226	attention := false
227	for _, tab := range session.Tabs {
228		attention = attention || tab.Attention
229	}
230	headerKey := pickerSessionRowKey(ref)
231	refs[headerKey] = ref
232	header := pickerRow{
233		line: protocol.PickerLine{
234			Key:          headerKey,
235			Kind:         protocol.PickerLineSession,
236			Label:        session.Name,
237			Detail:       pickerSessionDetail(session),
238			Status:       pickerSessionStatus(session),
239			StatusDetail: reason,
240			Attention:    attention,
241			Stopped:      stopped,
242			// Dim keeps the informational eligibility signal (fresh,
243			// compatible, reachable) and the hard non-committable state.
244			Dim:       !eligible || !selectable,
245			Ephemeral: session.Ephemeral,
246		},
247		ref:    ref,
248		hasRef: true,
249	}
250	block := pickerSessionBlock{lastUsedSeq: session.LastUsedSeq, name: session.Name, lifecycle: session.LifecycleID}
251	if len(session.Tabs) == 0 {
252		// Without tab metadata the session is its own destination.
253		header.line.Focusable = true
254		header.line.Actions = actions
255		header.defaultCursor = true
256		block.rows = append(block.rows, header)
257		return block
258	}
259	// A session with tabs names a group: its tab rows are the destinations.
260	block.rows = append(block.rows, header)
261	active := pickerActiveTabIndex(session)
262	for i, tab := range session.Tabs {
263		tabRef := ref
264		tabRef.tab = pickerTabRef{present: true, id: domain.TabStableID(tab.ID), index: i, name: tab.Name, count: len(session.Tabs)}
265		key := pickerTabRowKey(tabRef)
266		refs[key] = tabRef
267		tabActions := actions
268		if !stopped && tab.ID == "" {
269			// A live tab without a stable identity cannot be targeted exactly.
270			tabActions &^= protocol.PickerCanNavigate
271		}
272		label := tab.Name
273		if label == "" {
274			label = strconv.Itoa(int(tab.Index) + 1)
275		}
276		block.rows = append(block.rows, pickerRow{
277			line: protocol.PickerLine{
278				Key:          key,
279				Kind:         protocol.PickerLineTab,
280				Label:        label,
281				Detail:       tab.Detail,
282				StatusDetail: reason,
283				Attention:    tab.Attention,
284				Stopped:      stopped,
285				Dim:          header.line.Dim,
286				Focusable:    true,
287				Actions:      tabActions,
288				Ephemeral:    session.Ephemeral,
289			},
290			ref:           tabRef,
291			hasRef:        true,
292			defaultCursor: i == active,
293		})
294	}
295	return block
296}
297
298// pickerRowActions maps eligibility to the actions a row authorises. Kill is
299// offered on local rows only, exactly as on main; the daemon revalidates the
300// name, and a stopped row deletes its history record.
301func pickerRowActions(selectable, local bool) protocol.PickerLineActions {
302	var actions protocol.PickerLineActions
303	if selectable {
304		actions |= protocol.PickerCanNavigate
305	}
306	if local {
307		actions |= protocol.PickerCanKill
308	}
309	return actions
310}
311
312// pickerActiveTabIndex is the session's active tab, or its first tab.
313func pickerActiveTabIndex(session catalogue.RemoteCatalogSession) int {
314	for i, tab := range session.Tabs {
315		if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
316			return i
317		}
318	}
319	return 0
320}
321
322// sortPickerBlocks orders sessions by recency, then name, then lifecycle.
323func sortPickerBlocks(blocks []pickerSessionBlock) {
324	sort.SliceStable(blocks, func(i, j int) bool {
325		if blocks[i].lastUsedSeq != blocks[j].lastUsedSeq {
326			return blocks[i].lastUsedSeq > blocks[j].lastUsedSeq
327		}
328		if blocks[i].name != blocks[j].name {
329			return blocks[i].name < blocks[j].name
330		}
331		return blocks[i].lifecycle.String() < blocks[j].lifecycle.String()
332	})
333}
334
335// pickerProjectionLocked flattens one projection and computes its cursor:
336// the current attachment's tab, else its session, else the first default row
337// (the most recent session's active tab), else the first actionable row.
338func (c *pickerCatalogue) pickerProjectionLocked(rows []pickerRow) ([]protocol.PickerLine, protocol.PickerCursor) {
339	lines := make([]protocol.PickerLine, len(rows))
340	for i := range rows {
341		lines[i] = rows[i].line
342	}
343	if cursor, ok := c.currentCursorLocked(rows); ok {
344		return lines, cursor
345	}
346	for i, row := range rows {
347		if row.defaultCursor && row.line.Focusable && row.line.Actions&protocol.PickerCanNavigate != 0 {
348			return lines, protocol.PickerCursor{Key: row.line.Key, Index: i}
349		}
350	}
351	return lines, pickerCatalogueCursor(lines)
352}
353
354func (c *pickerCatalogue) currentCursorLocked(rows []pickerRow) (protocol.PickerCursor, bool) {
355	current := c.current
356	if !current.known {
357		return protocol.PickerCursor{}, false
358	}
359	matches := func(ref pickerSelectionRef) bool {
360		if ref.lifecycle != current.lifecycle || ref.local != current.local {
361			return false
362		}
363		return ref.local || ref.endpoint == current.endpoint
364	}
365	// Exact tab first, then the session's own row (no tab metadata), then its
366	// active tab.
367	session, active := -1, -1
368	for i, row := range rows {
369		if !row.hasRef || !row.line.Focusable || !matches(row.ref) {
370			continue
371		}
372		if !row.ref.tab.present {
373			session = i
374			continue
375		}
376		if current.tab != "" && row.ref.tab.id == current.tab {
377			return protocol.PickerCursor{Key: row.line.Key, Index: i}, true
378		}
379		if row.defaultCursor && active < 0 {
380			active = i
381		}
382	}
383	for _, index := range []int{session, active} {
384		if index >= 0 {
385			return protocol.PickerCursor{Key: rows[index].line.Key, Index: index}, true
386		}
387	}
388	return protocol.PickerCursor{}, false
389}
390
391// pickerTabRowKey is the stable, opaque key of one tab row: the session
392// identity plus the tab's stable ID, or its ordinal and raw name when the
393// catalogue carries no ID.
394func pickerTabRowKey(ref pickerSelectionRef) string {
395	tab := string(ref.tab.id)
396	if tab == "" {
397		tab = fmt.Sprintf("#%d:%s", ref.tab.index, ref.tab.name)
398	}
399	lifecycle := append(append([]byte(nil), ref.lifecycle[:]...), 0)
400	lifecycle = append(lifecycle, tab...)
401	return pickerRowKey("t", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.registration.Generation, lifecycle)
402}
403
404// attachmentTab is the client-owned tab selection an attachment Hello
405// carries: a stable tab to prefer on a live or stopped-local session, or an
406// exact stopped-remote selector. Its zero value attaches at session level.
407type attachmentTab struct {
408	preferred domain.TabStableID
409	stopped   *protocol.SessionAttachTarget
410}
411
412// pickerResolveTab turns a tab ref into the attachment tab it names, checked
413// against the latest session metadata. A tab that left the session is gone.
414func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSession) (attachmentTab, error) {
415	if !ref.tab.present {
416		return attachmentTab{}, nil
417	}
418	gone := pickerCatalogueError{Code: pickerCatalogueGone, Text: "tab is no longer in the catalogue"}
419	stopped := session.State == catalogue.RemoteCatalogSessionDown
420	if ref.tab.id != "" {
421		found := false
422		for _, tab := range session.Tabs {
423			if domain.TabStableID(tab.ID) == ref.tab.id {
424				found = true
425				break
426			}
427		}
428		if !found {
429			return attachmentTab{}, gone
430		}
431		if stopped && !ref.local {
432			target := protocol.SessionAttachTarget{LifecycleID: session.LifecycleID, SessionName: session.Name, TabID: ref.tab.id, TabIndex: protocol.NoTabIndex, Stopped: true}
433			if target.Validate() != nil {
434				return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"}
435			}
436			return attachmentTab{stopped: &target}, nil
437		}
438		return attachmentTab{preferred: ref.tab.id}, nil
439	}
440	if ref.tab.index >= len(session.Tabs) || session.Tabs[ref.tab.index].Name != ref.tab.name || len(session.Tabs) != ref.tab.count {
441		return attachmentTab{}, gone
442	}
443	if !stopped {
444		return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "tab has no stable identity"}
445	}
446	if ref.local || ref.tab.count > int(^uint16(0)) {
447		// A local restore without a stable tab falls back to the session.
448		return attachmentTab{}, nil
449	}
450	target := protocol.SessionAttachTarget{
451		LifecycleID: session.LifecycleID, SessionName: session.Name, Stopped: true,
452		TabIndex: int32(ref.tab.index), TabRawName: ref.tab.name, TabExpectedCount: uint16(ref.tab.count),
453	}
454	if target.Validate() != nil {
455		return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"}
456	}
457	return attachmentTab{stopped: &target}, nil
458}
459
460// pickerObservationFailing reports a host the broker observed failing: its
461// last probe could not reach it, authenticate, or read its catalogue. An
462// incompatible host is reachable and handled apart.
463func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {
464	return observation.Availability.Failing() && observation.Availability != domain.RemoteAvailabilityIncompatible
465}
466
467// pickerUnavailableNotice is main's remote-unavailable notice: the session
468// and its origin, then the reason in words.
469func pickerUnavailableNotice(ref pickerSelectionRef, observation ports.BrokerDaemonObservation, reason string) string {
470	message := "Remote session unavailable"
471	origin := pickerOriginLabel(observation)
472	if ref.name != "" {
473		message += ": " + ref.name + "@" + origin
474	} else {
475		message += ": " + origin
476	}
477	return message + " — " + pickerReasonText(reason)
478}
479
480// pickerReasonText renders one domain.RemoteReason in words (main's
481// remotePickerReasonText).
482func pickerReasonText(reason string) string {
483	switch reason {
484	case domain.RemoteReasonCatalogStale:
485		return "catalog stale"
486	case domain.RemoteReasonHostUnreachable:
487		return "host unreachable"
488	case domain.RemoteReasonVersionMismatch:
489		return "version mismatch"
490	case domain.RemoteReasonSessionStopped:
491		return "session stopped"
492	case domain.RemoteReasonSessionBroken:
493		return "session broken"
494	case domain.RemoteReasonMalformed:
495		return "catalog malformed"
496	case domain.RemoteReasonAuthFailure:
497		return "authentication failed"
498	case domain.RemoteReasonNoDaemon:
499		return "no daemon — Enter to create a session"
500	case domain.RemoteReasonRefreshing:
501		return "refreshing"
502	case domain.RemoteReasonIdentityChanged:
503		return "session identity changed"
504	default:
505		return "unavailable"
506	}
507}
508
509// pickerKillTarget is one resolved kill: the exact local control route and
510// the session name the daemon kills (live) or deletes from history
511// (stopped).
512type pickerKillTarget struct {
513	route   BrokerOperationRoute
514	name    string
515	stopped bool
516}
517
518// ResolveKill revalidates one row key for destruction against the latest
519// publication. Only local sessions are killable, exactly as on main; the row
520// must still name the same lifecycle and name.
521func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) {
522	if c == nil {
523		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
524	}
525	c.mu.Lock()
526	defer c.mu.Unlock()
527	ref, ok := c.refs[key]
528	if !ok {
529		ref, ok = c.retired[key]
530	}
531	if !ok || ref.kind != pickerSelectionExact {
532		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
533	}
534	if !ref.local {
535		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote sessions cannot be killed from the picker"}
536	}
537	epoch := c.snapshot.Epoch
538	if epoch == 0 {
539		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}
540	}
541	if ref.epoch != 0 && ref.epoch != epoch {
542		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}
543	}
544	authority := c.authorityForRefLocked(ref)
545	if !authority.found {
546		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
547	}
548	session, found := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
549	if !found {
550		if pickerSessionReplaced(authority.observation.Sessions, ref) {
551			return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
552		}
553		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}
554	}
555	if session.Name != ref.name {
556		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
557	}
558	if domain.ValidateSessionName(session.Name) != nil {
559		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}
560	}
561	return pickerKillTarget{
562		route:   BrokerOperationRoute{Epoch: epoch, Destination: ports.BrokerEndpointFence{Local: true}},
563		name:    session.Name,
564		stopped: session.State != catalogue.RemoteCatalogSessionUp,
565	}, nil
566}
567
568// pickerHostHealth is one remote host's notice-relevant state.
569type pickerHostHealth struct {
570	key         string
571	observation ports.BrokerDaemonObservation
572	health      domain.RemoteHealth
573}
574
575// remoteHealth reports every remote host's state, in catalogue order.
576// Transition policy lives in domain.RemoteHealthTransition.
577func (c *pickerCatalogue) remoteHealth() []pickerHostHealth {
578	if c == nil {
579		return nil
580	}
581	c.mu.Lock()
582	defer c.mu.Unlock()
583	hosts := make([]pickerHostHealth, 0, len(c.order))
584	for _, key := range c.order {
585		host, ok := c.hosts[key]
586		if !ok || host.observation.Local {
587			continue
588		}
589		observation := host.observation
590		hosts = append(hosts, pickerHostHealth{key: key, observation: observation, health: domain.RemoteHealth{
591			Availability:    observation.Availability,
592			VersionMismatch: pickerObservationVersionMismatch(observation),
593			Episode:         observation.FailureEpisode,
594		}})
595	}
596	return hosts
597}
598
599// pickerHostHealthNotice renders one host event as a toast scoped to the host,
600// so a recovery replaces the visible failure.
601func pickerHostHealthNotice(host pickerHostHealth, event domain.RemoteHealthEvent) domain.Notification {
602	n := domain.Notification{Code: domain.NoticeRemoteObservation, Scope: host.key}
603	origin := pickerOriginLabel(host.observation)
604	if event == domain.RemoteHealthRecovered {
605		n.Severity = domain.NoticeInfo
606		n.Message = "Remote host reconnected: " + origin
607		return n
608	}
609	n.Severity = domain.NoticeError
610	if host.observation.Availability == domain.RemoteAvailabilityNoDaemon {
611		n.Severity = domain.NoticeWarn
612	}
613	n.Message = "Remote check failed: " + origin + " — " + pickerHostFailureText(host.observation, host.health.VersionMismatch)
614	return n
615}
616
617func pickerHostFailureText(observation ports.BrokerDaemonObservation, versionMismatch bool) string {
618	if observation.Availability == domain.RemoteAvailabilityIncompatible || versionMismatch {
619		return "remote vev version is incompatible"
620	}
621	switch observation.LastFailure.Kind {
622	case domain.RemoteFailureAuthentication:
623		return "SSH authentication failed; verify non-interactive SSH access"
624	case domain.RemoteFailureTrust:
625		return "SSH host verification failed; verify the host key policy"
626	case domain.RemoteFailureIncompatible:
627		return "remote vev version is incompatible"
628	case domain.RemoteFailureInvalidResponse:
629		return "remote catalog response is invalid"
630	case domain.RemoteFailureTimeout:
631		return "SSH timed out"
632	}
633	switch observation.Availability {
634	case domain.RemoteAvailabilityAuthFailed:
635		return "SSH authentication failed; verify non-interactive SSH access"
636	case domain.RemoteAvailabilityInvalidResponse:
637		return "remote catalog response is invalid"
638	case domain.RemoteAvailabilityNoDaemon:
639		return "no vev daemon is running"
640	default:
641		return "SSH connection failed; verify SSH access"
642	}
643}

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