Files
iris-context/commands/fs/zoxide.go
T

225 lines
5.0 KiB
Go

package fs
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/versenilvis/fuzzy"
"github.com/versenilvis/iris/spec"
)
const (
zoxideCacheTTL = 2 * time.Second
zoxideQueryTimeout = 500 * time.Millisecond
zoxideEmptyLimit = 20
zoxideSearchLimit = 10
)
type zoxideQueryFunc func(context.Context) ([]byte, error)
type zoxideSource struct {
mu sync.Mutex
query zoxideQueryFunc
now func() time.Time
dirs []string
err error
fetched time.Time
}
func init() {
spec.Register(&spec.Spec{
Name: "z",
Description: "jump to directory",
MaxArgs: 0,
Generator: ZoxideGenerator(),
})
spec.Register(&spec.Spec{
Name: "zi",
Description: "jump to directory interactively",
MaxArgs: 0,
Generator: ZoxideGenerator(),
})
}
func newZoxideSource(query zoxideQueryFunc) *zoxideSource {
return &zoxideSource{query: query, now: time.Now}
}
var defaultZoxideSource = newZoxideSource(func(ctx context.Context) ([]byte, error) {
return exec.CommandContext(ctx, "zoxide", "query", "-l").Output()
})
// directories caches failures too, preventing a missing zoxide binary from
// spawning a failed process on every keystroke.
func (s *zoxideSource) directories() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
if !s.fetched.IsZero() && now.Before(s.fetched.Add(zoxideCacheTTL)) {
return append([]string(nil), s.dirs...), s.err
}
ctx, cancel := context.WithTimeout(context.Background(), zoxideQueryTimeout)
defer cancel()
out, err := s.query(ctx)
s.fetched = s.now()
s.err = err
s.dirs = nil
if err != nil {
return nil, err
}
for line := range strings.SplitSeq(string(out), "\n") {
line = strings.TrimSuffix(line, "\r")
if line != "" {
s.dirs = append(s.dirs, line)
}
}
return append([]string(nil), s.dirs...), nil
}
func ZoxideGenerator() spec.GeneratorFunc {
return newZoxideGenerator(defaultZoxideSource.directories)
}
func newZoxideGenerator(directorySource func() ([]string, error)) spec.GeneratorFunc {
return func(tokens []string, prefix string, partial string) []spec.Suggestion {
fullQuery := strings.Join(tokens[1:], " ")
local := spec.FileGenerator("/")(tokens, prefix, fullQuery)
if strings.ContainsAny(fullQuery, `/\`) {
return local
}
dirs, err := directorySource()
if err != nil {
return local
}
external := zoxideSuggestions(dirs, fullQuery)
return mergeDirectorySuggestions(local, external, spec.GetCWD())
}
}
func zoxideSuggestions(dirs []string, query string) []spec.Suggestion {
candidates := dirs
limit := zoxideEmptyLimit
if query != "" {
candidates = matchedZoxideDirectories(dirs, query)
limit = zoxideSearchLimit
}
home, _ := os.UserHomeDir()
results := make([]spec.Suggestion, 0, min(limit, len(candidates)))
seen := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
path := filepath.Clean(candidate)
if !filepath.IsAbs(path) {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
continue
}
results = append(results, spec.Suggestion{
Cmd: path,
Desc: displayDirectory(path, home),
Priority: 40,
})
if len(results) == limit {
break
}
}
return results
}
func matchedZoxideDirectories(dirs []string, query string) []string {
if len(dirs) == 0 {
return nil
}
results := make([]string, 0, len(dirs))
seen := make(map[string]struct{}, len(dirs))
lowerQuery := strings.ToLower(query)
for _, dir := range dirs {
if strings.Contains(strings.ToLower(dir), lowerQuery) {
results = append(results, dir)
seen[dir] = struct{}{}
}
}
matches := fuzzy.NewSearcher(dirs).SearchWithScores(query, &fuzzy.SearchOptions{Limit: len(dirs)})
for _, match := range matches {
if _, ok := seen[match.Str]; ok {
continue
}
results = append(results, match.Str)
seen[match.Str] = struct{}{}
}
return results
}
func mergeDirectorySuggestions(local, external []spec.Suggestion, cwd string) []spec.Suggestion {
results := make([]spec.Suggestion, 0, len(local)+len(external))
seen := make(map[string]struct{}, len(local)+len(external))
add := func(suggestion spec.Suggestion) {
key := directoryKey(suggestion.Cmd, cwd)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
results = append(results, suggestion)
}
for _, suggestion := range local {
add(suggestion)
}
for _, suggestion := range external {
add(suggestion)
}
return results
}
func directoryKey(path, cwd string) string {
if path == "" {
return ""
}
if !filepath.IsAbs(path) {
path = filepath.Join(cwd, path)
}
return filepath.Clean(path)
}
func displayDirectory(path, home string) string {
path = filepath.Clean(path)
if home == "" {
return path
}
rel, err := filepath.Rel(filepath.Clean(home), path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return path
}
if rel == "." {
return "~"
}
return filepath.Join("~", rel)
}