talos/internal/pkg/dashboard/resourceexplorer.go
Andrey Smirnov f7be2c5984
feat: add resource view to talosctl dashboard
Fixes #12933

There are many usecases for this:

* exploring resources and state of the system, learning available
  resources
* when a Talos machine is booted up in an environment without network
  access, learning all available network interfaces, all disks
  available, etc.

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
(cherry picked from commit 5e24d5265bde9adee92c02e675140de87ee126bf)
2026-04-15 18:35:18 +04:00

872 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package dashboard
import (
"bytes"
"context"
"fmt"
"sort"
"strings"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/resource/meta"
"github.com/cosi-project/runtime/pkg/safe"
"github.com/cosi-project/runtime/pkg/state"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
yaml "go.yaml.in/yaml/v4"
"google.golang.org/grpc/codes"
"k8s.io/client-go/util/jsonpath"
"github.com/siderolabs/talos/pkg/machinery/client"
)
const (
pageResourceTypes = "resource-types"
pageResourceList = "resource-list"
pageResourceYAML = "resource-yaml"
)
// ResourceExplorerGrid implements the resource browser screen.
type ResourceExplorerGrid struct {
tview.Grid
app *tview.Application
dashboard *Dashboard
ctx context.Context //nolint:containedctx // used to stop/start watches
pages *tview.Pages
level int // 0=types, 1=instances, 2=yaml
// Level 0: resource type list
typesWrapper *tview.Grid // contains typesTable + optional filterInput
typesTable *tview.Table
filterInput *tview.InputField
filterActive bool
filterText string
resourceDefs []*meta.ResourceDefinition
// Level 1: resource instance list
resourceTable *tview.Table
selectedRD *meta.ResourceDefinition
resources map[string]resource.Resource
sortedIDs []string // sorted IDs matching table rows (row-1 indexed)
watchCancel context.CancelFunc
dynamicColumnNames []string
dynamicColumns []func(any) (string, error)
// Level 2: YAML detail
yamlView *tview.TextView
selectedNode string
active bool
}
// NewResourceExplorerGrid initializes ResourceExplorerGrid.
func NewResourceExplorerGrid(ctx context.Context, dashboard *Dashboard) *ResourceExplorerGrid {
widget := &ResourceExplorerGrid{
Grid: *tview.NewGrid(),
app: dashboard.app,
dashboard: dashboard,
ctx: ctx,
resources: make(map[string]resource.Resource),
}
widget.SetRows(0).SetColumns(0)
// Level 0: resource types table
widget.typesTable = tview.NewTable()
widget.typesTable.SetBorder(true).
SetTitle(" Resource Types (Enter: select, /: filter) ")
widget.typesTable.SetFixed(1, 0)
widget.typesTable.SetSelectable(true, false)
widget.typesTable.SetSelectedStyle(tcell.StyleDefault.Attributes(tcell.AttrReverse))
widget.typesTable.SetSelectedFunc(func(row, _ int) {
widget.selectResourceType(row)
})
widget.typesTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Rune() == '/' {
widget.activateFilter()
return nil
}
return event
})
// Filter input shown below the types table when '/' is pressed
widget.filterInput = tview.NewInputField()
widget.filterInput.SetLabel("filter: ")
widget.filterInput.SetLabelColor(tcell.ColorYellow)
widget.filterInput.SetFieldBackgroundColor(tcell.ColorDefault)
widget.filterInput.SetChangedFunc(func(text string) {
widget.filterText = text
widget.renderTypesTable()
})
widget.filterInput.SetDoneFunc(func(key tcell.Key) {
// Esc clears the filter; Enter keeps the filtered view
widget.deactivateFilter(key == tcell.KeyEscape)
})
// Wrapper grid: table fills row 0; filter input occupies row 1 when active
widget.typesWrapper = tview.NewGrid()
widget.typesWrapper.SetRows(0).SetColumns(0)
widget.typesWrapper.AddItem(widget.typesTable, 0, 0, 1, 1, 0, 0, true)
// Level 1: resource instances table
widget.resourceTable = tview.NewTable()
widget.resourceTable.SetBorder(true)
widget.resourceTable.SetFixed(1, 0)
widget.resourceTable.SetSelectable(true, false)
widget.resourceTable.SetSelectedStyle(tcell.StyleDefault.Attributes(tcell.AttrReverse))
widget.resourceTable.SetSelectedFunc(func(row, _ int) {
widget.selectResource(row)
})
widget.resourceTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
widget.goBack()
return nil
}
return event
})
// Level 2: YAML detail view
widget.yamlView = tview.NewTextView()
widget.yamlView.SetBorder(true)
widget.yamlView.SetScrollable(true)
widget.yamlView.SetDynamicColors(true)
widget.yamlView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
widget.goBack()
return nil
}
return event
})
// Pages to manage which level is visible
widget.pages = tview.NewPages()
widget.pages.AddPage(pageResourceTypes, widget.typesWrapper, true, true)
widget.pages.AddPage(pageResourceList, widget.resourceTable, true, false)
widget.pages.AddPage(pageResourceYAML, widget.yamlView, true, false)
widget.AddItem(widget.pages, 0, 0, 1, 1, 0, 0, true)
widget.initTypesTableHeader()
return widget
}
// onScreenSelect implements the screenSelectListener interface.
func (widget *ResourceExplorerGrid) onScreenSelect(active bool) {
widget.active = active
if active {
switch widget.level {
case 0:
if widget.resourceDefs == nil {
widget.loadResourceTypes()
}
widget.app.SetFocus(widget.typesTable)
case 1:
// Restart watch if it stopped when screen was hidden
if widget.watchCancel == nil && widget.selectedRD != nil {
widget.startResourceWatch(widget.selectedRD)
}
widget.app.SetFocus(widget.resourceTable)
case 2:
widget.app.SetFocus(widget.yamlView)
}
} else {
// Stop watch while screen is hidden; it will be restarted on next activation
widget.stopResourceWatch()
widget.deactivateFilter(true)
}
}
// OnNodeSelect implements the NodeSelectListener interface.
func (widget *ResourceExplorerGrid) OnNodeSelect(node string) {
if node == widget.selectedNode {
return
}
widget.selectedNode = node
widget.stopResourceWatch()
widget.deactivateFilter(true)
switch widget.level {
case 0:
// Reload resource types for the new node
widget.resourceDefs = nil
widget.initTypesTableHeader()
if widget.active {
widget.loadResourceTypes()
}
case 1:
// Stay on the resource list but reload for new node
widget.resources = make(map[string]resource.Resource)
widget.sortedIDs = nil
widget.initResourceTableHeader()
if widget.active {
widget.startResourceWatch(widget.selectedRD)
}
case 2:
// Drop back to resource list for the new node
widget.level = 1
widget.pages.SwitchToPage(pageResourceList)
widget.resources = make(map[string]resource.Resource)
widget.sortedIDs = nil
widget.initResourceTableHeader()
if widget.active {
widget.app.SetFocus(widget.resourceTable)
widget.startResourceWatch(widget.selectedRD)
}
}
}
// activateFilter shows the filter input below the types table.
func (widget *ResourceExplorerGrid) activateFilter() {
if widget.filterActive {
return
}
widget.filterActive = true
widget.filterInput.SetText(widget.filterText)
widget.typesWrapper.SetRows(0, 1)
widget.typesWrapper.AddItem(widget.filterInput, 1, 0, 1, 1, 0, 0, true)
widget.app.SetFocus(widget.filterInput)
}
// deactivateFilter hides the filter input. If clearText is true, the filter is also cleared.
func (widget *ResourceExplorerGrid) deactivateFilter(clearText bool) {
if !widget.filterActive {
if clearText && widget.filterText != "" {
widget.filterText = ""
widget.filterInput.SetText("")
if widget.resourceDefs != nil {
widget.renderTypesTable()
}
}
return
}
if clearText {
widget.filterText = ""
widget.filterInput.SetText("")
}
widget.filterActive = false
widget.typesWrapper.RemoveItem(widget.filterInput)
widget.typesWrapper.SetRows(0)
if clearText && widget.resourceDefs != nil {
widget.renderTypesTable()
}
if widget.active {
widget.app.SetFocus(widget.typesTable)
}
}
// loadResourceTypes asynchronously fetches the list of resource definitions.
func (widget *ResourceExplorerGrid) loadResourceTypes() {
widget.initTypesTableHeader()
widget.typesTable.SetCell(1, 0, &tview.TableCell{
Text: "[gray]Loading...[-]",
NotSelectable: true,
})
ctx := nodeContext(widget.ctx, widget.selectedNode)
go func() {
list, err := safe.StateListAll[*meta.ResourceDefinition](ctx, widget.dashboard.cli.COSI)
if err != nil {
widget.app.QueueUpdateDraw(func() {
widget.initTypesTableHeader()
widget.typesTable.SetCell(1, 0, &tview.TableCell{
Text: fmt.Sprintf("[red]%s[-]", formatError(err)),
NotSelectable: true,
})
})
return
}
defs := make([]*meta.ResourceDefinition, 0, list.Len())
for rd := range list.All() {
defs = append(defs, rd)
}
sort.Slice(defs, func(i, j int) bool {
return defs[i].TypedSpec().Type < defs[j].TypedSpec().Type
})
widget.app.QueueUpdateDraw(func() {
widget.resourceDefs = defs
widget.renderTypesTable()
})
}()
}
// initTypesTableHeader sets the header row for the types table.
func (widget *ResourceExplorerGrid) initTypesTableHeader() {
widget.typesTable.Clear()
widget.typesTable.SetCell(0, 0, headerCell("TYPE"))
widget.typesTable.SetCell(0, 1, headerCell("NAMESPACE"))
widget.typesTable.SetCell(0, 2, headerCell("ALIASES"))
}
// renderTypesTable rebuilds the resource types table, applying the current filter.
func (widget *ResourceExplorerGrid) renderTypesTable() {
widget.initTypesTableHeader()
filter := strings.ToLower(widget.filterText)
row := 1
for _, rd := range widget.resourceDefs {
spec := rd.TypedSpec()
if filter != "" {
matched := strings.Contains(strings.ToLower(spec.Type), filter)
if !matched {
for _, alias := range spec.AllAliases {
if strings.Contains(strings.ToLower(alias), filter) {
matched = true
break
}
}
}
if !matched {
continue
}
}
widget.typesTable.SetCell(row, 0, &tview.TableCell{
Text: spec.Type,
Align: tview.AlignLeft,
Color: tcell.ColorWhite,
Reference: rd, // used by selectResourceType to retrieve the RD
Expansion: 1,
})
widget.typesTable.SetCell(row, 1, &tview.TableCell{
Text: spec.DefaultNamespace,
Align: tview.AlignLeft,
Color: tcell.ColorWhite,
})
widget.typesTable.SetCell(row, 2, &tview.TableCell{
Text: strings.Join(spec.Aliases, ", "),
Align: tview.AlignLeft,
Color: tcell.ColorGray,
})
row++
}
// Always start at the top of the list
widget.typesTable.SetOffset(0, 0)
if widget.typesTable.GetRowCount() > 1 {
widget.typesTable.Select(1, 0)
}
}
// selectResourceType is called when the user presses Enter on a resource type row.
func (widget *ResourceExplorerGrid) selectResourceType(row int) {
if row == 0 {
return
}
cell := widget.typesTable.GetCell(row, 0)
if cell == nil || cell.Reference == nil {
return
}
rd, ok := cell.Reference.(*meta.ResourceDefinition)
if !ok {
return
}
widget.selectedRD = rd
spec := rd.TypedSpec()
widget.resourceTable.SetTitle(fmt.Sprintf(" %s (ns: %s) — Enter: view YAML, Esc: back ", spec.DisplayType, spec.DefaultNamespace))
widget.buildDynamicColumns(rd)
widget.resources = make(map[string]resource.Resource)
widget.sortedIDs = nil
widget.initResourceTableHeader()
widget.level = 1
widget.pages.SwitchToPage(pageResourceList)
widget.app.SetFocus(widget.resourceTable)
widget.startResourceWatch(rd)
}
// buildDynamicColumns compiles the jsonpath expressions from the resource definition's PrintColumns.
func (widget *ResourceExplorerGrid) buildDynamicColumns(rd *meta.ResourceDefinition) {
cols := rd.TypedSpec().PrintColumns
widget.dynamicColumnNames = make([]string, 0, len(cols))
widget.dynamicColumns = make([]func(any) (string, error), 0, len(cols))
for _, col := range cols {
expr := jsonpath.New(col.Name).AllowMissingKeys(true)
if err := expr.Parse(col.JSONPath); err != nil {
// skip columns whose jsonpath can't be compiled
continue
}
widget.dynamicColumnNames = append(widget.dynamicColumnNames, strings.ToUpper(col.Name))
capturedExpr := expr
widget.dynamicColumns = append(widget.dynamicColumns, func(val any) (string, error) {
var buf bytes.Buffer
if e := capturedExpr.Execute(&buf, val); e != nil {
return "", e
}
return buf.String(), nil
})
}
}
// initResourceTableHeader sets the header row for the resource instances table.
func (widget *ResourceExplorerGrid) initResourceTableHeader() {
widget.resourceTable.Clear()
widget.resourceTable.SetCell(0, 0, headerCell("ID"))
widget.resourceTable.SetCell(0, 1, headerCell("VERSION"))
widget.resourceTable.SetCell(0, 2, headerCell("PHASE"))
widget.resourceTable.SetCell(0, 3, headerCell("OWNER"))
for i, name := range widget.dynamicColumnNames {
widget.resourceTable.SetCell(0, 4+i, headerCell(name))
}
widget.resourceTable.SetCell(1, 0, &tview.TableCell{
Text: "[gray]Loading...[-]",
NotSelectable: true,
})
}
// renderResourceTable rebuilds the resource instances table from widget.resources.
func (widget *ResourceExplorerGrid) renderResourceTable() {
ids := make([]string, 0, len(widget.resources))
for id := range widget.resources {
ids = append(ids, id)
}
sort.Strings(ids)
widget.sortedIDs = ids
// Remember the previously selected row to preserve it after a live update
selectedRow, _ := widget.resourceTable.GetSelection()
widget.initResourceTableHeader()
widget.resourceTable.RemoveRow(1) // remove "loading" header
for i, id := range ids {
res := widget.resources[id]
md := res.Metadata()
phaseText := tview.Escape(md.Phase().String())
phaseColor := tcell.ColorWhite
if md.Phase() == resource.PhaseTearingDown {
phaseText = "[red]" + phaseText + "[-]"
phaseColor = tcell.ColorDefault
}
widget.resourceTable.SetCell(i+1, 0, &tview.TableCell{
Text: tview.Escape(md.ID()),
Align: tview.AlignLeft,
Color: tcell.ColorWhite,
Expansion: 1,
})
widget.resourceTable.SetCell(i+1, 1, &tview.TableCell{
Text: tview.Escape(md.Version().String()),
Align: tview.AlignLeft,
Color: tcell.ColorGray,
})
widget.resourceTable.SetCell(i+1, 2, &tview.TableCell{
Text: phaseText,
Align: tview.AlignLeft,
Color: phaseColor,
})
widget.resourceTable.SetCell(i+1, 3, &tview.TableCell{
Text: tview.Escape(md.Owner()),
Align: tview.AlignLeft,
Color: tcell.ColorGray,
})
if len(widget.dynamicColumns) > 0 {
specVal := widget.marshalSpec(res)
for j, dynCol := range widget.dynamicColumns {
text, err := dynCol(specVal)
if err != nil {
text = ""
}
widget.resourceTable.SetCell(i+1, 4+j, &tview.TableCell{
Text: tview.Escape(text),
Align: tview.AlignLeft,
Color: tcell.ColorWhite,
})
}
}
}
// Restore selection if still valid, otherwise go to first row
rowCount := widget.resourceTable.GetRowCount()
if selectedRow > 0 && selectedRow < rowCount {
widget.resourceTable.Select(selectedRow, 0)
} else if rowCount > 1 {
// Initial render: reset scroll to top before selecting the first row.
widget.resourceTable.SetOffset(0, 0)
widget.resourceTable.Select(1, 0)
}
}
// startResourceWatch begins watching resources of the given kind.
func (widget *ResourceExplorerGrid) startResourceWatch(rd *meta.ResourceDefinition) {
widget.stopResourceWatch()
ctx, cancel := context.WithCancel(widget.ctx)
widget.watchCancel = cancel
go widget.runResourceWatch(ctx, rd)
}
// stopResourceWatch cancels a running resource watch if any.
func (widget *ResourceExplorerGrid) stopResourceWatch() {
if widget.watchCancel != nil {
widget.watchCancel()
widget.watchCancel = nil
}
}
// runResourceWatch runs the WatchKind event loop for the given resource definition.
//
//nolint:gocyclo
func (widget *ResourceExplorerGrid) runResourceWatch(ctx context.Context, rd *meta.ResourceDefinition) {
nodeCtx := nodeContext(ctx, widget.selectedNode)
spec := rd.TypedSpec()
eventCh := make(chan state.Event)
md := resource.NewMetadata(spec.DefaultNamespace, spec.Type, "", resource.VersionUndefined)
if err := widget.dashboard.cli.COSI.WatchKind(nodeCtx, &md, eventCh,
state.WithBootstrapContents(true),
state.WithWatchKindUnmarshalOptions(state.WithSkipProtobufUnmarshal()),
); err != nil {
widget.app.QueueUpdateDraw(func() {
if widget.selectedRD == rd {
widget.showResourceTableError(formatError(err))
}
})
return
}
bootstrapped := false
for {
select {
case <-ctx.Done():
return
case event := <-eventCh:
switch event.Type {
case state.Errored:
widget.app.QueueUpdateDraw(func() {
if widget.selectedRD == rd {
widget.showResourceTableError(formatError(event.Error))
}
})
return
case state.Bootstrapped:
bootstrapped = true
widget.app.QueueUpdateDraw(func() {
if widget.selectedRD == rd && widget.level >= 1 {
widget.renderResourceTable()
}
})
case state.Noop:
// nothing to do
case state.Created, state.Updated:
res := event.Resource
wasBootstrapped := bootstrapped
widget.app.QueueUpdateDraw(func() {
if widget.selectedRD == rd && widget.level >= 1 {
widget.resources[res.Metadata().ID()] = res
if wasBootstrapped {
widget.renderResourceTable()
}
}
})
case state.Destroyed:
res := event.Resource
wasBootstrapped := bootstrapped
widget.app.QueueUpdateDraw(func() {
if widget.selectedRD == rd && widget.level >= 1 {
delete(widget.resources, res.Metadata().ID())
if wasBootstrapped {
widget.renderResourceTable()
}
}
})
}
}
}
}
// marshalSpec marshals the resource spec to an unstructured value suitable for jsonpath evaluation.
func (widget *ResourceExplorerGrid) marshalSpec(res resource.Resource) any {
out, err := yaml.Marshal(res.Spec())
if err != nil {
return nil
}
var unstructured any
if err = yaml.Unmarshal(out, &unstructured); err != nil {
return nil
}
return unstructured
}
// showResourceTableError displays an error message in the resource instances table.
func (widget *ResourceExplorerGrid) showResourceTableError(msg string) {
widget.initResourceTableHeader()
widget.resourceTable.SetCell(1, 0, &tview.TableCell{
Text: fmt.Sprintf("[red]%s[-]", tview.Escape(msg)),
NotSelectable: true,
Expansion: 1,
})
}
// selectResource is called when the user presses Enter on a resource instance row.
func (widget *ResourceExplorerGrid) selectResource(row int) {
if row == 0 || row-1 >= len(widget.sortedIDs) {
return
}
id := widget.sortedIDs[row-1]
res, ok := widget.resources[id]
if !ok {
return
}
widget.showResourceYAML(res)
}
// showResourceYAML renders the YAML of the given resource and shows the YAML view.
func (widget *ResourceExplorerGrid) showResourceYAML(res resource.Resource) {
out, err := resource.MarshalYAML(res)
if err != nil {
widget.yamlView.SetText(fmt.Sprintf("Error marshaling resource: %v", err))
} else {
outBytes, marshalErr := yaml.Marshal(out)
if marshalErr != nil {
widget.yamlView.SetText(fmt.Sprintf("Error encoding YAML: %v", marshalErr))
} else {
var node yaml.Node
if unmarshalErr := yaml.Unmarshal(outBytes, &node); unmarshalErr != nil {
widget.yamlView.SetText(fmt.Sprintf("Error encoding YAML: %v", unmarshalErr))
} else {
var sb strings.Builder
renderYAMLNode(&sb, &node, 0, false)
widget.yamlView.SetText(sb.String())
}
}
}
widget.yamlView.SetTitle(fmt.Sprintf(" %s / %s (Esc: back) ", res.Metadata().Namespace(), res.Metadata().ID()))
widget.yamlView.ScrollToBeginning()
widget.level = 2
widget.pages.SwitchToPage(pageResourceYAML)
widget.app.SetFocus(widget.yamlView)
}
// goBack returns to the previous navigation level.
func (widget *ResourceExplorerGrid) goBack() {
switch widget.level {
case 2:
widget.level = 1
widget.pages.SwitchToPage(pageResourceList)
widget.app.SetFocus(widget.resourceTable)
case 1:
widget.stopResourceWatch()
widget.resources = make(map[string]resource.Resource)
widget.sortedIDs = nil
widget.level = 0
widget.pages.SwitchToPage(pageResourceTypes)
widget.app.SetFocus(widget.typesTable)
}
}
// formatError returns a user-friendly error message.
// Permission denied errors are given a clear explanation since sensitive resources
// are only accessible with elevated privileges.
func formatError(err error) string {
if client.StatusCode(err) == codes.PermissionDenied {
return "access denied: this resource is sensitive and requires elevated permissions"
}
return err.Error()
}
// renderYAMLNode walks a yaml.Node AST and writes a tview-colored representation
// into sb. indent is the current indentation level; inSequence signals that the
// caller is rendering a sequence item and has already written the "- " prefix.
//
// Color scheme:
//
// cyan mapping keys
// green string scalars
// yellow numeric, boolean, and null scalars
//
//nolint:gocyclo
func renderYAMLNode(sb *strings.Builder, node *yaml.Node, indent int, inSequence bool) {
prefix := strings.Repeat(" ", indent)
seqPrefix := strings.Repeat(" ", indent) + "- "
switch node.Kind { //nolint:exhaustive
case yaml.DocumentNode:
for _, child := range node.Content {
renderYAMLNode(sb, child, indent, false)
}
case yaml.MappingNode:
// Content is key/value pairs interleaved: [k0, v0, k1, v1, …]
for i := 0; i+1 < len(node.Content); i += 2 {
key := node.Content[i]
val := node.Content[i+1]
linePrefix := prefix
if inSequence && i == 0 {
linePrefix = seqPrefix
}
sb.WriteString(linePrefix)
fmt.Fprintf(sb, "[darkcyan]%s[-]:", tview.Escape(key.Value))
switch val.Kind { //nolint:exhaustive
case yaml.ScalarNode:
sb.WriteString(" ")
writeScalar(sb, val)
sb.WriteString("\n")
case yaml.MappingNode, yaml.SequenceNode:
sb.WriteString("\n")
renderYAMLNode(sb, val, indent+1, false)
default:
sb.WriteString("\n")
renderYAMLNode(sb, val, indent+1, false)
}
inSequence = false // only the first key gets the "- " prefix
}
case yaml.SequenceNode:
for _, item := range node.Content {
switch item.Kind { //nolint:exhaustive
case yaml.ScalarNode:
sb.WriteString(seqPrefix)
writeScalar(sb, item)
sb.WriteString("\n")
case yaml.MappingNode:
renderYAMLNode(sb, item, indent, true)
default:
sb.WriteString(seqPrefix + "\n")
renderYAMLNode(sb, item, indent+1, false)
}
}
case yaml.ScalarNode:
sb.WriteString(prefix)
writeScalar(sb, node)
sb.WriteString("\n")
case yaml.AliasNode:
renderYAMLNode(sb, node.Alias, indent, inSequence)
}
}
// writeScalar writes a single colored scalar value into sb.
func writeScalar(sb *strings.Builder, node *yaml.Node) {
switch node.ShortTag() {
case "!!str":
// Preserve the original quoting style in the display.
var quoted string
switch node.Style { //nolint:exhaustive
case yaml.DoubleQuotedStyle:
quoted = `"` + tview.Escape(node.Value) + `"`
case yaml.SingleQuotedStyle:
quoted = `'` + tview.Escape(node.Value) + `'`
case yaml.LiteralStyle:
quoted = "|\n" + tview.Escape(node.Value)
case yaml.FoldedStyle:
quoted = ">\n" + tview.Escape(node.Value)
default:
quoted = tview.Escape(node.Value)
}
fmt.Fprintf(sb, "[green]%s[-]", quoted)
case "!!int", "!!float":
fmt.Fprintf(sb, "[yellow]%s[-]", tview.Escape(node.Value))
case "!!bool":
fmt.Fprintf(sb, "[yellow]%s[-]", tview.Escape(node.Value))
case "!!null":
fmt.Fprintf(sb, "[yellow]%s[-]", tview.Escape(node.Value))
default:
sb.WriteString(tview.Escape(node.Value))
}
}
// headerCell creates a bold header cell for tables.
func headerCell(text string) *tview.TableCell {
return &tview.TableCell{
Text: "[::b]" + text,
Align: tview.AlignLeft,
NotSelectable: true,
Color: tcell.ColorWhite,
}
}