forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | package files |
| 2 | ||
| 3 | import ( | |
| 4 | "io/fs" | |
| 5 | "net/http" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "sort" | |
| 9 | "strconv" | |
| 10 | "strings" | |
| 11 | ) | |
| 12 | ||
| 13 | // Search limits: the endpoint feeds an autocomplete popup, so it favours a | |
| 14 | // fast, bounded answer over exhaustiveness on huge trees. | |
| 15 | const ( | |
| 16 | defaultSearchLimit = 50 | |
| 17 | maxSearchLimit = 500 | |
| 18 | // maxSearchVisited bounds the number of directory entries walked per | |
| 19 | // request, so a giant workspace cannot stall the server. | |
| 20 | maxSearchVisited = 50000 | |
| 21 | ) | |
| 22 | ||
| 23 | // skippedDirectories are never descended into: their contents are noise for a | |
| 24 | // file mention (and node_modules alone can hold hundreds of thousands of files). | |
| 25 | var skippedDirectories = map[string]bool{ | |
| 26 | ".git": true, | |
| 27 | "node_modules": true, | |
| 28 | } | |
| 29 | ||
| 30 | // Hit is one file matched by the search endpoint. | |
| 31 | type Hit struct { | |
| 32 | // Name is the base name of the file. | |
| 33 | Name string `json:"name"` | |
| 34 | // Path is the absolute path, directly usable in further API calls. | |
| 35 | Path string `json:"path"` | |
| 36 | // RelPath is the path relative to the workspace root, using forward | |
| 37 | // slashes: the form a mention such as "@src/main.go" displays. | |
| 38 | RelPath string `json:"relPath"` | |
| 39 | } | |
| 40 | ||
| 41 | // Search walks the workspace root recursively and returns the files whose | |
| 42 | // relative path contains query (case-insensitive), at most limit of them. | |
| 43 | // An empty query returns the first files in walk order. Results are ranked: | |
| 44 | // base-name matches first, then shorter paths. | |
| 45 | // | |
| 46 | // Example: | |
| 47 | // | |
| 48 | // hits := files.New("/work").Search("main", 20) | |
| 49 | // // [{Name:"main.go" Path:"/work/cmd/main.go" RelPath:"cmd/main.go"}] | |
| 50 | func (s *Service) Search(query string, limit int) []Hit { | |
| 51 | if limit <= 0 { | |
| 52 | limit = defaultSearchLimit | |
| 53 | } | |
| 54 | if limit > maxSearchLimit { | |
| 55 | limit = maxSearchLimit | |
| 56 | } | |
| 57 | walker := &searchWalker{root: s.root, needle: strings.ToLower(query)} | |
| 58 | _ = filepath.WalkDir(s.root, walker.visit) | |
| 59 | ||
| 60 | rankHits(walker.hits, walker.needle) | |
| 61 | if len(walker.hits) > limit { | |
| 62 | walker.hits = walker.hits[:limit] | |
| 63 | } | |
| 64 | return walker.hits | |
| 65 | } | |
| 66 | ||
| 67 | // searchWalker accumulates the files matching needle during one WalkDir. | |
| 68 | type searchWalker struct { | |
| 69 | root string | |
| 70 | needle string | |
| 71 | visited int | |
| 72 | hits []Hit | |
| 73 | } | |
| 74 | ||
| 75 | // visit is the WalkDir callback: it bounds the walk, prunes noisy | |
| 76 | // directories and collects matching files. | |
| 77 | func (w *searchWalker) visit(path string, d fs.DirEntry, err error) error { | |
| 78 | if err != nil { | |
| 79 | return nil // unreadable entries are skipped, not fatal | |
| 80 | } | |
| 81 | w.visited++ | |
| 82 | if w.visited > maxSearchVisited { | |
| 83 | return fs.SkipAll | |
| 84 | } | |
| 85 | if d.IsDir() { | |
| 86 | return w.visitDir(path, d) | |
| 87 | } | |
| 88 | w.collect(path, d) | |
| 89 | return nil | |
| 90 | } | |
| 91 | ||
| 92 | // visitDir skips the directories whose contents are noise for a mention. | |
| 93 | func (w *searchWalker) visitDir(path string, d fs.DirEntry) error { | |
| 94 | if path != w.root && skippedDirectories[d.Name()] { | |
| 95 | return fs.SkipDir | |
| 96 | } | |
| 97 | return nil | |
| 98 | } | |
| 99 | ||
| 100 | // collect records the file when its relative path contains the needle. | |
| 101 | func (w *searchWalker) collect(path string, d fs.DirEntry) { | |
| 102 | rel, err := filepath.Rel(w.root, path) | |
| 103 | if err != nil { | |
| 104 | return | |
| 105 | } | |
| 106 | rel = filepath.ToSlash(rel) | |
| 107 | if w.needle != "" && !strings.Contains(strings.ToLower(rel), w.needle) { | |
| 108 | return | |
| 109 | } | |
| 110 | w.hits = append(w.hits, Hit{Name: d.Name(), Path: path, RelPath: rel}) | |
| 111 | } | |
| 112 | ||
| 113 | // rankHits orders matches by relevance: files whose base name contains the | |
| 114 | // query before those matched only through a directory name, then shorter | |
| 115 | // paths first, then alphabetically for a stable order. | |
| 116 | func rankHits(hits []Hit, needle string) { | |
| 117 | nameMatches := func(h Hit) bool { | |
| 118 | return needle == "" || strings.Contains(strings.ToLower(h.Name), needle) | |
| 119 | } | |
| 120 | sort.SliceStable(hits, func(i, j int) bool { | |
| 121 | ni, nj := nameMatches(hits[i]), nameMatches(hits[j]) | |
| 122 | if ni != nj { | |
| 123 | return ni | |
| 124 | } | |
| 125 | if len(hits[i].RelPath) != len(hits[j].RelPath) { | |
| 126 | return len(hits[i].RelPath) < len(hits[j].RelPath) | |
| 127 | } | |
| 128 | return hits[i].RelPath < hits[j].RelPath | |
| 129 | }) | |
| 130 | } | |
| 131 | ||
| 132 | func (s *Service) searchFiles(w http.ResponseWriter, r *http.Request) { | |
| 133 | query := r.URL.Query() | |
| 134 | limit, _ := strconv.Atoi(query.Get("limit")) | |
| 135 | if _, err := os.Stat(s.root); err != nil { | |
| 136 | writeFSError(w, err) | |
| 137 | return | |
| 138 | } | |
| 139 | hits := s.Search(query.Get("q"), limit) | |
| 140 | if hits == nil { | |
| 141 | hits = []Hit{} | |
| 142 | } | |
| 143 | writeJSON(w, http.StatusOK, map[string]any{"root": s.root, "files": hits}) | |
| 144 | } |