okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffeat(api)!: migrate daemon and clients to protocol 361c43ca

main
11.6 KiB397 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"cmp"
6	"path"
7	"slices"
8	"strings"
9	"time"
10
11	"github.com/sahilm/fuzzy"
12)
13
14// folderRequest is how the daemon is asked about a typed folder: ~,
15// absolute paths and host: locations as they are (a bare host: is its
16// home), anything else under the current folder.
17func folderRequest(workspace, typed string) string {
18	if host, rest, remote := splitHost(typed); remote {
19		if rest != "/" {
20			rest = strings.TrimSuffix(rest, "/")
21		}
22		if rest != "" && rest[0] != '/' && rest[0] != '~' {
23			rest = "~/" + rest // as scp reads it: under the remote home
24		}
25		return host + ":" + cmp.Or(rest, "~")
26	}
27	if typed != "/" {
28		typed = strings.TrimSuffix(typed, "/")
29	}
30	if strings.HasPrefix(typed, "/") || typed == "~" || strings.HasPrefix(typed, "~/") {
31		return typed
32	}
33	return joinPlace(workspace, typed)
34}
35
36type queryKind int
37
38// folderQuery is what the typed text asks the picker for.
39type folderQuery struct {
40	// dir is the folder to list, as typed.
41	dir string
42	// segment filters the list: the text after dir's last slash, or all of
43	// it outside a listing.
44	segment string
45	kind    queryKind
46}
47
48// parseQuery reads typed text: a path lists its folder filtered by the
49// last segment, scp-style host:path lists that folder on the host (host:
50// and host:name list its home), and a word without / or : filters the
51// recent folders, unless it holds an @ and so can only be user@host.
52func parseQuery(q string) folderQuery {
53	if q == "~" {
54		return folderQuery{kind: queryListing, dir: "~"}
55	}
56	if host, rest, remote := splitHost(q); remote {
57		dir, segment := host+":~", rest
58		if rest == "~" {
59			segment = ""
60		}
61		if i := strings.LastIndex(rest, "/"); i >= 0 {
62			dir, segment = host+":"+rest[:i+1], rest[i+1:]
63		}
64		return folderQuery{kind: queryListing, dir: dir, segment: segment}
65	}
66	if i := strings.LastIndex(q, "/"); i >= 0 {
67		return folderQuery{kind: queryListing, dir: q[:i+1], segment: q[i+1:]}
68	}
69	if strings.Contains(q, "@") {
70		return folderQuery{kind: queryHosts, segment: q}
71	}
72	return folderQuery{kind: queryRecent, segment: q}
73}
74
75// bareHost is a [user@]host without its user.
76func bareHost(h string) string {
77	return h[strings.LastIndex(h, "@")+1:]
78}
79
80// hostRows are the hosts the text completes. A word only offers hosts it
81// starts, after the folders it matches, so a local filter reads as it
82// always did; with an @ every known host matches what follows it, and the
83// typed user goes with the completion.
84func hostRows(kind queryKind, text string, known []daemon.KnownHost) []folderRow {
85	user, name := "", text
86	if i := strings.LastIndex(text, "@"); kind == queryHosts && i >= 0 {
87		user, name = text[:i+1], text[i+1:]
88	}
89	names := make([]string, len(known))
90	for i, h := range known {
91		names[i] = bareHost(h.Host)
92	}
93	var matches []fuzzy.Match
94	switch {
95	case kind == queryHosts:
96		matches = fuzzyNames(name, names)
97	case name != "":
98		for i, n := range names {
99			if strings.HasPrefix(strings.ToLower(n), strings.ToLower(name)) || strings.HasPrefix(strings.ToLower(known[i].Label), strings.ToLower(name)) {
100				matches = append(matches, fuzzy.Match{Str: n, Index: i})
101			}
102		}
103	}
104	var rows []folderRow
105	for _, match := range matches {
106		h := known[match.Index]
107		target, label := h.Host, cmp.Or(h.Label, h.Host)
108		if user != "@" && user != "" {
109			target = user + bareHost(h.Host)
110			label = target
111		}
112		row := folderRow{path: label + ":", name: label, host: label, hostKey: target, source: h.Source, hostRow: true}
113		if shift := len(label) - len(match.Str); strings.HasSuffix(label, match.Str) {
114			for _, i := range match.MatchedIndexes {
115				row.matched = append(row.matched, i+shift)
116			}
117		}
118		rows = append(rows, row)
119	}
120	return rows
121}
122
123// frecency weighs each session in a folder by how recently it was used, so
124// a folder you work in daily outranks one you used often long ago: 4 within
125// the hour, halving past a day, a week and a month.
126func frecency(last *int64, now time.Time) float64 {
127	weight := 4.0
128	for _, within := range []time.Duration{time.Hour, 24 * time.Hour, 7 * 24 * time.Hour, 30 * 24 * time.Hour} {
129		if last != nil && now.Sub(time.Unix(*last, 0)) < within {
130			return weight
131		}
132		weight /= 2
133	}
134	return weight
135}
136
137type recentFolder struct {
138	last       *int64
139	path, host string
140	score      float64
141}
142
143// recentFolders are the distinct workspaces of sessions, most frecent
144// first, with the current one leading.
145func recentFolders(sessions []daemon.Session, current string, now time.Time) []recentFolder {
146	index := map[string]int{}
147	var out []recentFolder
148	for _, s := range sessions {
149		if s.Workspace == "" {
150			continue
151		}
152		i, ok := index[s.Workspace]
153		if !ok {
154			i, index[s.Workspace] = len(out), len(out)
155			out = append(out, recentFolder{path: s.Workspace, host: sessionHost(s)})
156		}
157		f := &out[i]
158		f.score += frecency(s.LastAssistantAt, now)
159		if s.LastAssistantAt != nil && (f.last == nil || *s.LastAssistantAt > *f.last) {
160			f.last = s.LastAssistantAt
161		}
162	}
163	if _, ok := index[current]; !ok && current != "" {
164		out = append(out, recentFolder{path: current})
165	}
166	slices.SortStableFunc(out, func(a, b recentFolder) int {
167		switch {
168		case a.path == current:
169			return -1
170		case b.path == current:
171			return 1
172		}
173		return cmp.Compare(b.score, a.score)
174	})
175	return out
176}
177
178// fuzzyNames matches segment against names, best first; an empty segment
179// keeps them all in order.
180func fuzzyNames(segment string, names []string) []fuzzy.Match {
181	if segment == "" {
182		all := make([]fuzzy.Match, len(names))
183		for i, n := range names {
184			all[i] = fuzzy.Match{Str: n, Index: i}
185		}
186		return all
187	}
188	return fuzzy.Find(segment, names)
189}
190
191func (m FolderPicker) workspace() string { return m.session.Workspace }
192
193// homed shortens p under its home to start with ~, led by its host's
194// label when it is remote.
195func (m FolderPicker) homed(p string) string {
196	host, _ := daemon.SplitLocation(p)
197	return placeText(p, m.labelOf(host), m.homes[m.canonical(host)])
198}
199
200// canonical is the [user@]host a typed host turned out to be, or the one
201// that reads as it.
202func (m FolderPicker) canonical(host string) string {
203	if c, ok := m.aliases[host]; ok {
204		return c
205	}
206	for c, label := range m.labels {
207		if label == host {
208			return c
209		}
210	}
211	return host
212}
213
214func (m FolderPicker) labelOf(host string) string {
215	return cmp.Or(m.labels[m.canonical(host)], m.labels[host], host)
216}
217
218// known are the hosts to complete: the daemon's recent ones, then hosts of
219// the sessions here in case the daemon has no list, then its ssh config.
220func (m FolderPicker) known() []daemon.KnownHost {
221	seen := map[string]bool{}
222	var out []daemon.KnownHost
223	add := func(h daemon.KnownHost) {
224		if h.Host != "" && !seen[h.Host] {
225			seen[h.Host] = true
226			out = append(out, h)
227		}
228	}
229	for _, h := range m.hosts {
230		if h.Source != "config" {
231			add(h)
232		}
233	}
234	for _, f := range recentFolders(m.sessions, "", time.Now()) {
235		if host, _ := daemon.SplitLocation(f.path); host != "" {
236			add(daemon.KnownHost{Host: host, Label: f.host, Source: "recent"})
237		}
238	}
239	for _, h := range m.hosts {
240		add(h)
241	}
242	return out
243}
244
245// probe is what is known of a host's reachability: its warm-up, else the
246// state the daemon's host list cached. Local folders have none.
247func (m FolderPicker) probe(host string) daemon.HostStatus {
248	if host == "" {
249		return daemon.HostStatus{}
250	}
251	if p, ok := m.probes[host]; ok {
252		return p
253	}
254	if p, ok := m.probes[m.canonical(host)]; ok {
255		return p
256	}
257	if i := slices.IndexFunc(m.hosts, func(h daemon.KnownHost) bool { return h.Host == m.canonical(host) }); i >= 0 {
258		return daemon.HostStatus{Host: host, State: m.hosts[i].State}
259	}
260	return daemon.HostStatus{}
261}
262
263// reachable says a row's folder can be asked about now: it is local, or
264// its host answered ready.
265func (m FolderPicker) reachable(row folderRow) bool {
266	return row.hostKey == "" || m.probe(row.hostKey).State == "ready"
267}
268
269// listing is the typed folder's listing, once it has arrived without error.
270func (m FolderPicker) listing() (daemon.FolderList, bool) {
271	if l := m.listings[m.listed]; l != nil && l.Err == nil {
272		return l.List, true
273	}
274	return daemon.FolderList{}, false
275}
276
277func (m FolderPicker) highlighted() (folderRow, bool) {
278	if m.cursor >= 0 && m.cursor < len(m.rows) {
279		return m.rows[m.cursor], true
280	}
281	return folderRow{}, false
282}
283
284// rebuild lists what the query asks for: recent folders, or the typed
285// folder's directories filtered by the last segment.
286func (m *FolderPicker) rebuild() {
287	before, _ := m.highlighted()
288	q := parseQuery(m.input.Value())
289	segment, listing := q.segment, q.kind == queryListing
290	m.rows, m.listed = nil, ""
291	var candidates []folderRow
292	switch q.kind {
293	case queryHosts:
294		m.section = "hosts"
295	case queryRecent:
296		m.section = "recent"
297		for _, f := range recentFolders(m.sessions, m.workspace(), time.Now()) {
298			host, p := daemon.SplitLocation(f.path)
299			candidates = append(candidates, folderRow{path: f.path, host: f.host, hostKey: host, name: path.Base(p), recent: true, age: f.last, repo: true})
300		}
301	case queryListing:
302		m.listed = folderRequest(m.workspace(), q.dir)
303		m.section = "in " + m.homed(m.listed)
304		if l, ok := m.listing(); ok {
305			m.section = "in " + m.homed(l.Path)
306			host, _ := daemon.SplitLocation(l.Path)
307			for _, e := range l.Entries {
308				if e.Hidden && !strings.HasPrefix(segment, ".") {
309					continue
310				}
311				row := folderRow{path: joinPlace(l.Path, e.Name), name: e.Name, hostKey: host, host: m.labelOf(host), repo: e.VCS != ""}
312				if e.Modified > 0 {
313					row.age = &e.Modified
314				}
315				candidates = append(candidates, row)
316			}
317		}
318	}
319	names := make([]string, len(candidates))
320	for i, row := range candidates {
321		names[i] = row.name
322	}
323	for _, match := range fuzzyNames(segment, names) {
324		row := candidates[match.Index]
325		row.matched = match.MatchedIndexes
326		m.rows = append(m.rows, row)
327	}
328	if q.kind != queryListing {
329		m.rows = append(m.rows, hostRows(q.kind, segment, m.known())...)
330	}
331	m.cursor = 0
332	if i := slices.IndexFunc(m.rows, func(r folderRow) bool { return r.path == before.path }); i >= 0 {
333		m.cursor = i
334	} else if !listing && segment == "" && len(m.rows) > 1 && !m.browse {
335		m.cursor = 1 // the current folder leads; the likeliest move is the next
336	}
337}
338
339// learnHome records the home a listing reported for its host, and which
340// host a typed one turned out to be.
341func (m *FolderPicker) learnHome(asked string, l daemon.FolderList) {
342	if l.Home == "" {
343		return
344	}
345	host, home := daemon.SplitLocation(l.Home)
346	m.homes[host] = home
347	typed, _, remote := splitHost(asked)
348	canonical, _ := daemon.SplitLocation(l.Path)
349	if !remote || canonical == "" || typed == canonical {
350		return
351	}
352	m.aliases[typed] = canonical
353	if m.labels[canonical] == "" {
354		m.labels[canonical] = typed
355	}
356	if p, ok := m.probes[typed]; ok && m.probes[canonical].State == "" {
357		m.probes[canonical], m.warmed[canonical] = p, true
358	}
359}
360
361// parentQuery is the folder above the one being listed, or above the
362// session's folder when nothing is typed.
363func (m FolderPicker) parentQuery() string {
364	dir := m.workspace()
365	if m.listed != "" {
366		dir = m.listed
367		if l, ok := m.listing(); ok {
368			dir = l.Path
369		}
370	}
371	// a remote ~ is only known once its listing answers
372	if host, rest, remote := splitHost(dir); remote && !strings.HasPrefix(rest, "/") {
373		if rest == "~" {
374			return m.input.Value()
375		}
376		return host + ":" + scpRelative(path.Dir(rest)) + "/"
377	}
378	up := m.homed(parentPlace(dir))
379	if strings.HasSuffix(up, "/") {
380		return up
381	}
382	return up + "/"
383}
384
385// target is the folder enter moves to: the highlighted row, or the typed
386// folder itself when it has nothing left to pick.
387func (m FolderPicker) target() string {
388	if row, ok := m.highlighted(); ok {
389		return row.path
390	}
391	if q := parseQuery(m.input.Value()); q.kind == queryListing && q.segment == "" {
392		if l, ok := m.listing(); ok {
393			return l.Path
394		}
395	}
396	return ""
397}