feat: use filepicker bubble (#289)

* feat: use filepicker bubble

* fix: use new API

* chore: bump bubbles
This commit is contained in:
Maas Lalani 2023-03-06 11:54:02 -05:00 committed by GitHub
parent 99e6625a39
commit b5444d5f0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 42 additions and 296 deletions

View file

@ -7,8 +7,8 @@ import (
"path/filepath" "path/filepath"
"github.com/alecthomas/kong" "github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/filepicker"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/internal/stack"
"github.com/charmbracelet/gum/style" "github.com/charmbracelet/gum/style"
) )
@ -27,29 +27,26 @@ func (o Options) Run() error {
return fmt.Errorf("file not found: %w", err) return fmt.Errorf("file not found: %w", err)
} }
m := model{ fp := filepicker.New()
path: path, fp.Path = path
cursor: o.Cursor, fp.Height = o.Height
selected: 0, fp.AutoHeight = o.Height == 0
showHidden: o.All, fp.Cursor = o.Cursor
dirAllowed: o.Directory, fp.DirAllowed = o.Directory
fileAllowed: o.File, fp.FileAllowed = o.File
autoHeight: o.Height == 0, fp.ShowHidden = o.All
height: o.Height, fp.Styles = filepicker.Styles{
max: 0, Cursor: o.CursorStyle.ToLipgloss(),
min: 0, Symlink: o.SymlinkStyle.ToLipgloss(),
selectedStack: stack.NewStack(), Directory: o.DirectoryStyle.ToLipgloss(),
minStack: stack.NewStack(), File: o.FileStyle.ToLipgloss(),
maxStack: stack.NewStack(), Permission: o.PermissionsStyle.ToLipgloss(),
cursorStyle: o.CursorStyle.ToLipgloss().Inline(true), Selected: o.SelectedStyle.ToLipgloss(),
symlinkStyle: o.SymlinkStyle.ToLipgloss().Inline(true), FileSize: o.FileSizeStyle.ToLipgloss(),
directoryStyle: o.DirectoryStyle.ToLipgloss().Inline(true),
fileStyle: o.FileStyle.ToLipgloss().Inline(true),
permissionStyle: o.PermissionsStyle.ToLipgloss().Inline(true),
selectedStyle: o.SelectedStyle.ToLipgloss().Inline(true),
fileSizeStyle: o.FileSizeStyle.ToLipgloss().Inline(true),
} }
m := model{filepicker: fp}
tm, err := tea.NewProgram(&m, tea.WithOutput(os.Stderr)).Run() tm, err := tea.NewProgram(&m, tea.WithOutput(os.Stderr)).Run()
if err != nil { if err != nil {
return fmt.Errorf("unable to pick selection: %w", err) return fmt.Errorf("unable to pick selection: %w", err)
@ -57,11 +54,11 @@ func (o Options) Run() error {
m = tm.(model) m = tm.(model)
if m.path == "" { if m.selectedPath == "" {
os.Exit(1) os.Exit(1)
} }
fmt.Println(m.path) fmt.Println(m.selectedPath)
return nil return nil
} }

View file

@ -13,268 +13,41 @@
package file package file
import ( import (
"fmt" "github.com/charmbracelet/bubbles/filepicker"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/gum/internal/stack"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
) )
const marginBottom = 5
type model struct { type model struct {
quitting bool filepicker filepicker.Model
path string selectedPath string
files []os.DirEntry quitting bool
showHidden bool
dirAllowed bool
fileAllowed bool
selected int
selectedStack stack.Stack
min int
max int
maxStack stack.Stack
minStack stack.Stack
height int
autoHeight bool
cursor string
cursorStyle lipgloss.Style
symlinkStyle lipgloss.Style
directoryStyle lipgloss.Style
fileStyle lipgloss.Style
permissionStyle lipgloss.Style
selectedStyle lipgloss.Style
fileSizeStyle lipgloss.Style
}
type readDirMsg []os.DirEntry
func readDir(path string, showHidden bool) tea.Cmd {
return func() tea.Msg {
dirEntries, err := os.ReadDir(path)
if err != nil {
return tea.Quit
}
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 []fs.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if isHidden {
continue
}
sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
}
return readDirMsg(sanitizedDirEntries)
}
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return readDir(m.path, m.showHidden) return m.filepicker.Init()
} }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { 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: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "g":
m.selected = 0
m.min = 0
m.max = m.height - 1
case "G":
m.selected = len(m.files) - 1
m.min = len(m.files) - m.height
m.max = len(m.files) - 1
case "j", "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 "k", "up":
m.selected--
if m.selected < 0 {
m.selected = 0
}
if m.selected < m.min {
m.min--
m.max--
}
case "J", "pgdown":
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 "K", "pgup":
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 "ctrl+c", "q": case "ctrl+c", "q":
m.path = ""
m.quitting = true
return m, tea.Quit return m, tea.Quit
case "backspace", "h", "left":
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 "l", "right", "enter":
if len(m.files) == 0 {
break
}
f := m.files[m.selected]
info, err := f.Info()
if err != nil {
break
}
isSymlink := info.Mode()&fs.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 msg.String() == "enter" {
m.path = filepath.Join(m.path, f.Name())
m.quitting = true
return m, tea.Quit
}
}
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 var cmd tea.Cmd
} m.filepicker, cmd = m.filepicker.Update(msg)
if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
func (m model) pushView() { m.selectedPath = path
m.minStack.Push(m.min) m.quitting = true
m.maxStack.Push(m.max) return m, tea.Quit
m.selectedStack.Push(m.selected) }
} return m, cmd
func (m model) popView() (int, int, int) {
return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop()
} }
func (m model) View() string { func (m model) View() string {
if m.quitting { if m.quitting {
return "" return ""
} }
if len(m.files) == 0 { return m.filepicker.View()
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()&fs.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.fileSizeStyle.GetWidth())+"s %s", info.Mode().String(), size, name)
if isSymlink {
selected = fmt.Sprintf("%s → %s", selected, symlinkPath)
}
s.WriteString(m.cursorStyle.Render(m.cursor) + m.selectedStyle.Render(selected))
s.WriteRune('\n')
continue
}
var style = m.fileStyle
if f.IsDir() {
style = m.directoryStyle
} else if isSymlink {
style = m.symlinkStyle
}
fileName := style.Render(name)
if isSymlink {
fileName = fmt.Sprintf("%s → %s", fileName, symlinkPath)
}
s.WriteString(fmt.Sprintf(" %s %s %s", m.permissionStyle.Render(info.Mode().String()), m.fileSizeStyle.Render(size), fileName))
s.WriteRune('\n')
}
return s.String()
} }

