okami.mom/albedo

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

dawntui: sessions and folders fuzzy filter, shared confirm and footers10e0f66

main
12.8 KiB489 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"cmp"
6	"context"
7	"strings"
8	"time"
9
10	"charm.land/bubbles/v2/textinput"
11	tea "charm.land/bubbletea/v2"
12)
13
14// WorkspaceRetry is a turn refused because the session's folder had gone
15// missing; it goes out again once the session moves.
16type WorkspaceRetry struct {
17	Images   []daemon.ImageAttachment
18	Pastes   []string
19	Missing  string
20	Prompt   string
21	Continue bool
22}
23
24// FolderMovedMsg is the daemon's answer to moving a session.
25type FolderMovedMsg struct {
26	Err       error
27	Retry     *WorkspaceRetry
28	SessionID string
29	Workspace string
30	Location  *daemon.Location
31	Session   *daemon.Session
32}
33
34type FolderPickerCancelMsg struct{}
35
36// FolderOpenSessionMsg opens a session picked from a folder's preview.
37type FolderOpenSessionMsg struct{ Session daemon.Session }
38
39// FolderNewSessionMsg starts a session in a folder picked while browsing.
40type FolderNewSessionMsg struct{ Workspace string }
41
42type folderListMsg struct {
43	Gen  int64
44	Err  error
45	Path string
46	List daemon.FolderList
47}
48
49type folderRepoMsg struct {
50	Gen  int64
51	Repo *daemon.Repo
52	Path string
53}
54
55type folderPreviewMsg struct {
56	Gen     int64
57	Err     error
58	Path    string
59	Preview daemon.FolderPreview
60}
61
62type folderPreviewTickMsg struct {
63	Path string
64	Gen  int64
65}
66
67type hostsMsg struct {
68	Gen   int64
69	Err   error
70	Hosts []daemon.KnownHost
71}
72
73// hostStatusMsg is a host's probe as the daemon answered it; Warmed marks
74// the answer to warming it, which has always settled.
75type hostStatusMsg struct {
76	Gen    int64
77	Err    error
78	Host   string
79	Status daemon.HostStatus
80	Warmed bool
81}
82
83type hostPollMsg struct {
84	Host string
85	Gen  int64
86}
87
88// connectTickMsg advances the connecting face; Gen is the picker's, so a
89// closed picker's tick never speeds up the next one.
90type connectTickMsg struct{ Gen int64 }
91
92type hostSignedInMsg struct {
93	Gen  int64
94	Err  error
95	Host string
96}
97
98type folderSessionsMsg struct {
99	Err      error
100	Sessions []daemon.Session
101	Gen      int64
102}
103
104const (
105	// queryRecent filters the recent folders, and offers hosts the text
106	// starts.
107	queryRecent queryKind = iota
108	// queryHosts completes a [user@]host before its colon.
109	queryHosts
110	// queryListing lists a folder, here or on a host.
111	queryListing
112)
113
114// folderRow is one folder in the picker's list.
115type folderRow struct {
116	age        *int64
117	path, name string
118	// host labels a remote folder's host.
119	host string
120	// hostKey is the [user@]host a remote row is reached through.
121	hostKey string
122	// source is where a host row's host is known from: recent or config.
123	source  string
124	matched []int
125	// recent rows show where the folder is beside its name.
126	recent bool
127	// repo says the folder may be in a repository worth asking about.
128	repo bool
129	// hostRow is a host to complete, not a folder.
130	hostRow bool
131}
132
133type cachedFolderPreview struct {
134	daemon.FolderPreview
135	loading, err bool
136}
137
138// FolderPicker moves a session to another folder: recent workspaces, or a
139// typed path listed through the daemon, beside a preview of the highlighted
140// folder. Browsing, from the sessions view, it starts a session in the
141// folder instead. Either way → steps into the folder's sessions to open one.
142type FolderPicker struct {
143	readCtx     context.Context
144	cancelReads context.CancelFunc
145	src         folderSource
146	asked       map[string]bool
147	retry       *WorkspaceRetry
148	previews    map[string]*cachedFolderPreview
149	repos       map[string]*daemon.Repo
150	listings    map[string]*folderListMsg
151
152	// session is the one being moved; browsing, only its workspace is set.
153	session daemon.Session
154	// Session failures survive query edits; a reopened picker rejects the old load.
155	sessionsError string
156	// homes are the folders ~ names: the daemon's under "", a remote
157	// host's under its [user@]host, as listings and probes report them.
158	homes map[string]string
159	// labels are how hosts read, by [user@]host; aliases name the host a
160	// typed one turned out to be.
161	labels  map[string]string
162	aliases map[string]string
163	hosts   []daemon.KnownHost
164	// probes are hosts' reachability, warmed once each per picker.
165	probes   map[string]daemon.HostStatus
166	warmed   map[string]bool
167	section  string
168	listed   string
169	notice   string
170	sessions []daemon.Session
171
172	rows               []folderRow
173	input              textinput.Model
174	sessionsGeneration int64
175	Width, Height      int
176	cursor             int
177	sessionCursor      int
178	browse             bool
179
180	sessionsLoading bool
181	moving          bool
182
183	// inSessions moves the cursor through the highlighted folder's sessions.
184	inSessions bool
185
186	// frame animates the face beside a host still connecting; ticking is
187	// whether its next tick is already on its way.
188	frame   int
189	ticking bool
190}
191
192func NewFolderPicker(src folderSource, session daemon.Session, retry *WorkspaceRetry) FolderPicker {
193	ti := newTextInput()
194	ti.Prompt = ""
195	ti.Placeholder = "Type a path or choose a folder"
196	st := ti.Styles()
197	st.Focused.Placeholder, st.Blurred.Placeholder = DefaultStyles.Faint, DefaultStyles.Faint
198	ti.SetStyles(st)
199	ti.Focus()
200	readCtx, cancelReads := context.WithCancel(context.Background())
201	m := FolderPicker{
202		readCtx:            readCtx,
203		cancelReads:        cancelReads,
204		input:              ti,
205		session:            session,
206		retry:              retry,
207		src:                src,
208		listings:           map[string]*folderListMsg{},
209		repos:              map[string]*daemon.Repo{},
210		asked:              map[string]bool{},
211		previews:           map[string]*cachedFolderPreview{},
212		homes:              map[string]string{},
213		labels:             map[string]string{},
214		aliases:            map[string]string{},
215		probes:             map[string]daemon.HostStatus{},
216		warmed:             map[string]bool{},
217		sessionsLoading:    true,
218		sessionsGeneration: int64(nextPageGeneration()),
219	}
220	m.rebuild()
221	return m
222}
223
224// NewFolderBrowser picks a folder to start a session in or to open one of
225// its sessions, starting from query.
226func NewFolderBrowser(src folderSource, workspace, query string) FolderPicker {
227	m := NewFolderPicker(src, daemon.Session{Workspace: workspace}, nil)
228	m.browse = true
229	m.setQuery(query)
230	return m
231}
232
233func (m *FolderPicker) SetSize(width, height int) {
234	m.Width, m.Height = width, height
235	m.input.SetWidth(max(1, width-8))
236}
237
238func (m *FolderPicker) Close() {
239	if m.cancelReads != nil {
240		m.cancelReads()
241	}
242	m.sessionsGeneration = int64(nextPageGeneration())
243}
244
245func (m FolderPicker) Init() tea.Cmd {
246	return tea.Batch(m.loadSessionsCmd(), m.loadHostsCmd(), m.fetch(), m.previewCmd())
247}
248
249func (m FolderPicker) Update(msg tea.Msg) (FolderPicker, tea.Cmd) {
250	switch msg := msg.(type) {
251	case folderSessionsMsg:
252		if msg.Gen != m.sessionsGeneration {
253			return m, nil
254		}
255		m.sessionsLoading = false
256		if msg.Err != nil {
257			m.sessionsError = msg.Err.Error()
258			return m, nil
259		}
260		m.sessionsError = ""
261		m.sessions, m.inSessions = msg.Sessions, false
262		for _, s := range m.sessions {
263			if host, _ := daemon.SplitLocation(s.Workspace); host != "" && m.labels[host] == "" {
264				m.labels[host] = sessionHost(s)
265			}
266		}
267		if parseQuery(m.input.Value()).kind != queryListing {
268			m.cursor = -1 // nothing was there to pick yet
269			m.rebuild()
270		}
271	case hostsMsg:
272		if msg.Gen != m.sessionsGeneration {
273			return m, nil
274		}
275		if msg.Err != nil {
276			return m, nil // a daemon without the list still has the sessions' hosts
277		}
278		m.hosts = msg.Hosts
279		for _, h := range m.hosts {
280			if h.Label != "" {
281				m.labels[h.Host] = h.Label
282			}
283		}
284		if parseQuery(m.input.Value()).kind != queryListing {
285			m.rebuild()
286		}
287	case hostPollMsg:
288		if msg.Gen != m.sessionsGeneration {
289			return m, nil
290		}
291		if m.probe(msg.Host).State != "warming" {
292			return m, nil
293		}
294		src, ctx, gen, h := m.src, m.readCtx, m.sessionsGeneration, msg.Host
295		return m, func() tea.Msg {
296			status, err := src.Host(ctx, h)
297			return hostStatusMsg{Gen: gen, Host: h, Status: status, Err: err}
298		}
299	case hostStatusMsg:
300		if msg.Gen != m.sessionsGeneration {
301			return m, nil
302		}
303		return m, m.probed(msg)
304	case connectTickMsg:
305		if msg.Gen != m.sessionsGeneration {
306			return m, nil
307		}
308		m.ticking = false
309		m.frame++
310		return m, m.tick()
311	case hostSignedInMsg:
312		if msg.Gen != m.sessionsGeneration {
313			return m, nil
314		}
315		if msg.Err != nil {
316			m.notice = "Signing in to " + m.labelOf(msg.Host) + " failed: " + msg.Err.Error()
317			return m, nil
318		}
319		m.warmed[msg.Host], m.warmed[m.canonical(msg.Host)] = false, false
320		return m, m.warm(msg.Host)
321	case folderListMsg:
322		if msg.Gen != m.sessionsGeneration {
323			return m, nil
324		}
325		m.listings[msg.Path] = &msg
326		if msg.Err == nil {
327			m.learnHome(msg.Path, msg.List)
328		}
329		if msg.Path == m.listed {
330			m.rebuild()
331		}
332	case folderRepoMsg:
333		if msg.Gen != m.sessionsGeneration {
334			return m, nil
335		}
336		m.repos[msg.Path] = msg.Repo
337		return m, nil
338	case folderPreviewMsg:
339		if msg.Gen != m.sessionsGeneration {
340			return m, nil
341		}
342		if c := m.previews[msg.Path]; c != nil {
343			c.loading, c.err = false, msg.Err != nil
344			c.FolderPreview = msg.Preview
345		}
346		return m, nil
347	case folderPreviewTickMsg:
348		if msg.Gen != m.sessionsGeneration {
349			return m, nil
350		}
351		if row, ok := m.highlighted(); ok && row.path == msg.Path {
352			return m, m.fetchPreview(msg.Path)
353		}
354		return m, nil
355	case tea.KeyPressMsg:
356		if m.moving {
357			return m, nil
358		}
359		if msg.String() == "ctrl+r" && m.sessionsError != "" {
360			if m.sessionsLoading {
361				return m, nil
362			}
363			m.sessionsLoading = true
364			return m, m.loadSessionsCmd()
365		}
366		if m.inSessions {
367			if cmd, done := m.sessionKey(msg); done {
368				return m, cmd
369			}
370		}
371		switch msg.String() {
372		case "esc", "ctrl+c", "ctrl+d":
373			return m, func() tea.Msg { return FolderPickerCancelMsg{} }
374		case "ctrl+l":
375			if row, ok := m.highlighted(); ok && m.probe(row.hostKey).State == "needs_auth" {
376				return m, m.signIn(row.hostKey)
377			}
378		case "up", "ctrl+p":
379			m.cursor = max(0, m.cursor-1)
380		case "down", "ctrl+n":
381			m.cursor = max(0, min(len(m.rows)-1, m.cursor+1))
382		case "tab":
383			if row, ok := m.highlighted(); ok && row.hostRow {
384				m.setQuery(row.path)
385			} else if ok {
386				m.setQuery(m.homed(row.path) + "/")
387			}
388		case "shift+tab":
389			m.setQuery(m.parentQuery())
390		case "right":
391			row, _ := m.highlighted()
392			if m.input.Position() < len([]rune(m.input.Value())) || len(m.sessionsIn(row.path)) == 0 {
393				return m.typed(msg)
394			}
395			m.inSessions, m.sessionCursor = true, 0
396		case "enter":
397			if row, ok := m.highlighted(); ok && row.hostRow {
398				m.setQuery(row.path)
399				break
400			}
401			return m, m.move()
402		default:
403			return m.typed(msg)
404		}
405	case tea.PasteMsg:
406		return m.typed(msg)
407	}
408	return m, m.follow()
409}
410
411// follow asks for what the list now shows: the listing, repositories and
412// the preview, and warms the hosts it reaches.
413func (m *FolderPicker) follow() tea.Cmd {
414	return tea.Batch(m.fetch(), m.previewCmd(), m.warmShown())
415}
416
417// hostPollEvery is how often a warming host is asked again.
418const hostPollEvery = 750 * time.Millisecond
419
420// sessionKey moves through the highlighted folder's sessions; done is false
421// for a key that leaves them for the folder list, which then handles it.
422func (m *FolderPicker) sessionKey(msg tea.KeyPressMsg) (cmd tea.Cmd, done bool) {
423	row, _ := m.highlighted()
424	sessions := m.sessionsIn(row.path)
425	switch msg.String() {
426	case "up", "ctrl+p":
427		m.sessionCursor = max(0, m.sessionCursor-1)
428	case "down", "ctrl+n":
429		m.sessionCursor = max(0, min(len(sessions)-1, m.sessionCursor+1))
430	case "enter":
431		if m.sessionCursor < len(sessions) {
432			s := sessions[m.sessionCursor]
433			return func() tea.Msg { return FolderOpenSessionMsg{Session: s} }, true
434		}
435	case "left", "esc":
436		m.inSessions = false
437	default:
438		m.inSessions = false
439		return nil, false
440	}
441	return nil, true
442}
443
444// typed hands msg to the query and lists what it asks for now.
445func (m FolderPicker) typed(msg tea.Msg) (FolderPicker, tea.Cmd) {
446	value := m.input.Value()
447	var cmd tea.Cmd
448	m.input, cmd = m.input.Update(msg)
449	if m.input.Value() != value {
450		m.notice = ""
451		m.rebuild()
452	}
453	return m, tea.Batch(cmd, m.follow())
454}
455
456func (m *FolderPicker) setQuery(q string) {
457	m.input.SetValue(q)
458	m.input.CursorEnd()
459	m.notice = ""
460	m.rebuild()
461}
462
463func (m *FolderPicker) move() tea.Cmd {
464	target := m.target()
465	host, _ := daemon.SplitLocation(target)
466	probe := m.probe(host)
467	switch {
468	case target == "":
469		return nil
470	case probe.State == "unreachable" || probe.State == "unsupported":
471		m.notice = m.labelOf(host) + ": " + cmp.Or(probe.Detail, strings.ReplaceAll(probe.State, "_", " "))
472		return nil
473	case probe.State == "needs_auth":
474		m.notice = "Sign in to " + m.labelOf(host) + " first · ctrl+l"
475		return nil
476	case m.browse:
477		return func() tea.Msg { return FolderNewSessionMsg{Workspace: target} }
478	case target == m.workspace() && m.retry == nil:
479		return func() tea.Msg { return FolderPickerCancelMsg{} }
480	}
481	m.moving, m.notice = true, ""
482	return moveCmd(m.src, m.session.ID, m.workspace(), target, m.retry)
483}
484
485// Refused shows why the daemon would not move the session and lets you
486// pick again.
487func (m *FolderPicker) Refused(err error) {
488	m.moving, m.notice = false, err.Error()
489}