Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filepicker Bubble #343

Merged
merged 6 commits into from
Mar 6, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ jobs:
test:
strategy:
matrix:
go-version: [~1.13, ^1]
go-version: [~1.16, ^1]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
env:
Expand Down
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")),
maaslalani marked this conversation as resolved.
Show resolved Hide resolved
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."
maaslalani marked this conversation as resolved.
Show resolved Hide resolved
}
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()
}
11 changes: 11 additions & 0 deletions filepicker/hidden_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !windows
// +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
}
Loading