feat(tui): stumble mode — resurface stale entries card by card #36
@@ -71,6 +71,7 @@ type ListParams struct {
|
|||||||
From *string
|
From *string
|
||||||
To *string
|
To *string
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
|
ModifiedBefore *time.Time
|
||||||
CardsOnly bool
|
CardsOnly bool
|
||||||
IncludeDeleted bool
|
IncludeDeleted bool
|
||||||
CardTypeFilter *CardType
|
CardTypeFilter *CardType
|
||||||
@@ -216,6 +217,10 @@ func listWhere(params ListParams) (string, []any) {
|
|||||||
where = append(where, "e.card_type = ?")
|
where = append(where, "e.card_type = ?")
|
||||||
args = append(args, string(*params.CardTypeFilter))
|
args = append(args, string(*params.CardTypeFilter))
|
||||||
}
|
}
|
||||||
|
if params.ModifiedBefore != nil {
|
||||||
|
where = append(where, "e.modified_at < ?")
|
||||||
|
args = append(args, params.ModifiedBefore.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
clause := ""
|
clause := ""
|
||||||
if len(where) > 0 {
|
if len(where) > 0 {
|
||||||
@@ -239,6 +244,8 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
switch params.Sort {
|
switch params.Sort {
|
||||||
case "use_count":
|
case "use_count":
|
||||||
orderCol = "e.use_count"
|
orderCol = "e.use_count"
|
||||||
|
case "modified_at":
|
||||||
|
orderCol = "e.modified_at"
|
||||||
case "created_at", "":
|
case "created_at", "":
|
||||||
orderCol = "e.created_at"
|
orderCol = "e.created_at"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ type railTagsLoadedMsg struct {
|
|||||||
tags []db.TagCount
|
tags []db.TagCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type staleEntitiesLoadedMsg struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type stumbleActionMsg struct {
|
||||||
|
action string
|
||||||
|
}
|
||||||
|
|
||||||
type statusClearMsg struct{ seq int }
|
type statusClearMsg struct{ seq int }
|
||||||
|
|
||||||
type editorFinishedMsg struct {
|
type editorFinishedMsg struct {
|
||||||
@@ -289,3 +297,33 @@ func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
|
|||||||
return statusClearMsg{seq: seq}
|
return statusClearMsg{seq: seq}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadStaleEntities(store *db.Store) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entities, err := store.List(staleParams())
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return staleEntitiesLoadedMsg{entities}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if _, err := store.SoftDelete(id); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stumbleActionMsg{"dismissed"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stumblePin(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
pinned := true
|
||||||
|
update := db.EntityUpdate{Pinned: &pinned}
|
||||||
|
if err := store.Update(id, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stumbleActionMsg{"pinned"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func renderHelp(width, height int) string {
|
|||||||
{"#", "filter by tag"},
|
{"#", "filter by tag"},
|
||||||
{"m", "absorb (merge into target)"},
|
{"m", "absorb (merge into target)"},
|
||||||
{"p", "promote to card"},
|
{"p", "promote to card"},
|
||||||
|
{"S", "stumble (resurface stale entries)"},
|
||||||
}},
|
}},
|
||||||
{"Detail View", [][2]string{
|
{"Detail View", [][2]string{
|
||||||
{"p", "promote to card"},
|
{"p", "promote to card"},
|
||||||
@@ -51,6 +52,14 @@ func renderHelp(width, height int) string {
|
|||||||
{"r", "run checklist"},
|
{"r", "run checklist"},
|
||||||
{"f", "fill template"},
|
{"f", "fill template"},
|
||||||
}},
|
}},
|
||||||
|
{"Stumble", [][2]string{
|
||||||
|
{"n / →", "skip to next"},
|
||||||
|
{"d", "dismiss (soft delete)"},
|
||||||
|
{"!", "pin"},
|
||||||
|
{"p", "promote to card"},
|
||||||
|
{"m", "absorb"},
|
||||||
|
{"esc", "exit"},
|
||||||
|
}},
|
||||||
{"Run Mode", [][2]string{
|
{"Run Mode", [][2]string{
|
||||||
{"j/k", "move between steps"},
|
{"j/k", "move between steps"},
|
||||||
{"space", "toggle step"},
|
{"space", "toggle step"},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type keyMap struct {
|
|||||||
FocusRight key.Binding
|
FocusRight key.Binding
|
||||||
Tab key.Binding
|
Tab key.Binding
|
||||||
ToggleRail key.Binding
|
ToggleRail key.Binding
|
||||||
|
Stumble key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys = keyMap{
|
var keys = keyMap{
|
||||||
@@ -66,4 +67,5 @@ var keys = keyMap{
|
|||||||
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
|
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
|
||||||
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
|
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
|
||||||
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
|
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
|
||||||
|
Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")),
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-3
@@ -22,6 +22,7 @@ const (
|
|||||||
stateConfirm
|
stateConfirm
|
||||||
statePromote
|
statePromote
|
||||||
stateAbsorb
|
stateAbsorb
|
||||||
|
stateStumble
|
||||||
)
|
)
|
||||||
|
|
||||||
type viewMode int
|
type viewMode int
|
||||||
@@ -85,6 +86,7 @@ type model struct {
|
|||||||
promote promoteModel
|
promote promoteModel
|
||||||
absorb absorbModel
|
absorb absorbModel
|
||||||
tagRail tagRailModel
|
tagRail tagRailModel
|
||||||
|
stumble stumbleModel
|
||||||
showHelp bool
|
showHelp bool
|
||||||
|
|
||||||
focus focusPane
|
focus focusPane
|
||||||
@@ -231,8 +233,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action))
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action))
|
||||||
|
|
||||||
case entityPromotedMsg:
|
case entityPromotedMsg:
|
||||||
m.state = stateList
|
if !m.stumble.done && len(m.stumble.entries) > 0 {
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType)))
|
m.stumble.advance()
|
||||||
|
m.state = stateStumble
|
||||||
|
} else {
|
||||||
|
m.state = stateList
|
||||||
|
}
|
||||||
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType)))
|
||||||
|
|
||||||
case entityDemotedMsg:
|
case entityDemotedMsg:
|
||||||
return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid"))
|
return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid"))
|
||||||
@@ -241,7 +248,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.setStatus("copied")
|
return m, m.setStatus("copied")
|
||||||
|
|
||||||
case entityAbsorbedMsg:
|
case entityAbsorbedMsg:
|
||||||
m.state = stateList
|
if !m.stumble.done && len(m.stumble.entries) > 0 {
|
||||||
|
m.stumble.advance()
|
||||||
|
m.state = stateStumble
|
||||||
|
} else {
|
||||||
|
m.state = stateList
|
||||||
|
}
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
|
||||||
|
|
||||||
case absorbSourcesLoadedMsg:
|
case absorbSourcesLoadedMsg:
|
||||||
@@ -265,6 +277,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.state = stateTagFilter
|
m.state = stateTagFilter
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case staleEntitiesLoadedMsg:
|
||||||
|
m.stumble = newStumbleModel()
|
||||||
|
m.stumble.setEntries(msg.entities)
|
||||||
|
m.stumble.setSize(m.width, m.contentHeight())
|
||||||
|
m.state = stateStumble
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case stumbleActionMsg:
|
||||||
|
return m, m.setStatus(msg.action)
|
||||||
|
|
||||||
case editorFinishedMsg:
|
case editorFinishedMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
@@ -300,6 +322,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updatePromote(msg)
|
return m.updatePromote(msg)
|
||||||
case stateAbsorb:
|
case stateAbsorb:
|
||||||
return m.updateAbsorb(msg)
|
return m.updateAbsorb(msg)
|
||||||
|
case stateStumble:
|
||||||
|
return m.updateStumble(msg)
|
||||||
default:
|
default:
|
||||||
return m.updateKeys(msg)
|
return m.updateKeys(msg)
|
||||||
}
|
}
|
||||||
@@ -532,6 +556,12 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case "S":
|
||||||
|
if m.state == stateList {
|
||||||
|
return m, loadStaleEntities(m.store)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case "i":
|
case "i":
|
||||||
if m.mode == modeCards && m.state == stateList {
|
if m.mode == modeCards && m.state == stateList {
|
||||||
m.cards.setIntent(m.cards.intent.next())
|
m.cards.setIntent(m.cards.intent.next())
|
||||||
@@ -818,6 +848,57 @@ func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) updateStumble(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.stumble.done {
|
||||||
|
m.state = stateList
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
m.state = stateList
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
case "n", "right":
|
||||||
|
m.stumble.advance()
|
||||||
|
if m.stumble.done {
|
||||||
|
return m, m.setStatus("all caught up")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "d":
|
||||||
|
e := m.stumble.current()
|
||||||
|
if e != nil {
|
||||||
|
m.stumble.removeCurrent()
|
||||||
|
return m, stumbleDismiss(m.store, e.ID)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "!":
|
||||||
|
e := m.stumble.current()
|
||||||
|
if e != nil {
|
||||||
|
m.stumble.advance()
|
||||||
|
return m, stumblePin(m.store, e.ID)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "p":
|
||||||
|
e := m.stumble.current()
|
||||||
|
if e != nil {
|
||||||
|
m.promote = newPromoteModel(e.ID, e.Body)
|
||||||
|
m.state = statePromote
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "m":
|
||||||
|
e := m.stumble.current()
|
||||||
|
if e != nil {
|
||||||
|
if e.CardType != nil {
|
||||||
|
return m, m.setStatus("target must be fluid")
|
||||||
|
}
|
||||||
|
return m, loadAbsorbSources(m.store, e.ID)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
return renderHelp(m.width, m.height)
|
return renderHelp(m.width, m.height)
|
||||||
@@ -852,6 +933,8 @@ func (m model) View() string {
|
|||||||
content = m.promote.view(m.width)
|
content = m.promote.view(m.width)
|
||||||
case stateAbsorb:
|
case stateAbsorb:
|
||||||
content = m.absorb.view(m.width)
|
content = m.absorb.view(m.width)
|
||||||
|
case stateStumble:
|
||||||
|
content = m.stumble.view()
|
||||||
}
|
}
|
||||||
|
|
||||||
header := m.headerView()
|
header := m.headerView()
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ func contextHints(m model) []hint {
|
|||||||
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||||
case stateAbsorb:
|
case stateAbsorb:
|
||||||
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
|
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
|
||||||
|
case stateStumble:
|
||||||
|
return []hint{{"n", "skip"}, {"d", "dismiss"}, {"!", "pin"}, {"p", "promote"}, {"m", "absorb"}, {"esc", "quit"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/glamour"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
const staleThresholdDays = 30
|
||||||
|
|
||||||
|
type stumbleModel struct {
|
||||||
|
entries []*db.Entity
|
||||||
|
cursor int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStumbleModel() stumbleModel {
|
||||||
|
return stumbleModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stumbleModel) setEntries(entries []*db.Entity) {
|
||||||
|
s.entries = entries
|
||||||
|
s.cursor = 0
|
||||||
|
s.done = len(entries) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stumbleModel) setSize(width, height int) {
|
||||||
|
s.width = width
|
||||||
|
s.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stumbleModel) current() *db.Entity {
|
||||||
|
if s.done || len(s.entries) == 0 || s.cursor >= len(s.entries) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.entries[s.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stumbleModel) advance() {
|
||||||
|
s.cursor++
|
||||||
|
if s.cursor >= len(s.entries) {
|
||||||
|
s.done = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stumbleModel) removeCurrent() {
|
||||||
|
if s.cursor < len(s.entries) {
|
||||||
|
s.entries = append(s.entries[:s.cursor], s.entries[s.cursor+1:]...)
|
||||||
|
if s.cursor >= len(s.entries) {
|
||||||
|
s.done = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stumbleModel) total() int {
|
||||||
|
return len(s.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stumbleModel) view() string {
|
||||||
|
if s.done {
|
||||||
|
return s.doneView()
|
||||||
|
}
|
||||||
|
|
||||||
|
e := s.current()
|
||||||
|
if e == nil {
|
||||||
|
return s.doneView()
|
||||||
|
}
|
||||||
|
|
||||||
|
w := s.width
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
progress := fmt.Sprintf("stumble [%d/%d]", s.cursor+1, len(s.entries))
|
||||||
|
b.WriteString(detailHeaderStyle.Render(progress))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||||
|
title := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
title = *e.Title
|
||||||
|
}
|
||||||
|
if len(title) > w-6 {
|
||||||
|
title = title[:w-9] + "…"
|
||||||
|
}
|
||||||
|
b.WriteString(" " + glyphStyle.Render(glyph) + " " + title)
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
var meta []string
|
||||||
|
meta = append(meta, string(e.Glyph))
|
||||||
|
if e.CardType != nil {
|
||||||
|
meta = append(meta, string(*e.CardType))
|
||||||
|
}
|
||||||
|
for _, t := range e.Tags {
|
||||||
|
meta = append(meta, tagStyle.Render("#"+t))
|
||||||
|
}
|
||||||
|
meta = append(meta, "captured "+e.CreatedAt.Format("Jan 2"))
|
||||||
|
b.WriteString(" " + idStyle.Render(strings.Join(meta, " · ")))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
bodyWidth := w - 6
|
||||||
|
if bodyWidth < 20 {
|
||||||
|
bodyWidth = 20
|
||||||
|
}
|
||||||
|
r, _ := glamour.NewTermRenderer(
|
||||||
|
glamour.WithStylePath("dark"),
|
||||||
|
glamour.WithWordWrap(bodyWidth),
|
||||||
|
)
|
||||||
|
rendered, err := r.Render(e.Body)
|
||||||
|
if err != nil {
|
||||||
|
rendered = e.Body
|
||||||
|
}
|
||||||
|
rendered = strings.TrimRight(rendered, "\n")
|
||||||
|
b.WriteString(" " + rendered)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
age := daysAgo(e.ModifiedAt)
|
||||||
|
ageText := fmt.Sprintf("last touched %d days ago", age)
|
||||||
|
b.WriteString(" " + stumbleAgeStyle.Render(ageText))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||||
|
|
||||||
|
lines := strings.Split(b.String(), "\n")
|
||||||
|
if len(lines) > s.height {
|
||||||
|
lines = lines[:s.height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stumbleModel) doneView() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(detailHeaderStyle.Render(" all caught up"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
reviewed := s.total()
|
||||||
|
if reviewed > 0 {
|
||||||
|
b.WriteString(idStyle.Render(fmt.Sprintf(" %d entries reviewed", reviewed)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(idStyle.Render(" no stale entries found"))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func daysAgo(t time.Time) int {
|
||||||
|
return int(math.Floor(time.Since(t).Hours() / 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
func staleParams() db.ListParams {
|
||||||
|
threshold := time.Now().UTC().AddDate(0, 0, -staleThresholdDays)
|
||||||
|
return db.ListParams{
|
||||||
|
ModifiedBefore: &threshold,
|
||||||
|
Sort: "modified_at",
|
||||||
|
Order: "asc",
|
||||||
|
Limit: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,4 +143,7 @@ var (
|
|||||||
|
|
||||||
railCountStyle = lipgloss.NewStyle().
|
railCountStyle = lipgloss.NewStyle().
|
||||||
Foreground(dim)
|
Foreground(dim)
|
||||||
|
|
||||||
|
stumbleAgeStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "#cc4400", Dark: "#fab387"})
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user