mirror of
https://github.com/charmbracelet/glow
synced 2024-12-14 06:02:27 +00:00
597e56bad4
closes #547
890 lines
21 KiB
Go
890 lines
21 KiB
Go
package ui
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/paginator"
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/log"
|
|
"github.com/muesli/reflow/ansi"
|
|
"github.com/muesli/reflow/truncate"
|
|
"github.com/sahilm/fuzzy"
|
|
)
|
|
|
|
const (
|
|
stashIndent = 1
|
|
stashViewItemHeight = 3 // height of stash entry, including gap
|
|
stashViewTopPadding = 5 // logo, status bar, gaps
|
|
stashViewBottomPadding = 3 // pagination and gaps, but not help
|
|
stashViewHorizontalPadding = 6
|
|
)
|
|
|
|
var stashingStatusMessage = statusMessage{normalStatusMessage, "Stashing..."}
|
|
|
|
var (
|
|
dividerDot = darkGrayFg.SetString(" • ")
|
|
dividerBar = darkGrayFg.SetString(" │ ")
|
|
|
|
logoStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#ECFD65")).
|
|
Background(fuchsia).
|
|
Bold(true)
|
|
|
|
stashSpinnerStyle = lipgloss.NewStyle().
|
|
Foreground(gray)
|
|
stashInputPromptStyle = lipgloss.NewStyle().
|
|
Foreground(yellowGreen).
|
|
MarginRight(1)
|
|
stashInputCursorStyle = lipgloss.NewStyle().
|
|
Foreground(fuchsia).
|
|
MarginRight(1)
|
|
)
|
|
|
|
// MSG
|
|
|
|
type (
|
|
filteredMarkdownMsg []*markdown
|
|
fetchedMarkdownMsg *markdown
|
|
)
|
|
|
|
// MODEL
|
|
|
|
// stashViewState is the high-level state of the file listing.
|
|
type stashViewState int
|
|
|
|
const (
|
|
stashStateReady stashViewState = iota
|
|
stashStateLoadingDocument
|
|
stashStateShowingError
|
|
)
|
|
|
|
// The types of documents we are currently showing to the user.
|
|
type sectionKey int
|
|
|
|
const (
|
|
documentsSection = iota
|
|
filterSection
|
|
)
|
|
|
|
// section contains definitions and state information for displaying a tab and
|
|
// its contents in the file listing view.
|
|
type section struct {
|
|
key sectionKey
|
|
paginator paginator.Model
|
|
cursor int
|
|
}
|
|
|
|
// map sections to their associated types.
|
|
var sections = map[sectionKey]section{}
|
|
|
|
// filterState is the current filtering state in the file listing.
|
|
type filterState int
|
|
|
|
const (
|
|
unfiltered filterState = iota // no filter set
|
|
filtering // user is actively setting a filter
|
|
filterApplied // a filter is applied and user is not editing filter
|
|
)
|
|
|
|
// statusMessageType adds some context to the status message being sent.
|
|
type statusMessageType int
|
|
|
|
// Types of status messages.
|
|
const (
|
|
normalStatusMessage statusMessageType = iota
|
|
subtleStatusMessage
|
|
errorStatusMessage
|
|
)
|
|
|
|
// statusMessage is an ephemeral note displayed in the UI.
|
|
type statusMessage struct {
|
|
status statusMessageType
|
|
message string
|
|
}
|
|
|
|
func initSections() {
|
|
sections = map[sectionKey]section{
|
|
documentsSection: {
|
|
key: documentsSection,
|
|
paginator: newStashPaginator(),
|
|
},
|
|
filterSection: {
|
|
key: filterSection,
|
|
paginator: newStashPaginator(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// String returns a styled version of the status message appropriate for the
|
|
// given context.
|
|
func (s statusMessage) String() string {
|
|
switch s.status {
|
|
case subtleStatusMessage:
|
|
return dimGreenFg(s.message)
|
|
case errorStatusMessage:
|
|
return redFg(s.message)
|
|
default:
|
|
return greenFg(s.message)
|
|
}
|
|
}
|
|
|
|
type stashModel struct {
|
|
common *commonModel
|
|
err error
|
|
spinner spinner.Model
|
|
filterInput textinput.Model
|
|
stashFullyLoaded bool // have we loaded all available stashed documents from the server?
|
|
viewState stashViewState
|
|
filterState filterState
|
|
showFullHelp bool
|
|
showStatusMessage bool
|
|
statusMessage statusMessage
|
|
statusMessageTimer *time.Timer
|
|
|
|
// Available document sections we can cycle through. We use a slice, rather
|
|
// than a map, because order is important.
|
|
sections []section
|
|
|
|
// Index of the section we're currently looking at
|
|
sectionIndex int
|
|
|
|
// Tracks if docs were loaded
|
|
loaded bool
|
|
|
|
// The master set of markdown documents we're working with.
|
|
markdowns []*markdown
|
|
|
|
// Markdown documents we're currently displaying. Filtering, toggles and so
|
|
// on will alter this slice so we can show what is relevant. For that
|
|
// reason, this field should be considered ephemeral.
|
|
filteredMarkdowns []*markdown
|
|
|
|
// Page we're fetching stash items from on the server, which is different
|
|
// from the local pagination. Generally, the server will return more items
|
|
// than we can display at a time so we can paginate locally without having
|
|
// to fetch every time.
|
|
serverPage int64
|
|
}
|
|
|
|
func (m stashModel) loadingDone() bool {
|
|
return m.loaded
|
|
}
|
|
|
|
func (m stashModel) currentSection() *section {
|
|
return &m.sections[m.sectionIndex]
|
|
}
|
|
|
|
func (m stashModel) paginator() *paginator.Model {
|
|
return &m.currentSection().paginator
|
|
}
|
|
|
|
func (m *stashModel) setPaginator(p paginator.Model) {
|
|
m.currentSection().paginator = p
|
|
}
|
|
|
|
func (m stashModel) cursor() int {
|
|
return m.currentSection().cursor
|
|
}
|
|
|
|
func (m *stashModel) setCursor(i int) {
|
|
m.currentSection().cursor = i
|
|
}
|
|
|
|
// Whether or not the spinner should be spinning.
|
|
func (m stashModel) shouldSpin() bool {
|
|
loading := !m.loadingDone()
|
|
openingDocument := m.viewState == stashStateLoadingDocument
|
|
return loading || openingDocument
|
|
}
|
|
|
|
func (m *stashModel) setSize(width, height int) {
|
|
m.common.width = width
|
|
m.common.height = height
|
|
|
|
m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(
|
|
m.filterInput.Prompt,
|
|
)
|
|
|
|
m.updatePagination()
|
|
}
|
|
|
|
func (m *stashModel) resetFiltering() {
|
|
m.filterState = unfiltered
|
|
m.filterInput.Reset()
|
|
m.filteredMarkdowns = nil
|
|
|
|
sortMarkdowns(m.markdowns)
|
|
|
|
// If the filtered section is present (it's always at the end) slice it out
|
|
// of the sections slice to remove it from the UI.
|
|
if m.sections[len(m.sections)-1].key == filterSection {
|
|
m.sections = m.sections[:len(m.sections)-1]
|
|
}
|
|
|
|
// If the current section is out of bounds (it would be if we cut down the
|
|
// slice above) then return to the first section.
|
|
if m.sectionIndex > len(m.sections)-1 {
|
|
m.sectionIndex = 0
|
|
}
|
|
|
|
// Update pagination after we've switched sections.
|
|
m.updatePagination()
|
|
}
|
|
|
|
// Is a filter currently being applied?
|
|
func (m stashModel) filterApplied() bool {
|
|
return m.filterState != unfiltered
|
|
}
|
|
|
|
// Should we be updating the filter?
|
|
func (m stashModel) shouldUpdateFilter() bool {
|
|
// If we're in the middle of setting a note don't update the filter so that
|
|
// the focus won't jump around.
|
|
return m.filterApplied()
|
|
}
|
|
|
|
// Update pagination according to the amount of markdowns for the current
|
|
// state.
|
|
func (m *stashModel) updatePagination() {
|
|
_, helpHeight := m.helpView()
|
|
|
|
availableHeight := m.common.height -
|
|
stashViewTopPadding -
|
|
helpHeight -
|
|
stashViewBottomPadding
|
|
|
|
m.paginator().PerPage = max(1, availableHeight/stashViewItemHeight)
|
|
|
|
if pages := len(m.getVisibleMarkdowns()); pages < 1 {
|
|
m.paginator().SetTotalPages(1)
|
|
} else {
|
|
m.paginator().SetTotalPages(pages)
|
|
}
|
|
|
|
// Make sure the page stays in bounds
|
|
if m.paginator().Page >= m.paginator().TotalPages-1 {
|
|
m.paginator().Page = max(0, m.paginator().TotalPages-1)
|
|
}
|
|
}
|
|
|
|
// MarkdownIndex returns the index of the currently selected markdown item.
|
|
func (m stashModel) markdownIndex() int {
|
|
return m.paginator().Page*m.paginator().PerPage + m.cursor()
|
|
}
|
|
|
|
// Return the current selected markdown in the stash.
|
|
func (m stashModel) selectedMarkdown() *markdown {
|
|
i := m.markdownIndex()
|
|
|
|
mds := m.getVisibleMarkdowns()
|
|
if i < 0 || len(mds) == 0 || len(mds) <= i {
|
|
return nil
|
|
}
|
|
|
|
return mds[i]
|
|
}
|
|
|
|
// Adds markdown documents to the model.
|
|
func (m *stashModel) addMarkdowns(mds ...*markdown) {
|
|
if len(mds) == 0 {
|
|
return
|
|
}
|
|
|
|
m.markdowns = append(m.markdowns, mds...)
|
|
if !m.filterApplied() {
|
|
sortMarkdowns(m.markdowns)
|
|
}
|
|
|
|
m.updatePagination()
|
|
}
|
|
|
|
// Returns the markdowns that should be currently shown.
|
|
func (m stashModel) getVisibleMarkdowns() []*markdown {
|
|
if m.filterState == filtering || m.currentSection().key == filterSection {
|
|
return m.filteredMarkdowns
|
|
}
|
|
|
|
return m.markdowns
|
|
}
|
|
|
|
// Command for opening a markdown document in the pager. Note that this also
|
|
// alters the model.
|
|
func (m *stashModel) openMarkdown(md *markdown) tea.Cmd {
|
|
m.viewState = stashStateLoadingDocument
|
|
cmd := loadLocalMarkdown(md)
|
|
return tea.Batch(cmd, m.spinner.Tick)
|
|
}
|
|
|
|
func (m *stashModel) hideStatusMessage() {
|
|
m.showStatusMessage = false
|
|
m.statusMessage = statusMessage{}
|
|
if m.statusMessageTimer != nil {
|
|
m.statusMessageTimer.Stop()
|
|
}
|
|
}
|
|
|
|
func (m *stashModel) moveCursorUp() {
|
|
m.setCursor(m.cursor() - 1)
|
|
if m.cursor() < 0 && m.paginator().Page == 0 {
|
|
// Stop
|
|
m.setCursor(0)
|
|
return
|
|
}
|
|
|
|
if m.cursor() >= 0 {
|
|
return
|
|
}
|
|
// Go to previous page
|
|
m.paginator().PrevPage()
|
|
|
|
m.setCursor(m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) - 1)
|
|
}
|
|
|
|
func (m *stashModel) moveCursorDown() {
|
|
itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns()))
|
|
|
|
m.setCursor(m.cursor() + 1)
|
|
if m.cursor() < itemsOnPage {
|
|
return
|
|
}
|
|
|
|
if !m.paginator().OnLastPage() {
|
|
m.paginator().NextPage()
|
|
m.setCursor(0)
|
|
return
|
|
}
|
|
|
|
// During filtering the cursor position can exceed the number of
|
|
// itemsOnPage. It's more intuitive to start the cursor at the
|
|
// topmost position when moving it down in this scenario.
|
|
if m.cursor() > itemsOnPage {
|
|
m.setCursor(0)
|
|
return
|
|
}
|
|
m.setCursor(itemsOnPage - 1)
|
|
}
|
|
|
|
// INIT
|
|
|
|
func newStashModel(common *commonModel) stashModel {
|
|
sp := spinner.New()
|
|
sp.Spinner = spinner.Line
|
|
sp.Style = stashSpinnerStyle
|
|
|
|
si := textinput.New()
|
|
si.Prompt = "Find:"
|
|
si.PromptStyle = stashInputPromptStyle
|
|
si.Cursor.Style = stashInputCursorStyle
|
|
si.Focus()
|
|
|
|
s := []section{
|
|
sections[documentsSection],
|
|
}
|
|
|
|
m := stashModel{
|
|
common: common,
|
|
spinner: sp,
|
|
filterInput: si,
|
|
serverPage: 1,
|
|
sections: s,
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func newStashPaginator() paginator.Model {
|
|
p := paginator.New()
|
|
p.Type = paginator.Dots
|
|
p.ActiveDot = brightGrayFg("•")
|
|
p.InactiveDot = darkGrayFg.Render("•")
|
|
return p
|
|
}
|
|
|
|
// UPDATE
|
|
|
|
func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case errMsg:
|
|
m.err = msg
|
|
|
|
case localFileSearchFinished:
|
|
// We're finished searching for local files
|
|
m.loaded = true
|
|
|
|
case filteredMarkdownMsg:
|
|
m.filteredMarkdowns = msg
|
|
m.setCursor(0)
|
|
return m, nil
|
|
|
|
case spinner.TickMsg:
|
|
if m.shouldSpin() {
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
case statusMessageTimeoutMsg:
|
|
if applicationContext(msg) == stashContext {
|
|
m.hideStatusMessage()
|
|
}
|
|
}
|
|
|
|
if m.filterState == filtering {
|
|
cmds = append(cmds, m.handleFiltering(msg))
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// Updates per the current state
|
|
switch m.viewState {
|
|
case stashStateReady:
|
|
cmds = append(cmds, m.handleDocumentBrowsing(msg))
|
|
case stashStateShowingError:
|
|
// Any key exists the error view
|
|
if _, ok := msg.(tea.KeyMsg); ok {
|
|
m.viewState = stashStateReady
|
|
}
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// Updates for when a user is browsing the markdown listing.
|
|
func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
|
|
numDocs := len(m.getVisibleMarkdowns())
|
|
|
|
switch msg := msg.(type) {
|
|
// Handle keys
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "k", "ctrl+k", "up":
|
|
m.moveCursorUp()
|
|
|
|
case "j", "ctrl+j", "down":
|
|
m.moveCursorDown()
|
|
|
|
// Go to the very start
|
|
case "home", "g":
|
|
m.paginator().Page = 0
|
|
m.setCursor(0)
|
|
|
|
// Go to the very end
|
|
case "end", "G":
|
|
m.paginator().Page = m.paginator().TotalPages - 1
|
|
m.setCursor(m.paginator().ItemsOnPage(numDocs) - 1)
|
|
|
|
// Clear filter (if applicable)
|
|
case keyEsc:
|
|
if m.filterApplied() {
|
|
m.resetFiltering()
|
|
}
|
|
|
|
// Next section
|
|
case "tab", "L":
|
|
if len(m.sections) == 0 || m.filterState == filtering {
|
|
break
|
|
}
|
|
m.sectionIndex++
|
|
if m.sectionIndex >= len(m.sections) {
|
|
m.sectionIndex = 0
|
|
}
|
|
m.updatePagination()
|
|
|
|
// Previous section
|
|
case "shift+tab", "H":
|
|
if len(m.sections) == 0 || m.filterState == filtering {
|
|
break
|
|
}
|
|
m.sectionIndex--
|
|
if m.sectionIndex < 0 {
|
|
m.sectionIndex = len(m.sections) - 1
|
|
}
|
|
m.updatePagination()
|
|
|
|
case "F":
|
|
m.loaded = false
|
|
return findLocalFiles(*m.common)
|
|
|
|
// Edit document in EDITOR
|
|
case "e":
|
|
md := m.selectedMarkdown()
|
|
return openEditor(md.localPath, 0)
|
|
|
|
// Open document
|
|
case keyEnter:
|
|
m.hideStatusMessage()
|
|
|
|
if numDocs == 0 {
|
|
break
|
|
}
|
|
|
|
// Load the document from the server. We'll handle the message
|
|
// that comes back in the main update function.
|
|
md := m.selectedMarkdown()
|
|
cmds = append(cmds, m.openMarkdown(md))
|
|
|
|
// Filter your notes
|
|
case "/":
|
|
m.hideStatusMessage()
|
|
|
|
// Build values we'll filter against
|
|
for _, md := range m.markdowns {
|
|
md.buildFilterValue()
|
|
}
|
|
|
|
m.filteredMarkdowns = m.markdowns
|
|
|
|
m.paginator().Page = 0
|
|
m.setCursor(0)
|
|
m.filterState = filtering
|
|
m.filterInput.CursorEnd()
|
|
m.filterInput.Focus()
|
|
return textinput.Blink
|
|
|
|
// Toggle full help
|
|
case "?":
|
|
m.showFullHelp = !m.showFullHelp
|
|
m.updatePagination()
|
|
|
|
// Show errors
|
|
case "!":
|
|
if m.err != nil && m.viewState == stashStateReady {
|
|
m.viewState = stashStateShowingError
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update paginator. Pagination key handling is done here, but it could
|
|
// also be moved up to this level, in which case we'd use model methods
|
|
// like model.PageUp().
|
|
newPaginatorModel, cmd := m.paginator().Update(msg)
|
|
m.setPaginator(newPaginatorModel)
|
|
cmds = append(cmds, cmd)
|
|
|
|
// Extra paginator keystrokes
|
|
if key, ok := msg.(tea.KeyMsg); ok {
|
|
switch key.String() {
|
|
case "b", "u":
|
|
m.paginator().PrevPage()
|
|
case "f", "d":
|
|
m.paginator().NextPage()
|
|
}
|
|
}
|
|
|
|
// Keep the index in bounds when paginating
|
|
itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns()))
|
|
if m.cursor() > itemsOnPage-1 {
|
|
m.setCursor(max(0, itemsOnPage-1))
|
|
}
|
|
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
// Updates for when a user is in the filter editing interface.
|
|
func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
|
|
// Handle keys
|
|
if msg, ok := msg.(tea.KeyMsg); ok {
|
|
switch msg.String() {
|
|
case keyEsc:
|
|
// Cancel filtering
|
|
m.resetFiltering()
|
|
case keyEnter, "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down":
|
|
m.hideStatusMessage()
|
|
|
|
if len(m.markdowns) == 0 {
|
|
break
|
|
}
|
|
|
|
h := m.getVisibleMarkdowns()
|
|
|
|
// If we've filtered down to nothing, clear the filter
|
|
if len(h) == 0 {
|
|
m.viewState = stashStateReady
|
|
m.resetFiltering()
|
|
break
|
|
}
|
|
|
|
// When there's only one filtered markdown left we can just
|
|
// "open" it directly
|
|
if len(h) == 1 {
|
|
m.viewState = stashStateReady
|
|
m.resetFiltering()
|
|
cmds = append(cmds, m.openMarkdown(h[0]))
|
|
break
|
|
}
|
|
|
|
// Add new section if it's not present
|
|
if m.sections[len(m.sections)-1].key != filterSection {
|
|
m.sections = append(m.sections, sections[filterSection])
|
|
}
|
|
m.sectionIndex = len(m.sections) - 1
|
|
|
|
m.filterInput.Blur()
|
|
|
|
m.filterState = filterApplied
|
|
if m.filterInput.Value() == "" {
|
|
m.resetFiltering()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the filter text input component
|
|
newFilterInputModel, inputCmd := m.filterInput.Update(msg)
|
|
currentFilterVal := m.filterInput.Value()
|
|
newFilterVal := newFilterInputModel.Value()
|
|
m.filterInput = newFilterInputModel
|
|
cmds = append(cmds, inputCmd)
|
|
|
|
// If the filtering input has changed, request updated filtering
|
|
if newFilterVal != currentFilterVal {
|
|
cmds = append(cmds, filterMarkdowns(*m))
|
|
}
|
|
|
|
// Update pagination
|
|
m.updatePagination()
|
|
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
// VIEW
|
|
|
|
func (m stashModel) view() string {
|
|
var s string
|
|
switch m.viewState {
|
|
case stashStateShowingError:
|
|
return errorView(m.err, false)
|
|
case stashStateLoadingDocument:
|
|
s += " " + m.spinner.View() + " Loading document..."
|
|
case stashStateReady:
|
|
loadingIndicator := " "
|
|
if m.shouldSpin() {
|
|
loadingIndicator = m.spinner.View()
|
|
}
|
|
|
|
// Only draw the normal header if we're not using the header area for
|
|
// something else (like a note or delete prompt).
|
|
header := m.headerView()
|
|
|
|
// Rules for the logo, filter and status message.
|
|
logoOrFilter := " "
|
|
if m.showStatusMessage && m.filterState == filtering {
|
|
logoOrFilter += m.statusMessage.String()
|
|
} else if m.filterState == filtering {
|
|
logoOrFilter += m.filterInput.View()
|
|
} else {
|
|
logoOrFilter += glowLogoView()
|
|
if m.showStatusMessage {
|
|
logoOrFilter += " " + m.statusMessage.String()
|
|
}
|
|
}
|
|
logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis)
|
|
|
|
help, helpHeight := m.helpView()
|
|
|
|
populatedView := m.populatedView()
|
|
populatedViewHeight := strings.Count(populatedView, "\n") + 2
|
|
|
|
// We need to fill any empty height with newlines so the footer reaches
|
|
// the bottom.
|
|
availHeight := m.common.height -
|
|
stashViewTopPadding -
|
|
populatedViewHeight -
|
|
helpHeight -
|
|
stashViewBottomPadding
|
|
blankLines := strings.Repeat("\n", max(0, availHeight))
|
|
|
|
var pagination string
|
|
if m.paginator().TotalPages > 1 {
|
|
pagination = m.paginator().View()
|
|
|
|
// If the dot pagination is wider than the width of the window
|
|
// use the arabic paginator.
|
|
if ansi.PrintableRuneWidth(pagination) > m.common.width-stashViewHorizontalPadding {
|
|
// Copy the paginator since m.paginator() returns a pointer to
|
|
// the active paginator and we don't want to mutate it. In
|
|
// normal cases, where the paginator is not a pointer, we could
|
|
// safely change the model parameters for rendering here as the
|
|
// current model is discarded after reuturning from a View().
|
|
// One could argue, in fact, that using pointers in
|
|
// a functional framework is an antipattern and our use of
|
|
// pointers in our model should be refactored away.
|
|
p := *(m.paginator())
|
|
p.Type = paginator.Arabic
|
|
pagination = paginationStyle.Render(p.View())
|
|
}
|
|
|
|
// We could also look at m.stashFullyLoaded and add an indicator
|
|
// showing that we don't actually know how many more pages there
|
|
// are.
|
|
}
|
|
|
|
s += fmt.Sprintf(
|
|
"%s%s\n\n %s\n\n%s\n\n%s %s\n\n%s",
|
|
loadingIndicator,
|
|
logoOrFilter,
|
|
header,
|
|
populatedView,
|
|
blankLines,
|
|
pagination,
|
|
help,
|
|
)
|
|
}
|
|
return "\n" + indent(s, stashIndent)
|
|
}
|
|
|
|
func glowLogoView() string {
|
|
return logoStyle.Render(" Glow ")
|
|
}
|
|
|
|
func (m stashModel) headerView() string {
|
|
localCount := len(m.markdowns)
|
|
|
|
var sections []string //nolint:prealloc
|
|
|
|
// Filter results
|
|
if m.filterState == filtering {
|
|
if localCount == 0 {
|
|
return grayFg("Nothing found.")
|
|
}
|
|
if localCount > 0 {
|
|
sections = append(sections, fmt.Sprintf("%d local", localCount))
|
|
}
|
|
|
|
for i := range sections {
|
|
sections[i] = grayFg(sections[i])
|
|
}
|
|
|
|
return strings.Join(sections, dividerDot.String())
|
|
}
|
|
|
|
// Tabs
|
|
for i, v := range m.sections {
|
|
var s string
|
|
|
|
switch v.key {
|
|
case documentsSection:
|
|
s = fmt.Sprintf("%d documents", localCount)
|
|
|
|
case filterSection:
|
|
s = fmt.Sprintf("%d “%s”", len(m.filteredMarkdowns), m.filterInput.Value())
|
|
}
|
|
|
|
if m.sectionIndex == i && len(m.sections) > 1 {
|
|
s = selectedTabStyle.Render(s)
|
|
} else {
|
|
s = tabStyle.Render(s)
|
|
}
|
|
sections = append(sections, s)
|
|
}
|
|
|
|
return strings.Join(sections, dividerBar.String())
|
|
}
|
|
|
|
func (m stashModel) populatedView() string {
|
|
mds := m.getVisibleMarkdowns()
|
|
|
|
var b strings.Builder
|
|
|
|
// Empty states
|
|
if len(mds) == 0 {
|
|
f := func(s string) {
|
|
b.WriteString(" " + grayFg(s))
|
|
}
|
|
|
|
switch m.sections[m.sectionIndex].key {
|
|
case documentsSection:
|
|
if m.loadingDone() {
|
|
f("No files found.")
|
|
} else {
|
|
f("Looking for local files...")
|
|
}
|
|
case filterSection:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
if len(mds) > 0 {
|
|
start, end := m.paginator().GetSliceBounds(len(mds))
|
|
docs := mds[start:end]
|
|
|
|
for i, md := range docs {
|
|
stashItemView(&b, m, i, md)
|
|
if i != len(docs)-1 {
|
|
fmt.Fprintf(&b, "\n\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there aren't enough items to fill up this page (always the last page)
|
|
// then we need to add some newlines to fill up the space where stash items
|
|
// would have been.
|
|
itemsOnPage := m.paginator().ItemsOnPage(len(mds))
|
|
if itemsOnPage < m.paginator().PerPage {
|
|
n := (m.paginator().PerPage - itemsOnPage) * stashViewItemHeight
|
|
if len(mds) == 0 {
|
|
n -= stashViewItemHeight - 1
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
fmt.Fprint(&b, "\n")
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// COMMANDS
|
|
|
|
func loadLocalMarkdown(md *markdown) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if md.localPath == "" {
|
|
return errMsg{errors.New("could not load file: missing path")}
|
|
}
|
|
|
|
data, err := os.ReadFile(md.localPath)
|
|
if err != nil {
|
|
log.Debug("error reading local file", "error", err)
|
|
return errMsg{err}
|
|
}
|
|
md.Body = string(data)
|
|
return fetchedMarkdownMsg(md)
|
|
}
|
|
}
|
|
|
|
func filterMarkdowns(m stashModel) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.filterInput.Value() == "" || !m.filterApplied() {
|
|
return filteredMarkdownMsg(m.markdowns) // return everything
|
|
}
|
|
|
|
targets := []string{}
|
|
mds := m.markdowns
|
|
|
|
for _, t := range mds {
|
|
targets = append(targets, t.filterValue)
|
|
}
|
|
|
|
ranks := fuzzy.Find(m.filterInput.Value(), targets)
|
|
sort.Stable(ranks)
|
|
|
|
filtered := []*markdown{}
|
|
for _, r := range ranks {
|
|
filtered = append(filtered, mds[r.Index])
|
|
}
|
|
|
|
return filteredMarkdownMsg(filtered)
|
|
}
|
|
}
|