mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
00eab47218
- New /edit (alias /ed) opens $EDITOR on a chosen file via tea.ExecProcess - Typing '/edit ' activates a fuzzy file popup mirroring the @ trigger: reuses GetFileSuggestions (git ls-files), supports directory drill-down, excludes MCP resources - Selecting a file auto-submits and runs $EDITOR ($VISUAL preferred); on exit prints 'Edited <path>' - Manual paths supported (~/, relative, absolute); non-existent paths pass through so the editor can create them; directories are rejected - /help updated with the new command
441 lines
13 KiB
Go
441 lines
13 KiB
Go
package ui
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// FileSuggestion represents a single file, directory, or MCP resource
|
|
// suggestion for the @ autocomplete popup.
|
|
type FileSuggestion struct {
|
|
// RelPath is the path relative to the search base (e.g. "cmd/kit/main.go")
|
|
// or a display name for MCP resources (e.g. "mcp:server/resource-name").
|
|
RelPath string
|
|
// IsDir is true when the entry is a directory.
|
|
IsDir bool
|
|
// Score is the fuzzy match score (higher is better).
|
|
Score int
|
|
// IsMCPResource is true for MCP resource entries.
|
|
IsMCPResource bool
|
|
// MCPServerName is the MCP server name (set when IsMCPResource is true).
|
|
MCPServerName string
|
|
// MCPResourceURI is the MCP resource URI (set when IsMCPResource is true).
|
|
MCPResourceURI string
|
|
// MCPMIMEType is the MIME type hint from the MCP server.
|
|
MCPMIMEType string
|
|
}
|
|
|
|
// maxFileSuggestions is the maximum number of file suggestions returned.
|
|
const maxFileSuggestions = 20
|
|
|
|
// fileListCache caches the result of listFiles() keyed by directory to avoid
|
|
// re-running git subprocesses on every keystroke during @file completion.
|
|
var fileListCache struct {
|
|
mu sync.Mutex
|
|
dir string // searchDir that produced the cached entries
|
|
cwd string // cwd used for the git query
|
|
entries []FileSuggestion // cached file list
|
|
expireAt time.Time // when the cache entry expires
|
|
}
|
|
|
|
// fileListCacheTTL controls how long a cached file list stays valid.
|
|
// During rapid typing the list is reused; after the TTL a fresh git
|
|
// ls-files is executed so newly created files become visible.
|
|
const fileListCacheTTL = 3 * time.Second
|
|
|
|
// getCachedFileList returns the file list for searchDir, using a short-lived
|
|
// cache to avoid repeated subprocess calls during @file autocompletion.
|
|
func getCachedFileList(searchDir, cwd string) []FileSuggestion {
|
|
fileListCache.mu.Lock()
|
|
defer fileListCache.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
if fileListCache.dir == searchDir &&
|
|
fileListCache.cwd == cwd &&
|
|
now.Before(fileListCache.expireAt) {
|
|
// Return a copy so callers can mutate (e.g. prepend baseDir).
|
|
cp := make([]FileSuggestion, len(fileListCache.entries))
|
|
copy(cp, fileListCache.entries)
|
|
return cp
|
|
}
|
|
|
|
// Cache miss or expired — run the real (potentially expensive) lookup.
|
|
files := listFiles(searchDir, cwd)
|
|
|
|
fileListCache.dir = searchDir
|
|
fileListCache.cwd = cwd
|
|
fileListCache.entries = files
|
|
fileListCache.expireAt = now.Add(fileListCacheTTL)
|
|
|
|
// Return a copy.
|
|
cp := make([]FileSuggestion, len(files))
|
|
copy(cp, files)
|
|
return cp
|
|
}
|
|
|
|
// ExtractAtPrefix checks the current line for an @-file trigger at cursorCol.
|
|
// It returns:
|
|
// - hasAt: true if a valid @ trigger was found
|
|
// - prefix: the text after @ (possibly empty) that the user has typed so far
|
|
// - startIdx: byte offset of the @ character in the line
|
|
//
|
|
// The @ must appear at the start of the line or after whitespace. Quoted paths
|
|
// are supported: @"path with spaces" — the returned prefix strips quotes.
|
|
func ExtractAtPrefix(line string, cursorCol int) (hasAt bool, prefix string, startIdx int) {
|
|
if cursorCol > len(line) {
|
|
cursorCol = len(line)
|
|
}
|
|
|
|
// Walk backwards from cursorCol to find the @ character.
|
|
text := line[:cursorCol]
|
|
|
|
// Find the last @ that is preceded by whitespace or is at position 0.
|
|
atIdx := -1
|
|
for i := len(text) - 1; i >= 0; i-- {
|
|
if text[i] == '@' {
|
|
// Must be at start of line or preceded by whitespace.
|
|
if i == 0 || text[i-1] == ' ' || text[i-1] == '\t' {
|
|
atIdx = i
|
|
break
|
|
}
|
|
}
|
|
// Stop scanning if we hit a space — the @ we want must be in the
|
|
// current "word".
|
|
if text[i] == ' ' || text[i] == '\t' {
|
|
break
|
|
}
|
|
}
|
|
|
|
if atIdx < 0 {
|
|
return false, "", 0
|
|
}
|
|
|
|
raw := text[atIdx+1:]
|
|
|
|
// Handle quoted paths: @"some path" — strip leading quote.
|
|
if after, found := strings.CutPrefix(raw, `"`); found {
|
|
raw = strings.TrimSuffix(after, `"`)
|
|
}
|
|
|
|
return true, raw, atIdx
|
|
}
|
|
|
|
// editTriggerPrefixes lists the command tokens (including trailing space)
|
|
// that activate the /edit fuzzy-file picker. Aliases come first so the
|
|
// longer alias "/edit " is matched before a hypothetical superset.
|
|
var editTriggerPrefixes = []string{"/edit ", "/ed "}
|
|
|
|
// ExtractEditPrefix detects when the input value is a single-line /edit (or
|
|
// alias) invocation and returns the path-portion the user has typed so far.
|
|
//
|
|
// Returns:
|
|
// - cmdLen: byte offset where the path argument begins (i.e. length of
|
|
// the matched command token, including its trailing space)
|
|
// - pathPrefix: text the user has typed after the command token
|
|
// - ok: true when the value matches one of the /edit triggers
|
|
//
|
|
// Multi-line values never match — /edit only makes sense as a single line.
|
|
func ExtractEditPrefix(value string) (cmdLen int, pathPrefix string, ok bool) {
|
|
if strings.Contains(value, "\n") {
|
|
return 0, "", false
|
|
}
|
|
for _, p := range editTriggerPrefixes {
|
|
if strings.HasPrefix(value, p) {
|
|
return len(p), value[len(p):], true
|
|
}
|
|
}
|
|
return 0, "", false
|
|
}
|
|
|
|
// GetFileSuggestions returns file/directory suggestions matching the given
|
|
// prefix. It tries `git ls-files` first (fast, respects .gitignore), then
|
|
// falls back to a simple directory walk.
|
|
//
|
|
// If prefix contains a path separator the search is scoped to that
|
|
// subdirectory. For example, prefix "cmd/k" searches inside "cmd/" for
|
|
// entries matching "k".
|
|
func GetFileSuggestions(prefix string, cwd string) []FileSuggestion {
|
|
// Resolve the base directory and filter query from the prefix.
|
|
baseDir, query := splitPrefixPath(prefix)
|
|
|
|
searchDir := cwd
|
|
if baseDir != "" {
|
|
candidate := resolveSearchDir(baseDir, cwd)
|
|
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
|
searchDir = candidate
|
|
} else {
|
|
return nil // invalid base directory
|
|
}
|
|
}
|
|
|
|
files := getCachedFileList(searchDir, cwd)
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Prepend baseDir so results display as "cmd/main.go" not just "main.go".
|
|
if baseDir != "" {
|
|
for i := range files {
|
|
files[i].RelPath = baseDir + files[i].RelPath
|
|
}
|
|
}
|
|
|
|
return fuzzyFilterFiles(files, prefix, query)
|
|
}
|
|
|
|
// splitPrefixPath separates a prefix like "cmd/kit/m" into
|
|
// baseDir="cmd/kit/" and query="m". If there is no separator the
|
|
// baseDir is empty and query is the full prefix.
|
|
func splitPrefixPath(prefix string) (baseDir, query string) {
|
|
// Handle ~ expansion display (we keep it in the prefix for display
|
|
// but resolve it when actually searching).
|
|
idx := strings.LastIndex(prefix, "/")
|
|
if idx < 0 {
|
|
return "", prefix
|
|
}
|
|
return prefix[:idx+1], prefix[idx+1:]
|
|
}
|
|
|
|
// resolveSearchDir converts a baseDir from the prefix into an absolute path.
|
|
// Supports ~/, ../, and absolute paths.
|
|
func resolveSearchDir(baseDir, cwd string) string {
|
|
// Expand ~/
|
|
if strings.HasPrefix(baseDir, "~/") {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, baseDir[2:])
|
|
}
|
|
}
|
|
|
|
// Absolute paths
|
|
if filepath.IsAbs(baseDir) {
|
|
return filepath.Clean(baseDir)
|
|
}
|
|
|
|
// Relative to cwd
|
|
return filepath.Join(cwd, baseDir)
|
|
}
|
|
|
|
// listFiles returns files and directories within searchDir, relative to that
|
|
// directory. Uses `git ls-files` when inside a git repo for speed and
|
|
// .gitignore awareness, otherwise falls back to os.ReadDir.
|
|
func listFiles(searchDir, cwd string) []FileSuggestion {
|
|
// Try git ls-files first (fast, respects .gitignore).
|
|
if files := listFilesGit(searchDir, cwd); files != nil {
|
|
return files
|
|
}
|
|
return listFilesReadDir(searchDir)
|
|
}
|
|
|
|
// listFilesGit uses `git ls-files` and `git ls-files --others --exclude-standard`
|
|
// to list tracked and untracked-but-not-ignored files.
|
|
func listFilesGit(searchDir, cwd string) []FileSuggestion {
|
|
// Check if we're in a git repo.
|
|
check := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
check.Dir = cwd
|
|
if err := check.Run(); err != nil {
|
|
return nil
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
var results []FileSuggestion
|
|
|
|
// Tracked files.
|
|
cmd := exec.Command("git", "ls-files")
|
|
cmd.Dir = searchDir
|
|
out, err := cmd.Output()
|
|
if err == nil {
|
|
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Normalize separators.
|
|
line = filepath.ToSlash(line)
|
|
addFileEntries(&results, seen, line, searchDir)
|
|
}
|
|
}
|
|
|
|
// Untracked, non-ignored files.
|
|
cmd2 := exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
|
cmd2.Dir = searchDir
|
|
out2, err := cmd2.Output()
|
|
if err == nil {
|
|
for line := range strings.SplitSeq(strings.TrimSpace(string(out2)), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
line = filepath.ToSlash(line)
|
|
addFileEntries(&results, seen, line, searchDir)
|
|
}
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return nil
|
|
}
|
|
return results
|
|
}
|
|
|
|
// addFileEntries adds the file and any intermediate directory entries to
|
|
// results if not already seen. Paths are stored with forward slashes.
|
|
func addFileEntries(results *[]FileSuggestion, seen map[string]bool, relPath string, searchDir string) {
|
|
// Add intermediate directories as suggestions (first component only).
|
|
parts := strings.SplitN(relPath, "/", 2)
|
|
if len(parts) > 1 {
|
|
dir := parts[0] + "/"
|
|
if !seen[dir] {
|
|
seen[dir] = true
|
|
*results = append(*results, FileSuggestion{RelPath: dir, IsDir: true})
|
|
}
|
|
}
|
|
|
|
// Add the file itself.
|
|
if !seen[relPath] {
|
|
seen[relPath] = true
|
|
*results = append(*results, FileSuggestion{RelPath: relPath, IsDir: false})
|
|
}
|
|
}
|
|
|
|
// listFilesReadDir is the fallback when git is not available. Lists immediate
|
|
// children of dir via os.ReadDir, skipping hidden dirs and common noise.
|
|
func listFilesReadDir(dir string) []FileSuggestion {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
skip := map[string]bool{
|
|
".git": true, "node_modules": true, ".kit": true,
|
|
"__pycache__": true, ".venv": true, "vendor": true,
|
|
}
|
|
|
|
var results []FileSuggestion
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if skip[name] {
|
|
continue
|
|
}
|
|
// Skip hidden files/dirs (except common config files).
|
|
if strings.HasPrefix(name, ".") && name != ".env" && name != ".gitignore" {
|
|
continue
|
|
}
|
|
if e.IsDir() {
|
|
results = append(results, FileSuggestion{RelPath: name + "/", IsDir: true})
|
|
} else {
|
|
results = append(results, FileSuggestion{RelPath: name, IsDir: false})
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
// fuzzyFilterFiles scores and filters file suggestions against the query,
|
|
// returning the top maxFileSuggestions results sorted by score descending.
|
|
// Directories are boosted slightly so they appear near the top.
|
|
func fuzzyFilterFiles(files []FileSuggestion, fullPrefix, query string) []FileSuggestion {
|
|
if query == "" && fullPrefix == "" {
|
|
// No filter — return all (capped).
|
|
if len(files) > maxFileSuggestions {
|
|
files = files[:maxFileSuggestions]
|
|
}
|
|
return files
|
|
}
|
|
|
|
// When there's a base dir but no query (e.g. "cmd/"), show everything
|
|
// in that directory.
|
|
if query == "" {
|
|
var filtered []FileSuggestion
|
|
for i := range files {
|
|
if strings.HasPrefix(files[i].RelPath, fullPrefix) {
|
|
// Only show direct children of the base directory.
|
|
rest := files[i].RelPath[len(fullPrefix):]
|
|
if rest == "" {
|
|
continue
|
|
}
|
|
filtered = append(filtered, files[i])
|
|
}
|
|
}
|
|
if len(filtered) > maxFileSuggestions {
|
|
filtered = filtered[:maxFileSuggestions]
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
var scored []FileSuggestion
|
|
queryLower := strings.ToLower(query)
|
|
|
|
for i := range files {
|
|
path := files[i].RelPath
|
|
// When we have a fullPrefix with a dir component, only consider
|
|
// files under that directory.
|
|
if fullPrefix != query && !strings.HasPrefix(path, fullPrefix[:len(fullPrefix)-len(query)]) {
|
|
continue
|
|
}
|
|
|
|
score := scoreFilePath(queryLower, path)
|
|
if score <= 0 {
|
|
continue
|
|
}
|
|
|
|
// Boost directories so they appear near the top for navigation.
|
|
if files[i].IsDir {
|
|
score += 10
|
|
}
|
|
|
|
files[i].Score = score
|
|
scored = append(scored, files[i])
|
|
}
|
|
|
|
// Sort by score descending.
|
|
sort.Slice(scored, func(i, j int) bool {
|
|
return scored[i].Score > scored[j].Score
|
|
})
|
|
|
|
if len(scored) > maxFileSuggestions {
|
|
scored = scored[:maxFileSuggestions]
|
|
}
|
|
return scored
|
|
}
|
|
|
|
// scoreFilePath scores a file path against a fuzzy query. Higher is better.
|
|
// Returns 0 if there is no match.
|
|
func scoreFilePath(query, path string) int {
|
|
pathLower := strings.ToLower(path)
|
|
baseName := filepath.Base(strings.TrimSuffix(path, "/"))
|
|
baseNameLower := strings.ToLower(baseName)
|
|
|
|
// Exact basename match.
|
|
if baseNameLower == query {
|
|
return 1000
|
|
}
|
|
|
|
// Basename starts with query.
|
|
if strings.HasPrefix(baseNameLower, query) {
|
|
return 800 - len(baseName) + len(query)
|
|
}
|
|
|
|
// Basename contains query as substring.
|
|
if strings.Contains(baseNameLower, query) {
|
|
return 500 - len(baseName) + len(query)
|
|
}
|
|
|
|
// Full path contains query as substring.
|
|
if strings.Contains(pathLower, query) {
|
|
return 300 - len(path) + len(query)
|
|
}
|
|
|
|
// Fuzzy character match on basename.
|
|
if score := fuzzyCharacterMatch(query, baseNameLower); score > 0 {
|
|
return score
|
|
}
|
|
|
|
// Fuzzy character match on full path.
|
|
if score := fuzzyCharacterMatch(query, pathLower); score > 0 {
|
|
return score - 50
|
|
}
|
|
|
|
return 0
|
|
}
|