feat(history): add Atuin context scopes
This commit is contained in:
+231
-25
@@ -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"
|
||||
)
|
||||
|
||||
@@ -21,18 +23,28 @@ var (
|
||||
sessionHistory []string
|
||||
sessionHistoryMu sync.Mutex
|
||||
|
||||
historyCache []string
|
||||
idMapCache map[string]int
|
||||
historyCache []string
|
||||
idMapCache map[string]int
|
||||
sourceMapCache map[string]string
|
||||
searcherCache *fuzzy.Searcher
|
||||
mu sync.Mutex
|
||||
lastModTime int64
|
||||
searcherCache *fuzzy.Searcher
|
||||
mu sync.Mutex
|
||||
lastModTime int64
|
||||
|
||||
atuinCmds []string
|
||||
atuinLastMod 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,8 +340,8 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
|
||||
for i := range limit {
|
||||
cmd := historyCache[i]
|
||||
results = append(results, HistResult{
|
||||
ID: idMapCache[cmd],
|
||||
Cmd: cmd,
|
||||
ID: idMapCache[cmd],
|
||||
Cmd: cmd,
|
||||
Source: sourceMapCache[cmd],
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user