glow/ui/stash.go

1227 lines
30 KiB
Go
Raw Normal View History

package ui
import (
"errors"
2020-05-13 23:02:39 +00:00
"fmt"
"io/ioutil"
2020-08-18 19:41:57 +00:00
"log"
2020-05-13 23:02:39 +00:00
"sort"
"strings"
2020-05-19 00:45:13 +00:00
"time"
2020-05-13 23:02:39 +00:00
"github.com/charmbracelet/bubbles/paginator"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/charm"
"github.com/charmbracelet/charm/ui/common"
"github.com/muesli/reflow/ansi"
2020-05-13 23:02:39 +00:00
te "github.com/muesli/termenv"
"github.com/sahilm/fuzzy"
)
2020-05-15 00:23:11 +00:00
const (
stashIndent = 1
2020-11-30 23:44:58 +00:00
stashViewItemHeight = 3 // height of stash entry, including gap
stashViewTopPadding = 5 // logo, status bar, gaps
stashViewBottomPadding = 3 // pagination and gaps, but not help
2020-05-22 02:29:46 +00:00
stashViewHorizontalPadding = 6
2020-05-15 00:23:11 +00:00
)
var (
2020-11-11 21:28:48 +00:00
stashTextInputPromptStyle styleFunc = newFgStyle(common.YellowGreen)
dividerDot string = darkGrayFg(" • ")
offlineHeaderNote string = darkGrayFg("(Offline)")
)
// MSG
2020-05-22 19:31:54 +00:00
type fetchedMarkdownMsg *markdown
2020-05-15 22:34:42 +00:00
type deletedStashedItemMsg int
type filteredMarkdownMsg []*markdown
// MODEL
// High-level state of the application.
type stashViewState int
const (
stashStateReady stashViewState = iota
2020-05-15 19:08:45 +00:00
stashStateLoadingDocument
stashStateShowingError
)
// Which types of documents we are showing. We use an int as the underlying
// type both for easy equality testing, and because the default state can have
// different types of docs depending on the user's preferences. For example,
// if the local-only flag is passed, the default state contains only documents
// of type local. Otherwise, it could contain stash, news, and converted types.
type stashDocState int
const (
stashShowDefaultDocs stashDocState = iota
stashShowNewsDocs
)
// The current filtering state.
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
)
// The state of the currently selected document.
type selectionState int
const (
selectionIdle = iota
selectionSettingNote
selectionPromptingDelete
)
type stashModel struct {
general *general
err error
spinner spinner.Model
noteInput textinput.Model
2020-11-13 21:33:22 +00:00
filterInput textinput.Model
stashFullyLoaded bool // have we loaded all available stashed documents from the server?
loadingFromNetwork bool // are we currently loading something from the network?
viewState stashViewState
filterState filterState
selectionState selectionState
showFullHelp bool
// The types of documents we are showing
docState stashDocState
// Maps document states to document types, i.e. the news state contains a
// set of type "news".
docStateMap map[stashDocState]DocTypeSet
// Tracks what exactly is loaded between the stash, news and local files
loaded DocTypeSet
2020-05-15 00:23:11 +00:00
// 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
// Paths to files being stashed. We treat this like a set, ignoring the
// value portion with an empty struct.
filesStashing map[string]struct{}
// This is just the selected item in relation to the current page in view.
// To get the index of the selected item as it relates to the full set of
// documents we've fetched use the markdownIndex() method on this struct.
2020-05-21 19:14:33 +00:00
index int
2020-05-15 00:23:11 +00:00
// This handles the local pagination, which is different than the page
// we're fetching from on the server side
paginator paginator.Model
2020-05-21 19:14:33 +00:00
// 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.
2020-05-15 00:23:11 +00:00
page int
2020-08-21 17:39:59 +00:00
showStatusMessage bool
statusMessage string
statusMessageTimer *time.Timer
2020-05-15 00:23:11 +00:00
}
func (m stashModel) localOnly() bool {
return m.general.cfg.localOnly()
}
func (m stashModel) stashedOnly() bool {
return m.general.cfg.stashedOnly()
}
2020-10-18 03:01:20 +00:00
func (m stashModel) loadingDone() bool {
return m.loaded.Equals(m.general.cfg.DocumentTypes.Difference(ConvertedDoc))
2020-10-18 03:01:20 +00:00
}
// Returns whether or not we're online. That is, when "local-only" mode is
// disabled and we've authenticated successfully.
func (m stashModel) online() bool {
return !m.localOnly() && m.general.authStatus == authOK
}
func (m *stashModel) setSize(width, height int) {
m.general.width = width
m.general.height = height
2020-05-15 00:23:11 +00:00
// Update the paginator
m.setTotalPages()
2020-11-30 23:44:58 +00:00
// height of stash entry, including gap
m.noteInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(m.noteInput.Prompt)
m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(m.filterInput.Prompt)
}
func (m *stashModel) resetFiltering() {
m.filterState = unfiltered
m.filterInput.Reset()
sort.Stable(markdownsByLocalFirst(m.markdowns))
m.filteredMarkdowns = nil
m.setTotalPages()
}
// Is a filter currently being applied?
func (m stashModel) isFiltering() 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.isFiltering() && m.selectionState != selectionSettingNote
}
// Sets the total paginator pages according to the amount of markdowns for the
// current state.
func (m *stashModel) setTotalPages() {
_, helpHeight := m.helpView()
availableHeight := m.general.height -
stashViewTopPadding -
2020-11-30 23:44:58 +00:00
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)
}
2020-05-22 02:29:46 +00:00
// 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.
2020-05-21 19:14:33 +00:00
func (m stashModel) markdownIndex() int {
return m.paginator.Page*m.paginator.PerPage + m.index
}
// Return the current selected markdown in the stash.
2020-05-22 20:28:15 +00:00
func (m stashModel) selectedMarkdown() *markdown {
i := m.markdownIndex()
2020-10-24 20:45:05 +00:00
mds := m.getVisibleMarkdowns()
if i < 0 || len(mds) == 0 || len(mds) <= i {
return nil
}
2020-10-24 20:45:05 +00:00
return mds[i]
2020-05-22 20:28:15 +00:00
}
// Adds markdown documents to the model.
2020-05-22 20:01:23 +00:00
func (m *stashModel) addMarkdowns(mds ...*markdown) {
if len(mds) > 0 {
m.markdowns = append(m.markdowns, mds...)
if !m.isFiltering() {
sort.Stable(markdownsByLocalFirst(m.markdowns))
}
m.setTotalPages()
}
2020-05-22 20:01:23 +00:00
}
2020-12-01 03:12:05 +00:00
// Find a local markdown by its path and replace it.
func (m *stashModel) replaceLocalMarkdown(localPath string, newMarkdown *markdown) error {
var found bool
// Look for local markdown
for i, md := range m.markdowns {
if md.localPath == localPath {
m.markdowns[i] = newMarkdown
found = true
break
}
}
if !found {
err := fmt.Errorf("could't find local markdown %s; not removing from stash", localPath)
if debug {
log.Println(err)
}
return err
}
if m.isFiltering() {
found = false
for i, md := range m.filteredMarkdowns {
if md.localPath == localPath {
m.filteredMarkdowns[i] = newMarkdown
found = true
break
}
}
if !found {
err := fmt.Errorf("warning: found local markdown %s in the master markdown list, but not in the filter results", localPath)
if debug {
log.Println(err)
}
return err
}
}
return nil
}
// Return the number of markdown documents of a given type.
func (m stashModel) countMarkdowns(t DocType) (found int) {
mds := m.getVisibleMarkdowns()
if len(mds) == 0 {
return
}
for i := 0; i < len(mds); i++ {
if mds[i].markdownType == t {
found++
}
}
return
}
2020-11-25 16:40:24 +00:00
// Sift through the master markdown collection for the specified types.
func (m stashModel) getMarkdownByType(types ...DocType) []*markdown {
2020-11-25 16:40:24 +00:00
var agg []*markdown
if len(m.markdowns) == 0 {
return agg
}
for _, t := range types {
2020-11-19 23:56:46 +00:00
for _, md := range m.markdowns {
2020-11-25 16:40:24 +00:00
if md.markdownType == t {
agg = append(agg, md)
2020-11-19 23:56:46 +00:00
}
}
}
2020-11-25 16:40:12 +00:00
2020-11-25 16:40:24 +00:00
sort.Sort(markdownsByLocalFirst(agg))
return agg
}
// Returns the markdowns that should be currently shown.
func (m stashModel) getVisibleMarkdowns() []*markdown {
if m.isFiltering() {
return m.filteredMarkdowns
}
2020-11-25 16:40:24 +00:00
return m.getMarkdownByType(m.docStateMap[m.docState].AsSlice()...)
2020-11-25 16:40:24 +00:00
}
// Return the markdowns eligible to be filtered.
func (m stashModel) getFilterableMarkdowns() []*markdown {
return m.getMarkdownByType(m.docStateMap[m.docState].AsSlice()...)
}
// Command for opening a markdown document in the pager. Note that this also
// alters the model.
func (m *stashModel) openMarkdown(md *markdown) tea.Cmd {
var cmd tea.Cmd
m.viewState = stashStateLoadingDocument
if md.markdownType == LocalDoc {
cmd = loadLocalMarkdown(md)
} else {
cmd = loadRemoteMarkdown(m.general.cc, md.ID, md.markdownType)
}
return tea.Batch(cmd, spinner.Tick)
}
2020-08-21 17:39:59 +00:00
func (m *stashModel) hideStatusMessage() {
m.showStatusMessage = false
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
}
func (m *stashModel) moveCursorUp() {
m.index--
if m.index < 0 && m.paginator.Page == 0 {
// Stop
m.index = 0
return
}
if m.index >= 0 {
return
}
// Go to previous page
m.paginator.PrevPage()
m.index = m.paginator.ItemsOnPage(len(m.getVisibleMarkdowns())) - 1
}
func (m *stashModel) moveCursorDown() {
itemsOnPage := m.paginator.ItemsOnPage(len(m.getVisibleMarkdowns()))
m.index++
if m.index < itemsOnPage {
return
}
if !m.paginator.OnLastPage() {
m.paginator.NextPage()
m.index = 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.index > itemsOnPage {
m.index = 0
return
}
m.index = itemsOnPage - 1
}
// INIT
func newStashModel(general *general) stashModel {
sp := spinner.NewModel()
sp.Spinner = spinner.Line
sp.ForegroundColor = common.SpinnerColor.String()
sp.HideFor = time.Millisecond * 50
sp.MinimumLifetime = time.Millisecond * 180
sp.Start()
2020-05-15 00:52:25 +00:00
p := paginator.NewModel()
p.Type = paginator.Dots
p.ActiveDot = brightGrayFg("•")
p.InactiveDot = darkGrayFg("•")
2020-05-15 00:52:25 +00:00
2020-05-22 02:29:46 +00:00
ni := textinput.NewModel()
ni.Prompt = stashTextInputPromptStyle("Memo: ")
2020-05-22 02:29:46 +00:00
ni.CursorColor = common.Fuschia.String()
2020-05-22 22:42:18 +00:00
ni.CharLimit = noteCharacterLimit
2020-05-22 02:29:46 +00:00
ni.Focus()
si := textinput.NewModel()
2020-11-13 21:33:22 +00:00
si.Prompt = stashTextInputPromptStyle("Filter: ")
si.CursorColor = common.Fuschia.String()
si.CharLimit = noteCharacterLimit
si.Focus()
m := stashModel{
general: general,
spinner: sp,
noteInput: ni,
2020-11-13 21:33:22 +00:00
filterInput: si,
page: 1,
paginator: p,
loaded: NewDocTypeSet(),
loadingFromNetwork: true,
filesStashing: make(map[string]struct{}),
docState: stashShowDefaultDocs,
docStateMap: map[stashDocState]DocTypeSet{
stashShowDefaultDocs: general.cfg.DocumentTypes.Difference(NewsDoc),
stashShowNewsDocs: NewDocTypeSet(NewsDoc),
},
}
return m
}
// UPDATE
2020-11-25 16:40:24 +00:00
func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) {
var cmds []tea.Cmd
2020-05-15 00:52:25 +00:00
switch msg := msg.(type) {
case errMsg:
m.err = msg
case stashLoadErrMsg:
m.err = msg.err
m.loaded.Add(StashedDoc) // still done, albeit unsuccessfully
m.stashFullyLoaded = true
m.loadingFromNetwork = false
case newsLoadErrMsg:
m.err = msg.err
m.loaded.Add(NewsDoc) // still done, albeit unsuccessfully
case localFileSearchFinished:
// We're finished searching for local files
m.loaded.Add(LocalDoc)
case gotStashMsg, gotNewsMsg:
// Stash or news results have come in from the server.
//
// With the stash, this doesn't mean the whole stash listing is loaded,
// but now know it can load, at least, so mark the stash as loaded here.
var docs []*markdown
switch msg := msg.(type) {
case gotStashMsg:
m.loaded.Add(StashedDoc)
m.loadingFromNetwork = false
docs = wrapMarkdowns(StashedDoc, msg)
if len(msg) == 0 {
// If the server comes back with nothing then we've got
// everything
m.stashFullyLoaded = true
} else {
// Load the next page
m.page++
cmds = append(cmds, loadStash(m))
}
case gotNewsMsg:
m.loaded.Add(NewsDoc)
docs = wrapMarkdowns(NewsDoc, msg)
}
// If we're filtering build filter indexes immediately so any
// matching results will show up in the filter.
if m.isFiltering() {
for _, md := range docs {
md.buildFilterValue()
}
2020-05-21 19:14:33 +00:00
}
if m.shouldUpdateFilter() {
cmds = append(cmds, filterMarkdowns(m))
}
2020-05-21 19:14:33 +00:00
m.addMarkdowns(docs...)
case filteredMarkdownMsg:
m.filteredMarkdowns = msg
return m, nil
case spinner.TickMsg:
2020-10-18 03:01:20 +00:00
condition := !m.loadingDone() ||
m.loadingFromNetwork ||
m.viewState == stashStateLoadingDocument ||
len(m.filesStashing) > 0 ||
m.spinner.Visible()
if condition {
newSpinnerModel, cmd := m.spinner.Update(msg)
m.spinner = newSpinnerModel
2020-05-15 22:39:23 +00:00
cmds = append(cmds, cmd)
}
// A note was set on a document. This may have happened in the pager so
2020-05-22 02:29:46 +00:00
// we'll find the corresponding document here and update accordingly.
case noteSavedMsg:
2020-05-22 22:42:18 +00:00
for i := range m.markdowns {
if m.markdowns[i].ID == msg.ID {
m.markdowns[i].Note = msg.Note
}
}
2020-08-21 17:39:59 +00:00
// Something was stashed. Add it to the stash listing.
case stashSuccessMsg:
md := markdown(msg)
delete(m.filesStashing, md.localPath) // remove from the things-we're-stashing list
_ = m.replaceLocalMarkdown(md.localPath, &md)
2020-08-21 17:39:59 +00:00
m.showStatusMessage = true
m.statusMessage = "Stashed!"
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
2020-08-21 20:41:06 +00:00
m.statusMessageTimer = time.NewTimer(statusMessageTimeout)
2020-08-21 17:39:59 +00:00
cmds = append(cmds, waitForStatusMessageTimeout(stashContext, m.statusMessageTimer))
case statusMessageTimeoutMsg:
if applicationContext(msg) == stashContext {
m.hideStatusMessage()
}
}
if m.filterState == filtering {
cmds = append(cmds, m.handleFiltering(msg))
return m, tea.Batch(cmds...)
}
switch m.selectionState {
case selectionSettingNote:
cmds = append(cmds, m.handleNoteInput(msg))
return m, tea.Batch(cmds...)
case selectionPromptingDelete:
cmds = append(cmds, m.handleDeleteConfirmation(msg))
return m, tea.Batch(cmds...)
}
2020-11-25 16:40:24 +00:00
// Updates per the current state
switch m.viewState {
case stashStateReady:
2020-11-25 16:40:24 +00:00
cmds = append(cmds, m.handleDocumentBrowsing(msg))
case stashStateShowingError:
// Any key exists the error view
if _, ok := msg.(tea.KeyMsg); ok {
m.viewState = stashStateReady
2020-11-25 16:40:24 +00:00
}
}
2020-11-25 16:40:24 +00:00
return m, tea.Batch(cmds...)
}
2020-12-01 03:12:05 +00:00
// Updates for when a user is browsing the markdown listing.
2020-11-25 16:40:24 +00:00
func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
2020-11-25 16:40:24 +00:00
pages := len(m.getVisibleMarkdowns())
2020-11-25 16:40:24 +00:00
switch msg := msg.(type) {
// Handle keys
case tea.KeyMsg:
switch msg.String() {
case "k", "ctrl+k", "up", "shift+tab":
m.moveCursorUp()
case "j", "ctrl+j", "down", "tab":
m.moveCursorDown()
// Go to the very start
case "home", "g":
m.paginator.Page = 0
m.index = 0
// Go to the very end
case "end", "G":
m.paginator.Page = m.paginator.TotalPages - 1
m.index = m.paginator.ItemsOnPage(pages) - 1
case "esc":
if m.isFiltering() {
m.resetFiltering()
break
}
m.docState = stashShowDefaultDocs
2020-08-21 17:39:59 +00:00
2020-11-25 16:40:24 +00:00
// Open document
case "enter":
m.hideStatusMessage()
2020-11-25 16:40:24 +00:00
if pages == 0 {
break
}
2020-11-25 16:40:24 +00:00
// 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))
2020-11-19 23:56:46 +00:00
2020-11-25 16:40:24 +00:00
// Filter your notes
case "/":
m.hideStatusMessage()
2020-11-25 16:40:24 +00:00
// Build values we'll filter against
for _, md := range m.markdowns {
md.buildFilterValue()
}
2020-11-25 16:40:24 +00:00
m.filteredMarkdowns = m.getFilterableMarkdowns()
2020-11-25 16:40:24 +00:00
m.paginator.Page = 0
m.index = 0
m.filterState = filtering
2020-11-25 16:40:24 +00:00
m.filterInput.CursorEnd()
m.filterInput.Focus()
return textinput.Blink
2020-11-25 16:40:24 +00:00
// Set note
case "m":
m.hideStatusMessage()
2020-08-21 17:39:59 +00:00
2020-11-25 16:40:24 +00:00
if pages == 0 {
break
}
2020-11-25 16:40:24 +00:00
md := m.selectedMarkdown()
isUserMarkdown := md.markdownType == StashedDoc || md.markdownType == ConvertedDoc
isSettingNote := m.selectionState == selectionSettingNote
isPromptingDelete := m.selectionState == selectionPromptingDelete
2020-05-22 20:28:15 +00:00
2020-11-25 16:40:24 +00:00
if isUserMarkdown && !isSettingNote && !isPromptingDelete {
m.selectionState = selectionSettingNote
2020-11-25 16:40:24 +00:00
m.noteInput.SetValue(md.Note)
m.noteInput.CursorEnd()
return textinput.Blink
}
2020-11-25 16:40:24 +00:00
// Show news
case "n":
2020-11-30 23:47:34 +00:00
if !m.online() || m.isFiltering() {
// If we're offline disable the news section
return nil
}
if m.docState == stashShowNewsDocs {
2020-11-25 16:40:24 +00:00
// Exit news
m.docState = stashShowDefaultDocs
2020-11-25 16:40:24 +00:00
m.resetFiltering()
} else {
// Show news
m.hideStatusMessage()
m.paginator.Page = 0
m.index = 0
m.docState = stashShowNewsDocs
2020-11-25 16:40:24 +00:00
m.setTotalPages()
}
2020-11-24 23:31:12 +00:00
2020-11-25 16:40:24 +00:00
return nil
2020-11-19 23:56:46 +00:00
2020-11-25 16:40:24 +00:00
// Stash
case "s":
if pages == 0 || m.general.authStatus != authOK || m.selectedMarkdown() == nil {
break
}
2020-11-25 16:40:24 +00:00
md := m.selectedMarkdown()
2020-11-25 16:40:24 +00:00
_, isBeingStashed := m.filesStashing[md.localPath]
isLocalMarkdown := md.markdownType == LocalDoc
2020-11-25 16:40:24 +00:00
markdownPathMissing := md.localPath == ""
2020-11-25 16:40:24 +00:00
if isBeingStashed || !isLocalMarkdown || markdownPathMissing {
if debug && isBeingStashed {
log.Printf("refusing to stash markdown; we're already stashing %s", md.localPath)
} else if debug && isLocalMarkdown && markdownPathMissing {
log.Printf("refusing to stash markdown; local path is empty: %#v", md)
}
2020-11-25 16:40:24 +00:00
break
}
2020-11-25 16:40:24 +00:00
// Checks passed; perform the stash
m.filesStashing[md.localPath] = struct{}{}
cmds = append(cmds, stashDocument(m.general.cc, *md))
2020-11-25 16:40:24 +00:00
if m.loadingDone() && !m.spinner.Visible() {
m.spinner.Start()
cmds = append(cmds, spinner.Tick)
}
2020-08-21 17:39:59 +00:00
2020-11-25 16:40:24 +00:00
// Prompt for deletion
case "x":
m.hideStatusMessage()
validState := m.viewState == stashStateReady &&
m.selectionState == selectionIdle
if pages == 0 && !validState {
2020-11-25 16:40:24 +00:00
break
}
2020-05-22 20:28:15 +00:00
2020-11-25 16:40:24 +00:00
t := m.selectedMarkdown().markdownType
if t == StashedDoc || t == ConvertedDoc {
m.selectionState = selectionPromptingDelete
}
// Toggle full help
case "?":
m.showFullHelp = !m.showFullHelp
2020-11-30 23:44:58 +00:00
m.setTotalPages()
2020-11-25 16:40:24 +00:00
// Show errors
case "!":
if m.err != nil && m.viewState == stashStateReady {
m.viewState = stashStateShowingError
2020-11-25 16:40:24 +00:00
return nil
}
}
2020-11-25 16:40:24 +00:00
}
2020-11-25 16:40:24 +00:00
// 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.paginator = 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()
}
2020-11-25 16:40:24 +00:00
}
2020-11-25 16:40:24 +00:00
// Keep the index in bounds when paginating
itemsOnPage := m.paginator.ItemsOnPage(len(m.getVisibleMarkdowns()))
if m.index > itemsOnPage-1 {
m.index = max(0, itemsOnPage-1)
}
2020-11-25 16:40:24 +00:00
// If we're on the last page and we haven't loaded everything, get
// more stuff.
if m.paginator.OnLastPage() && !m.loadingFromNetwork && !m.stashFullyLoaded {
m.page++
m.loadingFromNetwork = true
cmds = append(cmds, loadStash(*m))
}
2020-11-25 16:40:24 +00:00
return tea.Batch(cmds...)
}
2020-11-25 16:40:24 +00:00
// Updates for when a user is being prompted whether or not to delete a
// markdown item.
func (m *stashModel) handleDeleteConfirmation(msg tea.Msg) tea.Cmd {
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
// Confirm deletion
case "y":
if m.selectionState != selectionPromptingDelete {
2020-11-25 16:40:24 +00:00
break
}
smd := m.selectedMarkdown()
for i, md := range m.markdowns {
if md != smd {
continue
}
if md.markdownType == ConvertedDoc {
2020-11-25 16:40:24 +00:00
// If document was stashed during this session, convert it
// back to a local file.
md.markdownType = LocalDoc
2020-11-25 16:40:24 +00:00
md.Note = stripAbsolutePath(m.markdowns[i].localPath, m.general.cwd)
} else {
2020-11-25 16:40:24 +00:00
// Delete optimistically and remove the stashed item
// before we've received a success response.
if m.isFiltering() {
mds, _ := deleteMarkdown(m.filteredMarkdowns, m.markdowns[i])
m.filteredMarkdowns = mds
}
mds, _ := deleteMarkdown(m.markdowns, m.markdowns[i])
m.markdowns = mds
}
2020-11-25 16:40:24 +00:00
}
m.selectionState = selectionIdle
2020-11-25 16:40:24 +00:00
m.setTotalPages()
2020-11-25 16:40:24 +00:00
return deleteStashedItem(m.general.cc, smd.ID)
default:
// Any other keys cancels deletion
m.selectionState = selectionIdle
}
2020-11-25 16:40:24 +00:00
}
2020-11-25 16:40:24 +00:00
return nil
}
2020-12-01 03:12:05 +00:00
// Updates for when a user is in the filter editing interface.
2020-11-25 16:40:24 +00:00
func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
2020-11-25 16:40:24 +00:00
// Handle keys
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
case "esc":
// Cancel filtering
m.resetFiltering()
case "enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down":
m.hideStatusMessage()
2020-11-25 16:40:24 +00:00
if len(m.markdowns) == 0 {
break
}
2020-11-25 16:40:24 +00:00
h := m.getVisibleMarkdowns()
2020-11-25 16:40:24 +00:00
// If we've filtered down to nothing, clear the filter
if len(h) == 0 {
m.viewState = stashStateReady
2020-11-25 16:40:24 +00:00
m.resetFiltering()
break
}
2020-11-25 16:40:24 +00:00
// When there's only one filtered markdown left we can just
// "open" it directly
if len(h) == 1 {
m.viewState = stashStateReady
2020-11-25 16:40:24 +00:00
m.resetFiltering()
cmds = append(cmds, m.openMarkdown(h[0]))
break
}
2020-11-25 16:40:24 +00:00
m.filterInput.Blur()
m.filterState = filterApplied
2020-11-25 16:40:24 +00:00
if m.filterInput.Value() == "" {
m.resetFiltering()
}
}
2020-11-25 16:40:24 +00:00
}
2020-11-25 16:40:24 +00:00
// 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)
2020-11-25 16:40:24 +00:00
// If the filtering input has changed, request updated filtering
if newFilterVal != currentFilterVal {
cmds = append(cmds, filterMarkdowns(*m))
}
2020-05-22 02:29:46 +00:00
2020-11-25 16:40:24 +00:00
// Update pagination
m.setTotalPages()
2020-11-25 16:40:24 +00:00
return tea.Batch(cmds...)
}
2020-05-22 02:29:46 +00:00
2020-11-25 16:40:24 +00:00
func (m *stashModel) handleNoteInput(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
case "esc":
// Cancel note
m.noteInput.Reset()
m.selectionState = selectionIdle
2020-11-25 16:40:24 +00:00
case "enter":
// Set new note
md := m.selectedMarkdown()
newNote := m.noteInput.Value()
cmd := saveDocumentNote(m.general.cc, md.ID, newNote)
md.Note = newNote
m.noteInput.Reset()
m.selectionState = selectionIdle
2020-11-25 16:40:24 +00:00
return cmd
}
2020-05-15 00:52:25 +00:00
}
2020-11-25 16:40:24 +00:00
if m.shouldUpdateFilter() {
cmds = append(cmds, filterMarkdowns(*m))
}
// Update the note text input component
newNoteInputModel, noteInputCmd := m.noteInput.Update(msg)
m.noteInput = newNoteInputModel
cmds = append(cmds, noteInputCmd)
return tea.Batch(cmds...)
}
// VIEW
2020-11-25 16:40:24 +00:00
func (m stashModel) view() string {
var s string
switch m.viewState {
case stashStateShowingError:
return errorView(m.err, false)
2020-05-15 19:08:45 +00:00
case stashStateLoadingDocument:
s += " " + m.spinner.View() + " Loading document..."
case stashStateReady:
loadingIndicator := " "
2020-10-18 03:01:20 +00:00
if !m.localOnly() && (!m.loadingDone() || m.loadingFromNetwork || m.spinner.Visible()) {
loadingIndicator = m.spinner.View()
}
help, helpHeight := m.helpView()
2020-05-21 19:14:33 +00:00
// We need to fill any empty height with newlines so the footer reaches
// the bottom.
2020-11-30 23:44:58 +00:00
availHeight := m.general.height -
stashViewTopPadding -
helpHeight -
stashViewBottomPadding
numBlankLines := max(0, availHeight%stashViewItemHeight)
2020-05-15 00:23:11 +00:00
blankLines := ""
if numBlankLines > 0 {
blankLines = strings.Repeat("\n", numBlankLines)
}
2020-08-13 19:37:53 +00:00
var header string
2020-08-21 17:39:59 +00:00
if m.showStatusMessage {
header = greenFg(m.statusMessage)
} else {
switch m.selectionState {
case selectionPromptingDelete:
header = redFg("Delete this item from your stash? ") + faintRedFg("(y/N)")
case selectionSettingNote:
2020-08-21 17:39:59 +00:00
header = yellowFg("Set the memo for this item?")
}
2020-05-15 22:34:42 +00:00
}
// Only draw the normal header if we're not using the header area for
// something else (like a prompt or status message)
if header == "" {
header = m.headerView()
}
2020-11-13 21:33:22 +00:00
logoOrFilter := glowLogoView(" Glow ")
2020-11-13 21:33:22 +00:00
// If we're filtering we replace the logo with the filter field
if m.isFiltering() {
2020-11-13 21:33:22 +00:00
logoOrFilter = m.filterInput.View()
} else if m.docState == stashShowNewsDocs {
2020-12-01 21:56:29 +00:00
logoOrFilter += normalFg(" News ")
}
var pagination string
if m.paginator.TotalPages > 1 {
pagination = m.paginator.View()
// If the dot pagination is wider than the width of the window
// switch to the arabic paginator.
if ansi.PrintableRuneWidth(pagination) > m.general.width-stashViewHorizontalPadding {
m.paginator.Type = paginator.Arabic
pagination = common.Subtle(m.paginator.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.
}
2020-05-15 00:52:25 +00:00
s += fmt.Sprintf(
2020-11-30 23:44:58 +00:00
"%s %s\n\n %s\n\n%s\n\n%s %s\n\n%s",
loadingIndicator,
2020-11-13 21:33:22 +00:00
logoOrFilter,
2020-05-15 22:34:42 +00:00
header,
m.populatedView(),
2020-05-15 01:12:13 +00:00
blankLines,
pagination,
help,
2020-05-15 00:52:25 +00:00
)
2020-05-13 23:02:39 +00:00
}
return "\n" + indent(s, stashIndent)
2020-05-13 23:02:39 +00:00
}
func glowLogoView(text string) string {
return te.String(text).
Bold().
Foreground(glowLogoTextColor).
Background(common.Fuschia.Color()).
String()
}
func (m stashModel) headerView() string {
2020-10-18 03:01:20 +00:00
loading := !m.loadingDone()
noMarkdowns := len(m.markdowns) == 0
if m.general.authStatus == authFailed && m.stashedOnly() {
return common.Subtle("Cant load stash. Are you offline?")
}
var maybeOffline string
if m.general.authStatus == authFailed {
maybeOffline = " " + offlineHeaderNote
}
// Still loading. We haven't found files, stashed items, or news yet.
if loading && noMarkdowns {
if m.stashedOnly() {
return common.Subtle("Loading your stash...")
}
2020-12-01 03:12:05 +00:00
return common.Subtle("Looking for stuff...") + maybeOffline
}
localItems := m.countMarkdowns(LocalDoc)
stashedItems := m.countMarkdowns(StashedDoc) + m.countMarkdowns(ConvertedDoc)
newsItems := m.countMarkdowns(NewsDoc)
// Loading's finished and all we have is news.
2020-10-25 03:57:12 +00:00
if !loading && localItems == 0 && stashedItems == 0 && newsItems == 0 {
if m.stashedOnly() {
return common.Subtle("No stashed markdown files found.") + maybeOffline
} else {
return common.Subtle("No local or stashed markdown files found.") + maybeOffline
}
}
// There are local and/or stashed files, so display counts.
var s string
if localItems > 0 {
s += common.Subtle(fmt.Sprintf("%d Local", localItems))
}
if stashedItems > 0 {
var divider string
if localItems > 0 {
divider = dividerDot
}
si := common.Subtle(fmt.Sprintf("%d Stashed", stashedItems))
s += fmt.Sprintf("%s%s", divider, si)
}
2020-10-25 03:57:12 +00:00
if newsItems > 0 {
var divider string
if localItems > 0 || stashedItems > 0 {
divider = dividerDot
}
si := common.Subtle(fmt.Sprintf("%d News", newsItems))
s += fmt.Sprintf("%s%s", divider, si)
}
return common.Subtle(s) + maybeOffline
2020-05-13 23:02:39 +00:00
}
func (m stashModel) populatedView() string {
var b strings.Builder
2020-05-15 00:52:25 +00:00
mds := m.getVisibleMarkdowns()
if len(mds) > 0 {
start, end := m.paginator.GetSliceBounds(len(mds))
docs := mds[start:end]
2020-05-15 00:23:11 +00:00
2020-06-02 23:44:26 +00:00
for i, md := range docs {
stashItemView(&b, m, i, md)
if i != len(docs)-1 {
fmt.Fprintf(&b, "\n\n")
}
}
2020-05-13 23:02:39 +00:00
}
2020-05-15 00:52:25 +00:00
// If there aren't enough items to fill up this page (always the last page)
2020-06-02 23:44:26 +00:00
// then we need to add some newlines to fill up the space where stash items
// would have been.
itemsOnPage := m.paginator.ItemsOnPage(len(mds))
2020-05-15 00:52:25 +00:00
if itemsOnPage < m.paginator.PerPage {
n := (m.paginator.PerPage - itemsOnPage) * stashViewItemHeight
if len(mds) == 0 {
2020-06-02 23:44:26 +00:00
n -= stashViewItemHeight - 1
}
for i := 0; i < n; i++ {
fmt.Fprint(&b, "\n")
}
2020-05-15 00:52:25 +00:00
}
return b.String()
2020-05-15 00:23:11 +00:00
}
// COMMANDS
func loadRemoteMarkdown(cc *charm.Client, id int, t DocType) tea.Cmd {
return func() tea.Msg {
2020-05-22 19:31:54 +00:00
var (
md *charm.Markdown
err error
)
if t == StashedDoc || t == ConvertedDoc {
2020-05-22 19:31:54 +00:00
md, err = cc.GetStashMarkdown(id)
} else {
md, err = cc.GetNewsMarkdown(id)
}
2020-05-21 19:14:33 +00:00
if err != nil {
2020-08-18 19:41:57 +00:00
if debug {
log.Println("error loading remote markdown:", err)
}
return errMsg{err}
2020-05-21 19:14:33 +00:00
}
2020-05-22 19:31:54 +00:00
return fetchedMarkdownMsg(&markdown{
2020-05-22 20:28:15 +00:00
markdownType: t,
2020-08-21 00:21:52 +00:00
Markdown: *md,
2020-05-21 19:14:33 +00:00
})
2020-05-14 02:08:17 +00:00
}
}
2020-05-15 22:34:42 +00:00
func loadLocalMarkdown(md *markdown) tea.Cmd {
return func() tea.Msg {
if md.markdownType != LocalDoc {
return errMsg{errors.New("could not load local file: not a local file")}
}
if md.localPath == "" {
return errMsg{errors.New("could not load file: missing path")}
}
data, err := ioutil.ReadFile(md.localPath)
if err != nil {
2020-08-18 19:41:57 +00:00
if debug {
log.Println("error reading local markdown:", err)
}
return errMsg{err}
}
md.Body = string(data)
return fetchedMarkdownMsg(md)
}
}
func deleteStashedItem(cc *charm.Client, id int) tea.Cmd {
return func() tea.Msg {
2020-05-15 22:34:42 +00:00
err := cc.DeleteMarkdown(id)
if err != nil {
if debug {
2020-08-24 19:59:28 +00:00
log.Println("could not delete stashed item:", err)
}
return errMsg{err}
2020-05-15 22:34:42 +00:00
}
return deletedStashedItemMsg(id)
}
}
2020-05-18 20:08:49 +00:00
func filterMarkdowns(m stashModel) tea.Cmd {
return func() tea.Msg {
if m.filterInput.Value() == "" || !m.isFiltering() {
2020-11-25 16:40:24 +00:00
return filteredMarkdownMsg(m.getFilterableMarkdowns()) // return everything
}
targets := []string{}
2020-11-25 16:40:24 +00:00
mds := m.getFilterableMarkdowns()
2020-11-25 16:40:24 +00:00
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 {
2020-11-25 16:40:24 +00:00
filtered = append(filtered, mds[r.Index])
}
return filteredMarkdownMsg(filtered)
}
}
2020-05-18 20:08:49 +00:00
// ETC
2020-12-01 03:12:05 +00:00
// Delete a markdown from a slice of markdowns.
func deleteMarkdown(markdowns []*markdown, target *markdown) ([]*markdown, error) {
index := -1
for i, v := range markdowns {
switch target.markdownType {
case LocalDoc, ConvertedDoc:
if v.localPath == target.localPath {
index = i
}
case StashedDoc, NewsDoc:
if v.ID == target.ID {
index = i
}
default:
return nil, errors.New("unknown markdown type")
}
}
if index == -1 {
err := fmt.Errorf("could not find markdown to delete")
if debug {
log.Println(err)
}
return nil, err
}
return append(markdowns[:index], markdowns[index+1:]...), nil
}