package tui
import (
"albedo/cli/internal/daemon"
"cmp"
"context"
"path"
"slices"
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/sahilm/fuzzy"
)
// folderSource is what the folder picker asks the daemon.
type folderSource interface {
List(path string) (daemon.FolderList, error)
Repo(path string) (*daemon.Repo, error)
Preview(path string) (daemon.FolderPreview, error)
Sessions() ([]daemon.Session, error)
Move(id, workspace string) (daemon.Session, error)
Hosts() ([]daemon.KnownHost, error)
Host(host string) (daemon.HostStatus, error)
Warm(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 }
func (d daemonFolders) List(p string) (daemon.FolderList, error) {
return daemon.ListFolders(context.Background(), d.conn, p)
}
func (d daemonFolders) Repo(p string) (*daemon.Repo, error) {
return daemon.FolderRepo(context.Background(), d.conn, p)
}
func (d daemonFolders) Preview(p string) (daemon.FolderPreview, error) {
return daemon.PreviewFolder(context.Background(), d.conn, p)
}
func (d daemonFolders) Sessions() ([]daemon.Session, error) {
return daemon.ListSessions(context.Background(), d.conn)
}
func (d daemonFolders) Move(id, workspace string) (daemon.Session, error) {
return daemon.MoveSession(context.Background(), d.conn, id, workspace)
}
func (d daemonFolders) Hosts() ([]daemon.KnownHost, error) {
return daemon.ListHosts(context.Background(), d.conn)
}
func (d daemonFolders) Host(h string) (daemon.HostStatus, error) {
return daemon.GetHost(context.Background(), d.conn, h)
}
func (d daemonFolders) Warm(h string) (daemon.HostStatus, error) {
return daemon.WarmHost(context.Background(), d.conn, h)
}
func (d daemonFolders) Local() bool { return d.conn.Local() }
// WorkspaceRetry is a turn refused because the session's folder had gone
// missing; it goes out again once the session moves.
type WorkspaceRetry struct {
Image *daemon.ImageAttachment
Missing string
Prompt string
Continue bool
}
// FolderMovedMsg is the daemon's answer to moving a session.
type FolderMovedMsg struct {
Err error
Retry *WorkspaceRetry
SessionID string
Workspace string
Location *daemon.Location
}
type FolderPickerCancelMsg struct{}
// FolderOpenSessionMsg opens a session picked from a folder's preview.
type FolderOpenSessionMsg struct{ Session daemon.Session }
// FolderNewSessionMsg starts a session in a folder picked while browsing.
type FolderNewSessionMsg struct{ Workspace string }
type folderListMsg struct {
Err error
Path string
List daemon.FolderList
}
type folderRepoMsg struct {
Repo *daemon.Repo
Path string
}
type folderPreviewMsg struct {
Err error
Path string
Preview daemon.FolderPreview
}
type folderPreviewTickMsg struct{ Path string }
type hostsMsg struct {
Err error
Hosts []daemon.KnownHost
}
// hostStatusMsg is a host's probe as the daemon answered it; Warmed marks
// the answer to warming it, which has always settled.
type hostStatusMsg struct {
Err error
Host string
Status daemon.HostStatus
Warmed bool
}
type hostPollMsg struct{ Host string }
// connectTickMsg advances the connecting face; Gen is the picker's, so a
// closed picker's tick never speeds up the next one.
type connectTickMsg struct{ Gen int64 }
type hostSignedInMsg struct {
Err error
Host string
}
type folderSessionsMsg struct {
Err error
Sessions []daemon.Session
Gen int64
}
// 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(target)
if err != nil {
return FolderMovedMsg{SessionID: id, Retry: retry, Err: err}
}
target = list.Path
}
s, err := src.Move(id, target)
return FolderMovedMsg{SessionID: id, Workspace: cmp.Or(s.Workspace, target), Location: s.Location, Retry: retry, Err: err}
}
}
// folderRequest is how the daemon is asked about a typed folder: ~,
// absolute paths and host: locations as they are (a bare host: is its
// home), anything else under the current folder.
func folderRequest(workspace, typed string) string {
if host, rest, remote := splitHost(typed); remote {
if rest != "/" {
rest = strings.TrimSuffix(rest, "/")
}
if rest != "" && rest[0] != '/' && rest[0] != '~' {
rest = "~/" + rest // as scp reads it: under the remote home
}
return host + ":" + cmp.Or(rest, "~")
}
if typed != "/" {
typed = strings.TrimSuffix(typed, "/")
}
if strings.HasPrefix(typed, "/") || typed == "~" || strings.HasPrefix(typed, "~/") {
return typed
}
return joinPlace(workspace, typed)
}
type queryKind int
const (
// queryRecent filters the recent folders, and offers hosts the text
// starts.
queryRecent queryKind = iota
// queryHosts completes a [user@]host before its colon.
queryHosts
// queryListing lists a folder, here or on a host.
queryListing
)
// folderQuery is what the typed text asks the picker for.
type folderQuery struct {
// dir is the folder to list, as typed.
dir string
// segment filters the list: the text after dir's last slash, or all of
// it outside a listing.
segment string
kind queryKind
}
// parseQuery reads typed text: a path lists its folder filtered by the
// last segment, scp-style host:path lists that folder on the host (host:
// and host:name list its home), and a word without / or : filters the
// recent folders, unless it holds an @ and so can only be user@host.
func parseQuery(q string) folderQuery {
if q == "~" {
return folderQuery{kind: queryListing, dir: "~"}
}
if host, rest, remote := splitHost(q); remote {
dir, segment := host+":~", rest
if rest == "~" {
segment = ""
}
if i := strings.LastIndex(rest, "/"); i >= 0 {
dir, segment = host+":"+rest[:i+1], rest[i+1:]
}
return folderQuery{kind: queryListing, dir: dir, segment: segment}
}
if i := strings.LastIndex(q, "/"); i >= 0 {
return folderQuery{kind: queryListing, dir: q[:i+1], segment: q[i+1:]}
}
if strings.Contains(q, "@") {
return folderQuery{kind: queryHosts, segment: q}
}
return folderQuery{kind: queryRecent, segment: q}
}
// bareHost is a [user@]host without its user.
func bareHost(h string) string {
return h[strings.LastIndex(h, "@")+1:]
}
// hostRows are the hosts the text completes. A word only offers hosts it
// starts, after the folders it matches, so a local filter reads as it
// always did; with an @ every known host matches what follows it, and the
// typed user goes with the completion.
func hostRows(kind queryKind, text string, known []daemon.KnownHost) []folderRow {
user, name := "", text
if i := strings.LastIndex(text, "@"); kind == queryHosts && i >= 0 {
user, name = text[:i+1], text[i+1:]
}
names := make([]string, len(known))
for i, h := range known {
names[i] = bareHost(h.Host)
}
var matches []fuzzy.Match
switch {
case kind == queryHosts:
matches = fuzzyNames(name, names)
case name != "":
for i, n := range names {
if strings.HasPrefix(strings.ToLower(n), strings.ToLower(name)) || strings.HasPrefix(strings.ToLower(known[i].Label), strings.ToLower(name)) {
matches = append(matches, fuzzy.Match{Str: n, Index: i})
}
}
}
var rows []folderRow
for _, match := range matches {
h := known[match.Index]
target, label := h.Host, cmp.Or(h.Label, h.Host)
if user != "@" && user != "" {
target = user + bareHost(h.Host)
label = target
}
row := folderRow{path: label + ":", name: label, host: label, hostKey: target, source: h.Source, hostRow: true}
if shift := len(label) - len(match.Str); strings.HasSuffix(label, match.Str) {
for _, i := range match.MatchedIndexes {
row.matched = append(row.matched, i+shift)
}
}
rows = append(rows, row)
}
return rows
}
// frecency weighs each session in a folder by how recently it was used, so
// a folder you work in daily outranks one you used often long ago: 4 within
// the hour, halving past a day, a week and a month.
func frecency(last *int64, now time.Time) float64 {
weight := 4.0
for _, within := range []time.Duration{time.Hour, 24 * time.Hour, 7 * 24 * time.Hour, 30 * 24 * time.Hour} {
if last != nil && now.Sub(time.Unix(*last, 0)) < within {
return weight
}
weight /= 2
}
return weight
}
type recentFolder struct {
last *int64
path, host string
score float64
}
// recentFolders are the distinct workspaces of sessions, most frecent
// first, with the current one leading.
func recentFolders(sessions []daemon.Session, current string, now time.Time) []recentFolder {
index := map[string]int{}
var out []recentFolder
for _, s := range sessions {
if s.Workspace == "" {
continue
}
i, ok := index[s.Workspace]
if !ok {
i, index[s.Workspace] = len(out), len(out)
out = append(out, recentFolder{path: s.Workspace, host: sessionHost(s)})
}
f := &out[i]
f.score += frecency(s.LastAssistantAt, now)
if s.LastAssistantAt != nil && (f.last == nil || *s.LastAssistantAt > *f.last) {
f.last = s.LastAssistantAt
}
}
if _, ok := index[current]; !ok && current != "" {
out = append(out, recentFolder{path: current})
}
slices.SortStableFunc(out, func(a, b recentFolder) int {
switch {
case a.path == current:
return -1
case b.path == current:
return 1
}
return cmp.Compare(b.score, a.score)
})
return out
}
// fuzzyNames matches segment against names, best first; an empty segment
// keeps them all in order.
func fuzzyNames(segment string, names []string) []fuzzy.Match {
if segment == "" {
all := make([]fuzzy.Match, len(names))
for i, n := range names {
all[i] = fuzzy.Match{Str: n, Index: i}
}
return all
}
return fuzzy.Find(segment, names)
}
// folderRow is one folder in the picker's list.
type folderRow struct {
age *int64
path, name string
// host labels a remote folder's host.
host string
// hostKey is the [user@]host a remote row is reached through.
hostKey string
// source is where a host row's host is known from: recent or config.
source string
matched []int
// recent rows show where the folder is beside its name.
recent bool
// repo says the folder may be in a repository worth asking about.
repo bool
// hostRow is a host to complete, not a folder.
hostRow bool
}
type cachedFolderPreview struct {
daemon.FolderPreview
loading, err bool
}
// FolderPicker moves a session to another folder: recent workspaces, or a
// typed path listed through the daemon, beside a preview of the highlighted
// folder. Browsing, from the sessions view, it starts a session in the
// folder instead. Either way → steps into the folder's sessions to open one.
type FolderPicker struct {
src folderSource
asked map[string]bool
retry *WorkspaceRetry
previews map[string]*cachedFolderPreview
repos map[string]*daemon.Repo
listings map[string]*folderListMsg
// session is the one being moved; browsing, only its workspace is set.
session daemon.Session
// Session failures survive query edits; a reopened picker rejects the old load.
sessionsError string
// homes are the folders ~ names: the daemon's under "", a remote
// host's under its [user@]host, as listings and probes report them.
homes map[string]string
// labels are how hosts read, by [user@]host; aliases name the host a
// typed one turned out to be.
labels map[string]string
aliases map[string]string
hosts []daemon.KnownHost
// probes are hosts' reachability, warmed once each per picker.
probes map[string]daemon.HostStatus
warmed map[string]bool
section string
listed string
notice string
sessions []daemon.Session
rows []folderRow
input textinput.Model
sessionsGeneration int64
Width, Height int
cursor int
sessionCursor int
browse bool
sessionsLoading bool
moving bool
// inSessions moves the cursor through the highlighted folder's sessions.
inSessions bool
// frame animates the face beside a host still connecting; ticking is
// whether its next tick is already on its way.
frame int
ticking bool
}
func NewFolderPicker(src folderSource, session daemon.Session, retry *WorkspaceRetry) FolderPicker {
ti := newTextInput()
ti.Prompt = ""
ti.Placeholder = "Type a path or choose a folder"
st := ti.Styles()
st.Focused.Placeholder, st.Blurred.Placeholder = DefaultStyles.Faint, DefaultStyles.Faint
ti.SetStyles(st)
ti.Focus()
m := FolderPicker{
input: ti,
session: session,
retry: retry,
src: src,
listings: map[string]*folderListMsg{},
repos: map[string]*daemon.Repo{},
asked: map[string]bool{},
previews: map[string]*cachedFolderPreview{},
homes: map[string]string{},
labels: map[string]string{},
aliases: map[string]string{},
probes: map[string]daemon.HostStatus{},
warmed: map[string]bool{},
sessionsLoading: true,
sessionsGeneration: time.Now().UnixNano(),
}
m.rebuild()
return m
}
// NewFolderBrowser picks a folder to start a session in or to open one of
// its sessions, starting from query.
func NewFolderBrowser(src folderSource, workspace, query string) FolderPicker {
m := NewFolderPicker(src, daemon.Session{Workspace: workspace}, nil)
m.browse = true
m.setQuery(query)
return m
}
func (m *FolderPicker) SetSize(width, height int) {
m.Width, m.Height = width, height
m.input.SetWidth(max(1, width-8))
}
func (m FolderPicker) Init() tea.Cmd {
return tea.Batch(m.loadSessionsCmd(), m.loadHostsCmd(), m.fetch(), m.previewCmd())
}
func (m FolderPicker) loadHostsCmd() tea.Cmd {
src := m.src
return func() tea.Msg {
hosts, err := src.Hosts()
return hostsMsg{Hosts: hosts, Err: err}
}
}
func (m FolderPicker) loadSessionsCmd() tea.Cmd {
src, gen := m.src, m.sessionsGeneration
return func() tea.Msg {
sessions, err := src.Sessions()
return folderSessionsMsg{Sessions: sessions, Err: err, Gen: gen}
}
}
func (m FolderPicker) workspace() string { return m.session.Workspace }
// homed shortens p under its home to start with ~, led by its host's
// label when it is remote.
func (m FolderPicker) homed(p string) string {
host, _ := daemon.SplitLocation(p)
return placeText(p, m.labelOf(host), m.homes[m.canonical(host)])
}
// canonical is the [user@]host a typed host turned out to be, or the one
// that reads as it.
func (m FolderPicker) canonical(host string) string {
if c, ok := m.aliases[host]; ok {
return c
}
for c, label := range m.labels {
if label == host {
return c
}
}
return host
}
func (m FolderPicker) labelOf(host string) string {
return cmp.Or(m.labels[m.canonical(host)], m.labels[host], host)
}
// known are the hosts to complete: the daemon's recent ones, then hosts of
// the sessions here in case the daemon has no list, then its ssh config.
func (m FolderPicker) known() []daemon.KnownHost {
seen := map[string]bool{}
var out []daemon.KnownHost
add := func(h daemon.KnownHost) {
if h.Host != "" && !seen[h.Host] {
seen[h.Host] = true
out = append(out, h)
}
}
for _, h := range m.hosts {
if h.Source != "config" {
add(h)
}
}
for _, f := range recentFolders(m.sessions, "", time.Now()) {
if host, _ := daemon.SplitLocation(f.path); host != "" {
add(daemon.KnownHost{Host: host, Label: f.host, Source: "recent"})
}
}
for _, h := range m.hosts {
add(h)
}
return out
}
// probe is what is known of a host's reachability: its warm-up, else the
// state the daemon's host list cached. Local folders have none.
func (m FolderPicker) probe(host string) daemon.HostStatus {
if host == "" {
return daemon.HostStatus{}
}
if p, ok := m.probes[host]; ok {
return p
}
if p, ok := m.probes[m.canonical(host)]; ok {
return p
}
if i := slices.IndexFunc(m.hosts, func(h daemon.KnownHost) bool { return h.Host == m.canonical(host) }); i >= 0 {
return daemon.HostStatus{Host: host, State: m.hosts[i].State}
}
return daemon.HostStatus{}
}
// reachable says a row's folder can be asked about now: it is local, or
// its host answered ready.
func (m FolderPicker) reachable(row folderRow) bool {
return row.hostKey == "" || m.probe(row.hostKey).State == "ready"
}
// listing is the typed folder's listing, once it has arrived without error.
func (m FolderPicker) listing() (daemon.FolderList, bool) {
if l := m.listings[m.listed]; l != nil && l.Err == nil {
return l.List, true
}
return daemon.FolderList{}, false
}
func (m FolderPicker) highlighted() (folderRow, bool) {
if m.cursor >= 0 && m.cursor < len(m.rows) {
return m.rows[m.cursor], true
}
return folderRow{}, false
}
// rebuild lists what the query asks for: recent folders, or the typed
// folder's directories filtered by the last segment.
func (m *FolderPicker) rebuild() {
before, _ := m.highlighted()
q := parseQuery(m.input.Value())
segment, listing := q.segment, q.kind == queryListing
m.rows, m.listed = nil, ""
var candidates []folderRow
switch q.kind {
case queryHosts:
m.section = "hosts"
case queryRecent:
m.section = "recent"
for _, f := range recentFolders(m.sessions, m.workspace(), time.Now()) {
host, p := daemon.SplitLocation(f.path)
candidates = append(candidates, folderRow{path: f.path, host: f.host, hostKey: host, name: path.Base(p), recent: true, age: f.last, repo: true})
}
case queryListing:
m.listed = folderRequest(m.workspace(), q.dir)
m.section = "in " + m.homed(m.listed)
if l, ok := m.listing(); ok {
m.section = "in " + m.homed(l.Path)
host, _ := daemon.SplitLocation(l.Path)
for _, e := range l.Entries {
if e.Hidden && !strings.HasPrefix(segment, ".") {
continue
}
row := folderRow{path: joinPlace(l.Path, e.Name), name: e.Name, hostKey: host, host: m.labelOf(host), repo: e.VCS != ""}
if e.Modified > 0 {
row.age = &e.Modified
}
candidates = append(candidates, row)
}
}
}
names := make([]string, len(candidates))
for i, row := range candidates {
names[i] = row.name
}
for _, match := range fuzzyNames(segment, names) {
row := candidates[match.Index]
row.matched = match.MatchedIndexes
m.rows = append(m.rows, row)
}
if q.kind != queryListing {
m.rows = append(m.rows, hostRows(q.kind, segment, m.known())...)
}
m.cursor = 0
if i := slices.IndexFunc(m.rows, func(r folderRow) bool { return r.path == before.path }); i >= 0 {
m.cursor = i
} else if !listing && segment == "" && len(m.rows) > 1 && !m.browse {
m.cursor = 1 // the current folder leads; the likeliest move is the next
}
}
func (m FolderPicker) Update(msg tea.Msg) (FolderPicker, tea.Cmd) {
switch msg := msg.(type) {
case folderSessionsMsg:
if msg.Gen != m.sessionsGeneration {
return m, nil
}
m.sessionsLoading = false
if msg.Err != nil {
m.sessionsError = msg.Err.Error()
return m, nil
}
m.sessionsError = ""
m.sessions, m.inSessions = msg.Sessions, false
for _, s := range m.sessions {
if host, _ := daemon.SplitLocation(s.Workspace); host != "" && m.labels[host] == "" {
m.labels[host] = sessionHost(s)
}
}
if parseQuery(m.input.Value()).kind != queryListing {
m.cursor = -1 // nothing was there to pick yet
m.rebuild()
}
case hostsMsg:
if msg.Err != nil {
return m, nil // a daemon without the list still has the sessions' hosts
}
m.hosts = msg.Hosts
for _, h := range m.hosts {
if h.Label != "" {
m.labels[h.Host] = h.Label
}
}
if parseQuery(m.input.Value()).kind != queryListing {
m.rebuild()
}
case hostPollMsg:
if m.probe(msg.Host).State != "warming" {
return m, nil
}
src, h := m.src, msg.Host
return m, func() tea.Msg {
status, err := src.Host(h)
return hostStatusMsg{Host: h, Status: status, Err: err}
}
case hostStatusMsg:
return m, m.probed(msg)
case connectTickMsg:
if msg.Gen != m.sessionsGeneration {
return m, nil
}
m.ticking = false
m.frame++
return m, m.tick()
case hostSignedInMsg:
if msg.Err != nil {
m.notice = "Signing in to " + m.labelOf(msg.Host) + " failed: " + msg.Err.Error()
return m, nil
}
m.warmed[msg.Host], m.warmed[m.canonical(msg.Host)] = false, false
return m, m.warm(msg.Host)
case folderListMsg:
m.listings[msg.Path] = &msg
if msg.Err == nil {
m.learnHome(msg.Path, msg.List)
}
if msg.Path == m.listed {
m.rebuild()
}
case folderRepoMsg:
m.repos[msg.Path] = msg.Repo
return m, nil
case folderPreviewMsg:
if c := m.previews[msg.Path]; c != nil {
c.loading, c.err = false, msg.Err != nil
c.FolderPreview = msg.Preview
}
return m, nil
case folderPreviewTickMsg:
if row, ok := m.highlighted(); ok && row.path == msg.Path {
return m, m.fetchPreview(msg.Path)
}
return m, nil
case tea.KeyPressMsg:
if m.moving {
return m, nil
}
if msg.String() == "ctrl+r" && m.sessionsError != "" {
if m.sessionsLoading {
return m, nil
}
m.sessionsLoading = true
return m, m.loadSessionsCmd()
}
if m.inSessions {
if cmd, done := m.sessionKey(msg); done {
return m, cmd
}
}
switch msg.String() {
case "esc", "ctrl+c":
return m, func() tea.Msg { return FolderPickerCancelMsg{} }
case "ctrl+l":
if row, ok := m.highlighted(); ok && m.probe(row.hostKey).State == "needs_auth" {
return m, m.signIn(row.hostKey)
}
case "up", "ctrl+p":
m.cursor = max(0, m.cursor-1)
case "down", "ctrl+n":
m.cursor = max(0, min(len(m.rows)-1, m.cursor+1))
case "tab":
if row, ok := m.highlighted(); ok && row.hostRow {
m.setQuery(row.path)
} else if ok {
m.setQuery(m.homed(row.path) + "/")
}
case "shift+tab":
m.setQuery(m.parentQuery())
case "right":
row, _ := m.highlighted()
if m.input.Position() < len([]rune(m.input.Value())) || len(m.sessionsIn(row.path)) == 0 {
return m.typed(msg)
}
m.inSessions, m.sessionCursor = true, 0
case "enter":
if row, ok := m.highlighted(); ok && row.hostRow {
m.setQuery(row.path)
break
}
return m, m.move()
default:
return m.typed(msg)
}
case tea.PasteMsg:
return m.typed(msg)
}
return m, m.follow()
}
// follow asks for what the list now shows: the listing, repositories and
// the preview, and warms the hosts it reaches.
func (m *FolderPicker) follow() tea.Cmd {
return tea.Batch(m.fetch(), m.previewCmd(), m.warmShown())
}
// learnHome records the home a listing reported for its host, and which
// host a typed one turned out to be.
func (m *FolderPicker) learnHome(asked string, l daemon.FolderList) {
if l.Home == "" {
return
}
host, home := daemon.SplitLocation(l.Home)
m.homes[host] = home
typed, _, remote := splitHost(asked)
canonical, _ := daemon.SplitLocation(l.Path)
if !remote || canonical == "" || typed == canonical {
return
}
m.aliases[typed] = canonical
if m.labels[canonical] == "" {
m.labels[canonical] = typed
}
if p, ok := m.probes[typed]; ok && m.probes[canonical].State == "" {
m.probes[canonical], m.warmed[canonical] = p, true
}
}
// hostPollEvery is how often a warming host is asked again.
const hostPollEvery = 750 * time.Millisecond
// 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 := m.src
return tea.Batch(func() tea.Msg {
status, err := src.Warm(host)
return hostStatusMsg{Host: host, Status: status, Err: err, Warmed: true}
}, pollHost(host), 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) tea.Cmd {
return tea.Tick(hostPollEvery, func(time.Time) tea.Msg { return hostPollMsg{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":
m.probes[msg.Host] = msg.Status // it may say what the probe is doing
return pollHost(msg.Host)
}
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
}
return tea.ExecProcess(signInCommand(host, probe.ControlPath), func(err error) tea.Msg {
return hostSignedInMsg{Host: host, Err: err}
})
}
// sessionKey moves through the highlighted folder's sessions; done is false
// for a key that leaves them for the folder list, which then handles it.
func (m *FolderPicker) sessionKey(msg tea.KeyPressMsg) (cmd tea.Cmd, done bool) {
row, _ := m.highlighted()
sessions := m.sessionsIn(row.path)
switch msg.String() {
case "up", "ctrl+p":
m.sessionCursor = max(0, m.sessionCursor-1)
case "down", "ctrl+n":
m.sessionCursor = max(0, min(len(sessions)-1, m.sessionCursor+1))
case "enter":
if m.sessionCursor < len(sessions) {
s := sessions[m.sessionCursor]
return func() tea.Msg { return FolderOpenSessionMsg{Session: s} }, true
}
case "left", "esc":
m.inSessions = false
default:
m.inSessions = false
return nil, false
}
return nil, true
}
// typed hands msg to the query and lists what it asks for now.
func (m FolderPicker) typed(msg tea.Msg) (FolderPicker, tea.Cmd) {
value := m.input.Value()
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
if m.input.Value() != value {
m.notice = ""
m.rebuild()
}
return m, tea.Batch(cmd, m.follow())
}
func (m *FolderPicker) setQuery(q string) {
m.input.SetValue(q)
m.input.CursorEnd()
m.notice = ""
m.rebuild()
}
// parentQuery is the folder above the one being listed, or above the
// session's folder when nothing is typed.
func (m FolderPicker) parentQuery() string {
dir := m.workspace()
if m.listed != "" {
dir = m.listed
if l, ok := m.listing(); ok {
dir = l.Path
}
}
// a remote ~ is only known once its listing answers
if host, rest, remote := splitHost(dir); remote && !strings.HasPrefix(rest, "/") {
if rest == "~" {
return m.input.Value()
}
return host + ":" + path.Dir(rest) + "/"
}
up := m.homed(parentPlace(dir))
if strings.HasSuffix(up, "/") {
return up
}
return up + "/"
}
// target is the folder enter moves to: the highlighted row, or the typed
// folder itself when it has nothing left to pick.
func (m FolderPicker) target() string {
if row, ok := m.highlighted(); ok {
return row.path
}
if q := parseQuery(m.input.Value()); q.kind == queryListing && q.segment == "" {
if l, ok := m.listing(); ok {
return l.Path
}
}
return ""
}
func (m *FolderPicker) move() tea.Cmd {
target := m.target()
host, _ := daemon.SplitLocation(target)
probe := m.probe(host)
switch {
case target == "":
return nil
case probe.State == "unreachable" || probe.State == "unsupported":
m.notice = m.labelOf(host) + ": " + cmp.Or(probe.Detail, strings.ReplaceAll(probe.State, "_", " "))
return nil
case probe.State == "needs_auth":
m.notice = "Sign in to " + m.labelOf(host) + " first · ctrl+l"
return nil
case m.browse:
return func() tea.Msg { return FolderNewSessionMsg{Workspace: target} }
case target == m.workspace() && m.retry == nil:
return func() tea.Msg { return FolderPickerCancelMsg{} }
}
m.moving, m.notice = true, ""
return moveCmd(m.src, m.session.ID, m.workspace(), target, m.retry)
}
// Refused shows why the daemon would not move the session and lets you
// pick again.
func (m *FolderPicker) Refused(err error) {
m.moving, m.notice = false, err.Error()
}
// 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 := m.src
cmds = append(cmds, func() tea.Msg {
list, err := src.List(p)
return folderListMsg{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, p := m.src, row.path
cmds = append(cmds, func() tea.Msg {
repo, _ := src.Repo(p)
return folderRepoMsg{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 := row.path
return tea.Tick(previewDebounce, func(time.Time) tea.Msg { return folderPreviewTickMsg{Path: p} })
}
func (m *FolderPicker) fetchPreview(p string) tea.Cmd {
if m.previews[p] != nil {
return nil
}
m.previews[p] = &cachedFolderPreview{loading: true}
src := m.src
return func() tea.Msg {
preview, err := src.Preview(p)
return folderPreviewMsg{Path: p, Preview: preview, Err: err}
}
}