1// Package filepicker provides a file picker component for Bubble Tea
2// applications.
3package filepicker
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "sort"
10 "strconv"
11 "strings"
12 "sync/atomic"
13
14 "github.com/charmbracelet/bubbles/v2/key"
15 tea "github.com/charmbracelet/bubbletea/v2"
16 "github.com/charmbracelet/lipgloss/v2"
17 "github.com/dustin/go-humanize"
18)
19
20var lastID int64
21
22func nextID() int {
23 return int(atomic.AddInt64(&lastID, 1))
24}
25
26// New returns a new filepicker model with default styling and key bindings.
27func New() Model {
28 return Model{
29 id: nextID(),
30 CurrentDirectory: ".",
31 Cursor: ">",
32 AllowedTypes: []string{},
33 selected: 0,
34 ShowPermissions: true,
35 ShowSize: true,
36 ShowHidden: false,
37 DirAllowed: false,
38 FileAllowed: true,
39 AutoHeight: true,
40 height: 0,
41 maxIdx: 0,
42 minIdx: 0,
43 selectedStack: newStack(),
44 minStack: newStack(),
45 maxStack: newStack(),
46 KeyMap: DefaultKeyMap(),
47 Styles: DefaultStyles(),
48 }
49}
50
51type errorMsg struct {
52 err error
53}
54
55type readDirMsg struct {
56 id int
57 entries []os.DirEntry
58}
59
60const (
61 marginBottom = 5
62 fileSizeWidth = 7
63 paddingLeft = 2
64)
65
66// KeyMap defines key bindings for each user action.
67type KeyMap struct {
68 GoToTop key.Binding
69 GoToLast key.Binding
70 Down key.Binding
71 Up key.Binding
72 PageUp key.Binding
73 PageDown key.Binding
74 Back key.Binding
75 Open key.Binding
76 Select key.Binding
77}
78
79// DefaultKeyMap defines the default keybindings.
80func DefaultKeyMap() KeyMap {
81 return KeyMap{
82 GoToTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first")),
83 GoToLast: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last")),
84 Down: key.NewBinding(key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j", "down")),
85 Up: key.NewBinding(key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k", "up")),
86 PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up")),
87 PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down")),
88 Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")),
89 Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")),
90 Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
91 }
92}
93
94// Styles defines the possible customizations for styles in the file picker.
95type Styles struct {
96 DisabledCursor lipgloss.Style
97 Cursor lipgloss.Style
98 Symlink lipgloss.Style
99 Directory lipgloss.Style
100 File lipgloss.Style
101 DisabledFile lipgloss.Style
102 Permission lipgloss.Style
103 Selected lipgloss.Style
104 DisabledSelected lipgloss.Style
105 FileSize lipgloss.Style
106 EmptyDirectory lipgloss.Style
107}
108
109// DefaultStyles defines the default styling for the file picker.
110func DefaultStyles() Styles {
111 return Styles{
112 DisabledCursor: lipgloss.NewStyle().Foreground(lipgloss.Color("247")),
113 Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")),
114 Symlink: lipgloss.NewStyle().Foreground(lipgloss.Color("36")),
115 Directory: lipgloss.NewStyle().Foreground(lipgloss.Color("99")),
116 File: lipgloss.NewStyle(),
117 DisabledFile: lipgloss.NewStyle().Foreground(lipgloss.Color("243")),
118 DisabledSelected: lipgloss.NewStyle().Foreground(lipgloss.Color("247")),
119 Permission: lipgloss.NewStyle().Foreground(lipgloss.Color("244")),
120 Selected: lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true),
121 FileSize: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right),
122 EmptyDirectory: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."),
123 }
124}
125
126// Model represents a file picker.
127type Model struct {
128 id int
129
130 // Path is the path which the user has selected with the file picker.
131 Path string
132
133 // CurrentDirectory is the directory that the user is currently in.
134 CurrentDirectory string
135
136 // AllowedTypes specifies which file types the user may select.
137 // If empty the user may select any file.
138 AllowedTypes []string
139
140 KeyMap KeyMap
141 files []os.DirEntry
142 ShowPermissions bool
143 ShowSize bool
144 ShowHidden bool
145 DirAllowed bool
146 FileAllowed bool
147
148 FileSelected string
149 selected int
150 selectedStack stack
151
152 minIdx int
153 maxIdx int
154 maxStack stack
155 minStack stack
156
157 height int
158 AutoHeight bool
159
160 Cursor string
161 Styles Styles
162}
163
164type stack struct {
165 Push func(int)
166 Pop func() int
167 Length func() int
168}
169
170func newStack() stack {
171 slice := make([]int, 0)
172 return stack{
173 Push: func(i int) {
174 slice = append(slice, i)
175 },
176 Pop: func() int {
177 res := slice[len(slice)-1]
178 slice = slice[:len(slice)-1]
179 return res
180 },
181 Length: func() int {
182 return len(slice)
183 },
184 }
185}
186
187func (m *Model) pushView(selected, minimum, maximum int) {
188 m.selectedStack.Push(selected)
189 m.minStack.Push(minimum)
190 m.maxStack.Push(maximum)
191}
192
193func (m *Model) popView() (int, int, int) {
194 return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop()
195}
196
197func (m Model) readDir(path string, showHidden bool) tea.Cmd {
198 return func() tea.Msg {
199 dirEntries, err := os.ReadDir(path)
200 if err != nil {
201 return errorMsg{err}
202 }
203
204 sort.Slice(dirEntries, func(i, j int) bool {
205 if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
206 return dirEntries[i].Name() < dirEntries[j].Name()
207 }
208 return dirEntries[i].IsDir()
209 })
210
211 if showHidden {
212 return readDirMsg{id: m.id, entries: dirEntries}
213 }
214
215 var sanitizedDirEntries []os.DirEntry
216 for _, dirEntry := range dirEntries {
217 isHidden, _ := IsHidden(dirEntry.Name())
218 if isHidden {
219 continue
220 }
221 sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
222 }
223 return readDirMsg{id: m.id, entries: sanitizedDirEntries}
224 }
225}
226
227// SetHeight sets the height of the file picker.
228func (m *Model) SetHeight(h int) {
229 m.height = h
230 if m.maxIdx > m.height-1 {
231 m.maxIdx = m.minIdx + m.height - 1
232 }
233}
234
235// Height returns the height of the file picker.
236func (m Model) Height() int {
237 return m.height
238}
239
240// Init initializes the file picker model.
241func (m Model) Init() tea.Cmd {
242 return m.readDir(m.CurrentDirectory, m.ShowHidden)
243}
244
245// Update handles user interactions within the file picker model.
246func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
247 switch msg := msg.(type) {
248 case readDirMsg:
249 if msg.id != m.id {
250 break
251 }
252 m.files = msg.entries
253 m.maxIdx = max(m.maxIdx, m.Height()-1)
254 case tea.WindowSizeMsg:
255 if m.AutoHeight {
256 m.SetHeight(msg.Height - marginBottom)
257 }
258 m.maxIdx = m.Height() - 1
259 case tea.KeyPressMsg:
260 switch {
261 case key.Matches(msg, m.KeyMap.GoToTop):
262 m.selected = 0
263 m.minIdx = 0
264 m.maxIdx = m.Height() - 1
265 case key.Matches(msg, m.KeyMap.GoToLast):
266 m.selected = len(m.files) - 1
267 m.minIdx = len(m.files) - m.Height()
268 m.maxIdx = len(m.files) - 1
269 case key.Matches(msg, m.KeyMap.Down):
270 m.selected++
271 if m.selected >= len(m.files) {
272 m.selected = len(m.files) - 1
273 }
274 if m.selected > m.maxIdx {
275 m.minIdx++
276 m.maxIdx++
277 }
278 case key.Matches(msg, m.KeyMap.Up):
279 m.selected--
280 if m.selected < 0 {
281 m.selected = 0
282 }
283 if m.selected < m.minIdx {
284 m.minIdx--
285 m.maxIdx--
286 }
287 case key.Matches(msg, m.KeyMap.PageDown):
288 m.selected += m.Height()
289 if m.selected >= len(m.files) {
290 m.selected = len(m.files) - 1
291 }
292 m.minIdx += m.Height()
293 m.maxIdx += m.Height()
294
295 if m.maxIdx >= len(m.files) {
296 m.maxIdx = len(m.files) - 1
297 m.minIdx = m.maxIdx - m.Height()
298 }
299 case key.Matches(msg, m.KeyMap.PageUp):
300 m.selected -= m.Height()
301 if m.selected < 0 {
302 m.selected = 0
303 }
304 m.minIdx -= m.Height()
305 m.maxIdx -= m.Height()
306
307 if m.minIdx < 0 {
308 m.minIdx = 0
309 m.maxIdx = m.minIdx + m.Height()
310 }
311 case key.Matches(msg, m.KeyMap.Back):
312 m.CurrentDirectory = filepath.Dir(m.CurrentDirectory)
313 if m.selectedStack.Length() > 0 {
314 m.selected, m.minIdx, m.maxIdx = m.popView()
315 } else {
316 m.selected = 0
317 m.minIdx = 0
318 m.maxIdx = m.Height() - 1
319 }
320 return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
321 case key.Matches(msg, m.KeyMap.Open):
322 if len(m.files) == 0 {
323 break
324 }
325
326 f := m.files[m.selected]
327 info, err := f.Info()
328 if err != nil {
329 break
330 }
331 isSymlink := info.Mode()&os.ModeSymlink != 0
332 isDir := f.IsDir()
333
334 if isSymlink {
335 symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name()))
336 info, err := os.Stat(symlinkPath)
337 if err != nil {
338 break
339 }
340 if info.IsDir() {
341 isDir = true
342 }
343 }
344
345 if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) {
346 if key.Matches(msg, m.KeyMap.Select) {
347 // Select the current path as the selection
348 m.Path = filepath.Join(m.CurrentDirectory, f.Name())
349 }
350 }
351
352 if !isDir {
353 break
354 }
355
356 m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name())
357 m.pushView(m.selected, m.minIdx, m.maxIdx)
358 m.selected = 0
359 m.minIdx = 0
360 m.maxIdx = m.Height() - 1
361 return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
362 }
363 }
364 return m, nil
365}
366
367// View returns the view of the file picker.
368func (m Model) View() string {
369 if len(m.files) == 0 {
370 return m.Styles.EmptyDirectory.Height(m.Height()).MaxHeight(m.Height()).String()
371 }
372 var s strings.Builder
373
374 for i, f := range m.files {
375 if i < m.minIdx || i > m.maxIdx {
376 continue
377 }
378
379 var symlinkPath string
380 info, _ := f.Info()
381 isSymlink := info.Mode()&os.ModeSymlink != 0
382 size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec
383 name := f.Name()
384
385 if isSymlink {
386 symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name))
387 }
388
389 disabled := !m.canSelect(name) && !f.IsDir()
390
391 if m.selected == i { //nolint:nestif
392 selected := ""
393 if m.ShowPermissions {
394 selected += " " + info.Mode().String()
395 }
396 if m.ShowSize {
397 selected += fmt.Sprintf("%"+strconv.Itoa(m.Styles.FileSize.GetWidth())+"s", size)
398 }
399 selected += " " + name
400 if isSymlink {
401 selected += " → " + symlinkPath
402 }
403 if disabled {
404 s.WriteString(m.Styles.DisabledCursor.Render(m.Cursor) + m.Styles.DisabledSelected.Render(selected))
405 } else {
406 s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected))
407 }
408 s.WriteRune('\n')
409 continue
410 }
411
412 style := m.Styles.File
413 if f.IsDir() {
414 style = m.Styles.Directory
415 } else if isSymlink {
416 style = m.Styles.Symlink
417 } else if disabled {
418 style = m.Styles.DisabledFile
419 }
420
421 fileName := style.Render(name)
422 s.WriteString(m.Styles.Cursor.Render(" "))
423 if isSymlink {
424 fileName += " → " + symlinkPath
425 }
426 if m.ShowPermissions {
427 s.WriteString(" " + m.Styles.Permission.Render(info.Mode().String()))
428 }
429 if m.ShowSize {
430 s.WriteString(m.Styles.FileSize.Render(size))
431 }
432 s.WriteString(" " + fileName)
433 s.WriteRune('\n')
434 }
435
436 for i := lipgloss.Height(s.String()); i <= m.Height(); i++ {
437 s.WriteRune('\n')
438 }
439
440 return s.String()
441}
442
443// DidSelectFile returns whether a user has selected a file (on this msg).
444func (m Model) DidSelectFile(msg tea.Msg) (bool, string) {
445 didSelect, path := m.didSelectFile(msg)
446 if didSelect && m.canSelect(path) {
447 return true, path
448 }
449 return false, ""
450}
451
452// DidSelectDisabledFile returns whether a user tried to select a disabled file
453// (on this msg). This is necessary only if you would like to warn the user that
454// they tried to select a disabled file.
455func (m Model) DidSelectDisabledFile(msg tea.Msg) (bool, string) {
456 didSelect, path := m.didSelectFile(msg)
457 if didSelect && !m.canSelect(path) {
458 return true, path
459 }
460 return false, ""
461}
462
463func (m Model) didSelectFile(msg tea.Msg) (bool, string) {
464 if len(m.files) == 0 {
465 return false, ""
466 }
467 switch msg := msg.(type) {
468 case tea.KeyPressMsg:
469 // If the msg does not match the Select keymap then this could not have been a selection.
470 if !key.Matches(msg, m.KeyMap.Select) {
471 return false, ""
472 }
473
474 // The key press was a selection, let's confirm whether the current file could
475 // be selected or used for navigating deeper into the stack.
476 f := m.files[m.selected]
477 info, err := f.Info()
478 if err != nil {
479 return false, ""
480 }
481 isSymlink := info.Mode()&os.ModeSymlink != 0
482 isDir := f.IsDir()
483
484 if isSymlink {
485 symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name()))
486 info, err := os.Stat(symlinkPath)
487 if err != nil {
488 break
489 }
490 if info.IsDir() {
491 isDir = true
492 }
493 }
494
495 if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) && m.Path != "" {
496 return true, m.Path
497 }
498
499 // If the msg was not a KeyPressMsg, then the file could not have been selected this iteration.
500 // Only a KeyPressMsg can select a file.
501 default:
502 return false, ""
503 }
504 return false, ""
505}
506
507func (m Model) canSelect(file string) bool {
508 if len(m.AllowedTypes) <= 0 {
509 return true
510 }
511
512 for _, ext := range m.AllowedTypes {
513 if strings.HasSuffix(file, ext) {
514 return true
515 }
516 }
517 return false
518}
519
520// HighlightedPath returns the path of the currently highlighted file or directory.
521func (m Model) HighlightedPath() string {
522 if len(m.files) == 0 || m.selected < 0 || m.selected >= len(m.files) {
523 return ""
524 }
525 return filepath.Join(m.CurrentDirectory, m.files[m.selected].Name())
526}