Simplify picker to multi-select only

Amolith created

Change summary

cmd/root.go                        |   6 
internal/picker/filepicker.go      | 164 +++++++------------------------
internal/picker/filepicker_test.go |   1 
3 files changed, 39 insertions(+), 132 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -446,12 +446,9 @@ func promptFileSelection(cfg *config.ResolvedConfig, snapshotID string) ([]strin
 		return nil, nil
 	}
 
-	// Build an in-memory filesystem and selection tracker.
+	// Build an in-memory filesystem; New creates the Selection internally.
 	sfs := restic.NewSnapshotFS(nodes)
-	sel := picker.NewSelection(sfs)
-
 	fp := picker.New(sfs)
-	fp.Selection = sel
 
 	p := tea.NewProgram(pickerModel{picker: fp})
 	result, err := p.Run()
@@ -469,6 +466,7 @@ func promptFileSelection(cfg *config.ResolvedConfig, snapshotID string) ([]strin
 	}
 
 	// Full restore: everything selected or nothing toggled.
+	sel := m.picker.Selection
 	if sel.AllSelected() {
 		return nil, nil
 	}

internal/picker/filepicker.go 🔗

@@ -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 ""

internal/picker/filepicker_test.go 🔗

@@ -43,7 +43,6 @@ func initPicker(t *testing.T) Model {
 
 	sfs := restic.NewSnapshotFS(testNodes())
 	m := New(sfs)
-	m.Selection = NewSelection(sfs)
 
 	// Process Init to get the readDir command.
 	cmd := m.Init()