返回 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 file pickers. rel
41 // is the entry's slash-separated path from the workspace root.
42 func SkipEntry(rel, name string, isDir bool) bool {
43 if skipEntryNames[name] {
44 return true
45 }
46 return isDir && (skipDirNames[name] || skipDirPaths[rel])
47 }
48
49 var skipDirPaths = map[string]bool{
50 "bin": true,
51 "desktop/frontend/wailsjs": true,
52 "npm/.stage": true,
53 "site/.astro": true,
54 "stage": true,
55 "tmp": true,
56 }
57
58 const (
59 minQueryLen = 2
60 maxWalkEntries = 10000
61 )
62
63 // SearchResult is a single entry returned by Search. It carries the relative
64 // path (slash-normalized) and whether the entry is a directory, so callers
65 // can present the correct icon and append "/" vs " " on selection.
66 type SearchResult struct {
67 Path string
68 IsDir bool
69 }
70
71 // Search finds entries under root whose path matches query. A match is
72 // recorded when the query is a substring of the file's basename (preferred
73 // tier), of any slash-separated path segment (fallback tier), or of a
74 // directory name (lowest tier). It is bounded by limit and skips common
75 // generated/vendor directories so interactive completion stays responsive on
76 // large workspaces.
77 func Search(root, query string, limit int) []SearchResult {
78 query = strings.ToLower(strings.TrimSpace(query))
79 if len(query) < minQueryLen || strings.ContainsAny(query, `/\`) || limit <= 0 {
80 return nil
81 }
82
83 showHidden := strings.HasPrefix(query, ".")
84 var basenameHits []SearchResult
85 var segmentHits []SearchResult
86 var dirHits []SearchResult
87 visited := 0
88 _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
89 if err != nil {
90 if d != nil && d.IsDir() {
91 return filepath.SkipDir
92 }
93 return nil
94 }
95 if path == root {
96 return nil
97 }
98 visited++
99 if visited > maxWalkEntries {
100 return filepath.SkipAll
101 }
102
103 name := d.Name()
104 if d.IsDir() {
105 rel, err := filepath.Rel(root, path)
106 if err != nil {
107 return filepath.SkipDir
108 }
109 rel = filepath.ToSlash(rel)
110 if SkipEntry(rel, name, true) || (!showHidden && strings.HasPrefix(name, ".")) {
111 return filepath.SkipDir
112 }
113 // Allow matching directory names so the user can select a
114 // folder directly from the @-menu instead of only its contents.
115 if strings.Contains(strings.ToLower(name), query) {
116 dirHits = append(dirHits, SearchResult{Path: rel, IsDir: true})
117 }
118 return nil
119 }
120 if skipEntryNames[name] {
121 return nil
122 }
123 if !showHidden && strings.HasPrefix(name, ".") {
124 return nil
125 }
126 if info, err := d.Info(); err != nil || !info.Mode().IsRegular() {
127 return nil
128 }
129 rel, err := filepath.Rel(root, path)
130 if err != nil {
131 return nil
132 }
133 rel = filepath.ToSlash(rel)
134 nameLower := strings.ToLower(name)
135 switch {
136 case strings.Contains(nameLower, query):
137 basenameHits = append(basenameHits, SearchResult{Path: rel})
138 case pathSegmentContains(rel, query):
139 segmentHits = append(segmentHits, SearchResult{Path: rel})
140 }
141 return nil
142 })
143 sort.Slice(basenameHits, func(i, j int) bool { return basenameHits[i].Path < basenameHits[j].Path })
144 sort.Slice(segmentHits, func(i, j int) bool { return segmentHits[i].Path < segmentHits[j].Path })
145 sort.Slice(dirHits, func(i, j int) bool { return dirHits[i].Path < dirHits[j].Path })
146 // Directories first so the user can navigate into them; then basename
147 // hits (most relevant file matches); then path-segment hits. We reserve
148 // up to dirQuota slots for directories so they are never fully crowded
149 // out by a large number of file matches.
150 const dirQuota = 5
151 out := make([]SearchResult, 0, limit)
152 nDirs := len(dirHits)
153 if nDirs > dirQuota {
154 nDirs = dirQuota
155 }
156 out = append(out, dirHits[:nDirs]...)
157 remaining := limit - len(out)
158 if remaining > 0 {
159 if len(basenameHits) > remaining {
160 basenameHits = basenameHits[:remaining]
161 }
162 out = append(out, basenameHits...)
163 remaining = limit - len(out)
164 }
165 if remaining > 0 {
166 if len(segmentHits) > remaining {
167 segmentHits = segmentHits[:remaining]
168 }
169 out = append(out, segmentHits...)
170 }
171 return out
172 }
173
174 // pathSegmentContains reports whether query appears in any slash-separated
175 // segment of the slash-normalized relative path. The basename is matched
176 // independently by the caller, so this helper is meaningful only for
177 // directories above the file (e.g. "src/planind/index.tsx" with query
178 // "planind" matches the "planind" segment).
179 func pathSegmentContains(relSlash, queryLower string) bool {
180 for _, seg := range strings.Split(relSlash, "/") {
181 if strings.Contains(strings.ToLower(seg), queryLower) {
182 return true
183 }
184 }
185 return false
186 }
187
187 lines GO