feat: full-text vault search, preview & export CLI flags
a3c1891cbeb4f69a43c60b5ccfa26eedc8d95d3f
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 22:35
parent abf7406c
feat: full-text vault search, preview & export CLI flags - TASK-013: a '/'-prefixed picker query runs a full-text content search (ripgrep with a Go-walk fallback), showing file:line and opening at the match - TASK-019: glint -p/--preview [file] opens straight into the Glamour read view - TASK-030: glint -e/--export <file> renders a file to printable HTML headlessly Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
11 files changed
README.md +3 −1
@@ -30,6 +30,8 @@ ```bash
glint # fuzzy picker over the current directory
glint notes.md # open a file
glint -n [name] # new note in the current dir (-n -i → inbox, -n -v → vault)
+glint -p [file] # open straight into the read preview (glow-style reader)
+glint -e <file> # export a file to printable house-style HTML and open it
glint -t # today's daily note
glint -d # browse the daily-notes folder
glint -v # fuzzy picker over your vault, from anywhere
@@ -67,7 +69,7 @@ | `Ctrl+Z` / `Ctrl+Y` | undo / redo |
| `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
| `Ctrl+E` | export a printable HTML document and open it in the browser → Print → Save as PDF (see [Export](#export-pdf)) |
| `Ctrl+P` | toggle the Glamour read preview |
-| `Ctrl+F` | fuzzy file picker (with live preview) |
+| `Ctrl+F` | fuzzy file picker (with live preview); prefix the query with `/` to full-text search note contents — results show `file:line text` and open at the match |
| `Ctrl+G` | find in document (`Enter`/`↓` next, `Shift+Tab`/`↑` prev, `Esc` close) |
| `Ctrl+L` | go to line (type a number, `Enter` to jump, `Esc` to cancel) |
| `Ctrl+D` | today's daily note |
- → Full-text-vault-search.md +16 −4
@@ -1,10 +1,10 @@
---
id: TASK-013
title: Full-text vault search
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-29 16:26'
-updated_date: '2026-06-30 00:21'
+updated_date: '2026-06-30 05:07'
labels:
- feature
- release-1
@@ -21,6 +21,18 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 Search note contents across the vault; results show file + matching line
-- [ ] #2 Selecting a result opens the file at the match
+- [x] #1 Search note contents across the vault; results show file + matching line
+- [x] #2 Selecting a result opens the file at the match
<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. search.go: contentSearch(root,query) → ripgrep (rg -g *.md, exit-1=empty) with goWalkSearch fallback; SearchHit{Path,Line,Text}; cap maxHits. 2. picker: result type {path,line,text}; '/'-prefixed query runs content search, else filename fuzzy; Selected()/SelectedLine(); listView shows relpath:line text. 3. app: Enter on a content hit Loads then GotoLine(ln). 4. README+help document '/query' content search. 5. TDD throughout.
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+search.go (rg with go-walk fallback, SearchHit, maxHits cap); picker '/'-prefix runs contentSearch, result{path,line,text}, Selected()/SelectedLine(), rowLabel shows file:line text; app Enter GotoLine on a hit; README+help document /query. 7 tests, all green.
+<!-- SECTION:NOTES:END -->
- → Open-directly-in-preview-reading-mode-p-preview.md +11 −5
@@ -1,10 +1,10 @@
---
id: TASK-019
title: 'Open directly in preview / reading mode (-p, --preview)'
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-29 16:56'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-30 05:11'
labels:
- feature
- release-1
@@ -21,7 +21,13 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 glint -p [file] (and --preview / -preview / --p) opens the file directly in the read preview
-- [ ] #2 Ctrl+P toggles back to the editor; quit/esc behave normally
-- [ ] #3 Works as a glow-style reader (themed preview, no dark panels)
+- [x] #1 glint -p [file] (and --preview / -preview / --p) opens the file directly in the read preview
+- [x] #2 Ctrl+P toggles back to the editor; quit/esc behave normally
+- [x] #3 Works as a glow-style reader (themed preview, no dark panels)
<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+main.go -p/--preview flag → App.StartPreview(path): Load then togglePreview (ModePreview); no file falls back to picker. setSize re-renders preview at new width. README usage + help COMMANDS document -p. 3 app tests, all green.
+<!-- SECTION:NOTES:END -->
- → CLI-export-flag-e-export-to-HTML.md +32 −0
@@ -0,0 +1,32 @@
+---
+id: TASK-030
+title: CLI export flag -e / --export to HTML
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-06-30 05:13'
+updated_date: '2026-06-30 05:31'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 29000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Like -p (preview), add -e / --export [file] to export a named markdown file straight to printable house-style HTML from the command line — headless, no TUI. Mirrors Ctrl+E: render via export.Write, open in browser, print the output path, exit. Requires a file arg.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 glint -e <file> (and --export / -export / --e) exports the file to house-style HTML and opens it in the browser
+- [x] #2 Prints the output path; headless (no editor TUI); errors if no file given
+- [x] #3 README + help document the flag
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+main.go -e/--export standalone command → App.ExportFile(path): writeExport (read+export.Write, no browser) then OpenInBrowser best-effort; prints output path; errors if no file. Extracted shared exportOptions (Ctrl+E reuses it). README usage + help document -e. 2 app tests green.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +56 −9
@@ -188,6 +188,20 @@ return nil
}
}
+// StartPreview opens path straight into the Glamour read view (glint -p), making
+// glint a glow-style reader. Ctrl+P toggles back to the editor, which already
+// holds the loaded file. With no path it falls back to the picker (TASK-019).
+func (a *App) StartPreview(path string) error {
+ if path == "" {
+ return a.Start("", false)
+ }
+ if err := a.Load(path); err != nil {
+ return err
+ }
+ a.togglePreview() // render the buffer and switch to ModePreview
+ return nil
+}
+
func (a *App) Init() tea.Cmd { return nil }
// Update routes messages. Global keys are handled first, then mode-specific.
@@ -389,6 +403,8 @@ if msg.Type == tea.KeyEnter {
if sel := a.picker.Selected(); sel != "" {
if err := a.Load(sel); err != nil {
a.status = "Open failed: " + err.Error()
+ } else if ln := a.picker.SelectedLine(); ln > 0 {
+ a.editor.GotoLine(ln) // a content-search hit opens at the match (TASK-013)
}
}
return a, nil
@@ -419,15 +435,7 @@ // the house style and opens it in the browser, where the user picks
// Print → Save as PDF (TASK-021). No external converter is required.
func (a *App) exportPDF() (tea.Model, tea.Cmd) {
md := string(a.editor.Bytes())
- opts := export.Options{
- Title: export.Title(a.path, md),
- Theme: export.MapTheme(a.theme.Name),
- FontDisplay: a.cfg.FontDisplay,
- FontBody: a.cfg.FontBody,
- FontMono: a.cfg.FontMono,
- Cover: true,
- }
- out, err := export.Write(a.path, md, opts)
+ out, err := export.Write(a.path, md, a.exportOptions(a.path, md))
if err != nil {
a.status = "Export failed: " + err.Error()
return a, nil
@@ -439,6 +447,42 @@ return a, nil
}
a.status = "Exported " + out + " — Print → Save as PDF in the browser"
return a, nil
+}
+
+// exportOptions builds the house-style export options for path/markdown, shared
+// by the in-editor Ctrl+E and the headless `glint -e` command (TASK-030).
+func (a *App) exportOptions(path, md string) export.Options {
+ return export.Options{
+ Title: export.Title(path, md),
+ Theme: export.MapTheme(a.theme.Name),
+ FontDisplay: a.cfg.FontDisplay,
+ FontBody: a.cfg.FontBody,
+ FontMono: a.cfg.FontMono,
+ Cover: true,
+ }
+}
+
+// writeExport renders the markdown file at path to a printable HTML document
+// beside it and returns the output path, without opening a browser (TASK-030).
+func (a *App) writeExport(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ md := string(data)
+ return export.Write(path, md, a.exportOptions(path, md))
+}
+
+// ExportFile is the headless form of Ctrl+E for `glint -e <file>`: it renders
+// path to house-style HTML, opens it in the browser (best-effort), and returns
+// the output path (TASK-030).
+func (a *App) ExportFile(path string) (string, error) {
+ out, err := a.writeExport(path)
+ if err != nil {
+ return "", err
+ }
+ _ = export.OpenInBrowser(out) // best-effort; the path is returned regardless
+ return out, nil
}
// promptSaveAs opens the one-line save-as prompt for an unnamed buffer.
@@ -804,6 +848,9 @@ a.helpView.Width = maxInt(cw-2, 1)
a.helpView.Height = maxInt(textRows-4, 1)
if a.picker != nil {
a.picker.SetSize(w, h-1) // picker keeps its full-width split
+ }
+ if a.mode == ModePreview {
+ _ = a.preview.Render(string(a.editor.Bytes())) // re-wrap at the new width
}
}
internal/app/app_test.go +70 −0
@@ -756,3 +756,73 @@ if got := a.editor.Lines[0]; got != "- [ ] task" {
t.Fatalf("click on content toggled the box: line = %q", got)
}
}
+
+func TestStartPreviewOpensFileInReadView(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "doc.md")
+ os.WriteFile(p, []byte("# Title\n\nbody"), 0o644)
+ a := New(config.Default())
+ if err := a.StartPreview(p); err != nil {
+ t.Fatal(err)
+ }
+ if a.mode != ModePreview {
+ t.Errorf("StartPreview should start in ModePreview, mode = %d", a.mode)
+ }
+ if string(a.editor.Bytes()) != "# Title\n\nbody" {
+ t.Errorf("StartPreview should load the file into the editor too")
+ }
+}
+
+func TestStartPreviewCtrlPTogglesToEditor(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "doc.md")
+ os.WriteFile(p, []byte("# Title"), 0o644)
+ a := New(config.Default())
+ if err := a.StartPreview(p); err != nil {
+ t.Fatal(err)
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+ if a.mode != ModeEditor {
+ t.Errorf("Ctrl+P from preview should return to the editor, mode = %d", a.mode)
+ }
+}
+
+func TestStartPreviewNoFileFallsBackToPicker(t *testing.T) {
+ dir := t.TempDir()
+ a := New(config.Default())
+ t.Setenv("GLINT_VAULT", dir)
+ if err := a.StartPreview(""); err != nil {
+ t.Fatal(err)
+ }
+ if a.mode != ModePicker {
+ t.Errorf("StartPreview with no file should fall back to the picker, mode = %d", a.mode)
+ }
+}
+
+func TestWriteExportProducesHtml(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "doc.md")
+ os.WriteFile(p, []byte("# My Title\n\nbody text"), 0o644)
+ a := New(config.Default())
+ out, err := a.writeExport(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out != filepath.Join(dir, "doc.html") {
+ t.Errorf("out = %q, want doc.html beside the source", out)
+ }
+ data, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatalf("export file not written: %v", err)
+ }
+ if !strings.Contains(string(data), "My Title") {
+ t.Errorf("exported HTML should contain the title")
+ }
+}
+
+func TestWriteExportMissingFile(t *testing.T) {
+ a := New(config.Default())
+ if _, err := a.writeExport(filepath.Join(t.TempDir(), "nope.md")); err == nil {
+ t.Error("writeExport should error on a missing file")
+ }
+}
internal/help/help.go +4 −1
@@ -14,6 +14,8 @@ COMMANDS (each takes a short letter or the full word, one or two dashes:
-n / --n / -new / --new all work)
-n, --new [name] new note in the current directory
combine with -i or -v to target the inbox or vault
+ -p, --preview [file] open a file straight into the read preview (glow-style)
+ -e, --export <file> export a file to printable house-style HTML and open it
-t, --today open today's daily note (in the vault)
-d, --daily browse the daily-notes folder
-v, --vault fuzzy picker over your vault, from anywhere
@@ -35,7 +37,8 @@ Ctrl+S save (an unnamed buffer prompts for a name)
Ctrl+E export a printable HTML doc (house style) and open it in
the browser → Print → Save as PDF
Ctrl+P toggle the read preview
- Ctrl+F fuzzy file picker
+ Ctrl+F fuzzy file picker (prefix the query with / to full-text
+ search note contents; results open at the matching line)
Ctrl+G find in document (Enter/down next, Shift+Tab/up prev)
Ctrl+L go to line (type a number, Enter to jump)
Ctrl+D today's daily note
internal/picker/picker.go +59 −5
@@ -4,6 +4,7 @@ // top, and paints with the active theme.
package picker
import (
+ "fmt"
"io/fs"
"os"
"path/filepath"
@@ -25,13 +26,22 @@ 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 []string
+ matches []result
sel int
width int
height int
@@ -113,15 +123,40 @@ m.recompute()
return cmd
}
-// recompute refilters and reranks against the current query.
+// 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() {
- m.matches = rankEntries(m.all, m.input.Value(), m.todayPath)
+ 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.
@@ -166,12 +201,31 @@ }
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]
+ 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
@@ -264,7 +318,7 @@ 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)
+ label := truncateTail(m.rowLabel(m.matches[i]), labelW)
if i == m.sel {
b.WriteString(pointer.Render("▸ "))
b.WriteString(selStyle.Render(label))
internal/picker/search.go +119 −0
@@ -0,0 +1,119 @@
+package picker
+
+import (
+ "bufio"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+)
+
+// SearchHit is one content match: a file, the 1-based line number, and the
+// matching line's text (TASK-013).
+type SearchHit struct {
+ Path string
+ Line int
+ Text string
+}
+
+// maxHits bounds a content search so a common word can't flood the list.
+const maxHits = 200
+
+// contentSearch finds query as a case-insensitive substring across the markdown
+// files under root, returning file + line hits. It shells out to ripgrep when
+// available and falls back to an in-process walk otherwise (TASK-013).
+func contentSearch(root, query string) []SearchHit {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return nil
+ }
+ if hits, ok := ripgrepSearch(root, query); ok {
+ return hits
+ }
+ return goWalkSearch(root, query)
+}
+
+// ripgrepSearch runs rg over the markdown files under root; ok is false when rg
+// is missing or errored, so the caller can fall back to the in-process walk. A
+// no-match exit (code 1) is a clean empty result, not a fallback trigger.
+func ripgrepSearch(root, query string) (hits []SearchHit, ok bool) {
+ rg, err := exec.LookPath("rg")
+ if err != nil {
+ return nil, false
+ }
+ cmd := exec.Command(rg,
+ "--line-number", "--no-heading", "--color=never",
+ "--fixed-strings", "--ignore-case",
+ "-g", "*.md",
+ "--", query, root)
+ out, err := cmd.Output()
+ if err != nil {
+ if ee, isExit := err.(*exec.ExitError); isExit && ee.ExitCode() == 1 {
+ return nil, true // no matches
+ }
+ return nil, false
+ }
+ return parseRgOutput(string(out)), true
+}
+
+// parseRgOutput turns ripgrep's "path:line:text" lines into hits, skipping
+// malformed lines and capping the count at maxHits.
+func parseRgOutput(out string) []SearchHit {
+ var hits []SearchHit
+ sc := bufio.NewScanner(strings.NewReader(out))
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for sc.Scan() && len(hits) < maxHits {
+ if h, ok := parseHitLine(sc.Text()); ok {
+ hits = append(hits, h)
+ }
+ }
+ return hits
+}
+
+// parseHitLine splits one "path:line:text" record. A line whose second field is
+// not a number (e.g. a path containing a colon, or rg banner noise) is rejected.
+func parseHitLine(line string) (SearchHit, bool) {
+ parts := strings.SplitN(line, ":", 3)
+ if len(parts) < 3 {
+ return SearchHit{}, false
+ }
+ n, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return SearchHit{}, false
+ }
+ return SearchHit{Path: parts[0], Line: n, Text: strings.TrimSpace(parts[2])}, true
+}
+
+// goWalkSearch is the ripgrep-free fallback: walk the markdown files under root
+// and scan each line for a case-insensitive substring match (TASK-013).
+func goWalkSearch(root, query string) []SearchHit {
+ files, err := walkMarkdown(root)
+ if err != nil {
+ return nil
+ }
+ needle := strings.ToLower(query)
+ var hits []SearchHit
+ for _, f := range files {
+ if len(hits) >= maxHits {
+ break
+ }
+ data, err := os.ReadFile(f.path)
+ if err != nil {
+ continue
+ }
+ sc := bufio.NewScanner(strings.NewReader(string(data)))
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ ln := 0
+ for sc.Scan() {
+ ln++
+ text := sc.Text()
+ if strings.Contains(strings.ToLower(text), needle) {
+ hits = append(hits, SearchHit{Path: f.path, Line: ln, Text: strings.TrimSpace(text)})
+ if len(hits) >= maxHits {
+ break
+ }
+ }
+ }
+ }
+ return hits
+}
internal/picker/search_test.go +94 −0
@@ -0,0 +1,94 @@
+package picker
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "glint/internal/theme"
+)
+
+func TestGoWalkSearchFindsLineMatches(t *testing.T) {
+ root := t.TempDir()
+ os.WriteFile(filepath.Join(root, "a.md"), []byte("alpha\nfind me here\nbeta"), 0o644)
+ os.WriteFile(filepath.Join(root, "b.md"), []byte("nothing relevant"), 0o644)
+
+ hits := goWalkSearch(root, "find me")
+ if len(hits) != 1 {
+ t.Fatalf("got %d hits, want 1: %+v", len(hits), hits)
+ }
+ h := hits[0]
+ if h.Path != filepath.Join(root, "a.md") {
+ t.Errorf("path = %q, want a.md", h.Path)
+ }
+ if h.Line != 2 {
+ t.Errorf("line = %d, want 2", h.Line)
+ }
+ if h.Text != "find me here" {
+ t.Errorf("text = %q, want %q", h.Text, "find me here")
+ }
+}
+
+func TestGoWalkSearchCaseInsensitive(t *testing.T) {
+ root := t.TempDir()
+ os.WriteFile(filepath.Join(root, "a.md"), []byte("The Quick Brown Fox"), 0o644)
+ hits := goWalkSearch(root, "quick brown")
+ if len(hits) != 1 {
+ t.Fatalf("case-insensitive search should find 1 hit, got %d", len(hits))
+ }
+}
+
+func TestParseRgOutput(t *testing.T) {
+ out := "/v/a.md:2:find me here\n/v/sub/b.md:10:another match\nmalformed line\n"
+ hits := parseRgOutput(out)
+ if len(hits) != 2 {
+ t.Fatalf("got %d hits, want 2 (malformed skipped): %+v", len(hits), hits)
+ }
+ if hits[0].Path != "/v/a.md" || hits[0].Line != 2 || hits[0].Text != "find me here" {
+ t.Errorf("hit0 = %+v", hits[0])
+ }
+ if hits[1].Line != 10 {
+ t.Errorf("hit1 line = %d, want 10", hits[1].Line)
+ }
+}
+
+func TestContentSearchEmptyQuery(t *testing.T) {
+ if hits := contentSearch(t.TempDir(), " "); hits != nil {
+ t.Errorf("blank query should yield no hits, got %+v", hits)
+ }
+}
+
+func TestPickerSlashPrefixContentSearch(t *testing.T) {
+ root := t.TempDir()
+ os.WriteFile(filepath.Join(root, "note.md"), []byte("intro\nthe needle line\noutro"), 0o644)
+
+ m, err := New(root, theme.FlexokiDark(), "", "dark")
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.input.SetValue("/needle")
+ m.recompute()
+
+ if got := m.Selected(); got != filepath.Join(root, "note.md") {
+ t.Errorf("Selected() = %q, want note.md", got)
+ }
+ if got := m.SelectedLine(); got != 2 {
+ t.Errorf("SelectedLine() = %d, want 2", got)
+ }
+}
+
+func TestPickerFilenameModeHasNoLine(t *testing.T) {
+ root := t.TempDir()
+ os.WriteFile(filepath.Join(root, "note.md"), []byte("body"), 0o644)
+
+ m, err := New(root, theme.FlexokiDark(), "", "dark")
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.input.SetValue("note")
+ m.recompute()
+
+ if m.SelectedLine() != 0 {
+ t.Errorf("filename match should have line 0, got %d", m.SelectedLine())
+ }
+}
main.go +22 −0
@@ -53,6 +53,8 @@ flagDaily := boolFlag("d", "daily")
flagVault := boolFlag("v", "vault")
flagConfig := boolFlag("c", "config")
flagInbox := boolFlag("i", "inbox")
+ flagPreview := boolFlag("p", "preview")
+ flagExport := boolFlag("e", "export")
flagKeys := flag.Bool("keys", false, "show what the terminal sends for each key")
flag.Parse()
@@ -62,6 +64,8 @@ isDaily := *flagDaily[0] || *flagDaily[1]
isVault := *flagVault[0] || *flagVault[1]
isConfig := *flagConfig[0] || *flagConfig[1]
isInbox := *flagInbox[0] || *flagInbox[1]
+ isPreview := *flagPreview[0] || *flagPreview[1]
+ isExport := *flagExport[0] || *flagExport[1]
// Standalone commands (no editor TUI).
if isConfig {
@@ -79,8 +83,26 @@ name = args[0]
}
a := app.New(cfg)
+
+ // Headless export: render a named file to HTML and exit, no TUI.
+ if isExport {
+ if name == "" {
+ fmt.Fprintln(os.Stderr, "glint: -e needs a file to export")
+ os.Exit(1)
+ }
+ out, err := a.ExportFile(name)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "glint:", err)
+ os.Exit(1)
+ }
+ fmt.Println("glint: exported", out, "— open it, then Print → Save as PDF")
+ return
+ }
+
var startErr error
switch {
+ case isPreview:
+ startErr = a.StartPreview(name) // open straight into the read view (glow-style)
case isNew:
// New note in the current dir, or the inbox/vault when combined.
dir := cfg.WorkingDir()