Filepicker Bubble (#343)

* feat: filepicker bubble

* fix: bump bubbles to 1.16

* fix: allow customization of empty state

* fix: change `Enter` to `Open`

* fix: lint gomnd

* fix: don't send msg, instead check if this update caused a selection
This commit is contained in:
Maas Lalani
2023-03-06 10:59:59 -05:00
committed by GitHub
parent e4e3137a64
commit 3372cf1aea
6 changed files with 458 additions and 2 deletions
+1 -1
View File
@@ -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:
+421
View File
@@ -0,0 +1,421 @@
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
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, ""
}
+11
View File
@@ -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
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows
// +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
}
+2 -1
View File
@@ -1,12 +1,13 @@
module github.com/charmbracelet/bubbles
go 1.13
go 1.16
require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea v0.23.1
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v0.6.0
github.com/dustin/go-humanize v1.0.1
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.14
+2
View File
@@ -10,6 +10,8 @@ github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87ini
github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=