nandi/oripublic Fork 0
b6cd929ab1425a4ef6073ea9c189917cca02cf3c
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

search.go · 144 lines · 4.1 KBGo Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1package files
2
3import (
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.
15const (
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).
25var skippedDirectories = map[string]bool{
26 ".git": true,
27 "node_modules": true,
28}
29
30// Hit is one file matched by the search endpoint.
31type 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"}]
50func (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.
68type 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.
77func (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.
93func (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.
101func (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.
116func 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
132func (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}