github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/tui/model/state_choose_in_place_patches.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 model 16 17 import ( 18 "errors" 19 "fmt" 20 "slices" 21 22 "github.com/charmbracelet/bubbles/key" 23 "github.com/charmbracelet/bubbles/table" 24 tea "github.com/charmbracelet/bubbletea" 25 "github.com/charmbracelet/lipgloss" 26 "github.com/google/osv-scalibr/guidedremediation/internal/resolution" 27 "github.com/google/osv-scalibr/guidedremediation/internal/tui/components" 28 ) 29 30 type stateChooseInPlacePatches struct { 31 stateInPlace stateInPlaceResult 32 33 table table.Model // in-place table to render 34 patchIdx []int // for each flattened patch, its index into unflattened patches 35 vulnsInfos []components.ViewModel // vulns info views corresponding to each flattened patch 36 37 focusedInfo components.ViewModel // the infoview that is currently focused, nil if not focused 38 39 viewWidth int // width for rendering (same as model.mainViewWidth) 40 } 41 42 func newStateChooseInPlacePatches(m Model, inPlaceState stateInPlaceResult) stateChooseInPlacePatches { 43 s := stateChooseInPlacePatches{ 44 stateInPlace: inPlaceState, 45 } 46 47 // pre-computation of flattened patches and vulns 48 for idx, p := range m.lockfilePatches { 49 for _, fixedVuln := range p.Fixed { 50 s.patchIdx = append(s.patchIdx, idx) 51 vulnIdx := slices.IndexFunc(m.lockfileGraph.Vulns, func(v resolution.Vulnerability) bool { return v.OSV.Id == fixedVuln.ID }) 52 if vulnIdx == -1 { 53 // something went wrong, just ignore this. 54 s.vulnsInfos = append(s.vulnsInfos, components.TextView("")) 55 } else { 56 s.vulnsInfos = append(s.vulnsInfos, components.NewVulnInfo(m.lockfileGraph.Vulns[vulnIdx], m.detailsRenderer)) 57 } 58 } 59 } 60 61 // Grab the table out of the InPlaceInfo, so it looks consistent. 62 // This is quite hacky. 63 c := components.NewInPlaceInfo(m.lockfilePatches, m.lockfileGraph.Vulns, m.detailsRenderer) 64 t, ok := c.(interface{ GetModel() table.Model }) 65 if !ok { 66 errorAndExit(m, errors.New("failed to get table model from in-place info")) 67 } 68 s.table = t.GetModel() 69 // insert the select/deselect all row, and a placeholder row for the 'done' line 70 r := s.table.Rows() 71 r = slices.Insert(r, 0, table.Row{"", "", ""}) 72 r = append(r, table.Row{"", "", ""}) 73 s.table.SetRows(r) 74 s = s.updateTableRows(m) 75 s = s.Resize(m.viewWidth, m.viewHeight).(stateChooseInPlacePatches) 76 s = s.ResizeInfo(m.viewWidth, m.viewHeight).(stateChooseInPlacePatches) 77 78 return s 79 } 80 81 func (st stateChooseInPlacePatches) Init(m Model) tea.Cmd { 82 return nil 83 } 84 85 func (st stateChooseInPlacePatches) Update(m Model, msg tea.Msg) (tea.Model, tea.Cmd) { 86 var cmd tea.Cmd 87 if msg, ok := msg.(tea.KeyMsg); ok { 88 switch { 89 case key.Matches(msg, components.Keys.SwitchView): 90 if st.IsInfoFocused() { 91 st.focusedInfo = nil 92 st.table.Focus() 93 } else if view, canFocus := st.currentInfoView(); canFocus { 94 st.focusedInfo = view 95 st.table.Blur() // ignore key presses when the info view is focused 96 } 97 case st.IsInfoFocused(): 98 st.focusedInfo, cmd = st.focusedInfo.Update(msg) 99 // VulnInfo returns nil as the model when it wants to exit, instead of the CloseViewModel Cmd 100 // if it quits, we need to re-focus the table 101 if st.focusedInfo == nil { 102 st.table.Focus() 103 } 104 case key.Matches(msg, components.Keys.Quit): 105 // go back to in-place results 106 m.st = st.stateInPlace 107 return m, nil 108 109 case key.Matches(msg, components.Keys.Select): 110 if st.table.Cursor() == len(st.table.Rows())-1 { // hit enter on done line 111 // go back to in-place results 112 m.st = st.stateInPlace 113 return m, nil 114 } 115 if st.table.Cursor() == 0 { // select/deselect all 116 // if nothing is selected, set everything to true, otherwise set everything to false 117 selection := !slices.Contains(st.stateInPlace.selectedChanges, true) 118 for i := range st.stateInPlace.selectedChanges { 119 st.stateInPlace.selectedChanges[i] = selection 120 } 121 } else { 122 st = st.toggleSelection(st.table.Cursor() - 1) 123 } 124 st = st.updateTableRows(m) 125 } 126 } 127 // update the table 128 t, c := st.table.Update(msg) 129 st.table = t 130 m.st = st 131 132 return m, tea.Batch(cmd, c) 133 } 134 135 func (st stateChooseInPlacePatches) View(m Model) string { 136 tableStr := lipgloss.PlaceHorizontal(st.viewWidth, lipgloss.Center, st.table.View()) 137 return lipgloss.JoinVertical(lipgloss.Left, 138 tableStr, 139 components.RenderSelectorOption(st.table.Cursor() == len(st.table.Rows())-1, " > ", "%s", "Done"), 140 ) 141 } 142 143 func (st stateChooseInPlacePatches) InfoView() string { 144 v, _ := st.currentInfoView() 145 return v.View() 146 } 147 148 func (st stateChooseInPlacePatches) updateTableRows(m Model) stateChooseInPlacePatches { 149 // update the checkbox for each row 150 rows := st.table.Rows() 151 anySelected := false 152 for i, pIdx := range st.patchIdx { 153 // don't render a checkbox on the empty lines 154 if rows[i+1][0] == "" { 155 continue 156 } 157 var checkBox string 158 if st.stateInPlace.selectedChanges[pIdx] { 159 checkBox = "[x]" 160 anySelected = true 161 } else { 162 checkBox = "[ ]" 163 } 164 rows[i+1][0] = fmt.Sprintf("%s %s", checkBox, m.lockfilePatches[pIdx].PackageUpdates[0].Name) 165 } 166 // show select all only if nothing is selected, 167 // show deselect all if anything is selected 168 if anySelected { 169 rows[0][0] = "DESELECT ALL" 170 } else { 171 rows[0][0] = "SELECT ALL" 172 } 173 st.table.SetRows(rows) 174 // there is no table.Columns() method, so I can't resize the columns to fit the checkbox properly :( 175 return st 176 } 177 178 func (st stateChooseInPlacePatches) toggleSelection(idx int) stateChooseInPlacePatches { 179 i := st.patchIdx[idx] 180 st.stateInPlace.selectedChanges[i] = !st.stateInPlace.selectedChanges[i] 181 return st 182 } 183 184 func (st stateChooseInPlacePatches) currentInfoView() (view components.ViewModel, canFocus bool) { 185 if c := st.table.Cursor(); c > 0 && c < len(st.table.Rows())-1 { 186 return st.vulnsInfos[c-1], true 187 } 188 189 return components.TextView(""), false 190 } 191 192 func (st stateChooseInPlacePatches) Resize(w, h int) modelState { 193 st.viewWidth = w 194 st.table.SetWidth(w) 195 st.table.SetHeight(h - 1) // -1 to account for 'Done' line at bottom 196 return st 197 } 198 199 func (st stateChooseInPlacePatches) ResizeInfo(w, h int) modelState { 200 for i, info := range st.vulnsInfos { 201 st.vulnsInfos[i] = info.Resize(w, h) 202 } 203 return st 204 } 205 206 func (st stateChooseInPlacePatches) IsInfoFocused() bool { 207 return st.focusedInfo != nil 208 }