From 1efbdf4ea478c29dc77bc28aecac994ff8f71d4e Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 25 Mar 2026 23:18:24 -0600 Subject: [PATCH] Add vendored filepicker with fs.FS multi-select and tri-state --- internal/picker/filepicker.go | 545 ++++++++++++++++++++++++++++++ internal/picker/selection.go | 261 ++++++++++++++ internal/picker/selection_test.go | 249 ++++++++++++++ 3 files changed, 1055 insertions(+) create mode 100644 internal/picker/filepicker.go create mode 100644 internal/picker/selection.go create mode 100644 internal/picker/selection_test.go diff --git a/internal/picker/filepicker.go b/internal/picker/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..289a179e8d8bc1095b42b69d0cd0fd1986b1c4ba --- /dev/null +++ b/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()) +} diff --git a/internal/picker/selection.go b/internal/picker/selection.go new file mode 100644 index 0000000000000000000000000000000000000000..94ba815735b12df85c934ca7b0a503d2f69e9f12 --- /dev/null +++ b/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) + } + } +} diff --git a/internal/picker/selection_test.go b/internal/picker/selection_test.go new file mode 100644 index 0000000000000000000000000000000000000000..099e389e498f4d0883878bf40ef5cc23b98d1c43 --- /dev/null +++ b/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)") + } +}