package files import ( "io/fs" "net/http" "os" "path/filepath" "sort" "strconv" "strings" ) // Search limits: the endpoint feeds an autocomplete popup, so it favours a // fast, bounded answer over exhaustiveness on huge trees. const ( defaultSearchLimit = 50 maxSearchLimit = 500 // maxSearchVisited bounds the number of directory entries walked per // request, so a giant workspace cannot stall the server. maxSearchVisited = 50000 ) // skippedDirectories are never descended into: their contents are noise for a // file mention (and node_modules alone can hold hundreds of thousands of files). var skippedDirectories = map[string]bool{ ".git": true, "node_modules": true, } // Hit is one file matched by the search endpoint. type Hit struct { // Name is the base name of the file. Name string `json:"name"` // Path is the absolute path, directly usable in further API calls. Path string `json:"path"` // RelPath is the path relative to the workspace root, using forward // slashes: the form a mention such as "@src/main.go" displays. RelPath string `json:"relPath"` } // Search walks the workspace root recursively and returns the files whose // relative path contains query (case-insensitive), at most limit of them. // An empty query returns the first files in walk order. Results are ranked: // base-name matches first, then shorter paths. // // Example: // // hits := files.New("/work").Search("main", 20) // // [{Name:"main.go" Path:"/work/cmd/main.go" RelPath:"cmd/main.go"}] func (s *Service) Search(query string, limit int) []Hit { if limit <= 0 { limit = defaultSearchLimit } if limit > maxSearchLimit { limit = maxSearchLimit } walker := &searchWalker{root: s.root, needle: strings.ToLower(query)} _ = filepath.WalkDir(s.root, walker.visit) rankHits(walker.hits, walker.needle) if len(walker.hits) > limit { walker.hits = walker.hits[:limit] } return walker.hits } // searchWalker accumulates the files matching needle during one WalkDir. type searchWalker struct { root string needle string visited int hits []Hit } // visit is the WalkDir callback: it bounds the walk, prunes noisy // directories and collects matching files. func (w *searchWalker) visit(path string, d fs.DirEntry, err error) error { if err != nil { return nil // unreadable entries are skipped, not fatal } w.visited++ if w.visited > maxSearchVisited { return fs.SkipAll } if d.IsDir() { return w.visitDir(path, d) } w.collect(path, d) return nil } // visitDir skips the directories whose contents are noise for a mention. func (w *searchWalker) visitDir(path string, d fs.DirEntry) error { if path != w.root && skippedDirectories[d.Name()] { return fs.SkipDir } return nil } // collect records the file when its relative path contains the needle. func (w *searchWalker) collect(path string, d fs.DirEntry) { rel, err := filepath.Rel(w.root, path) if err != nil { return } rel = filepath.ToSlash(rel) if w.needle != "" && !strings.Contains(strings.ToLower(rel), w.needle) { return } w.hits = append(w.hits, Hit{Name: d.Name(), Path: path, RelPath: rel}) } // rankHits orders matches by relevance: files whose base name contains the // query before those matched only through a directory name, then shorter // paths first, then alphabetically for a stable order. func rankHits(hits []Hit, needle string) { nameMatches := func(h Hit) bool { return needle == "" || strings.Contains(strings.ToLower(h.Name), needle) } sort.SliceStable(hits, func(i, j int) bool { ni, nj := nameMatches(hits[i]), nameMatches(hits[j]) if ni != nj { return ni } if len(hits[i].RelPath) != len(hits[j].RelPath) { return len(hits[i].RelPath) < len(hits[j].RelPath) } return hits[i].RelPath < hits[j].RelPath }) } func (s *Service) searchFiles(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() limit, _ := strconv.Atoi(query.Get("limit")) if _, err := os.Stat(s.root); err != nil { writeFSError(w, err) return } hits := s.Search(query.Get("q"), limit) if hits == nil { hits = []Hit{} } writeJSON(w, http.StatusOK, map[string]any{"root": s.root, "files": hits}) }