feat: fuzzy file picker (Ctrl+P) and daily note (Ctrl+D)
4608e5cfa4ba3b430b01fc3179f744496d6c03f4
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-27 22:02
parent 59533b8a
6 files changed
go.mod +1 −0
@@ -12,6 +12,7 @@ )
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
go.sum +2 −0
@@ -6,6 +6,8 @@ github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
internal/app/app.go +52 −0
@@ -5,9 +5,12 @@
import (
"fmt"
"os"
+ "path/filepath"
+ "time"
"glint/internal/config"
"glint/internal/editor"
+ "glint/internal/picker"
"glint/internal/preview"
tea "github.com/charmbracelet/bubbletea"
@@ -29,6 +32,7 @@ mode Mode
cfg config.Config
editor *editor.Editor
preview *preview.Model
+ picker *picker.Model
path string
status string
width int
@@ -92,6 +96,10 @@ case tea.KeyCtrlS:
return a.save()
case tea.KeyCtrlR:
return a.togglePreview()
+ case tea.KeyCtrlP:
+ return a.openPicker()
+ case tea.KeyCtrlD:
+ return a.openDaily()
case tea.KeyEsc:
a.mode = ModeEditor
return a, nil
@@ -102,6 +110,16 @@ case ModeEditor:
a.editor.HandleKey(msg)
case ModePreview:
return a, a.preview.Update(msg)
+ case ModePicker:
+ if msg.Type == tea.KeyEnter {
+ if sel := a.picker.Selected(); sel != "" {
+ if err := a.Load(sel); err != nil {
+ a.status = "Open failed: " + err.Error()
+ }
+ }
+ return a, nil
+ }
+ return a, a.picker.Update(msg)
}
return a, nil
}
@@ -141,12 +159,46 @@ a.editor.SetSize(w, h-1) // reserve one row for the status bar
a.preview.SetSize(w, h-1)
}
+// 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)
+ if err != nil {
+ a.status = "Picker failed: " + err.Error()
+ return a, nil
+ }
+ p.SetSize(a.width, a.height-1)
+ a.picker = p
+ a.mode = ModePicker
+ return a, nil
+}
+
+// openDaily opens today's daily note, creating the file and directory if needed.
+func (a *App) openDaily() (tea.Model, tea.Cmd) {
+ path := a.cfg.DailyPath(time.Now())
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ a.status = "Daily dir failed: " + err.Error()
+ return a, nil
+ }
+ if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
+ a.status = "Daily create failed: " + err.Error()
+ return a, nil
+ }
+ }
+ if err := a.Load(path); err != nil {
+ a.status = "Daily open failed: " + err.Error()
+ }
+ return a, nil
+}
+
// View renders the active sub-view plus the status bar.
func (a *App) View() string {
var body string
switch a.mode {
case ModePreview:
body = a.preview.View()
+ case ModePicker:
+ body = a.picker.View()
default:
body = a.editor.View()
}
internal/app/app_test.go +33 −0
@@ -143,3 +143,36 @@ if a.mode != ModeEditor {
t.Errorf("Esc should return to editor, mode = %d", a.mode)
}
}
+
+func TestCtrlPOpensPicker(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644)
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+ if a.mode != ModePicker {
+ t.Errorf("mode = %d, want ModePicker", a.mode)
+ }
+}
+
+func TestCtrlDCreatesAndOpensDaily(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ cfg.DailySubdir = "Daily"
+ cfg.DailyFormat = "2006-01-02"
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
+ if a.mode != ModeEditor {
+ t.Errorf("mode = %d, want ModeEditor after daily open", a.mode)
+ }
+ if a.path == "" {
+ t.Error("daily path should be set")
+ }
+ if _, err := os.Stat(a.path); err != nil {
+ t.Errorf("daily file should exist on disk: %v", err)
+ }
+}
internal/picker/picker.go +183 −0
@@ -0,0 +1,183 @@
+// Package picker is a fuzzy file browser over a directory of markdown files.
+package picker
+
+import (
+ "io/fs"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Model holds the query input 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
+}
+
+// New walks root for markdown files and returns a ready picker.
+func New(root string) (*Model, error) {
+ files, err := walkMarkdown(root)
+ if err != nil {
+ return nil, err
+ }
+ ti := textinput.New()
+ ti.Placeholder = "filter…"
+ ti.Focus()
+ m := &Model{input: ti, all: files, root: root, width: 80, height: 24}
+ m.recompute()
+ return m, nil
+}
+
+// SetSize records dimensions.
+func (m *Model) SetSize(w, h int) {
+ m.width = w
+ m.height = h
+}
+
+// Update handles query edits and selection movement.
+func (m *Model) Update(msg tea.Msg) tea.Cmd {
+ if key, ok := msg.(tea.KeyMsg); ok {
+ switch key.Type {
+ case tea.KeyUp:
+ if m.sel > 0 {
+ m.sel--
+ }
+ return nil
+ case tea.KeyDown:
+ if m.sel < len(m.matches)-1 {
+ m.sel++
+ }
+ return nil
+ }
+ }
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ m.recompute()
+ return cmd
+}
+
+// recompute filters and ranks all paths against the current query.
+func (m *Model) recompute() {
+ q := m.input.Value()
+ type scored struct {
+ path string
+ 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})
+ }
+ }
+ sort.SliceStable(hits, func(i, j int) bool {
+ if hits[i].score != hits[j].score {
+ return hits[i].score > hits[j].score
+ }
+ return hits[i].path < hits[j].path
+ })
+ m.matches = m.matches[:0]
+ for _, h := range hits {
+ m.matches = append(m.matches, h.path)
+ }
+ if m.sel >= len(m.matches) {
+ m.sel = 0
+ }
+}
+
+// label is the vault-relative path shown to the user and matched against.
+func (m *Model) label(p string) string {
+ if rel, err := filepath.Rel(m.root, p); err == nil {
+ return rel
+ }
+ return p
+}
+
+// Selected returns the highlighted match's absolute path, or "".
+func (m *Model) Selected() string {
+ if m.sel < 0 || m.sel >= len(m.matches) {
+ return ""
+ }
+ return m.matches[m.sel]
+}
+
+// View renders the query line and the match list.
+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"))
+ rows := m.height - 2
+ if rows < 1 {
+ rows = 1
+ }
+ 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))
+ } else {
+ b.WriteString(plain.Render(" " + label))
+ }
+ b.WriteByte('\n')
+ }
+ 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
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil // skip unreadable entries
+ }
+ if d.IsDir() {
+ if path != root && strings.HasPrefix(d.Name(), ".") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if strings.EqualFold(filepath.Ext(d.Name()), ".md") {
+ abs, aerr := filepath.Abs(path)
+ if aerr == nil {
+ out = append(out, abs)
+ }
+ }
+ return nil
+ })
+ return out, err
+}
+
+// fuzzyMatch reports whether query is a case-insensitive subsequence of
+// candidate, scoring contiguous matches higher (fewer gaps -> higher score).
+func fuzzyMatch(query, candidate string) (int, bool) {
+ if query == "" {
+ return 0, true
+ }
+ q := []rune(strings.ToLower(query))
+ c := []rune(strings.ToLower(candidate))
+ score, qi, last := 0, 0, -1
+ for ci := 0; ci < len(c) && qi < len(q); ci++ {
+ if c[ci] == q[qi] {
+ if last >= 0 {
+ score -= ci - last - 1 // penalize gaps
+ }
+ last = ci
+ qi++
+ }
+ }
+ if qi != len(q) {
+ return 0, false
+ }
+ return score, true
+}
internal/picker/picker_test.go +65 −0
@@ -0,0 +1,65 @@
+package picker
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+)
+
+func TestFuzzyMatchSubsequence(t *testing.T) {
+ if _, ok := fuzzyMatch("abc", "axbxc"); !ok {
+ t.Error("abc should match axbxc as a subsequence")
+ }
+ if _, ok := fuzzyMatch("abc", "acb"); ok {
+ t.Error("abc should NOT match acb (out of order)")
+ }
+ if _, ok := fuzzyMatch("", "anything"); !ok {
+ t.Error("empty query should match anything")
+ }
+}
+
+func TestFuzzyMatchCaseInsensitive(t *testing.T) {
+ if _, ok := fuzzyMatch("DOC", "my-document"); !ok {
+ t.Error("DOC should match my-document case-insensitively")
+ }
+}
+
+func TestFuzzyMatchContiguousScoresHigher(t *testing.T) {
+ tight, ok1 := fuzzyMatch("ab", "abxx")
+ loose, ok2 := fuzzyMatch("ab", "axxb")
+ if !ok1 || !ok2 {
+ t.Fatal("both should match")
+ }
+ if tight <= loose {
+ t.Errorf("contiguous match (%d) should score higher than gapped (%d)", tight, loose)
+ }
+}
+
+func TestWalkMarkdownSkipsDotDirsAndNonMarkdown(t *testing.T) {
+ root := t.TempDir()
+ os.WriteFile(filepath.Join(root, "a.md"), nil, 0o644)
+ os.WriteFile(filepath.Join(root, "b.txt"), nil, 0o644)
+ os.MkdirAll(filepath.Join(root, ".git"), 0o755)
+ 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)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sort.Strings(got)
+ want := []string{
+ filepath.Join(root, "a.md"),
+ filepath.Join(root, "sub", "d.md"),
+ }
+ if len(got) != len(want) {
+ t.Fatalf("walkMarkdown = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
+ }
+ }
+}