You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
zk/internal/adapter/fzf/note_filter.go

199 lines
5.9 KiB
Go

package fzf
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/zk-org/zk/internal/adapter/term"
"github.com/zk-org/zk/internal/core"
"github.com/zk-org/zk/internal/util/opt"
stringsutil "github.com/zk-org/zk/internal/util/strings"
)
// NoteFilter uses fzf to filter interactively a set of notes.
type NoteFilter struct {
opts NoteFilterOpts
fs core.FileStorage
terminal *term.Terminal
templateLoader core.TemplateLoader
}
// NoteFilterOpts holds the configuration for the fzf notes filtering.
//
// The absolute path to the notebook (NotebookDir) and the working directory
// (WorkingDir) are used to make the path of each note relative to the working
// directory.
type NoteFilterOpts struct {
// Indicates whether the filtering is interactive. If not, fzf is bypassed.
Interactive bool
// Indicates whether fzf is opened for every query, even if empty.
AlwaysFilter bool
// Format for a single line, taken from the config `fzf-line` property.
LineTemplate opt.String
// Optionally provide additional arguments, taken from the config `fzf-options` property.
FzfOptions opt.String
// Key binding for the new action.
NewBinding opt.String
// Preview command to run when selecting a note.
PreviewCmd opt.String
// When non null, a "create new note from query" binding will be added to
// fzf to create a note in this directory.
NewNoteDir *core.Dir
// Absolute path to the notebook.
NotebookDir string
}
func NewNoteFilter(opts NoteFilterOpts, fs core.FileStorage, terminal *term.Terminal, templateLoader core.TemplateLoader) *NoteFilter {
return &NoteFilter{
opts: opts,
fs: fs,
terminal: terminal,
templateLoader: templateLoader,
}
}
// Apply filters the given notes with fzf.
func (f *NoteFilter) Apply(notes []core.ContextualNote) ([]core.ContextualNote, error) {
selectedNotes := make([]core.ContextualNote, 0)
relPaths := []string{}
absPaths := []string{}
if !f.opts.Interactive || !f.terminal.IsInteractive() || (!f.opts.AlwaysFilter && len(notes) == 0) {
return notes, nil
}
lineTemplate, err := f.templateLoader.LoadTemplate(f.opts.LineTemplate.OrString(defaultLineTemplate).String())
if err != nil {
return selectedNotes, err
}
for _, note := range notes {
absPath := filepath.Join(f.opts.NotebookDir, note.Path)
absPaths = append(absPaths, absPath)
if relPath, err := f.fs.Rel(absPath); err == nil {
relPaths = append(relPaths, relPath)
} else {
relPaths = append(relPaths, note.Path)
}
}
zkBin, err := os.Executable()
if err != nil {
return selectedNotes, err
}
bindings := []Binding{}
if dir := f.opts.NewNoteDir; dir != nil {
suffix := ""
if dir.Name != "" {
suffix = " in " + dir.Name + "/"
}
newBinding := f.opts.NewBinding.OrString("Ctrl-E").String()
if newBinding != "" {
bindings = append(bindings, Binding{
Keys: newBinding,
Description: "create a note with the query as title" + suffix,
Action: fmt.Sprintf(`abort+execute("%s" new "%s" --title {q} < /dev/tty > /dev/tty)`, zkBin, dir.Path),
})
}
}
previewCmd := f.opts.PreviewCmd.OrString("cat {-1}").Unwrap()
fzf, err := New(Opts{
Options: f.opts.FzfOptions.OrString(defaultOptions),
PreviewCmd: opt.NewNotEmptyString(previewCmd),
Padding: 2,
Bindings: bindings,
})
if err != nil {
return selectedNotes, err
}
for i, note := range notes {
context := lineRenderContext{
Filename: note.Filename(),
FilenameStem: note.FilenameStem(),
Path: note.Path,
AbsPath: absPaths[i],
RelPath: relPaths[i],
Title: note.Title,
TitleOrPath: note.Title,
Body: stringsutil.JoinLines(note.Body),
RawContent: stringsutil.JoinLines(note.RawContent),
WordCount: note.WordCount,
Tags: note.Tags,
Metadata: note.Metadata,
Created: note.Created,
Modified: note.Modified,
Checksum: note.Checksum,
}
if context.TitleOrPath == "" {
context.TitleOrPath = note.Path
}
line, err := lineTemplate.Render(context)
if err != nil {
return selectedNotes, err
}
// The absolute path is appended at the end of the line to be used in
// the preview command.
absPathField := f.terminal.MustStyle(context.AbsPath, core.StyleUnderstate)
fzf.Add([]string{line, absPathField})
}
selection, err := fzf.Selection()
if err != nil {
return selectedNotes, err
}
for _, s := range selection {
path := s[len(s)-1]
for i, m := range notes {
if absPaths[i] == path {
selectedNotes = append(selectedNotes, m)
}
}
}
return selectedNotes, nil
}
var defaultLineTemplate = `{{style "title" title-or-path}} {{style "understate" body}} {{style "understate" (json metadata)}}`
// defaultOptions are the default fzf options used when filtering notes.
var defaultOptions = strings.Join([]string{
"--tiebreak begin", // Prefer matches located at the beginning of the line
"--exact", // Look for exact matches instead of fuzzy ones by default
"--tabstop 4", // Length of tab characters
"--height 100%", // Height of the list relative to the terminal window
"--layout reverse", // Display the input field at the top
"--no-hscroll", // Make sure the path and titles are always visible
"--color hl:-1,hl+:-1", // Don't highlight search terms
"--preview-window wrap", // Enable line wrapping in the preview window
}, " ")
type lineRenderContext struct {
Filename string
FilenameStem string `handlebars:"filename-stem"`
Path string
AbsPath string `handlebars:"abs-path"`
RelPath string `handlebars:"rel-path"`
Title string
TitleOrPath string `handlebars:"title-or-path"`
Body string
RawContent string `handlebars:"raw-content"`
WordCount int `handlebars:"word-count"`
Tags []string
Metadata map[string]interface{}
Created time.Time
Modified time.Time
Checksum string
}