package filepicker import ( "fmt" "os" "path/filepath" "sort" "strings" "sync" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/dustin/go-humanize" ) var ( lastID int idMtx sync.Mutex ) // Return the next ID we should use on the Model. func nextID() int { idMtx.Lock() defer idMtx.Unlock() lastID++ return lastID } // New returns a new filepicker model with default styling and key bindings. func New() Model { return Model{ id: nextID(), CurrentDirectory: ".", Cursor: ">", selected: 0, 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 []os.DirEntry const marginBottom = 5 const fileSizeWidth = 8 const 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. var DefaultKeyMap = 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 { Cursor lipgloss.Style Symlink lipgloss.Style Directory lipgloss.Style File lipgloss.Style Permission lipgloss.Style Selected lipgloss.Style FileSize lipgloss.Style EmptyDirectory lipgloss.Style } // DefaultStyles defines the default styling for the file picker. var DefaultStyles = Styles{ Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")), Symlink: lipgloss.NewStyle().Foreground(lipgloss.Color("36")), Directory: lipgloss.NewStyle().Foreground(lipgloss.Color("99")), File: lipgloss.NewStyle(), Permission: lipgloss.NewStyle().Foreground(lipgloss.Color("244")), Selected: lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true), FileSize: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right), EmptyDirectory: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."), } // Model represents a file picker. type Model struct { id int // currentDirectory is the path which the user has selected with the file picker. Path string CurrentDirectory string KeyMap KeyMap files []os.DirEntry ShowHidden bool DirAllowed bool FileAllowed bool FileSelcted string selected int selectedStack stack min int max int maxStack stack minStack stack 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() { m.minStack.Push(m.min) m.maxStack.Push(m.max) m.selectedStack.Push(m.selected) } func (m Model) popView() (int, int, int) { return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop() } func 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(dirEntries) } var sanitizedDirEntries []os.DirEntry for _, dirEntry := range dirEntries { isHidden, _ := IsHidden(dirEntry.Name()) if isHidden { continue } sanitizedDirEntries = append(sanitizedDirEntries, dirEntry) } return readDirMsg(sanitizedDirEntries) } } // Init initializes the file picker model. func (m Model) Init() tea.Cmd { return readDir(m.CurrentDirectory, m.ShowHidden) } // 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: m.files = msg m.max = m.Height case tea.WindowSizeMsg: if m.AutoHeight { m.Height = msg.Height - marginBottom } m.max = m.Height 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, 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 = 0 m.min = 0 m.max = m.Height - 1 return 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.String() } var s strings.Builder for i, f := range m.files { if i < m.min { continue } if i > m.max { break } var symlinkPath string info, _ := f.Info() isSymlink := info.Mode()&os.ModeSymlink != 0 size := humanize.Bytes(uint64(info.Size())) name := f.Name() if isSymlink { symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name)) } if m.selected == i { selected := fmt.Sprintf(" %s %"+fmt.Sprint(m.Styles.FileSize.GetWidth())+"s %s", info.Mode().String(), size, name) if isSymlink { selected = fmt.Sprintf("%s → %s", selected, symlinkPath) } s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected)) s.WriteRune('\n') continue } var style = m.Styles.File if f.IsDir() { style = m.Styles.Directory } else if isSymlink { style = m.Styles.Symlink } fileName := style.Render(name) if isSymlink { fileName = fmt.Sprintf("%s → %s", fileName, symlinkPath) } s.WriteString(fmt.Sprintf(" %s %s %s", m.Styles.Permission.Render(info.Mode().String()), m.Styles.FileSize.Render(size), fileName)) s.WriteRune('\n') } return s.String() } // HasSelectedFile returns whether a user has selected a file (on this msg). func (m Model) DidSelectFile(msg tea.Msg) (bool, string) { 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, "" }