Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1babd32f32
|
||
|
|
669b73dbda
|
||
|
|
e897b4a89a
|
@@ -327,6 +327,9 @@ deduplicate-branches = true # merge same-name local/remote branches
|
||||
[zoxide]
|
||||
extend-cd = false # include zoxide's frecent directories in cd suggestions
|
||||
|
||||
[history]
|
||||
scope = "global" # "global", "directory", "workspace", or "hybrid"
|
||||
|
||||
[updater]
|
||||
check-on-startup = true # check for updates on startup
|
||||
channel = "stable" # "stable" or "nightly"
|
||||
|
||||
+222
-16
@@ -9,11 +9,13 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/versenilvis/fuzzy"
|
||||
"github.com/versenilvis/iris/integration/shell"
|
||||
"github.com/versenilvis/iris/internal/config"
|
||||
"github.com/versenilvis/iris/internal/workspace"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -29,10 +31,20 @@ var (
|
||||
lastModTime int64
|
||||
|
||||
atuinCmds []string
|
||||
atuinEntries []atuinEntry
|
||||
atuinLastMod int64
|
||||
lastAtuinMode int = -1
|
||||
)
|
||||
|
||||
type atuinEntry struct {
|
||||
Command string
|
||||
CWD string
|
||||
Timestamp int64
|
||||
Exit int
|
||||
Session string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
func RecordSessionCommand(cmd string) {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" {
|
||||
@@ -79,7 +91,7 @@ func sanitizeUTF8(s string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func loadAtuinCmds() ([]string, error) {
|
||||
func loadAtuinEntries() ([]atuinEntry, error) {
|
||||
dbPath, err := config.AtuinDBPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -89,36 +101,69 @@ func loadAtuinCmds() ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var cmds []string
|
||||
var entries []atuinEntry
|
||||
for rows.Next() {
|
||||
var cmd string
|
||||
if err := rows.Scan(&cmd); err != nil {
|
||||
var entry atuinEntry
|
||||
if err := rows.Scan(
|
||||
&entry.Command,
|
||||
&entry.CWD,
|
||||
&entry.Timestamp,
|
||||
&entry.Exit,
|
||||
&entry.Session,
|
||||
&entry.Hostname,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
cmd = strings.TrimSpace(sanitizeUTF8(cmd))
|
||||
cmd = strings.ReplaceAll(cmd, "\n", " ")
|
||||
cmd = strings.ReplaceAll(cmd, "\r", "")
|
||||
if cmd != "" && !seen[cmd] {
|
||||
seen[cmd] = true
|
||||
cmds = append(cmds, cmd)
|
||||
entry.Command = strings.TrimSpace(sanitizeUTF8(entry.Command))
|
||||
entry.Command = strings.ReplaceAll(entry.Command, "\n", " ")
|
||||
entry.Command = strings.ReplaceAll(entry.Command, "\r", "")
|
||||
entry.CWD = sanitizeUTF8(entry.CWD)
|
||||
if entry.Command != "" {
|
||||
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 {
|
||||
cmds[i], cmds[j] = cmds[j], cmds[i]
|
||||
}
|
||||
return cmds, rows.Err()
|
||||
return cmds
|
||||
}
|
||||
|
||||
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()
|
||||
defer mu.Unlock()
|
||||
|
||||
@@ -159,11 +204,13 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
|
||||
if mod != atuinLastMod {
|
||||
atuinLastMod = mod
|
||||
atuinCmds = nil
|
||||
atuinEntries = nil
|
||||
historyCache = 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, 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/versenilvis/iris/internal/config"
|
||||
)
|
||||
|
||||
func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
|
||||
originalConfig := config.Get()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Core.Atuin = 2
|
||||
cfg.Core.AtuinDBPath = "/tmp/does-not-exist.db"
|
||||
@@ -19,11 +21,16 @@ func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
|
||||
mu.Lock()
|
||||
origHistoryCache := historyCache
|
||||
origAtuinCmds := atuinCmds
|
||||
origAtuinEntries := atuinEntries
|
||||
origAtuinLastMod := atuinLastMod
|
||||
origLastAtuinMode := lastAtuinMode
|
||||
historyCache = nil
|
||||
atuinCmds = []string{"git push", "ls -l"}
|
||||
mu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
config.Init(originalConfig)
|
||||
|
||||
sessionHistoryMu.Lock()
|
||||
sessionHistory = origSessionHistory
|
||||
sessionHistoryMu.Unlock()
|
||||
@@ -31,6 +38,9 @@ func TestSearchHistory_AtuinSourceMapping(t *testing.T) {
|
||||
mu.Lock()
|
||||
historyCache = origHistoryCache
|
||||
atuinCmds = origAtuinCmds
|
||||
atuinEntries = origAtuinEntries
|
||||
atuinLastMod = origAtuinLastMod
|
||||
lastAtuinMode = origLastAtuinMode
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ type ZoxideConfig struct {
|
||||
ExtendCd bool `toml:"extend-cd"`
|
||||
}
|
||||
|
||||
type HistoryConfig struct {
|
||||
// Scope controls Atuin filtering for non-empty history searches.
|
||||
Scope string `toml:"scope"`
|
||||
}
|
||||
|
||||
type SuggestOnEmptyConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
DebounceMS int `toml:"debounce_ms"`
|
||||
@@ -130,6 +135,7 @@ type Config struct {
|
||||
Core CoreConfig `toml:"core"`
|
||||
UI UIConfig `toml:"ui"`
|
||||
Git GitConfig `toml:"git"`
|
||||
History HistoryConfig `toml:"history"`
|
||||
Updater UpdaterConfig `toml:"updater"`
|
||||
AI AIConfig `toml:"ai"`
|
||||
Zoxide ZoxideConfig `toml:"zoxide"`
|
||||
@@ -288,6 +294,11 @@ func validate(cfg *Config) error {
|
||||
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}
|
||||
if !validChannels[cfg.Updater.Channel] {
|
||||
return fmt.Errorf("updater.channel: invalid value %q (want: stable|nightly)", cfg.Updater.Channel)
|
||||
|
||||
@@ -33,6 +33,9 @@ func TestDefaultConfigAndState(t *testing.T) {
|
||||
if cfg.Zoxide.ExtendCd {
|
||||
t.Errorf("expected zoxide cd extension to be disabled by default")
|
||||
}
|
||||
if cfg.History.Scope != "global" {
|
||||
t.Errorf("expected global history scope, got %q", cfg.History.Scope)
|
||||
}
|
||||
|
||||
// test manual provider registration
|
||||
cfg.AI.Provider = "custom"
|
||||
@@ -210,6 +213,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) {
|
||||
tmpDir, err := os.MkdirTemp("", "iris-config-test")
|
||||
if err != nil {
|
||||
|
||||
@@ -29,6 +29,9 @@ func DefaultConfig() *Config {
|
||||
FilterActiveBranch: true,
|
||||
DeduplicateBranches: true,
|
||||
},
|
||||
History: HistoryConfig{
|
||||
Scope: "global",
|
||||
},
|
||||
Updater: UpdaterConfig{
|
||||
CheckOnStartup: true,
|
||||
Channel: "stable",
|
||||
|
||||
@@ -108,6 +108,21 @@ func detectGitInfo(cwd string) (hasGit bool, branch string) {
|
||||
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 {
|
||||
key string // cwd + "|" + dirModTime
|
||||
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) {
|
||||
tmp := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(tmp, "package.json"), []byte("{}"), 0644)
|
||||
|
||||
@@ -101,6 +101,10 @@ deduplicate-branches = true
|
||||
# include zoxide directories in cd suggestions
|
||||
extend-cd = false
|
||||
|
||||
[history]
|
||||
# atuin scope for typed queries: "global", "directory", "workspace", or "hybrid"
|
||||
scope = "global"
|
||||
|
||||
[updater]
|
||||
# check for updates on startup
|
||||
check-on-startup = true
|
||||
|
||||
@@ -40,6 +40,9 @@ func TestConfigCommands(t *testing.T) {
|
||||
if !strings.Contains(string(content), "extend-cd = false") {
|
||||
t.Error("expected initialized config to include extend-cd = false")
|
||||
}
|
||||
if !strings.Contains(string(content), "scope = \"global\"") {
|
||||
t.Error("expected initialized config to include global history scope")
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
ConfigShowCmd.SetOut(buf)
|
||||
|
||||
+16
-2
@@ -35,7 +35,7 @@ fi
|
||||
# prompt to complete, and exec'ing here would seize the tty from the real iris
|
||||
if [[ -o interactive ]] && [ -t 0 ] && [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then
|
||||
export IRIS_ACTIVE_SHELL="zsh"
|
||||
exec iris
|
||||
exec "${IRIS_BIN:-iris}"
|
||||
fi
|
||||
|
||||
# Iris Autocomplete Hook
|
||||
@@ -80,7 +80,7 @@ fi
|
||||
# prompt to complete, and exec'ing here would seize the tty from the real iris
|
||||
if [[ $- == *i* ]] && [ -t 0 ] && [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then
|
||||
export IRIS_ACTIVE_SHELL="bash"
|
||||
exec iris
|
||||
exec "${IRIS_BIN:-iris}"
|
||||
fi
|
||||
|
||||
# Iris Autocomplete Hook
|
||||
@@ -115,7 +115,11 @@ end
|
||||
|
||||
if status is-interactive; and not set -q IRIS_PID; and not set -q IRIS_RESCUE
|
||||
set -gx IRIS_ACTIVE_SHELL "fish"
|
||||
if set -q IRIS_BIN
|
||||
exec "$IRIS_BIN"
|
||||
else
|
||||
exec iris
|
||||
end
|
||||
end
|
||||
|
||||
# Iris Autocomplete Hook
|
||||
@@ -247,6 +251,12 @@ expand-alias = true
|
||||
# automatically execute command after accepting suggestion
|
||||
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]
|
||||
# visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons)
|
||||
style = "modern"
|
||||
@@ -280,6 +290,10 @@ deduplicate-branches = true
|
||||
# include zoxide directories in cd suggestions
|
||||
extend-cd = false
|
||||
|
||||
[history]
|
||||
# atuin scope for typed queries: "global", "directory", "workspace", or "hybrid"
|
||||
scope = "global"
|
||||
|
||||
[updater]
|
||||
# check for updates on startup
|
||||
check-on-startup = true
|
||||
|
||||
+16
-1
@@ -41,7 +41,11 @@ func TestInitAutostartRequiresInteractiveShell(t *testing.T) {
|
||||
t.Run(shell, func(t *testing.T) {
|
||||
script := captureInitScript(t, shell)
|
||||
|
||||
head, _, found := strings.Cut(script, "exec iris")
|
||||
autostart := `exec "${IRIS_BIN:-iris}"`
|
||||
if shell == "fish" {
|
||||
autostart = `exec "$IRIS_BIN"`
|
||||
}
|
||||
head, _, found := strings.Cut(script, autostart)
|
||||
if !found {
|
||||
t.Fatalf("%s init script has no autostart", shell)
|
||||
}
|
||||
@@ -57,3 +61,14 @@ func TestInitAutostartRequiresInteractiveShell(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitAutostartSupportsBinaryOverride(t *testing.T) {
|
||||
for _, shell := range []string{"zsh", "bash", "fish"} {
|
||||
t.Run(shell, func(t *testing.T) {
|
||||
script := captureInitScript(t, shell)
|
||||
if !strings.Contains(script, "IRIS_BIN") {
|
||||
t.Fatalf("%s init script does not support IRIS_BIN", shell)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ func MergeResults(query string, mode string) []spec.Suggestion {
|
||||
|
||||
if mode == "history" {
|
||||
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
|
||||
baseConf := 75
|
||||
|
||||
Reference in New Issue
Block a user