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  }