@@ -0,0 +1,545 @@
+// Package picker provides a 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
+// - Package renamed from filepicker to picker
+//
+// Upstream: https://github.com/charmbracelet/bubbles
+// PR #759: https://github.com/charmbracelet/bubbles/pull/759
+package picker
+
+import (
+ "fmt"
+ "io/fs"
+ "path"
+ "sort"
+ "strconv"
+ "strings"
+ "sync/atomic"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/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(fsys fs.FS) Model {
+ return Model{
+ id: nextID(),
+ FS: fsys,
+ 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 []fs.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
+ Toggle 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")),
+ Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
+ }
+}
+
+// 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
+}
+
+// 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."),
+ }
+}
+
+// Model represents a file picker backed by an [io/fs.FS].
+type Model struct {
+ id int
+
+ // FS is the filesystem to browse.
+ FS fs.FS
+
+ // Selection tracks multi-select state. When non-nil, the picker
+ // 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.
+ 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
+
+ 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(dir string, showHidden bool) tea.Cmd {
+ return func() tea.Msg {
+ dirEntries, err := fs.ReadDir(m.FS, dir)
+ 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 filtered []fs.DirEntry
+ for _, e := range dirEntries {
+ if strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ filtered = append(filtered, e)
+ }
+ return readDirMsg{id: m.id, entries: filtered}
+ }
+}
+
+// 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.minIdx + m.Height() - 1
+ if m.maxIdx >= len(m.files) && len(m.files) > 0 {
+ m.maxIdx = len(m.files) - 1
+ m.minIdx = max(0, m.maxIdx-m.Height()+1)
+ }
+ if m.selected > m.maxIdx {
+ m.selected = m.maxIdx
+ }
+ if m.selected < m.minIdx {
+ m.selected = m.minIdx
+ }
+ 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.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)
+ }
+ }
+ case m.Selection != nil && key.Matches(msg, m.KeyMap.Select):
+ // Enter confirms the multi-selection.
+ m.Confirmed = true
+ return m, tea.Quit
+ case key.Matches(msg, m.KeyMap.Back):
+ m.CurrentDirectory = path.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]
+ 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 {
+ break
+ }
+
+ m.CurrentDirectory = m.entryPath(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
+ }
+
+ info, err := f.Info()
+ if err != nil {
+ continue
+ }
+ 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
+ selected := m.checkboxFor(f.Name())
+ if m.ShowPermissions {
+ selected += " " + info.Mode().String()
+ }
+ if m.ShowSize {
+ 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.WriteRune('\n')
+ continue
+ }
+
+ style := m.Styles.File
+ if f.IsDir() {
+ style = m.Styles.Directory
+ } else if disabled {
+ style = m.Styles.DisabledFile
+ }
+
+ fileName := style.Render(name)
+ s.WriteString(m.Styles.Cursor.Render(" "))
+ s.WriteString(m.checkboxFor(f.Name()))
+ 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, 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.
+func (m Model) checkboxFor(name string) string {
+ if m.Selection == nil {
+ return ""
+ }
+ p := m.entryPath(name)
+ switch m.Selection.State(p) {
+ case CheckAll:
+ return " [x]"
+ case CheckPartial:
+ return " [-]"
+ default:
+ return " [ ]"
+ }
+}
+
+// entryPath returns the FS-relative path for a child entry name in
+// the current directory.
+func (m Model) entryPath(name string) string {
+ return path.Join(m.CurrentDirectory, name)
+}
+
+// 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 m.entryPath(m.files[m.selected].Name())
+}
@@ -0,0 +1,261 @@
+package picker
+
+import (
+ "io/fs"
+ "path"
+ "sort"
+)
+
+// CheckState represents the tri-state checkbox value for a node.
+type CheckState int
+
+const (
+ // CheckNone means no descendants are selected.
+ CheckNone CheckState = iota
+ // CheckPartial means some but not all descendants are selected.
+ CheckPartial
+ // CheckAll means all selectable descendants are selected.
+ CheckAll
+)
+
+// nodeKind distinguishes leaf-selectable nodes from structural ones.
+type nodeKind int
+
+const (
+ kindFile nodeKind = iota // regular file — always selectable
+ kindEmptyDir // directory with no children — selectable leaf
+ kindDir // directory with children — state is derived
+)
+
+// Selection tracks multi-select state over an fs.FS tree. It maintains
+// a flat set of selected leaves (files and empty dirs) and cached
+// subtree counts for O(1) tri-state lookups.
+//
+// The tree index is built once from the FS and is immutable; only the
+// selected set and subtree counts change on toggle.
+type Selection struct {
+ selected map[string]bool // selected leaves (files + empty dirs)
+
+ // Immutable tree index, built once from the FS.
+ children map[string][]string // parent → sorted child paths
+ parent map[string]string // child → parent path
+ kind map[string]nodeKind
+ totalSelectable map[string]int // total selectable leaves in subtree
+
+ // Mutable counter, updated incrementally on toggle.
+ selectedCount map[string]int // selected leaves in subtree
+}
+
+// NewSelection builds a Selection from the given fs.FS by walking the
+// entire tree once. The FS must implement fs.ReadDirFS.
+func NewSelection(fsys fs.FS) *Selection {
+ s := &Selection{
+ selected: make(map[string]bool),
+ children: make(map[string][]string),
+ parent: make(map[string]string),
+ kind: make(map[string]nodeKind),
+ totalSelectable: make(map[string]int),
+ selectedCount: make(map[string]int),
+ }
+
+ s.buildIndex(fsys, ".")
+ return s
+}
+
+// buildIndex recursively walks the FS to populate the tree index.
+// Returns the total selectable count for the subtree rooted at dir.
+func (s *Selection) buildIndex(fsys fs.FS, dir string) int {
+ entries, err := fs.ReadDir(fsys, dir)
+ if err != nil {
+ return 0
+ }
+
+ total := 0
+ for _, e := range entries {
+ child := path.Join(dir, e.Name())
+
+ s.parent[child] = dir
+ if e.IsDir() {
+ childTotal := s.buildIndex(fsys, child)
+ if childTotal == 0 {
+ // Empty directory — treat as a selectable leaf.
+ s.kind[child] = kindEmptyDir
+ s.totalSelectable[child] = 1
+ total++
+ } else {
+ s.kind[child] = kindDir
+ s.totalSelectable[child] = childTotal
+ total += childTotal
+ }
+ } else {
+ s.kind[child] = kindFile
+ s.totalSelectable[child] = 1
+ total++
+ }
+ s.children[dir] = append(s.children[dir], child)
+ }
+
+ s.totalSelectable[dir] = total
+ s.kind[dir] = kindDir
+
+ return total
+}
+
+// State returns the tri-state checkbox value for the given path.
+func (s *Selection) State(p string) CheckState {
+ k := s.kind[p]
+ switch k {
+ case kindFile, kindEmptyDir:
+ if s.selected[p] {
+ return CheckAll
+ }
+ return CheckNone
+ default:
+ total := s.totalSelectable[p]
+ count := s.selectedCount[p]
+ switch count {
+ case 0:
+ return CheckNone
+ case total:
+ return CheckAll
+ default:
+ return CheckPartial
+ }
+ }
+}
+
+// Toggle flips the selection of the given path. For files and empty
+// dirs, this is a simple toggle. For directories, it selects all
+// descendants if the current state is none or partial, or deselects
+// all if fully selected.
+func (s *Selection) Toggle(p string) {
+ state := s.State(p)
+
+ k := s.kind[p]
+ switch k {
+ case kindFile, kindEmptyDir:
+ if s.selected[p] {
+ delete(s.selected, p)
+ s.adjustCounts(p, -1)
+ } else {
+ s.selected[p] = true
+ s.adjustCounts(p, +1)
+ }
+ default:
+ if state == CheckAll {
+ s.setSubtree(p, false)
+ } else {
+ s.setSubtree(p, true)
+ }
+ }
+}
+
+// setSubtree selects or deselects all selectable leaves under p.
+// It walks the subtree once, flipping leaves and returning the count
+// of changes, then adjusts ancestor counts in a single upward pass.
+func (s *Selection) setSubtree(p string, sel bool) {
+ changed := s.applySubtree(p, sel)
+ if changed == 0 {
+ return
+ }
+
+ // Adjust ancestors of p (not p itself — applySubtree already
+ // updated interior counts within the subtree).
+ delta := changed
+ if !sel {
+ delta = -changed
+ }
+ cur := p
+ for {
+ par, ok := s.parent[cur]
+ if !ok {
+ break
+ }
+ s.selectedCount[par] += delta
+ cur = par
+ }
+}
+
+// applySubtree recursively sets leaves under p to the desired state.
+// For interior nodes within the subtree, it updates selectedCount
+// directly. Returns the number of leaves that actually changed.
+func (s *Selection) applySubtree(p string, sel bool) int {
+ k := s.kind[p]
+ switch k {
+ case kindFile, kindEmptyDir:
+ was := s.selected[p]
+ if sel == was {
+ return 0
+ }
+ if sel {
+ s.selected[p] = true
+ } else {
+ delete(s.selected, p)
+ }
+ return 1
+ default:
+ changed := 0
+ for _, child := range s.children[p] {
+ changed += s.applySubtree(child, sel)
+ }
+ // Update this interior node's count directly.
+ if sel {
+ s.selectedCount[p] += changed
+ } else {
+ s.selectedCount[p] -= changed
+ }
+ return changed
+ }
+}
+
+// adjustCounts increments or decrements selectedCount for all ancestors
+// of p (not p itself, since leaves don't have subtree counts).
+func (s *Selection) adjustCounts(p string, delta int) {
+ cur := p
+ for {
+ par, ok := s.parent[cur]
+ if !ok {
+ break
+ }
+ s.selectedCount[par] += delta
+ cur = par
+ }
+}
+
+// AllSelected reports whether every selectable leaf in the tree is
+// selected. When true, the caller should perform a full restore
+// rather than emitting --include flags.
+func (s *Selection) AllSelected() bool {
+ return s.State(".") == CheckAll
+}
+
+// SelectedPaths returns the minimal set of FS-relative paths covering
+// the current selection. Fully-selected subtrees are compressed to
+// their root directory. Returns nil if nothing is selected or if
+// everything is selected (check AllSelected() to distinguish).
+func (s *Selection) SelectedPaths() []string {
+ if s.State(".") == CheckNone || s.State(".") == CheckAll {
+ return nil
+ }
+ var paths []string
+ s.collectPaths(".", &paths)
+ sort.Strings(paths)
+ return paths
+}
+
+// collectPaths walks the tree top-down, emitting fully-selected
+// subtrees as single paths and recursing into partial ones.
+func (s *Selection) collectPaths(p string, out *[]string) {
+ for _, child := range s.children[p] {
+ state := s.State(child)
+ switch state {
+ case CheckNone:
+ continue
+ case CheckAll:
+ // Fully selected — emit just this path, don't recurse.
+ *out = append(*out, child)
+ case CheckPartial:
+ s.collectPaths(child, out)
+ }
+ }
+}
@@ -0,0 +1,249 @@
+package picker
+
+import (
+ "testing"
+
+ "git.secluded.site/keld/internal/restic"
+)
+
+// testSelection builds a Selection from a small set of LsNodes.
+// Tree structure:
+//
+// music/
+// album-01/
+// 01-track.flac
+// 02-track.flac
+// album-02/
+// 01-song.flac
+// docs/
+// notes.txt
+// empty/
+// readme.txt
+func testSelection(t *testing.T) *Selection {
+ t.Helper()
+ nodes := []restic.LsNode{
+ {Name: "music", Type: "dir", Path: "/music"},
+ {Name: "album-01", Type: "dir", Path: "/music/album-01"},
+ {Name: "01-track.flac", Type: "file", Path: "/music/album-01/01-track.flac", Size: 1000},
+ {Name: "02-track.flac", Type: "file", Path: "/music/album-01/02-track.flac", Size: 2000},
+ {Name: "album-02", Type: "dir", Path: "/music/album-02"},
+ {Name: "01-song.flac", Type: "file", Path: "/music/album-02/01-song.flac", Size: 3000},
+ {Name: "docs", Type: "dir", Path: "/docs"},
+ {Name: "notes.txt", Type: "file", Path: "/docs/notes.txt", Size: 500},
+ {Name: "empty", Type: "dir", Path: "/empty"},
+ {Name: "readme.txt", Type: "file", Path: "/readme.txt", Size: 100},
+ }
+ sfs := restic.NewSnapshotFS(nodes)
+ return NewSelection(sfs)
+}
+
+func TestSelectionInitialState(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ // Everything starts unselected.
+ if sel.State(".") != CheckNone {
+ t.Errorf("root: got %v, want CheckNone", sel.State("."))
+ }
+ if sel.State("music") != CheckNone {
+ t.Errorf("music: got %v, want CheckNone", sel.State("music"))
+ }
+ if sel.State("readme.txt") != CheckNone {
+ t.Errorf("readme.txt: got %v, want CheckNone", sel.State("readme.txt"))
+ }
+ if sel.AllSelected() {
+ t.Error("AllSelected should be false initially")
+ }
+ if paths := sel.SelectedPaths(); len(paths) != 0 {
+ t.Errorf("SelectedPaths: got %v, want empty", paths)
+ }
+}
+
+func TestSelectionToggleFile(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ sel.Toggle("readme.txt")
+ if sel.State("readme.txt") != CheckAll {
+ t.Errorf("readme.txt after toggle: got %v, want CheckAll", sel.State("readme.txt"))
+ }
+ // Root should be partial now.
+ if sel.State(".") != CheckPartial {
+ t.Errorf("root after file toggle: got %v, want CheckPartial", sel.State("."))
+ }
+
+ // Toggle again to deselect.
+ sel.Toggle("readme.txt")
+ if sel.State("readme.txt") != CheckNone {
+ t.Errorf("readme.txt after second toggle: got %v, want CheckNone", sel.State("readme.txt"))
+ }
+ if sel.State(".") != CheckNone {
+ t.Errorf("root after deselect: got %v, want CheckNone", sel.State("."))
+ }
+}
+
+func TestSelectionToggleDirectory(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ // Selecting album-01 should select both tracks.
+ sel.Toggle("music/album-01")
+ if sel.State("music/album-01") != CheckAll {
+ t.Errorf("album-01: got %v, want CheckAll", sel.State("music/album-01"))
+ }
+ if sel.State("music/album-01/01-track.flac") != CheckAll {
+ t.Error("01-track.flac should be selected")
+ }
+ if sel.State("music/album-01/02-track.flac") != CheckAll {
+ t.Error("02-track.flac should be selected")
+ }
+ // music should be partial (album-02 is not selected).
+ if sel.State("music") != CheckPartial {
+ t.Errorf("music: got %v, want CheckPartial", sel.State("music"))
+ }
+
+ // Toggle album-01 again to deselect all.
+ sel.Toggle("music/album-01")
+ if sel.State("music/album-01") != CheckNone {
+ t.Errorf("album-01 after deselect: got %v, want CheckNone", sel.State("music/album-01"))
+ }
+ if sel.State("music") != CheckNone {
+ t.Errorf("music after deselect: got %v, want CheckNone", sel.State("music"))
+ }
+}
+
+func TestSelectionPartialThenToggle(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ // Select one track in album-01.
+ sel.Toggle("music/album-01/01-track.flac")
+ if sel.State("music/album-01") != CheckPartial {
+ t.Errorf("album-01: got %v, want CheckPartial", sel.State("music/album-01"))
+ }
+
+ // Toggling the partial directory should select ALL descendants.
+ sel.Toggle("music/album-01")
+ if sel.State("music/album-01") != CheckAll {
+ t.Errorf("album-01 after toggle from partial: got %v, want CheckAll", sel.State("music/album-01"))
+ }
+ if sel.State("music/album-01/02-track.flac") != CheckAll {
+ t.Error("02-track.flac should now be selected")
+ }
+}
+
+func TestSelectionEmptyDir(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ sel.Toggle("empty")
+ if sel.State("empty") != CheckAll {
+ t.Errorf("empty dir: got %v, want CheckAll", sel.State("empty"))
+ }
+
+ sel.Toggle("empty")
+ if sel.State("empty") != CheckNone {
+ t.Errorf("empty dir after deselect: got %v, want CheckNone", sel.State("empty"))
+ }
+}
+
+func TestSelectionAllSelected(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ // Select everything via root.
+ sel.Toggle(".")
+ if !sel.AllSelected() {
+ t.Error("AllSelected should be true after selecting root")
+ }
+ if sel.State(".") != CheckAll {
+ t.Errorf("root: got %v, want CheckAll", sel.State("."))
+ }
+
+ // SelectedPaths should return empty (full restore = no --include).
+ if paths := sel.SelectedPaths(); len(paths) != 0 {
+ t.Errorf("SelectedPaths when all selected: got %v, want empty", paths)
+ }
+
+ // Deselect root.
+ sel.Toggle(".")
+ if sel.AllSelected() {
+ t.Error("AllSelected should be false after deselecting root")
+ }
+}
+
+func TestSelectionSelectedPaths(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ // Select all of album-01 and the single file in docs.
+ sel.Toggle("music/album-01")
+ sel.Toggle("docs/notes.txt")
+
+ paths := sel.SelectedPaths()
+ // album-01 is fully selected → compressed to directory.
+ // docs has only one child (notes.txt), so selecting it makes docs
+ // fully selected → compressed to directory too.
+ want := map[string]bool{
+ "music/album-01": true,
+ "docs": true,
+ }
+ if len(paths) != len(want) {
+ t.Fatalf("SelectedPaths: got %v, want keys of %v", paths, want)
+ }
+ for _, p := range paths {
+ if !want[p] {
+ t.Errorf("unexpected path %q in SelectedPaths", p)
+ }
+ }
+}
+
+func TestSelectionSelectedPathsCompression(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ // Select all of music (both albums).
+ sel.Toggle("music")
+
+ paths := sel.SelectedPaths()
+ // Should compress to just "music".
+ if len(paths) != 1 || paths[0] != "music" {
+ t.Errorf("SelectedPaths: got %v, want [music]", paths)
+ }
+}
+
+func TestSelectionDeselectChildMakesParentPartial(t *testing.T) {
+ t.Parallel()
+ sel := testSelection(t)
+
+ // Select entire music tree, then deselect one track.
+ sel.Toggle("music")
+ sel.Toggle("music/album-01/01-track.flac")
+
+ if sel.State("music/album-01") != CheckPartial {
+ t.Errorf("album-01: got %v, want CheckPartial", sel.State("music/album-01"))
+ }
+ if sel.State("music") != CheckPartial {
+ t.Errorf("music: got %v, want CheckPartial", sel.State("music"))
+ }
+
+ // SelectedPaths should list individual files, not the dir.
+ paths := sel.SelectedPaths()
+ pathSet := make(map[string]bool, len(paths))
+ for _, p := range paths {
+ pathSet[p] = true
+ }
+ // album-01 has only 02-track selected; album-02 is fully selected.
+ if !pathSet["music/album-01/02-track.flac"] {
+ t.Error("expected music/album-01/02-track.flac in paths")
+ }
+ if !pathSet["music/album-02"] {
+ t.Error("expected music/album-02 (fully selected) in paths")
+ }
+ if pathSet["music/album-01"] {
+ t.Error("music/album-01 should NOT be in paths (only partially selected)")
+ }
+ if pathSet["music"] {
+ t.Error("music should NOT be in paths (only partially selected)")
+ }
+}