github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/tui/components/vuln_list.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package components 16 17 import ( 18 "cmp" 19 "fmt" 20 "io" 21 "slices" 22 23 "github.com/charmbracelet/bubbles/key" 24 "github.com/charmbracelet/bubbles/list" 25 tea "github.com/charmbracelet/bubbletea" 26 "github.com/charmbracelet/lipgloss" 27 "github.com/google/osv-scalibr/guidedremediation/internal/resolution" 28 "github.com/google/osv-scalibr/guidedremediation/internal/severity" 29 "github.com/muesli/reflow/truncate" 30 ) 31 32 // vulnList is a ViewModel list of vulnerabilities, selectable to show details 33 type vulnList struct { 34 // There is a table model that could be used for this instead, 35 // but there is much less control over the styling of the cells 36 list.Model 37 38 detailsRenderer DetailsRenderer // renderer for markdown details. 39 40 preamble string // text to write above vuln list 41 currVulnInfo ViewModel // selected vulnerability 42 43 delegate list.ItemDelegate // default item renderer 44 blurred bool // whether the cursor should be hidden and input disabled 45 } 46 47 // NewVulnList creates a ViewModel list of vulnerabilities, selectable to show details. 48 func NewVulnList(vulns []resolution.Vulnerability, preamble string, detailsRenderer DetailsRenderer) ViewModel { 49 vl := vulnList{ 50 preamble: preamble, 51 detailsRenderer: detailsRenderer, 52 } 53 // Sort the vulns by descending severity, then ID 54 vulns = slices.Clone(vulns) 55 slices.SortFunc(vulns, func(a, b resolution.Vulnerability) int { 56 return cmp.Or( 57 -cmp.Compare(severityScore(a), severityScore(b)), 58 cmp.Compare(a.OSV.Id, b.OSV.Id), 59 ) 60 }) 61 items := make([]list.Item, 0, len(vulns)) 62 delegate := vulnListItemDelegate{idWidth: 0} 63 for _, v := range vulns { 64 items = append(items, vulnListItem{v}) 65 if w := lipgloss.Width(v.OSV.Id); w > delegate.idWidth { 66 delegate.idWidth = w 67 } 68 } 69 l := list.New(items, delegate, ViewMinWidth, ViewMinHeight-vl.preambleHeight()) 70 l.SetFilteringEnabled(false) 71 l.SetShowStatusBar(false) 72 l.SetShowHelp(false) 73 l.DisableQuitKeybindings() 74 l.KeyMap = list.KeyMap{ 75 CursorUp: Keys.Up, 76 CursorDown: Keys.Down, 77 NextPage: Keys.Right, 78 PrevPage: Keys.Left, 79 } 80 l.Styles.TitleBar = lipgloss.NewStyle().PaddingLeft(2).Width(ViewMinWidth).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true) 81 l.Styles.Title = lipgloss.NewStyle() 82 83 l.Title = fmt.Sprintf("%s %s %s", 84 lipgloss.NewStyle().Width(delegate.idWidth).Render("VULN ID"), 85 " SEV ", // intentional spacing, scores always 5 wide 86 "SUMMARY", 87 ) 88 vl.Model = l 89 vl.delegate = delegate 90 91 return &vl 92 } 93 94 func severityScore(v resolution.Vulnerability) int { 95 score := -1 96 for _, s := range v.OSV.Severity { 97 floatScore, _ := severity.CalculateScore(s) 98 roundedScore := int(floatScore * 10) // CVSS scores are only to 1 decimal place. 99 if roundedScore > score { 100 score = roundedScore 101 } 102 } 103 if score < 0 { 104 return 999 // Sort unknown before critical 105 } 106 return score 107 } 108 109 func (v *vulnList) preambleHeight() int { 110 if len(v.preamble) == 0 { 111 return 0 112 } 113 114 return lipgloss.Height(v.preamble) 115 } 116 117 func (v *vulnList) Resize(w, h int) ViewModel { 118 v.SetWidth(w) 119 v.SetHeight(h - v.preambleHeight()) 120 v.Styles.TitleBar = v.Styles.TitleBar.Width(w) 121 if v.currVulnInfo != nil { 122 v.currVulnInfo = v.currVulnInfo.Resize(w, h) 123 } 124 return v 125 } 126 127 func (v *vulnList) Update(msg tea.Msg) (ViewModel, tea.Cmd) { 128 if v.blurred { 129 return v, nil 130 } 131 var cmd tea.Cmd 132 if v.currVulnInfo != nil { 133 v.currVulnInfo, cmd = v.currVulnInfo.Update(msg) 134 return v, cmd 135 } 136 if msg, ok := msg.(tea.KeyMsg); ok { 137 switch { 138 case key.Matches(msg, Keys.Quit): 139 return v, CloseViewModel 140 case key.Matches(msg, Keys.Select): 141 vuln := v.SelectedItem().(vulnListItem) 142 v.currVulnInfo = NewVulnInfo(vuln.Vulnerability, v.detailsRenderer) 143 v.currVulnInfo.Resize(v.Width(), v.Height()) 144 145 return v, nil 146 } 147 } 148 if v.currVulnInfo == nil { 149 v.Model, cmd = v.Model.Update(msg) 150 } 151 152 return v, cmd 153 } 154 155 func (v *vulnList) View() string { 156 if v.currVulnInfo != nil { 157 return v.currVulnInfo.View() 158 } 159 str := v.Model.View() 160 if len(v.preamble) > 0 { 161 str = lipgloss.JoinVertical(lipgloss.Left, v.preamble, str) 162 } 163 164 return str 165 } 166 167 func (v *vulnList) Blur() { 168 v.blurred = true 169 v.SetDelegate(blurredDelegate{v.delegate}) 170 } 171 172 func (v *vulnList) Focus() { 173 v.blurred = false 174 v.SetDelegate(v.delegate) 175 } 176 177 // Helpers for the list.Model 178 type vulnListItem struct { 179 resolution.Vulnerability 180 } 181 182 func (v vulnListItem) FilterValue() string { 183 return v.OSV.Id 184 } 185 186 type vulnListItemDelegate struct { 187 idWidth int 188 } 189 190 func (d vulnListItemDelegate) Height() int { return 1 } 191 func (d vulnListItemDelegate) Spacing() int { return 0 } 192 func (d vulnListItemDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } 193 194 func (d vulnListItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 195 vuln, ok := listItem.(vulnListItem) 196 if !ok { 197 return 198 } 199 cursor := " " 200 idStyle := lipgloss.NewStyle().Width(d.idWidth).Align(lipgloss.Left) 201 if index == m.Index() { 202 cursor = SelectedTextStyle.Render(">") 203 idStyle = idStyle.Inherit(SelectedTextStyle) 204 } 205 id := idStyle.Render(vuln.OSV.Id) 206 severity := RenderSeverityShort(vuln.OSV.Severity) 207 str := fmt.Sprintf("%s %s %s ", cursor, id, severity) 208 fmt.Fprint(w, str) 209 fmt.Fprint(w, truncate.StringWithTail(vuln.OSV.Summary, uint(m.Width()-lipgloss.Width(str)), "…")) //nolint:gosec 210 } 211 212 // workaround item delegate wrapper to stop the selected item from being shown as selected 213 type blurredDelegate struct { 214 list.ItemDelegate 215 } 216 217 func (d blurredDelegate) Render(w io.Writer, m list.Model, _ int, listItem list.Item) { 218 d.ItemDelegate.Render(w, m, -1, listItem) 219 }