github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/tui/model/model.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 provides the program model for the guided remediation interactive tui.
    16  package model
    17  
    18  import (
    19  	"os"
    20  
    21  	"github.com/charmbracelet/bubbles/help"
    22  	"github.com/charmbracelet/bubbles/key"
    23  	tea "github.com/charmbracelet/bubbletea"
    24  	"github.com/charmbracelet/lipgloss"
    25  	"github.com/google/osv-scalibr/guidedremediation/internal/lockfile"
    26  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest"
    27  	"github.com/google/osv-scalibr/guidedremediation/internal/remediation"
    28  	"github.com/google/osv-scalibr/guidedremediation/internal/tui/components"
    29  	"github.com/google/osv-scalibr/guidedremediation/options"
    30  	"github.com/google/osv-scalibr/guidedremediation/result"
    31  	"github.com/google/osv-scalibr/log"
    32  	"golang.org/x/term"
    33  )
    34  
    35  // Model is a bubbletea Model for the guided remediation interactive tui.
    36  type Model struct {
    37  	manifestRW manifest.ReadWriter
    38  	lockfileRW lockfile.ReadWriter
    39  	options    options.FixVulnsOptions
    40  
    41  	lockfileGraph   remediation.ResolvedGraph
    42  	lockfilePatches []result.Patch
    43  
    44  	relockBaseManifest *remediation.ResolvedManifest
    45  	relockBaseErrors   []result.ResolveError
    46  
    47  	termWidth  int // width of the whole terminal
    48  	termHeight int // height of the whole terminal
    49  
    50  	viewWidth       int                        // width of each of the two view panel
    51  	viewHeight      int                        // height of each of the two view panel
    52  	viewStyle       lipgloss.Style             // border style to render views
    53  	detailsRenderer components.DetailsRenderer // renderer for markdown details.
    54  
    55  	help help.Model // help text renderer
    56  
    57  	st      modelState // current state of program
    58  	err     error      // set if a fatal error occurs within the program
    59  	writing bool       // whether the model is currently shelling out writing lockfile/manifest file
    60  }
    61  
    62  // NewModel creates a new Model for the guided remediation interactive tui.
    63  func NewModel(manifestRW manifest.ReadWriter, lockfileRW lockfile.ReadWriter, opts options.FixVulnsOptions, detailsRenderer components.DetailsRenderer) (Model, error) {
    64  	if detailsRenderer == nil {
    65  		detailsRenderer = components.FallbackDetailsRenderer{}
    66  	}
    67  	m := Model{
    68  		manifestRW: manifestRW,
    69  		lockfileRW: lockfileRW,
    70  		options:    opts,
    71  		st:         newStateInitialize(),
    72  		help:       help.New(),
    73  		viewStyle: lipgloss.NewStyle().
    74  			BorderStyle(lipgloss.RoundedBorder()).
    75  			Padding(components.ViewVPad, components.ViewHPad),
    76  		detailsRenderer: detailsRenderer,
    77  	}
    78  
    79  	w, h, err := term.GetSize(int(os.Stdout.Fd()))
    80  	if err != nil {
    81  		log.Errorf("Failed to get terminal size: %v", err)
    82  		return Model{}, err
    83  	}
    84  	m = m.setTermSize(w, h)
    85  
    86  	return m, nil
    87  }
    88  
    89  func (m Model) setTermSize(w, h int) Model {
    90  	m.termWidth = w
    91  	m.termHeight = h
    92  
    93  	// The internal rendering space of the views occupy a percentage of the terminal width
    94  	viewWidth := max(int(float64(w)*components.ViewWidthPct), components.ViewMinWidth)
    95  	// The internal height is constant
    96  	viewHeight := components.ViewMinHeight
    97  
    98  	// The total width/height, including the whitespace padding and border characters on each side
    99  	paddedWidth := viewWidth + 2*components.ViewHPad + 2
   100  	paddedHeight := viewHeight + 2*components.ViewVPad + 2
   101  
   102  	// resize the views to the calculated dimensions
   103  	m.viewWidth = viewWidth
   104  	m.viewHeight = viewHeight
   105  	m.viewStyle = m.viewStyle.Width(paddedWidth).Height(paddedHeight)
   106  
   107  	m.st = m.st.ResizeInfo(m.viewWidth, m.viewHeight)
   108  
   109  	return m
   110  }
   111  
   112  func (m Model) getBorderStyles() (lipgloss.Style, lipgloss.Style) {
   113  	unfocused := m.viewStyle.BorderForeground(components.ColorDisabled)
   114  	if m.st.IsInfoFocused() {
   115  		return unfocused, m.viewStyle
   116  	}
   117  	return m.viewStyle, unfocused
   118  }
   119  
   120  func errorAndExit(m Model, err error) (tea.Model, tea.Cmd) {
   121  	m.err = err
   122  	return m, tea.Quit
   123  }
   124  
   125  // Init initializes the model.
   126  func (m Model) Init() tea.Cmd {
   127  	return m.st.Init(m)
   128  }
   129  
   130  // Update updates the model.
   131  func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   132  	switch msg := msg.(type) {
   133  	case tea.KeyMsg:
   134  		switch {
   135  		case msg.Type == tea.KeyCtrlC: // always quit on ctrl+c
   136  			return m, tea.Quit
   137  		case key.Matches(msg, components.Keys.Help): // toggle help
   138  			m.help.ShowAll = !m.help.ShowAll
   139  		}
   140  	case tea.WindowSizeMsg:
   141  		m = m.setTermSize(msg.Width, msg.Height)
   142  	}
   143  
   144  	return m.st.Update(m, msg)
   145  }
   146  
   147  // View returns the view of the model.
   148  func (m Model) View() string {
   149  	// render both views side-by-side
   150  	mainStyle, infoStyle := m.getBorderStyles()
   151  	mainView := mainStyle.Render(m.st.View(m))
   152  	infoView := infoStyle.Render(m.st.InfoView())
   153  	view := lipgloss.JoinHorizontal(lipgloss.Top, mainView, infoView)
   154  
   155  	// If we can't fit both side-by-side, only render the focused view
   156  	if lipgloss.Width(view) > m.termWidth {
   157  		if m.st.IsInfoFocused() {
   158  			view = infoView
   159  		} else {
   160  			view = mainView
   161  		}
   162  	}
   163  
   164  	// add the help to the bottom
   165  	view = lipgloss.JoinVertical(lipgloss.Center, view, m.help.View(components.Keys))
   166  
   167  	return lipgloss.Place(m.termWidth, m.termHeight, lipgloss.Center, lipgloss.Center, view)
   168  }
   169  
   170  // Error returns the error of the model, if any.
   171  func (m Model) Error() error {
   172  	return m.err
   173  }
   174  
   175  type modelState interface {
   176  	Init(m Model) tea.Cmd
   177  	Update(m Model, msg tea.Msg) (tea.Model, tea.Cmd)
   178  	View(m Model) string
   179  	Resize(w, h int) modelState
   180  
   181  	InfoView() string
   182  	ResizeInfo(w, h int) modelState
   183  	IsInfoFocused() bool
   184  }
   185  
   186  type writeMsg struct {
   187  	err error
   188  }