// Package filepicker provides a file picker component for Bubble Tea // applications. package filepicker import ( "fmt" "os" "path/filepath" "sort" "strconv" "strings" "sync/atomic" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/dustin/go-humanize" ) var lastID int64 func nextID() int { return int(atomic.AddInt64(&lastID, 1)) } // New returns a new filepicker model with default styling and key bindings. func New() Model { return Model{ id: nextID(), CurrentDirectory: ".", Cursor: ">", AllowedTypes: []string{}, selected: 0, ShowPermissions: true, ShowSize: true, ShowHidden: false, DirAllowed: false, FileAllowed: true, AutoHeight: true, Height: 0, max: 0, min: 0, selectedStack: newStack(), minStack: newStack(), maxStack: newStack(), KeyMap: DefaultKeyMap(), Styles: DefaultStyles(), } } type errorMsg struct { err error } type readDirMsg struct { id int entries []os.DirEntry } const ( marginBottom = 5 fileSizeWidth = 7 paddingLeft = 2 ) // KeyMap defines key bindings for each user action. type KeyMap struct { GoToTop key.Binding GoToLast key.Binding Down key.Binding Up key.Binding PageUp key.Binding PageDown key.Binding Back key.Binding Open key.Binding Select key.Binding } // DefaultKeyMap defines the default keybindings. func DefaultKeyMap() KeyMap { return KeyMap{ GoToTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first")), GoToLast: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last")), Down: key.NewBinding(key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j", "down")), Up: key.NewBinding(key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k", "up")), PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up")), PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down")), Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")), Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")), Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), } } // Styles defines the possible customizations for styles in the file picker. type Styles struct { DisabledCursor lipgloss.Style Cursor lipgloss.Style Symlink lipgloss.Style Directory lipgloss.Style File lipgloss.Style DisabledFile lipgloss.Style Permission lipgloss.Style Selected lipgloss.Style DisabledSelected lipgloss.Style FileSize lipgloss.Style EmptyDirectory lipgloss.Style } // DefaultStyles defines the default styling for the file picker. func DefaultStyles() Styles { return DefaultStylesWithRenderer(lipgloss.DefaultRenderer()) } // DefaultStylesWithRenderer defines the default styling for the file picker, // with a given Lip Gloss renderer. func DefaultStylesWithRenderer(r *lipgloss.Renderer) Styles { return Styles{ DisabledCursor: r.NewStyle().Foreground(lipgloss.Color("247")), Cursor: r.NewStyle().Foreground(lipgloss.Color("212")), Symlink: r.NewStyle().Foreground(lipgloss.Color("36")), Directory: r.NewStyle().Foreground(lipgloss.Color("99")), File: r.NewStyle(), DisabledFile: r.NewStyle().Foreground(lipgloss.Color("243")), DisabledSelected: r.NewStyle().Foreground(lipgloss.Color("247")), Permission: r.NewStyle().Foreground(lipgloss.Color("244")), Selected: r.NewStyle().Foreground(lipgloss.Color("212")).Bold(true), FileSize: r.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right), EmptyDirectory: r.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."), } } // Model represents a file picker. type Model struct { id int // Path is the path which the user has selected with the file picker. Path string // CurrentDirectory is the directory that the user is currently in. CurrentDirectory string // AllowedTypes specifies which file types the user may select. // If empty the user may select any file. AllowedTypes []string KeyMap KeyMap files []os.DirEntry ShowPermissions bool ShowSize bool ShowHidden bool DirAllowed bool FileAllowed bool FileSelected string selected int selectedStack stack min int max int maxStack stack minStack stack // Height of the picker. // // Deprecated: use [Model.SetHeight] instead. Height int AutoHeight bool Cursor string Styles Styles } type stack struct { Push func(int) Pop func() int Length func() int } func newStack() stack { slice := make([]int, 0) return stack{ Push: func(i int) { slice = append(slice, i) }, Pop: func() int { res := slice[len(slice)-1] slice = slice[:len(slice)-1] return res }, Length: func() int { return len(slice) }, } } func (m *Model) pushView(selected, minimum, maximum int) { m.selectedStack.Push(selected) m.minStack.Push(minimum) m.maxStack.Push(maximum) } func (m *Model) popView() (int, int, int) { return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop() } func (m Model) readDir(path string, showHidden bool) tea.Cmd { return func() tea.Msg { dirEntries, err := os.ReadDir(path) if err != nil { return errorMsg{err} } sort.Slice(dirEntries, func(i, j int) bool { if dirEntries[i].IsDir() == dirEntries[j].IsDir() { return dirEntries[i].Name() < dirEntries[j].Name() } return dirEntries[i].IsDir() }) if showHidden { return readDirMsg{id: m.id, entries: dirEntries} } var sanitizedDirEntries []os.DirEntry for _, dirEntry := range dirEntries { isHidden, _ := IsHidden(dirEntry.Name()) if isHidden { continue } sanitizedDirEntries = append(sanitizedDirEntries, dirEntry) } return readDirMsg{id: m.id, entries: sanitizedDirEntries} } } // Init initializes the file picker model. func (m Model) Init() tea.Cmd { return m.readDir(m.CurrentDirectory, m.ShowHidden) } // SetHeight sets the height of the filepicker. func (m *Model) SetHeight(height int) { m.Height = height if m.max > m.Height-1 { m.max = m.min + m.Height - 1 } } // Update handles user interactions within the file picker model. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case readDirMsg: if msg.id != m.id { break } m.files = msg.entries m.max = max(m.max, m.Height-1) case tea.WindowSizeMsg: if m.AutoHeight { m.Height = msg.Height - marginBottom } m.max = m.Height - 1 case tea.KeyMsg: switch { case key.Matches(msg, m.KeyMap.GoToTop): m.selected = 0 m.min = 0 m.max = m.Height - 1 case key.Matches(msg, m.KeyMap.GoToLast): m.selected = len(m.files) - 1 m.min = len(m.files) - m.Height m.max = len(m.files) - 1 case key.Matches(msg, m.KeyMap.Down): m.selected++ if m.selected >= len(m.files) { m.selected = len(m.files) - 1 } if m.selected > m.max { m.min++ m.max++ } case key.Matches(msg, m.KeyMap.Up): m.selected-- if m.selected < 0 { m.selected = 0 } if m.selected < m.min { m.min-- m.max-- } case key.Matches(msg, m.KeyMap.PageDown): m.selected += m.Height if m.selected >= len(m.files) { m.selected = len(m.files) - 1 } m.min += m.Height m.max += m.Height if m.max >= len(m.files) { m.max = len(m.files) - 1 m.min = m.max - m.Height } case key.Matches(msg, m.KeyMap.PageUp): m.selected -= m.Height if m.selected < 0 { m.selected = 0 } m.min -= m.Height m.max -= m.Height if m.min < 0 { m.min = 0 m.max = m.min + m.Height } case key.Matches(msg, m.KeyMap.Back): m.CurrentDirectory = filepath.Dir(m.CurrentDirectory) if m.selectedStack.Length() > 0 { m.selected, m.min, m.max = m.popView() } else { m.selected = 0 m.min = 0 m.max = m.Height - 1 } return m, m.readDir(m.CurrentDirectory, m.ShowHidden) case key.Matches(msg, m.KeyMap.Open): if len(m.files) == 0 { break } f := m.files[m.selected] info, err := f.Info() if err != nil { break } isSymlink := info.Mode()&os.ModeSymlink != 0 isDir := f.IsDir() if isSymlink { symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name())) info, err := os.Stat(symlinkPath) if err != nil { break } if info.IsDir() { isDir = true } } if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) { if key.Matches(msg, m.KeyMap.Select) { // Select the current path as the selection m.Path = filepath.Join(m.CurrentDirectory, f.Name()) } } if !isDir { break } m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name()) m.pushView(m.selected, m.min, m.max) m.selected = 0 m.min = 0 m.max = m.Height - 1 return m, m.readDir(m.CurrentDirectory, m.ShowHidden) } } return m, nil } // View returns the view of the file picker. func (m Model) View() string { if len(m.files) == 0 { return m.Styles.EmptyDirectory.Height(m.Height).MaxHeight(m.Height).String() } var s strings.Builder for i, f := range m.files { if i < m.min || i > m.max { continue } var symlinkPath string info, _ := f.Info() isSymlink := info.Mode()&os.ModeSymlink != 0 size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec name := f.Name() if isSymlink { symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name)) } disabled := !m.canSelect(name) && !f.IsDir() if m.selected == i { //nolint:nestif selected := "" if m.ShowPermissions { selected += " " + info.Mode().String() } if m.ShowSize { selected += fmt.Sprintf("%"+strconv.Itoa(m.Styles.FileSize.GetWidth())+"s", size) } selected += " " + name if isSymlink { selected += " → " + symlinkPath } if disabled { s.WriteString(m.Styles.DisabledSelected.Render(m.Cursor) + m.Styles.DisabledSelected.Render(selected)) } else { s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected)) } s.WriteRune('\n') continue } style := m.Styles.File if f.IsDir() { style = m.Styles.Directory } else if isSymlink { style = m.Styles.Symlink } else if disabled { style = m.Styles.DisabledFile } fileName := style.Render(name) s.WriteString(m.Styles.Cursor.Render(" ")) if isSymlink { fileName += " → " + symlinkPath } if m.ShowPermissions { s.WriteString(" " + m.Styles.Permission.Render(info.Mode().String())) } if m.ShowSize { s.WriteString(m.Styles.FileSize.Render(size)) } s.WriteString(" " + fileName) s.WriteRune('\n') } for i := lipgloss.Height(s.String()); i <= m.Height; i++ { s.WriteRune('\n') } return s.String() } // DidSelectFile returns whether a user has selected a file (on this msg). func (m Model) DidSelectFile(msg tea.Msg) (bool, string) { didSelect, path := m.didSelectFile(msg) if didSelect && m.canSelect(path) { return true, path } return false, "" } // DidSelectDisabledFile returns whether a user tried to select a disabled file // (on this msg). This is necessary only if you would like to warn the user that // they tried to select a disabled file. func (m Model) DidSelectDisabledFile(msg tea.Msg) (bool, string) { didSelect, path := m.didSelectFile(msg) if didSelect && !m.canSelect(path) { return true, path } return false, "" } func (m Model) didSelectFile(msg tea.Msg) (bool, string) { if len(m.files) == 0 { return false, "" } switch msg := msg.(type) { case tea.KeyMsg: // If the msg does not match the Select keymap then this could not have been a selection. if !key.Matches(msg, m.KeyMap.Select) { return false, "" } // The key press was a selection, let's confirm whether the current file could // be selected or used for navigating deeper into the stack. f := m.files[m.selected] info, err := f.Info() if err != nil { return false, "" } isSymlink := info.Mode()&os.ModeSymlink != 0 isDir := f.IsDir() if isSymlink { symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name())) info, err := os.Stat(symlinkPath) if err != nil { break } if info.IsDir() { isDir = true } } if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) && m.Path != "" { return true, m.Path } // If the msg was not a KeyMsg, then the file could not have been selected this iteration. // Only a KeyMsg can select a file. default: return false, "" } return false, "" } func (m Model) canSelect(file string) bool { if len(m.AllowedTypes) <= 0 { return true } for _, ext := range m.AllowedTypes { if strings.HasSuffix(file, ext) { return true } } return false }