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 }