fix(cd): harden zoxide directory suggestions

This commit is contained in:
2026-08-14 13:11:21 +03:00
parent 0b2221d5e9
commit 11fad73ef5
12 changed files with 436 additions and 136 deletions
+2 -5
View File
@@ -13,11 +13,8 @@ func init() {
Description: "change directory",
MaxArgs: 0,
Generator: func(tokens []string, prefix string, partial string) []spec.Suggestion {
// The file generator only reads the current directory, so `cd proj`
// comes back empty unless its parent is where you already are --
// which is not how cd is typically used. zoxide.extend-cd folds in
// the frecency database that `z` already queries, making any
// directory zoxide has seen reachable by name from anywhere.
// Extend cd with zoxide's frecency database while keeping local
// filesystem suggestions first.
if config.Get().Zoxide.ExtendCd {
return ZoxideGenerator()(tokens, prefix, partial)
}
+171 -72
View File
@@ -1,14 +1,10 @@
// please note that zoxide also shows external suggestions
// at the end of the list on command mode
// they are the old directories that you have visited
// this is a feature, not a bug, and I want to keep it
package fs
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
@@ -17,6 +13,24 @@ import (
"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",
@@ -32,94 +46,179 @@ func init() {
})
}
// zoxideCacheTTL bounds how stale the directory list may be. Generators run on
// every keystroke and `zoxide query -l` is a subprocess costing on the order of
// ten milliseconds, which is the entire latency budget for a suggestion redraw.
// The list only changes when the user changes directory, so a short window
// costs nothing in practice.
const zoxideCacheTTL = 2 * time.Second
var zoxideCache struct {
sync.Mutex
dirs []string
err error
fetched time.Time
func newZoxideSource(query zoxideQueryFunc) *zoxideSource {
return &zoxideSource{query: query, now: time.Now}
}
// zoxideDirs returns zoxide's known directories, most frecent first.
func zoxideDirs() ([]string, error) {
zoxideCache.Lock()
defer zoxideCache.Unlock()
var defaultZoxideSource = newZoxideSource(func(ctx context.Context) ([]byte, error) {
return exec.CommandContext(ctx, "zoxide", "query", "-l").Output()
})
if !zoxideCache.fetched.IsZero() && time.Since(zoxideCache.fetched) < zoxideCacheTTL {
return zoxideCache.dirs, zoxideCache.err
// 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(), 500*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), zoxideQueryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "zoxide", "query", "-l").Output()
zoxideCache.fetched = time.Now()
zoxideCache.err = err
zoxideCache.dirs = nil
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(bytes.TrimSpace(out)), "\n") {
if line = strings.TrimSpace(line); line != "" {
zoxideCache.dirs = append(zoxideCache.dirs, line)
for line := range strings.SplitSeq(string(out), "\n") {
line = strings.TrimSuffix(line, "\r")
if line != "" {
s.dirs = append(s.dirs, line)
}
}
return zoxideCache.dirs, nil
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:], " ")
localSuggestions := spec.FileGenerator("/")(tokens, prefix, fullQuery)
var zoxideSuggestions []spec.Suggestion
dirs, err := zoxideDirs()
if err == nil {
home, _ := os.UserHomeDir()
if fullQuery == "" {
limit := min(len(dirs), 20)
for i := range limit {
path := dirs[i]
display := strings.Replace(path, home, "~", 1)
zoxideSuggestions = append(zoxideSuggestions, spec.Suggestion{
Cmd: path,
Desc: display,
})
}
} else if !strings.Contains(fullQuery, "/") {
searcher := fuzzy.NewPlainSearcher(dirs)
matches := searcher.SearchWithScores(fullQuery, &fuzzy.SearchOptions{Limit: 10})
for _, m := range matches {
path := m.Str
display := strings.Replace(path, home, "~", 1)
zoxideSuggestions = append(zoxideSuggestions, spec.Suggestion{
Cmd: path,
Desc: display,
})
}
}
local := spec.FileGenerator("/")(tokens, prefix, fullQuery)
if strings.ContainsAny(fullQuery, `/\`) {
return local
}
var finalResults []spec.Suggestion
seen := make(map[string]bool)
finalResults = append(finalResults, localSuggestions...)
for _, s := range zoxideSuggestions {
if !seen[s.Cmd] {
finalResults = append(finalResults, s)
seen[s.Cmd] = true
}
dirs, err := directorySource()
if err != nil {
return local
}
return finalResults
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)
}
+176
View File
@@ -0,0 +1,176 @@
package fs
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/versenilvis/iris/spec"
)
func TestZoxideSourceCachesDirectoriesAndErrors(t *testing.T) {
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
calls := 0
source := newZoxideSource(func(context.Context) ([]byte, error) {
calls++
return []byte("/tmp/Project One \n/tmp/Проект\r\n"), nil
})
source.now = func() time.Time { return now }
want := []string{"/tmp/Project One ", "/tmp/Проект"}
for range 2 {
got, err := source.directories()
if err != nil {
t.Fatalf("directories returned an error: %v", err)
}
if !slices.Equal(got, want) {
t.Fatalf("directories = %q, want %q", got, want)
}
}
if calls != 1 {
t.Fatalf("query calls = %d, want 1", calls)
}
now = now.Add(zoxideCacheTTL)
if _, err := source.directories(); err != nil {
t.Fatalf("directories after cache expiry returned an error: %v", err)
}
if calls != 2 {
t.Fatalf("query calls after cache expiry = %d, want 2", calls)
}
wantErr := errors.New("zoxide unavailable")
errorCalls := 0
errorSource := newZoxideSource(func(context.Context) ([]byte, error) {
errorCalls++
return nil, wantErr
})
if _, err := errorSource.directories(); !errors.Is(err, wantErr) {
t.Fatalf("directories error = %v, want %v", err, wantErr)
}
if _, err := errorSource.directories(); !errors.Is(err, wantErr) {
t.Fatalf("cached directories error = %v, want %v", err, wantErr)
}
if errorCalls != 1 {
t.Fatalf("failed query calls = %d, want 1", errorCalls)
}
}
func TestZoxideGeneratorMergesValidDirectories(t *testing.T) {
cwd := t.TempDir()
local := filepath.Join(cwd, "project")
external := filepath.Join(t.TempDir(), "My Проект")
missing := filepath.Join(t.TempDir(), "deleted")
mustMkdirAll(t, local)
mustMkdirAll(t, external)
previousCWD := spec.GetCWD()
spec.SetCWD(cwd)
t.Cleanup(func() { spec.SetCWD(previousCWD) })
gen := newZoxideGenerator(func() ([]string, error) {
return []string{local, external, external, missing}, nil
})
results := gen([]string{"z", ""}, "z ", "")
if len(results) != 2 {
t.Fatalf("suggestion count = %d, want 2: %#v", len(results), results)
}
if results[0].Cmd != "project/" {
t.Fatalf("first suggestion = %q, want local project/", results[0].Cmd)
}
if results[1].Cmd != external {
t.Fatalf("second suggestion = %q, want %q", results[1].Cmd, external)
}
if results[1].Priority != 40 {
t.Fatalf("external priority = %d, want 40", results[1].Priority)
}
}
func TestZoxideGeneratorFiltersAndFallsBack(t *testing.T) {
cwd := t.TempDir()
nested := filepath.Join(cwd, "nested", "child")
mustMkdirAll(t, nested)
previousCWD := spec.GetCWD()
spec.SetCWD(cwd)
t.Cleanup(func() { spec.SetCWD(previousCWD) })
calls := 0
gen := newZoxideGenerator(func() ([]string, error) {
calls++
return nil, errors.New("zoxide unavailable")
})
results := gen([]string{"z", "nested/ch"}, "z ", "nested/ch")
if calls != 0 {
t.Fatalf("source calls for explicit path = %d, want 0", calls)
}
if len(results) != 1 || results[0].Cmd != "nested/child/" {
t.Fatalf("explicit path suggestions = %#v", results)
}
results = gen([]string{"z", "n"}, "z ", "n")
if calls != 1 {
t.Fatalf("source calls after name query = %d, want 1", calls)
}
if len(results) != 1 || results[0].Cmd != "nested/" {
t.Fatalf("fallback suggestions = %#v", results)
}
empty := newZoxideGenerator(func() ([]string, error) { return nil, nil })
if results := empty([]string{"z", "missing"}, "z ", "missing"); len(results) != 0 {
t.Fatalf("empty database suggestions = %#v, want none", results)
}
}
func TestZoxideGeneratorFuzzyMatchesUnicodeAndLookupQuotesSpaces(t *testing.T) {
cwd := t.TempDir()
target := filepath.Join(t.TempDir(), "My Awesome $Проект")
mustMkdirAll(t, target)
previousCWD := spec.GetCWD()
spec.SetCWD(cwd)
t.Cleanup(func() { spec.SetCWD(previousCWD) })
const command = "zoxide-test-command"
spec.Register(&spec.Spec{
Name: command,
MaxArgs: 0,
Generator: newZoxideGenerator(func() ([]string, error) { return []string{target}, nil }),
})
t.Cleanup(func() { delete(spec.Registry, command) })
results := spec.Lookup(command + " My Awe")
want := command + ` '` + target + `'`
if len(results) != 1 || results[0].Cmd != want {
t.Fatalf("lookup suggestions = %#v, want command %q", results, want)
}
}
func TestDisplayDirectoryOnlyShortensHomePrefix(t *testing.T) {
home := filepath.Join(string(os.PathSeparator), "Users", "example")
inside := filepath.Join(home, "Developer", "Проект")
outside := filepath.Join(string(os.PathSeparator), "tmp", "Users", "example", "data")
if got := displayDirectory(home, home); got != "~" {
t.Fatalf("displayDirectory(home) = %q, want ~", got)
}
if got := displayDirectory(inside, home); got != filepath.Join("~", "Developer", "Проект") {
t.Fatalf("displayDirectory(inside) = %q", got)
}
if got := displayDirectory(outside, home); got != outside {
t.Fatalf("displayDirectory(outside) = %q, want %q", got, outside)
}
}
func mustMkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("create directory %q: %v", path, err)
}
}