From 2f552b6eb3058b4bc9fa50a82d9f026a3490cc64 Mon Sep 17 00:00:00 2001 From: Amolith Date: Thu, 26 Mar 2026 22:42:55 -0600 Subject: [PATCH] Simplify picker to multi-select only --- cmd/root.go | 6 +- internal/picker/filepicker.go | 164 +++++++---------------------- internal/picker/filepicker_test.go | 1 - 3 files changed, 39 insertions(+), 132 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 67430f2d7c63fee2bbfbd1eb6154f64781810331..b53420ab7a9a4d0f8590569f7eb8bb642a1aa995 100644 --- a/cmd/root.go +++ b/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 } diff --git a/internal/picker/filepicker.go b/internal/picker/filepicker.go index e00d96ab5d65e5df3a02a61c37a73314672a8eec..a5aa2e7219fa3cd5b5319ad771b266e14d7e4d0b 100644 --- a/internal/picker/filepicker.go +++ b/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 "" diff --git a/internal/picker/filepicker_test.go b/internal/picker/filepicker_test.go index e32cb6366e18d5f82b70c6680d631a458fc3a75d..c5aa0a07ca81223a58857c82d442fbfda9e15f2a 100644 --- a/internal/picker/filepicker_test.go +++ b/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()