okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
61c43ca
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"cmp" 6"path" 7"slices" 8"strings" 9"time" 10 11"github.com/sahilm/fuzzy" 12) 13 14// folderRequest is how the daemon is asked about a typed folder: ~, 15// absolute paths and host: locations as they are (a bare host: is its 16// home), anything else under the current folder. 17func folderRequest (workspace ,typed string )string { 18if host ,rest ,remote := splitHost (typed );remote { 19if rest != "/" { 20rest = strings .TrimSuffix (rest ,"/" ) 21 } 22if rest != "" && rest [0 ]!= '/' && rest [0 ]!= '~' { 23rest = "~/" + rest // as scp reads it: under the remote home 24 } 25return host + ":" + cmp .Or (rest ,"~" ) 26 } 27if typed != "/" { 28typed = strings .TrimSuffix (typed ,"/" ) 29 } 30if strings .HasPrefix (typed ,"/" )|| typed == "~" || strings .HasPrefix (typed ,"~/" ) { 31return typed 32 } 33return joinPlace (workspace ,typed ) 34} 35 36type queryKind int 37 38// folderQuery is what the typed text asks the picker for. 39type folderQuery struct { 40// dir is the folder to list, as typed. 41dir string 42// segment filters the list: the text after dir's last slash, or all of 43// it outside a listing. 44segment string 45kind queryKind 46} 47 48// parseQuery reads typed text: a path lists its folder filtered by the 49// last segment, scp-style host:path lists that folder on the host (host: 50// and host:name list its home), and a word without / or : filters the 51// recent folders, unless it holds an @ and so can only be user@host. 52func parseQuery (q string )folderQuery { 53if q == "~" { 54return folderQuery {kind :queryListing ,dir :"~" } 55 } 56if host ,rest ,remote := splitHost (q );remote { 57dir ,segment := host + ":~" ,rest 58if rest == "~" { 59segment = "" 60 } 61if i := strings .LastIndex (rest ,"/" );i >= 0 { 62dir ,segment = host + ":" + rest [:i + 1 ],rest [i + 1 :] 63 } 64return folderQuery {kind :queryListing ,dir :dir ,segment :segment } 65 } 66if i := strings .LastIndex (q ,"/" );i >= 0 { 67return folderQuery {kind :queryListing ,dir :q [:i + 1 ],segment :q [i + 1 :]} 68 } 69if strings .Contains (q ,"@" ) { 70return folderQuery {kind :queryHosts ,segment :q } 71 } 72return folderQuery {kind :queryRecent ,segment :q } 73} 74 75// bareHost is a [user@]host without its user. 76func bareHost (h string )string { 77return h [strings .LastIndex (h ,"@" )+ 1 :] 78} 79 80// hostRows are the hosts the text completes. A word only offers hosts it 81// starts, after the folders it matches, so a local filter reads as it 82// always did; with an @ every known host matches what follows it, and the 83// typed user goes with the completion. 84func hostRows (kind queryKind ,text string ,known []daemon.KnownHost ) []folderRow { 85user ,name := "" ,text 86if i := strings .LastIndex (text ,"@" );kind == queryHosts && i >= 0 { 87user ,name = text [:i + 1 ],text [i + 1 :] 88 } 89names := make ([]string ,len (known )) 90for i ,h := range known { 91names [i ]= bareHost (h .Host ) 92 } 93var matches []fuzzy.Match 94switch { 95case kind == queryHosts : 96matches = fuzzyNames (name ,names ) 97case name != "" : 98for i ,n := range names { 99if strings .HasPrefix (strings .ToLower (n ),strings .ToLower (name ))|| strings .HasPrefix (strings .ToLower (known [i ].Label ),strings .ToLower (name )) { 100matches = append (matches , fuzzy.Match {Str :n ,Index :i }) 101 } 102 } 103 } 104var rows []folderRow 105for _ ,match := range matches { 106h := known [match .Index ] 107target ,label := h .Host ,cmp .Or (h .Label ,h .Host ) 108if user != "@" && user != "" { 109target = user + bareHost (h .Host ) 110label = target 111 } 112row := folderRow {path :label + ":" ,name :label ,host :label ,hostKey :target ,source :h .Source ,hostRow :true } 113if shift := len (label )- len (match .Str );strings .HasSuffix (label ,match .Str ) { 114for _ ,i := range match .MatchedIndexes { 115row .matched = append (row .matched ,i + shift ) 116 } 117 } 118rows = append (rows ,row ) 119 } 120return rows 121} 122 123// frecency weighs each session in a folder by how recently it was used, so 124// a folder you work in daily outranks one you used often long ago: 4 within 125// the hour, halving past a day, a week and a month. 126func frecency (last * int64 ,now time.Time )float64 { 127weight := 4.0 128for _ ,within := range []time.Duration {time .Hour ,24 * time .Hour ,7 * 24 * time .Hour ,30 * 24 * time .Hour } { 129if last != nil && now .Sub (time .Unix (* last ,0 ))< within { 130return weight 131 } 132weight /= 2 133 } 134return weight 135} 136 137type recentFolder struct { 138last * int64 139path ,host string 140score float64 141} 142 143// recentFolders are the distinct workspaces of sessions, most frecent 144// first, with the current one leading. 145func recentFolders (sessions []daemon.Session ,current string ,now time.Time ) []recentFolder { 146index := map [string ]int {} 147var out []recentFolder 148for _ ,s := range sessions { 149if s .Workspace == "" { 150continue 151 } 152i ,ok := index [s .Workspace ] 153if ! ok { 154i ,index [s .Workspace ]= len (out ),len (out ) 155out = append (out ,recentFolder {path :s .Workspace ,host :sessionHost (s )}) 156 } 157f := & out [i ] 158f .score += frecency (s .LastAssistantAt ,now ) 159if s .LastAssistantAt != nil && (f .last == nil || * s .LastAssistantAt > * f .last ) { 160f .last = s .LastAssistantAt 161 } 162 } 163if _ ,ok := index [current ];! ok && current != "" { 164out = append (out ,recentFolder {path :current }) 165 } 166slices .SortStableFunc (out ,func (a ,b recentFolder )int { 167switch { 168case a .path == current : 169return - 1 170case b .path == current : 171return 1 172 } 173return cmp .Compare (b .score ,a .score ) 174 }) 175return out 176} 177 178// fuzzyNames matches segment against names, best first; an empty segment 179// keeps them all in order. 180func fuzzyNames (segment string ,names []string ) []fuzzy.Match { 181if segment == "" { 182all := make ([]fuzzy.Match ,len (names )) 183for i ,n := range names { 184all [i ]= fuzzy.Match {Str :n ,Index :i } 185 } 186return all 187 } 188return fuzzy .Find (segment ,names ) 189} 190 191func (m FolderPicker )workspace ()string {return m .session .Workspace } 192 193// homed shortens p under its home to start with ~, led by its host's 194// label when it is remote. 195func (m FolderPicker )homed (p string )string { 196host ,_ := daemon .SplitLocation (p ) 197return placeText (p ,m .labelOf (host ),m .homes [m .canonical (host )]) 198} 199 200// canonical is the [user@]host a typed host turned out to be, or the one 201// that reads as it. 202func (m FolderPicker )canonical (host string )string { 203if c ,ok := m .aliases [host ];ok { 204return c 205 } 206for c ,label := range m .labels { 207if label == host { 208return c 209 } 210 } 211return host 212} 213 214func (m FolderPicker )labelOf (host string )string { 215return cmp .Or (m .labels [m .canonical (host )],m .labels [host ],host ) 216} 217 218// known are the hosts to complete: the daemon's recent ones, then hosts of 219// the sessions here in case the daemon has no list, then its ssh config. 220func (m FolderPicker )known () []daemon.KnownHost { 221seen := map [string ]bool {} 222var out []daemon.KnownHost 223add := func (h daemon.KnownHost ) { 224if h .Host != "" && ! seen [h .Host ] { 225seen [h .Host ]= true 226out = append (out ,h ) 227 } 228 } 229for _ ,h := range m .hosts { 230if h .Source != "config" { 231add (h ) 232 } 233 } 234for _ ,f := range recentFolders (m .sessions ,"" ,time .Now ()) { 235if host ,_ := daemon .SplitLocation (f .path );host != "" { 236add (daemon.KnownHost {Host :host ,Label :f .host ,Source :"recent" }) 237 } 238 } 239for _ ,h := range m .hosts { 240add (h ) 241 } 242return out 243} 244 245// probe is what is known of a host's reachability: its warm-up, else the 246// state the daemon's host list cached. Local folders have none. 247func (m FolderPicker )probe (host string ) daemon.HostStatus { 248if host == "" { 249return daemon.HostStatus {} 250 } 251if p ,ok := m .probes [host ];ok { 252return p 253 } 254if p ,ok := m .probes [m .canonical (host )];ok { 255return p 256 } 257if i := slices .IndexFunc (m .hosts ,func (h daemon.KnownHost )bool {return h .Host == m .canonical (host ) });i >= 0 { 258return daemon.HostStatus {Host :host ,State :m .hosts [i ].State } 259 } 260return daemon.HostStatus {} 261} 262 263// reachable says a row's folder can be asked about now: it is local, or 264// its host answered ready. 265func (m FolderPicker )reachable (row folderRow )bool { 266return row .hostKey == "" || m .probe (row .hostKey ).State == "ready" 267} 268 269// listing is the typed folder's listing, once it has arrived without error. 270func (m FolderPicker )listing () (daemon.FolderList ,bool ) { 271if l := m .listings [m .listed ];l != nil && l .Err == nil { 272return l .List ,true 273 } 274return daemon.FolderList {},false 275} 276 277func (m FolderPicker )highlighted () (folderRow ,bool ) { 278if m .cursor >= 0 && m .cursor < len (m .rows ) { 279return m .rows [m .cursor ],true 280 } 281return folderRow {},false 282} 283 284// rebuild lists what the query asks for: recent folders, or the typed 285// folder's directories filtered by the last segment. 286func (m * FolderPicker )rebuild () { 287before ,_ := m .highlighted () 288q := parseQuery (m .input .Value ()) 289segment ,listing := q .segment ,q .kind == queryListing 290m .rows ,m .listed = nil ,"" 291var candidates []folderRow 292switch q .kind { 293case queryHosts : 294m .section = "hosts" 295case queryRecent : 296m .section = "recent" 297for _ ,f := range recentFolders (m .sessions ,m .workspace (),time .Now ()) { 298host ,p := daemon .SplitLocation (f .path ) 299candidates = append (candidates ,folderRow {path :f .path ,host :f .host ,hostKey :host ,name :path .Base (p ),recent :true ,age :f .last ,repo :true }) 300 } 301case queryListing : 302m .listed = folderRequest (m .workspace (),q .dir ) 303m .section = "in " + m .homed (m .listed ) 304if l ,ok := m .listing ();ok { 305m .section = "in " + m .homed (l .Path ) 306host ,_ := daemon .SplitLocation (l .Path ) 307for _ ,e := range l .Entries { 308if e .Hidden && ! strings .HasPrefix (segment ,"." ) { 309continue 310 } 311row := folderRow {path :joinPlace (l .Path ,e .Name ),name :e .Name ,hostKey :host ,host :m .labelOf (host ),repo :e .VCS != "" } 312if e .Modified > 0 { 313row .age = & e .Modified 314 } 315candidates = append (candidates ,row ) 316 } 317 } 318 } 319names := make ([]string ,len (candidates )) 320for i ,row := range candidates { 321names [i ]= row .name 322 } 323for _ ,match := range fuzzyNames (segment ,names ) { 324row := candidates [match .Index ] 325row .matched = match .MatchedIndexes 326m .rows = append (m .rows ,row ) 327 } 328if q .kind != queryListing { 329m .rows = append (m .rows ,hostRows (q .kind ,segment ,m .known ())... ) 330 } 331m .cursor = 0 332if i := slices .IndexFunc (m .rows ,func (r folderRow )bool {return r .path == before .path });i >= 0 { 333m .cursor = i 334 }else if ! listing && segment == "" && len (m .rows )> 1 && ! m .browse { 335m .cursor = 1 // the current folder leads; the likeliest move is the next 336 } 337} 338 339// learnHome records the home a listing reported for its host, and which 340// host a typed one turned out to be. 341func (m * FolderPicker )learnHome (asked string ,l daemon.FolderList ) { 342if l .Home == "" { 343return 344 } 345host ,home := daemon .SplitLocation (l .Home ) 346m .homes [host ]= home 347typed ,_ ,remote := splitHost (asked ) 348canonical ,_ := daemon .SplitLocation (l .Path ) 349if ! remote || canonical == "" || typed == canonical { 350return 351 } 352m .aliases [typed ]= canonical 353if m .labels [canonical ]== "" { 354m .labels [canonical ]= typed 355 } 356if p ,ok := m .probes [typed ];ok && m .probes [canonical ].State == "" { 357m .probes [canonical ],m .warmed [canonical ]= p ,true 358 } 359} 360 361// parentQuery is the folder above the one being listed, or above the 362// session's folder when nothing is typed. 363func (m FolderPicker )parentQuery ()string { 364dir := m .workspace () 365if m .listed != "" { 366dir = m .listed 367if l ,ok := m .listing ();ok { 368dir = l .Path 369 } 370 } 371// a remote ~ is only known once its listing answers 372if host ,rest ,remote := splitHost (dir );remote && ! strings .HasPrefix (rest ,"/" ) { 373if rest == "~" { 374return m .input .Value () 375 } 376return host + ":" + scpRelative (path .Dir (rest ))+ "/" 377 } 378up := m .homed (parentPlace (dir )) 379if strings .HasSuffix (up ,"/" ) { 380return up 381 } 382return up + "/" 383} 384 385// target is the folder enter moves to: the highlighted row, or the typed 386// folder itself when it has nothing left to pick. 387func (m FolderPicker )target ()string { 388if row ,ok := m .highlighted ();ok { 389return row .path 390 } 391if q := parseQuery (m .input .Value ());q .kind == queryListing && q .segment == "" { 392if l ,ok := m .listing ();ok { 393return l .Path 394 } 395 } 396return "" 397}