tui: browse hosts in the folder picker, warm them before committing, and show the kernel link on the header's host
#58: host:/path and @ queries, host completion from GET /hosts, reachability and sign-in on rows, connecting/lost states on the header host, plus the ssh sign-in handoff for turns refused with host_needs_auth (#56). Signed-off-by: dawn <90008@klbr.net>
4 unmodified lines567891011121362 unmodified lines767778798081828384858687888965 unmodified lines4 unmodified lines "encoding/json" "errors" "fmt" "net/http" "strings" "sync" "sync/atomic")
62 unmodified lines } return fmt.Sprintf("http://127.0.0.1:%d", port)}
// Local says the daemon runs on this machine, so an ssh master opened here// is one it can ride.func (c *Connection) Local() bool { return strings.HasPrefix(c.BaseURL(), "http://127.0.0.1:")}
func (c *Connection) Update(other *Connection) { if c == nil || other == nil || c == other { return }65 unmodified lines3 unmodified lines456789101112276 unmodified linesExpand all2892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863 unmodified lines "context" "encoding/json" "net/http" "net/url" "time")
// The folder browser's routes, as robot-docs/workspaces.md describes them.
276 unmodified linesExpand all// MoveSession moves an idle session to another folder.func MoveSession(ctx context.Context, conn *Connection, id, workspace string) (Session, error) { return mutateSession(ctx, conn, operation{Name: "move session", Method: http.MethodPost, Path: sessionPath(id, "/workspace"), Body: map[string]string{"workspace": workspace}, Policy: authRecovery}, http.StatusOK)}
// HostStatus is what the daemon knows of a remote host over ssh: State is// ready, warming, needs_auth, unreachable or unsupported; Detail says why// when it is not ready. OS, Arch and Home come with a ready one.type HostStatus struct { Host string `json:"host"` State string `json:"state"` Detail string `json:"detail"` OS string `json:"os"` Arch string `json:"arch"` Home string `json:"home"` ControlPath string `json:"control_path"`}
// KnownHost is a host worth offering: one sessions worked on ("recent"), or// a Host entry of the daemon's ssh config ("config"). State is the cached// probe's, empty when there is none.type KnownHost struct { Host string `json:"host"` Label string `json:"label"` Source string `json:"source"` State string `json:"state"`}
// ListHosts is GET /hosts. It never probes.func ListHosts(ctx context.Context, conn *Connection) ([]KnownHost, error) { var result []KnownHost err := executeRead(ctx, conn, operation{Name: "list hosts", Method: http.MethodGet, Path: "/hosts", Policy: readRecovery}, func(data []byte) error { fields, err := object(data) if err != nil { return err } return required(fields, "hosts", &result) }) return result, err}
// GetHost is the cached probe of host, or warming while one runs.func GetHost(ctx context.Context, conn *Connection, host string) (HostStatus, error) { var status HostStatus err := executeRead(ctx, conn, operation{Name: "read host", Method: http.MethodGet, Path: "/hosts/" + url.PathEscape(host), Policy: readRecovery}, func(data []byte) error { return decodeHostStatus(data, &status) }) return status, err}
// WarmHost probes host now, past a cached answer, and answers how it went.func WarmHost(ctx context.Context, conn *Connection, host string) (HostStatus, error) { // the probe waits up to a minute ctx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() var status HostStatus operation := operation{Name: "warm host", Method: http.MethodPost, Path: "/hosts/" + url.PathEscape(host) + "/warm", Body: map[string]any{}, Policy: authRecovery} // requestBytes directly: executeMutation would cap the wait at 20 s body, err := requestBytes(ctx, conn, operation, responseLimits{successStatus: http.StatusOK, bodyBytes: 64 * 1024, errorBytes: 64 * 1024}) if err != nil { return status, err } if err := decodeHostStatus(body, &status); err != nil { return status, invalidResponse(operation, "", err) } return status, nil}
// WarmHost probes the session's host again, past a cached failure, so a// turn after a sign-in finds it ready.func (c *ChatClient) WarmHost(ctx context.Context, host string) (HostStatus, error) { return WarmHost(ctx, c.conn, host)}
// Host is the daemon's cached probe of host.func (c *ChatClient) Host(ctx context.Context, host string) (HostStatus, error) { return GetHost(ctx, c.conn, host)}
// LocalDaemon says the daemon runs on this machine, so an ssh master opened// here is one it can ride.func (c *ChatClient) LocalDaemon() bool { return c.conn.Local()}
func decodeHostStatus(data []byte, status *HostStatus) error { fields, err := object(data) if err != nil { return err } if err := required(fields, "host", &status.Host); err != nil { return err } if err := required(fields, "state", &status.State); err != nil { return err } return json.Unmarshal(data, status)}35 unmodified lines363738394041424344454647484921 unmodified lines717273747576777879808182838485868788893 unmodified lines35 unmodified linestype AgentStatus struct { Phase *AgentPhase `json:"phase,omitempty"` Running bool `json:"running"` Idle bool `json:"idle"` // KernelStale reports a kernel on older code than the daemon, waiting for // an idle moment (or /kernel upgrade) to be swapped. KernelStale bool `json:"kernelStale,omitempty"` // KernelLink is how the session reaches its kernel: none, booting, // attached, reattaching or lost; empty from a daemon that does not say. KernelLink string `json:"kernelLink,omitempty"`}
// ContextWindow reads the model's context window from the session's last// prepared request. It is nil when neither a catalog nor the configuration21 unmodified lines if result.Running == result.Idle { return fieldError("idle") } result.Phase = &phase if _, said := fields["kernel"]; !said { return nil } var kernel struct { Stale bool `json:"stale"` Link string `json:"link"` } if err = required(fields, "kernel", &kernel); err != nil { return err } result.KernelStale, result.KernelLink = kernel.Stale, kernel.Link return nil }) if err != nil { return nil, err3 unmodified lines81 unmodified lines8283848586878889909192939495969798240 unmodified linesExpand all33934034134234334434534634734834935053 unmodified lines404405406407408409410411412661 unmodified linesExpand all10741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096155 unmodified linesExpand all125212531254125512561257125812591260126112621263144 unmodified linesExpand all14081409141014111412137914131414141514161417141814191420142114221423142486 unmodified lines1511151215131514151515161517151815197 unmodified lines1527152815291530149015311532153315341535153615371538153915401541154215431544102 unmodified linesExpand all164716481649165016021651165216531654165516561657165816591660166116621663434 unmodified linesExpand all20982099210021012102210321042105210621072108210921102111211268 unmodified lines21812182218321842185218621872188218924 unmodified lines22142215221622172156221822192220222122221 unmodified line22242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252101 unmodified linesExpand all23542355235623572358235923602361236223632364148 unmodified linesExpand all81 unmodified lines Generation int64 Revision uint64}
// ChatHostMsg is the probe of the host a remote workspace is on.type ChatHostMsg struct { Err error SessionID string Workspace string Status daemon.HostStatus Generation int64}
// ChatWindowMsg carries the context window for the model a usage event named.type ChatWindowMsg struct { Err error Tokens *int240 unmodified linesExpand all ToolProgressText string ThoughtProgressText string Renderer TranscriptRenderer
// hostHome is the remote home ~ names in the header, once the host's // probe reported it for hostAsked, the workspace it was asked for. hostHome, hostAsked string
// userRows are the settled rows where user messages start. userRows []int effortOptions []string
53 unmodified lines // loadingOlder prevents overlapping history fetches. loadingOlder bool isSending bool interruptDeferred bool hostAuth *hostSignIn sentHere bool // graphemes says the terminal measures grapheme clusters (mode 2027), // which Bubble Tea turns off when it hands the terminal to the editor. graphemes bool661 unmodified linesExpand all } return nil}
// hostHomeCmd asks a remote workspace's host where its home is, once per// workspace, after the kernel attached: the probe has answered by then.func (m *ChatModel) hostHomeCmd() tea.Cmd { host, _ := daemon.SplitLocation(m.Workspace) if host == "" || m.hostAsked == m.Workspace || m.Status.KernelLink != "attached" { return nil } m.hostAsked = m.Workspace client, ctx, id, generation, workspace := m.client, m.streamCtx, m.SessionID, m.Generation, m.Workspace return func() tea.Msg { status, err := client.Host(ctx, host) return ChatHostMsg{SessionID: id, Generation: generation, Workspace: workspace, Status: status, Err: err} }}
func (m ChatModel) statusPollCmd() tea.Cmd { id, generation := m.SessionID, m.Generation return tea.Tick(750*time.Millisecond, func(time.Time) tea.Msg { return ChatStatusPollMsg{SessionID: id, Generation: generation} })}155 unmodified linesExpand all case "ctrl+v": return m, PasteClipboardImageCmd(m.SessionID, m.Generation) case "ctrl+g": return m, m.openEditorCmd() case "ctrl+l": if m.hostAuth != nil && m.hostAuth.here { return m, m.signInCmd() } case "pgup", "pgdown": delta := m.Viewport.Height() if msg.String() == "pgup" { delta = -delta144 unmodified linesExpand all } m.refreshViewportContent() } } home := m.hostHomeCmd() return m, tea.Batch(m.startAnimation(), m.statusPollCmd()) return m, tea.Batch(m.startAnimation(), m.statusPollCmd(), home)
case ChatHostMsg: if msg.SessionID == m.SessionID && msg.Generation == m.Generation && msg.Workspace == m.Workspace && msg.Err == nil { _, m.hostHome = daemon.SplitLocation(msg.Status.Home) } return m, nil
case ChatStreamEventMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation || m.streamStopped { return m, nil }86 unmodified lines return m, nil } if msg.Err == nil && (msg.Receipt.Status == "rejected" || msg.Receipt.DeliveryStatus == "cancelled" || msg.Receipt.DeliveryStatus == "committed") { delete(m.pendingContinuations, msg.Handle.ID()) m.dropSignIn() if rejection := msg.Receipt.Rejection(); rejection != nil { m.ClearNotices() m.AddError(rejection.Error()) }7 unmodified lines m.pendingUsers[index].Queued = true m.pendingUsers[index].BlockingReason = msg.Receipt.BlockingReason m.refreshViewportContent() } return m, m.resolveOperationCmd(msg.Handle) return m, tea.Batch(m.resolveOperationCmd(msg.Handle), m.hostAuthCmd(msg.Receipt.BlockingReason))
case chatHostAuthMsg: m.offerSignIn(msg) return m, nil
case chatSignedInMsg: m.signedIn(msg) return m, nil
case ChatTurnSentMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil }102 unmodified linesExpand all// Moved takes the session's new folder, then sends again the turn a// missing folder refused.func (m *ChatModel) Moved(moved daemon.Session, retry *WorkspaceRetry) tea.Cmd { workspace := moved.Workspace m.Workspace, m.Host = workspace, sessionHost(moved) m.Workspace, m.Host, m.hostHome = workspace, sessionHost(moved), "" m.Renderer.Workspace = workspace m.rebuildSettledLines() // settled rows name paths from the old workspace m.refreshViewportContent() return m.resend(retry)}
// resend sends again a turn the daemon refused, once what refused it is fixed.func (m *ChatModel) resend(retry *WorkspaceRetry) tea.Cmd { if retry == nil { return nil } if retry.Image != nil && m.AttachedImage == nil {434 unmodified linesExpand all return "stopped" } return "" } if host := m.reaching(); host != "" { return "connecting to " + host + "…" } // the port owner gave up and forgot the kernel, so nothing reattaches if host, _ := daemon.SplitLocation(m.Workspace); host != "" && m.Status.KernelLink == "lost" { return "kernel on " + cmp.Or(m.Host, host) + " lost · the next turn starts a fresh one" } if m.isSending || m.pendingSendCount() > 0 && !m.Status.Running { return "preparing" } if m.Progress != nil {68 unmodified lines workspace = homePath(workspace) } else { host = cmp.Or(m.Host, host) lead = host + ":" workspace = underHome(workspace, m.hostHome) } model := m.Model if m.Effort != "" { model += ":" + m.Effort24 unmodified lines continue } place := m.Styles.Muted.Render(l.place) if host != "" { place = hostStyle(host).Render(host) + m.Styles.Muted.Render(":"+l.place) place = m.hostSegment(host) + m.Styles.Muted.Render(":"+l.place) } if l.brand { return titleRule(width, brand(m.AgentName)+m.Styles.Faint.Render(" on ")+place, m.Styles.Faint.Render(right)) }1 unmodified line } return m.Styles.Faint.Render(ansi.Truncate(model, width, "…"))}
// hostSegment is the header's host in its own color once the kernel there is// attached: faint while it boots or reattaches, the error color once lost.func (m ChatModel) hostSegment(host string) string { switch m.Status.KernelLink { case "booting", "reattaching": return m.Styles.Faint.Render(host) case "lost": return m.Styles.Error.Render(host) } return hostStyle(host).Render(host)}
// reaching is the remote host a booting or reattaching kernel is on.func (m ChatModel) reaching() string { host, _ := daemon.SplitLocation(m.Workspace) if link := m.Status.KernelLink; host == "" || link != "booting" && link != "reattaching" { return "" } return cmp.Or(m.Host, host)}
func (m ChatModel) View() string { width := m.chatWidth() pad := strings.Repeat(" ", m.padding()) var rows []string101 unmodified linesExpand all } if len(counts) > 0 && m.sidebarWidth() > 0 { counts = counts[1:] } if m.Status.KernelStale { counts = append([]string{"kernel older"}, counts...) } return counts}
func (m ChatModel) renderGlances() string {148 unmodified linesExpand all19 unmodified lines202122232425262728293031323317 unmodified lines5152535455565758596061626364656667686970717237 unmodified lines1101111121131141151161171181191201211221231241251261271281291301311321331341351361371384 unmodified lines1431441451461061471481491501514 unmodified lines15615715815911912016016116216316416516616716816917017117217317412517517617712817817918013113213318118218318418518618718818919019119219319419519619719819920020120220320420520620720813520921013713813921121221321421521621721821922014122122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028165 unmodified lines34734834935021735135235335435535635735835936036136236336436536615 unmodified lines38238338438524624738638738838939039139239339439539639739839940029 unmodified lines43043143243343443543643743843944044144214 unmodified lines4574584594603074614624634644654664674684694704714724734744752 unmodified lines47847948048132048248348432232348548648748848949049149249349432549549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056111 unmodified lines5735745755763475775785795803505815825835845855865873535885893553565905915925935945955965975985993646006016026036048 unmodified lines61361461561661761861962062162262313 unmodified lines63763863964040264164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168240840968368468568668768829 unmodified lines7187197207217227237247257267277287297304527317327337347357367373 unmodified lines74174274374474574674774874975075175275375475547175675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286325 unmodified lines88989089189250789389489589689711 unmodified lines9099109119125275285299139149159169179189199209219229239249259269271 unmodified line9299309319325409339349359369371 unmodified line93994094194294394494594694794894995095195295395495595695721 unmodified lines9799809819825829839849859869878 unmodified lines9969979989995991000100110021003100413 unmodified lines19 unmodified lines 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 }17 unmodified linesfunc (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.ImageAttachment37 unmodified lines}
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 }
type hostSignedInMsg struct { Err error Host string}
type folderSessionsMsg struct { Err error Sessions []daemon.Session Gen int644 unmodified lines// 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 host, _ := daemon.SplitLocation(target); host == "" && !strings.HasPrefix(target, "/") { if _, _, remote := splitHost(target); !remote && !strings.HasPrefix(target, "/") { list, err := src.List(target) if err != nil { return FolderMovedMsg{SessionID: id, Retry: retry, Err: err} }4 unmodified lines }}
// folderRequest is how the daemon is asked about a typed folder: ~,// absolute paths and host:/paths as they are, anything else under the// current 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 host, _ := daemon.SplitLocation(typed); host != "" || strings.HasPrefix(typed, "/") || typed == "~" || strings.HasPrefix(typed, "~/") { if strings.HasPrefix(typed, "/") || typed == "~" || strings.HasPrefix(typed, "~/") { return typed } return path.Join(workspace, typed) return joinPlace(workspace, typed)}
// splitQuery reads a typed path as the folder to list and the segment that// filters it. Text without a slash filters the recent folders instead.func splitQuery(q string) (dir, segment string, listing bool) {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 "~", "", true return folderQuery{kind: queryListing, dir: "~"} } i := strings.LastIndex(q, "/") if i < 0 { return "", q, false 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} } return q[:i+1], q[i+1:], true 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 {65 unmodified linestype folderRow struct { age *int64 path, name string // host labels a remote folder's host. host string 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.FolderPreview15 unmodified lines // 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 // home is the daemon's home, which ~ names, once a listing reports it. home 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.Session29 unmodified lines 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()14 unmodified lines m.input.SetWidth(max(1, width-8))}
func (m FolderPicker) Init() tea.Cmd { return tea.Batch(m.loadSessionsCmd(), m.fetch(), m.previewCmd()) 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()2 unmodified lines}
func (m FolderPicker) workspace() string { return m.session.Workspace }
// homed shortens p under the daemon's home to start with ~.// 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 { if m.home == "" { return homePath(p) 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 } return underHome(p, m.home) 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, true11 unmodified lines// 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() dir, segment, listing := splitQuery(m.input.Value()) q := parseQuery(m.input.Value()) segment, listing := q.segment, q.kind == queryListing m.rows, m.listed = nil, "" var candidates []folderRow if !listing { 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, name: path.Base(f.path), recent: true, age: f.last, repo: true}) candidates = append(candidates, folderRow{path: f.path, host: f.host, hostKey: host, name: path.Base(p), recent: true, age: f.last, repo: true}) } } else { m.listed = folderRequest(m.workspace(), dir) 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: path.Join(l.Path, e.Name), name: e.Name, repo: e.VCS != ""} 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)8 unmodified lines 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 {13 unmodified lines return m, nil } m.sessionsError = "" m.sessions, m.inSessions = msg.Sessions, false if _, _, listing := splitQuery(m.input.Value()); !listing { 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 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 && msg.List.Home != "" { m.home = msg.List.Home if msg.Err == nil { m.learnHome(msg.Path, msg.List) } if msg.Path == m.listed { m.rebuild() }29 unmodified lines } 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 { 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())3 unmodified lines 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, tea.Batch(m.fetch(), m.previewCmd()) 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))}
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": 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()25 unmodified lines if m.input.Value() != value { m.notice = "" m.rebuild() } return m, tea.Batch(cmd, m.fetch(), m.previewCmd()) return m, tea.Batch(cmd, m.follow())}
func (m *FolderPicker) setQuery(q string) { m.input.SetValue(q)11 unmodified lines if l, ok := m.listing(); ok { dir = l.Path } } up := m.homed(path.Dir(dir)) if up == "/" { return "/" // 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 typed1 unmodified linefunc (m FolderPicker) target() string { if row, ok := m.highlighted(); ok { return row.path } if _, segment, listing := splitQuery(m.input.Value()); listing && segment == "" { if q := parseQuery(m.input.Value()); q.kind == queryListing && q.segment == "" { if l, ok := m.listing(); ok { return l.Path } }1 unmodified line}
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{} }21 unmodified lines }) } first, last := m.visibleRows() for _, row := range m.rows[first:last] { if !row.repo || m.asked["repo:"+row.path] { if !row.repo || m.asked["repo:"+row.path] || !m.reachable(row) { continue } m.asked["repo:"+row.path] = true src, p := m.src, row.path8 unmodified lines// 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 || m.previews[row.path] != nil { 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} })13 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298// The folder picker's query grammar and host states are bug-prone logic// the e2e suite cannot reach without a real remote host: which text lists a// host's folders, which completes a host, how remote paths fold under that// host's home, and that a dead host is never picked.package tui
import ( "errors" "slices" "strings" "testing" "time"
tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi"
"albedo/cli/internal/daemon")
func TestParseQuery(t *testing.T) { for _, c := range []struct { q, dir, segment string kind queryKind }{ {"", "", "", queryRecent}, {"albedo", "", "albedo", queryRecent}, {"~", "~", "", queryListing}, {"~/pro", "~/", "pro", queryListing}, {"/srv/", "/srv/", "", queryListing}, {"src/", "src/", "", queryListing}, {"mayer@cher", "", "mayer@cher", queryHosts}, {"@cher", "", "@cher", queryHosts}, {"chernobog:", "chernobog:~", "", queryListing}, {"chernobog:~", "chernobog:~", "", queryListing}, {"chernobog:pro", "chernobog:~", "pro", queryListing}, {"chernobog:~/proj/", "chernobog:~/proj/", "", queryListing}, {"chernobog:~/proj/al", "chernobog:~/proj/", "al", queryListing}, {"mayer@chernobog:/srv/", "mayer@chernobog:/srv/", "", queryListing}, {"[::1]:/tmp/x", "[::1]:/tmp/", "x", queryListing}, // a slash before the colon is a local path, as the daemon reads it {"a/b:c/", "a/b:c/", "", queryListing}, } { got := parseQuery(c.q) if got.kind != c.kind || got.dir != c.dir || got.segment != c.segment { t.Errorf("parseQuery(%q) = %+v, want kind %d dir %q segment %q", c.q, got, c.kind, c.dir, c.segment) } }}
func TestFolderRequestKeepsLocationsAndJoinsRelativePaths(t *testing.T) { for _, c := range []struct{ workspace, typed, want string }{ {"/w", "chernobog:~", "chernobog:~"}, {"/w", "chernobog:~/proj/", "chernobog:~/proj"}, {"/w", "chernobog:/", "chernobog:/"}, {"/w", "chernobog:proj/", "chernobog:~/proj"}, {"/w", "~/x/", "~/x"}, {"/w", "src/", "/w/src"}, {"mayer@chernobog:/home/mayer/proj", "src/", "mayer@chernobog:/home/mayer/proj/src"}, {"mayer@chernobog:/home/mayer/proj", "../", "mayer@chernobog:/home/mayer"}, } { if got := folderRequest(c.workspace, c.typed); got != c.want { t.Errorf("folderRequest(%q, %q) = %q, want %q", c.workspace, c.typed, got, c.want) } }}
func TestHostRowsCompleteOnlyWhatTheTextAsksFor(t *testing.T) { known := []daemon.KnownHost{ {Host: "mayer@chernobog", Label: "chernobog", Source: "recent"}, {Host: "devbox", Label: "devbox", Source: "config"}, } names := func(rows []folderRow) (out []string) { for _, r := range rows { out = append(out, r.path+"="+r.hostKey) } return out } for _, c := range []struct { text string kind queryKind want string }{ {"", queryRecent, ""}, {"cher", queryRecent, "chernobog:=mayer@chernobog"}, // a bare word is a prefix, never a fuzzy match, so local filtering // does not fill with hosts {"cb", queryRecent, ""}, {"@", queryHosts, "chernobog:=mayer@chernobog devbox:=devbox"}, {"@dev", queryHosts, "devbox:=devbox"}, {"root@cher", queryHosts, "root@chernobog:=root@chernobog"}, } { if got := strings.Join(names(hostRows(c.kind, c.text, known)), " "); got != c.want { t.Errorf("hostRows(%q) = %q, want %q", c.text, got, c.want) } }}
// fakeFolders answers the picker the way the daemon does for one local// folder and one remote host.type fakeFolders struct { lists map[string]daemon.FolderList hosts []daemon.KnownHost probe daemon.HostStatus warmed []string}
func (f *fakeFolders) List(p string) (daemon.FolderList, error) { if l, ok := f.lists[p]; ok { return l, nil } return daemon.FolderList{}, errors.New("no such folder")}func (f *fakeFolders) Repo(string) (*daemon.Repo, error) { return nil, nil }func (f *fakeFolders) Preview(p string) (daemon.FolderPreview, error) { return daemon.FolderPreview{Path: p}, nil}func (f *fakeFolders) Sessions() ([]daemon.Session, error) { label := "chernobog" return []daemon.Session{ {ID: "a", Workspace: "/Users/dawn/proj/albedo"}, {ID: "b", Workspace: "mayer@chernobog:/home/mayer/proj/albedo", Location: &daemon.Location{Label: &label}}, }, nil}func (f *fakeFolders) Move(id, workspace string) (daemon.Session, error) { return daemon.Session{ID: id, Workspace: workspace}, nil}func (f *fakeFolders) Hosts() ([]daemon.KnownHost, error) { return f.hosts, nil }func (f *fakeFolders) Host(string) (daemon.HostStatus, error) { return f.probe, nil}func (f *fakeFolders) Warm(h string) (daemon.HostStatus, error) { f.warmed = append(f.warmed, h) return f.probe, nil}func (f *fakeFolders) Local() bool { return true }
// settle runs the picker's commands until it waits on nothing but timers,// which it drops: previews and polls are not what these tests are about.func settle(t *testing.T, m FolderPicker, cmd tea.Cmd) FolderPicker { t.Helper() queue := []tea.Cmd{cmd} for len(queue) > 0 { next := queue[0] queue = queue[1:] if next == nil { continue } done := make(chan tea.Msg, 1) go func() { done <- next() }() var msg tea.Msg select { case msg = <-done: case <-time.After(100 * time.Millisecond): continue // a tick } if batch, ok := msg.(tea.BatchMsg); ok { queue = append(queue, batch...) continue } if msg == nil { continue } var more tea.Cmd m, more = m.Update(msg) queue = append(queue, more) } return m}
func typeQuery(t *testing.T, m FolderPicker, text string) FolderPicker { for _, r := range text { var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) m = settle(t, m, cmd) } return m}
func remoteFolders(state, detail string) *fakeFolders { return &fakeFolders{ lists: map[string]daemon.FolderList{ "chernobog:~": {Path: "mayer@chernobog:/home/mayer", Home: "mayer@chernobog:/home/mayer", Entries: []daemon.FolderEntry{{Name: "proj"}, {Name: "notes"}}}, "chernobog:~/proj": {Path: "mayer@chernobog:/home/mayer/proj", Home: "mayer@chernobog:/home/mayer", Entries: []daemon.FolderEntry{{Name: "albedo", VCS: "jj"}}}, }, hosts: []daemon.KnownHost{{Host: "mayer@chernobog", Label: "chernobog", Source: "recent"}, {Host: "devbox", Label: "devbox", Source: "config"}}, probe: daemon.HostStatus{Host: "mayer@chernobog", State: state, Detail: detail, Home: "/home/mayer", OS: "Linux", Arch: "aarch64"}, }}
func pickerFrame(m FolderPicker) string { return ansi.Strip(m.View())}
func TestPickerBrowsesAHostFoldingUnderItsHome(t *testing.T) { src := remoteFolders("ready", "") m := NewFolderPicker(src, daemon.Session{ID: "a", Workspace: "/Users/dawn/proj/albedo"}, nil) m.SetSize(100, 16) m = settle(t, m, m.Init())
m = typeQuery(t, m, "chernobog:") frame := pickerFrame(m) if !strings.Contains(frame, "in chernobog:~") || !strings.Contains(frame, "proj") || !strings.Contains(frame, "notes") { t.Fatalf("chernobog: did not list the host's home:\n%s", frame) } // tab enters the highlighted folder in the host's own ~ m = settle(t, m, func() tea.Cmd { var c tea.Cmd; m, c = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}); return c }()) var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) m = settle(t, m, cmd) m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) m = settle(t, m, cmd) if got := m.input.Value(); got != "chernobog:~/proj/" { t.Fatalf("tab went to %q", got) } if frame := pickerFrame(m); !strings.Contains(frame, "in chernobog:~/proj") || !strings.Contains(frame, "albedo") { t.Fatalf("the host's folder did not list:\n%s", frame) } // shift+tab goes back up through the canonical path, still folded m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) m = settle(t, m, cmd) if got := m.input.Value(); got != "chernobog:~/" { t.Fatalf("shift+tab went to %q", got) } // enter moves the session to the canonical location m = typeQuery(t, m, "pro") m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) moved, ok := cmd().(FolderMovedMsg) if !ok || moved.Workspace != "mayer@chernobog:/home/mayer/proj" { t.Fatalf("enter moved to %#v", moved) } // back among the recent folders, the remote one reads like the preview's rule m.setQuery("") m.cursor = slices.IndexFunc(m.rows, func(r folderRow) bool { return r.hostKey != "" }) if row := m.row(m.rows[m.cursor], false, false, false, 60, time.Now()); !strings.Contains(ansi.Strip(row), "chernobog:~ › p") { t.Fatalf("a recent remote folder's where should lead with host:~: %q", ansi.Strip(row)) } if len(src.warmed) != 1 { t.Fatalf("the host was warmed %d times, want once: %v", len(src.warmed), src.warmed) }}
func TestPickerCompletesHostsAndRefusesADeadOne(t *testing.T) { src := remoteFolders("unsupported", "needs python >= 3.11") m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "") m.SetSize(140, 16) m = settle(t, m, m.Init())
m = typeQuery(t, m, "@") frame := pickerFrame(m) if !strings.Contains(frame, "chernobog") || !strings.Contains(frame, "devbox") || !strings.Contains(frame, "ssh config") { t.Fatalf("@ did not list the known hosts:\n%s", frame) } // highlighting the host warmed it, and the row says why it is no good if !strings.Contains(frame, "needs python >= 3.11") { t.Fatalf("the dead host's row does not say why:\n%s", frame) }
// the recent remote folder is the same host: enter refuses it m = NewFolderBrowser(src, "/Users/dawn/proj/albedo", "") m.SetSize(100, 16) m = settle(t, m, m.Init()) for range 3 { if row, _ := m.highlighted(); row.hostKey != "" { break } var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) m = settle(t, m, cmd) } var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { if _, started := cmd().(FolderNewSessionMsg); started { t.Fatalf("enter started a session on a dead host") } } if !strings.Contains(m.notice, "needs python >= 3.11") { t.Fatalf("enter did not say why: %q", m.notice) }}
func TestPickerOffersSignInForAHostThatNeedsAPerson(t *testing.T) { src := remoteFolders("needs_auth", "Permission denied (publickey)") m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "") m.SetSize(100, 16) m = settle(t, m, m.Init()) m = typeQuery(t, m, "cher") frame := pickerFrame(m) if !strings.Contains(frame, "sign in · ctrl+l") { t.Fatalf("the host row does not offer to sign in:\n%s", frame) } // without the daemon's control path there is nothing to open here var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl}) if cmd != nil || !strings.Contains(m.notice, "ssh mayer@chernobog") { t.Fatalf("ctrl+l without a control path: cmd %v, notice %q", cmd != nil, m.notice) }}