From b19a7c90572347f5e7c74acde0cf83ca7820a1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20Mengu=C3=A9?= Date: Mon, 13 Apr 2026 13:28:45 +0200 Subject: [PATCH] feat(filepicker): add support for abstracted filesystem io/fs.FS Add an optional io/fs.FS to filepicker's model to allow to browse an abstracted filesystem instead of the full filesystem exposed by the 'os' package. To resolve symlinks we use io/fs.ReadLink introduced with Go 1.25, but fallback to an internal implementation if the FS doesn't implement it. By the way, references to os types and constants (which nowadays are aliases to same symbols in io/fs) are replaced: os.Mode*, os.DirEntry. Fixes #815. Replaces #759. --- filepicker/filepicker.go | 93 ++++++++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index 1d2a1cc2..f0a7fe6a 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 ( @@ -127,6 +129,9 @@ func DefaultStyles() 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 @@ -138,7 +143,7 @@ type Model struct { AllowedTypes []string KeyMap KeyMap - files []os.DirEntry + files []fs.DirEntry ShowPermissions bool ShowSize bool ShowHidden bool @@ -196,7 +201,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} } @@ -212,7 +225,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 { @@ -309,7 +322,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.maxIdx = m.minIdx + 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.minIdx, m.maxIdx = m.popView() } else { @@ -328,12 +345,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 } @@ -345,7 +362,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()) + } } } @@ -353,7 +374,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.minIdx, m.maxIdx) m.selected = 0 m.minIdx = 0 @@ -364,6 +389,42 @@ 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)) + } + + // Since go 1.25 + if sym, ok := m.FS.(fs.ReadLinkFS); ok { + return sym.ReadLink(path.Join(m.CurrentDirectory, name)) + } + + // Fallback because io/fs.ReadLink just fails if ReadLinkFS is not implemented. + 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 { @@ -386,7 +447,7 @@ func (m Model) View() string { name := f.Name() if isSymlink { - symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name)) + symlinkPath, _ = m.evalSymlinks(name) } disabled := !m.canSelect(name) && !f.IsDir() @@ -481,12 +542,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 }