// 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 ( "fmt" "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 } // result is one match shown in the list: a file path, plus (for content-search // hits) the 1-based line number and the matching line's text. line == 0 marks a // plain filename match (TASK-013). type result struct { path string line int text string } // 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 []result 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 against the current query. A query prefixed with "/" runs // a full-text content search; otherwise it fuzzy-matches filenames (TASK-013). func (m *Model) recompute() { if rest, ok := strings.CutPrefix(m.input.Value(), "/"); ok { m.matches = m.contentMatches(rest) } else { m.matches = m.fileMatches(m.input.Value()) } if m.sel >= len(m.matches) { m.sel = 0 } m.renderPreview() } // fileMatches ranks filename matches as line-less results. func (m *Model) fileMatches(query string) []result { paths := rankEntries(m.all, query, m.todayPath) out := make([]result, len(paths)) for i, p := range paths { out[i] = result{path: p} } return out } // contentMatches searches note bodies for query, returning file+line hits. func (m *Model) contentMatches(query string) []result { hits := contentSearch(m.root, query) out := make([]result, len(hits)) for i, h := range hits { out[i] = result{path: h.Path, line: h.Line, text: h.Text} } return out } // 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 } // rowLabel is the list text for a match: the relative path, plus ":line text" // for a content-search hit (TASK-013). func (m *Model) rowLabel(r result) string { if r.line > 0 { return fmt.Sprintf("%s:%d %s", m.label(r.path), r.line, r.text) } return m.label(r.path) } // 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].path } // SelectedLine returns the 1-based line of the highlighted content-search hit, // or 0 for a filename match (or no selection) — the app jumps there on open // (TASK-013). func (m *Model) SelectedLine() int { if m.sel < 0 || m.sel >= len(m.matches) { return 0 } return m.matches[m.sel].line } // 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.rowLabel(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) }