From 0b2221d5e95adc725a59b84c8201d27eba8749c9 Mon Sep 17 00:00:00 2001 From: Kenny B Date: Sat, 1 Aug 2026 19:35:25 -0400 Subject: [PATCH] feat(cd): optionally complete from zoxide's database `cd` completes from FileGenerator, which reads one directory: the one you are standing in. So `cd proj` only ever resolves when proj happens to be a child of the current directory, and returns nothing at all otherwise -- including for directories visited constantly. That is the opposite of what cd is normally reached for, and it reads as completion being broken rather than as a scope limit. zoxide.extend-cd routes `cd` through the generator `z` already uses, so local children are offered first and zoxide's frecency database fills in everything else, reachable by name from anywhere. Off by default: it shells out to zoxide, and offering absolute paths from outside the current directory is a real change in what `cd` proposes. The query is also cached for two seconds. Generators run on every keystroke and `zoxide query -l` is a subprocess costing ~11ms here, which is the whole redraw budget; the list only changes when the user changes directory, so the window costs nothing. This applies to `z` and `zi` too, which were paying it per keystroke already. --- commands/fs/cd.go | 9 ++++++ commands/fs/zoxide.go | 56 +++++++++++++++++++++++++++++-------- internal/config/config.go | 16 +++++++++++ internal/config/defaults.go | 3 ++ 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/commands/fs/cd.go b/commands/fs/cd.go index 85123d0..ca755dd 100644 --- a/commands/fs/cd.go +++ b/commands/fs/cd.go @@ -3,6 +3,7 @@ package fs import ( "strings" + "github.com/versenilvis/iris/internal/config" "github.com/versenilvis/iris/spec" ) @@ -12,6 +13,14 @@ 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. + if config.Get().Zoxide.ExtendCd { + return ZoxideGenerator()(tokens, prefix, partial) + } fullQuery := strings.Join(tokens[1:], " ") return spec.FileGenerator("/")(tokens, prefix, fullQuery) }, diff --git a/commands/fs/zoxide.go b/commands/fs/zoxide.go index b0d67ca..4a29ef5 100644 --- a/commands/fs/zoxide.go +++ b/commands/fs/zoxide.go @@ -10,6 +10,8 @@ import ( "os" "os/exec" "strings" + "sync" + "time" "github.com/versenilvis/fuzzy" "github.com/versenilvis/iris/spec" @@ -30,24 +32,56 @@ 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 +} + +// zoxideDirs returns zoxide's known directories, most frecent first. +func zoxideDirs() ([]string, error) { + zoxideCache.Lock() + defer zoxideCache.Unlock() + + if !zoxideCache.fetched.IsZero() && time.Since(zoxideCache.fetched) < zoxideCacheTTL { + return zoxideCache.dirs, zoxideCache.err + } + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + out, err := exec.CommandContext(ctx, "zoxide", "query", "-l").Output() + zoxideCache.fetched = time.Now() + zoxideCache.err = err + zoxideCache.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) + } + } + return zoxideCache.dirs, nil +} + func ZoxideGenerator() 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 - cmd := exec.CommandContext(context.Background(), "zoxide", "query", "-l") - out, err := cmd.Output() + dirs, err := zoxideDirs() if err == nil { - lines := strings.Split(string(bytes.TrimSpace(out)), "\n") - var dirs []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" { - dirs = append(dirs, line) - } - } - home, _ := os.UserHomeDir() if fullQuery == "" { diff --git a/internal/config/config.go b/internal/config/config.go index d3b91d8..0db1300 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -78,6 +78,21 @@ type KeybindingsConfig struct { NavigateRight string `toml:"navigate-right"` } +type ZoxideConfig struct { + // ExtendCd adds zoxide's frecency database to `cd` completions. + // + // Without it `cd` only offers direct children of the current directory, + // because that is all the file generator walks -- so `cd proj` finds + // nothing unless you are already standing in the parent. With it, any + // directory zoxide has seen is reachable by name from anywhere, which is + // what `cd` is usually wanted for. + // + // Off by default: it shells out to zoxide, and the suggestions are absolute + // paths from outside the current directory, which is a real change in what + // `cd` offers. + ExtendCd bool `toml:"extend-cd"` +} + type SuggestOnEmptyConfig struct { Enabled bool `toml:"enabled"` DebounceMS int `toml:"debounce_ms"` @@ -127,6 +142,7 @@ type Config struct { Git GitConfig `toml:"git"` Updater UpdaterConfig `toml:"updater"` AI AIConfig `toml:"ai"` + Zoxide ZoxideConfig `toml:"zoxide"` Keybindings KeybindingsConfig `toml:"keybindings"` } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 695c3e4..6f302df 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -47,6 +47,9 @@ func DefaultConfig() *Config { MinIntervalMS: 5000, }, }, + Zoxide: ZoxideConfig{ + ExtendCd: false, + }, Keybindings: KeybindingsConfig{ ToggleMode: "ctrl+r", ToggleMenu: "shift+tab",