650 lines
15 KiB
Go
650 lines
15 KiB
Go
package integration
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
sessionHistory []string
|
|
sessionHistoryMu sync.Mutex
|
|
|
|
historyCache []string
|
|
idMapCache map[string]int
|
|
sourceMapCache map[string]string
|
|
searcherCache *fuzzy.Searcher
|
|
mu sync.Mutex
|
|
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 == "" {
|
|
return
|
|
}
|
|
cmd = sanitizeUTF8(cmd)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
sessionHistoryMu.Lock()
|
|
defer sessionHistoryMu.Unlock()
|
|
|
|
if len(sessionHistory) > 0 && sessionHistory[len(sessionHistory)-1] == cmd {
|
|
return
|
|
}
|
|
sessionHistory = append(sessionHistory, cmd)
|
|
historyCache = nil // invalidate to merge session history on next search
|
|
}
|
|
|
|
type HistResult struct {
|
|
ID int
|
|
Cmd string
|
|
FuzzyScore int
|
|
Source string
|
|
}
|
|
|
|
func init() {
|
|
idMapCache = make(map[string]int)
|
|
sourceMapCache = make(map[string]string)
|
|
}
|
|
|
|
func sanitizeUTF8(s string) string {
|
|
if utf8.ValidString(s) {
|
|
return s
|
|
}
|
|
var result strings.Builder
|
|
for _, r := range s {
|
|
if r == utf8.RuneError {
|
|
result.WriteRune('�')
|
|
} else {
|
|
result.WriteRune(r)
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
func loadAtuinEntries() ([]atuinEntry, error) {
|
|
dbPath, err := config.AtuinDBPath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
db.SetMaxOpenConns(1)
|
|
|
|
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()
|
|
|
|
var entries []atuinEntry
|
|
for rows.Next() {
|
|
var entry atuinEntry
|
|
if err := rows.Scan(
|
|
&entry.Command,
|
|
&entry.CWD,
|
|
&entry.Timestamp,
|
|
&entry.Exit,
|
|
&entry.Session,
|
|
&entry.Hostname,
|
|
); err != nil {
|
|
continue
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
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()
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
shellName := "bash"
|
|
if shell.Current != nil {
|
|
shellName = shell.Current.GetName()
|
|
}
|
|
|
|
var histFile string
|
|
if envHist := os.Getenv("HISTFILE"); envHist != "" {
|
|
histFile = envHist
|
|
} else {
|
|
switch shellName {
|
|
case "zsh":
|
|
histFile = filepath.Join(shell.GetZshConfigDir(), ".zsh_history")
|
|
case "fish":
|
|
histFile = filepath.Join(shell.GetFishDataDir(), "fish_history")
|
|
default:
|
|
histFile = filepath.Join(home, ".bash_history")
|
|
}
|
|
}
|
|
|
|
atuinMode := config.Get().Core.Atuin
|
|
if lastAtuinMode != -1 && atuinMode != lastAtuinMode {
|
|
historyCache = nil
|
|
}
|
|
lastAtuinMode = atuinMode
|
|
|
|
if atuinMode > 0 {
|
|
dbPath, _ := config.AtuinDBPath()
|
|
if info, err := os.Stat(dbPath); err == nil {
|
|
mod := info.ModTime().UnixNano()
|
|
if mod != atuinLastMod {
|
|
atuinLastMod = mod
|
|
atuinCmds = nil
|
|
atuinEntries = nil
|
|
historyCache = nil
|
|
}
|
|
}
|
|
if atuinCmds == nil {
|
|
atuinEntries, _ = loadAtuinEntries()
|
|
atuinCmds = atuinCommands(atuinEntries)
|
|
}
|
|
}
|
|
|
|
if info, err := os.Stat(histFile); err == nil {
|
|
if info.ModTime().UnixNano() > lastModTime {
|
|
historyCache = nil // force reload
|
|
idMapCache = make(map[string]int)
|
|
lastModTime = info.ModTime().UnixNano()
|
|
}
|
|
}
|
|
|
|
// lazy load history if cache is empty
|
|
if len(historyCache) == 0 {
|
|
file, err := os.Open(histFile)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if file != nil {
|
|
defer func() { _ = file.Close() }()
|
|
}
|
|
|
|
var allCmds []string
|
|
if atuinMode == 1 && len(atuinCmds) > 0 {
|
|
// atuin only — prepend newest-first so the merge loop below works
|
|
allCmds = atuinCmds
|
|
} else {
|
|
if file != nil {
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
cmd := line
|
|
|
|
if shellName == "zsh" {
|
|
parts := strings.SplitN(line, ";", 2)
|
|
if len(parts) == 2 {
|
|
cmd = parts[1]
|
|
}
|
|
} else if shellName == "bash" {
|
|
if strings.HasPrefix(line, "#") && len(line) > 1 {
|
|
isTimestamp := true
|
|
for _, c := range line[1:] {
|
|
if c < '0' || c > '9' {
|
|
isTimestamp = false
|
|
break
|
|
}
|
|
}
|
|
if isTimestamp {
|
|
continue
|
|
}
|
|
}
|
|
} else if shellName == "fish" {
|
|
if after, ok := strings.CutPrefix(line, "- cmd: "); ok {
|
|
cmd = after
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
|
|
cmd = strings.TrimSpace(cmd)
|
|
if cmd != "" {
|
|
cmd = sanitizeUTF8(cmd)
|
|
allCmds = append(allCmds, cmd)
|
|
}
|
|
}
|
|
if scanner.Err() != nil {
|
|
_ = scanner.Err()
|
|
}
|
|
}
|
|
// mode 2: append atuin (newer, higher priority) after shell file
|
|
if atuinMode == 2 && len(atuinCmds) > 0 {
|
|
allCmds = append(allCmds, atuinCmds...)
|
|
}
|
|
}
|
|
|
|
// build historyCache backwards so newest commands come first
|
|
seen := make(map[string]bool)
|
|
atuinSeen := make(map[string]bool)
|
|
for _, c := range atuinCmds {
|
|
atuinSeen[c] = true
|
|
}
|
|
|
|
historyCache = nil
|
|
idMapCache = make(map[string]int)
|
|
sourceMapCache = make(map[string]string)
|
|
|
|
currentID := len(sessionHistory) + len(allCmds)
|
|
|
|
sessionHistoryMu.Lock()
|
|
for i := len(sessionHistory) - 1; i >= 0; i-- {
|
|
cmd := sessionHistory[i]
|
|
if !seen[cmd] {
|
|
historyCache = append(historyCache, cmd)
|
|
seen[cmd] = true
|
|
idMapCache[cmd] = currentID
|
|
if atuinSeen[cmd] {
|
|
sourceMapCache[cmd] = "atuin"
|
|
} else {
|
|
sourceMapCache[cmd] = "session"
|
|
}
|
|
currentID--
|
|
}
|
|
}
|
|
sessionHistoryMu.Unlock()
|
|
|
|
for i := len(allCmds) - 1; i >= 0; i-- {
|
|
cmd := allCmds[i]
|
|
if !seen[cmd] {
|
|
historyCache = append(historyCache, cmd)
|
|
seen[cmd] = true
|
|
idMapCache[cmd] = currentID
|
|
if atuinSeen[cmd] {
|
|
sourceMapCache[cmd] = "atuin"
|
|
} else {
|
|
sourceMapCache[cmd] = "history"
|
|
}
|
|
currentID--
|
|
}
|
|
}
|
|
|
|
searcherCache = fuzzy.NewPlainSearcher(historyCache)
|
|
}
|
|
|
|
if query == "" {
|
|
var results []HistResult
|
|
limit := min(len(historyCache), 100)
|
|
|
|
for i := range limit {
|
|
cmd := historyCache[i]
|
|
results = append(results, HistResult{
|
|
ID: idMapCache[cmd],
|
|
Cmd: cmd,
|
|
Source: sourceMapCache[cmd],
|
|
})
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
var alternativeQueries []string
|
|
for name, target := range aliases {
|
|
if target != "" {
|
|
qLow := strings.ToLower(query)
|
|
tLow := strings.ToLower(target)
|
|
nLow := strings.ToLower(name)
|
|
|
|
if qLow == tLow {
|
|
alternativeQueries = append(alternativeQueries, name)
|
|
} else if strings.HasPrefix(qLow, tLow+" ") {
|
|
suffix := query[len(target):]
|
|
alternativeQueries = append(alternativeQueries, name+suffix)
|
|
}
|
|
|
|
if qLow == nLow {
|
|
alternativeQueries = append(alternativeQueries, target)
|
|
} else if strings.HasPrefix(qLow, nLow+" ") {
|
|
suffix := query[len(name):]
|
|
alternativeQueries = append(alternativeQueries, target+suffix)
|
|
}
|
|
}
|
|
}
|
|
|
|
var results []HistResult
|
|
seenCmds := make(map[string]bool)
|
|
|
|
addMatches := func(q string) {
|
|
qLow := strings.ToLower(q)
|
|
|
|
// extract pure substring matches (all words present) based strictly on recency order (historyCache is newest-first)
|
|
// this ensures that long commands with exact substrings are never truncated by the fuzzy searcher's limit
|
|
strictMatches := 0
|
|
words := strings.Fields(qLow)
|
|
if len(words) == 0 {
|
|
words = []string{qLow}
|
|
}
|
|
|
|
for _, cmd := range historyCache {
|
|
if seenCmds[cmd] {
|
|
continue
|
|
}
|
|
|
|
cmdLow := strings.ToLower(cmd)
|
|
matchAll := true
|
|
for _, w := range words {
|
|
if !strings.Contains(cmdLow, w) {
|
|
matchAll = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if !matchAll {
|
|
continue
|
|
}
|
|
|
|
seenCmds[cmd] = true
|
|
results = append(results, HistResult{
|
|
ID: idMapCache[cmd],
|
|
Cmd: cmd,
|
|
FuzzyScore: 10000,
|
|
Source: sourceMapCache[cmd],
|
|
})
|
|
strictMatches++
|
|
if strictMatches >= 200 {
|
|
break
|
|
}
|
|
}
|
|
|
|
matches := searcherCache.SearchWithScores(q, &fuzzy.SearchOptions{Limit: 1000})
|
|
for _, m := range matches {
|
|
if seenCmds[m.Str] {
|
|
continue
|
|
}
|
|
|
|
// filter out extremely weak fuzzy matches (e.g. random garbage typing that
|
|
// loosely matches across a very long command)
|
|
if len(q) > 0 && m.Score/len(q) < 150 {
|
|
continue
|
|
}
|
|
|
|
seenCmds[m.Str] = true
|
|
results = append(results, HistResult{
|
|
ID: idMapCache[m.Str],
|
|
Cmd: m.Str,
|
|
FuzzyScore: m.Score,
|
|
Source: sourceMapCache[m.Str],
|
|
})
|
|
}
|
|
}
|
|
|
|
addMatches(query)
|
|
for _, altQ := range alternativeQueries {
|
|
addMatches(altQ)
|
|
}
|
|
|
|
getTier := func(cmd, q string) int {
|
|
bestTier := 4
|
|
check := func(ql string) {
|
|
cmdLow := strings.ToLower(cmd)
|
|
qlLow := strings.ToLower(ql)
|
|
tier := 4
|
|
if cmdLow == qlLow {
|
|
tier = 1
|
|
} else if strings.HasPrefix(cmdLow, qlLow) {
|
|
tier = 2
|
|
} else if strings.Contains(cmdLow, qlLow) {
|
|
tier = 3
|
|
}
|
|
if tier < bestTier {
|
|
bestTier = tier
|
|
}
|
|
}
|
|
check(q)
|
|
for _, altQ := range alternativeQueries {
|
|
check(altQ)
|
|
}
|
|
return bestTier
|
|
}
|
|
|
|
tiers := make([]int, len(results))
|
|
for i, r := range results {
|
|
tiers[i] = getTier(r.Cmd, query)
|
|
}
|
|
|
|
sort.SliceStable(results, func(i, j int) bool {
|
|
tI := tiers[i]
|
|
tJ := tiers[j]
|
|
if tI != tJ {
|
|
return tI < tJ
|
|
}
|
|
|
|
if tI == 4 && results[i].FuzzyScore != results[j].FuzzyScore {
|
|
return results[i].FuzzyScore > results[j].FuzzyScore
|
|
}
|
|
|
|
return results[i].ID > results[j].ID
|
|
})
|
|
|
|
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
|
|
}
|