package ui import ( "errors" "fmt" "os" "strings" "github.com/charmbracelet/boba" "github.com/charmbracelet/boba/pager" "github.com/charmbracelet/boba/spinner" "github.com/charmbracelet/charm" "github.com/charmbracelet/charm/ui/common" "github.com/charmbracelet/charm/ui/keygen" "github.com/muesli/reflow/indent" te "github.com/muesli/termenv" "golang.org/x/crypto/ssh/terminal" ) const ( statusBarHeight = 1 ) var ( statusBarBg = common.NewColorPair("#242424", "#F4F0F4") statusBarFg = common.NewColorPair("#5A5A5A", "") ) // NewProgram returns a new Boba program func NewProgram() *boba.Program { return boba.NewProgram(initialize, update, view) } // MESSAGES type fatalErrMsg error type errMsg error type newCharmClientMsg *charm.Client type sshAuthErrMsg struct{} type terminalSizeMsg struct { width int height int err error } func (t terminalSizeMsg) Size() (int, int) { return t.width, t.height } func (t terminalSizeMsg) Error() error { return t.err } // MODEL type state int const ( stateInitCharmClient state = iota stateKeygenRunning stateKeygenFinished stateShowStash stateShowDocument ) type model struct { cc *charm.Client user *charm.User spinner spinner.Model keygen keygen.Model state state err error stash stashModel pager pager.Model terminalWidth int terminalHeight int docNote string } func (m *model) unloadDocument() { m.pager = pager.Model{} m.state = stateShowStash m.stash.state = stashStateLoaded } // INIT func initialize() (boba.Model, boba.Cmd) { s := spinner.NewModel() s.Type = spinner.Dot s.ForegroundColor = common.SpinnerColor w, h, err := terminal.GetSize(int(os.Stdout.Fd())) return model{ spinner: s, state: stateInitCharmClient, err: err, terminalWidth: w, terminalHeight: h, }, boba.Batch( newCharmClient, spinner.Tick(s), boba.GetTerminalSize(func(w, h int, err error) boba.TerminalSizeMsg { return terminalSizeMsg{width: w, height: h, err: err} }), ) } // UPDATE func update(msg boba.Msg, mdl boba.Model) (boba.Model, boba.Cmd) { m, ok := mdl.(model) if !ok { return model{err: errors.New("could not perform assertion on model in update")}, boba.Quit } var cmd boba.Cmd switch msg := msg.(type) { case boba.KeyMsg: switch msg.String() { case "q": fallthrough case "esc": if m.state == stateShowDocument { m.unloadDocument() return m, nil } return m, boba.Quit case "ctrl+c": return m, boba.Quit } case fatalErrMsg: m.err = msg return m, boba.Quit case errMsg: m.err = msg return m, nil case terminalSizeMsg: if msg.Error() != nil { m.err = msg.Error() return m, nil } w, h := msg.Size() m.terminalWidth = w m.terminalHeight = h return m, nil case sshAuthErrMsg: // If we haven't run the keygen yet, do that if m.state != stateKeygenFinished { m.state = stateKeygenRunning m.keygen = keygen.NewModel() return m, keygen.GenerateKeys } // The keygen didn't work and we can't auth m.err = errors.New("SSH authentication failed") return m, boba.Quit case spinner.TickMsg: switch m.state { case stateInitCharmClient: m.spinner, cmd = spinner.Update(msg, m.spinner) } return m, cmd case stashSpinnerTickMsg: if m.state == stateShowStash { m.stash, cmd = stashUpdate(msg, m.stash) } return m, cmd case keygen.DoneMsg: m.state = stateKeygenFinished return m, newCharmClient case newCharmClientMsg: m.cc = msg m.state = stateShowStash m.stash, cmd = stashInit(m.cc) return m, cmd case gotStashedItemMsg: // TODO: there's a (unlikely) potential race condition that could // happen here where we start the pager before we have received the // terminal size. We could solve this in a few ways, including by // getting the terminal size imperatively and synchronously m.state = stateShowDocument m.pager = pager.NewModel( m.terminalWidth, m.terminalHeight-statusBarHeight, ) m.docNote = msg.Note m.pager.Content(msg.Body) return m, nil } switch m.state { case stateKeygenRunning: mdl, cmd := keygen.Update(msg, boba.Model(m.keygen)) keygenModel, ok := mdl.(keygen.Model) if !ok { m.err = errors.New("could not perform assertion on keygen model in main update") return m, boba.Quit } m.keygen = keygenModel return m, cmd case stateShowStash: m.stash, cmd = stashUpdate(msg, m.stash) return m, cmd case stateShowDocument: newPagerModel, cmd := pager.Update(msg, boba.Model(m.pager)) newPagerModel_, ok := newPagerModel.(pager.Model) if !ok { m.err = errors.New("could not assert boba.Model to pager.Model in main update") return m, nil } m.pager = newPagerModel_ return m, cmd } return m, nil } // VIEW func view(mdl boba.Model) string { m, ok := mdl.(model) if !ok { return "could not perform assertion on model in view" } if m.err != nil { return m.err.Error() + "\n" } var s string switch m.state { case stateInitCharmClient: s += spinner.View(m.spinner) + " Initializing..." case stateKeygenRunning: s += keygen.View(m.keygen) case stateKeygenFinished: s += spinner.View(m.spinner) + " Re-initializing..." case stateShowStash: s += stashView(m.stash) case stateShowDocument: //return fmt.Sprintf("\n%s\n%s", statusBarView(m), pager.View(m.pager)) return fmt.Sprintf("%s\n%s", pager.View(m.pager), statusBarView(m)) } if m.state != stateShowStash && m.state != stateShowDocument { s = "\n" + indent.String(s, 2) } return s } func statusBarView(m model) string { // Logo logoText := " Glow " logo := te.String(logoText). Bold(). Foreground(common.YellowGreen.Color()). Background(common.Fuschia.Color()). String() // Note noteText := m.docNote if len(noteText) == 0 { noteText = "(No title)" } noteText = " " + noteText note := te.String(noteText). Foreground(statusBarFg.Color()). Background(statusBarBg.Color()).String() // Scroll percent percentText := fmt.Sprintf(" %3.f%% ", m.pager.ScrollPercent()*100) percent := te.String(percentText). Foreground(statusBarFg.Color()). Background(statusBarBg.Color()). String() emptySpace := te.String(" ").Background(statusBarBg.Color()).String() return logo + note + strings.Repeat( emptySpace, m.terminalWidth-len(logoText)-len(noteText)-len(percentText), ) + percent } // COMMANDS func newCharmClient() boba.Msg { cfg, err := charm.ConfigFromEnv() if err != nil { return fatalErrMsg(err) } cc, err := charm.NewClient(cfg) if err == charm.ErrMissingSSHAuth { return sshAuthErrMsg{} } else if err != nil { return fatalErrMsg(err) } return newCharmClientMsg(cc) } // ETC func min(a, b int) int { if a < b { return a } return b } func max(a, b int) int { if a > b { return a } return b }