Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature Request] Directory Picker #547

Open
ondrovic opened this issue Jul 10, 2024 · 0 comments
Open

[Feature Request] Directory Picker #547

ondrovic opened this issue Jul 10, 2024 · 0 comments
Labels
enhancement New feature or request

Comments

@ondrovic
Copy link

ondrovic commented Jul 10, 2024

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")
    }
}
@ondrovic ondrovic added the enhancement New feature or request label Jul 10, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant