filepicker.go

  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}