v0.38.0 58133ea

file_suggest.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close