feat: picker sorts by mtime, floats today's daily, themed list
88a983f55589e464b561808a5491cc5efd90a9da
humdrum <me@humdrum.me> · 2026-06-28 09:32
parent 6f9f840b
3 files changed
internal/app/app.go +4 −1
@@ -206,6 +206,9 @@ func (a *App) cycleTheme() (tea.Model, tea.Cmd) {
a.theme = theme.Next(a.theme.Name)
a.editor.SetTheme(a.theme)
a.preview.SetStyle(a.glamourStyle())
+ if a.picker != nil {
+ a.picker.SetTheme(a.theme)
+ }
a.status = "Theme: " + a.theme.Name
return a, nil
}
@@ -236,7 +239,7 @@ }
// openPicker builds a fresh picker over the vault and switches to it.
func (a *App) openPicker() (tea.Model, tea.Cmd) {
- p, err := picker.New(a.cfg.VaultDir)
+ p, err := picker.New(a.cfg.VaultDir, a.theme, a.cfg.DailyPath(time.Now()))
if err != nil {
a.status = "Picker failed: " + err.Error()
return a, nil
internal/picker/picker.go +86 −35
@@ -1,4 +1,6 @@
// Package picker is a fuzzy file browser over a directory of markdown files.
+// It sorts by modified-date (newest first), floats today's daily note to the
+// top, and paints with the active theme.
package picker
import (
@@ -6,33 +8,55 @@ "io/fs"
"path/filepath"
"sort"
"strings"
+ "time"
+
+ "glint/internal/theme"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
-// Model holds the query input and the current match list.
+// fileEntry is a markdown file plus its modification time.
+type fileEntry struct {
+ path string
+ mod time.Time
+}
+
+// Model holds the query input, the file set, and the current match list.
type Model struct {
- input textinput.Model
- all []string // absolute .md paths
- root string
- matches []string
- sel int
- width int
- height int
+ input textinput.Model
+ all []fileEntry
+ root string
+ todayPath string
+ matches []string
+ sel int
+ width int
+ height int
+ theme theme.Theme
}
-// New walks root for markdown files and returns a ready picker.
-func New(root string) (*Model, error) {
+// New walks root for markdown files and returns a ready picker themed by th.
+// todayPath is the absolute path of today's daily note (floated to the top when
+// present); pass "" if there is none.
+func New(root string, th theme.Theme, todayPath string) (*Model, error) {
files, err := walkMarkdown(root)
if err != nil {
return nil, err
}
ti := textinput.New()
+ ti.Prompt = "note › "
ti.Placeholder = "filter…"
ti.Focus()
- m := &Model{input: ti, all: files, root: root, width: 80, height: 24}
+ m := &Model{
+ input: ti,
+ all: files,
+ root: root,
+ todayPath: todayPath,
+ width: 80,
+ height: 24,
+ theme: th,
+ }
m.recompute()
return m, nil
}
@@ -43,6 +67,12 @@ m.width = w
m.height = h
}
+// SetTheme repaints the picker with a new theme.
+func (m *Model) SetTheme(t theme.Theme) { m.theme = t }
+
+// Query returns the current filter text (used by the app's new-note shortcut).
+func (m *Model) Query() string { return m.input.Value() }
+
// Update handles query edits and selection movement.
func (m *Model) Update(msg tea.Msg) tea.Cmd {
if key, ok := msg.(tea.KeyMsg); ok {
@@ -65,36 +95,51 @@ m.recompute()
return cmd
}
-// recompute filters and ranks all paths against the current query.
+// recompute refilters and reranks against the current query.
func (m *Model) recompute() {
- q := m.input.Value()
+ m.matches = rankEntries(m.all, m.input.Value(), m.todayPath)
+ if m.sel >= len(m.matches) {
+ m.sel = 0
+ }
+}
+
+// rankEntries filters all entries by the fuzzy query and orders the survivors.
+// Empty query: today's note first, then modified-date descending. Non-empty:
+// fuzzy score descending, then modified-date descending.
+func rankEntries(all []fileEntry, query, today string) []string {
type scored struct {
- path string
+ e fileEntry
score int
}
var hits []scored
- for _, p := range m.all {
- label := m.label(p)
- if s, ok := fuzzyMatch(q, label); ok {
- hits = append(hits, scored{p, s})
+ for _, e := range all {
+ label := filepath.Base(e.path)
+ if s, ok := fuzzyMatch(query, label); ok {
+ hits = append(hits, scored{e, s})
}
}
sort.SliceStable(hits, func(i, j int) bool {
+ if query == "" {
+ ti := hits[i].e.path == today
+ tj := hits[j].e.path == today
+ if ti != tj {
+ return ti // today's note floats up
+ }
+ return hits[i].e.mod.After(hits[j].e.mod)
+ }
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
- return hits[i].path < hits[j].path
+ return hits[i].e.mod.After(hits[j].e.mod)
})
- m.matches = m.matches[:0]
+ out := make([]string, 0, len(hits))
for _, h := range hits {
- m.matches = append(m.matches, h.path)
- }
- if m.sel >= len(m.matches) {
- m.sel = 0
+ out = append(out, h.e.path)
}
+ return out
}
-// label is the vault-relative path shown to the user and matched against.
+// label is the vault-relative path shown to the user.
func (m *Model) label(p string) string {
if rel, err := filepath.Rel(m.root, p); err == nil {
return rel
@@ -110,13 +155,14 @@ }
return m.matches[m.sel]
}
-// View renders the query line and the match list.
+// View renders the query line and the match list, themed.
func (m *Model) View() string {
var b strings.Builder
b.WriteString(m.input.View())
b.WriteByte('\n')
- selStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a24")).Background(lipgloss.Color("#e0af68"))
- plain := lipgloss.NewStyle().Foreground(lipgloss.Color("#c0caf5"))
+ selStyle := lipgloss.NewStyle().Foreground(m.theme.SelFg).Background(m.theme.SelBg)
+ plain := lipgloss.NewStyle().Foreground(m.theme.Text)
+ pointer := lipgloss.NewStyle().Foreground(m.theme.Pointer)
rows := m.height - 2
if rows < 1 {
rows = 1
@@ -124,7 +170,8 @@ }
for i := 0; i < len(m.matches) && i < rows; i++ {
label := m.label(m.matches[i])
if i == m.sel {
- b.WriteString(selStyle.Render("> " + label))
+ b.WriteString(pointer.Render("▸ "))
+ b.WriteString(selStyle.Render(label))
} else {
b.WriteString(plain.Render(" " + label))
}
@@ -133,10 +180,9 @@ }
return b.String()
}
-// walkMarkdown returns absolute paths of .md files under root, skipping any
-// directory whose name begins with a dot.
-func walkMarkdown(root string) ([]string, error) {
- var out []string
+// walkMarkdown returns .md files (with mtimes) under root, skipping dot-dirs.
+func walkMarkdown(root string) ([]fileEntry, error) {
+ var out []fileEntry
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries
@@ -149,9 +195,14 @@ return nil
}
if strings.EqualFold(filepath.Ext(d.Name()), ".md") {
abs, aerr := filepath.Abs(path)
- if aerr == nil {
- out = append(out, abs)
+ if aerr != nil {
+ return nil
+ }
+ var mod time.Time
+ if info, ierr := d.Info(); ierr == nil {
+ mod = info.ModTime()
}
+ out = append(out, fileEntry{path: abs, mod: mod})
}
return nil
})
internal/picker/picker_test.go +54 −1
@@ -5,6 +5,7 @@ "os"
"path/filepath"
"sort"
"testing"
+ "time"
)
func TestFuzzyMatchSubsequence(t *testing.T) {
@@ -45,9 +46,13 @@ os.WriteFile(filepath.Join(root, ".git", "c.md"), nil, 0o644)
os.MkdirAll(filepath.Join(root, "sub"), 0o755)
os.WriteFile(filepath.Join(root, "sub", "d.md"), nil, 0o644)
- got, err := walkMarkdown(root)
+ entries, err := walkMarkdown(root)
if err != nil {
t.Fatal(err)
+ }
+ var got []string
+ for _, e := range entries {
+ got = append(got, e.path)
}
sort.Strings(got)
want := []string{
@@ -63,3 +68,51 @@ t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
+
+func TestRankEntriesMtimeDescending(t *testing.T) {
+ base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ all := []fileEntry{
+ {path: "/v/old.md", mod: base},
+ {path: "/v/new.md", mod: base.Add(48 * time.Hour)},
+ {path: "/v/mid.md", mod: base.Add(24 * time.Hour)},
+ }
+ got := rankEntries(all, "", "")
+ want := []string{"/v/new.md", "/v/mid.md", "/v/old.md"}
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
+ }
+ }
+}
+
+func TestRankEntriesTodayFloatsToTop(t *testing.T) {
+ base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ all := []fileEntry{
+ {path: "/v/new.md", mod: base.Add(48 * time.Hour)},
+ {path: "/v/Daily/2026-06-28.md", mod: base}, // oldest, but today
+ }
+ got := rankEntries(all, "", "/v/Daily/2026-06-28.md")
+ if got[0] != "/v/Daily/2026-06-28.md" {
+ t.Errorf("today's note should float to top, got %q", got[0])
+ }
+}
+
+func TestRankEntriesQueryFiltersAndTieBreaksByMtime(t *testing.T) {
+ base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ all := []fileEntry{
+ {path: "/v/alpha.md", mod: base},
+ {path: "/v/alpha-new.md", mod: base.Add(24 * time.Hour)},
+ {path: "/v/zzz.md", mod: base.Add(72 * time.Hour)},
+ }
+ got := rankEntries(all, "alpha", "")
+ // zzz filtered out; both "alpha" files present, newer first on equal score is
+ // not guaranteed (scores differ), but zzz must be absent.
+ for _, p := range got {
+ if p == "/v/zzz.md" {
+ t.Error("zzz.md should not match query 'alpha'")
+ }
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d matches, want 2", len(got))
+ }
+}