okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
10e0f66
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"cmp" 6"context" 7"strings" 8"time" 9 10"charm.land/bubbles/v2/textinput" 11 tea"charm.land/bubbletea/v2" 12) 13 14// WorkspaceRetry is a turn refused because the session's folder had gone 15// missing; it goes out again once the session moves. 16type WorkspaceRetry struct { 17Images []daemon.ImageAttachment 18Pastes []string 19Missing string 20Prompt string 21Continue bool 22} 23 24// FolderMovedMsg is the daemon's answer to moving a session. 25type FolderMovedMsg struct { 26Err error 27Retry * WorkspaceRetry 28SessionID string 29Workspace string 30Location * daemon.Location 31Session * daemon.Session 32} 33 34type FolderPickerCancelMsg struct {} 35 36// FolderOpenSessionMsg opens a session picked from a folder's preview. 37type FolderOpenSessionMsg struct {Session daemon.Session } 38 39// FolderNewSessionMsg starts a session in a folder picked while browsing. 40type FolderNewSessionMsg struct {Workspace string } 41 42type folderListMsg struct { 43Gen int64 44Err error 45Path string 46List daemon.FolderList 47} 48 49type folderRepoMsg struct { 50Gen int64 51Repo * daemon.Repo 52Path string 53} 54 55type folderPreviewMsg struct { 56Gen int64 57Err error 58Path string 59Preview daemon.FolderPreview 60} 61 62type folderPreviewTickMsg struct { 63Path string 64Gen int64 65} 66 67type hostsMsg struct { 68Gen int64 69Err error 70Hosts []daemon.KnownHost 71} 72 73// hostStatusMsg is a host's probe as the daemon answered it; Warmed marks 74// the answer to warming it, which has always settled. 75type hostStatusMsg struct { 76Gen int64 77Err error 78Host string 79Status daemon.HostStatus 80Warmed bool 81} 82 83type hostPollMsg struct { 84Host string 85Gen int64 86} 87 88// connectTickMsg advances the connecting face; Gen is the picker's, so a 89// closed picker's tick never speeds up the next one. 90type connectTickMsg struct {Gen int64 } 91 92type hostSignedInMsg struct { 93Gen int64 94Err error 95Host string 96} 97 98type folderSessionsMsg struct { 99Err error 100Sessions []daemon.Session 101Gen int64 102} 103 104const ( 105// queryRecent filters the recent folders, and offers hosts the text 106// starts. 107queryRecent queryKind = iota 108// queryHosts completes a [user@]host before its colon. 109queryHosts 110// queryListing lists a folder, here or on a host. 111queryListing 112) 113 114// folderRow is one folder in the picker's list. 115type folderRow struct { 116age * int64 117path ,name string 118// host labels a remote folder's host. 119host string 120// hostKey is the [user@]host a remote row is reached through. 121hostKey string 122// source is where a host row's host is known from: recent or config. 123source string 124matched []int 125// recent rows show where the folder is beside its name. 126recent bool 127// repo says the folder may be in a repository worth asking about. 128repo bool 129// hostRow is a host to complete, not a folder. 130hostRow bool 131} 132 133type cachedFolderPreview struct { 134 daemon.FolderPreview 135loading ,err bool 136} 137 138// FolderPicker moves a session to another folder: recent workspaces, or a 139// typed path listed through the daemon, beside a preview of the highlighted 140// folder. Browsing, from the sessions view, it starts a session in the 141// folder instead. Either way → steps into the folder's sessions to open one. 142type FolderPicker struct { 143readCtx context.Context 144cancelReads context.CancelFunc 145src folderSource 146asked map [string ]bool 147retry * WorkspaceRetry 148previews map [string ]* cachedFolderPreview 149repos map [string ]* daemon.Repo 150listings map [string ]* folderListMsg 151 152// session is the one being moved; browsing, only its workspace is set. 153session daemon.Session 154// Session failures survive query edits; a reopened picker rejects the old load. 155sessionsError string 156// homes are the folders ~ names: the daemon's under "", a remote 157// host's under its [user@]host, as listings and probes report them. 158homes map [string ]string 159// labels are how hosts read, by [user@]host; aliases name the host a 160// typed one turned out to be. 161labels map [string ]string 162aliases map [string ]string 163hosts []daemon.KnownHost 164// probes are hosts' reachability, warmed once each per picker. 165probes map [string ]daemon.HostStatus 166warmed map [string ]bool 167section string 168listed string 169notice string 170sessions []daemon.Session 171 172rows []folderRow 173input textinput.Model 174sessionsGeneration int64 175Width ,Height int 176cursor int 177sessionCursor int 178browse bool 179 180sessionsLoading bool 181moving bool 182 183// inSessions moves the cursor through the highlighted folder's sessions. 184inSessions bool 185 186// frame animates the face beside a host still connecting; ticking is 187// whether its next tick is already on its way. 188frame int 189ticking bool 190} 191 192func NewFolderPicker (src folderSource ,session daemon.Session ,retry * WorkspaceRetry )FolderPicker { 193ti := newTextInput () 194ti .Prompt = "" 195ti .Placeholder = "Type a path or choose a folder" 196st := ti .Styles () 197st .Focused .Placeholder ,st .Blurred .Placeholder = DefaultStyles .Faint ,DefaultStyles .Faint 198ti .SetStyles (st ) 199ti .Focus () 200readCtx ,cancelReads := context .WithCancel (context .Background ()) 201m := FolderPicker { 202readCtx :readCtx , 203cancelReads :cancelReads , 204input :ti , 205session :session , 206retry :retry , 207src :src , 208listings :map [string ]* folderListMsg {}, 209repos :map [string ]* daemon.Repo {}, 210asked :map [string ]bool {}, 211previews :map [string ]* cachedFolderPreview {}, 212homes :map [string ]string {}, 213labels :map [string ]string {}, 214aliases :map [string ]string {}, 215probes :map [string ]daemon.HostStatus {}, 216warmed :map [string ]bool {}, 217sessionsLoading :true , 218sessionsGeneration :int64 (nextPageGeneration ()), 219 } 220m .rebuild () 221return m 222} 223 224// NewFolderBrowser picks a folder to start a session in or to open one of 225// its sessions, starting from query. 226func NewFolderBrowser (src folderSource ,workspace ,query string )FolderPicker { 227m := NewFolderPicker (src , daemon.Session {Workspace :workspace },nil ) 228m .browse = true 229m .setQuery (query ) 230return m 231} 232 233func (m * FolderPicker )SetSize (width ,height int ) { 234m .Width ,m .Height = width ,height 235m .input .SetWidth (max (1 ,width - 8 )) 236} 237 238func (m * FolderPicker )Close () { 239if m .cancelReads != nil { 240m .cancelReads () 241 } 242m .sessionsGeneration = int64 (nextPageGeneration ()) 243} 244 245func (m FolderPicker )Init () tea.Cmd { 246return tea .Batch (m .loadSessionsCmd (),m .loadHostsCmd (),m .fetch (),m .previewCmd ()) 247} 248 249func (m FolderPicker )Update (msg tea.Msg ) (FolderPicker , tea.Cmd ) { 250switch msg := msg .(type ) { 251case folderSessionsMsg : 252if msg .Gen != m .sessionsGeneration { 253return m ,nil 254 } 255m .sessionsLoading = false 256if msg .Err != nil { 257m .sessionsError = msg .Err .Error () 258return m ,nil 259 } 260m .sessionsError = "" 261m .sessions ,m .inSessions = msg .Sessions ,false 262for _ ,s := range m .sessions { 263if host ,_ := daemon .SplitLocation (s .Workspace );host != "" && m .labels [host ]== "" { 264m .labels [host ]= sessionHost (s ) 265 } 266 } 267if parseQuery (m .input .Value ()).kind != queryListing { 268m .cursor = - 1 // nothing was there to pick yet 269m .rebuild () 270 } 271case hostsMsg : 272if msg .Gen != m .sessionsGeneration { 273return m ,nil 274 } 275if msg .Err != nil { 276return m ,nil // a daemon without the list still has the sessions' hosts 277 } 278m .hosts = msg .Hosts 279for _ ,h := range m .hosts { 280if h .Label != "" { 281m .labels [h .Host ]= h .Label 282 } 283 } 284if parseQuery (m .input .Value ()).kind != queryListing { 285m .rebuild () 286 } 287case hostPollMsg : 288if msg .Gen != m .sessionsGeneration { 289return m ,nil 290 } 291if m .probe (msg .Host ).State != "warming" { 292return m ,nil 293 } 294src ,ctx ,gen ,h := m .src ,m .readCtx ,m .sessionsGeneration ,msg .Host 295return m ,func () tea.Msg { 296status ,err := src .Host (ctx ,h ) 297return hostStatusMsg {Gen :gen ,Host :h ,Status :status ,Err :err } 298 } 299case hostStatusMsg : 300if msg .Gen != m .sessionsGeneration { 301return m ,nil 302 } 303return m ,m .probed (msg ) 304case connectTickMsg : 305if msg .Gen != m .sessionsGeneration { 306return m ,nil 307 } 308m .ticking = false 309m .frame ++ 310return m ,m .tick () 311case hostSignedInMsg : 312if msg .Gen != m .sessionsGeneration { 313return m ,nil 314 } 315if msg .Err != nil { 316m .notice = "Signing in to " + m .labelOf (msg .Host )+ " failed: " + msg .Err .Error () 317return m ,nil 318 } 319m .warmed [msg .Host ],m .warmed [m .canonical (msg .Host )]= false ,false 320return m ,m .warm (msg .Host ) 321case folderListMsg : 322if msg .Gen != m .sessionsGeneration { 323return m ,nil 324 } 325m .listings [msg .Path ]= & msg 326if msg .Err == nil { 327m .learnHome (msg .Path ,msg .List ) 328 } 329if msg .Path == m .listed { 330m .rebuild () 331 } 332case folderRepoMsg : 333if msg .Gen != m .sessionsGeneration { 334return m ,nil 335 } 336m .repos [msg .Path ]= msg .Repo 337return m ,nil 338case folderPreviewMsg : 339if msg .Gen != m .sessionsGeneration { 340return m ,nil 341 } 342if c := m .previews [msg .Path ];c != nil { 343c .loading ,c .err = false ,msg .Err != nil 344c .FolderPreview = msg .Preview 345 } 346return m ,nil 347case folderPreviewTickMsg : 348if msg .Gen != m .sessionsGeneration { 349return m ,nil 350 } 351if row ,ok := m .highlighted ();ok && row .path == msg .Path { 352return m ,m .fetchPreview (msg .Path ) 353 } 354return m ,nil 355case tea.KeyPressMsg : 356if m .moving { 357return m ,nil 358 } 359if msg .String ()== "ctrl+r" && m .sessionsError != "" { 360if m .sessionsLoading { 361return m ,nil 362 } 363m .sessionsLoading = true 364return m ,m .loadSessionsCmd () 365 } 366if m .inSessions { 367if cmd ,done := m .sessionKey (msg );done { 368return m ,cmd 369 } 370 } 371switch msg .String () { 372case "esc" ,"ctrl+c" ,"ctrl+d" : 373return m ,func () tea.Msg {return FolderPickerCancelMsg {} } 374case "ctrl+l" : 375if row ,ok := m .highlighted ();ok && m .probe (row .hostKey ).State == "needs_auth" { 376return m ,m .signIn (row .hostKey ) 377 } 378case "up" ,"ctrl+p" : 379m .cursor = max (0 ,m .cursor - 1 ) 380case "down" ,"ctrl+n" : 381m .cursor = max (0 ,min (len (m .rows )- 1 ,m .cursor + 1 )) 382case "tab" : 383if row ,ok := m .highlighted ();ok && row .hostRow { 384m .setQuery (row .path ) 385 }else if ok { 386m .setQuery (m .homed (row .path )+ "/" ) 387 } 388case "shift+tab" : 389m .setQuery (m .parentQuery ()) 390case "right" : 391row ,_ := m .highlighted () 392if m .input .Position ()< len ([]rune (m .input .Value ()))|| len (m .sessionsIn (row .path ))== 0 { 393return m .typed (msg ) 394 } 395m .inSessions ,m .sessionCursor = true ,0 396case "enter" : 397if row ,ok := m .highlighted ();ok && row .hostRow { 398m .setQuery (row .path ) 399break 400 } 401return m ,m .move () 402default : 403return m .typed (msg ) 404 } 405case tea.PasteMsg : 406return m .typed (msg ) 407 } 408return m ,m .follow () 409} 410 411// follow asks for what the list now shows: the listing, repositories and 412// the preview, and warms the hosts it reaches. 413func (m * FolderPicker )follow () tea.Cmd { 414return tea .Batch (m .fetch (),m .previewCmd (),m .warmShown ()) 415} 416 417// hostPollEvery is how often a warming host is asked again. 418const hostPollEvery = 750 * time .Millisecond 419 420// sessionKey moves through the highlighted folder's sessions; done is false 421// for a key that leaves them for the folder list, which then handles it. 422func (m * FolderPicker )sessionKey (msg tea.KeyPressMsg ) (cmd tea.Cmd ,done bool ) { 423row ,_ := m .highlighted () 424sessions := m .sessionsIn (row .path ) 425switch msg .String () { 426case "up" ,"ctrl+p" : 427m .sessionCursor = max (0 ,m .sessionCursor - 1 ) 428case "down" ,"ctrl+n" : 429m .sessionCursor = max (0 ,min (len (sessions )- 1 ,m .sessionCursor + 1 )) 430case "enter" : 431if m .sessionCursor < len (sessions ) { 432s := sessions [m .sessionCursor ] 433return func () tea.Msg {return FolderOpenSessionMsg {Session :s } },true 434 } 435case "left" ,"esc" : 436m .inSessions = false 437default : 438m .inSessions = false 439return nil ,false 440 } 441return nil ,true 442} 443 444// typed hands msg to the query and lists what it asks for now. 445func (m FolderPicker )typed (msg tea.Msg ) (FolderPicker , tea.Cmd ) { 446value := m .input .Value () 447var cmd tea.Cmd 448m .input ,cmd = m .input .Update (msg ) 449if m .input .Value ()!= value { 450m .notice = "" 451m .rebuild () 452 } 453return m ,tea .Batch (cmd ,m .follow ()) 454} 455 456func (m * FolderPicker )setQuery (q string ) { 457m .input .SetValue (q ) 458m .input .CursorEnd () 459m .notice = "" 460m .rebuild () 461} 462 463func (m * FolderPicker )move () tea.Cmd { 464target := m .target () 465host ,_ := daemon .SplitLocation (target ) 466probe := m .probe (host ) 467switch { 468case target == "" : 469return nil 470case probe .State == "unreachable" || probe .State == "unsupported" : 471m .notice = m .labelOf (host )+ ": " + cmp .Or (probe .Detail ,strings .ReplaceAll (probe .State ,"_" ," " )) 472return nil 473case probe .State == "needs_auth" : 474m .notice = "Sign in to " + m .labelOf (host )+ " first · ctrl+l" 475return nil 476case m .browse : 477return func () tea.Msg {return FolderNewSessionMsg {Workspace :target } } 478case target == m .workspace ()&& m .retry == nil : 479return func () tea.Msg {return FolderPickerCancelMsg {} } 480 } 481m .moving ,m .notice = true ,"" 482return moveCmd (m .src ,m .session .ID ,m .workspace (),target ,m .retry ) 483} 484 485// Refused shows why the daemon would not move the session and lets you 486// pick again. 487func (m * FolderPicker )Refused (err error ) { 488m .moving ,m .notice = false ,err .Error () 489}