github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/runners/packages/searchView.go (about)

     1  package packages
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"strconv"
     7  	"strings"
     8  
     9  	"github.com/ActiveState/cli/internal/colorize"
    10  	"github.com/ActiveState/cli/internal/errs"
    11  	"github.com/ActiveState/cli/internal/locale"
    12  	"github.com/ActiveState/cli/internal/logging"
    13  	"github.com/ActiveState/cli/internal/output"
    14  	"github.com/ActiveState/cli/pkg/platform/api/vulnerabilities/model"
    15  	"github.com/charmbracelet/bubbles/viewport"
    16  	tea "github.com/charmbracelet/bubbletea"
    17  	"github.com/charmbracelet/lipgloss"
    18  	"golang.org/x/term"
    19  )
    20  
    21  var (
    22  	keyName        = locale.Tl("search_name", "Name")
    23  	keyDescription = locale.Tl("search_description", "Description")
    24  	keyWebsite     = locale.Tl("search_website", "Website")
    25  	keyLicense     = locale.Tl("search_license", "License")
    26  	keyVersions    = locale.Tl("search_versions", "Versions")
    27  	keyVulns       = locale.Tl("search_vulnerabilities", "Vulnerabilities (CVEs)")
    28  
    29  	keys = []string{
    30  		keyName,
    31  		keyDescription,
    32  		keyWebsite,
    33  		keyLicense,
    34  		keyVersions,
    35  		keyVulns,
    36  	}
    37  )
    38  
    39  const (
    40  	leftPad        = 2
    41  	verticalMargin = 7
    42  	scrollUp       = "up"
    43  	scrollDown     = "down"
    44  )
    45  
    46  type view struct {
    47  	width         int
    48  	height        int
    49  	content       string
    50  	searchResults *structuredSearchResults
    51  	ready         bool
    52  	viewport      viewport.Model
    53  }
    54  
    55  func NewView(results *structuredSearchResults, out output.Outputer) (*view, error) {
    56  	outFD, ok := out.Config().OutWriterFD()
    57  	if !ok {
    58  		logging.Error("Could not get output writer file descriptor, falling back to stdout")
    59  		outFD = os.Stdout.Fd()
    60  	}
    61  
    62  	width, height, err := term.GetSize(int(outFD))
    63  	if err != nil {
    64  		return nil, errs.Wrap(err, "Could not get terminal size")
    65  	}
    66  
    67  	return &view{
    68  		width:         width,
    69  		height:        height,
    70  		searchResults: results,
    71  	}, nil
    72  }
    73  
    74  func (v *view) Init() tea.Cmd {
    75  	return nil
    76  }
    77  
    78  func (v *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    79  	var (
    80  		cmd  tea.Cmd
    81  		cmds []tea.Cmd
    82  	)
    83  
    84  	switch msg := msg.(type) {
    85  	case tea.MouseMsg:
    86  		switch msg.Button {
    87  		case tea.MouseButtonWheelUp:
    88  			v.viewport.LineUp(3)
    89  			return v, nil
    90  		case tea.MouseButtonWheelDown:
    91  			v.viewport.LineDown(3)
    92  			return v, nil
    93  		}
    94  	case tea.KeyMsg:
    95  		stringMsg := strings.ToLower(msg.String())
    96  		switch stringMsg {
    97  		case "q", "ctrl+c":
    98  			return v, tea.Quit
    99  		case "up":
   100  			v.viewport.LineUp(1)
   101  			return v, nil
   102  		case "down":
   103  			v.viewport.LineDown(1)
   104  			return v, nil
   105  		case "pgup":
   106  			v.viewport.LineUp(v.height - verticalMargin)
   107  			return v, nil
   108  		case "pgdown":
   109  			v.viewport.LineDown(v.height - verticalMargin)
   110  			return v, nil
   111  		}
   112  	case tea.WindowSizeMsg:
   113  		if !v.ready {
   114  			// Keep the searching message and command in view
   115  			v.viewport = viewport.New(msg.Width, msg.Height-verticalMargin)
   116  			v.content = v.processContent()
   117  			v.viewport.SetContent(v.processContent())
   118  			v.ready = true
   119  		} else {
   120  			v.width = msg.Width
   121  			v.height = msg.Height
   122  		}
   123  	}
   124  
   125  	v.viewport, cmd = v.viewport.Update(msg)
   126  	cmds = append(cmds, cmd)
   127  
   128  	return v, tea.Batch(cmds...)
   129  }
   130  
   131  func (v *view) View() string {
   132  	return v.viewport.View() + "\n\n" + v.footerView()
   133  }
   134  
   135  func (v *view) processContent() string {
   136  	maxKeyLength := 0
   137  	for _, key := range keys {
   138  		renderedKey := colorize.StyleLightGrey.Render(key)
   139  		if len(renderedKey) > maxKeyLength {
   140  			maxKeyLength = len(renderedKey) + 2
   141  		}
   142  	}
   143  
   144  	doc := strings.Builder{}
   145  	for _, pkg := range v.searchResults.Results {
   146  		if pkg.Name != "" {
   147  			doc.WriteString(formatRow(colorize.StyleLightGrey.Render(keyName), colorize.StyleActionable.Render(pkg.Name), maxKeyLength, v.width))
   148  		}
   149  		if pkg.Description != "" {
   150  			doc.WriteString(formatRow(colorize.StyleLightGrey.Render(keyDescription), pkg.Description, maxKeyLength, v.width))
   151  		}
   152  		if pkg.Website != "" {
   153  			doc.WriteString(formatRow(colorize.StyleLightGrey.Render(keyWebsite), colorize.StyleCyan.Render(pkg.Website), maxKeyLength, v.width))
   154  		}
   155  		if pkg.License != "" {
   156  			doc.WriteString(formatRow(colorize.StyleLightGrey.Render(keyLicense), colorize.StyleCyan.Render(pkg.License), maxKeyLength, v.width))
   157  		}
   158  
   159  		var versions []string
   160  		for i, v := range pkg.Versions {
   161  			if i > 5 {
   162  				versions = append(versions, locale.Tl("search_more_versions", "... ({{.V0}} more)", strconv.Itoa(len(pkg.Versions)-5)))
   163  				break
   164  			}
   165  			versions = append(versions, colorize.StyleCyan.Render(v))
   166  		}
   167  		if len(versions) > 0 {
   168  			doc.WriteString(formatRow(colorize.StyleLightGrey.Render(keyVersions), strings.Join(versions, ", "), maxKeyLength, v.width))
   169  		}
   170  
   171  		if len(pkg.Vulnerabilities) > 0 {
   172  			var (
   173  				critical = pkg.Vulnerabilities[model.SeverityCritical]
   174  				high     = pkg.Vulnerabilities[model.SeverityHigh]
   175  				medium   = pkg.Vulnerabilities[model.SeverityMedium]
   176  				low      = pkg.Vulnerabilities[model.SeverityLow]
   177  			)
   178  
   179  			vunlSummary := []string{}
   180  			if critical > 0 {
   181  				vunlSummary = append(vunlSummary, locale.Tr("vulnerability_critical", strconv.Itoa(critical)))
   182  			}
   183  			if high > 0 {
   184  				vunlSummary = append(vunlSummary, locale.Tr("vulnerability_high", strconv.Itoa(high)))
   185  			}
   186  			if medium > 0 {
   187  				vunlSummary = append(vunlSummary, locale.Tr("vulnerability_medium", strconv.Itoa(medium)))
   188  			}
   189  			if low > 0 {
   190  				vunlSummary = append(vunlSummary, locale.Tr("vulnerability_low", strconv.Itoa(low)))
   191  			}
   192  
   193  			if len(vunlSummary) > 0 {
   194  				doc.WriteString(formatRow(colorize.StyleLightGrey.Render(keyVulns), strings.Join(vunlSummary, ", "), maxKeyLength, v.width))
   195  			}
   196  		}
   197  
   198  		doc.WriteString("\n")
   199  	}
   200  	return doc.String()
   201  }
   202  
   203  func (v *view) footerView() string {
   204  	var footerText string
   205  	scrollValue := v.viewport.ScrollPercent() * 100
   206  	footerText += locale.Tl("search_more_matches", "... {{.V0}}% scrolled, use arrow and page keys to scroll. Press Q to quit.", strconv.Itoa(int(scrollValue)))
   207  	footerText += fmt.Sprintf("\n\n%s '%s'", colorize.StyleBold.Render(locale.Tl("search_more_info", "For more info run")), colorize.StyleActionable.Render(locale.Tl("search_more_info_command", "state info <name>")))
   208  	return lipgloss.NewStyle().Render(footerText)
   209  }
   210  
   211  // formatRow formats a key-value pair into a single line of text
   212  // It pads the key both left and right and ensures the value is wrapped to the
   213  // correct width.
   214  // Example:
   215  // Initially we would have:
   216  //
   217  // Name: value
   218  //
   219  // After padding:
   220  //
   221  //	Name:   value
   222  func formatRow(key, value string, maxKeyLength, width int) string {
   223  	rowStyle := lipgloss.NewStyle().Width(width)
   224  
   225  	// Pad key and wrap the value
   226  	// The viewport does not support padding so we need to pad the key manually
   227  	// First, pad the key left to indent the entire view
   228  	// Then, pad the key right to ensure that the values are aligned with the
   229  	// other values in the view.
   230  	paddedKey := strings.Repeat(" ", leftPad) + key + strings.Repeat(" ", maxKeyLength-len(key))
   231  
   232  	// The value style is strictly for the information that a key maps to.
   233  	// ie. the description string, the website string, etc.
   234  	// We have a separate width here to ensure that the value is wrapped to the
   235  	// correct width.
   236  	valueStyle := lipgloss.NewStyle().Width(width - len(paddedKey))
   237  	wrapped := valueStyle.Render(value)
   238  
   239  	// The rendered value ends up being a bit too wide, so we need to reduce the
   240  	// width that we are working with to ensure that the wrapped value fits
   241  	indentedValue := strings.ReplaceAll(wrapped, "\n", "\n"+strings.Repeat(" ", len(paddedKey)-15))
   242  
   243  	formattedRow := fmt.Sprintf("%s%s", paddedKey, indentedValue)
   244  	return rowStyle.Render(formattedRow) + "\n"
   245  }