glow/ui/ui.go

768 lines
18 KiB
Go
Raw Normal View History

package ui
import (
"errors"
2020-05-22 18:03:21 +00:00
"fmt"
2020-06-20 23:54:01 +00:00
"log"
"os"
"path/filepath"
2020-05-27 15:55:00 +00:00
"strings"
2020-08-21 17:39:59 +00:00
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/charm"
"github.com/charmbracelet/charm/keygen"
2020-10-30 15:14:36 +00:00
"github.com/charmbracelet/glow/utils"
"github.com/muesli/gitcha"
2020-05-14 19:06:13 +00:00
te "github.com/muesli/termenv"
"github.com/segmentio/ksuid"
2020-05-14 19:06:13 +00:00
)
2020-08-21 20:41:06 +00:00
const (
noteCharacterLimit = 256 // should match server
statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!"
2020-12-14 23:42:33 +00:00
ellipsis = "…"
// Only show the spinner if it spins for at least this amount of time.
spinnerVisibilityTimeout = time.Millisecond * 140
// Minimum amount of time the spinner should be visible once it starts.
spinnerMinLifetime = time.Millisecond * 550
2020-08-21 20:41:06 +00:00
)
2020-08-13 19:37:53 +00:00
var (
config Config
2020-12-10 01:12:01 +00:00
markdownExtensions = []string{
"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
}
2020-12-11 01:35:00 +00:00
// True if we're logging to a file, in which case we'll log more stuff.
debug = false
// Types of documents we allow the user to stash.
2020-12-10 01:12:01 +00:00
stashableDocTypes = NewDocTypeSet(LocalDoc, NewsDoc)
2020-05-22 02:29:46 +00:00
)
2020-08-13 19:37:53 +00:00
// NewProgram returns a new Tea program.
func NewProgram(cfg Config) *tea.Program {
2020-08-07 16:34:48 +00:00
if cfg.Logfile != "" {
2020-06-20 23:54:01 +00:00
log.Println("-- Starting Glow ----------------")
log.Printf("High performance pager: %v", cfg.HighPerformancePager)
log.Printf("Glamour rendering: %v", cfg.GlamourEnabled)
2020-06-20 23:54:01 +00:00
log.Println("Bubble Tea now initializing...")
2020-08-18 19:41:57 +00:00
debug = true
2020-06-20 23:54:01 +00:00
}
config = cfg
opts := []tea.ProgramOption{tea.WithAltScreen()}
2022-10-25 14:40:51 +00:00
if cfg.EnableMouse {
opts = append(opts, tea.WithMouseCellMotion())
}
return tea.NewProgram(newModel(cfg), opts...)
}
type errMsg struct{ err error }
func (e errMsg) Error() string { return e.err.Error() }
type (
newCharmClientMsg *charm.Client
sshAuthErrMsg struct{}
keygenFailedMsg struct{ err error }
keygenSuccessMsg struct{}
initLocalFileSearchMsg struct {
cwd string
ch chan gitcha.SearchResult
}
)
2022-10-25 14:40:51 +00:00
type (
foundLocalFileMsg gitcha.SearchResult
localFileSearchFinished struct{}
gotStashMsg []*charm.Markdown
stashLoadErrMsg struct{ err error }
gotNewsMsg []*charm.Markdown
statusMessageTimeoutMsg applicationContext
newsLoadErrMsg struct{ err error }
stashSuccessMsg markdown
stashFailMsg struct {
err error
markdown markdown
}
)
2020-12-11 00:26:24 +00:00
// applicationContext indicates the area of the application something applies
2020-12-11 01:35:00 +00:00
// to. Occasionally used as an argument to commands and messages.
2020-08-21 17:39:59 +00:00
type applicationContext int
const (
stashContext applicationContext = iota
pagerContext
)
2020-12-11 01:35:00 +00:00
// state is the top-level application state.
type state int
const (
stateShowStash state = iota
2020-05-14 02:08:17 +00:00
stateShowDocument
)
2020-05-22 02:29:46 +00:00
func (s state) String() string {
2020-12-11 01:35:00 +00:00
return map[state]string{
stateShowStash: "showing file listing",
stateShowDocument: "showing document",
2020-05-22 02:29:46 +00:00
}[s]
}
type authStatus int
const (
authConnecting authStatus = iota
authOK
authFailed
)
func (s authStatus) String() string {
return map[authStatus]string{
authConnecting: "connecting",
authOK: "ok",
authFailed: "failed",
}[s]
}
type keygenState int
const (
keygenUnstarted keygenState = iota
keygenRunning
keygenFinished
)
// Common stuff we'll need to access in all models.
type commonModel struct {
cfg Config
cc *charm.Client
cwd string
authStatus authStatus
width int
height int
// Local IDs of files stashed this session. We treat this like a set,
// ignoring the value portion with an empty struct.
filesStashed map[ksuid.KSUID]struct{}
// ID of the most recently stashed markdown
latestFileStashed ksuid.KSUID
// Files currently being stashed. We remove files from this set once
// a stash operation has either succeeded or failed.
filesStashing map[ksuid.KSUID]struct{}
}
func (c commonModel) isStashing() bool {
return len(c.filesStashing) > 0
}
type model struct {
common *commonModel
state state
keygenState keygenState
fatalErr error
// Sub-models
stash stashModel
pager pagerModel
// Channel that receives paths to local markdown files
// (via the github.com/muesli/gitcha package)
localFileFinder chan gitcha.SearchResult
2020-05-14 02:08:17 +00:00
}
// unloadDocument unloads a document from the pager. Note that while this
// method alters the model we also need to send along any commands returned.
func (m *model) unloadDocument() []tea.Cmd {
2020-05-14 02:08:17 +00:00
m.state = stateShowStash
m.stash.viewState = stashStateReady
m.pager.unload()
2020-06-03 18:17:41 +00:00
m.pager.showHelp = false
var batch []tea.Cmd
if m.pager.viewport.HighPerformanceRendering {
batch = append(batch, tea.ClearScrollArea)
}
if !m.stash.shouldSpin() {
batch = append(batch, m.stash.spinner.Tick)
}
return batch
}
func newModel(cfg Config) tea.Model {
if cfg.GlamourStyle == "auto" {
if te.HasDarkBackground() {
cfg.GlamourStyle = "dark"
} else {
cfg.GlamourStyle = "light"
}
}
if len(cfg.DocumentTypes) == 0 {
cfg.DocumentTypes.Add(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
}
common := commonModel{
cfg: cfg,
authStatus: authConnecting,
filesStashed: make(map[ksuid.KSUID]struct{}),
filesStashing: make(map[ksuid.KSUID]struct{}),
}
return model{
common: &common,
state: stateShowStash,
keygenState: keygenUnstarted,
pager: newPagerModel(&common),
stash: newStashModel(&common),
}
}
func (m model) Init() tea.Cmd {
var cmds []tea.Cmd
d := m.common.cfg.DocumentTypes
if d.Contains(StashedDoc) || d.Contains(NewsDoc) {
cmds = append(cmds,
newCharmClient,
m.stash.spinner.Tick,
)
}
if d.Contains(LocalDoc) {
cmds = append(cmds, findLocalFiles(m))
}
return tea.Batch(cmds...)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2020-06-05 20:57:27 +00:00
// If there's been an error, any key exits
if m.fatalErr != nil {
2020-06-05 20:57:27 +00:00
if _, ok := msg.(tea.KeyMsg); ok {
return m, tea.Quit
}
}
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
2021-03-09 13:16:17 +00:00
case "esc":
if m.state == stateShowDocument {
batch := m.unloadDocument()
return m, tea.Batch(batch...)
}
case "q":
var cmd tea.Cmd
2020-05-22 02:29:46 +00:00
switch m.state {
case stateShowStash:
// pass through all keys if we're editing the filter
2021-03-09 13:16:17 +00:00
if m.stash.filterState == filtering || m.stash.selectionState == selectionSettingNote {
m.stash, cmd = m.stash.update(msg)
return m, cmd
2020-05-22 02:29:46 +00:00
}
2020-06-23 19:11:54 +00:00
// Special cases for the pager
2020-05-22 02:29:46 +00:00
case stateShowDocument:
switch m.pager.state {
// If setting a note send all keys straight through
case pagerStateSetNote:
var batch []tea.Cmd
2020-11-25 16:40:24 +00:00
newPagerModel, cmd := m.pager.update(msg)
m.pager = newPagerModel
batch = append(batch, cmd)
return m, tea.Batch(batch...)
}
2020-05-14 02:08:17 +00:00
}
2020-05-22 02:29:46 +00:00
return m, tea.Quit
2020-05-14 02:08:17 +00:00
case "left", "h", "delete":
if m.state == stateShowDocument && m.pager.state != pagerStateSetNote {
cmds = append(cmds, m.unloadDocument()...)
return m, tea.Batch(cmds...)
}
2020-06-23 19:11:54 +00:00
// Ctrl+C always quits no matter where in the application you are.
case "ctrl+c":
return m, tea.Quit
}
// Window size is received when starting up and on every resize
case tea.WindowSizeMsg:
m.common.width = msg.Width
m.common.height = msg.Height
m.stash.setSize(msg.Width, msg.Height)
m.pager.setSize(msg.Width, msg.Height)
2020-05-18 22:58:19 +00:00
case initLocalFileSearchMsg:
m.localFileFinder = msg.ch
m.common.cwd = msg.cwd
cmds = append(cmds, findNextLocalFile(m))
case sshAuthErrMsg:
if m.keygenState != keygenFinished { // if we haven't run the keygen yet, do that
m.keygenState = keygenRunning
cmds = append(cmds, generateSSHKeys)
} else {
// The keygen ran but things still didn't work and we can't auth
m.common.authStatus = authFailed
m.stash.err = errors.New("SSH authentication failed; we tried ssh-agent, loading keys from disk, and generating SSH keys")
2020-09-09 23:46:10 +00:00
if debug {
log.Println("entering offline mode;", m.stash.err)
2020-09-09 23:46:10 +00:00
}
// Even though it failed, news/stash loading is finished
m.stash.loaded.Add(StashedDoc, NewsDoc)
}
case keygenFailedMsg:
// Keygen failed. That sucks.
m.common.authStatus = authFailed
m.stash.err = errors.New("could not authenticate; could not generate SSH keys")
2020-09-09 23:46:10 +00:00
if debug {
log.Println("entering offline mode;", m.stash.err)
2020-09-09 23:46:10 +00:00
}
m.keygenState = keygenFinished
// Even though it failed, news/stash loading is finished
m.stash.loaded.Add(StashedDoc, NewsDoc)
case keygenSuccessMsg:
// The keygen's done, so let's try initializing the charm client again
m.keygenState = keygenFinished
cmds = append(cmds, newCharmClient)
case newCharmClientMsg:
m.common.cc = msg
m.common.authStatus = authOK
cmds = append(cmds, loadStash(m.stash), loadNews(m.stash))
2020-05-14 02:08:17 +00:00
case stashLoadErrMsg:
m.common.authStatus = authFailed
2020-05-22 19:31:54 +00:00
case fetchedMarkdownMsg:
// We've loaded a markdown file's contents for rendering
2020-08-21 00:21:52 +00:00
m.pager.currentDocument = *msg
2020-10-30 15:14:36 +00:00
msg.Body = string(utils.RemoveFrontmatter([]byte(msg.Body)))
cmds = append(cmds, renderWithGlamour(m.pager, msg.Body))
2020-05-15 19:08:45 +00:00
case contentRenderedMsg:
m.state = stateShowDocument
case noteSavedMsg:
// A note was saved to a document. This will have been done in the
// pager, so we'll need to find the corresponding note in the stash.
// So, pass the message to the stash for processing.
2020-11-25 16:40:24 +00:00
stashModel, cmd := m.stash.update(msg)
m.stash = stashModel
return m, cmd
case localFileSearchFinished, gotStashMsg, gotNewsMsg:
// Always pass these messages to the stash so we can keep it updated
// about network activity, even if the user isn't currently viewing
// the stash.
2020-11-25 16:40:24 +00:00
stashModel, cmd := m.stash.update(msg)
m.stash = stashModel
return m, cmd
case foundLocalFileMsg:
newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg))
m.stash.addMarkdowns(newMd)
2020-12-15 18:37:11 +00:00
if m.stash.filterApplied() {
newMd.buildFilterValue()
}
if m.stash.shouldUpdateFilter() {
cmds = append(cmds, filterMarkdowns(m.stash))
}
cmds = append(cmds, findNextLocalFile(m))
2020-08-21 00:21:52 +00:00
case stashSuccessMsg:
// Common handling that should happen regardless of application state
md := markdown(msg)
m.stash.addMarkdowns(&md)
2020-12-15 18:37:11 +00:00
m.common.filesStashed[msg.stashID] = struct{}{}
delete(m.common.filesStashing, md.stashID)
2020-12-15 18:37:11 +00:00
if m.stash.filterApplied() {
for _, v := range m.stash.filteredMarkdowns {
if v.stashID == msg.stashID && v.docType == ConvertedDoc {
// Add the server-side ID we got back so we can do things
// like rename and stash it.
v.ID = msg.ID
// Keep the unique ID in sync so we can do things like
// delete. Note that the markdown received a new unique ID
// when it was added to the file listing in
// stash.addMarkdowns.
v.uniqueID = md.uniqueID
break
}
}
}
2020-12-11 00:26:24 +00:00
case stashFailMsg:
// Common handling that should happen regardless of application state
2020-12-15 18:37:11 +00:00
delete(m.common.filesStashed, msg.markdown.stashID)
delete(m.common.filesStashing, msg.markdown.stashID)
2020-12-11 00:26:24 +00:00
case filteredMarkdownMsg:
if m.state == stateShowDocument {
newStashModel, cmd := m.stash.update(msg)
m.stash = newStashModel
cmds = append(cmds, cmd)
2020-08-21 17:39:59 +00:00
}
}
// Process children
switch m.state {
case stateShowStash:
2020-11-25 16:40:24 +00:00
newStashModel, cmd := m.stash.update(msg)
m.stash = newStashModel
cmds = append(cmds, cmd)
2020-05-14 02:08:17 +00:00
case stateShowDocument:
2020-11-25 16:40:24 +00:00
newPagerModel, cmd := m.pager.update(msg)
m.pager = newPagerModel
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m model) View() string {
if m.fatalErr != nil {
return errorView(m.fatalErr, true)
}
switch m.state {
2020-05-14 02:08:17 +00:00
case stateShowDocument:
return m.pager.View()
2020-08-13 19:26:34 +00:00
default:
2020-11-25 16:40:24 +00:00
return m.stash.view()
}
}
func errorView(err error, fatal bool) string {
exitMsg := "press any key to "
if fatal {
exitMsg += "exit"
} else {
exitMsg += "return"
}
s := fmt.Sprintf("%s\n\n%v\n\n%s",
errorTitleStyle.Render("ERROR"),
2020-06-05 20:57:27 +00:00
err,
subtleStyle.Render(exitMsg),
2020-06-05 20:57:27 +00:00
)
2020-07-16 19:37:48 +00:00
return "\n" + indent(s, 3)
2020-06-05 20:57:27 +00:00
}
// COMMANDS
2020-05-18 21:53:46 +00:00
func findLocalFiles(m model) tea.Cmd {
return func() tea.Msg {
var (
cwd = m.common.cfg.WorkingDirectory
err error
)
if cwd == "" {
cwd, err = os.Getwd()
} else {
var info os.FileInfo
info, err = os.Stat(cwd)
if err == nil && info.IsDir() {
cwd, err = filepath.Abs(cwd)
}
}
// Note that this is one error check for both cases above
if err != nil {
if debug {
log.Println("error finding local files:", err)
}
return errMsg{err}
2020-08-18 19:41:57 +00:00
}
if debug {
log.Println("local directory is:", cwd)
}
var ignore []string
if !m.common.cfg.ShowAllFiles {
ignore = ignorePatterns(m)
}
ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, ignore)
if err != nil {
if debug {
log.Println("error finding local files:", err)
}
return errMsg{err}
2020-08-22 08:16:01 +00:00
}
return initLocalFileSearchMsg{ch: ch, cwd: cwd}
}
}
func findNextLocalFile(m model) tea.Cmd {
return func() tea.Msg {
res, ok := <-m.localFileFinder
if ok {
// Okay now find the next one
return foundLocalFileMsg(res)
}
// We're done
2020-08-21 15:56:40 +00:00
if debug {
log.Println("local file search finished")
2020-08-21 15:56:40 +00:00
}
return localFileSearchFinished{}
}
}
func newCharmClient() tea.Msg {
cfg, err := charm.ConfigFromEnv()
if err != nil {
return errMsg{err}
}
cc, err := charm.NewClient(cfg)
if err == charm.ErrMissingSSHAuth {
if debug {
log.Println("missing SSH auth:", err)
2020-08-07 16:34:48 +00:00
}
return sshAuthErrMsg{}
} else if err != nil {
if debug {
log.Println("error creating new charm client:", err)
2020-08-07 16:34:48 +00:00
}
return errMsg{err}
2020-08-07 16:34:48 +00:00
}
return newCharmClientMsg(cc)
}
2020-05-14 00:21:37 +00:00
func loadStash(m stashModel) tea.Cmd {
return func() tea.Msg {
if m.common.cc == nil {
2020-09-09 21:00:25 +00:00
err := errors.New("no charm client")
if debug {
log.Println("error loading stash:", err)
}
return stashLoadErrMsg{err}
}
stash, err := m.common.cc.GetStash(m.serverPage)
if err != nil {
2020-08-18 19:41:57 +00:00
if debug {
if _, ok := err.(charm.ErrAuthFailed); ok {
log.Println("auth failure while loading stash:", err)
} else {
log.Println("error loading stash:", err)
}
2020-08-18 19:41:57 +00:00
}
2020-08-13 19:37:53 +00:00
return stashLoadErrMsg{err}
}
2020-12-03 23:11:06 +00:00
if debug {
log.Println("loaded stash page", m.serverPage)
2020-12-03 23:11:06 +00:00
}
return gotStashMsg(stash)
}
}
func loadNews(m stashModel) tea.Cmd {
return func() tea.Msg {
if m.common.cc == nil {
2020-09-09 21:00:25 +00:00
err := errors.New("no charm client")
if debug {
log.Println("error loading news:", err)
}
return newsLoadErrMsg{err}
}
news, err := m.common.cc.GetNews(1) // just fetch the first page
if err != nil {
2020-08-18 19:41:57 +00:00
if debug {
log.Println("error loading news:", err)
}
2020-08-13 19:37:53 +00:00
return newsLoadErrMsg{err}
}
2020-12-03 23:11:06 +00:00
if debug {
log.Println("fetched news")
}
return gotNewsMsg(news)
}
}
func generateSSHKeys() tea.Msg {
2020-09-09 23:46:10 +00:00
if debug {
log.Println("running keygen...")
}
2020-08-18 15:08:39 +00:00
_, err := keygen.NewSSHKeyPair(nil)
if err != nil {
2020-08-18 19:41:57 +00:00
if debug {
log.Println("keygen failed:", err)
}
return keygenFailedMsg{err}
}
2020-09-09 23:46:10 +00:00
if debug {
log.Println("keys generated successfully")
2020-09-09 23:46:10 +00:00
}
return keygenSuccessMsg{}
}
func saveDocumentNote(cc *charm.Client, id int, note string) tea.Cmd {
if cc == nil {
return func() tea.Msg {
2020-09-09 21:00:25 +00:00
err := errors.New("can't set note; no charm client")
if debug {
log.Println("error saving note:", err)
}
return errMsg{err}
}
}
return func() tea.Msg {
if err := cc.SetMarkdownNote(id, note); err != nil {
if debug {
log.Println("error saving note:", err)
}
return errMsg{err}
}
return noteSavedMsg(&charm.Markdown{ID: id, Note: note})
}
}
func stashDocument(cc *charm.Client, md markdown) tea.Cmd {
return func() tea.Msg {
if cc == nil {
err := errors.New("can't stash; no charm client")
if debug {
log.Println("error stashing document:", err)
}
return stashFailMsg{err, md}
}
// Is the document missing a body? If so, it likely means it needs to
// be loaded. But...if it turns out the document body really is empty
2020-12-10 01:12:01 +00:00
// then we'll stash it anyway.
if len(md.Body) == 0 {
switch md.docType {
2020-12-10 01:12:01 +00:00
case LocalDoc:
2022-10-25 14:40:51 +00:00
data, err := os.ReadFile(md.localPath)
2020-12-10 01:12:01 +00:00
if err != nil {
if debug {
log.Println("error loading document body for stashing:", err)
}
2020-12-11 00:26:24 +00:00
return stashFailMsg{err, md}
2020-12-10 01:12:01 +00:00
}
md.Body = string(data)
case NewsDoc:
newMD, err := fetchMarkdown(cc, md.ID, md.docType)
2020-12-10 01:12:01 +00:00
if err != nil {
if debug {
log.Println(err)
}
2020-12-11 00:26:24 +00:00
return stashFailMsg{err, md}
2020-12-10 01:12:01 +00:00
}
md.Body = newMD.Body
default:
err := fmt.Errorf("user is attempting to stash an unsupported markdown type: %s", md.docType)
if debug {
2020-12-11 00:26:24 +00:00
log.Println(err)
}
2020-12-11 00:26:24 +00:00
return stashFailMsg{err, md}
}
}
newMd, err := cc.StashMarkdown(md.Note, md.Body)
if err != nil {
if debug {
log.Println("error stashing document:", err)
}
2020-12-11 00:26:24 +00:00
return stashFailMsg{err, md}
}
md.convertToStashed()
2020-12-10 01:12:01 +00:00
// The server sends the whole stashed document back, but we really just
// need to know the ID so we can operate on this newly stashed
// markdown.
md.ID = newMd.ID
2020-12-10 01:12:01 +00:00
return stashSuccessMsg(md)
}
}
2020-08-21 17:39:59 +00:00
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
return func() tea.Msg {
<-t.C
return statusMessageTimeoutMsg(appCtx)
}
}
// ETC
// Convert a Gitcha result to an internal representation of a markdown
// document. Note that we could be doing things like checking if the file is
// a directory, but we trust that gitcha has already done that.
2020-08-24 20:10:06 +00:00
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
md := &markdown{
docType: LocalDoc,
localPath: res.Path,
Markdown: charm.Markdown{
Note: stripAbsolutePath(res.Path, cwd),
CreatedAt: res.Info.ModTime(),
},
}
2020-08-24 20:10:06 +00:00
return md
}
func stripAbsolutePath(fullPath, cwd string) string {
return strings.Replace(fullPath, cwd+string(os.PathSeparator), "", -1)
}
// Lightweight version of reflow's indent function.
2020-05-27 15:55:00 +00:00
func indent(s string, n int) string {
if n <= 0 || s == "" {
return s
}
l := strings.Split(s, "\n")
b := strings.Builder{}
i := strings.Repeat(" ", n)
for _, v := range l {
fmt.Fprintf(&b, "%s%s\n", i, v)
}
return b.String()
}
2020-05-14 00:21:37 +00:00
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
}