Add vendored filepicker with fs.FS multi-select and tri-state

Amolith created

Change summary

internal/picker/filepicker.go     | 545 +++++++++++++++++++++++++++++++++
internal/picker/selection.go      | 261 +++++++++++++++
internal/picker/selection_test.go | 249 +++++++++++++++
3 files changed, 1,055 insertions(+)

Detailed changes

internal/picker/filepicker.go 🔗

@@ -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())
+}

internal/picker/selection.go 🔗

@@ -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)
+		}
+	}
+}

internal/picker/selection_test.go 🔗

@@ -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)")
+	}
+}