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