mirror of
https://github.com/charmbracelet/glow
synced 2024-12-12 13:12:32 +00:00
b1d377237d
* 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>
423 lines
9.2 KiB
Go
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
|
|
}
|