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  }