返回 DeepSeek-Reasonix
search.go
根目录 / internal / fileref / search.go
1 package fileref
2
3 import (
4 "io/fs"
5 "path/filepath"
6 "sort"
7 "strings"
8 )
9
10 var skipEntryNames = map[string]bool{
11 ".codex": true,
12 ".DS_Store": true,
13 ".git": true,
14 ".npm": true,
15 ".pnpm-store": true,
16 "node_modules": true,
17 "Thumbs.db": true,
18 }
19
20 // skipDirNames are build outputs across ecosystems: their contents are
21 // generated, so an "@" hit inside one points at a file nobody edits (#3900).
22 var skipDirNames = map[string]bool{
23 "build": true,
24 "dist": true,
25 "target": true,
26 "__pycache__": true,
27 "venv": true,
28 ".venv": true,
29 ".gradle": true,
30 ".next": true,
31 ".nuxt": true,
32 ".svelte-kit": true,
33 ".pytest_cache": true,
34 ".mypy_cache": true,
35 ".tox": true,
36 ".terraform": true,
37 ".dart_tool": true,
38 }
39
40 // SkipEntry reports whether a workspace entry is hidden from "@" reference
41 // search. rel is the entry's slash-separated path from the workspace root.
42 func SkipEntry(rel, name string, isDir bool) bool {
43 return SkipBrowseEntry(name, isDir) || (isDir && skipDirPaths[rel])
44 }
45
46 // SkipBrowseEntry reports whether a workspace entry is hidden from browsing
47 // surfaces that mirror the disk, such as a file tree.
48 func SkipBrowseEntry(name string, isDir bool) bool {
49 return skipEntryNames[name] || (isDir && skipDirNames[name])
50 }
51
52 // skipDirPaths are generated-output paths of this repository; their generic
53 // names ("tmp", "bin") are hand-edited directories elsewhere, so only search
54 // may skip them.
55 var skipDirPaths = map[string]bool{
56 "bin": true,
57 "desktop/frontend/wailsjs": true, // retired Wails-generated bindings (stale dirs on old checkouts)
58 "npm/.stage": true,
59 "site/.astro": true,
60 "stage": true,
61 "tmp": true,
62 }
63
64 const (
65 minQueryLen = 2
66 maxWalkEntries = 10000
67 )
68
69 // SearchResult is a single entry returned by Search. It carries the relative
70 // path (slash-normalized) and whether the entry is a directory, so callers
71 // can present the correct icon and append "/" vs " " on selection.
72 type SearchResult struct {
73 Path string
74 IsDir bool
75 }
76
77 // Search finds entries under root whose path matches query. A match is
78 // recorded when the query is a substring of the file's basename (preferred
79 // tier), of any slash-separated path segment (fallback tier), or of a
80 // directory name (lowest tier). It is bounded by limit and skips common
81 // generated/vendor directories so interactive completion stays responsive on
82 // large workspaces.
83 func Search(root, query string, limit int) []SearchResult {
84 query = strings.ToLower(strings.TrimSpace(query))
85 if len(query) < minQueryLen || strings.ContainsAny(query, `/\`) || limit <= 0 {
86 return nil
87 }
88
89 showHidden := strings.HasPrefix(query, ".")
90 var basenameHits []SearchResult
91 var segmentHits []SearchResult
92 var dirHits []SearchResult
93 visited := 0
94 _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
95 if err != nil {
96 if d != nil && d.IsDir() {
97 return filepath.SkipDir
98 }
99 return nil
100 }
101 if path == root {
102 return nil
103 }
104 visited++
105 if visited > maxWalkEntries {
106 return filepath.SkipAll
107 }
108
109 name := d.Name()
110 if d.IsDir() {
111 rel, err := filepath.Rel(root, path)
112 if err != nil {
113 return filepath.SkipDir
114 }
115 rel = filepath.ToSlash(rel)
116 if SkipEntry(rel, name, true) || (!showHidden && strings.HasPrefix(name, ".")) {
117 return filepath.SkipDir
118 }
119 // Allow matching directory names so the user can select a
120 // folder directly from the @-menu instead of only its contents.
121 if strings.Contains(strings.ToLower(name), query) {
122 dirHits = append(dirHits, SearchResult{Path: rel, IsDir: true})
123 }
124 return nil
125 }
126 if skipEntryNames[name] {
127 return nil
128 }
129 if !showHidden && strings.HasPrefix(name, ".") {
130 return nil
131 }
132 if info, err := d.Info(); err != nil || !info.Mode().IsRegular() {
133 return nil
134 }
135 rel, err := filepath.Rel(root, path)
136 if err != nil {
137 return nil
138 }
139 rel = filepath.ToSlash(rel)
140 nameLower := strings.ToLower(name)
141 switch {
142 case strings.Contains(nameLower, query):
143 basenameHits = append(basenameHits, SearchResult{Path: rel})
144 case pathSegmentContains(rel, query):
145 segmentHits = append(segmentHits, SearchResult{Path: rel})
146 }
147 return nil
148 })
149 sort.Slice(basenameHits, func(i, j int) bool { return NaturalLess(basenameHits[i].Path, basenameHits[j].Path) })
150 sort.Slice(segmentHits, func(i, j int) bool { return NaturalLess(segmentHits[i].Path, segmentHits[j].Path) })
151 sort.Slice(dirHits, func(i, j int) bool { return NaturalLess(dirHits[i].Path, dirHits[j].Path) })
152 // Directories first so the user can navigate into them; then basename
153 // hits (most relevant file matches); then path-segment hits. We reserve
154 // up to dirQuota slots for directories so they are never fully crowded
155 // out by a large number of file matches.
156 const dirQuota = 5
157 out := make([]SearchResult, 0, limit)
158 nDirs := min(len(dirHits), dirQuota)
159 out = append(out, dirHits[:nDirs]...)
160 remaining := limit - len(out)
161 if remaining > 0 {
162 if len(basenameHits) > remaining {
163 basenameHits = basenameHits[:remaining]
164 }
165 out = append(out, basenameHits...)
166 remaining = limit - len(out)
167 }
168 if remaining > 0 {
169 if len(segmentHits) > remaining {
170 segmentHits = segmentHits[:remaining]
171 }
172 out = append(out, segmentHits...)
173 }
174 return out
175 }
176
177 // pathSegmentContains reports whether query appears in any slash-separated
178 // segment of the slash-normalized relative path. The basename is matched
179 // independently by the caller, so this helper is meaningful only for
180 // directories above the file (e.g. "src/planind/index.tsx" with query
181 // "planind" matches the "planind" segment).
182 func pathSegmentContains(relSlash, queryLower string) bool {
183 for seg := range strings.SplitSeq(relSlash, "/") {
184 if strings.Contains(strings.ToLower(seg), queryLower) {
185 return true
186 }
187 }
188 return false
189 }
190
190 lines GO