1package modes
2
3import (
4 "os"
5 "path/filepath"
6 "regexp"
7 "sort"
8 "strings"
9 "time"
10
11 "github.com/sahilm/fuzzy"
12
13 "github.com/bnema/zut/packages/ignore"
14 "github.com/bnema/zut/packages/tui"
15)
16
17// recursiveScanLimits bound the recursive walk so the picker stays
18// responsive in very large repos. Hitting either cap stops the walk
19// early; the entries gathered so far are still searchable.
20//
21// The entry cap is set against the real bottleneck, which is not memory
22// but the per-keystroke fuzzy.Find ranking that scores every entry. A
23// fileEntry is ~120 bytes all-in (40-byte struct header plus the path
24// string), so even 50k entries is only ~6 MB. fuzzy.Find scales roughly
25// linearly: ~2 ms at 5k, ~13 ms at 50k, ~21 ms at 100k on a typical
26// laptop. 50k keeps ranking under one 60 Hz frame (~16 ms) while
27// comfortably holding a large monorepo once nested .gitignore pruning
28// (node_modules, build outputs, tool caches) has done its job. The
29// depth cap likewise guards against pathologically deep trees; 24
30// levels is far below anything a human navigates yet still reaches the
31// deeply nested source files real monorepos bury.
32const (
33 maxRecursiveEntries = 50000
34 maxRecursiveDepth = 24
35)
36
37// alwaysSkipDir is never descended into during a recursive scan,
38// regardless of .gitignore. .git is a repo-internal directory that
39// real projects rarely list in their own .gitignore yet never want
40// surfaced as an @-mention target.
41const alwaysSkipDir = ".git"
42
43// fileSuggester provides an @-triggered file/directory picker popup.
44// Type "@" followed by an optional filter to list files in the working
45// directory. Arrow up/down navigate, enter selects, esc cancels.
46// Right arrow opens a directory, left arrow goes back to the parent.
47type fileSuggester struct {
48 cursor int
49 lastMatches []fileEntry
50 cwd string // project root
51 browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
52 // recursive enables a whole-tree fuzzy search instead of
53 // directory-by-directory browsing. Mirrors the persisted
54 // recursive_file_suggest setting; toggled live from /settings.
55 recursive bool
56 // respectGitignore drops entries matched by the project's root
57 // .gitignore (and always .git) from both the flat and recursive
58 // listings. Mirrors the persisted respect_gitignore setting; on by
59 // default. Toggled live from /settings.
60 respectGitignore bool
61 cachedDir string // absolute directory we last scanned
62 cachedAll []fileEntry
63 // cachedMTime is the mtime of cachedDir at the time of the scan.
64 // scan() compares the current mtime against this on every call and
65 // re-reads the directory if it has changed, so files or folders
66 // created mid-session show up in the picker without restarting zut.
67 // Stat is cheap (single syscall) so doing it per keystroke while
68 // the popup is open does not impact responsiveness.
69 cachedMTime time.Time
70}
71
72type fileEntry struct {
73 name string // display name
74 rel string // path relative to cwd (used for chip insertion)
75 isDir bool
76}
77
78// newFileSuggester returns a picker that respects .gitignore by
79// default. Callers override via SetRespectGitignore once the persisted
80// setting is known.
81func newFileSuggester() *fileSuggester { return &fileSuggester{respectGitignore: true} }
82
83// SetCWD updates the project root.
84func (s *fileSuggester) SetCWD(cwd string) {
85 if s.cwd != cwd {
86 s.cwd = cwd
87 s.browseRel = ""
88 s.cachedDir = ""
89 s.cachedAll = nil
90 }
91}
92
93// SetRecursive toggles whole-tree fuzzy search. Switching modes drops
94// the cache and resets the browse position so the next render reflects
95// the new mode immediately.
96func (s *fileSuggester) SetRecursive(on bool) {
97 if s.recursive == on {
98 return
99 }
100 s.recursive = on
101 s.browseRel = ""
102 s.cachedDir = ""
103 s.cachedAll = nil
104 s.cursor = 0
105}
106
107// SetRespectGitignore toggles .gitignore filtering for both modes.
108// Switching drops the cache so the next scan reflects the new state.
109func (s *fileSuggester) SetRespectGitignore(on bool) {
110 if s.respectGitignore == on {
111 return
112 }
113 s.respectGitignore = on
114 s.cachedDir = ""
115 s.cachedAll = nil
116 s.cursor = 0
117}
118
119// browseDir returns the absolute directory currently being browsed.
120func (s *fileSuggester) browseDir() string {
121 if s.browseRel == "" {
122 return s.cwd
123 }
124 return filepath.Join(s.cwd, s.browseRel)
125}
126
127// scan reads entries from the current browse directory.
128//
129// Results are cached by absolute path + mtime: a repeated call against
130// the same directory returns the cached slice when nothing has changed
131// on disk, and re-reads when an entry was added, removed, or renamed
132// (any of which bumps the directory's mtime on every filesystem zut
133// supports). A failed stat falls through to a fresh ReadDir rather
134// than returning a stale cache so transient errors self-heal.
135func (s *fileSuggester) scan() []fileEntry {
136 if s.recursive {
137 return s.scanRecursive()
138 }
139 dir := s.browseDir()
140 var mtime time.Time
141 if info, err := os.Stat(dir); err == nil {
142 mtime = info.ModTime()
143 }
144 if s.cachedDir == dir && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
145 return s.cachedAll
146 }
147 entries, err := os.ReadDir(dir)
148 if err != nil {
149 return nil
150 }
151 var ig *ignore.Gitignore
152 if s.respectGitignore {
153 ig = ignore.Load(s.cwd)
154 }
155 var all []fileEntry
156 for _, e := range entries {
157 name := e.Name()
158 rel := name
159 if s.browseRel != "" {
160 rel = filepath.Join(s.browseRel, name)
161 }
162 if s.respectGitignore {
163 if e.IsDir() && name == alwaysSkipDir {
164 continue
165 }
166 if ig.Match(filepath.ToSlash(rel), e.IsDir()) {
167 continue
168 }
169 }
170 all = append(all, fileEntry{
171 name: name,
172 rel: rel,
173 isDir: e.IsDir(),
174 })
175 }
176 sort.Slice(all, func(i, j int) bool {
177 if all[i].isDir != all[j].isDir {
178 return all[i].isDir
179 }
180 return strings.ToLower(all[i].name) < strings.ToLower(all[j].name)
181 })
182 s.cachedAll = all
183 s.cachedDir = dir
184 s.cachedMTime = mtime
185 return all
186}
187
188// scanRecursive walks the whole project tree below cwd and returns
189// every file and directory as a fileEntry whose rel is the path
190// relative to cwd. The walk honors the project's root .gitignore (so
191// build outputs, dependency directories, and tool caches like
192// .terraform/.terragrunt-cache stay out of the picker) plus an
193// unconditional .git skip, and stops once it hits the entry/depth caps.
194//
195// Results are cached by cwd + mtime of cwd. Unlike the flat scan a
196// single mtime can't catch every nested change, so the cache is best
197// effort; Invalidate() (called on each keystroke path that matters)
198// and the explicit cache drops on toggle keep it fresh enough for an
199// interactive picker.
200func (s *fileSuggester) scanRecursive() []fileEntry {
201 root := s.cwd
202 var mtime time.Time
203 if info, err := os.Stat(root); err == nil {
204 mtime = info.ModTime()
205 }
206 if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
207 return s.cachedAll
208 }
209
210 var stack *ignore.Stack
211 if s.respectGitignore {
212 stack = ignore.NewStack(root)
213 }
214 var all []fileEntry
215 rootSep := strings.Count(root, string(os.PathSeparator))
216 // pushed mirrors the directories whose nested .gitignore we've
217 // brought into scope on stack but not yet dropped. WalkDir is
218 // depth-first in lexical order, so before each entry we pop frames
219 // for directories we've finished walking (those that are no longer
220 // an ancestor of the current path), then push the current
221 // directory. Honoring nested .gitignore files is what stops a
222 // vendored node_modules whose ignore rule lives in a subdirectory
223 // (e.g. .opencode/.gitignore) from swamping the entry budget before
224 // the walk ever reaches the user's deeply nested source file.
225 var pushed []string
226 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
227 if err != nil {
228 if d != nil && d.IsDir() {
229 return filepath.SkipDir
230 }
231 return nil
232 }
233 if path == root {
234 return nil
235 }
236 rel, relErr := filepath.Rel(root, path)
237 if relErr != nil {
238 return nil
239 }
240 relSlash := filepath.ToSlash(rel)
241 if s.respectGitignore {
242 // Drop stack frames for directories we've finished walking:
243 // any pushed dir that is not an ancestor of this entry's dir.
244 dirSlash := relSlash
245 if !d.IsDir() {
246 if i := strings.LastIndex(relSlash, "/"); i >= 0 {
247 dirSlash = relSlash[:i]
248 } else {
249 dirSlash = ""
250 }
251 }
252 for len(pushed) > 0 {
253 top := pushed[len(pushed)-1]
254 if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") {
255 break
256 }
257 pushed = pushed[:len(pushed)-1]
258 stack.Pop()
259 }
260 // .gitignore patterns are matched against slash-separated paths.
261 if stack.Match(relSlash, d.IsDir()) {
262 if d.IsDir() {
263 return filepath.SkipDir
264 }
265 return nil
266 }
267 }
268 if d.IsDir() {
269 // .git is always pruned: it's never a useful @-mention target
270 // and would otherwise blow the entry budget even when
271 // gitignore filtering is off.
272 if d.Name() == alwaysSkipDir {
273 return filepath.SkipDir
274 }
275 if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
276 return filepath.SkipDir
277 }
278 // This directory is kept; bring its nested .gitignore (if
279 // any) into scope for the descendants we're about to visit.
280 if s.respectGitignore {
281 stack.Push(path, rel)
282 pushed = append(pushed, relSlash)
283 }
284 }
285 all = append(all, fileEntry{
286 name: rel,
287 rel: rel,
288 isDir: d.IsDir(),
289 })
290 if len(all) >= maxRecursiveEntries {
291 return filepath.SkipAll
292 }
293 return nil
294 })
295
296 s.cachedAll = all
297 s.cachedDir = root
298 s.cachedMTime = mtime
299 return all
300}
301
302// extractAtQuery returns the filter string after "@".
303func extractAtQuery(input string) (string, bool) {
304 input = strings.TrimRight(input, " ")
305 idx := strings.LastIndex(input, "@")
306 if idx < 0 {
307 return "", false
308 }
309 if idx > 0 && input[idx-1] != ' ' {
310 return "", false
311 }
312 query := input[idx+1:]
313 if strings.ContainsAny(query, " \t\n") {
314 return "", false
315 }
316 return query, true
317}
318
319// matches returns file entries matching the current @-query.
320//
321// An empty query returns every entry in scan order. A non-empty query
322// is ranked with sahilm/fuzzy: in recursive mode the pattern is matched
323// against each entry's path relative to cwd (so "foobar" can find
324// "src/foo/bar.go"); in flat mode it matches the entry's display name.
325func (s *fileSuggester) matches(input string) []fileEntry {
326 query, ok := extractAtQuery(input)
327 if !ok {
328 return nil
329 }
330 all := s.scan()
331 if len(all) == 0 {
332 return nil
333 }
334 if query == "" {
335 return all
336 }
337 haystack := make([]string, len(all))
338 for i, e := range all {
339 if s.recursive {
340 haystack[i] = e.rel
341 } else {
342 haystack[i] = e.name
343 }
344 }
345 ranked := fuzzy.Find(query, haystack)
346 out := make([]fileEntry, 0, len(ranked))
347 for _, m := range ranked {
348 if m.Index >= 0 && m.Index < len(all) {
349 out = append(out, all[m.Index])
350 }
351 }
352 return out
353}
354
355// Active reports whether the popup should be visible.
356func (s *fileSuggester) Active(input string) bool {
357 return len(s.matches(input)) > 0
358}
359
360// Reset clears state.
361func (s *fileSuggester) Reset() {
362 s.cursor = 0
363 s.lastMatches = nil
364 s.browseRel = ""
365 s.cachedDir = ""
366 s.cachedAll = nil
367}
368
369// Invalidate forces a rescan.
370func (s *fileSuggester) Invalidate() {
371 s.cachedDir = ""
372 s.cachedAll = nil
373}
374
375func (s *fileSuggester) Up() {
376 if s.cursor > 0 {
377 s.cursor--
378 }
379}
380
381func (s *fileSuggester) Down() {
382 if s.cursor < len(s.lastMatches)-1 {
383 s.cursor++
384 }
385}
386
387// Right opens the selected directory, descending into it.
388// Returns true if a directory was entered. Disabled in recursive mode,
389// where the whole tree is already flattened into the result list.
390func (s *fileSuggester) Right() bool {
391 if s.recursive {
392 return false
393 }
394 m := s.lastMatches
395 if len(m) == 0 || s.cursor >= len(m) {
396 return false
397 }
398 e := m[s.cursor]
399 if !e.isDir {
400 return false
401 }
402 s.browseRel = e.rel
403 s.cachedDir = ""
404 s.cachedAll = nil
405 s.cursor = 0
406 return true
407}
408
409// Left goes back to the parent directory.
410// Returns true if we moved up. Disabled in recursive mode.
411func (s *fileSuggester) Left() bool {
412 if s.recursive {
413 return false
414 }
415 if s.browseRel == "" {
416 return false
417 }
418 parent := filepath.Dir(s.browseRel)
419 if parent == "." {
420 parent = ""
421 }
422 s.browseRel = parent
423 s.cachedDir = ""
424 s.cachedAll = nil
425 s.cursor = 0
426 return true
427}
428
429// Selection returns the relative path of the currently highlighted entry.
430func (s *fileSuggester) Selection(input string) string {
431 m := s.matches(input)
432 if len(m) == 0 {
433 return ""
434 }
435 if s.cursor >= len(m) {
436 s.cursor = len(m) - 1
437 }
438 return m[s.cursor].rel
439}
440
441// SelectedEntry returns the currently highlighted entry.
442func (s *fileSuggester) SelectedEntry(input string) (fileEntry, bool) {
443 m := s.matches(input)
444 if len(m) == 0 {
445 return fileEntry{}, false
446 }
447 if s.cursor >= len(m) {
448 s.cursor = len(m) - 1
449 }
450 return m[s.cursor], true
451}
452
453// Render returns the popup lines.
454func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
455 m := s.matches(input)
456 if len(m) == 0 {
457 return nil
458 }
459 s.lastMatches = m
460 if s.cursor >= len(m) {
461 s.cursor = len(m) - 1
462 }
463
464 const maxVisible = 12
465 visible := m
466 offset := 0
467 if len(m) > maxVisible {
468 if s.cursor >= maxVisible {
469 offset = s.cursor - maxVisible + 1
470 }
471 end := offset + maxVisible
472 if end > len(m) {
473 end = len(m)
474 offset = end - maxVisible
475 }
476 visible = m[offset:end]
477 }
478
479 var out []string
480
481 // Show breadcrumb when browsing a subdirectory.
482 if s.browseRel != "" {
483 crumb := th.FGColor(th.Accent, " "+s.browseRel+"/")
484 out = append(out, crumb)
485 }
486
487 for i, e := range visible {
488 idx := offset + i
489 name := e.name
490 if e.isDir {
491 name += "/"
492 }
493 plain := " " + name
494 if idx == s.cursor {
495 out = append(out, th.PadHighlight(plain, width))
496 } else {
497 out = append(out, th.FGColor(th.Muted, plain))
498 }
499 }
500
501 out = append(out, "")
502 var hint string
503 switch {
504 case s.recursive:
505 hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)"
506 case s.browseRel != "":
507 hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
508 default:
509 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
510 }
511 out = append(out, th.FGColor(th.Muted, hint))
512 out = append(out, "")
513 return out
514}
515
516// fileChipRE matches @-picker chips inserted by modes:
517// [file:relative/path] and [dir:relative/path/]. The lower-level
518// tui.Editor has its own drag/drop chips ([file:N:basename]) which
519// Editor.SubmitValue expands before callers reach this helper; if one
520// leaks through, leave it untouched rather than guessing at a path.
521var fileChipRE = regexp.MustCompile(`\[(file|dir):([^\]]+)\]`)
522var editorFileChipRE = regexp.MustCompile(`^\d+:`)
523
524// expandFileChips replaces [file:name] and [dir:name/] chips with
525// the full path relative to cwd.
526func expandFileChips(text, cwd string) string {
527 return fileChipRE.ReplaceAllStringFunc(text, func(match string) string {
528 groups := fileChipRE.FindStringSubmatch(match)
529 if len(groups) < 3 {
530 return match
531 }
532 name := groups[2]
533 if editorFileChipRE.MatchString(name) {
534 return match
535 }
536 // Strip trailing slash from dir names.
537 name = strings.TrimRight(name, "/")
538 return filepath.Join(cwd, name)
539 })
540}