okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
597cb84
main
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 { 15List (ctx context.Context ,path string ) (daemon.FolderList ,error ) 16Repo (ctx context.Context ,path string ) (* daemon.Repo ,error ) 17Preview (ctx context.Context ,path string ) (daemon.FolderPreview ,error ) 18Sessions (ctx context.Context ) ([]daemon.Session ,error ) 19Move (ctx context.Context ,id ,workspace string ) (daemon.Session ,error ) 20Hosts (ctx context.Context ) ([]daemon.KnownHost ,error ) 21Host (ctx context.Context ,host string ) (daemon.HostStatus ,error ) 22Warm (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. 25Local ()bool 26} 27 28// daemonFolders is a folderSource over a daemon connection. 29type daemonFolders struct { 30conn * daemon.Connection 31condition daemon.SessionCondition 32} 33 34func (d daemonFolders )List (ctx context.Context ,p string ) (daemon.FolderList ,error ) { 35return daemon .ListFolders (ctx ,d .conn ,p ) 36} 37 38func (d daemonFolders )Repo (ctx context.Context ,p string ) (* daemon.Repo ,error ) { 39return daemon .FolderRepo (ctx ,d .conn ,p ) 40} 41 42func (d daemonFolders )Preview (ctx context.Context ,p string ) (daemon.FolderPreview ,error ) { 43return daemon .PreviewFolder (ctx ,d .conn ,p ) 44} 45 46func (d daemonFolders )Sessions (ctx context.Context ) ([]daemon.Session ,error ) { 47return daemon .ListSessions (ctx ,d .conn ) 48} 49 50func (d daemonFolders )Move (ctx context.Context ,id ,workspace string ) (daemon.Session ,error ) { 51return daemon .MoveSession (ctx ,d .conn ,id ,workspace ,d .condition ) 52} 53 54func (d daemonFolders )Hosts (ctx context.Context ) ([]daemon.KnownHost ,error ) { 55return daemon .ListHosts (ctx ,d .conn ) 56} 57 58func (d daemonFolders )Host (ctx context.Context ,h string ) (daemon.HostStatus ,error ) { 59return daemon .GetHost (ctx ,d .conn ,h ) 60} 61 62func (d daemonFolders )Warm (ctx context.Context ,h string ) (daemon.HostStatus ,error ) { 63return 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 { 72return func () tea.Msg { 73target := folderRequest (workspace ,input ) 74if _ ,_ ,remote := splitHost (target );! remote && ! strings .HasPrefix (target ,"/" ) { 75list ,err := src .List (context .Background (),target ) 76if err != nil { 77return FolderMovedMsg {SessionID :id ,Retry :retry ,Err :err } 78 } 79target = list .Path 80 } 81s ,err := src .Move (context .Background (),id ,target ) 82return 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 { 87src ,ctx ,gen := m .src ,m .readCtx ,m .sessionsGeneration 88return func () tea.Msg { 89hosts ,err := src .Hosts (ctx ) 90return hostsMsg {Gen :gen ,Hosts :hosts ,Err :err } 91 } 92} 93 94func (m FolderPicker )loadSessionsCmd () tea.Cmd { 95src ,ctx ,gen := m .src ,m .readCtx ,m .sessionsGeneration 96return func () tea.Msg { 97sessions ,err := src .Sessions (ctx ) 98return 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 { 105var cmds []tea.Cmd 106if row ,ok := m .highlighted ();ok { 107cmds = append (cmds ,m .warm (row .hostKey )) 108 } 109if host ,_ ,remote := splitHost (m .listed );remote { 110cmds = append (cmds ,m .warm (m .canonical (host ))) 111 } 112return 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 { 118if host == "" || m .warmed [host ]|| m .warmed [m .canonical (host )] { 119return nil 120 } 121m .warmed [host ]= true 122m .probes [host ]= daemon.HostStatus {Host :host ,State :"warming" } 123src ,ctx ,gen := m .src ,m .readCtx ,m .sessionsGeneration 124return tea .Batch (func () tea.Msg { 125status ,err := src .Warm (ctx ,host ) 126return 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 { 131for _ ,p := range m .probes { 132if p .State == "warming" { 133return true 134 } 135 } 136return false 137} 138 139// tick keeps the connecting face moving while any host still warms. 140func (m * FolderPicker )tick () tea.Cmd { 141if m .ticking || ! m .warming () { 142return nil 143 } 144m .ticking = true 145gen := m .sessionsGeneration 146return tea .Tick (faceInterval ,func (time.Time ) tea.Msg {return connectTickMsg {Gen :gen } }) 147} 148 149func pollHost (host string ,gen int64 ) tea.Cmd { 150return 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 { 157current ,ok := m .probes [msg .Host ] 158if ! ok || current .State != "warming" && ! msg .Warmed { 159return nil 160 } 161switch { 162case msg .Err != nil : 163if msg .Warmed { 164delete (m .probes ,msg .Host ) 165 } 166return nil 167case msg .Status .State == "warming" || msg .Status .State == "probing" : 168msg .Status .State = "warming" 169m .probes [msg .Host ]= msg .Status // it may say what the probe is doing 170return pollHost (msg .Host ,m .sessionsGeneration ) 171 } 172m .probes [msg .Host ]= msg .Status 173if msg .Status .Home != "" { 174_ ,home := daemon .SplitLocation (msg .Status .Home ) 175m .homes [m .canonical (msg .Host )]= home 176 } 177return 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 { 183probe := m .probe (host ) 184if ! m .src .Local ()|| probe .ControlPath == "" { 185m .notice = "Run `ssh " + host + "` on the machine albedo runs on, then try again" 186return nil 187 } 188gen := m .sessionsGeneration 189return tea .ExecProcess (signInCommand (host ,probe .ControlPath ),func (err error ) tea.Msg { 190return 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 { 197var cmds []tea.Cmd 198if p := m .listed ;p != "" && ! m .asked ["list:" + p ] { 199m .asked ["list:" + p ]= true 200src ,ctx ,gen := m .src ,m .readCtx ,m .sessionsGeneration 201cmds = append (cmds ,func () tea.Msg { 202list ,err := src .List (ctx ,p ) 203return folderListMsg {Gen :gen ,Path :p ,List :list ,Err :err } 204 }) 205 } 206first ,last := m .visibleRows () 207for _ ,row := range m .rows [first :last ] { 208if ! row .repo || m .asked ["repo:" + row .path ]|| ! m .reachable (row ) { 209continue 210 } 211m .asked ["repo:" + row .path ]= true 212src ,ctx ,gen ,p := m .src ,m .readCtx ,m .sessionsGeneration ,row .path 213cmds = append (cmds ,func () tea.Msg { 214repo ,_ := src .Repo (ctx ,p ) 215return folderRepoMsg {Gen :gen ,Path :p ,Repo :repo } 216 }) 217 } 218return 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 { 224row ,ok := m .highlighted () 225if ! ok || row .hostRow || m .previews [row .path ]!= nil || ! m .reachable (row ) { 226return nil 227 } 228p ,gen := row .path ,m .sessionsGeneration 229return tea .Tick (previewDebounce ,func (time.Time ) tea.Msg {return folderPreviewTickMsg {Gen :gen ,Path :p } }) 230} 231 232func (m * FolderPicker )fetchPreview (p string ) tea.Cmd { 233if m .previews [p ]!= nil { 234return nil 235 } 236m .previews [p ]= & cachedFolderPreview {loading :true } 237src ,ctx ,gen := m .src ,m .readCtx ,m .sessionsGeneration 238return func () tea.Msg { 239preview ,err := src .Preview (ctx ,p ) 240return folderPreviewMsg {Gen :gen ,Path :p ,Preview :preview ,Err :err } 241 } 242}