github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/tui/components/vuln_info.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  	"fmt"
    19  	"strings"
    20  
    21  	"github.com/charmbracelet/bubbles/key"
    22  	"github.com/charmbracelet/bubbles/viewport"
    23  	tea "github.com/charmbracelet/bubbletea"
    24  	"github.com/charmbracelet/lipgloss"
    25  	"github.com/google/osv-scalibr/guidedremediation/internal/resolution"
    26  	"github.com/muesli/reflow/wordwrap"
    27  )
    28  
    29  // vulnInfo is a ViewModel to display the details of a specific vulnerability
    30  type vulnInfo struct {
    31  	vuln        resolution.Vulnerability
    32  	chainGraphs []ChainGraph
    33  
    34  	width  int
    35  	height int
    36  	cursor int
    37  
    38  	numDetailLines  int             // number of lines to show for details in the main view
    39  	detailsRenderer DetailsRenderer // renderer for markdown details.
    40  
    41  	viewport    viewport.Model // used for scrolling onlyDetails & onlyGraphs views
    42  	onlyDetails bool           // if the details screen is open
    43  	onlyGraphs  bool           // if the affected screen is open
    44  }
    45  
    46  var (
    47  	vulnInfoHeadingStyle = lipgloss.NewStyle().
    48  				Bold(true).
    49  				Width(10).
    50  				MarginRight(2).
    51  				Foreground(ColorPrimary)
    52  	highlightedVulnInfoHeadingStyle = vulnInfoHeadingStyle.Reverse(true)
    53  )
    54  
    55  // NewVulnInfo creates a ViewModel to display the details of a specific vulnerability.
    56  func NewVulnInfo(vuln resolution.Vulnerability, detailsRenderer DetailsRenderer) ViewModel {
    57  	v := vulnInfo{
    58  		vuln:            vuln,
    59  		width:           ViewMinWidth,
    60  		height:          ViewMinHeight,
    61  		cursor:          0,
    62  		numDetailLines:  5,
    63  		viewport:        viewport.New(ViewMinWidth, 20),
    64  		detailsRenderer: detailsRenderer,
    65  	}
    66  	v.viewport.KeyMap = viewport.KeyMap{
    67  		Up:       Keys.Up,
    68  		Down:     Keys.Down,
    69  		PageUp:   Keys.Left,
    70  		PageDown: Keys.Right,
    71  	}
    72  
    73  	v.chainGraphs = FindChainGraphs(vuln.Subgraphs)
    74  
    75  	return &v
    76  }
    77  
    78  func (v *vulnInfo) Resize(w, h int) ViewModel {
    79  	v.width = w
    80  	v.height = h
    81  	v.viewport.Width = w
    82  	v.viewport.Height = h
    83  	if v.onlyDetails {
    84  		v.viewport.SetContent(v.detailsOnlyView())
    85  	}
    86  	return v
    87  }
    88  
    89  func (v *vulnInfo) Update(msg tea.Msg) (ViewModel, tea.Cmd) {
    90  	if v.onlyDetails || v.onlyGraphs {
    91  		if msg, ok := msg.(tea.KeyMsg); ok {
    92  			if key.Matches(msg, Keys.Quit) {
    93  				v.onlyDetails = false
    94  				v.onlyGraphs = false
    95  
    96  				return v, nil
    97  			}
    98  		}
    99  		var cmd tea.Cmd
   100  		v.viewport, cmd = v.viewport.Update(msg)
   101  
   102  		return v, cmd
   103  	}
   104  	if msg, ok := msg.(tea.KeyMsg); ok {
   105  		switch {
   106  		case key.Matches(msg, Keys.Quit):
   107  			return nil, nil
   108  		case key.Matches(msg, Keys.Down):
   109  			if v.cursor < 4 {
   110  				v.cursor++
   111  			}
   112  		case key.Matches(msg, Keys.Up):
   113  			if v.cursor > 0 {
   114  				v.cursor--
   115  			}
   116  		case key.Matches(msg, Keys.Select):
   117  			if v.cursor == 3 {
   118  				v.onlyDetails = true
   119  				v.viewport.SetContent(v.detailsOnlyView())
   120  				v.viewport.GotoTop()
   121  			}
   122  			if v.cursor == 4 {
   123  				v.onlyGraphs = true
   124  				v.viewport.SetContent(v.graphOnlyView())
   125  				v.viewport.GotoTop()
   126  			}
   127  		}
   128  	}
   129  
   130  	return v, nil
   131  }
   132  
   133  func (v *vulnInfo) View() string {
   134  	if v.onlyDetails || v.onlyGraphs {
   135  		return v.viewport.View()
   136  	}
   137  
   138  	detailWidth := v.width - (vulnInfoHeadingStyle.GetWidth() + vulnInfoHeadingStyle.GetMarginRight())
   139  
   140  	vID := v.vuln.OSV.Id
   141  	sev := RenderSeverity(v.vuln.OSV.Severity)
   142  	sum := wordwrap.String(v.vuln.OSV.Summary, detailWidth)
   143  
   144  	det, err := v.detailsRenderer.Render(v.vuln.OSV.Details, v.width)
   145  	if err != nil {
   146  		det, _ = FallbackDetailsRenderer{}.Render(v.vuln.OSV.Details, v.width)
   147  	}
   148  	det = lipgloss.NewStyle().MaxHeight(v.numDetailLines).Render(det)
   149  
   150  	s := strings.Builder{}
   151  	s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
   152  		v.headingStyle(0).Render("ID:"), vID))
   153  	s.WriteString("\n")
   154  	s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
   155  		v.headingStyle(1).Render("Severity:"), sev))
   156  	s.WriteString("\n")
   157  	s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
   158  		v.headingStyle(2).Render("Summary:"), sum))
   159  	s.WriteString("\n")
   160  	s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
   161  		v.headingStyle(3).Render("Details:"), det))
   162  	s.WriteString("\n")
   163  	s.WriteString(v.headingStyle(4).Render("Affected:"))
   164  	s.WriteString("\n")
   165  	if len(v.chainGraphs) == 0 {
   166  		s.WriteString("ERROR: could not resolve any affected paths\n")
   167  		return s.String()
   168  	}
   169  	s.WriteString(lipgloss.NewStyle().MaxWidth(v.width).Render(v.chainGraphs[0].String()))
   170  	s.WriteString("\n")
   171  	if len(v.chainGraphs) > 1 {
   172  		s.WriteString(DisabledTextStyle.Render(fmt.Sprintf("+%d other paths", len(v.chainGraphs)-1)))
   173  		s.WriteString("\n")
   174  	}
   175  
   176  	return s.String()
   177  }
   178  
   179  func (v *vulnInfo) detailsOnlyView() string {
   180  	s := strings.Builder{}
   181  	s.WriteString(vulnInfoHeadingStyle.Render("Details:"))
   182  	s.WriteString("\n")
   183  	var det string
   184  	det, err := v.detailsRenderer.Render(v.vuln.OSV.Details, v.width)
   185  	if err != nil {
   186  		det, _ = FallbackDetailsRenderer{}.Render(v.vuln.OSV.Details, v.width)
   187  	}
   188  	s.WriteString(det)
   189  
   190  	return s.String()
   191  }
   192  
   193  func (v *vulnInfo) graphOnlyView() string {
   194  	// Annoyingly, some graphs still get clipped on the right side.
   195  	// This needs horizontal scrolling, but that's not supported by the bubbles viewport
   196  	// and it's difficult to implement
   197  	s := strings.Builder{}
   198  	s.WriteString(vulnInfoHeadingStyle.Render("Affected:"))
   199  	strs := make([]string, 0, 2*len(v.chainGraphs)) // 2x to include padding newlines between graphs
   200  	for _, g := range v.chainGraphs {
   201  		strs = append(strs, "\n", g.String())
   202  	}
   203  	s.WriteString(lipgloss.JoinVertical(lipgloss.Center, strs...))
   204  
   205  	return s.String()
   206  }
   207  
   208  func (v *vulnInfo) headingStyle(idx int) lipgloss.Style {
   209  	if idx == v.cursor {
   210  		return highlightedVulnInfoHeadingStyle
   211  	}
   212  
   213  	return vulnInfoHeadingStyle
   214  }
   215  
   216  // DetailsRenderer is an interface for rendering the markdown details of an OSV record.
   217  type DetailsRenderer interface {
   218  	Render(details string, width int) (string, error)
   219  }
   220  
   221  // FallbackDetailsRenderer is a DetailsRenderer that renders the details as-is (without markdown).
   222  type FallbackDetailsRenderer struct{}
   223  
   224  // Render renders the details as-is (without markdown).
   225  func (FallbackDetailsRenderer) Render(details string, width int) (string, error) {
   226  	return wordwrap.String(details, width), nil
   227  }