filepicker.go

  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}