Skip to content

Commit

Permalink
feat: filepicker bubble
Browse files Browse the repository at this point in the history
  • Loading branch information
maaslalani committed Mar 3, 2023
1 parent e4e3137 commit 2694acc
Show file tree
Hide file tree
Showing 5 changed files with 397 additions and 0 deletions.
364 changes: 364 additions & 0 deletions filepicker/filepicker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
package filepicker

import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
)

// New returns a new filepicker model with default styling and key bindings.
func New() Model {
return Model{
Path: ".",
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,
}
}

// FileSelectedMsg is the msg that is return when a user makes a valid
// selection on a file.
type FileSelectedMsg struct {
Path string
}

type errorMsg struct {
err error
}

type readDirMsg []os.DirEntry

const marginBottom = 5
const fileSizeWidth = 8

// 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
Enter 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")),
Enter: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "enter")),
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
}

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

// Model represents a file picker.
type Model struct {
KeyMap KeyMap
Path string
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.Path, 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
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.Path = filepath.Dir(m.Path)
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.Path, m.ShowHidden)
case key.Matches(msg, m.KeyMap.Enter):
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.Path, 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) {
selectedFile := filepath.Join(m.Path, f.Name())
return m, func() tea.Msg {
return FileSelectedMsg{selectedFile}
}
}
}

if !isDir {
break
}

m.Path = filepath.Join(m.Path, f.Name())
m.pushView()
m.selected = 0
m.min = 0
m.max = m.Height - 1
return m, readDir(m.Path, m.ShowHidden)
}
}
return m, nil
}

// View returns the view of the file picker.
func (m Model) View() string {
if len(m.files) == 0 {
return "Bummer. No files found."
}
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.Path, 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()
}
10 changes: 10 additions & 0 deletions filepicker/hidden_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build !windows

package filepicker

import "strings"

// IsHidden reports whether a file is hidden or not.
func IsHidden(file string) (bool, error) {
return strings.HasPrefix(file, "."), nil
}
20 changes: 20 additions & 0 deletions filepicker/hidden_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//go:build windows

package filepicker

import (
"syscall"
)

// IsHidden reports whether a file is hidden or not.
func IsHidden(file string) (bool, error) {
pointer, err := syscall.UTF16PtrFromString(file)
if err != nil {
return false, err
}
attributes, err := syscall.GetFileAttributes(pointer)
if err != nil {
return false, err
}
return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil
}
Loading

0 comments on commit 2694acc

Please sign in to comment.