// 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 ( "io/fs" "os" "path/filepath" "sort" "strings" "time" "glint/internal/preview" "glint/internal/theme" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // 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 []fileEntry root string todayPath string matches []string sel int width int height int theme theme.Theme preview *preview.Model style string } // 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. glamourStyle is the name of the glamour // rendering style for the preview pane. func New(root string, th theme.Theme, todayPath, glamourStyle 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, todayPath: todayPath, width: 80, height: 24, theme: th, preview: preview.New(glamourStyle), style: glamourStyle, } m.recompute() return m, nil } // SetSize records dimensions and sizes the preview pane. func (m *Model) SetSize(w, h int) { m.width = w m.height = h m.preview.SetSize(m.previewWidth(), h-2) m.renderPreview() } // SetTheme repaints the picker with a new theme. func (m *Model) SetTheme(t theme.Theme) { m.theme = t } // SetStyle changes the preview's glamour style. func (m *Model) SetStyle(s string) { m.style = s m.preview.SetStyle(s) m.renderPreview() } // 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 { switch key.Type { case tea.KeyUp: if m.sel > 0 { m.sel-- m.renderPreview() } return nil case tea.KeyDown: if m.sel < len(m.matches)-1 { m.sel++ m.renderPreview() } return nil } } var cmd tea.Cmd m.input, cmd = m.input.Update(msg) m.recompute() return cmd } // recompute refilters and reranks against the current query. func (m *Model) recompute() { m.matches = rankEntries(m.all, m.input.Value(), m.todayPath) if m.sel >= len(m.matches) { m.sel = 0 } m.renderPreview() } // 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 { e fileEntry score int } var hits []scored 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].e.mod.After(hits[j].e.mod) }) out := make([]string, 0, len(hits)) for _, h := range hits { out = append(out, h.e.path) } return out } // 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 } 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] } // previewWidth is the column width of the preview pane (~60%), or 0 when the // terminal is too narrow to split. func (m *Model) previewWidth() int { if m.width < 60 { return 0 } return m.width*6/10 - 2 // minus border } // listColWidth is the width available to the match list: the full terminal when // the preview pane is hidden, else the leftover column beside it (matching the // width used to render listCol in View). func (m *Model) listColWidth() int { if m.previewWidth() <= 0 { return m.width } w := m.width - m.previewWidth() - 3 if w < 1 { w = 1 } return w } // truncateTail shortens s to at most max runes, keeping the tail (the filename // end is the useful part of a long path) behind a leading ellipsis. Labels must // fit one row: a wrapped label inflates the list past the viewport and scrolls // the query field off the top. func truncateTail(s string, max int) string { if max <= 0 { return "" } r := []rune(s) if len(r) <= max { return s } if max == 1 { return "…" } return "…" + string(r[len(r)-(max-1):]) } // renderPreview loads the highlighted file into the preview viewport. func (m *Model) renderPreview() { if m.previewWidth() <= 0 { return } sel := m.Selected() if sel == "" { _ = m.preview.Render("") return } data, err := os.ReadFile(sel) if err != nil { _ = m.preview.Render("_could not read file_") return } _ = m.preview.Render(string(data)) } // View renders the query + themed match list on the left and a glamour preview // of the highlighted file on the right (when the terminal is wide enough). func (m *Model) View() string { left := m.listView() if m.previewWidth() <= 0 { return left } border := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(m.theme.Muted). Width(m.previewWidth()). Height(m.height - 2) right := border.Render(m.preview.View()) listCol := lipgloss.NewStyle().Width(m.width - m.previewWidth() - 3).Render(left) return lipgloss.JoinHorizontal(lipgloss.Top, listCol, right) } // listView is the query line plus the themed match list. func (m *Model) listView() string { var b strings.Builder b.WriteString(m.input.View()) b.WriteByte('\n') 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 } labelW := m.listColWidth() - 2 // leave room for the "▸ "/" " prefix for i := 0; i < len(m.matches) && i < rows; i++ { label := truncateTail(m.label(m.matches[i]), labelW) if i == m.sel { b.WriteString(pointer.Render("▸ ")) b.WriteString(selStyle.Render(label)) } else { b.WriteString(plain.Render(" " + label)) } b.WriteByte('\n') } return b.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 } 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 { 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 }) 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 } // NewNotePath builds an absolute note path from a query under root. It trims // surrounding whitespace, appends ".md" when absent, and supports "Folder/Name". // A blank query yields "". func NewNotePath(root, query string) string { rel := strings.TrimSpace(query) if rel == "" { return "" } if !strings.EqualFold(filepath.Ext(rel), ".md") { rel += ".md" } return filepath.Join(root, rel) }