Files
crush-code-agent-ide/vendor/github.com/charmbracelet/bubbles/v2/filepicker/filepicker.go
Carlos Alexandro Becker a700b64a28 fix: hide completions tui when no results (#206)
* fix: hide completions tui when no results

* fix: gitignore

* Revert "fix(tui): completions should not close on no results (#198)"

This reverts commit 833eede1c1.

* fix: completions

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: accept

* fix: improvements

* chore(deps): update bubbles

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: improvements

* fix: accept

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
2025-07-16 22:11:12 -03:00

527 lines
13 KiB
Go

// Package filepicker provides a file picker component for Bubble Tea
// applications.
package filepicker
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync/atomic"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/dustin/go-humanize"
)
var lastID int64
func nextID() int {
return int(atomic.AddInt64(&lastID, 1))
}
// New returns a new filepicker model with default styling and key bindings.
func New() Model {
return Model{
id: nextID(),
CurrentDirectory: ".",
Cursor: ">",
AllowedTypes: []string{},
selected: 0,
ShowPermissions: true,
ShowSize: true,
ShowHidden: false,
DirAllowed: false,
FileAllowed: true,
AutoHeight: true,
height: 0,
maxIdx: 0,
minIdx: 0,
selectedStack: newStack(),
minStack: newStack(),
maxStack: newStack(),
KeyMap: DefaultKeyMap(),
Styles: DefaultStyles(),
}
}
type errorMsg struct {
err error
}
type readDirMsg struct {
id int
entries []os.DirEntry
}
const (
marginBottom = 5
fileSizeWidth = 7
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")),
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
}
}
// Styles defines the possible customizations for styles in the file picker.
type Styles struct {
DisabledCursor lipgloss.Style
Cursor lipgloss.Style
Symlink lipgloss.Style
Directory lipgloss.Style
File lipgloss.Style
DisabledFile lipgloss.Style
Permission lipgloss.Style
Selected lipgloss.Style
DisabledSelected lipgloss.Style
FileSize lipgloss.Style
EmptyDirectory lipgloss.Style
}
// DefaultStyles defines the default styling for the file picker.
func DefaultStyles() Styles {
return Styles{
DisabledCursor: lipgloss.NewStyle().Foreground(lipgloss.Color("247")),
Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")),
Symlink: lipgloss.NewStyle().Foreground(lipgloss.Color("36")),
Directory: lipgloss.NewStyle().Foreground(lipgloss.Color("99")),
File: lipgloss.NewStyle(),
DisabledFile: lipgloss.NewStyle().Foreground(lipgloss.Color("243")),
DisabledSelected: lipgloss.NewStyle().Foreground(lipgloss.Color("247")),
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")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."),
}
}
// Model represents a file picker.
type Model struct {
id int
// Path is the path which the user has selected with the file picker.
Path string
// CurrentDirectory is the directory that the user is currently in.
CurrentDirectory string
// AllowedTypes specifies which file types the user may select.
// If empty the user may select any file.
AllowedTypes []string
KeyMap KeyMap
files []os.DirEntry
ShowPermissions bool
ShowSize bool
ShowHidden bool
DirAllowed bool
FileAllowed bool
FileSelected string
selected int
selectedStack stack
minIdx int
maxIdx 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, minimum, maximum int) {
m.selectedStack.Push(selected)
m.minStack.Push(minimum)
m.maxStack.Push(maximum)
}
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}
}
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{id: m.id, entries: dirEntries}
}
var sanitizedDirEntries []os.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if isHidden {
continue
}
sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
}
return readDirMsg{id: m.id, entries: sanitizedDirEntries}
}
}
// SetHeight sets the height of the file picker.
func (m *Model) SetHeight(h int) {
m.height = h
if m.maxIdx > m.height-1 {
m.maxIdx = m.minIdx + m.height - 1
}
}
// Height returns the height of the file picker.
func (m Model) Height() int {
return m.height
}
// Init initializes the file picker model.
func (m Model) Init() tea.Cmd {
return m.readDir(m.CurrentDirectory, 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:
if msg.id != m.id {
break
}
m.files = msg.entries
m.maxIdx = max(m.maxIdx, m.Height()-1)
case tea.WindowSizeMsg:
if m.AutoHeight {
m.SetHeight(msg.Height - marginBottom)
}
m.maxIdx = m.Height() - 1
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.KeyMap.GoToTop):
m.selected = 0
m.minIdx = 0
m.maxIdx = m.Height() - 1
case key.Matches(msg, m.KeyMap.GoToLast):
m.selected = len(m.files) - 1
m.minIdx = len(m.files) - m.Height()
m.maxIdx = 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.maxIdx {
m.minIdx++
m.maxIdx++
}
case key.Matches(msg, m.KeyMap.Up):
m.selected--
if m.selected < 0 {
m.selected = 0
}
if m.selected < m.minIdx {
m.minIdx--
m.maxIdx--
}
case key.Matches(msg, m.KeyMap.PageDown):
m.selected += m.Height()
if m.selected >= len(m.files) {
m.selected = len(m.files) - 1
}
m.minIdx += m.Height()
m.maxIdx += m.Height()
if m.maxIdx >= len(m.files) {
m.maxIdx = len(m.files) - 1
m.minIdx = m.maxIdx - m.Height()
}
case key.Matches(msg, m.KeyMap.PageUp):
m.selected -= m.Height()
if m.selected < 0 {
m.selected = 0
}
m.minIdx -= m.Height()
m.maxIdx -= m.Height()
if m.minIdx < 0 {
m.minIdx = 0
m.maxIdx = m.minIdx + m.Height()
}
case key.Matches(msg, m.KeyMap.Back):
m.CurrentDirectory = filepath.Dir(m.CurrentDirectory)
if m.selectedStack.Length() > 0 {
m.selected, m.minIdx, m.maxIdx = m.popView()
} else {
m.selected = 0
m.minIdx = 0
m.maxIdx = m.Height() - 1
}
return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
case key.Matches(msg, m.KeyMap.Open):
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.CurrentDirectory, 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) {
// Select the current path as the selection
m.Path = filepath.Join(m.CurrentDirectory, f.Name())
}
}
if !isDir {
break
}
m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name())
m.pushView(m.selected, m.minIdx, m.maxIdx)
m.selected = 0
m.minIdx = 0
m.maxIdx = m.Height() - 1
return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
}
}
return m, nil
}
// View returns the view of the file picker.
func (m Model) View() string {
if len(m.files) == 0 {
return m.Styles.EmptyDirectory.Height(m.Height()).MaxHeight(m.Height()).String()
}
var s strings.Builder
for i, f := range m.files {
if i < m.minIdx || i > m.maxIdx {
continue
}
var symlinkPath string
info, _ := f.Info()
isSymlink := info.Mode()&os.ModeSymlink != 0
size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec
name := f.Name()
if isSymlink {
symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name))
}
disabled := !m.canSelect(name) && !f.IsDir()
if m.selected == i { //nolint:nestif
selected := ""
if m.ShowPermissions {
selected += " " + info.Mode().String()
}
if m.ShowSize {
selected += fmt.Sprintf("%"+strconv.Itoa(m.Styles.FileSize.GetWidth())+"s", size)
}
selected += " " + name
if isSymlink {
selected += " → " + symlinkPath
}
if disabled {
s.WriteString(m.Styles.DisabledCursor.Render(m.Cursor) + m.Styles.DisabledSelected.Render(selected))
} else {
s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected))
}
s.WriteRune('\n')
continue
}
style := m.Styles.File
if f.IsDir() {
style = m.Styles.Directory
} else if isSymlink {
style = m.Styles.Symlink
} else if disabled {
style = m.Styles.DisabledFile
}
fileName := style.Render(name)
s.WriteString(m.Styles.Cursor.Render(" "))
if isSymlink {
fileName += " → " + symlinkPath
}
if m.ShowPermissions {
s.WriteString(" " + m.Styles.Permission.Render(info.Mode().String()))
}
if m.ShowSize {
s.WriteString(m.Styles.FileSize.Render(size))
}
s.WriteString(" " + fileName)
s.WriteRune('\n')
}
for i := lipgloss.Height(s.String()); i <= m.Height(); i++ {
s.WriteRune('\n')
}
return s.String()
}
// DidSelectFile returns whether a user has selected a file (on this msg).
func (m Model) DidSelectFile(msg tea.Msg) (bool, string) {
didSelect, path := m.didSelectFile(msg)
if didSelect && m.canSelect(path) {
return true, path
}
return false, ""
}
// DidSelectDisabledFile returns whether a user tried to select a disabled file
// (on this msg). This is necessary only if you would like to warn the user that
// they tried to select a disabled file.
func (m Model) DidSelectDisabledFile(msg tea.Msg) (bool, string) {
didSelect, path := m.didSelectFile(msg)
if didSelect && !m.canSelect(path) {
return true, path
}
return false, ""
}
func (m Model) didSelectFile(msg tea.Msg) (bool, string) {
if len(m.files) == 0 {
return false, ""
}
switch msg := msg.(type) {
case tea.KeyPressMsg:
// 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 KeyPressMsg, then the file could not have been selected this iteration.
// Only a KeyPressMsg can select a file.
default:
return false, ""
}
return false, ""
}
func (m Model) canSelect(file string) bool {
if len(m.AllowedTypes) <= 0 {
return true
}
for _, ext := range m.AllowedTypes {
if strings.HasSuffix(file, ext) {
return true
}
}
return false
}
// HighlightedPath returns the path of the currently highlighted file or directory.
func (m Model) HighlightedPath() string {
if len(m.files) == 0 || m.selected < 0 || m.selected >= len(m.files) {
return ""
}
return filepath.Join(m.CurrentDirectory, m.files[m.selected].Name())
}