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.
This commit is contained in:
@@ -3,6 +3,7 @@ package fs
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/versenilvis/iris/internal/config"
|
||||||
"github.com/versenilvis/iris/spec"
|
"github.com/versenilvis/iris/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,6 +13,14 @@ func init() {
|
|||||||
Description: "change directory",
|
Description: "change directory",
|
||||||
MaxArgs: 0,
|
MaxArgs: 0,
|
||||||
Generator: func(tokens []string, prefix string, partial string) []spec.Suggestion {
|
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:], " ")
|
fullQuery := strings.Join(tokens[1:], " ")
|
||||||
return spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
return spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
||||||
},
|
},
|
||||||
|
|||||||
+45
-11
@@ -10,6 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/versenilvis/fuzzy"
|
"github.com/versenilvis/fuzzy"
|
||||||
"github.com/versenilvis/iris/spec"
|
"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 {
|
func ZoxideGenerator() spec.GeneratorFunc {
|
||||||
return func(tokens []string, prefix string, partial string) []spec.Suggestion {
|
return func(tokens []string, prefix string, partial string) []spec.Suggestion {
|
||||||
fullQuery := strings.Join(tokens[1:], " ")
|
fullQuery := strings.Join(tokens[1:], " ")
|
||||||
localSuggestions := spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
localSuggestions := spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
||||||
|
|
||||||
var zoxideSuggestions []spec.Suggestion
|
var zoxideSuggestions []spec.Suggestion
|
||||||
cmd := exec.CommandContext(context.Background(), "zoxide", "query", "-l")
|
dirs, err := zoxideDirs()
|
||||||
out, err := cmd.Output()
|
|
||||||
if err == nil {
|
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()
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
if fullQuery == "" {
|
if fullQuery == "" {
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ type KeybindingsConfig struct {
|
|||||||
NavigateRight string `toml:"navigate-right"`
|
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 {
|
type SuggestOnEmptyConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
DebounceMS int `toml:"debounce_ms"`
|
DebounceMS int `toml:"debounce_ms"`
|
||||||
@@ -127,6 +142,7 @@ type Config struct {
|
|||||||
Git GitConfig `toml:"git"`
|
Git GitConfig `toml:"git"`
|
||||||
Updater UpdaterConfig `toml:"updater"`
|
Updater UpdaterConfig `toml:"updater"`
|
||||||
AI AIConfig `toml:"ai"`
|
AI AIConfig `toml:"ai"`
|
||||||
|
Zoxide ZoxideConfig `toml:"zoxide"`
|
||||||
Keybindings KeybindingsConfig `toml:"keybindings"`
|
Keybindings KeybindingsConfig `toml:"keybindings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func DefaultConfig() *Config {
|
|||||||
MinIntervalMS: 5000,
|
MinIntervalMS: 5000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Zoxide: ZoxideConfig{
|
||||||
|
ExtendCd: false,
|
||||||
|
},
|
||||||
Keybindings: KeybindingsConfig{
|
Keybindings: KeybindingsConfig{
|
||||||
ToggleMode: "ctrl+r",
|
ToggleMode: "ctrl+r",
|
||||||
ToggleMenu: "shift+tab",
|
ToggleMenu: "shift+tab",
|
||||||
|
|||||||
Reference in New Issue
Block a user