okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: sessions and folders fuzzy filter, shared confirm and footers10e0f66

main
20.5 KiB628 linesraw
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) {
24	h, n := max(1, cmp.Or(m.Height, 24)-folderChrome), len(m.rows)+1
25	if n <= h {
26		return 0, len(m.rows)
27	}
28	start := min(max(0, m.cursor+1-h/2), n-h)
29	return max(0, start-1), min(len(m.rows), start+h-1)
30}
31
32func (m FolderPicker) View() string {
33	width := cmp.Or(m.Width, 80)
34	heading, title := "sessions by folder", ""
35	if !m.browse {
36		heading, title = "move session", DefaultStyles.Faint.Render(sessionTitle(m.session))
37	}
38	return listFrame{
39		title:     brand("albedo") + " " + DefaultStyles.Muted.Render(heading),
40		right:     title,
41		filter:    promptLead() + m.input.View(),
42		list:      m.list,
43		paneWidth: halfPane,
44		pane: func(w, h int) []string {
45			pane := m.preview(w-1, h)
46			for i := range pane {
47				pane[i] = svFit(pane[i], w-1) + " "
48			}
49			return pane
50		},
51		footer: 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 (
59	narrowKeys    = 28
60	minStatusRoom = 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 {
66	hints := m.hints()
67	keys := " " + keyHints(hints...)
68	if m.sessionsError != "" && width-ansi.StringWidth(keys) < narrowKeys {
69		hints = []hint{m.retryHint()}
70		keys = " " + keyHints(hints...)
71	}
72	room := width - ansi.StringWidth(keys) - 3
73	if room < minStatusRoom {
74		room = width - 2 // too little beside the keys: an urgent status takes a row of its own
75	}
76	status, urgent := m.status(room)
77	return 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) {
84	dead := m.deadHighlighted()
85	switch {
86	case m.moving:
87		return DefaultStyles.Faint.Render("moving…"), true
88	case m.notice != "":
89		return DefaultStyles.Warning.Render(tailFit(m.notice, room)), true
90	case dead != "":
91		return DefaultStyles.Error.Render(ansi.Truncate(dead, room, "…")), true
92	case m.sessionsError != "":
93		return DefaultStyles.Warning.Render(ansi.Truncate("Recent sessions: "+m.sessionsError, room, "…")), true
94	case m.retry != nil:
95		const missing = "Folder not found: "
96		return DefaultStyles.Warning.Render(missing) + DefaultStyles.Muted.Render(tailFit(m.homed(m.retry.Missing), room-ansi.StringWidth(missing))), true
97	case m.browse: // nothing moves, so there is no "now in"
98		return "", false
99	}
100	const here = "now in "
101	where := tailFit(m.homed(m.workspace()), room-ansi.StringWidth(here))
102	if where == "" {
103		return "", false
104	}
105	return 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 {
111	var hints []hint
112	if m.sessionsError != "" {
113		hints = append(hints, m.retryHint())
114	}
115	if m.inSessions {
116		return append(hints, hint{"↑↓", "move"}, hint{"enter", "open"}, hint{"←", "folders"})
117	}
118	hints = append(hints, hint{"↑↓", "move"}, hint{"tab", "open"}, hint{"⇧tab", "up"})
119	row, ok := m.highlighted()
120	if ok && len(m.sessionsIn(row.path)) > 0 {
121		hints = append(hints, hint{"→", "sessions"})
122	}
123	if ok && m.probe(row.hostKey).State == "needs_auth" {
124		hints = append(hints, hint{"ctrl+l", "sign in"})
125	}
126	action := "move here"
127	if m.browse {
128		action = "new session"
129	}
130	return 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 {
135	if m.sessionsLoading {
136		return hint{"ctrl+r", "retrying…"}
137	}
138	return 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 {
143	if w <= 0 {
144		return ""
145	}
146	if n := ansi.StringWidth(s); n > w {
147		return "…" + ansi.TruncateLeft(s, n-w+1, "")
148	}
149	return s
150}
151
152// list is the section rule over the rows, at exactly width × height.
153func (m FolderPicker) list(width, height int) []string {
154	if len(m.rows) == 0 {
155		return scrollWindow([]string{sectionRule(m.section, 0, width), DefaultStyles.Faint.Render("   " + m.emptyNote())}, 1, width, height)
156	}
157	here, latest := m.workspace(), m.latestOther()
158	now := time.Now()
159	return drawWindow(len(m.rows)+1, func(i int) string {
160		if i == 0 {
161			return sectionRule(m.section, len(m.rows), width)
162		}
163		row := m.rows[i-1]
164		return 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 {
170	if host, _, remote := splitHost(m.listed); remote {
171		return folderRow{hostKey: m.canonical(host), hostRow: true}
172	}
173	return 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 {
179	row, _ := m.highlighted()
180	if row.hostKey == "" {
181		row = m.typedHost()
182	}
183	if p := m.probe(row.hostKey); p.State == "unreachable" || p.State == "unsupported" {
184		return m.labelOf(row.hostKey) + ": " + cmp.Or(p.Detail, p.State)
185	}
186	return ""
187}
188
189func (m FolderPicker) emptyNote() string {
190	l := m.listings[m.listed]
191	host, _, remote := splitHost(m.listed)
192	switch {
193	case m.listed == "" && m.section == "hosts":
194		return "no known hosts match; type host: to browse one"
195	case m.listed == "":
196		return "no matches"
197	case l == nil && remote && m.probe(m.canonical(host)).State == "warming":
198		label := m.labelOf(host)
199		return reachingText(label, m.probe(m.canonical(host)).Step) + " " + DefaultStyles.Agent.Render(connectingFace(label, m.frame))
200	case l == nil:
201		return "loading…"
202	case l.Err != nil:
203		return l.Err.Error()
204	case len(l.List.Entries) == 0:
205		return "No folders here"
206	}
207	return "no matches"
208}
209
210// latestOther is the most recently used recent folder besides this one.
211func (m FolderPicker) latestOther() string {
212	best, at := "", int64(0)
213	for _, row := range m.rows {
214		if row.recent && row.age != nil && *row.age > at && row.path != m.workspace() {
215			best, at = row.path, *row.age
216		}
217	}
218	return 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 {
224	switch {
225	case row.hostRow && row.source == "config":
226		return "ssh config"
227	case row.hostRow:
228		return "recent host"
229	}
230	host, p := daemon.SplitLocation(row.path)
231	if host == "" {
232		return strings.Join(crumbParts(m.homed(path.Dir(p))), " › ")
233	}
234	// the host leads the first crumb, as in the preview's rule
235	crumbs := crumbParts(scpRelative(underHome(path.Dir(p), m.homes[m.canonical(host)])))
236	crumbs[0] = cmp.Or(row.host, m.labelOf(host)) + ":" + crumbs[0]
237	return 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) {
244	p := m.probe(row.hostKey)
245	switch p.State {
246	case "warming":
247		return "", DefaultStyles.Faint, true
248	case "needs_auth":
249		return "sign in · ctrl+l", DefaultStyles.Warning, false
250	case "unreachable", "unsupported":
251		return cmp.Or(p.Detail, p.State), DefaultStyles.Error, false
252	}
253	return "", 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 {
259	glyph, glyphStyle := "· ", DefaultStyles.Faint
260	switch {
261	case selected:
262		glyph, glyphStyle = "◆ ", DefaultStyles.Agent
263	case here:
264		glyph = "○ "
265	case latest:
266		glyph, glyphStyle = "● ", DefaultStyles.Success
267	}
268	whereW, tagW, ageW := 0, 12, 4
269	if (row.recent || row.hostRow) && width >= 48 {
270		whereW = 16
271	}
272	nameW := min(18, max(4, width-4-(whereW+2)-(tagW+2)-(ageW+2)))
273	bar := " "
274	if selected {
275		bar = selectBar()
276	}
277	note, noteStyle, dim := m.reach(row)
278	base := lipgloss.NewStyle()
279	switch {
280	case dim:
281		base = DefaultStyles.Faint
282	case selected:
283		base = DefaultStyles.Bold
284	}
285	line := bar + glyphStyle.Render(glyph) + " " + litName(row.name, row.matched, nameW, base)
286	if whereW > 0 {
287		line += "  " + DefaultStyles.Faint.Render(svCell(m.where(row), whereW, false))
288	}
289	if note != "" {
290		line += "  " + noteStyle.Render(svCell(note, tagW+2+ageW, false))
291		if selected {
292			return selectedLine(line, width)
293		}
294		return line
295	}
296	line += "  " + DefaultStyles.Faint.Render(svCell(repoTag(m.repos[row.path]), tagW, false))
297	age := ""
298	if here {
299		age = "here"
300	} else if row.age != nil {
301		age = svCompactAge(row.age, now)
302	}
303	line += "  " + DefaultStyles.Faint.Render(svCell(age, ageW, true))
304	if selected {
305		return selectedLine(line, width)
306	}
307	return 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 {
312	lit := DefaultStyles.Prompt.Bold(true).Underline(true)
313	fitted := ansi.Truncate(name, w, "…")
314	var b strings.Builder
315	for i, r := range fitted {
316		style := base
317		if slices.Contains(matched, i) {
318			style = lit
319		}
320		b.WriteString(style.Render(string(r)))
321	}
322	return 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 {
327	if p == "/" {
328		return []string{"/"}
329	}
330	if strings.HasPrefix(p, "/") {
331		return append([]string{"/"}, strings.Split(p[1:], "/")...)
332	}
333	return 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 {
339	switch {
340	case r == nil:
341		return ""
342	case r.Kind == "jj" && r.Bookmark != nil:
343		return "⚑ " + r.Bookmark.Name
344	case r.Kind == "jj":
345		if r.Change != "" {
346			return "@ " + r.Change
347		}
348		return ""
349	}
350	if r.Branch != "" || r.Commit != "" {
351		return "⎇ " + cmp.Or(r.Branch, r.Commit)
352	}
353	return ""
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 {
362	row, ok := m.highlighted()
363	if !ok {
364		if row = m.typedHost(); row.hostKey == "" {
365			return make([]string, height)
366		}
367	}
368	if row.hostRow || !m.reachable(row) {
369		return m.hostPane(row, width, height)
370	}
371	c := m.previews[row.path]
372	var body []string
373	var languages []daemon.LanguageShare
374	var tree *daemon.FolderPreview
375	switch {
376	case c == nil || c.loading:
377		body = []string{DefaultStyles.Faint.Render("looking…")}
378	case c.err:
379		body = []string{DefaultStyles.Faint.Render("Could not open this folder")}
380	default:
381		languages, tree = c.Languages, &c.FolderPreview
382		if c.Repo != nil {
383			body = 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.
388	sessions := m.sessionsIn(row.path)
389	if tree != nil {
390		kept := 0
391		if len(sessions) > 0 {
392			kept = sessionHead + min(len(sessions), max(3, height/3))
393		}
394		body = append(body, fitTree(*tree, height-2-len(body)-kept)...)
395	}
396	lines := []string{m.paneRule(row.path, languages, width), ""}
397	for _, l := range body {
398		lines = append(lines, "  "+l)
399	}
400	lines = append(lines, m.sessionBlock(sessions, width, height-len(lines)-sessionHead)...)
401	return 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 {
407	p := m.probe(row.hostKey)
408	label := m.labelOf(row.hostKey)
409	var body []string
410	switch p.State {
411	case "", "warming":
412		body = []string{DefaultStyles.Faint.Render(reachingText(label, p.Step)+" ") + DefaultStyles.Agent.Render(connectingFace(label, m.frame))}
413	case "ready":
414		if p.OS != "" {
415			body = append(body, DefaultStyles.Muted.Render(strings.TrimSpace(p.OS+" "+p.Arch)))
416		}
417		if home := m.homes[m.canonical(row.hostKey)]; home != "" {
418			body = append(body, DefaultStyles.Faint.Render("~ is ")+DefaultStyles.Muted.Render(home))
419		}
420	case "needs_auth":
421		body = []string{DefaultStyles.Warning.Render(cmp.Or(p.Detail, "needs a person to sign in")), DefaultStyles.Faint.Render("ctrl+l signs in here")}
422	default:
423		body = []string{DefaultStyles.Error.Render(cmp.Or(p.Detail, p.State))}
424	}
425	lines := []string{DefaultStyles.Decor.Render("─ ") + hostStyle(label).Render(label) + " " + DefaultStyles.Decor.Render(strings.Repeat("─", max(0, width-3-ansi.StringWidth(label)))), ""}
426	for _, l := range body {
427		lines = append(lines, "  "+ansi.Truncate(l, max(0, width-2), "…"))
428	}
429	if !row.hostRow {
430		lines = append(lines, m.sessionBlock(m.sessionsIn(row.path), width, height-len(lines)-sessionHead)...)
431	}
432	return 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 {
441	if len(sessions) == 0 || room <= 0 {
442		return nil
443	}
444	now := time.Now()
445	rows := make([]string, len(sessions))
446	for i, s := range sessions {
447		selected := m.inSessions && i == m.sessionCursor
448		glyph, style := "· ", DefaultStyles.Faint
449		switch {
450		case selected:
451			glyph, style = "◆ ", DefaultStyles.Agent
452		case s.LastAssistantAt != nil && now.Sub(time.Unix(*s.LastAssistantAt, 0)) < time.Hour:
453			glyph, style = "● ", DefaultStyles.Success
454		}
455		titleStyle := DefaultStyles.Muted
456		bar := " "
457		if selected {
458			titleStyle = DefaultStyles.Bold
459			bar = selectBar()
460		}
461		title := titleStyle.Render(sessionTitle(s))
462		line := bar + " " + style.Render(glyph) + title
463		if s.LastAssistantAt != nil {
464			line += DefaultStyles.Faint.Render("  " + svCompactAge(s.LastAssistantAt, now))
465		}
466		if selected {
467			line = selectedLine(line, width)
468		}
469		rows[i] = line
470	}
471	if len(rows) > room && !m.inSessions {
472		rows = append(rows[:room-1], DefaultStyles.Faint.Render(fmt.Sprintf("   … %d more", len(sessions)-room+1)))
473	}
474	return append([]string{"", plainRule("sessions", width), ""}, scrollWindow(rows, m.sessionCursor, width, min(room, len(rows)))...)
475}
476
477func (m FolderPicker) sessionsIn(p string) []daemon.Session {
478	return 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 {
485	parts := crumbParts(m.homed(p))
486	lead := func() string { return DefaultStyles.Decor.Render("─ ") + crumbs(parts) + " " }
487	tail := ""
488	if len(languages) > 0 {
489		tail = " " + languageStyle(languages[0].Name, knownLanguageColor(languages[0].Color)).Render(strings.ToLower(languages[0].Name)) + " "
490	}
491	for len(parts) > 2 && ansi.StringWidth(lead()+tail) > w-8 {
492		parts = append([]string{"…"}, parts[2:]...)
493	}
494	n := max(0, w-ansi.StringWidth(lead())-ansi.StringWidth(tail))
495	var rule strings.Builder
496	used, sum := 0, 0.0
497	for _, l := range languages {
498		sum += l.Share
499		k := min(n, int(float64(n)*sum+0.5)) - used
500		rule.WriteString(languageStyle(l.Name, knownLanguageColor(l.Color)).Render(strings.Repeat("─", k)))
501		used += k
502	}
503	rule.WriteString(DefaultStyles.Decor.Render(strings.Repeat("─", n-used)))
504	return lead() + rule.String() + tail
505}
506
507// crumbs is a path as soft breadcrumbs, the leaf in bold.
508func crumbs(parts []string) string {
509	out := make([]string, len(parts))
510	for i, p := range parts {
511		out[i] = DefaultStyles.Muted.Bold(i == len(parts)-1).Render(p)
512	}
513	return strings.Join(out, DefaultStyles.Decor.Render(" › "))
514}
515
516// plainRule is a section rule without a count.
517func plainRule(label string, w int) string {
518	head := " " + label + " "
519	return 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 {
525	var parts []string
526	switch {
527	case r.Kind == "jj":
528		head := ""
529		if r.Bookmark != nil {
530			head = DefaultStyles.Agent.Render("⚑ " + r.Bookmark.Name)
531			if r.Bookmark.Ahead > 0 {
532				head += DefaultStyles.Faint.Render(fmt.Sprintf("+%d", r.Bookmark.Ahead))
533			}
534			parts = append(parts, head)
535		}
536		if r.Change != "" {
537			parts = append(parts, DefaultStyles.Faint.Render("@ ")+DefaultStyles.Muted.Render(r.Change))
538		}
539	case r.Branch != "" || r.Commit != "":
540		parts = append(parts, DefaultStyles.Agent.Render("⎇ "+cmp.Or(r.Branch, r.Commit)))
541	}
542	if r.Changed != nil && *r.Changed > 0 {
543		parts = append(parts, DefaultStyles.Warning.Render(fmt.Sprintf("✎ %d", *r.Changed)))
544	}
545	if r.Touched != nil {
546		parts = append(parts, DefaultStyles.Faint.Render(svCompactAge(r.Touched, now)))
547	}
548	return 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 {
555	for _, children := range []int{math.MaxInt, 2, 0} {
556		if out := folderTree(p, children, len(p.Tree)); len(out) <= rows {
557			return out
558		}
559	}
560	for top := len(p.Tree) - 1; top > 0; top-- {
561		if out := folderTree(p, 0, top); len(out) <= rows {
562			return out
563		}
564	}
565	return 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 {
573	colors := map[string]string{}
574	for _, l := range p.Languages {
575		colors[l.Name] = knownLanguageColor(l.Color)
576	}
577	branch := func(last bool) string {
578		text := "├─ "
579		if last {
580			text = "╰─ "
581		}
582		return DefaultStyles.Decor.Render(text)
583	}
584	more := func(n int) string { return DefaultStyles.Faint.Render(fmt.Sprintf("… %d more", n)) }
585	name := func(n daemon.FolderNode) string {
586		s := DefaultStyles.Muted.Render(n.Name) + DefaultStyles.Decor.Render("/")
587		if !n.Dir {
588			s = languageStyle(n.Language, colors[n.Language]).Render(n.Name)
589		}
590		if n.Changed > 0 {
591			s += "  " + DefaultStyles.Faint.Render(fmt.Sprintf("✎ %d", n.Changed))
592		}
593		return s
594	}
595	tree, hidden := p.Tree[:min(top, len(p.Tree))], p.More+max(0, len(p.Tree)-top)
596	var out []string
597	for i, entry := range tree {
598		last := i == len(tree)-1 && hidden == 0
599		out = append(out, branch(last)+name(entry))
600		if children == 0 {
601			continue
602		}
603		indentText := "│  "
604		if last {
605			indentText = "   "
606		}
607		indent := DefaultStyles.Decor.Render(indentText)
608		shown := entry.Children[:min(children, len(entry.Children))]
609		rest := entry.More + len(entry.Children) - len(shown)
610		for j, child := range shown {
611			out = append(out, indent+branch(j == len(shown)-1 && rest == 0)+name(child))
612		}
613		if rest > 0 {
614			out = append(out, indent+branch(true)+more(rest))
615		}
616	}
617	if hidden > 0 {
618		out = append(out, branch(true)+more(hidden))
619	}
620	return out
621}
622
623func knownLanguageColor(color *string) string {
624	if color != nil {
625		return *color
626	}
627	return ""
628}