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 }