diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index 3898742c8..c5eeb165f 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -4,7 +4,9 @@ package filepicker import ( "fmt" + "io/fs" "os" + "path" "path/filepath" "sort" "strconv" @@ -54,7 +56,7 @@ type errorMsg struct { type readDirMsg struct { id int - entries []os.DirEntry + entries []fs.DirEntry } const ( @@ -133,6 +135,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 @@ -144,7 +149,7 @@ type Model struct { AllowedTypes []string KeyMap KeyMap - files []os.DirEntry + files []fs.DirEntry ShowPermissions bool ShowSize bool ShowHidden bool @@ -205,7 +210,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} } @@ -221,7 +234,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 { @@ -313,7 +326,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 { @@ -332,12 +349,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 } @@ -349,7 +366,11 @@ 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()) + } } } @@ -357,7 +378,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 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 @@ -368,6 +393,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 { @@ -382,12 +439,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() @@ -482,12 +539,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 }