You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Is your feature request related to a problem? Please describe.
Easily be able to pick a directory
Describe the solution you'd like
SImilar to the filepicker, except for directories.
Should include the ability to
* Navigate directory structure,
* moving into a directory
* going up a directory
* not listing files
Something that can be used with both huh, and bubbletea
Describe alternatives you've considered
Using a normal arg to have the user pass in a directory string :-(
Additional context
Very rough attempt, needs some polishing for sure, currently the way the selection works isn't the best, It should select the selected item without having to go into it first, also missing some funcs atm
folderpicker
package folderpicker
import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
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 dirpicker model with default styling and key bindings.
func New() Model {
return Model{
id: nextID(),
CurrentDirectory: ".",
Cursor: ">",
selected: 0,
ShowHidden: false,
AutoHeight: true,
Height: 0,
max: 0,
min: 0,
selectedStack: newStack(),
minStack: newStack(),
maxStack: newStack(),
KeyMap: DefaultKeyMap(),
Styles: DefaultStyles(),
}
}
type errorMsg struct {
err error
}
type readDirMsg struct {
id int
entries []os.DirEntry
dirCount int
}
const (
marginBottom = 5
paddingLeft = 2
)
// 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
Open key.Binding
Select key.Binding
}
// DefaultKeyMap defines the default keybindings.
func DefaultKeyMap() KeyMap {
return 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")),
Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")),
// tried bind to space but neither space, spacebar, keyspace worked
Select: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "select")),
}
}
// Styles defines the possible customizations for styles in the directory picker.
type Styles struct {
Cursor lipgloss.Style
Directory lipgloss.Style
Selected lipgloss.Style
EmptyDirectory lipgloss.Style
}
// DefaultStyles defines the default styling for the directory picker.
func DefaultStyles() Styles {
return DefaultStylesWithRenderer(lipgloss.DefaultRenderer())
}
// DefaultStylesWithRenderer defines the default styling for the directory picker,
// with a given Lip Gloss renderer.
func DefaultStylesWithRenderer(r *lipgloss.Renderer) Styles {
return Styles{
Cursor: r.NewStyle().Foreground(lipgloss.Color("212")),
Directory: r.NewStyle().Foreground(lipgloss.Color("99")),
Selected: r.NewStyle().Foreground(lipgloss.Color("212")).Bold(true),
EmptyDirectory: r.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("No directories found."),
}
}
// Model represents a directory picker.
type Model struct {
id int
// Path is the path which the user has selected with the directory picker.
Path string
// CurrentDirectory is the directory that the user is currently in.
CurrentDirectory string
KeyMap KeyMap
directories []os.DirEntry
ShowHidden bool
DirSelected 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(selected, min, max int) {
m.selectedStack.Push(selected)
m.minStack.Push(min)
m.maxStack.Push(max)
}
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 {
return func() tea.Msg {
dirEntries, err := os.ReadDir(path)
if err != nil {
return errorMsg{err}
}
var directories []os.DirEntry
for _, entry := range dirEntries {
if entry.IsDir() {
if !showHidden {
isHidden, _ := IsHidden(entry.Name())
if !isHidden {
directories = append(directories, entry)
}
} else {
directories = append(directories, entry)
}
}
}
sort.Slice(directories, func(i, j int) bool {
return directories[i].Name() < directories[j].Name()
})
return readDirMsg{id: m.id, entries: directories, dirCount: len(directories)}
}
}
// Init initializes the directory picker model.
func (m Model) Init() tea.Cmd {
return m.readDir(m.CurrentDirectory, m.ShowHidden)
}
// Update handles user interactions within the directory picker model.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case readDirMsg:
if msg.id != m.id {
break
}
m.directories = msg.entries
m.max = max(m.max, m.Height-1)
case tea.WindowSizeMsg:
if m.AutoHeight {
m.Height = msg.Height - marginBottom
}
m.max = m.Height - 1
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.directories) - 1
m.min = len(m.directories) - m.Height
m.max = len(m.directories) - 1
case key.Matches(msg, m.KeyMap.Down):
m.selected++
if m.selected >= len(m.directories) {
m.selected = len(m.directories) - 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.directories) {
m.selected = len(m.directories) - 1
}
m.min += m.Height
m.max += m.Height
if m.max >= len(m.directories) {
m.max = len(m.directories) - 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.CurrentDirectory = filepath.Dir(m.CurrentDirectory)
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, m.readDir(m.CurrentDirectory, m.ShowHidden)
case key.Matches(msg, m.KeyMap.Open):
if len(m.directories) == 0 {
break
}
dir := m.directories[m.selected]
m.CurrentDirectory = filepath.Join(m.CurrentDirectory, dir.Name())
m.pushView(m.selected, m.min, m.max)
m.selected = 0
m.min = 0
m.max = m.Height - 1
return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
case key.Matches(msg, m.KeyMap.Select):
if len(m.directories) == 0 {
break
}
// Select the current directory as the selection
m.Path = m.CurrentDirectory
}
}
return m, nil
}
// View returns the view of the directory picker.
func (m Model) View() string {
if len(m.directories) == 0 {
return m.Styles.EmptyDirectory.Height(m.Height).MaxHeight(m.Height).String()
}
var s strings.Builder
for i, dir := range m.directories {
if i < m.min || i > m.max {
continue
}
name := dir.Name()
if m.selected == i {
s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(name))
s.WriteRune('\n')
continue
}
s.WriteString(m.Styles.Cursor.Render(" "))
s.WriteString(m.Styles.Directory.Render(name))
s.WriteRune('\n')
}
for i := lipgloss.Height(s.String()); i <= m.Height; i++ {
s.WriteRune('\n')
}
return s.String()
}
// DidSelectDirectory returns whether a user has selected a directory (on this msg).
func (m Model) DidSelectDirectory(msg tea.Msg) (bool, string) {
switch msg := msg.(type) {
case tea.KeyMsg:
if key.Matches(msg, m.KeyMap.Select) && m.Path != "" {
return true, m.Path
}
}
return false, ""
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// IsHidden reports whether a file is hidden or not.
// This function should be implemented differently for Windows and non-Windows systems.
// You can use the build tags as shown in the helper functions you provided.
func IsHidden(file string) (bool, error) {
// Implementation depends on the operating system
// For simplicity, we'll use the Unix-like implementation here
return strings.HasPrefix(file, "."), nil
}
usage example
package main
import (
"fmt"
"os"
"<path-to>/folderpicker"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
folderpicker folderpicker.Model
selectedDirectory string
quitting bool
err error
}
func (m model) Init() tea.Cmd {
return m.folderpicker.Init()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit
}
}
var cmd tea.Cmd
m.folderpicker, cmd = m.folderpicker.Update(msg)
// Did the user select a directory?
if didSelect, path := m.folderpicker.DidSelectDirectory(msg); didSelect {
m.selectedDirectory = path
m.quitting = true
return m, tea.Quit
}
return m, cmd
}
func (m model) View() string {
if m.quitting {
return ""
}
var s strings.Builder
s.WriteString("\n ")
if m.err != nil {
s.WriteString(m.err.Error())
} else if m.selectedDirectory == "" {
s.WriteString("Pick a folder:")
} else {
s.WriteString("Selected folder: " + m.folderpicker.Styles.Selected.Render(m.selectedDirectory))
}
s.WriteString("\n\n" + m.folderpicker.View() + "\n")
return s.String()
}
func main() {
fp := folderpicker.New()
fp.CurrentDirectory, _ = os.UserHomeDir()
m := model{
folderpicker: fp,
}
tm, _ := tea.NewProgram(&m).Run()
mm := tm.(model)
if mm.selectedDirectory != "" {
fmt.Println("\n You selected: " + m.folderpicker.Styles.Selected.Render(mm.selectedDirectory) + "\n")
} else {
fmt.Println("\n No directory was selected.\n")
}
}
The text was updated successfully, but these errors were encountered:
Is your feature request related to a problem? Please describe.
Easily be able to pick a directory
Describe the solution you'd like
SImilar to the filepicker, except for directories.
Should include the ability to
Something that can be used with both huh, and bubbletea
Describe alternatives you've considered
Using a normal arg to have the user pass in a directory string :-(
Additional context
Very rough attempt, needs some polishing for sure, currently the way the selection works isn't the best, It should select the selected item without having to go into it first, also missing some funcs atm
folderpicker
usage example
The text was updated successfully, but these errors were encountered: