@@ -1,11 +1,12 @@
-// Package picker provides a file picker component for Bubble Tea
-// applications, backed by an [io/fs.FS].
+// Package picker provides a multi-select file picker component for
+// Bubble Tea applications, backed by an [io/fs.FS].
//
// Vendored from charm.land/bubbles/v2/filepicker (upstream main branch,
// 2026-03-26) with the following modifications:
//
// - Stripped to fs.FS only (no real-filesystem / os.ReadDir support)
-// - Multi-select with tri-state directory checkboxes
+// - Multi-select only with tri-state directory checkboxes
+// - Single-select mode, AllowedTypes, and DidSelectFile removed
// - Package renamed from filepicker to picker
//
// Upstream: https://github.com/charmbracelet/bubbles
@@ -33,20 +34,19 @@ func nextID() int {
return int(atomic.AddInt64(&lastID, 1))
}
-// New returns a new filepicker model with default styling and key bindings.
+// New returns a new multi-select file picker with default styling and
+// key bindings. The Selection is built from the given FS.
func New(fsys fs.FS) Model {
return Model{
id: nextID(),
FS: fsys,
+ Selection: NewSelection(fsys),
CurrentDirectory: ".",
Cursor: ">",
- AllowedTypes: []string{},
selected: 0,
ShowPermissions: true,
ShowSize: true,
ShowHidden: false,
- DirAllowed: false,
- FileAllowed: true,
AutoHeight: true,
height: 0,
maxIdx: 0,
@@ -98,7 +98,7 @@ func 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", "open")),
+ Open: key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("l", "open")),
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
}
@@ -106,69 +106,54 @@ func DefaultKeyMap() KeyMap {
// Styles defines the possible customizations for styles in the file picker.
type Styles struct {
- DisabledCursor lipgloss.Style
- Cursor 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
+ Cursor 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.
func DefaultStyles() Styles {
return Styles{
- DisabledCursor: lipgloss.NewStyle().Foreground(lipgloss.Color("247")),
- Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")),
- 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."),
+ Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")),
+ 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")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."),
}
}
-// Model represents a file picker backed by an [io/fs.FS].
+// Model represents a multi-select file picker backed by an [io/fs.FS].
+// Use [New] to construct; the Selection field is always initialized.
type Model struct {
id int
// FS is the filesystem to browse.
FS fs.FS
- // Selection tracks multi-select state. When non-nil, the picker
+ // Selection tracks multi-select state. Always set by New;
// renders tri-state checkboxes and supports space-to-toggle.
Selection *Selection
// Confirmed is set to true when the user presses Enter to confirm
- // a multi-selection. The caller should check this after Update
- // and read SelectedPaths()/AllSelected() from the Selection.
+ // the selection. The caller should check this after the program
+ // exits and read SelectedPaths()/AllSelected() from the Selection.
Confirmed bool
- // 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 []fs.DirEntry
ShowPermissions bool
ShowSize bool
ShowHidden bool
- DirAllowed bool
- FileAllowed bool
- FileSelected string
selected int
selectedStack stack
@@ -342,16 +327,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
case key.Matches(msg, m.KeyMap.Toggle):
if m.Selection != nil && len(m.files) > 0 {
- f := m.files[m.selected]
- // Only toggle files that pass the AllowedTypes filter
- // (or directories, which are always toggleable).
- if f.IsDir() || m.canSelect(f.Name()) {
- p := m.entryPath(f.Name())
- m.Selection.Toggle(p)
- }
+ p := m.entryPath(m.files[m.selected].Name())
+ m.Selection.Toggle(p)
}
- case m.Selection != nil && key.Matches(msg, m.KeyMap.Select):
- // Enter confirms the multi-selection.
+ case key.Matches(msg, m.KeyMap.Select):
+ // Enter confirms the selection and quits.
m.Confirmed = true
return m, tea.Quit
case key.Matches(msg, m.KeyMap.Back):
@@ -369,20 +349,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
break
}
- f := m.files[m.selected]
- isDir := f.IsDir()
-
- if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) {
- if key.Matches(msg, m.KeyMap.Select) {
- m.Path = m.entryPath(f.Name())
- }
- }
-
- if !isDir {
+ if !m.files[m.selected].IsDir() {
break
}
- m.CurrentDirectory = m.entryPath(f.Name())
+ m.CurrentDirectory = m.entryPath(m.files[m.selected].Name())
m.pushView(m.selected, m.minIdx, m.maxIdx)
m.selected = 0
m.minIdx = 0
@@ -411,9 +382,8 @@ func (m Model) View() string {
}
size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec
name := f.Name()
- disabled := !m.canSelect(name) && !f.IsDir()
- if m.selected == i { //nolint:nestif
+ if m.selected == i {
selected := m.checkboxFor(f.Name())
if m.ShowPermissions {
selected += " " + info.Mode().String()
@@ -422,11 +392,7 @@ func (m Model) View() string {
selected += fmt.Sprintf("%"+strconv.Itoa(m.Styles.FileSize.GetWidth())+"s", size)
}
selected += " " + name
- 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.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected))
s.WriteRune('\n')
continue
}
@@ -434,8 +400,6 @@ func (m Model) View() string {
style := m.Styles.File
if f.IsDir() {
style = m.Styles.Directory
- } else if disabled {
- style = m.Styles.DisabledFile
}
fileName := style.Render(name)
@@ -458,63 +422,9 @@ func (m Model) View() string {
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, p := m.didSelectFile(msg)
- if didSelect && m.canSelect(p) {
- return true, p
- }
- 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, p := m.didSelectFile(msg)
- if didSelect && !m.canSelect(p) {
- return true, p
- }
- 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 !key.Matches(msg, m.KeyMap.Select) {
- return false, ""
- }
-
- f := m.files[m.selected]
- isDir := f.IsDir()
-
- if ((!isDir && m.FileAllowed) || (isDir && m.DirAllowed)) && m.Path != "" {
- return true, m.Path
- }
- 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
-}
-
// checkboxFor returns the tri-state checkbox prefix for the given
-// entry name, or an empty string if multi-select is not active.
+// entry name. Returns an empty string if Selection is nil (defensive;
+// New always initializes it).
func (m Model) checkboxFor(name string) string {
if m.Selection == nil {
return ""