v0.1.98 d55af26

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/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}

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 477

↑↓ moveenter openesc close