From 2694acc3805566331b9ae621be7980d9078c0f96 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Fri, 3 Mar 2023 12:42:55 -0500 Subject: [PATCH 1/6] feat: filepicker bubble --- filepicker/filepicker.go | 364 +++++++++++++++++++++++++++++++++++ filepicker/hidden_unix.go | 10 + filepicker/hidden_windows.go | 20 ++ go.mod | 1 + go.sum | 2 + 5 files changed, 397 insertions(+) create mode 100644 filepicker/filepicker.go create mode 100644 filepicker/hidden_unix.go create mode 100644 filepicker/hidden_windows.go diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go new file mode 100644 index 00000000..9014bb81 --- /dev/null +++ b/filepicker/filepicker.go @@ -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() +} diff --git a/filepicker/hidden_unix.go b/filepicker/hidden_unix.go new file mode 100644 index 00000000..94aaae1e --- /dev/null +++ b/filepicker/hidden_unix.go @@ -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 +} diff --git a/filepicker/hidden_windows.go b/filepicker/hidden_windows.go new file mode 100644 index 00000000..96723407 --- /dev/null +++ b/filepicker/hidden_windows.go @@ -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 +} diff --git a/go.mod b/go.mod index 2f66d5e0..7f0a14ea 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbletea v0.23.1 github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss v0.6.0 + github.com/dustin/go-humanize v1.0.1 github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.14 diff --git a/go.sum b/go.sum index fef0eebf..482900f8 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87ini github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= From 7944d483f51652831467b619443e3b31cfb97ca1 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Fri, 3 Mar 2023 13:06:52 -0500 Subject: [PATCH 2/6] fix: bump bubbles to 1.16 --- .github/workflows/build.yml | 2 +- filepicker/hidden_unix.go | 1 + filepicker/hidden_windows.go | 1 + go.mod | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 546ea3e1..0c33264c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ jobs: test: strategy: matrix: - go-version: [~1.13, ^1] + go-version: [~1.16, ^1] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} env: diff --git a/filepicker/hidden_unix.go b/filepicker/hidden_unix.go index 94aaae1e..f611d0e1 100644 --- a/filepicker/hidden_unix.go +++ b/filepicker/hidden_unix.go @@ -1,4 +1,5 @@ //go:build !windows +// +build !windows package filepicker diff --git a/filepicker/hidden_windows.go b/filepicker/hidden_windows.go index 96723407..d9ec5add 100644 --- a/filepicker/hidden_windows.go +++ b/filepicker/hidden_windows.go @@ -1,4 +1,5 @@ //go:build windows +// +build windows package filepicker diff --git a/go.mod b/go.mod index 7f0a14ea..58186173 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/charmbracelet/bubbles -go 1.13 +go 1.16 require ( github.com/atotto/clipboard v0.1.4 From 63497b3393b7511ce3fcc35fe6f2bc91dd292f82 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Fri, 3 Mar 2023 13:39:14 -0500 Subject: [PATCH 3/6] fix: allow customization of empty state --- filepicker/filepicker.go | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index 9014bb81..045af929 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -77,24 +77,26 @@ var DefaultKeyMap = KeyMap{ // 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 + Cursor lipgloss.Style + Symlink lipgloss.Style + Directory lipgloss.Style + File lipgloss.Style + Permission lipgloss.Style + Selected lipgloss.Style + FileSize lipgloss.Style + EmptyDirectory 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), + 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), + EmptyDirectory: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 2).SetString("Bummer. No Files Found."), } // Model represents a file picker. @@ -313,7 +315,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // View returns the view of the file picker. func (m Model) View() string { if len(m.files) == 0 { - return "Bummer. No files found." + return m.Styles.EmptyDirectory.String() } var s strings.Builder From d629f6dbf0d5c7bf1a608a8c45bb65635bcd50d5 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Fri, 3 Mar 2023 13:41:02 -0500 Subject: [PATCH 4/6] fix: change `Enter` to `Open` --- filepicker/filepicker.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index 045af929..4ece1838 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -58,7 +58,7 @@ type KeyMap struct { PageUp key.Binding PageDown key.Binding Back key.Binding - Enter key.Binding + Open key.Binding Select key.Binding } @@ -71,7 +71,7 @@ var DefaultKeyMap = KeyMap{ 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")), + Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "enter")), Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), } @@ -264,7 +264,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.max = m.Height - 1 } return m, readDir(m.Path, m.ShowHidden) - case key.Matches(msg, m.KeyMap.Enter): + case key.Matches(msg, m.KeyMap.Open): if len(m.files) == 0 { break } From 6d20f910a47cbe9033c20ea0739cf12de4c5332b Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Fri, 3 Mar 2023 13:44:35 -0500 Subject: [PATCH 5/6] fix: lint gomnd --- filepicker/filepicker.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index 4ece1838..e3c99972 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -48,6 +48,7 @@ type readDirMsg []os.DirEntry const marginBottom = 5 const fileSizeWidth = 8 +const paddingLeft = 2 // KeyMap defines key bindings for each user action. type KeyMap struct { @@ -96,7 +97,7 @@ var DefaultStyles = Styles{ 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), - EmptyDirectory: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 2).SetString("Bummer. No Files Found."), + EmptyDirectory: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."), } // Model represents a file picker. From 958a0ea710f1d656cfef9cf9b989020a35eb5e23 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Fri, 3 Mar 2023 16:44:59 -0500 Subject: [PATCH 6/6] fix: don't send msg, instead check if this update caused a selection --- filepicker/filepicker.go | 122 ++++++++++++++++++++++++++++----------- 1 file changed, 88 insertions(+), 34 deletions(-) diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index e3c99972..253eef18 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -6,6 +6,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" @@ -13,33 +14,41 @@ import ( "github.com/dustin/go-humanize" ) +var ( + lastID int + idMtx sync.Mutex +) + +// Return the next ID we should use on the Model. +func nextID() int { + idMtx.Lock() + defer idMtx.Unlock() + lastID++ + return lastID +} + // 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, + id: nextID(), + currentDirectory: ".", + 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 } @@ -72,7 +81,7 @@ var DefaultKeyMap = KeyMap{ 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")), - Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "enter")), + Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")), Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), } @@ -102,8 +111,13 @@ var DefaultStyles = Styles{ // Model represents a file picker. type Model struct { + id int + + // currentDirectory is the path which the user has selected with the file picker. + Path string + currentDirectory string + KeyMap KeyMap - Path string files []os.DirEntry ShowHidden bool DirAllowed bool @@ -190,7 +204,7 @@ func readDir(path string, showHidden bool) tea.Cmd { // Init initializes the file picker model. func (m Model) Init() tea.Cmd { - return readDir(m.Path, m.ShowHidden) + return readDir(m.currentDirectory, m.ShowHidden) } // Update handles user interactions within the file picker model. @@ -256,7 +270,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.max = m.min + m.Height } case key.Matches(msg, m.KeyMap.Back): - m.Path = filepath.Dir(m.Path) + m.currentDirectory = filepath.Dir(m.currentDirectory) if m.selectedStack.Length() > 0 { m.selected, m.min, m.max = m.popView() } else { @@ -264,7 +278,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.min = 0 m.max = m.Height - 1 } - return m, readDir(m.Path, m.ShowHidden) + return m, readDir(m.currentDirectory, m.ShowHidden) case key.Matches(msg, m.KeyMap.Open): if len(m.files) == 0 { break @@ -279,7 +293,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { isDir := f.IsDir() if isSymlink { - symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.Path, f.Name())) + symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.currentDirectory, f.Name())) info, err := os.Stat(symlinkPath) if err != nil { break @@ -291,10 +305,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 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} - } + // Select the current path as the selection + m.Path = filepath.Join(m.currentDirectory, f.Name()) } } @@ -302,12 +314,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { break } - m.Path = filepath.Join(m.Path, f.Name()) + m.currentDirectory = filepath.Join(m.currentDirectory, f.Name()) m.pushView() m.selected = 0 m.min = 0 m.max = m.Height - 1 - return m, readDir(m.Path, m.ShowHidden) + return m, readDir(m.currentDirectory, m.ShowHidden) } } return m, nil @@ -335,7 +347,7 @@ func (m Model) View() string { name := f.Name() if isSymlink { - symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.Path, name)) + symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.currentDirectory, name)) } if m.selected == i { @@ -365,3 +377,45 @@ func (m Model) View() string { return s.String() } + +// HasSelectedFile returns whether a user has selected a file (on this msg). +func (m Model) DidSelectFile(msg tea.Msg) (bool, string) { + switch msg := msg.(type) { + case tea.KeyMsg: + // If the msg does not match the Select keymap then this could not have been a selection. + if !key.Matches(msg, m.KeyMap.Select) { + return false, "" + } + + // The key press was a selection, let's confirm whether the current file could + // be selected or used for navigating deeper into the stack. + f := m.files[m.selected] + info, err := f.Info() + if err != nil { + return false, "" + } + isSymlink := info.Mode()&os.ModeSymlink != 0 + isDir := f.IsDir() + + if isSymlink { + symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.currentDirectory, f.Name())) + info, err := os.Stat(symlinkPath) + if err != nil { + break + } + if info.IsDir() { + isDir = true + } + } + + if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) && m.Path != "" { + return true, m.Path + } + + // If the msg was not a KeyMsg, then the file could not have been selected this iteration. + // Only a KeyMsg can select a file. + default: + return false, "" + } + return false, "" +}