From 9f0c00d4b6b1c0c55c3e641d04a43b8e3b36e342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20G=C3=96R=C3=96G?= Date: Sun, 13 Apr 2025 13:53:45 +0200 Subject: [PATCH] feat(filepicker): allow configuring FS --- filepicker/filepicker.go | 17 ++++++--- filepicker/filepicker_test.go | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 filepicker/filepicker_test.go diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index b749684d6..c849dbaca 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -4,6 +4,7 @@ package filepicker import ( "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -25,9 +26,11 @@ func nextID() int { // New returns a new filepicker model with default styling and key bindings. func New() Model { + dir, _ := os.Getwd() return Model{ id: nextID(), CurrentDirectory: ".", + FS: os.DirFS(dir), Cursor: ">", AllowedTypes: []string{}, selected: 0, @@ -104,6 +107,7 @@ type Styles struct { DisabledSelected lipgloss.Style FileSize lipgloss.Style EmptyDirectory lipgloss.Style + HelpStyle lipgloss.Style } // DefaultStyles defines the default styling for the file picker. @@ -133,6 +137,9 @@ type Model struct { // CurrentDirectory is the directory that the user is currently in. CurrentDirectory string + // FS is the filesystem the file picker can walk. + FS fs.FS + // AllowedTypes specifies which file types the user may select. // If empty the user may select any file. AllowedTypes []string @@ -194,9 +201,9 @@ func (m *Model) popView() (int, int, int) { return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop() } -func (m Model) readDir(path string, showHidden bool) tea.Cmd { +func (m Model) readDir(fysy fs.FS, path string, showHidden bool) tea.Cmd { return func() tea.Msg { - dirEntries, err := os.ReadDir(path) + dirEntries, err := fs.ReadDir(fysy, path) if err != nil { return errorMsg{err} } @@ -239,7 +246,7 @@ func (m Model) Height() int { // Init initializes the file picker model. func (m Model) Init() tea.Cmd { - return m.readDir(m.CurrentDirectory, m.ShowHidden) + return m.readDir(m.FS, m.CurrentDirectory, m.ShowHidden) } // Update handles user interactions within the file picker model. @@ -317,7 +324,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.minIdx = 0 m.maxIdx = m.Height() - 1 } - return m, m.readDir(m.CurrentDirectory, m.ShowHidden) + return m, m.readDir(m.FS, m.CurrentDirectory, m.ShowHidden) case key.Matches(msg, m.KeyMap.Open): if len(m.files) == 0 { break @@ -358,7 +365,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.selected = 0 m.minIdx = 0 m.maxIdx = m.Height() - 1 - return m, m.readDir(m.CurrentDirectory, m.ShowHidden) + return m, m.readDir(m.FS, m.CurrentDirectory, m.ShowHidden) } } return m, nil diff --git a/filepicker/filepicker_test.go b/filepicker/filepicker_test.go new file mode 100644 index 000000000..453f09765 --- /dev/null +++ b/filepicker/filepicker_test.go @@ -0,0 +1,68 @@ +package filepicker + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + "strings" + "testing" + "testing/fstest" +) + +func TestFS(t *testing.T) { + fp := New() + fp.FS = fstest.MapFS{ + "bubbles/help.txt": {Data: []byte("1")}, + "bubbles/list.txt": {Data: []byte("1")}, + "charm.sh": {Data: []byte(" 4")}, + "hello.txt": {Data: []byte(" 2")}, + "huh.txt": {Data: []byte(" 3")}, + } + + fp.SetHeight(10) + + cmd := fp.Init() + fp, _ = fp.Update(cmd()) + + lines := strings.Split(fp.View(), "\n") + for lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + expected := []string{"bubbles", "charm.sh", "hello.txt", "huh.txt"} + if len(lines) != len(expected) { + t.Fatalf("len(lines) != len(expected): got %d, want %d", len(lines), len(expected)) + } + for i, line := range lines { + contains := expected[i] + if got := line; !strings.Contains(got, contains) { + t.Errorf("View() line %d = %v; must contains %v", i, got, contains) + } + } + + expected = []string{"0B", "4B", "2B", "3B"} + if len(lines) != len(expected) { + t.Fatalf("len(lines) != len(expected): got %d, want %d", len(lines), len(expected)) + } + for i, line := range lines { + contains := expected[i] + if got := line; !strings.Contains(got, contains) { + t.Errorf("View() line %d = %v; must contains %v", i, got, contains) + } + } + + fp, cmd = fp.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + fp, _ = fp.Update(cmd()) + + lines = strings.Split(fp.View(), "\n") + for lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + expected = []string{"help.txt", "list.txt"} + if len(lines) != len(expected) { + t.Fatalf("len(lines) != len(expected): got %d, want %d", len(lines), len(expected)) + } + for i, line := range lines { + contains := expected[i] + if got := line; !strings.Contains(got, contains) { + t.Errorf("View() line %d = %v; must contains %v", i, got, contains) + } + } +}