-
Notifications
You must be signed in to change notification settings - Fork 252
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
e4e3137
commit 2694acc
Showing
5 changed files
with
397 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.