Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e897b4a89a
|
@@ -324,8 +324,8 @@ navigate-right = "right" # accept ghost text
|
|||||||
filter-active-branch = true # exclude current branch from suggestions
|
filter-active-branch = true # exclude current branch from suggestions
|
||||||
deduplicate-branches = true # merge same-name local/remote branches
|
deduplicate-branches = true # merge same-name local/remote branches
|
||||||
|
|
||||||
[zoxide]
|
[history]
|
||||||
extend-cd = false # include zoxide's frecent directories in cd suggestions
|
scope = "global" # "global", "directory", "workspace", or "hybrid"
|
||||||
|
|
||||||
[updater]
|
[updater]
|
||||||
check-on-startup = true # check for updates on startup
|
check-on-startup = true # check for updates on startup
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package fs
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/versenilvis/iris/internal/config"
|
|
||||||
"github.com/versenilvis/iris/spec"
|
"github.com/versenilvis/iris/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,11 +12,6 @@ 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 {
|
||||||
// Extend cd with zoxide's frecency database while keeping local
|
|
||||||
// filesystem suggestions first.
|
|
||||||
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)
|
||||||
},
|
},
|
||||||
|
|||||||
+43
-176
@@ -1,36 +1,20 @@
|
|||||||
|
// 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
|
package fs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/versenilvis/fuzzy"
|
"github.com/versenilvis/fuzzy"
|
||||||
"github.com/versenilvis/iris/spec"
|
"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() {
|
func init() {
|
||||||
spec.Register(&spec.Spec{
|
spec.Register(&spec.Spec{
|
||||||
Name: "z",
|
Name: "z",
|
||||||
@@ -46,179 +30,62 @@ func init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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 {
|
return func(tokens []string, prefix string, partial string) []spec.Suggestion {
|
||||||
fullQuery := strings.Join(tokens[1:], " ")
|
fullQuery := strings.Join(tokens[1:], " ")
|
||||||
local := spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
localSuggestions := spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
||||||
if strings.ContainsAny(fullQuery, `/\`) {
|
|
||||||
return local
|
|
||||||
}
|
|
||||||
|
|
||||||
dirs, err := directorySource()
|
var zoxideSuggestions []spec.Suggestion
|
||||||
if err != nil {
|
cmd := exec.CommandContext(context.Background(), "zoxide", "query", "-l")
|
||||||
return local
|
out, err := cmd.Output()
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
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 fullQuery == "" {
|
||||||
if err != nil || !info.IsDir() {
|
limit := min(len(dirs), 20)
|
||||||
continue
|
for i := range limit {
|
||||||
}
|
path := dirs[i]
|
||||||
|
display := strings.Replace(path, home, "~", 1)
|
||||||
results = append(results, spec.Suggestion{
|
zoxideSuggestions = append(zoxideSuggestions, spec.Suggestion{
|
||||||
Cmd: path,
|
Cmd: path,
|
||||||
Desc: displayDirectory(path, home),
|
Desc: display,
|
||||||
Priority: 40,
|
|
||||||
})
|
})
|
||||||
if len(results) == limit {
|
}
|
||||||
break
|
} 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
var finalResults []spec.Suggestion
|
||||||
}
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
func matchedZoxideDirectories(dirs []string, query string) []string {
|
finalResults = append(finalResults, localSuggestions...)
|
||||||
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 _, s := range zoxideSuggestions {
|
||||||
for _, match := range matches {
|
if !seen[s.Cmd] {
|
||||||
if _, ok := seen[match.Str]; ok {
|
finalResults = append(finalResults, s)
|
||||||
continue
|
seen[s.Cmd] = true
|
||||||
}
|
}
|
||||||
results = append(results, match.Str)
|
|
||||||
seen[match.Str] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return finalResults
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+222
-16
@@ -9,11 +9,13 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/versenilvis/fuzzy"
|
"github.com/versenilvis/fuzzy"
|
||||||
"github.com/versenilvis/iris/integration/shell"
|
"github.com/versenilvis/iris/integration/shell"
|
||||||
"github.com/versenilvis/iris/internal/config"
|
"github.com/versenilvis/iris/internal/config"
|
||||||
|
"github.com/versenilvis/iris/internal/workspace"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,10 +31,20 @@ var (
|
|||||||
lastModTime int64
|
lastModTime int64
|
||||||
|
|
||||||
atuinCmds []string
|
atuinCmds []string
|
||||||
|
atuinEntries []atuinEntry
|
||||||
atuinLastMod int64
|
atuinLastMod int64
|
||||||
lastAtuinMode int = -1
|
lastAtuinMode int = -1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type atuinEntry struct {
|
||||||
|
Command string
|
||||||
|
CWD string
|
||||||
|
Timestamp int64
|
||||||
|
Exit int
|
||||||
|
Session string
|
||||||
|
Hostname string
|
||||||
|
}
|
||||||
|
|
||||||
func RecordSessionCommand(cmd string) {
|
func RecordSessionCommand(cmd string) {
|
||||||
cmd = strings.TrimSpace(cmd)
|
cmd = strings.TrimSpace(cmd)
|
||||||
if cmd == "" {
|
if cmd == "" {
|
||||||
@@ -79,7 +91,7 @@ func sanitizeUTF8(s string) string {
|
|||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAtuinCmds() ([]string, error) {
|
func loadAtuinEntries() ([]atuinEntry, error) {
|
||||||
dbPath, err := config.AtuinDBPath()
|
dbPath, err := config.AtuinDBPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -89,36 +101,69 @@ func loadAtuinCmds() ([]string, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
rows, err := db.QueryContext(context.Background(), `SELECT command FROM history WHERE deleted_at IS NULL ORDER BY timestamp DESC LIMIT 10000`)
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT command, cwd, timestamp, exit, session, hostname
|
||||||
|
FROM history
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 10000`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
var entries []atuinEntry
|
||||||
var cmds []string
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cmd string
|
var entry atuinEntry
|
||||||
if err := rows.Scan(&cmd); err != nil {
|
if err := rows.Scan(
|
||||||
|
&entry.Command,
|
||||||
|
&entry.CWD,
|
||||||
|
&entry.Timestamp,
|
||||||
|
&entry.Exit,
|
||||||
|
&entry.Session,
|
||||||
|
&entry.Hostname,
|
||||||
|
); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cmd = strings.TrimSpace(sanitizeUTF8(cmd))
|
entry.Command = strings.TrimSpace(sanitizeUTF8(entry.Command))
|
||||||
cmd = strings.ReplaceAll(cmd, "\n", " ")
|
entry.Command = strings.ReplaceAll(entry.Command, "\n", " ")
|
||||||
cmd = strings.ReplaceAll(cmd, "\r", "")
|
entry.Command = strings.ReplaceAll(entry.Command, "\r", "")
|
||||||
if cmd != "" && !seen[cmd] {
|
entry.CWD = sanitizeUTF8(entry.CWD)
|
||||||
seen[cmd] = true
|
if entry.Command != "" {
|
||||||
cmds = append(cmds, cmd)
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// reverse array to be oldest-first
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func atuinCommands(entries []atuinEntry) []string {
|
||||||
|
seen := make(map[string]bool, len(entries))
|
||||||
|
cmds := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !seen[entry.Command] {
|
||||||
|
seen[entry.Command] = true
|
||||||
|
cmds = append(cmds, entry.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing history merge expects oldest-first input.
|
||||||
for i, j := 0, len(cmds)-1; i < j; i, j = i+1, j-1 {
|
for i, j := 0, len(cmds)-1; i < j; i, j = i+1, j-1 {
|
||||||
cmds[i], cmds[j] = cmds[j], cmds[i]
|
cmds[i], cmds[j] = cmds[j], cmds[i]
|
||||||
}
|
}
|
||||||
return cmds, rows.Err()
|
return cmds
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) {
|
func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) {
|
||||||
|
return SearchHistoryIn(query, aliases, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchHistoryIn applies configured Atuin context to a non-empty query.
|
||||||
|
func SearchHistoryIn(query string, aliases map[string]string, cwd string) ([]HistResult, error) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
@@ -159,11 +204,13 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
|
|||||||
if mod != atuinLastMod {
|
if mod != atuinLastMod {
|
||||||
atuinLastMod = mod
|
atuinLastMod = mod
|
||||||
atuinCmds = nil
|
atuinCmds = nil
|
||||||
|
atuinEntries = nil
|
||||||
historyCache = nil
|
historyCache = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if atuinCmds == nil {
|
if atuinCmds == nil {
|
||||||
atuinCmds, _ = loadAtuinCmds()
|
atuinEntries, _ = loadAtuinEntries()
|
||||||
|
atuinCmds = atuinCommands(atuinEntries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,5 +486,164 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
|
|||||||
return results[i].ID > results[j].ID
|
return results[i].ID > results[j].ID
|
||||||
})
|
})
|
||||||
|
|
||||||
return results, nil
|
return applyAtuinScope(results, query, aliases, cwd), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAtuinScope(global []HistResult, query string, aliases map[string]string, cwd string) []HistResult {
|
||||||
|
scope := config.Get().History.Scope
|
||||||
|
if query == "" || cwd == "" || config.Get().Core.Atuin == 0 || scope == "global" {
|
||||||
|
return global
|
||||||
|
}
|
||||||
|
|
||||||
|
directory, repository := contextualAtuinCommands(atuinEntries, cwd)
|
||||||
|
queries := historyQueries(query, aliases)
|
||||||
|
directoryMatches := matchContextCommands(directory, queries)
|
||||||
|
repositoryMatches := matchContextCommands(repository, queries)
|
||||||
|
|
||||||
|
switch scope {
|
||||||
|
case "directory":
|
||||||
|
return directoryMatches
|
||||||
|
case "workspace":
|
||||||
|
if workspace.GitRoot(cwd) == "" {
|
||||||
|
return directoryMatches
|
||||||
|
}
|
||||||
|
return mergeHistoryResults(directoryMatches, repositoryMatches)
|
||||||
|
case "hybrid":
|
||||||
|
contextual := mergeHistoryResults(directoryMatches, repositoryMatches)
|
||||||
|
return mergeHistoryResults(contextual, global)
|
||||||
|
default:
|
||||||
|
return global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextualAtuinCommands(entries []atuinEntry, cwd string) (directory, repository []string) {
|
||||||
|
cwd = filepath.Clean(cwd)
|
||||||
|
root := workspace.GitRoot(cwd)
|
||||||
|
seenDirectory := make(map[string]bool)
|
||||||
|
seenRepository := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryCWD := filepath.Clean(entry.CWD)
|
||||||
|
if entry.CWD == "" || entry.CWD == "unknown" || !filepath.IsAbs(entryCWD) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entryCWD == cwd {
|
||||||
|
if !seenDirectory[entry.Command] {
|
||||||
|
directory = append(directory, entry.Command)
|
||||||
|
seenDirectory[entry.Command] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if root != "" && pathWithin(root, entryCWD) && !seenRepository[entry.Command] {
|
||||||
|
repository = append(repository, entry.Command)
|
||||||
|
seenRepository[entry.Command] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory, repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathWithin(root, path string) bool {
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyQueries(query string, aliases map[string]string) []string {
|
||||||
|
queries := []string{query}
|
||||||
|
qLow := strings.ToLower(query)
|
||||||
|
for name, target := range aliases {
|
||||||
|
if target == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tLow := strings.ToLower(target)
|
||||||
|
nLow := strings.ToLower(name)
|
||||||
|
if qLow == tLow {
|
||||||
|
queries = append(queries, name)
|
||||||
|
} else if strings.HasPrefix(qLow, tLow+" ") {
|
||||||
|
queries = append(queries, name+query[len(target):])
|
||||||
|
}
|
||||||
|
if qLow == nLow {
|
||||||
|
queries = append(queries, target)
|
||||||
|
} else if strings.HasPrefix(qLow, nLow+" ") {
|
||||||
|
queries = append(queries, target+query[len(name):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchContextCommands(commands, queries []string) []HistResult {
|
||||||
|
if len(commands) == 0 || len(queries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]HistResult, 0, min(len(commands), 100))
|
||||||
|
seen := make(map[string]bool, len(commands))
|
||||||
|
for tier := 1; tier <= 3; tier++ {
|
||||||
|
for i, cmd := range commands {
|
||||||
|
if seen[cmd] || bestHistoryTier(cmd, queries) != tier {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[cmd] = true
|
||||||
|
results = append(results, HistResult{ID: len(commands) - i, Cmd: cmd, FuzzyScore: 10000, Source: "atuin"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searcher := fuzzy.NewPlainSearcher(commands)
|
||||||
|
for _, query := range queries {
|
||||||
|
matches := searcher.SearchWithScores(query, &fuzzy.SearchOptions{Limit: min(len(commands), 200)})
|
||||||
|
for _, match := range matches {
|
||||||
|
if seen[match.Str] || len(query) == 0 || match.Score/len(query) < 150 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[match.Str] = true
|
||||||
|
results = append(results, HistResult{Cmd: match.Str, FuzzyScore: match.Score, Source: "atuin"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestHistoryTier(cmd string, queries []string) int {
|
||||||
|
cmdLow := strings.ToLower(cmd)
|
||||||
|
best := 4
|
||||||
|
for _, query := range queries {
|
||||||
|
queryLow := strings.ToLower(query)
|
||||||
|
switch {
|
||||||
|
case cmdLow == queryLow:
|
||||||
|
return 1
|
||||||
|
case strings.HasPrefix(cmdLow, queryLow):
|
||||||
|
best = min(best, 2)
|
||||||
|
case containsAllWords(cmdLow, queryLow):
|
||||||
|
best = min(best, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAllWords(cmd, query string) bool {
|
||||||
|
words := strings.Fields(query)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, word := range words {
|
||||||
|
if !strings.Contains(cmd, word) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeHistoryResults(groups ...[]HistResult) []HistResult {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var results []HistResult
|
||||||
|
for _, group := range groups {
|
||||||
|
for _, result := range group {
|
||||||
|
if seen[result.Cmd] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[result.Cmd] = true
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/versenilvis/iris/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadAtuinEntriesPreservesContext(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "history.db")
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`CREATE TABLE history (
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
cwd TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
exit INTEGER NOT NULL,
|
||||||
|
session TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
deleted_at INTEGER
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows := []struct {
|
||||||
|
command, cwd, session, hostname string
|
||||||
|
timestamp, exit, deletedAt int
|
||||||
|
}{
|
||||||
|
{"old command", "/repo", "s1", "host", 10, 0, 0},
|
||||||
|
{"new\ncommand", "/repo/pkg", "s2", "host", 30, 1, 0},
|
||||||
|
{"deleted command", "/repo", "s3", "host", 40, 0, 99},
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
var deletedAt any
|
||||||
|
if row.deletedAt != 0 {
|
||||||
|
deletedAt = row.deletedAt
|
||||||
|
}
|
||||||
|
_, err = db.Exec(
|
||||||
|
"INSERT INTO history VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
row.command, row.cwd, row.timestamp, row.exit, row.session, row.hostname, deletedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalConfig := config.Get()
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Core.AtuinDBPath = dbPath
|
||||||
|
config.Init(cfg)
|
||||||
|
t.Cleanup(func() { config.Init(originalConfig) })
|
||||||
|
|
||||||
|
entries, err := loadAtuinEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load Atuin entries: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected two active entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].Command != "new command" || entries[0].CWD != "/repo/pkg" || entries[0].Exit != 1 {
|
||||||
|
t.Fatalf("unexpected newest entry: %#v", entries[0])
|
||||||
|
}
|
||||||
|
if entries[1].Command != "old command" || entries[1].Session != "s1" {
|
||||||
|
t.Fatalf("unexpected oldest entry: %#v", entries[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyAtuinScope(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
cwd := filepath.Join(repo, "internal", "pkg")
|
||||||
|
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cwd, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalConfig := config.Get()
|
||||||
|
originalEntries := atuinEntries
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.Init(originalConfig)
|
||||||
|
atuinEntries = originalEntries
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Core.Atuin = 1
|
||||||
|
config.Init(cfg)
|
||||||
|
atuinEntries = []atuinEntry{
|
||||||
|
{Command: "git status", CWD: cwd, Timestamp: 50},
|
||||||
|
{Command: "git pull", CWD: repo, Timestamp: 40},
|
||||||
|
{Command: "git status", CWD: cwd, Timestamp: 30},
|
||||||
|
{Command: "git push", CWD: filepath.Join(t.TempDir(), "other"), Timestamp: 20},
|
||||||
|
{Command: "git unknown", CWD: "unknown", Timestamp: 10},
|
||||||
|
}
|
||||||
|
global := []HistResult{
|
||||||
|
{Cmd: "git push", Source: "atuin"},
|
||||||
|
{Cmd: "git pull", Source: "atuin"},
|
||||||
|
{Cmd: "git status", Source: "atuin"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
scope string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"global", []string{"git push", "git pull", "git status"}},
|
||||||
|
{"directory", []string{"git status"}},
|
||||||
|
{"workspace", []string{"git status", "git pull"}},
|
||||||
|
{"hybrid", []string{"git status", "git pull", "git push"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.scope, func(t *testing.T) {
|
||||||
|
cfg.History.Scope = tt.scope
|
||||||
|
got := resultCommands(applyAtuinScope(global, "g", map[string]string{"g": "git"}, cwd))
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("scope %s: got %v, want %v", tt.scope, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.History.Scope = "hybrid"
|
||||||
|
if got := resultCommands(applyAtuinScope(global, "", nil, cwd)); !reflect.DeepEqual(got, resultCommands(global)) {
|
||||||
|
t.Fatalf("empty query must stay global: got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceScopeOutsideGitUsesDirectory(t *testing.T) {
|
||||||
|
cwd := t.TempDir()
|
||||||
|
originalConfig := config.Get()
|
||||||
|
originalEntries := atuinEntries
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.Init(originalConfig)
|
||||||
|
atuinEntries = originalEntries
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Core.Atuin = 1
|
||||||
|
cfg.History.Scope = "workspace"
|
||||||
|
config.Init(cfg)
|
||||||
|
atuinEntries = []atuinEntry{
|
||||||
|
{Command: "make local", CWD: cwd},
|
||||||
|
{Command: "make elsewhere", CWD: t.TempDir()},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := resultCommands(applyAtuinScope(nil, "make", nil, cwd))
|
||||||
|
if !reflect.DeepEqual(got, []string{"make local"}) {
|
||||||
|
t.Fatalf("got %v, want directory-only result", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkContextualAtuinCommands(b *testing.B) {
|
||||||
|
repo := b.TempDir()
|
||||||
|
cwd := filepath.Join(repo, "internal", "pkg")
|
||||||
|
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cwd, 0755); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
entries := make([]atuinEntry, 10000)
|
||||||
|
for i := range entries {
|
||||||
|
entryCWD := repo
|
||||||
|
if i%3 == 0 {
|
||||||
|
entryCWD = cwd
|
||||||
|
}
|
||||||
|
entries[i] = atuinEntry{Command: fmt.Sprintf("command %d", i), CWD: entryCWD}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
contextualAtuinCommands(entries, cwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkApplyAtuinScope(b *testing.B) {
|
||||||
|
repo := b.TempDir()
|
||||||
|
cwd := filepath.Join(repo, "internal", "pkg")
|
||||||
|
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cwd, 0755); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalConfig := config.Get()
|
||||||
|
originalEntries := atuinEntries
|
||||||
|
b.Cleanup(func() {
|
||||||
|
config.Init(originalConfig)
|
||||||
|
atuinEntries = originalEntries
|
||||||
|
})
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Core.Atuin = 1
|
||||||
|
cfg.History.Scope = "hybrid"
|
||||||
|
config.Init(cfg)
|
||||||
|
|
||||||
|
atuinEntries = make([]atuinEntry, 10000)
|
||||||
|
global := make([]HistResult, 10000)
|
||||||
|
for i := range atuinEntries {
|
||||||
|
entryCWD := repo
|
||||||
|
if i%3 == 0 {
|
||||||
|
entryCWD = cwd
|
||||||
|
}
|
||||||
|
command := fmt.Sprintf("command %d", i)
|
||||||
|
atuinEntries[i] = atuinEntry{Command: command, CWD: entryCWD}
|
||||||
|
global[i] = HistResult{Cmd: command, Source: "atuin"}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
applyAtuinScope(global, "command 999", nil, cwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultCommands(results []HistResult) []string {
|
||||||
|
commands := make([]string, len(results))
|
||||||
|
for i, result := range results {
|
||||||
|
commands[i] = result.Cmd
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/versenilvis/iris/internal/config"
|
"github.com/versenilvis/iris/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
|
func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
|
||||||
|
originalConfig := config.Get()
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Core.Atuin = 2
|
cfg.Core.Atuin = 2
|
||||||
cfg.Core.AtuinDBPath = "/tmp/does-not-exist.db"
|
cfg.Core.AtuinDBPath = "/tmp/does-not-exist.db"
|
||||||
@@ -19,11 +21,16 @@ func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
origHistoryCache := historyCache
|
origHistoryCache := historyCache
|
||||||
origAtuinCmds := atuinCmds
|
origAtuinCmds := atuinCmds
|
||||||
|
origAtuinEntries := atuinEntries
|
||||||
|
origAtuinLastMod := atuinLastMod
|
||||||
|
origLastAtuinMode := lastAtuinMode
|
||||||
historyCache = nil
|
historyCache = nil
|
||||||
atuinCmds = []string{"git push", "ls -l"}
|
atuinCmds = []string{"git push", "ls -l"}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
config.Init(originalConfig)
|
||||||
|
|
||||||
sessionHistoryMu.Lock()
|
sessionHistoryMu.Lock()
|
||||||
sessionHistory = origSessionHistory
|
sessionHistory = origSessionHistory
|
||||||
sessionHistoryMu.Unlock()
|
sessionHistoryMu.Unlock()
|
||||||
@@ -31,6 +38,9 @@ func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
historyCache = origHistoryCache
|
historyCache = origHistoryCache
|
||||||
atuinCmds = origAtuinCmds
|
atuinCmds = origAtuinCmds
|
||||||
|
atuinEntries = origAtuinEntries
|
||||||
|
atuinLastMod = origAtuinLastMod
|
||||||
|
lastAtuinMode = origLastAtuinMode
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ type KeybindingsConfig struct {
|
|||||||
NavigateRight string `toml:"navigate-right"`
|
NavigateRight string `toml:"navigate-right"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ZoxideConfig struct {
|
type HistoryConfig struct {
|
||||||
// ExtendCd adds zoxide's frecency database to cd completions.
|
// Scope controls Atuin filtering for non-empty history searches.
|
||||||
ExtendCd bool `toml:"extend-cd"`
|
Scope string `toml:"scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SuggestOnEmptyConfig struct {
|
type SuggestOnEmptyConfig struct {
|
||||||
@@ -130,9 +130,9 @@ type Config struct {
|
|||||||
Core CoreConfig `toml:"core"`
|
Core CoreConfig `toml:"core"`
|
||||||
UI UIConfig `toml:"ui"`
|
UI UIConfig `toml:"ui"`
|
||||||
Git GitConfig `toml:"git"`
|
Git GitConfig `toml:"git"`
|
||||||
|
History HistoryConfig `toml:"history"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +288,11 @@ func validate(cfg *Config) error {
|
|||||||
return fmt.Errorf("core.shell: invalid value %q (want: bash|zsh|fish)", cfg.Core.Shell)
|
return fmt.Errorf("core.shell: invalid value %q (want: bash|zsh|fish)", cfg.Core.Shell)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validHistoryScopes := map[string]bool{"global": true, "directory": true, "workspace": true, "hybrid": true}
|
||||||
|
if !validHistoryScopes[cfg.History.Scope] {
|
||||||
|
return fmt.Errorf("history.scope: invalid value %q (want: global|directory|workspace|hybrid)", cfg.History.Scope)
|
||||||
|
}
|
||||||
|
|
||||||
validChannels := map[string]bool{"stable": true, "nightly": true}
|
validChannels := map[string]bool{"stable": true, "nightly": true}
|
||||||
if !validChannels[cfg.Updater.Channel] {
|
if !validChannels[cfg.Updater.Channel] {
|
||||||
return fmt.Errorf("updater.channel: invalid value %q (want: stable|nightly)", cfg.Updater.Channel)
|
return fmt.Errorf("updater.channel: invalid value %q (want: stable|nightly)", cfg.Updater.Channel)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ func TestDefaultConfigAndState(t *testing.T) {
|
|||||||
if !cfg.Core.CobraProbeEnabled {
|
if !cfg.Core.CobraProbeEnabled {
|
||||||
t.Errorf("expected cobra probing to be enabled by default")
|
t.Errorf("expected cobra probing to be enabled by default")
|
||||||
}
|
}
|
||||||
if cfg.Zoxide.ExtendCd {
|
if cfg.History.Scope != "global" {
|
||||||
t.Errorf("expected zoxide cd extension to be disabled by default")
|
t.Errorf("expected global history scope, got %q", cfg.History.Scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
// test manual provider registration
|
// test manual provider registration
|
||||||
@@ -210,6 +210,21 @@ func TestValidateAutoUpdateRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateHistoryScope(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
for _, scope := range []string{"global", "directory", "workspace", "hybrid"} {
|
||||||
|
cfg.History.Scope = scope
|
||||||
|
if err := validate(cfg); err != nil {
|
||||||
|
t.Errorf("expected history scope %q to be valid: %v", scope, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.History.Scope = "session"
|
||||||
|
if err := validate(cfg); err == nil {
|
||||||
|
t.Error("expected unsupported history scope to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadSave(t *testing.T) {
|
func TestLoadSave(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "iris-config-test")
|
tmpDir, err := os.MkdirTemp("", "iris-config-test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ func DefaultConfig() *Config {
|
|||||||
FilterActiveBranch: true,
|
FilterActiveBranch: true,
|
||||||
DeduplicateBranches: true,
|
DeduplicateBranches: true,
|
||||||
},
|
},
|
||||||
|
History: HistoryConfig{
|
||||||
|
Scope: "global",
|
||||||
|
},
|
||||||
Updater: UpdaterConfig{
|
Updater: UpdaterConfig{
|
||||||
CheckOnStartup: true,
|
CheckOnStartup: true,
|
||||||
Channel: "stable",
|
Channel: "stable",
|
||||||
@@ -47,9 +50,6 @@ 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",
|
||||||
|
|||||||
@@ -108,6 +108,21 @@ func detectGitInfo(cwd string) (hasGit bool, branch string) {
|
|||||||
return hasGit, ""
|
return hasGit, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitRoot returns the repository root containing cwd, or an empty string.
|
||||||
|
func GitRoot(cwd string) string {
|
||||||
|
dir := filepath.Clean(cwd)
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
key string // cwd + "|" + dirModTime
|
key string // cwd + "|" + dirModTime
|
||||||
info WorkspaceInfo
|
info WorkspaceInfo
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ func TestDetect_EmptyDirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitRoot(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
nested := filepath.Join(repo, "internal", "pkg")
|
||||||
|
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(nested, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GitRoot(nested); got != repo {
|
||||||
|
t.Fatalf("expected git root %q, got %q", repo, got)
|
||||||
|
}
|
||||||
|
if got := GitRoot(t.TempDir()); got != "" {
|
||||||
|
t.Fatalf("expected no git root, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetect_NodeProject(t *testing.T) {
|
func TestDetect_NodeProject(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
_ = os.WriteFile(filepath.Join(tmp, "package.json"), []byte("{}"), 0644)
|
_ = os.WriteFile(filepath.Join(tmp, "package.json"), []byte("{}"), 0644)
|
||||||
|
|||||||
+3
-3
@@ -97,9 +97,9 @@ filter-active-branch = true
|
|||||||
# merge remote and local branches with same name
|
# merge remote and local branches with same name
|
||||||
deduplicate-branches = true
|
deduplicate-branches = true
|
||||||
|
|
||||||
[zoxide]
|
[history]
|
||||||
# include zoxide directories in cd suggestions
|
# atuin scope for typed queries: "global", "directory", "workspace", or "hybrid"
|
||||||
extend-cd = false
|
scope = "global"
|
||||||
|
|
||||||
[updater]
|
[updater]
|
||||||
# check for updates on startup
|
# check for updates on startup
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ func TestConfigCommands(t *testing.T) {
|
|||||||
if !strings.Contains(string(content), "shell-login = false") {
|
if !strings.Contains(string(content), "shell-login = false") {
|
||||||
t.Error("expected initialized config to include shell-login = false")
|
t.Error("expected initialized config to include shell-login = false")
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(content), "extend-cd = false") {
|
if !strings.Contains(string(content), "scope = \"global\"") {
|
||||||
t.Error("expected initialized config to include extend-cd = false")
|
t.Error("expected initialized config to include global history scope")
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
|
|||||||
+9
-3
@@ -247,6 +247,12 @@ expand-alias = true
|
|||||||
# automatically execute command after accepting suggestion
|
# automatically execute command after accepting suggestion
|
||||||
auto-execute = false
|
auto-execute = false
|
||||||
|
|
||||||
|
# 0 = off, 1 = atuin history only, 2 = atuin history + default history
|
||||||
|
atuin-history = 0
|
||||||
|
|
||||||
|
# custom atuin database path (leave empty for default)
|
||||||
|
atuin-db-path = ""
|
||||||
|
|
||||||
[ui]
|
[ui]
|
||||||
# visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons)
|
# visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons)
|
||||||
style = "modern"
|
style = "modern"
|
||||||
@@ -276,9 +282,9 @@ filter-active-branch = true
|
|||||||
# merge remote and local branches with same name
|
# merge remote and local branches with same name
|
||||||
deduplicate-branches = true
|
deduplicate-branches = true
|
||||||
|
|
||||||
[zoxide]
|
[history]
|
||||||
# include zoxide directories in cd suggestions
|
# atuin scope for typed queries: "global", "directory", "workspace", or "hybrid"
|
||||||
extend-cd = false
|
scope = "global"
|
||||||
|
|
||||||
[updater]
|
[updater]
|
||||||
# check for updates on startup
|
# check for updates on startup
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ func MergeResults(query string, mode string) []spec.Suggestion {
|
|||||||
|
|
||||||
if mode == "history" {
|
if mode == "history" {
|
||||||
aliases := spec.GetAliasesCopy()
|
aliases := spec.GetAliasesCopy()
|
||||||
histResults, _ := integration.SearchHistory(query, aliases)
|
histResults, _ := integration.SearchHistoryIn(query, aliases, spec.GetCWD())
|
||||||
|
|
||||||
// scale confidence based on recency (index in histResults) so the most recent commands stay on top
|
// scale confidence based on recency (index in histResults) so the most recent commands stay on top
|
||||||
baseConf := 75
|
baseConf := 75
|
||||||
|
|||||||
+6
-22
@@ -2,7 +2,6 @@ package spec
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -275,15 +274,15 @@ func Lookup(input string) []Suggestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suggested := g.Cmd
|
suggested := g.Cmd
|
||||||
if filepath.IsAbs(g.Cmd) || strings.ContainsAny(g.Cmd, " \t\r\n") {
|
if strings.Contains(suggested, " ") && !strings.HasPrefix(suggested, "\"") {
|
||||||
suggested = quoteSuggestionArgument(g.Cmd)
|
suggested = "\"" + suggested + "\""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Absolute results replace all typed arguments, avoiding duplication
|
// if the suggestion is a full path that includes
|
||||||
// for multi-word directory matches.
|
// words already in the command line (multi-word support), we replace
|
||||||
|
// the entire argument part by using prefix only
|
||||||
finalCmd := ""
|
finalCmd := ""
|
||||||
replaceArguments := strings.HasPrefix(g.Cmd, tokens[depth]) || filepath.IsAbs(g.Cmd)
|
if len(tokens) > depth+1 && strings.HasPrefix(g.Cmd, tokens[depth]) {
|
||||||
if len(tokens) > depth+1 && replaceArguments {
|
|
||||||
finalCmd = prefix + " " + suggested
|
finalCmd = prefix + " " + suggested
|
||||||
} else if genLinePrefix != "" {
|
} else if genLinePrefix != "" {
|
||||||
finalCmd = genLinePrefix + " " + suggested
|
finalCmd = genLinePrefix + " " + suggested
|
||||||
@@ -383,21 +382,6 @@ func Lookup(input string) []Suggestion {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func quoteSuggestionArgument(argument string) string {
|
|
||||||
prefix := ""
|
|
||||||
value := argument
|
|
||||||
if strings.HasPrefix(value, "~/") {
|
|
||||||
prefix = "~/"
|
|
||||||
value = strings.TrimPrefix(value, prefix)
|
|
||||||
}
|
|
||||||
if !strings.ContainsAny(value, " \t\r\n'\"\\$`!;&|<>()*?[]{}#^") {
|
|
||||||
return argument
|
|
||||||
}
|
|
||||||
|
|
||||||
escaped := strings.ReplaceAll(value, "'", `'\''`)
|
|
||||||
return prefix + "'" + escaped + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func topLevelSuggestions(query string, aliases map[string]string) []Suggestion {
|
func topLevelSuggestions(query string, aliases map[string]string) []Suggestion {
|
||||||
scanExternalCommands()
|
scanExternalCommands()
|
||||||
results, seen := []Suggestion{}, make(map[string]bool)
|
results, seen := []Suggestion{}, make(map[string]bool)
|
||||||
|
|||||||
+1
-17
@@ -173,23 +173,6 @@ func TestLookup_NvimFileGenerator(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteSuggestionArgument(t *testing.T) {
|
|
||||||
tests := map[string]string{
|
|
||||||
"/tmp/project": "/tmp/project",
|
|
||||||
"/tmp/Проект": "/tmp/Проект",
|
|
||||||
"/tmp/My Project": `'/tmp/My Project'`,
|
|
||||||
"/tmp/$Project`command`": "'/tmp/$Project`command`'",
|
|
||||||
`/tmp/quote"and'apostrophe`: "'/tmp/quote\"and'\\''apostrophe'",
|
|
||||||
"~/My Project": `~/'My Project'`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for input, want := range tests {
|
|
||||||
if got := quoteSuggestionArgument(input); got != want {
|
|
||||||
t.Errorf("quoteSuggestionArgument(%q) = %q, want %q", input, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLookup_OptionAndFilePriority(t *testing.T) {
|
func TestLookup_OptionAndFilePriority(t *testing.T) {
|
||||||
ResetRegistry()
|
ResetRegistry()
|
||||||
Register(&Spec{
|
Register(&Spec{
|
||||||
@@ -406,3 +389,4 @@ func TestLookup_RealGitProvider(t *testing.T) {
|
|||||||
t.Errorf("expected 'git recent' in results for real GitProvider, got %v", results)
|
t.Errorf("expected 'git recent' in results for real GitProvider, got %v", results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,58 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/versenilvis/iris/commands/fs"
|
"github.com/versenilvis/iris/commands/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestZoxideGenerator(t *testing.T) {
|
func TestZoxideGenerator(t *testing.T) {
|
||||||
dataDir := t.TempDir()
|
// Setup: Create a mock zoxide binary
|
||||||
project := filepath.Join(dataDir, "project1")
|
tmp := t.TempDir()
|
||||||
docs := filepath.Join(dataDir, "docs")
|
mockZoxide := filepath.Join(tmp, "zoxide")
|
||||||
iris := filepath.Join(dataDir, "dev", "iris")
|
|
||||||
missing := filepath.Join(dataDir, "deleted")
|
|
||||||
for _, path := range []string{project, docs, iris} {
|
|
||||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
||||||
t.Fatalf("create directory %q: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binDir := t.TempDir()
|
// Script that prints mock directories
|
||||||
mockZoxide := filepath.Join(binDir, "zoxide")
|
script := "#!/bin/sh\necho \"/home/verse/project1\n/home/verse/docs\n/home/verse/dev/iris\""
|
||||||
script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' %q %q %q %q\n", project, docs, iris, missing)
|
_ = os.WriteFile(mockZoxide, []byte(script), 0755)
|
||||||
if err := os.WriteFile(mockZoxide, []byte(script), 0o755); err != nil {
|
|
||||||
t.Fatalf("create mock zoxide: %v", err)
|
t.Setenv("PATH", tmp+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
}
|
|
||||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
|
|
||||||
gen := fs.ZoxideGenerator()
|
gen := fs.ZoxideGenerator()
|
||||||
results := gen([]string{"z", ""}, "z ", "")
|
|
||||||
if len(results) != 3 {
|
|
||||||
t.Fatalf("suggestion count = %d, want 3: %#v", len(results), results)
|
|
||||||
}
|
|
||||||
for _, result := range results {
|
|
||||||
if result.Cmd == missing {
|
|
||||||
t.Fatalf("missing directory was suggested: %#v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results = gen([]string{"z", "iris"}, "z ", "iris")
|
|
||||||
if len(results) != 1 || results[0].Cmd != iris {
|
t.Run("Query returns correct result when partial is empty", func(t *testing.T) {
|
||||||
t.Fatalf("fuzzy suggestions = %#v, want %q", results, iris)
|
results := gen([]string{"z", ""}, "z ", "")
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Errorf("Expected results from zoxide history, got 0")
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
t.Run("Path replaces home dir with ~", func(t *testing.T) {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
results := gen([]string{"z", ""}, "z ", "")
|
||||||
|
foundHome := false
|
||||||
|
for _, r := range results {
|
||||||
|
if strings.HasPrefix(r.Desc, "~") {
|
||||||
|
foundHome = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundHome && home != "" {
|
||||||
|
t.Logf("Warning: Did not find ~ in descriptions, home is %s", home)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
t.Run("Sort by descending score", func(t *testing.T) {
|
||||||
|
results := gen([]string{"z", "i"}, "z ", "i")
|
||||||
|
if len(results) >= 1 {
|
||||||
|
if results[0].Cmd == "" {
|
||||||
|
t.Errorf("Empty result command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user