glow/ui/ui.go
Carlos Alexandro Becker b1d377237d
feat: add 'r' to refresh list and document (#624)
* feat: add 'r' to refresh list

closes #416

Co-authored-by: Dieter Eickstaedt <eickstaedt@deicon.de>

* feat: refresh document

Closes #501

Co-authored-by: fedeztk <federicoserranexus@gmail.com>

---------

Co-authored-by: Dieter Eickstaedt <eickstaedt@deicon.de>
Co-authored-by: fedeztk <federicoserranexus@gmail.com>
2024-07-08 16:15:08 -03:00

423 lines
9.2 KiB
Go

package ui
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glow/utils"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/muesli/gitcha"
te "github.com/muesli/termenv"
)
const (
statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
ellipsis = "…"
)
var (
config Config
markdownExtensions = []string{
"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
}
)
// NewProgram returns a new Tea program.
func NewProgram(cfg Config) *tea.Program {
log.Debug(
"Starting glow",
"high_perf_pager",
cfg.HighPerformancePager,
"glamour",
cfg.GlamourEnabled,
)
config = cfg
opts := []tea.ProgramOption{tea.WithAltScreen()}
if cfg.EnableMouse {
opts = append(opts, tea.WithMouseCellMotion())
}
m := newModel(cfg)
return tea.NewProgram(m, opts...)
}
type errMsg struct{ err error }
func (e errMsg) Error() string { return e.err.Error() }
type (
initLocalFileSearchMsg struct {
cwd string
ch chan gitcha.SearchResult
}
)
type (
foundLocalFileMsg gitcha.SearchResult
localFileSearchFinished struct{}
statusMessageTimeoutMsg applicationContext
)
// applicationContext indicates the area of the application something applies
// to. Occasionally used as an argument to commands and messages.
type applicationContext int
const (
stashContext applicationContext = iota
pagerContext
)
// state is the top-level application state.
type state int
const (
stateShowStash state = iota
stateShowDocument
)
func (s state) String() string {
return map[state]string{
stateShowStash: "showing file listing",
stateShowDocument: "showing document",
}[s]
}
// Common stuff we'll need to access in all models.
type commonModel struct {
cfg Config
cwd string
width int
height int
}
type model struct {
common *commonModel
state state
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
}
// 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 {
m.state = stateShowStash
m.stash.viewState = stashStateReady
m.pager.unload()
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 {
initSections()
if cfg.GlamourStyle == glamour.AutoStyle {
if te.HasDarkBackground() {
cfg.GlamourStyle = glamour.DarkStyle
} else {
cfg.GlamourStyle = glamour.LightStyle
}
}
teamList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
teamList.Styles.Title = lipgloss.NewStyle().Foreground(yellowGreen)
teamList.SetStatusBarItemName("team", "teams")
teamList.SetShowHelp(true)
// We use the team list status message as a permanent placeholder.
teamList.StatusMessageLifetime = time.Hour
common := commonModel{
cfg: cfg,
}
return model{
common: &common,
state: stateShowStash,
pager: newPagerModel(&common),
stash: newStashModel(&common),
}
}
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{m.stash.spinner.Tick}
cmds = append(cmds, findLocalFiles(*m.common))
return tea.Batch(cmds...)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If there's been an error, any key exits
if m.fatalErr != nil {
if _, ok := msg.(tea.KeyMsg); ok {
return m, tea.Quit
}
}
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
batch := m.unloadDocument()
return m, tea.Batch(batch...)
}
case "r":
if m.state == stateShowStash {
m.stash.markdowns = nil
return m, m.Init()
}
case "q":
var cmd tea.Cmd
switch m.state {
case stateShowStash:
// pass through all keys if we're editing the filter
if m.stash.filterState == filtering {
m.stash, cmd = m.stash.update(msg)
return m, cmd
}
}
return m, tea.Quit
case "left", "h", "delete":
if m.state == stateShowDocument {
cmds = append(cmds, m.unloadDocument()...)
return m, tea.Batch(cmds...)
}
// 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)
case initLocalFileSearchMsg:
m.localFileFinder = msg.ch
m.common.cwd = msg.cwd
cmds = append(cmds, findNextLocalFile(m))
case fetchedMarkdownMsg:
// We've loaded a markdown file's contents for rendering
m.pager.currentDocument = *msg
body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
cmds = append(cmds, renderWithGlamour(m.pager, body))
case contentRenderedMsg:
m.state = stateShowDocument
case localFileSearchFinished:
// 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.
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)
if m.stash.filterApplied() {
newMd.buildFilterValue()
}
if m.stash.shouldUpdateFilter() {
cmds = append(cmds, filterMarkdowns(m.stash))
}
cmds = append(cmds, findNextLocalFile(m))
case filteredMarkdownMsg:
if m.state == stateShowDocument {
newStashModel, cmd := m.stash.update(msg)
m.stash = newStashModel
cmds = append(cmds, cmd)
}
}
// Process children
switch m.state {
case stateShowStash:
newStashModel, cmd := m.stash.update(msg)
m.stash = newStashModel
cmds = append(cmds, cmd)
case stateShowDocument:
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 {
case stateShowDocument:
return m.pager.View()
default:
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"),
err,
subtleStyle.Render(exitMsg),
)
return "\n" + indent(s, 3)
}
// COMMANDS
func findLocalFiles(m commonModel) tea.Cmd {
return func() tea.Msg {
log.Info("findLocalFiles")
var (
cwd = m.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 {
log.Error("error finding local files", "error", err)
return errMsg{err}
}
log.Debug("local directory is", "cwd", cwd)
// Switch between FindFiles and FindAllFiles to bypass .gitignore rules
var ch chan gitcha.SearchResult
if m.cfg.ShowAllFiles {
ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil)
} else {
ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m))
}
if err != nil {
log.Error("error finding local files", "error", err)
return errMsg{err}
}
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
log.Debug("local file search finished")
return localFileSearchFinished{}
}
}
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.
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
md := &markdown{
localPath: res.Path,
Note: stripAbsolutePath(res.Path, cwd),
Modtime: res.Info.ModTime(),
}
return md
}
func stripAbsolutePath(fullPath, cwd string) string {
return strings.ReplaceAll(fullPath, cwd+string(os.PathSeparator), "")
}
// Lightweight version of reflow's indent function.
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()
}
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
}