Skip to content
Open
Changes from all 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
91 changes: 76 additions & 15 deletions filepicker/filepicker.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package filepicker

import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strconv"
Expand Down Expand Up @@ -52,7 +54,7 @@ type errorMsg struct {

type readDirMsg struct {
id int
entries []os.DirEntry
entries []fs.DirEntry
}

const (
Expand Down Expand Up @@ -131,6 +133,9 @@ func DefaultStylesWithRenderer(r *lipgloss.Renderer) Styles {
type Model struct {
id int

// Optional [io/fs.FS] to browse. If nil, functions from package [os] are used.
FS fs.FS

// Path is the path which the user has selected with the file picker.
Path string

Expand All @@ -142,7 +147,7 @@ type Model struct {
AllowedTypes []string

KeyMap KeyMap
files []os.DirEntry
files []fs.DirEntry
ShowPermissions bool
ShowSize bool
ShowHidden bool
Expand Down Expand Up @@ -203,7 +208,15 @@ func (m *Model) popView() (int, int, int) {

func (m Model) readDir(path string, showHidden bool) tea.Cmd {
return func() tea.Msg {
dirEntries, err := os.ReadDir(path)
var (
dirEntries []fs.DirEntry
err error
)
if m.FS != nil {
dirEntries, err = fs.ReadDir(m.FS, path)
} else {
dirEntries, err = os.ReadDir(path)
}
if err != nil {
return errorMsg{err}
}
Expand All @@ -219,7 +232,7 @@ func (m Model) readDir(path string, showHidden bool) tea.Cmd {
return readDirMsg{id: m.id, entries: dirEntries}
}

var sanitizedDirEntries []os.DirEntry
var sanitizedDirEntries []fs.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if isHidden {
Expand Down Expand Up @@ -311,7 +324,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.max = m.min + m.Height
}
case key.Matches(msg, m.KeyMap.Back):
m.CurrentDirectory = filepath.Dir(m.CurrentDirectory)
if m.FS != nil {
m.CurrentDirectory = path.Dir(m.CurrentDirectory)
} else {
m.CurrentDirectory = filepath.Dir(m.CurrentDirectory)
}
if m.selectedStack.Length() > 0 {
m.selected, m.min, m.max = m.popView()
} else {
Expand All @@ -330,12 +347,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if err != nil {
break
}
isSymlink := info.Mode()&os.ModeSymlink != 0
isSymlink := info.Mode()&fs.ModeSymlink != 0
isDir := f.IsDir()

if isSymlink {
symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name()))
info, err := os.Stat(symlinkPath)
symlinkPath, _ := m.evalSymlinks(f.Name())
info, err = os.Stat(symlinkPath)
if err != nil {
break
}
Expand All @@ -347,15 +364,23 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
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 m.FS != nil {
m.Path = path.Join(m.CurrentDirectory, f.Name())
} else {
m.Path = filepath.Join(m.CurrentDirectory, f.Name())
}
}
}

if !isDir {
break
}

m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name())
if m.FS != nil {
m.CurrentDirectory = path.Join(m.CurrentDirectory, f.Name())
} else {
m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name())
}
m.pushView(m.selected, m.min, m.max)
m.selected = 0
m.min = 0
Expand All @@ -366,6 +391,38 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}

func (m *Model) evalSymlinks(name string) (string, error) {
if m.FS == nil {
return filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name))
}

// FIXME go 1.25 will get io/fs.ReadLink and io/fs.ReadLinkFS
// https://pkg.go.dev/io/fs@master#ReadLink
p := path.Join(m.CurrentDirectory, name)
for {
symlinkPathBytes, err := fs.ReadFile(m.FS, p)
if err != nil {
return "", err
}
symlinkPath := string(symlinkPathBytes)
if symlinkPath == "" {
return p, &fs.PathError{Path: p, Err: fs.ErrInvalid}
}
if path.IsAbs(symlinkPath) {
return p, &fs.PathError{Path: p, Err: fs.ErrInvalid}
}
q := path.Join(p, symlinkPath)
info, err := fs.Stat(m.FS, q)
if err != nil {
return p, err
}
p = q
if info.Mode()&fs.ModeSymlink != 0 {
return p, nil
}
}
}

// View returns the view of the file picker.
func (m Model) View() string {
if len(m.files) == 0 {
Expand All @@ -380,12 +437,12 @@ func (m Model) View() string {

var symlinkPath string
info, _ := f.Info()
isSymlink := info.Mode()&os.ModeSymlink != 0
isSymlink := info.Mode()&fs.ModeSymlink != 0
size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec
name := f.Name()

if isSymlink {
symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name))
symlinkPath, _ = m.evalSymlinks(name)
}

disabled := !m.canSelect(name) && !f.IsDir()
Expand Down Expand Up @@ -480,12 +537,16 @@ func (m Model) didSelectFile(msg tea.Msg) (bool, string) {
if err != nil {
return false, ""
}
isSymlink := info.Mode()&os.ModeSymlink != 0
isSymlink := info.Mode()&fs.ModeSymlink != 0
isDir := f.IsDir()

if isSymlink {
symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name()))
info, err := os.Stat(symlinkPath)
symlinkPath, _ := m.evalSymlinks(f.Name())
if m.FS != nil {
info, err = fs.Stat(m.FS, symlinkPath)
} else {
info, err = os.Stat(symlinkPath)
}
if err != nil {
break
}
Expand Down