Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249package tui
import ( "albedo/cli/internal/daemon" "cmp" "context" "strings" "time"
tea "charm.land/bubbletea/v2")
// folderSource is what the folder picker asks the daemon.type folderSource interface { List(ctx context.Context, path string) (daemon.FolderList, error) Repo(ctx context.Context, path string) (*daemon.Repo, error) Preview(ctx context.Context, path string) (daemon.FolderPreview, error) Sessions(ctx context.Context) ([]daemon.Session, error) Move(ctx context.Context, id, workspace string) (daemon.Session, error) Hosts(ctx context.Context) ([]daemon.KnownHost, error) Host(ctx context.Context, host string) (daemon.HostStatus, error) Warm(ctx context.Context, host string) (daemon.HostStatus, error) // Local says the daemon shares this machine, so an ssh master opened // here is one it rides. Local() bool}
// daemonFolders is a folderSource over a daemon connection.type daemonFolders struct { conn *daemon.Connection // condition is read when a move is dispatched: a family can change while // the picker is open. condition func() daemon.SessionCondition}
func (d daemonFolders) List(ctx context.Context, p string) (daemon.FolderList, error) { return daemon.ListFolders(ctx, d.conn, p)}
func (d daemonFolders) Repo(ctx context.Context, p string) (*daemon.Repo, error) { return daemon.FolderRepo(ctx, d.conn, p)}
func (d daemonFolders) Preview(ctx context.Context, p string) (daemon.FolderPreview, error) { return daemon.PreviewFolder(ctx, d.conn, p)}
func (d daemonFolders) Sessions(ctx context.Context) ([]daemon.Session, error) { return daemon.ListSessions(ctx, d.conn)}
func (d daemonFolders) Move(ctx context.Context, id, workspace string) (daemon.Session, error) { var condition daemon.SessionCondition if d.condition != nil { condition = d.condition() } return daemon.MoveSession(ctx, d.conn, id, workspace, condition)}
func (d daemonFolders) Hosts(ctx context.Context) ([]daemon.KnownHost, error) { return daemon.ListHosts(ctx, d.conn)}
func (d daemonFolders) Host(ctx context.Context, h string) (daemon.HostStatus, error) { return daemon.GetHost(ctx, d.conn, h)}
func (d daemonFolders) Warm(ctx context.Context, h string) (daemon.HostStatus, error) { return daemon.WarmHost(ctx, d.conn, h)}
func (d daemonFolders) Local() bool { return d.conn.Local() }
// moveCmd moves session id to input: absolute, under ~, or relative to// the folder it is in now. The daemon names the folder a ~ or relative// path is, and whether it exists.func moveCmd(src folderSource, id, workspace, input string, retry *WorkspaceRetry) tea.Cmd { return func() tea.Msg { target := folderRequest(workspace, input) if _, _, remote := splitHost(target); !remote && !strings.HasPrefix(target, "/") { list, err := src.List(context.Background(), target) if err != nil { return FolderMovedMsg{SessionID: id, Retry: retry, Err: err} } target = list.Path } s, err := src.Move(context.Background(), id, target) return FolderMovedMsg{SessionID: id, Workspace: cmp.Or(s.Workspace, target), Location: s.Location, Session: &s, Retry: retry, Err: err} }}
func (m FolderPicker) loadHostsCmd() tea.Cmd { src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration return func() tea.Msg { hosts, err := src.Hosts(ctx) return hostsMsg{Gen: gen, Hosts: hosts, Err: err} }}
func (m FolderPicker) loadSessionsCmd() tea.Cmd { src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration return func() tea.Msg { sessions, err := src.Sessions(ctx) return folderSessionsMsg{Sessions: sessions, Err: err, Gen: gen} }}
// warmShown warms the host of the highlighted row, and the host a remote// listing is typed for, once each.func (m *FolderPicker) warmShown() tea.Cmd { var cmds []tea.Cmd if row, ok := m.highlighted(); ok { cmds = append(cmds, m.warm(row.hostKey)) } if host, _, remote := splitHost(m.listed); remote { cmds = append(cmds, m.warm(m.canonical(host))) } return tea.Batch(cmds...)}
// warm probes host now and polls its state until the probe settles, once// per picker, so a dead host shows before it is picked.func (m *FolderPicker) warm(host string) tea.Cmd { if host == "" || m.warmed[host] || m.warmed[m.canonical(host)] { return nil } m.warmed[host] = true m.probes[host] = daemon.HostStatus{Host: host, State: "warming"} src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration return tea.Batch(func() tea.Msg { status, err := src.Warm(ctx, host) return hostStatusMsg{Gen: gen, Host: host, Status: status, Err: err, Warmed: true} }, pollHost(host, m.sessionsGeneration), m.tick())}
func (m FolderPicker) warming() bool { for _, p := range m.probes { if p.State == "warming" { return true } } return false}
// tick keeps the connecting face moving while any host still warms.func (m *FolderPicker) tick() tea.Cmd { if m.ticking || !m.warming() { return nil } m.ticking = true gen := m.sessionsGeneration return tea.Tick(faceInterval, func(time.Time) tea.Msg { return connectTickMsg{Gen: gen} })}
func pollHost(host string, gen int64) tea.Cmd { return tea.Tick(hostPollEvery, func(time.Time) tea.Msg { return hostPollMsg{Gen: gen, Host: host} })}
// probed takes a host's state. A poll still warming asks again; one that// lands after the probe settled is stale. A daemon that cannot say leaves// the host unmarked, and its listing speaks for it.func (m *FolderPicker) probed(msg hostStatusMsg) tea.Cmd { current, ok := m.probes[msg.Host] if !ok || current.State != "warming" && !msg.Warmed { return nil } switch { case msg.Err != nil: if msg.Warmed { delete(m.probes, msg.Host) } return nil case msg.Status.State == "warming" || msg.Status.State == "probing": msg.Status.State = "warming" m.probes[msg.Host] = msg.Status // it may say what the probe is doing return pollHost(msg.Host, m.sessionsGeneration) } m.probes[msg.Host] = msg.Status if msg.Status.Home != "" { _, home := daemon.SplitLocation(msg.Status.Home) m.homes[m.canonical(msg.Host)] = home } return m.follow()}
// signIn opens the daemon's ssh master for host in this terminal, the way// a refused turn does, then warms the host again.func (m *FolderPicker) signIn(host string) tea.Cmd { probe := m.probe(host) if !m.src.Local() || probe.ControlPath == "" { m.notice = "Run `ssh " + host + "` on the machine albedo runs on, then try again" return nil } gen := m.sessionsGeneration return tea.ExecProcess(signInCommand(host, probe.ControlPath), func(err error) tea.Msg { return hostSignedInMsg{Gen: gen, Host: host, Err: err} })}
// fetch asks for the listing being typed and the repositories of the rows// on screen, each once.func (m *FolderPicker) fetch() tea.Cmd { var cmds []tea.Cmd if p := m.listed; p != "" && !m.asked["list:"+p] { m.asked["list:"+p] = true src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration cmds = append(cmds, func() tea.Msg { list, err := src.List(ctx, p) return folderListMsg{Gen: gen, Path: p, List: list, Err: err} }) } first, last := m.visibleRows() for _, row := range m.rows[first:last] { if !row.repo || m.asked["repo:"+row.path] || !m.reachable(row) { continue } m.asked["repo:"+row.path] = true src, ctx, gen, p := m.src, m.readCtx, m.sessionsGeneration, row.path cmds = append(cmds, func() tea.Msg { repo, _ := src.Repo(ctx, p) return folderRepoMsg{Gen: gen, Path: p, Repo: repo} }) } return tea.Batch(cmds...)}
// previewCmd asks for the highlighted folder's preview after the same pause// as the sessions view, so scrolling past a row does not fetch it.func (m FolderPicker) previewCmd() tea.Cmd { row, ok := m.highlighted() if !ok || row.hostRow || m.previews[row.path] != nil || !m.reachable(row) { return nil } p, gen := row.path, m.sessionsGeneration return useTick(m.Tick, previewDebounce, func(time.Time) tea.Msg { return folderPreviewTickMsg{Gen: gen, Path: p} })}
func (m *FolderPicker) fetchPreview(p string) tea.Cmd { if m.previews[p] != nil { return nil } m.previews[p] = &cachedFolderPreview{loading: true} src, ctx, gen := m.src, m.readCtx, m.sessionsGeneration return func() tea.Msg { preview, err := src.Preview(ctx, p) return folderPreviewMsg{Gen: gen, Path: p, Preview: preview, Err: err} }}