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) } }