picker_lines.go RawHistoryFolder

1package daemon
2
3import (
4	"crypto/sha256"
5	"encoding/base64"
6	"math"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/protocol"
10	"github.com/bnema/vev/internal/usecase/picker"
11)
12
13// This file owns picker destination eligibility and the structured lines the
14// serving daemon publishes. The daemon knows lifecycles, tab identities, and
15// remote activation; the presenting client only renders. Keys are opaque and
16// resolve back to picker.Target values inside the daemon.
17
18// pickerRemoteActivation is the picker action authorized by the current remote
19// catalog snapshot. It is presentation state only; the structured remote
20// target remains the exact route and is revalidated at commit time.
21type pickerRemoteActivation uint8
22
23const (
24	pickerRemoteUnavailable pickerRemoteActivation = iota
25	pickerRemoteAttach
26	pickerRemoteRestart
27)
28
29// pickerSessionView is the daemon-private projection of one session or remote
30// catalogue row used to compute destination eligibility.
31type pickerSessionView struct {
32	ID                domain.SessionID
33	Section           string
34	Incarnation       domain.IncarnationID
35	Name              string
36	TargetName        string
37	Tabs              []pickerTabEntry
38	Active            int
39	Stopped           bool
40	ExpectedCreatedAt *int64
41	RemoteKey         *domain.RemoteSessionKey
42	HideRemoteOrigin  bool
43	// RemoteTarget is the structured route/lifecycle identity for picker rows.
44	// It is never reconstructed from Name or a rendered label.
45	RemoteTarget *domain.RemoteSessionTarget
46	// RemoteHost marks remote host status rows that have no session key.
47	RemoteHost       string
48	RemoteDetail     string
49	RemoteReason     string
50	RemoteActivation pickerRemoteActivation
51	// CannotAcceptMoves reports whether this session cannot receive a moved tab
52	// or pane. False for ordinary local (and stopped) sessions; true for
53	// restricted remote rows.
54	CannotAcceptMoves bool
55}
56
57// pickerTabEntry is one tab row; Name is drawn emphasized, Detail muted.
58type pickerTabEntry struct {
59	TabID     domain.TabStableID // stable tab identity; independent of its current index
60	Name      string             // tab display name
61	RawName   string             // unformatted name used by exact remote selectors
62	Detail    string             // " (paneTitle)" or "", drawn muted
63	Attention bool               // draw the attention marker right after Name, before Detail
64}
65
66// pickerSourceFilter names the row the attachment currently displays.
67type pickerSourceFilter struct {
68	Session     domain.SessionID
69	Incarnation domain.IncarnationID
70	TabID       domain.TabStableID
71	RemoteKey   *domain.RemoteSessionKey
72}
73
74// pickerLine is one published line plus its source-private target. cursor
75// marks the row the attachment currently displays; defaultCursor marks the row
76// a fresh interaction starts on when nothing matches the current view.
77type pickerLine struct {
78	line          protocol.PickerLine
79	target        picker.Target
80	cursor        bool
81	defaultCursor bool
82}
83
84// pickerLineSet is one published source: ordered lines, the opaque key map,
85// and the cursor hint.
86type pickerLineSet struct {
87	lines  []protocol.PickerLine
88	keys   map[string]picker.Target
89	cursor protocol.PickerCursor
90}
91
92// pickerLineSetFor projects one eligibility view set into structured lines in
93// view order (never canonicalized: order is semantically significant).
94func pickerLineSetFor(views []pickerSessionView, intent protocol.PickerIntent, source, current pickerSourceFilter) pickerLineSet {
95	set := pickerLineSet{keys: make(map[string]picker.Target)}
96	entries := make([]pickerLine, 0)
97	for _, view := range views {
98		if view.Section != "" {
99			entries = append(entries, pickerLine{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: view.Section, Dim: true}})
100		}
101		entries = append(entries, pickerSessionLines(view, intent, source, current)...)
102	}
103	for i, entry := range entries {
104		set.lines = append(set.lines, entry.line)
105		if entry.line.Key == "" {
106			continue
107		}
108		if _, dup := set.keys[entry.line.Key]; !dup {
109			set.keys[entry.line.Key] = entry.target
110		}
111		if entry.cursor {
112			set.cursor = protocol.PickerCursor{Key: entry.line.Key, Index: i}
113		}
114	}
115	if set.cursor.Key == "" {
116		// Prefer the active tab of a session over the first published row.
117		for i, entry := range entries {
118			if !entry.defaultCursor {
119				continue
120			}
121			set.cursor = protocol.PickerCursor{Key: entry.line.Key, Index: i}
122			break
123		}
124	}
125	if set.cursor.Key == "" && len(set.lines) > 0 {
126		// Last resort: the first row the source authorised as a cursor
127		// destination. A row kept for inspection or skipped by the source is
128		// never hinted.
129		for i, line := range set.lines {
130			if !line.Focusable {
131				continue
132			}
133			if target, ok := set.keys[line.Key]; ok && !target.Stopped {
134				set.cursor = protocol.PickerCursor{Key: line.Key, Index: i}
135				break
136			}
137		}
138	}
139	return set
140}
141
142// pickerRowKey derives the opaque key of one picker row. Local rows are keyed by
143// incarnation and name, which stays readable for the common case. A remote row
144// also carries its owning endpoint: the same machine can be registered under
145// more than one target (a pinned alias and one learned by attach), and two
146// endpoints may legitimately expose the same session lifecycle and name, so an
147// unqualified key would collide and the whole snapshot would be rejected. The
148// digest keeps the key unique per endpoint while staying inside the bounded key
149// length.
150func pickerRowKey(view pickerSessionView, name string) string {
151	if view.RemoteKey == nil {
152		return pickerClientKey(view.Incarnation, name)
153	}
154	digest := sha256.Sum256([]byte(view.RemoteKey.Host + "\x00" + string(view.Incarnation[:]) + "\x00" + name))
155	return "remote:" + base64.RawURLEncoding.EncodeToString(digest[:16])
156}
157
158// pickerSessionLines is the sole owner of intent-specific destination
159// eligibility. The daemon supplies canonical lifecycle/tab snapshots without
160// prefiltering.
161func pickerSessionLines(view pickerSessionView, intent protocol.PickerIntent, source, current pickerSourceFilter) []pickerLine {
162	stopped := view.Stopped
163	if view.RemoteTarget != nil {
164		stopped = view.RemoteTarget.Stopped
165	}
166	if intent != protocol.PickerIntentNavigation && stopped {
167		return nil
168	}
169	if intent == protocol.PickerIntentMoveTab && pickerSourceMatchesSession(source, view) {
170		return nil
171	}
172	if intent == protocol.PickerIntentMoveTab && len(view.Tabs) == 0 && !view.CannotAcceptMoves {
173		return nil
174	}
175
176	targetName := view.TargetName
177	if intent == protocol.PickerIntentNavigation && !stopped && view.ExpectedCreatedAt == nil {
178		targetName = ""
179	} else if targetName == "" {
180		targetName = view.Name
181	}
182	expectedCreatedAt, hasExpectedCreatedAt := int64Value(view.ExpectedCreatedAt)
183	common := picker.Target{
184		Session: view.ID, Incarnation: view.Incarnation, Name: targetName,
185		RemoteKey: view.RemoteKey, RemoteTarget: view.RemoteTarget, RemoteHost: view.RemoteHost,
186		UnavailableReason: view.RemoteReason, TabIndex: -1, Stopped: stopped,
187		ExpectedCreatedAt: optionalInt64(expectedCreatedAt, hasExpectedCreatedAt),
188	}
189	headerLine := pickerLine{
190		line: protocol.PickerLine{
191			Key: pickerRowKey(view, common.Name), Kind: pickerLineKindForView(view),
192			Label: view.Name, Detail: pickerStatusDetailFor(view, stopped), Stopped: stopped,
193			Status: pickerStatusFor(view, stopped), StatusDetail: view.RemoteDetail, Ephemeral: pickerEphemeral(view),
194		},
195		target: common,
196	}
197	if view.RemoteKey != nil {
198		headerLine.line.Label = view.RemoteKey.Name
199		if !view.HideRemoteOrigin {
200			display := view.RemoteKey.Display()
201			headerLine.line.Detail = display[len(view.RemoteKey.Name):]
202		}
203	}
204	header := headerLine
205	headerSelectable, headerFocusable, headerDim := pickerHeaderEligibility(view, intent, stopped)
206	header.line.Actions = pickerActionsFor(view, intent, headerSelectable)
207	header.line.Focusable = headerFocusable
208	header.line.Dim = headerDim
209	if headerFocusable && pickerSelectionMatches(header, current, intent) {
210		header.cursor = true
211	}
212	if !headerFocusable {
213		header.line.Actions = 0
214		header.target = picker.Target{}
215	}
216
217	lines := []pickerLine{header}
218	for i, tab := range view.Tabs {
219		if intent == protocol.PickerIntentMovePane && pickerSourceMatchesTab(source, view, tab) {
220			continue
221		}
222		tabTarget := common
223		tabTarget.TabID, tabTarget.TabIndex = tab.TabID, i
224		// Each tab row carries its own structured remote target: a stopped row
225		// restores the tab it names, a live row binds its stable tab ID.
226		if resolved, ok := pickerTabRemoteTarget(view, tab, i); ok {
227			tabTarget.RemoteTarget = &resolved
228		}
229		tabLine := pickerLine{
230			line: protocol.PickerLine{
231				Key:   pickerRowKey(view, common.Name+"#"+string(tab.TabID)),
232				Kind:  protocol.PickerLineTab,
233				Label: tab.Name, Detail: tab.Detail, Attention: tab.Attention,
234				Stopped: stopped, StatusDetail: view.RemoteDetail, Ephemeral: pickerEphemeral(view),
235			},
236			target: tabTarget,
237		}
238		selectable, focusable, dim := pickerTabEligibility(view, intent, tab, i)
239		tabLine.line.Actions = pickerActionsFor(view, intent, selectable)
240		tabLine.line.Focusable = focusable
241		tabLine.line.Dim = dim
242		if !focusable {
243			tabLine.line.Actions = 0
244			tabLine.target = picker.Target{}
245		}
246		if focusable && pickerSelectionMatches(tabLine, current, intent) {
247			tabLine.cursor = true
248		}
249		// A fresh navigation starts on the session's active tab, exactly like
250		// the daemon-owned model did.
251		if focusable && intent == protocol.PickerIntentNavigation && i == normalizedActiveTab(view) {
252			tabLine.defaultCursor = true
253		}
254		lines = append(lines, tabLine)
255	}
256	if intent == protocol.PickerIntentMovePane && len(lines) == 1 && !view.CannotAcceptMoves {
257		return nil
258	}
259	return lines
260}
261
262func pickerHeaderEligibility(view pickerSessionView, intent protocol.PickerIntent, stopped bool) (selectable, focusable, dim bool) {
263	if intent == protocol.PickerIntentMoveTab {
264		selectable, focusable = true, true
265	} else if view.ID != "" && pickerViewHasTarget(view) {
266		selectable, focusable = false, false
267	}
268	if intent == protocol.PickerIntentNavigation && stopped && len(view.Tabs) == 0 && pickerViewHasTarget(view) {
269		selectable, focusable = true, true
270	}
271	if pickerViewIsRemote(view) {
272		if view.RemoteTarget != nil {
273			selectable = intent == protocol.PickerIntentNavigation && stopped && len(view.Tabs) == 0 && pickerRemoteActivatable(view) && view.RemoteTarget.Validate() == nil
274			focusable = intent == protocol.PickerIntentNavigation && len(view.Tabs) == 0
275		} else {
276			selectable = false
277			focusable = intent == protocol.PickerIntentNavigation && view.RemoteHost != ""
278		}
279		dim = view.RemoteActivation == pickerRemoteUnavailable
280	}
281	if intent != protocol.PickerIntentNavigation && view.CannotAcceptMoves {
282		selectable, focusable, dim = false, false, true
283	}
284	return selectable, focusable, dim
285}
286
287func pickerTabEligibility(view pickerSessionView, intent protocol.PickerIntent, tab pickerTabEntry, index int) (selectable, focusable, dim bool) {
288	selectable, focusable = intent != protocol.PickerIntentMoveTab, intent != protocol.PickerIntentMoveTab
289	if pickerViewIsRemote(view) {
290		if view.RemoteTarget == nil {
291			focusable, selectable = false, false
292		} else {
293			resolved, resolvable := pickerTabRemoteTarget(view, tab, index)
294			focusable = true
295			selectable = resolvable && resolved.Validate() == nil && intent == protocol.PickerIntentNavigation && pickerRemoteActivatable(view)
296		}
297		dim = view.RemoteActivation == pickerRemoteUnavailable
298	}
299	if intent != protocol.PickerIntentNavigation && view.CannotAcceptMoves {
300		selectable, focusable, dim = false, false, true
301	}
302	return selectable, focusable, dim
303}
304
305// pickerTabRemoteTarget resolves the per-tab copy of a remote session's exact
306// route. A stopped session targets the tab the row names (stable ID when known,
307// otherwise its ordinal and raw name); a live session binds the tab's stable
308// ID. The second result is false when no exact target can be expressed.
309func pickerTabRemoteTarget(view pickerSessionView, tab pickerTabEntry, index int) (domain.RemoteSessionTarget, bool) {
310	if view.RemoteTarget == nil {
311		return domain.RemoteSessionTarget{}, false
312	}
313	target := *view.RemoteTarget
314	if target.Stopped {
315		target.LiveTabID = ""
316		if tab.TabID != "" {
317			target.StoppedTab = domain.NewStableTabSelector(tab.TabID)
318			return target, true
319		}
320		if target.StoppedTab == (domain.TabSelector{}) {
321			return target, true
322		}
323		selector, ok := remoteStoppedOrdinalSelector(index, tab.RawName, len(view.Tabs))
324		if !ok {
325			return domain.RemoteSessionTarget{}, false
326		}
327		target.StoppedTab = selector
328		return target, true
329	}
330	target.StoppedTab = domain.TabSelector{}
331	target.LiveTabID = tab.TabID
332	return target, true
333}
334
335// pickerActionsFor maps eligibility to the actions the source authorises. The
336// daemon revalidates key and lifecycle at commit, so these bits are a
337// presentation contract, never authority.
338// normalizedActiveTab clamps a session's active tab index into its tab range.
339func normalizedActiveTab(view pickerSessionView) int {
340	if view.Active < 0 || view.Active >= len(view.Tabs) {
341		return 0
342	}
343	return view.Active
344}
345
346func pickerActionsFor(view pickerSessionView, intent protocol.PickerIntent, selectable bool) protocol.PickerLineActions {
347	if !selectable {
348		return 0
349	}
350	if intent != protocol.PickerIntentNavigation {
351		return protocol.PickerCanMove
352	}
353	actions := protocol.PickerCanNavigate
354	if !pickerViewIsRemote(view) && pickerViewHasTarget(view) {
355		actions |= protocol.PickerCanKill
356	}
357	return actions
358}
359
360func pickerViewHasTarget(view pickerSessionView) bool {
361	return view.ID != "" && view.Name != ""
362}
363
364// pickerLineKindForView names one published header row's shape. A remote host
365// status row carries no session key and no structured route: it is published
366// for inspection, so it is a host row rather than a session row.
367func pickerLineKindForView(view pickerSessionView) protocol.PickerLineKind {
368	if pickerViewHostStatus(view) {
369		return protocol.PickerLineHost
370	}
371	return protocol.PickerLineSession
372}
373
374// pickerViewHostStatus reports a catalogue host line: an endpoint with no
375// session identity behind it.
376func pickerViewHostStatus(view pickerSessionView) bool {
377	return view.RemoteHost != "" && view.RemoteKey == nil && view.RemoteTarget == nil
378}
379
380func pickerViewIsRemote(view pickerSessionView) bool {
381	return view.RemoteKey != nil || view.RemoteTarget != nil || view.RemoteHost != ""
382}
383
384func pickerEphemeral(view pickerSessionView) bool {
385	return view.RemoteKey != nil || view.RemoteTarget != nil || view.RemoteHost != ""
386}
387
388func pickerRemoteActivatable(view pickerSessionView) bool {
389	return view.RemoteActivation == pickerRemoteAttach || view.RemoteActivation == pickerRemoteRestart
390}
391
392func pickerStatusFor(view pickerSessionView, stopped bool) protocol.PickerLineStatus {
393	remote := pickerViewIsRemote(view)
394	if remote {
395		switch view.RemoteReason {
396		case domain.RemoteReasonNoDaemon:
397			return protocol.PickerLineStatusNoDaemon
398		case domain.RemoteReasonHostUnreachable:
399			return protocol.PickerLineStatusDown
400		case domain.RemoteReasonVersionMismatch:
401			return protocol.PickerLineStatusVersion
402		case domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale:
403			return protocol.PickerLineStatusStale
404		case domain.RemoteReasonMalformed, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonAuthFailure:
405			return protocol.PickerLineStatusError
406		}
407	}
408	if stopped {
409		return protocol.PickerLineStatusStopped
410	}
411	if !remote {
412		return protocol.PickerLineStatusNone
413	}
414	if view.RemoteActivation == pickerRemoteAttach {
415		return protocol.PickerLineStatusUp
416	}
417	return protocol.PickerLineStatusError
418}
419
420// pickerStatusDetailFor fills the muted detail of a header line. Statuses
421// already represented by a badge are not repeated as adjacent text.
422func pickerStatusDetailFor(view pickerSessionView, _ bool) string {
423	if view.RemoteReason != "" {
424		return view.RemoteReason
425	}
426	return ""
427}
428
429func pickerSelectionMatches(line pickerLine, current pickerSourceFilter, intent protocol.PickerIntent) bool {
430	target := line.target
431	if target.Session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && target.Incarnation != current.Incarnation) {
432		return false
433	}
434	if target.RemoteKey != nil && line.line.Kind == protocol.PickerLineSession {
435		return line.line.Actions != 0 && (current.RemoteKey == nil || *target.RemoteKey == *current.RemoteKey)
436	}
437	if intent == protocol.PickerIntentMoveTab {
438		return line.line.Kind == protocol.PickerLineSession
439	}
440	// A stopped session with no retained tab metadata uses its selectable
441	// header as the default restart target and matches on exact session
442	// identity.
443	if current.TabID == "" && target.Stopped {
444		return line.line.Kind == protocol.PickerLineSession && line.line.Actions != 0
445	}
446	if line.line.Kind != protocol.PickerLineTab {
447		return false
448	}
449	return target.TabID == current.TabID
450}
451
452func pickerSourceMatchesSession(source pickerSourceFilter, view pickerSessionView) bool {
453	if source.Session != view.ID {
454		return false
455	}
456	return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == view.Incarnation
457}
458
459func pickerSourceMatchesTab(source pickerSourceFilter, view pickerSessionView, tab pickerTabEntry) bool {
460	return pickerSourceMatchesSession(source, view) && source.TabID == tab.TabID
461}
462
463func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {
464	if tabCount > math.MaxUint16 || index < 0 || index >= tabCount {
465		return domain.TabSelector{}, false
466	}
467	return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true
468}
469
470func int64Value(value *int64) (int64, bool) {
471	if value == nil {
472		return 0, false
473	}
474	return *value, true
475}
476
477func optionalInt64(value int64, present bool) *int64 {
478	if !present {
479		return nil
480	}
481	copyValue := value
482	return &copyValue
483}

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