mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-16 01:43:21 +00:00
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:
@@ -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, ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user