1package modes
2
3import (
4 "os"
5 "path/filepath"
6 "regexp"
7 "sort"
8 "strings"
9 "time"
10
11 "github.com/patriceckhart/zot/internal/tui"
12)
13
14// fileSuggester provides an @-triggered file/directory picker popup.
15// Type "@" followed by an optional filter to list files in the working
16// directory. Arrow up/down navigate, enter selects, esc cancels.
17// Right arrow opens a directory, left arrow goes back to the parent.
18type fileSuggester struct {
19 cursor int
20 lastMatches []fileEntry
21 cwd string // project root
22 browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
23 cachedDir string // absolute directory we last scanned
24 cachedAll []fileEntry
25 // cachedMTime is the mtime of cachedDir at the time of the scan.
26 // scan() compares the current mtime against this on every call and
27 // re-reads the directory if it has changed, so files or folders
28 // created mid-session show up in the picker without restarting zot.
29 // Stat is cheap (single syscall) so doing it per keystroke while
30 // the popup is open does not impact responsiveness.
31 cachedMTime time.Time
32}
33
34type fileEntry struct {
35 name string // display name
36 rel string // path relative to cwd (used for chip insertion)
37 isDir bool
38}
39
40func newFileSuggester() *fileSuggester { return &fileSuggester{} }
41
42// SetCWD updates the project root.
43func (s *fileSuggester) SetCWD(cwd string) {
44 if s.cwd != cwd {
45 s.cwd = cwd
46 s.browseRel = ""
47 s.cachedDir = ""
48 s.cachedAll = nil
49 }
50}
51
52// browseDir returns the absolute directory currently being browsed.
53func (s *fileSuggester) browseDir() string {
54 if s.browseRel == "" {
55 return s.cwd
56 }
57 return filepath.Join(s.cwd, s.browseRel)
58}
59
60// scan reads entries from the current browse directory.
61//
62// Results are cached by absolute path + mtime: a repeated call against
63// the same directory returns the cached slice when nothing has changed
64// on disk, and re-reads when an entry was added, removed, or renamed
65// (any of which bumps the directory's mtime on every filesystem zot
66// supports). A failed stat falls through to a fresh ReadDir rather
67// than returning a stale cache so transient errors self-heal.
68func (s *fileSuggester) scan() []fileEntry {
69 dir := s.browseDir()
70 var mtime time.Time
71 if info, err := os.Stat(dir); err == nil {
72 mtime = info.ModTime()
73 }
74 if s.cachedDir == dir && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
75 return s.cachedAll
76 }
77 entries, err := os.ReadDir(dir)
78 if err != nil {
79 return nil
80 }
81 var all []fileEntry
82 for _, e := range entries {
83 name := e.Name()
84 rel := name
85 if s.browseRel != "" {
86 rel = filepath.Join(s.browseRel, name)
87 }
88 all = append(all, fileEntry{
89 name: name,
90 rel: rel,
91 isDir: e.IsDir(),
92 })
93 }
94 sort.Slice(all, func(i, j int) bool {
95 if all[i].isDir != all[j].isDir {
96 return all[i].isDir
97 }
98 return strings.ToLower(all[i].name) < strings.ToLower(all[j].name)
99 })
100 s.cachedAll = all
101 s.cachedDir = dir
102 s.cachedMTime = mtime
103 return all
104}
105
106// extractAtQuery returns the filter string after "@".
107func extractAtQuery(input string) (string, bool) {
108 input = strings.TrimRight(input, " ")
109 idx := strings.LastIndex(input, "@")
110 if idx < 0 {
111 return "", false
112 }
113 if idx > 0 && input[idx-1] != ' ' {
114 return "", false
115 }
116 query := input[idx+1:]
117 if strings.ContainsAny(query, " \t\n") {
118 return "", false
119 }
120 return query, true
121}
122
123// matches returns file entries matching the current @-query.
124func (s *fileSuggester) matches(input string) []fileEntry {
125 query, ok := extractAtQuery(input)
126 if !ok {
127 return nil
128 }
129 all := s.scan()
130 if len(all) == 0 {
131 return nil
132 }
133 needle := strings.ToLower(query)
134 if needle == "" {
135 return all
136 }
137 var out []fileEntry
138 for _, e := range all {
139 if strings.Contains(strings.ToLower(e.name), needle) {
140 out = append(out, e)
141 }
142 }
143 return out
144}
145
146// Active reports whether the popup should be visible.
147func (s *fileSuggester) Active(input string) bool {
148 return len(s.matches(input)) > 0
149}
150
151// Reset clears state.
152func (s *fileSuggester) Reset() {
153 s.cursor = 0
154 s.lastMatches = nil
155 s.browseRel = ""
156 s.cachedDir = ""
157 s.cachedAll = nil
158}
159
160// Invalidate forces a rescan.
161func (s *fileSuggester) Invalidate() {
162 s.cachedDir = ""
163 s.cachedAll = nil
164}
165
166func (s *fileSuggester) Up() {
167 if s.cursor > 0 {
168 s.cursor--
169 }
170}
171
172func (s *fileSuggester) Down() {
173 if s.cursor < len(s.lastMatches)-1 {
174 s.cursor++
175 }
176}
177
178// Right opens the selected directory, descending into it.
179// Returns true if a directory was entered.
180func (s *fileSuggester) Right() bool {
181 m := s.lastMatches
182 if len(m) == 0 || s.cursor >= len(m) {
183 return false
184 }
185 e := m[s.cursor]
186 if !e.isDir {
187 return false
188 }
189 s.browseRel = e.rel
190 s.cachedDir = ""
191 s.cachedAll = nil
192 s.cursor = 0
193 return true
194}
195
196// Left goes back to the parent directory.
197// Returns true if we moved up.
198func (s *fileSuggester) Left() bool {
199 if s.browseRel == "" {
200 return false
201 }
202 parent := filepath.Dir(s.browseRel)
203 if parent == "." {
204 parent = ""
205 }
206 s.browseRel = parent
207 s.cachedDir = ""
208 s.cachedAll = nil
209 s.cursor = 0
210 return true
211}
212
213// Selection returns the relative path of the currently highlighted entry.
214func (s *fileSuggester) Selection(input string) string {
215 m := s.matches(input)
216 if len(m) == 0 {
217 return ""
218 }
219 if s.cursor >= len(m) {
220 s.cursor = len(m) - 1
221 }
222 return m[s.cursor].rel
223}
224
225// SelectedEntry returns the currently highlighted entry.
226func (s *fileSuggester) SelectedEntry(input string) (fileEntry, bool) {
227 m := s.matches(input)
228 if len(m) == 0 {
229 return fileEntry{}, false
230 }
231 if s.cursor >= len(m) {
232 s.cursor = len(m) - 1
233 }
234 return m[s.cursor], true
235}
236
237// Render returns the popup lines.
238func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
239 m := s.matches(input)
240 if len(m) == 0 {
241 return nil
242 }
243 s.lastMatches = m
244 if s.cursor >= len(m) {
245 s.cursor = len(m) - 1
246 }
247
248 const maxVisible = 12
249 visible := m
250 offset := 0
251 if len(m) > maxVisible {
252 if s.cursor >= maxVisible {
253 offset = s.cursor - maxVisible + 1
254 }
255 end := offset + maxVisible
256 if end > len(m) {
257 end = len(m)
258 offset = end - maxVisible
259 }
260 visible = m[offset:end]
261 }
262
263 var out []string
264
265 // Show breadcrumb when browsing a subdirectory.
266 if s.browseRel != "" {
267 crumb := th.FG256(th.Accent, " "+s.browseRel+"/")
268 out = append(out, crumb)
269 }
270
271 for i, e := range visible {
272 idx := offset + i
273 name := e.name
274 if e.isDir {
275 name += "/"
276 }
277 plain := " " + name
278 if idx == s.cursor {
279 out = append(out, th.PadHighlight(plain, width))
280 } else {
281 out = append(out, th.FG256(th.Muted, plain))
282 }
283 }
284
285 out = append(out, "")
286 hint := " \u2191/\u2193 navigate - enter select - esc cancel"
287 if s.browseRel != "" {
288 hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
289 } else {
290 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
291 }
292 out = append(out, th.FG256(th.Muted, hint))
293 out = append(out, "")
294 return out
295}
296
297// fileChipRE matches @-picker chips inserted by modes:
298// [file:relative/path] and [dir:relative/path/]. The lower-level
299// tui.Editor has its own drag/drop chips ([file:N:basename]) which
300// Editor.SubmitValue expands before callers reach this helper; if one
301// leaks through, leave it untouched rather than guessing at a path.
302var fileChipRE = regexp.MustCompile(`\[(file|dir):([^\]]+)\]`)
303var editorFileChipRE = regexp.MustCompile(`^\d+:`)
304
305// expandFileChips replaces [file:name] and [dir:name/] chips with
306// the full path relative to cwd.
307func expandFileChips(text, cwd string) string {
308 return fileChipRE.ReplaceAllStringFunc(text, func(match string) string {
309 groups := fileChipRE.FindStringSubmatch(match)
310 if len(groups) < 3 {
311 return match
312 }
313 name := groups[2]
314 if editorFileChipRE.MatchString(name) {
315 return match
316 }
317 // Strip trailing slash from dir names.
318 name = strings.TrimRight(name, "/")
319 return filepath.Join(cwd, name)
320 })
321}