1// Package picker provides a multi-select file picker component for
2// Bubble Tea applications, backed by an [io/fs.FS].
3//
4// Vendored from charm.land/bubbles/v2/filepicker (upstream main branch,
5// 2026-03-26) with the following modifications:
6//
7// - Stripped to fs.FS only (no real-filesystem / os.ReadDir support)
8// - Multi-select only with tri-state directory checkboxes
9// - Single-select mode, AllowedTypes, and DidSelectFile removed
10// - Package renamed from filepicker to picker
11//
12// Upstream: https://github.com/charmbracelet/bubbles
13// PR #759: https://github.com/charmbracelet/bubbles/pull/759
14package picker
15
16import (
17 "fmt"
18 "io/fs"
19 "path"
20 "sort"
21 "strconv"
22 "strings"
23 "sync/atomic"
24
25 "charm.land/bubbles/v2/key"
26 tea "charm.land/bubbletea/v2"
27 "charm.land/lipgloss/v2"
28 "github.com/dustin/go-humanize"
29)
30
31var lastID int64
32
33func nextID() int {
34 return int(atomic.AddInt64(&lastID, 1))
35}
36
37// New returns a new multi-select file picker with default styling and
38// key bindings. The Selection is built from the given FS.
39func New(fsys fs.FS) Model {
40 return Model{
41 id: nextID(),
42 FS: fsys,
43 Selection: NewSelection(fsys),
44 CurrentDirectory: ".",
45 Cursor: ">",
46 selected: 0,
47 ShowPermissions: true,
48 ShowSize: true,
49 ShowHidden: false,
50 AutoHeight: true,
51 height: 0,
52 maxIdx: 0,
53 minIdx: 0,
54 selectedStack: newStack(),
55 minStack: newStack(),
56 maxStack: newStack(),
57 KeyMap: DefaultKeyMap(),
58 Styles: DefaultStyles(),
59 }
60}
61
62type errorMsg struct {
63 err error
64}
65
66type readDirMsg struct {
67 id int
68 entries []fs.DirEntry
69}
70
71const (
72 marginBottom = 5
73 fileSizeWidth = 7
74 paddingLeft = 2
75)
76
77// KeyMap defines key bindings for each user action.
78type KeyMap struct {
79 GoToTop key.Binding
80 GoToLast key.Binding
81 Down key.Binding
82 Up key.Binding
83 PageUp key.Binding
84 PageDown key.Binding
85 Back key.Binding
86 Open key.Binding
87 Select key.Binding
88 Toggle key.Binding
89 Quit key.Binding
90}
91
92// DefaultKeyMap defines the default keybindings.
93func DefaultKeyMap() KeyMap {
94 return KeyMap{
95 GoToTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first")),
96 GoToLast: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last")),
97 Down: key.NewBinding(key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j", "down")),
98 Up: key.NewBinding(key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k", "up")),
99 PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up")),
100 PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down")),
101 Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")),
102 Open: key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("l", "open")),
103 Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
104 Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
105 Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "cancel")),
106 }
107}
108
109// Styles defines the possible customizations for styles in the file picker.
110type Styles struct {
111 Cursor lipgloss.Style
112 Directory lipgloss.Style
113 File lipgloss.Style
114 Permission lipgloss.Style
115 Selected lipgloss.Style
116 FileSize lipgloss.Style
117 EmptyDirectory lipgloss.Style
118}
119
120// DefaultStyles defines the default styling for the file picker.
121func DefaultStyles() Styles {
122 return Styles{
123 Cursor: lipgloss.NewStyle().Foreground(lipgloss.Color("212")),
124 Directory: lipgloss.NewStyle().Foreground(lipgloss.Color("99")),
125 File: lipgloss.NewStyle(),
126 Permission: lipgloss.NewStyle().Foreground(lipgloss.Color("244")),
127 Selected: lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true),
128 FileSize: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right),
129 EmptyDirectory: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."),
130 }
131}
132
133// Model represents a multi-select file picker backed by an [io/fs.FS].
134// Use [New] to construct; the Selection field is always initialized.
135type Model struct {
136 id int
137
138 // FS is the filesystem to browse.
139 FS fs.FS
140
141 // Selection tracks multi-select state. Always set by New;
142 // renders tri-state checkboxes and supports space-to-toggle.
143 Selection *Selection
144
145 // Confirmed is set to true when the user presses Enter to confirm
146 // the selection. The caller should check this after the program
147 // exits and read SelectedPaths()/AllSelected() from the Selection.
148 Confirmed bool
149
150 // CurrentDirectory is the directory that the user is currently in.
151 CurrentDirectory string
152
153 KeyMap KeyMap
154 files []fs.DirEntry
155 ShowPermissions bool
156 ShowSize bool
157 ShowHidden bool
158
159 selected int
160 selectedStack stack
161
162 minIdx int
163 maxIdx int
164 maxStack stack
165 minStack stack
166
167 height int
168 AutoHeight bool
169
170 Cursor string
171 Styles Styles
172}
173
174type stack struct {
175 Push func(int)
176 Pop func() int
177 Length func() int
178}
179
180func newStack() stack {
181 slice := make([]int, 0)
182 return stack{
183 Push: func(i int) {
184 slice = append(slice, i)
185 },
186 Pop: func() int {
187 res := slice[len(slice)-1]
188 slice = slice[:len(slice)-1]
189 return res
190 },
191 Length: func() int {
192 return len(slice)
193 },
194 }
195}
196
197func (m *Model) pushView(selected, minimum, maximum int) {
198 m.selectedStack.Push(selected)
199 m.minStack.Push(minimum)
200 m.maxStack.Push(maximum)
201}
202
203func (m *Model) popView() (int, int, int) {
204 return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop()
205}
206
207func (m Model) readDir(dir string, showHidden bool) tea.Cmd {
208 return func() tea.Msg {
209 dirEntries, err := fs.ReadDir(m.FS, dir)
210 if err != nil {
211 return errorMsg{err}
212 }
213
214 sort.Slice(dirEntries, func(i, j int) bool {
215 if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
216 return dirEntries[i].Name() < dirEntries[j].Name()
217 }
218 return dirEntries[i].IsDir()
219 })
220
221 if showHidden {
222 return readDirMsg{id: m.id, entries: dirEntries}
223 }
224
225 var filtered []fs.DirEntry
226 for _, e := range dirEntries {
227 if strings.HasPrefix(e.Name(), ".") {
228 continue
229 }
230 filtered = append(filtered, e)
231 }
232 return readDirMsg{id: m.id, entries: filtered}
233 }
234}
235
236// SetHeight sets the height of the file picker.
237func (m *Model) SetHeight(h int) {
238 m.height = h
239 if m.maxIdx > m.height-1 {
240 m.maxIdx = m.minIdx + m.height - 1
241 }
242}
243
244// Height returns the height of the file picker.
245func (m Model) Height() int {
246 return m.height
247}
248
249// Init initializes the file picker model.
250func (m Model) Init() tea.Cmd {
251 return m.readDir(m.CurrentDirectory, m.ShowHidden)
252}
253
254// Update handles user interactions within the file picker model.
255func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
256 switch msg := msg.(type) {
257 case readDirMsg:
258 if msg.id != m.id {
259 break
260 }
261 m.files = msg.entries
262 m.maxIdx = max(m.maxIdx, m.Height()-1)
263 case tea.WindowSizeMsg:
264 if m.AutoHeight {
265 m.SetHeight(msg.Height - marginBottom)
266 }
267 m.maxIdx = m.minIdx + m.Height() - 1
268 if m.maxIdx >= len(m.files) && len(m.files) > 0 {
269 m.maxIdx = len(m.files) - 1
270 m.minIdx = max(0, m.maxIdx-m.Height()+1)
271 }
272 if m.selected > m.maxIdx {
273 m.selected = m.maxIdx
274 }
275 if m.selected < m.minIdx {
276 m.selected = m.minIdx
277 }
278 case tea.KeyPressMsg:
279 switch {
280 case key.Matches(msg, m.KeyMap.Quit):
281 return m, tea.Quit
282 case key.Matches(msg, m.KeyMap.GoToTop):
283 m.selected = 0
284 m.minIdx = 0
285 m.maxIdx = m.Height() - 1
286 case key.Matches(msg, m.KeyMap.GoToLast):
287 m.selected = len(m.files) - 1
288 m.minIdx = len(m.files) - m.Height()
289 m.maxIdx = len(m.files) - 1
290 case key.Matches(msg, m.KeyMap.Down):
291 m.selected++
292 if m.selected >= len(m.files) {
293 m.selected = len(m.files) - 1
294 }
295 if m.selected > m.maxIdx {
296 m.minIdx++
297 m.maxIdx++
298 }
299 case key.Matches(msg, m.KeyMap.Up):
300 m.selected--
301 if m.selected < 0 {
302 m.selected = 0
303 }
304 if m.selected < m.minIdx {
305 m.minIdx--
306 m.maxIdx--
307 }
308 case key.Matches(msg, m.KeyMap.PageDown):
309 m.selected += m.Height()
310 if m.selected >= len(m.files) {
311 m.selected = len(m.files) - 1
312 }
313 m.minIdx += m.Height()
314 m.maxIdx += m.Height()
315
316 if m.maxIdx >= len(m.files) {
317 m.maxIdx = len(m.files) - 1
318 m.minIdx = m.maxIdx - m.Height()
319 }
320 case key.Matches(msg, m.KeyMap.PageUp):
321 m.selected -= m.Height()
322 if m.selected < 0 {
323 m.selected = 0
324 }
325 m.minIdx -= m.Height()
326 m.maxIdx -= m.Height()
327
328 if m.minIdx < 0 {
329 m.minIdx = 0
330 m.maxIdx = m.minIdx + m.Height()
331 }
332 case key.Matches(msg, m.KeyMap.Toggle):
333 if m.Selection != nil && len(m.files) > 0 {
334 p := m.entryPath(m.files[m.selected].Name())
335 m.Selection.Toggle(p)
336 }
337 case key.Matches(msg, m.KeyMap.Select):
338 // Enter confirms the selection and quits.
339 m.Confirmed = true
340 return m, tea.Quit
341 case key.Matches(msg, m.KeyMap.Back):
342 m.CurrentDirectory = path.Dir(m.CurrentDirectory)
343 if m.selectedStack.Length() > 0 {
344 m.selected, m.minIdx, m.maxIdx = m.popView()
345 } else {
346 m.selected = 0
347 m.minIdx = 0
348 m.maxIdx = m.Height() - 1
349 }
350 return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
351 case key.Matches(msg, m.KeyMap.Open):
352 if len(m.files) == 0 {
353 break
354 }
355
356 if !m.files[m.selected].IsDir() {
357 break
358 }
359
360 m.CurrentDirectory = m.entryPath(m.files[m.selected].Name())
361 m.pushView(m.selected, m.minIdx, m.maxIdx)
362 m.selected = 0
363 m.minIdx = 0
364 m.maxIdx = m.Height() - 1
365 return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
366 }
367 }
368 return m, nil
369}
370
371// View returns the view of the file picker.
372func (m Model) View() string {
373 if len(m.files) == 0 {
374 return m.Styles.EmptyDirectory.Height(m.Height()).MaxHeight(m.Height()).String()
375 }
376 var s strings.Builder
377
378 for i, f := range m.files {
379 if i < m.minIdx || i > m.maxIdx {
380 continue
381 }
382
383 info, err := f.Info()
384 if err != nil {
385 continue
386 }
387 size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec
388 name := f.Name()
389
390 if m.selected == i {
391 selected := m.checkboxFor(f.Name())
392 if m.ShowPermissions {
393 selected += " " + info.Mode().String()
394 }
395 if m.ShowSize {
396 selected += fmt.Sprintf("%"+strconv.Itoa(m.Styles.FileSize.GetWidth())+"s", size)
397 }
398 selected += " " + name
399 s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected))
400 s.WriteRune('\n')
401 continue
402 }
403
404 style := m.Styles.File
405 if f.IsDir() {
406 style = m.Styles.Directory
407 }
408
409 fileName := style.Render(name)
410 s.WriteString(m.Styles.Cursor.Render(" "))
411 s.WriteString(m.checkboxFor(f.Name()))
412 if m.ShowPermissions {
413 s.WriteString(" " + m.Styles.Permission.Render(info.Mode().String()))
414 }
415 if m.ShowSize {
416 s.WriteString(m.Styles.FileSize.Render(size))
417 }
418 s.WriteString(" " + fileName)
419 s.WriteRune('\n')
420 }
421
422 for i := lipgloss.Height(s.String()); i <= m.Height(); i++ {
423 s.WriteRune('\n')
424 }
425
426 return s.String()
427}
428
429// checkboxFor returns the tri-state checkbox prefix for the given
430// entry name. Returns an empty string if Selection is nil (defensive;
431// New always initializes it).
432func (m Model) checkboxFor(name string) string {
433 if m.Selection == nil {
434 return ""
435 }
436 p := m.entryPath(name)
437 switch m.Selection.State(p) {
438 case CheckAll:
439 return " [x]"
440 case CheckPartial:
441 return " [-]"
442 default:
443 return " [ ]"
444 }
445}
446
447// entryPath returns the FS-relative path for a child entry name in
448// the current directory.
449func (m Model) entryPath(name string) string {
450 return path.Join(m.CurrentDirectory, name)
451}
452
453// HighlightedPath returns the path of the currently highlighted file or directory.
454func (m Model) HighlightedPath() string {
455 if len(m.files) == 0 || m.selected < 0 || m.selected >= len(m.files) {
456 return ""
457 }
458 return m.entryPath(m.files[m.selected].Name())
459}