View file

@ -1,10 +0,0 @@
//go:build !windows
package file
import "strings"
// IsHidden reports whether a file is hidden or not.
func IsHidden(file string) (bool, error) {
return strings.HasPrefix(file, "."), nil
}

View file

@ -1,20 +0,0 @@
//go:build windows
package file
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
go.mod
View file

@ -5,7 +5,7 @@ go 1.18
require ( require (
github.com/alecthomas/kong v0.7.1 github.com/alecthomas/kong v0.7.1
github.com/alecthomas/mango-kong v0.1.0 github.com/alecthomas/mango-kong v0.1.0
github.com/charmbracelet/bubbles v0.15.0 github.com/charmbracelet/bubbles v0.15.1-0.20230306155959-3372cf1aea2b
github.com/charmbracelet/bubbletea v0.23.2 github.com/charmbracelet/bubbletea v0.23.2
github.com/charmbracelet/glamour v0.6.0 github.com/charmbracelet/glamour v0.6.0
github.com/charmbracelet/lipgloss v0.6.1-0.20230222162833-a74950e6da16 github.com/charmbracelet/lipgloss v0.6.1-0.20230222162833-a74950e6da16

6
go.sum
View file

@ -23,6 +23,12 @@ github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a h1:/prXWl
github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a/go.mod h1:5rZgJTHmgWISQnxnzzIJtQt3GC1bfJfNmr4SEtRDtTQ= github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a/go.mod h1:5rZgJTHmgWISQnxnzzIJtQt3GC1bfJfNmr4SEtRDtTQ=
github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI=
github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74=
github.com/charmbracelet/bubbles v0.15.1-0.20230303175231-2694acc38055 h1:4zkjfTm5xydSP+cZjgWyAmrjJXlx0E0E2DWu4x3r0vM=
github.com/charmbracelet/bubbles v0.15.1-0.20230303175231-2694acc38055/go.mod h1:2ZpQTAUWMFMWM5lx7FvucnwMOE+/JKP9d/Su1MKpZbo=
github.com/charmbracelet/bubbles v0.15.1-0.20230303214459-958a0ea710f1 h1:BcxSgchb4ZV1lJe9W9+LhQj6S3JouC39yYswVI4YOrw=
github.com/charmbracelet/bubbles v0.15.1-0.20230303214459-958a0ea710f1/go.mod h1:39HL8bnL0foloiENA/KvD+3mNg5SqWQV2Qh3eY/4ey4=
github.com/charmbracelet/bubbles v0.15.1-0.20230306155959-3372cf1aea2b h1:K9dWJ2spDhDhIrqnchjG867djPxWWe3mwdk6RdLMfhg=
github.com/charmbracelet/bubbles v0.15.1-0.20230306155959-3372cf1aea2b/go.mod h1:39HL8bnL0foloiENA/KvD+3mNg5SqWQV2Qh3eY/4ey4=
github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0= github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0=
github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU=
github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps= github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps=