mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
* 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>
527 lines
13 KiB
Go
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())
|
|
}
|