▍ humdrum codex / glint v1.0.2

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