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"fmt" 7"math" 8"path" 9"slices" 10"strings" 11"time" 12 13"charm.land/lipgloss/v2" 14"github.com/charmbracelet/x/ansi" 15) 16 17// folderChrome is the rows around the list: the title, the filter with its 18// rules, and the footer. 19const folderChrome = 6 20 21// visibleRows is the range of rows the list window shows, the way 22// scrollWindow cuts it under the section rule. 23func (m FolderPicker )visibleRows () (first ,last int ) { 24h ,n := max (1 ,cmp .Or (m .Height ,24 )- folderChrome ),len (m .rows )+ 1 25if n <= h { 26return 0 ,len (m .rows ) 27 } 28start := min (max (0 ,m .cursor + 1 - h / 2 ),n - h ) 29return max (0 ,start - 1 ),min (len (m .rows ),start + h - 1 ) 30} 31 32func (m FolderPicker )View ()string { 33width := cmp .Or (m .Width ,80 ) 34heading ,title := "sessions by folder" ,"" 35if ! m .browse { 36heading ,title = "move session" ,DefaultStyles .Faint .Render (sessionTitle (m .session )) 37 } 38return listFrame { 39title :brand ("albedo" )+ " " + DefaultStyles .Muted .Render (heading ), 40right :title , 41filter :promptLead ()+ m .input .View (), 42list :m .list , 43paneWidth :halfPane , 44pane :func (w ,h int ) []string { 45pane := m .preview (w - 1 ,h ) 46for i := range pane { 47pane [i ]= svFit (pane [i ],w - 1 )+ " " 48 } 49return pane 50 }, 51footer :m .footer (width ), 52 }.view (m .Width ,m .Height ) 53} 54 55// narrowKeys is the width the full keys need beside a status; less than that 56// and a failed session list keeps only its retry. minStatusRoom is the least 57// a status can have beside the keys. 58const ( 59narrowKeys = 28 60minStatusRoom = 16 61) 62 63// footer is the keys on the left and the most pressing thing to say on the 64// right, which takes what the keys leave. 65func (m FolderPicker )footer (width int )string { 66hints := m .hints () 67keys := " " + keyHints (hints ... ) 68if m .sessionsError != "" && width - ansi .StringWidth (keys )< narrowKeys { 69hints = []hint {m .retryHint ()} 70keys = " " + keyHints (hints ... ) 71 } 72room := width - ansi .StringWidth (keys )- 3 73if room < minStatusRoom { 74room = width - 2 // too little beside the keys: an urgent status takes a row of its own 75 } 76status ,urgent := m .status (room ) 77return footerLine (width ,hints ,status ,urgent ) 78} 79 80// status is the most pressing thing to say in room columns: a move in 81// flight, a refusal, a dead host, a failed listing, a folder that went 82// missing, or else where the session is. 83func (m FolderPicker )status (room int ) (string ,bool ) { 84dead := m .deadHighlighted () 85switch { 86case m .moving : 87return DefaultStyles .Faint .Render ("moving…" ),true 88case m .notice != "" : 89return DefaultStyles .Warning .Render (tailFit (m .notice ,room )),true 90case dead != "" : 91return DefaultStyles .Error .Render (ansi .Truncate (dead ,room ,"…" )),true 92case m .sessionsError != "" : 93return DefaultStyles .Warning .Render (ansi .Truncate ("Recent sessions: " + m .sessionsError ,room ,"…" )),true 94case m .retry != nil : 95const missing = "Folder not found: " 96return DefaultStyles .Warning .Render (missing )+ DefaultStyles .Muted .Render (tailFit (m .homed (m .retry .Missing ),room - ansi .StringWidth (missing ))),true 97case m .browse :// nothing moves, so there is no "now in" 98return "" ,false 99 } 100const here = "now in " 101where := tailFit (m .homed (m .workspace ()),room - ansi .StringWidth (here )) 102if where == "" { 103return "" ,false 104 } 105return DefaultStyles .Faint .Render (here )+ DefaultStyles .Muted .Render (where ),false 106} 107 108// hints are the footer's keys for where the cursor is; → shows only when 109// the highlighted folder has sessions to step into. 110func (m FolderPicker )hints () []hint { 111var hints []hint 112if m .sessionsError != "" { 113hints = append (hints ,m .retryHint ()) 114 } 115if m .inSessions { 116return append (hints ,hint {"↑↓" ,"move" },hint {"enter" ,"open" },hint {"←" ,"folders" }) 117 } 118hints = append (hints ,hint {"↑↓" ,"move" },hint {"tab" ,"open" },hint {"⇧tab" ,"up" }) 119row ,ok := m .highlighted () 120if ok && len (m .sessionsIn (row .path ))> 0 { 121hints = append (hints ,hint {"→" ,"sessions" }) 122 } 123if ok && m .probe (row .hostKey ).State == "needs_auth" { 124hints = append (hints ,hint {"ctrl+l" ,"sign in" }) 125 } 126action := "move here" 127if m .browse { 128action = "new session" 129 } 130return append (hints ,hint {"enter" ,action },hint {"esc" ,"back" }) 131} 132 133// retryHint is the way back into a session list that failed to load. 134func (m FolderPicker )retryHint ()hint { 135if m .sessionsLoading { 136return hint {"ctrl+r" ,"retrying…" } 137 } 138return hint {"ctrl+r" ,"retry sessions" } 139} 140 141// tailFit keeps the end of plain text, where a path names its folder. 142func tailFit (s string ,w int )string { 143if w <= 0 { 144return "" 145 } 146if n := ansi .StringWidth (s );n > w { 147return "…" + ansi .TruncateLeft (s ,n - w + 1 ,"" ) 148 } 149return s 150} 151 152// list is the section rule over the rows, at exactly width × height. 153func (m FolderPicker )list (width ,height int ) []string { 154if len (m .rows )== 0 { 155return scrollWindow ([]string {sectionRule (m .section ,0 ,width ),DefaultStyles .Faint .Render (" " + m .emptyNote ())},1 ,width ,height ) 156 } 157here ,latest := m .workspace (),m .latestOther () 158now := time .Now () 159return drawWindow (len (m .rows )+ 1 ,func (i int )string { 160if i == 0 { 161return sectionRule (m .section ,len (m .rows ),width ) 162 } 163row := m .rows [i - 1 ] 164return m .row (row ,i - 1 == m .cursor ,row .path == here ,row .path == latest ,width ,now ) 165 },m .cursor + 1 ,width ,height ) 166} 167 168// typedHost is the host a remote listing is typed for, as a row. 169func (m FolderPicker )typedHost ()folderRow { 170if host ,_ ,remote := splitHost (m .listed );remote { 171return folderRow {hostKey :m .canonical (host ),hostRow :true } 172 } 173return folderRow {} 174} 175 176// deadHighlighted is why the highlighted row's host cannot be reached, in 177// full, or empty when it can. 178func (m FolderPicker )deadHighlighted ()string { 179row ,_ := m .highlighted () 180if row .hostKey == "" { 181row = m .typedHost () 182 } 183if p := m .probe (row .hostKey );p .State == "unreachable" || p .State == "unsupported" { 184return m .labelOf (row .hostKey )+ ": " + cmp .Or (p .Detail ,p .State ) 185 } 186return "" 187} 188 189func (m FolderPicker )emptyNote ()string { 190l := m .listings [m .listed ] 191host ,_ ,remote := splitHost (m .listed ) 192switch { 193case m .listed == "" && m .section == "hosts" : 194return "no known hosts match; type host: to browse one" 195case m .listed == "" : 196return "no matches" 197case l == nil && remote && m .probe (m .canonical (host )).State == "warming" : 198label := m .labelOf (host ) 199return reachingText (label ,m .probe (m .canonical (host )).Step )+ " " + DefaultStyles .Agent .Render (connectingFace (label ,m .frame )) 200case l == nil : 201return "loading…" 202case l .Err != nil : 203return l .Err .Error () 204case len (l .List .Entries )== 0 : 205return "No folders here" 206 } 207return "no matches" 208} 209 210// latestOther is the most recently used recent folder besides this one. 211func (m FolderPicker )latestOther ()string { 212best ,at := "" ,int64 (0 ) 213for _ ,row := range m .rows { 214if row .recent && row .age != nil && * row .age > at && row .path != m .workspace () { 215best ,at = row .path ,* row .age 216 } 217 } 218return best 219} 220 221// where is the folder a row is in, led by its host when it is remote; a 222// host row says where the host is known from. 223func (m FolderPicker )where (row folderRow )string { 224switch { 225case row .hostRow && row .source == "config" : 226return "ssh config" 227case row .hostRow : 228return "recent host" 229 } 230host ,p := daemon .SplitLocation (row .path ) 231if host == "" { 232return strings .Join (crumbParts (m .homed (path .Dir (p )))," › " ) 233 } 234// the host leads the first crumb, as in the preview's rule 235crumbs := crumbParts (scpRelative (underHome (path .Dir (p ),m .homes [m .canonical (host )]))) 236crumbs [0 ]= cmp .Or (row .host ,m .labelOf (host ))+ ":" + crumbs [0 ] 237return strings .Join (crumbs ," › " ) 238} 239 240// reach is a remote row's host state where its head and age would go: 241// nothing once ready or while warming (the row is faint then), what is 242// wrong otherwise, and the way in when a person must sign in. 243func (m FolderPicker )reach (row folderRow ) (note string ,style lipgloss.Style ,dim bool ) { 244p := m .probe (row .hostKey ) 245switch p .State { 246case "warming" : 247return "" ,DefaultStyles .Faint ,true 248case "needs_auth" : 249return "sign in · ctrl+l" ,DefaultStyles .Warning ,false 250case "unreachable" ,"unsupported" : 251return cmp .Or (p .Detail ,p .State ),DefaultStyles .Error ,false 252 } 253return "" ,DefaultStyles .Faint ,false 254} 255 256// row is a folder in the sessions view's grammar: bar, glyph, name with 257// the matched letters lit, then quiet columns for where, head and age. 258func (m FolderPicker )row (row folderRow ,selected ,here ,latest bool ,width int ,now time.Time )string { 259glyph ,glyphStyle := "· " ,DefaultStyles .Faint 260switch { 261case selected : 262glyph ,glyphStyle = "◆ " ,DefaultStyles .Agent 263case here : 264glyph = "○ " 265case latest : 266glyph ,glyphStyle = "● " ,DefaultStyles .Success 267 } 268whereW ,tagW ,ageW := 0 ,12 ,4 269if (row .recent || row .hostRow )&& width >= 48 { 270whereW = 16 271 } 272nameW := min (18 ,max (4 ,width - 4 - (whereW + 2 )- (tagW + 2 )- (ageW + 2 ))) 273bar := " " 274if selected { 275bar = selectBar () 276 } 277note ,noteStyle ,dim := m .reach (row ) 278base := lipgloss .NewStyle () 279switch { 280case dim : 281base = DefaultStyles .Faint 282case selected : 283base = DefaultStyles .Bold 284 } 285line := bar + glyphStyle .Render (glyph )+ " " + litName (row .name ,row .matched ,nameW ,base ) 286if whereW > 0 { 287line += " " + DefaultStyles .Faint .Render (svCell (m .where (row ),whereW ,false )) 288 } 289if note != "" { 290line += " " + noteStyle .Render (svCell (note ,tagW + 2 + ageW ,false )) 291if selected { 292return selectedLine (line ,width ) 293 } 294return line 295 } 296line += " " + DefaultStyles .Faint .Render (svCell (repoTag (m .repos [row .path ]),tagW ,false )) 297age := "" 298if here { 299age = "here" 300 }else if row .age != nil { 301age = svCompactAge (row .age ,now ) 302 } 303line += " " + DefaultStyles .Faint .Render (svCell (age ,ageW ,true )) 304if selected { 305return selectedLine (line ,width ) 306 } 307return line 308} 309 310// litName fits name into w columns in base, its fuzzy-matched letters lit. 311func litName (name string ,matched []int ,w int ,base lipgloss.Style )string { 312lit := DefaultStyles .Prompt .Bold (true ).Underline (true ) 313fitted := ansi .Truncate (name ,w ,"…" ) 314var b strings.Builder 315for i ,r := range fitted { 316style := base 317if slices .Contains (matched ,i ) { 318style = lit 319 } 320b .WriteString (style .Render (string (r ))) 321 } 322return b .String ()+ strings .Repeat (" " ,max (0 ,w - ansi .StringWidth (fitted ))) 323} 324 325// crumbParts splits a homed path into the folders it passes through. 326func crumbParts (p string ) []string { 327if p == "/" { 328return []string {"/" } 329 } 330if strings .HasPrefix (p ,"/" ) { 331return append ([]string {"/" },strings .Split (p [1 :],"/" )... ) 332 } 333return strings .Split (p ,"/" ) 334} 335 336// repoTag is a list row's head: a git branch or detached commit, or a jj 337// bookmark or change. 338func repoTag (r * daemon.Repo )string { 339switch { 340case r == nil : 341return "" 342case r .Kind == "jj" && r .Bookmark != nil : 343return "⚑ " + r .Bookmark .Name 344case r .Kind == "jj" : 345if r .Change != "" { 346return "@ " + r .Change 347 } 348return "" 349 } 350if r .Branch != "" || r .Commit != "" { 351return "⎇ " + cmp .Or (r .Branch ,r .Commit ) 352 } 353return "" 354} 355 356// ── preview ──────────────────────────────────────────────────────────────── 357 358// preview is the highlighted folder in exactly height rows: its path 359// over its languages, the repository as the root of a two-level tree, then 360// the sessions working there. 361func (m FolderPicker )preview (width ,height int ) []string { 362row ,ok := m .highlighted () 363if ! ok { 364if row = m .typedHost ();row .hostKey == "" { 365return make ([]string ,height ) 366 } 367 } 368if row .hostRow || ! m .reachable (row ) { 369return m .hostPane (row ,width ,height ) 370 } 371c := m .previews [row .path ] 372var body []string 373var languages []daemon.LanguageShare 374var tree * daemon.FolderPreview 375switch { 376case c == nil || c .loading : 377body = []string {DefaultStyles .Faint .Render ("looking…" )} 378case c .err : 379body = []string {DefaultStyles .Faint .Render ("Could not open this folder" )} 380default : 381languages ,tree = c .Languages ,& c .FolderPreview 382if c .Repo != nil { 383body = append (body ,repoLine (c .Repo ,time .Now ())) 384 } 385 } 386// The tree fits around a third of the pane kept for the sessions, then 387// the sessions fill whatever the tree left. 388sessions := m .sessionsIn (row .path ) 389if tree != nil { 390kept := 0 391if len (sessions )> 0 { 392kept = sessionHead + min (len (sessions ),max (3 ,height / 3 )) 393 } 394body = append (body ,fitTree (* tree ,height - 2 - len (body )- kept )... ) 395 } 396lines := []string {m .paneRule (row .path ,languages ,width ),"" } 397for _ ,l := range body { 398lines = append (lines ," " + l ) 399 } 400lines = append (lines ,m .sessionBlock (sessions ,width ,height - len (lines )- sessionHead )... ) 401return append (lines ,make ([]string ,max (0 ,height - len (lines )))... )[:height ] 402} 403 404// hostPane is a host, or a folder on a host not reached yet: how the host 405// answers, then what it runs and where ~ is once it is ready. 406func (m FolderPicker )hostPane (row folderRow ,width ,height int ) []string { 407p := m .probe (row .hostKey ) 408label := m .labelOf (row .hostKey ) 409var body []string 410switch p .State { 411case "" ,"warming" : 412body = []string {DefaultStyles .Faint .Render (reachingText (label ,p .Step )+ " " )+ DefaultStyles .Agent .Render (connectingFace (label ,m .frame ))} 413case "ready" : 414if p .OS != "" { 415body = append (body ,DefaultStyles .Muted .Render (strings .TrimSpace (p .OS + " " + p .Arch ))) 416 } 417if home := m .homes [m .canonical (row .hostKey )];home != "" { 418body = append (body ,DefaultStyles .Faint .Render ("~ is " )+ DefaultStyles .Muted .Render (home )) 419 } 420case "needs_auth" : 421body = []string {DefaultStyles .Warning .Render (cmp .Or (p .Detail ,"needs a person to sign in" )),DefaultStyles .Faint .Render ("ctrl+l signs in here" )} 422default : 423body = []string {DefaultStyles .Error .Render (cmp .Or (p .Detail ,p .State ))} 424 } 425lines := []string {DefaultStyles .Decor .Render ("─ " )+ hostStyle (label ).Render (label )+ " " + DefaultStyles .Decor .Render (strings .Repeat ("─" ,max (0 ,width - 3 - ansi .StringWidth (label )))),"" } 426for _ ,l := range body { 427lines = append (lines ," " + ansi .Truncate (l ,max (0 ,width - 2 ),"…" )) 428 } 429if ! row .hostRow { 430lines = append (lines ,m .sessionBlock (m .sessionsIn (row .path ),width ,height - len (lines )- sessionHead )... ) 431 } 432return append (lines ,make ([]string ,max (0 ,height - len (lines )))... )[:height ] 433} 434 435// sessionHead is the rows above the sessions: their rule between blanks. 436const sessionHead = 3 437 438// sessionBlock is the sessions working in the previewed folder under their 439// rule, in at most room rows. Stepped into, it follows the cursor. 440func (m FolderPicker )sessionBlock (sessions []daemon.Session ,width ,room int ) []string { 441if len (sessions )== 0 || room <= 0 { 442return nil 443 } 444now := time .Now () 445rows := make ([]string ,len (sessions )) 446for i ,s := range sessions { 447selected := m .inSessions && i == m .sessionCursor 448glyph ,style := "· " ,DefaultStyles .Faint 449switch { 450case selected : 451glyph ,style = "◆ " ,DefaultStyles .Agent 452case s .LastAssistantAt != nil && now .Sub (time .Unix (* s .LastAssistantAt ,0 ))< time .Hour : 453glyph ,style = "● " ,DefaultStyles .Success 454 } 455titleStyle := DefaultStyles .Muted 456bar := " " 457if selected { 458titleStyle = DefaultStyles .Bold 459bar = selectBar () 460 } 461title := titleStyle .Render (sessionTitle (s )) 462line := bar + " " + style .Render (glyph )+ title 463if s .LastAssistantAt != nil { 464line += DefaultStyles .Faint .Render (" " + svCompactAge (s .LastAssistantAt ,now )) 465 } 466if selected { 467line = selectedLine (line ,width ) 468 } 469rows [i ]= line 470 } 471if len (rows )> room && ! m .inSessions { 472rows = append (rows [:room - 1 ],DefaultStyles .Faint .Render (fmt .Sprintf (" … %d more" ,len (sessions )- room + 1 ))) 473 } 474return append ([]string {"" ,plainRule ("sessions" ,width ),"" },scrollWindow (rows ,m .sessionCursor ,width ,min (room ,len (rows )))... ) 475} 476 477func (m FolderPicker )sessionsIn (p string ) []daemon.Session { 478return slices .DeleteFunc (slices .Clone (m .sessions ),func (s daemon.Session )bool {return s .Workspace != p }) 479} 480 481// paneRule heads the preview like sectionRule heads the list: the folder's 482// breadcrumbs, then a rule split by language share, then the language that 483// leads. 484func (m FolderPicker )paneRule (p string ,languages []daemon.LanguageShare ,w int )string { 485parts := crumbParts (m .homed (p )) 486lead := func ()string {return DefaultStyles .Decor .Render ("─ " )+ crumbs (parts )+ " " } 487tail := "" 488if len (languages )> 0 { 489tail = " " + languageStyle (languages [0 ].Name ,knownLanguageColor (languages [0 ].Color )).Render (strings .ToLower (languages [0 ].Name ))+ " " 490 } 491for len (parts )> 2 && ansi .StringWidth (lead ()+ tail )> w - 8 { 492parts = append ([]string {"…" },parts [2 :]... ) 493 } 494n := max (0 ,w - ansi .StringWidth (lead ())- ansi .StringWidth (tail )) 495var rule strings.Builder 496used ,sum := 0 ,0.0 497for _ ,l := range languages { 498sum += l .Share 499k := min (n ,int (float64 (n )* sum + 0.5 ))- used 500rule .WriteString (languageStyle (l .Name ,knownLanguageColor (l .Color )).Render (strings .Repeat ("─" ,k ))) 501used += k 502 } 503rule .WriteString (DefaultStyles .Decor .Render (strings .Repeat ("─" ,n - used ))) 504return lead ()+ rule .String ()+ tail 505} 506 507// crumbs is a path as soft breadcrumbs, the leaf in bold. 508func crumbs (parts []string )string { 509out := make ([]string ,len (parts )) 510for i ,p := range parts { 511out [i ]= DefaultStyles .Muted .Bold (i == len (parts )- 1 ).Render (p ) 512 } 513return strings .Join (out ,DefaultStyles .Decor .Render (" › " )) 514} 515 516// plainRule is a section rule without a count. 517func plainRule (label string ,w int )string { 518head := " " + label + " " 519return DefaultStyles .Decor .Render ("─" )+ DefaultStyles .Muted .Bold (true ).Render (head )+ DefaultStyles .Decor .Render (strings .Repeat ("─" ,max (0 ,w - 1 - ansi .StringWidth (head )))) 520} 521 522// repoLine is the repository at the root of the tree: the head, how many 523// files changed when any did, and when it was last touched. 524func repoLine (r * daemon.Repo ,now time.Time )string { 525var parts []string 526switch { 527case r .Kind == "jj" : 528head := "" 529if r .Bookmark != nil { 530head = DefaultStyles .Agent .Render ("⚑ " + r .Bookmark .Name ) 531if r .Bookmark .Ahead > 0 { 532head += DefaultStyles .Faint .Render (fmt .Sprintf ("+%d" ,r .Bookmark .Ahead )) 533 } 534parts = append (parts ,head ) 535 } 536if r .Change != "" { 537parts = append (parts ,DefaultStyles .Faint .Render ("@ " )+ DefaultStyles .Muted .Render (r .Change )) 538 } 539case r .Branch != "" || r .Commit != "" : 540parts = append (parts ,DefaultStyles .Agent .Render ("⎇ " + cmp .Or (r .Branch ,r .Commit ))) 541 } 542if r .Changed != nil && * r .Changed > 0 { 543parts = append (parts ,DefaultStyles .Warning .Render (fmt .Sprintf ("✎ %d" ,* r .Changed ))) 544 } 545if r .Touched != nil { 546parts = append (parts ,DefaultStyles .Faint .Render (svCompactAge (r .Touched ,now ))) 547 } 548return strings .Join (parts ," " ) 549} 550 551// fitTree is the preview's tree in at most rows lines. When the two levels 552// do not fit, directories show fewer children, then none, and last the 553// top level itself is cut short. 554func fitTree (p daemon.FolderPreview ,rows int ) []string { 555for _ ,children := range []int {math .MaxInt ,2 ,0 } { 556if out := folderTree (p ,children ,len (p .Tree ));len (out )<= rows { 557return out 558 } 559 } 560for top := len (p .Tree )- 1 ;top > 0 ;top -- { 561if out := folderTree (p ,0 ,top );len (out )<= rows { 562return out 563 } 564 } 565return nil 566} 567 568// folderTree draws the preview's two levels, at most children under each 569// directory and top entries in all: directories muted with a slash, files 570// in their language's color, changes noted beside them. With no children 571// shown, directories stand alone. 572func folderTree (p daemon.FolderPreview ,children ,top int ) []string { 573colors := map [string ]string {} 574for _ ,l := range p .Languages { 575colors [l .Name ]= knownLanguageColor (l .Color ) 576 } 577branch := func (last bool )string { 578text := "├─ " 579if last { 580text = "╰─ " 581 } 582return DefaultStyles .Decor .Render (text ) 583 } 584more := func (n int )string {return DefaultStyles .Faint .Render (fmt .Sprintf ("… %d more" ,n )) } 585name := func (n daemon.FolderNode )string { 586s := DefaultStyles .Muted .Render (n .Name )+ DefaultStyles .Decor .Render ("/" ) 587if ! n .Dir { 588s = languageStyle (n .Language ,colors [n .Language ]).Render (n .Name ) 589 } 590if n .Changed > 0 { 591s += " " + DefaultStyles .Faint .Render (fmt .Sprintf ("✎ %d" ,n .Changed )) 592 } 593return s 594 } 595tree ,hidden := p .Tree [:min (top ,len (p .Tree ))],p .More + max (0 ,len (p .Tree )- top ) 596var out []string 597for i ,entry := range tree { 598last := i == len (tree )- 1 && hidden == 0 599out = append (out ,branch (last )+ name (entry )) 600if children == 0 { 601continue 602 } 603indentText := "│ " 604if last { 605indentText = " " 606 } 607indent := DefaultStyles .Decor .Render (indentText ) 608shown := entry .Children [:min (children ,len (entry .Children ))] 609rest := entry .More + len (entry .Children )- len (shown ) 610for j ,child := range shown { 611out = append (out ,indent + branch (j == len (shown )- 1 && rest == 0 )+ name (child )) 612 } 613if rest > 0 { 614out = append (out ,indent + branch (true )+ more (rest )) 615 } 616 } 617if hidden > 0 { 618out = append (out ,branch (true )+ more (hidden )) 619 } 620return out 621} 622 623func knownLanguageColor (color * string )string { 624if color != nil { 625return * color 626 } 627return "" 628}