okami.mom/albedo

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

Aly Raffauffix: restore host staging progress and correct configuration597cb84

main
7.9 KiB242 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"cmp"
6	"context"
7	"strings"
8	"time"
9
10	tea "charm.land/bubbletea/v2"
11)
12
13// folderSource is what the folder picker asks the daemon.
14type folderSource interface {
15	List(ctx context.Context, path string) (daemon.FolderList, error)
16	Repo(ctx context.Context, path string) (*daemon.Repo, error)
17	Preview(ctx context.Context, path string) (daemon.FolderPreview, error)
18	Sessions(ctx context.Context) ([]daemon.Session, error)
19	Move(ctx context.Context, id, workspace string) (daemon.Session, error)
20	Hosts(ctx context.Context) ([]daemon.KnownHost, error)
21	Host(ctx context.Context, host string) (daemon.HostStatus, error)
22	Warm(ctx context.Context, host string) (daemon.HostStatus, error)
23	// Local says the daemon shares this machine, so an ssh master opened
24	// here is one it rides.
25	Local() bool
26}
27
28// daemonFolders is a folderSource over a daemon connection.
29type daemonFolders struct {
30	conn      *daemon.Connection
31	condition daemon.SessionCondition
32}
33
34func (d daemonFolders) List(ctx context.Context, p string) (daemon.FolderList, error) {
35	return daemon.ListFolders(ctx, d.conn, p)
36}
37
38func (d daemonFolders) Repo(ctx context.Context, p string) (*daemon.Repo, error) {
39	return daemon.FolderRepo(ctx, d.conn, p)
40}
41
42func (d daemonFolders) Preview(ctx context.Context, p string) (daemon.FolderPreview, error) {
43	return daemon.PreviewFolder(ctx, d.conn, p)
44}
45
46func (d daemonFolders) Sessions(ctx context.Context) ([]daemon.Session, error) {
47	return daemon.ListSessions(ctx, d.conn)
48}
49
50func (d daemonFolders) Move(ctx context.Context, id, workspace string) (daemon.Session, error) {
51	return daemon.MoveSession(ctx, d.conn, id, workspace, d.condition)
52}
53
54func (d daemonFolders) Hosts(ctx context.Context) ([]daemon.KnownHost, error) {
55	return daemon.ListHosts(ctx, d.conn)
56}
57
58func (d daemonFolders) Host(ctx context.Context, h string) (daemon.HostStatus, error) {
59	return daemon.GetHost(ctx, d.conn, h)
60}
61
62func (d daemonFolders) Warm(ctx context.Context, h string) (daemon.HostStatus, error) {
63	return daemon.WarmHost(ctx, d.conn, h)
64}
65
66func (d daemonFolders) Local() bool { return d.conn.Local() }
67
68// moveCmd moves session id to input: absolute, under ~, or relative to
69// the folder it is in now. The daemon names the folder a ~ or relative
70// path is, and whether it exists.
71func moveCmd(src folderSource, id, workspace, input string, retry *WorkspaceRetry) tea.Cmd {
72	return func() tea.Msg {
73		target := folderRequest(workspace, input)
74		if _, _, remote := splitHost(target); !remote && !strings.HasPrefix(target, "/") {
75			list, err := src.List(context.Background(), target)
76			if err != nil {
77				return FolderMovedMsg{SessionID: id, Retry: retry, Err: err}
78			}
79			target = list.Path
80		}
81		s, err := src.Move(context.Background(), id, target)
82		return FolderMovedMsg{SessionID: id, Workspace: cmp.Or(s.Workspace, target), Location: s.Location, Session: &s, Retry: retry, Err: err}
83	}
84}
85
86func (m FolderPicker) loadHostsCmd() tea.Cmd {
87	src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration
88	return func() tea.Msg {
89		hosts, err := src.Hosts(ctx)
90		return hostsMsg{Gen: gen, Hosts: hosts, Err: err}
91	}
92}
93
94func (m FolderPicker) loadSessionsCmd() tea.Cmd {
95	src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration
96	return func() tea.Msg {
97		sessions, err := src.Sessions(ctx)
98		return folderSessionsMsg{Sessions: sessions, Err: err, Gen: gen}
99	}
100}
101
102// warmShown warms the host of the highlighted row, and the host a remote
103// listing is typed for, once each.
104func (m *FolderPicker) warmShown() tea.Cmd {
105	var cmds []tea.Cmd
106	if row, ok := m.highlighted(); ok {
107		cmds = append(cmds, m.warm(row.hostKey))
108	}
109	if host, _, remote := splitHost(m.listed); remote {
110		cmds = append(cmds, m.warm(m.canonical(host)))
111	}
112	return tea.Batch(cmds...)
113}
114
115// warm probes host now and polls its state until the probe settles, once
116// per picker, so a dead host shows before it is picked.
117func (m *FolderPicker) warm(host string) tea.Cmd {
118	if host == "" || m.warmed[host] || m.warmed[m.canonical(host)] {
119		return nil
120	}
121	m.warmed[host] = true
122	m.probes[host] = daemon.HostStatus{Host: host, State: "warming"}
123	src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration
124	return tea.Batch(func() tea.Msg {
125		status, err := src.Warm(ctx, host)
126		return hostStatusMsg{Gen: gen, Host: host, Status: status, Err: err, Warmed: true}
127	}, pollHost(host, m.sessionsGeneration), m.tick())
128}
129
130func (m FolderPicker) warming() bool {
131	for _, p := range m.probes {
132		if p.State == "warming" {
133			return true
134		}
135	}
136	return false
137}
138
139// tick keeps the connecting face moving while any host still warms.
140func (m *FolderPicker) tick() tea.Cmd {
141	if m.ticking || !m.warming() {
142		return nil
143	}
144	m.ticking = true
145	gen := m.sessionsGeneration
146	return tea.Tick(faceInterval, func(time.Time) tea.Msg { return connectTickMsg{Gen: gen} })
147}
148
149func pollHost(host string, gen int64) tea.Cmd {
150	return tea.Tick(hostPollEvery, func(time.Time) tea.Msg { return hostPollMsg{Gen: gen, Host: host} })
151}
152
153// probed takes a host's state. A poll still warming asks again; one that
154// lands after the probe settled is stale. A daemon that cannot say leaves
155// the host unmarked, and its listing speaks for it.
156func (m *FolderPicker) probed(msg hostStatusMsg) tea.Cmd {
157	current, ok := m.probes[msg.Host]
158	if !ok || current.State != "warming" && !msg.Warmed {
159		return nil
160	}
161	switch {
162	case msg.Err != nil:
163		if msg.Warmed {
164			delete(m.probes, msg.Host)
165		}
166		return nil
167	case msg.Status.State == "warming" || msg.Status.State == "probing":
168		msg.Status.State = "warming"
169		m.probes[msg.Host] = msg.Status // it may say what the probe is doing
170		return pollHost(msg.Host, m.sessionsGeneration)
171	}
172	m.probes[msg.Host] = msg.Status
173	if msg.Status.Home != "" {
174		_, home := daemon.SplitLocation(msg.Status.Home)
175		m.homes[m.canonical(msg.Host)] = home
176	}
177	return m.follow()
178}
179
180// signIn opens the daemon's ssh master for host in this terminal, the way
181// a refused turn does, then warms the host again.
182func (m *FolderPicker) signIn(host string) tea.Cmd {
183	probe := m.probe(host)
184	if !m.src.Local() || probe.ControlPath == "" {
185		m.notice = "Run `ssh " + host + "` on the machine albedo runs on, then try again"
186		return nil
187	}
188	gen := m.sessionsGeneration
189	return tea.ExecProcess(signInCommand(host, probe.ControlPath), func(err error) tea.Msg {
190		return hostSignedInMsg{Gen: gen, Host: host, Err: err}
191	})
192}
193
194// fetch asks for the listing being typed and the repositories of the rows
195// on screen, each once.
196func (m *FolderPicker) fetch() tea.Cmd {
197	var cmds []tea.Cmd
198	if p := m.listed; p != "" && !m.asked["list:"+p] {
199		m.asked["list:"+p] = true
200		src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration
201		cmds = append(cmds, func() tea.Msg {
202			list, err := src.List(ctx, p)
203			return folderListMsg{Gen: gen, Path: p, List: list, Err: err}
204		})
205	}
206	first, last := m.visibleRows()
207	for _, row := range m.rows[first:last] {
208		if !row.repo || m.asked["repo:"+row.path] || !m.reachable(row) {
209			continue
210		}
211		m.asked["repo:"+row.path] = true
212		src, ctx, gen, p := m.src, m.readCtx, m.sessionsGeneration, row.path
213		cmds = append(cmds, func() tea.Msg {
214			repo, _ := src.Repo(ctx, p)
215			return folderRepoMsg{Gen: gen, Path: p, Repo: repo}
216		})
217	}
218	return tea.Batch(cmds...)
219}
220
221// previewCmd asks for the highlighted folder's preview after the same pause
222// as the sessions view, so scrolling past a row does not fetch it.
223func (m FolderPicker) previewCmd() tea.Cmd {
224	row, ok := m.highlighted()
225	if !ok || row.hostRow || m.previews[row.path] != nil || !m.reachable(row) {
226		return nil
227	}
228	p, gen := row.path, m.sessionsGeneration
229	return tea.Tick(previewDebounce, func(time.Time) tea.Msg { return folderPreviewTickMsg{Gen: gen, Path: p} })
230}
231
232func (m *FolderPicker) fetchPreview(p string) tea.Cmd {
233	if m.previews[p] != nil {
234		return nil
235	}
236	m.previews[p] = &cachedFolderPreview{loading: true}
237	src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration
238	return func() tea.Msg {
239		preview, err := src.Preview(ctx, p)
240		return folderPreviewMsg{Gen: gen, Path: p, Preview: preview, Err: err}
241	}
242}