@@ -2,7 +2,6 @@ package cmd
import (
"context"
- "errors"
"fmt"
"os"
"slices"
@@ -10,13 +9,10 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/fang/v2"
- "charm.land/huh/v2/spinner"
- "charm.land/lipgloss/v2"
"github.com/spf13/cobra"
"git.secluded.site/keld/internal/config"
"git.secluded.site/keld/internal/form"
- "git.secluded.site/keld/internal/picker"
"git.secluded.site/keld/internal/restic"
"git.secluded.site/keld/internal/theme"
"git.secluded.site/keld/internal/ui"
@@ -498,7 +494,8 @@ func promptPreset() (string, error) {
func promptForCommand(command string, cfg *config.ResolvedConfig) (map[string][]string, error) {
switch command {
case "restore":
- return promptRestore(cfg)
+ // Handled by the unified TUI session; nothing to prompt here.
+ return nil, nil
case "backup":
return promptBackup(cfg)
default:
@@ -506,123 +503,7 @@ func promptForCommand(command string, cfg *config.ResolvedConfig) (map[string][]
}
}
-// promptRestore collects the snapshot ID, target directory, and overwrite
-// behavior for restore, skipping prompts for values already present in
-// the resolved config.
-//
-// Snapshot selection uses an interactive picker when the resolved config
-// has a repository configured; otherwise falls back to a text input.
-// Auth/network errors during snapshot listing produce a terse note and
-// fall back to text input. User cancellation aborts entirely.
-func promptRestore(cfg *config.ResolvedConfig) (map[string][]string, error) {
- overrides := make(map[string][]string)
-
- // Step 1: Snapshot ID.
- if len(cfg.Arguments) == 0 {
- snapshotID, err := promptSnapshotID(cfg)
- if err != nil {
- return nil, err
- }
- if snapshotID == "" {
- return nil, fmt.Errorf("snapshot ID is required")
- }
- overrides[overrideArgumentsKey] = []string{snapshotID}
- }
-
- // Step 2: File selection (unless include flags are already set or
- // the snapshot ID uses snapshotID:subfolder syntax).
- snapshotID := ""
- if args := overrides[overrideArgumentsKey]; len(args) > 0 {
- snapshotID = args[0]
- } else if len(cfg.Arguments) > 0 {
- snapshotID = cfg.Arguments[0]
- }
- includes, err := promptFileSelection(cfg, snapshotID)
- if err != nil {
- return nil, err
- }
- overrides["include"] = append(overrides["include"], includes...)
-
- // Step 3: Target directory.
- if !cfg.HasFlag("target") {
- target, err := form.TargetDirectory()
- if err != nil {
- return nil, fmt.Errorf("target directory: %w", err)
- }
- overrides["target"] = []string{target}
- }
-
- // Step 4: Overwrite behavior.
- if !cfg.HasFlag("overwrite") {
- overwrite, err := form.SelectOverwrite()
- if err != nil {
- return nil, fmt.Errorf("overwrite selection: %w", err)
- }
- overrides["overwrite"] = []string{overwrite}
- }
-
- if len(overrides) == 0 {
- return nil, nil
- }
- return overrides, nil
-}
-
-// promptSnapshotID attempts to show an interactive snapshot picker using
-// the repository from the resolved config. Falls back to a manual text
-// input when:
-// - No repository is configured (silent fallback)
-// - Snapshot listing fails (prints a terse note, then text input)
-// - No snapshots exist (prints a note, then text input)
-//
-// If the user picks "Enter ID manually…" from the picker, switches to
-// the text input. User cancellation (ctrl+c) always aborts entirely.
-func promptSnapshotID(cfg *config.ResolvedConfig) (string, error) {
- var snapshots []restic.Snapshot
- var listErr error
-
- err := spinner.New().
- Title("Loading snapshots…").
- Action(func() {
- snapshots, listErr = restic.ListSnapshots(cfg)
- }).
- Run()
- if err != nil {
- // Spinner itself failed (unlikely); fall back gracefully.
- return form.ManualSnapshotID()
- }
- if listErr != nil {
- // Silent fallback for "no repo configured"; terse note for
- // anything else (auth failure, network error, bad JSON, etc.)
- if !isNoRepoError(listErr) {
- fmt.Fprintf(os.Stderr, "Could not list snapshots: %v\n", listErr)
- }
- return form.ManualSnapshotID()
- }
-
- if len(snapshots) == 0 {
- fmt.Fprintln(os.Stderr, "No snapshots found in repository.")
- return form.ManualSnapshotID()
- }
-
- selected, err := form.SelectSnapshot(snapshots)
- if err != nil {
- return "", err
- }
-
- if form.IsManualEntry(selected) {
- return form.ManualSnapshotID()
- }
-
- return selected, nil
-}
-
-// isNoRepoError checks whether the error from ListSnapshots indicates
-// that no repository was configured (as opposed to an auth/network/etc.
-// failure).
-func isNoRepoError(err error) bool {
- return errors.Is(err, restic.ErrNoRepo)
-}
// includeFlags lists the restic flags that provide explicit file
// selection for restore. When any of these are already present in the
@@ -641,189 +522,6 @@ func hasIncludeFlags(cfg *config.ResolvedConfig) bool {
return false
}
-// promptFileSelection runs the interactive file picker for a restore
-// snapshot and returns --include values (with leading "/" for restic's
-// absolute-path syntax). Returns nil when the picker is skipped or
-// when the user confirms a full restore.
-//
-// The picker is skipped when:
-// - Include-style flags already exist in the resolved config
-// - The snapshot ID uses snapshotID:subfolder syntax (contains ":")
-// - The snapshot contents cannot be listed (terse note, continues)
-func promptFileSelection(cfg *config.ResolvedConfig, snapshotID string) ([]string, error) {
- if hasIncludeFlags(cfg) {
- return nil, nil
- }
- if strings.Contains(snapshotID, ":") {
- return nil, nil
- }
- if snapshotID == "" {
- return nil, nil
- }
-
- // Fetch the snapshot's file listing behind a spinner.
- var nodes []restic.LsNode
- var lsErr error
-
- err := spinner.New().
- Title("Loading snapshot contents…").
- Action(func() {
- nodes, lsErr = restic.RunLs(cfg, snapshotID)
- }).
- Run()
- if err != nil {
- // Spinner itself failed (unlikely); skip the picker.
- fmt.Fprintf(os.Stderr, "Could not load file picker: %v\n", err)
- return nil, nil
- }
- if lsErr != nil {
- fmt.Fprintf(os.Stderr, "Could not list snapshot contents: %v\n", lsErr)
- return nil, nil
- }
- if len(nodes) == 0 {
- return nil, nil
- }
-
- // Build an in-memory filesystem; New creates the Selection internally.
- sfs := restic.NewSnapshotFS(nodes)
- fp := picker.New(sfs)
-
- p := tea.NewProgram(pickerModel{picker: fp, snapshotID: snapshotID})
- result, err := p.Run()
- if err != nil {
- return nil, fmt.Errorf("file picker: %w", err)
- }
- m, ok := result.(pickerModel)
- if !ok {
- return nil, fmt.Errorf("unexpected file picker model type %T", result)
- }
-
- if !m.picker.Confirmed {
- // User cancelled (Ctrl+C / quit without confirming).
- return nil, form.ErrAborted
- }
-
- // Full restore: everything selected or nothing toggled.
- sel := m.picker.Selection
- if sel.AllSelected() {
- printRestoreSummary(snapshotID, nil)
- return nil, nil
- }
- paths := sel.SelectedPaths()
- if paths == nil {
- // Nothing was selected — treat as full restore.
- printRestoreSummary(snapshotID, nil)
- return nil, nil
- }
-
- // Convert FS-relative paths to restic's absolute-path --include syntax.
- includes := make([]string, len(paths))
- for i, p := range paths {
- includes[i] = "/" + p
- }
- printRestoreSummary(snapshotID, includes)
- return includes, nil
-}
-
-// printRestoreSummary prints a brief summary of what will be restored
-// after the file picker confirms. When includes is nil, the entire
-// snapshot is being restored. When non-nil, a sample of paths is shown
-// with a count of the remainder.
-func printRestoreSummary(snapshotID string, includes []string) {
- if includes == nil {
- fmt.Fprintf(os.Stderr, "\nRestoring entire snapshot %s.\n\n", snapshotID)
- return
- }
-
- fmt.Fprintf(os.Stderr, "\nRestoring %d path(s) from snapshot %s:\n\n", len(includes), snapshotID)
-
- const maxShow = 5
- if len(includes) <= maxShow*2 {
- // Few enough to show them all.
- for _, p := range includes {
- fmt.Fprintf(os.Stderr, " • %s\n", p)
- }
- } else {
- // Sample evenly across the sorted list, always including
- // the first and last entries.
- for i := range maxShow {
- idx := i * (len(includes) - 1) / (maxShow - 1)
- fmt.Fprintf(os.Stderr, " • %s\n", includes[idx])
- }
- fmt.Fprintf(os.Stderr, " … and %d more\n", len(includes)-maxShow)
- }
- fmt.Fprintln(os.Stderr)
-}
-
-// pickerChromeLines is the number of terminal lines reserved for the
-// header and footer chrome around the file picker.
-//
-// header (1) + blank (1) + blank (1) + footer (1) = 4
-const pickerChromeLines = 4
-
-// pickerModel wraps picker.Model so it satisfies the tea.Model interface
-// for standalone use with tea.NewProgram. It adds a header showing the
-// snapshot ID and current directory, plus a footer with key hints.
-type pickerModel struct {
- picker picker.Model
- snapshotID string
- width int
-}
-
-func (m pickerModel) Init() tea.Cmd { return m.picker.Init() }
-
-func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- if wsm, ok := msg.(tea.WindowSizeMsg); ok {
- m.width = wsm.Width
- m.picker.AutoHeight = false
- h := wsm.Height - pickerChromeLines
- if h < 1 {
- h = 1
- }
- m.picker.SetHeight(h)
- // Forward the resized message so the picker clamps its
- // scroll indices and cursor position.
- wsm.Height = h
- msg = wsm
- }
- updated, cmd := m.picker.Update(msg)
- m.picker = updated
- return m, cmd
-}
-
-var (
- pickerHeaderStyle = lipgloss.NewStyle().Bold(true)
- pickerFooterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
-)
-
-func (m pickerModel) View() tea.View {
- // Header: snapshot ID + current directory.
- dir := m.picker.CurrentDirectory
- if dir == "." {
- dir = "/"
- } else {
- dir = "/" + dir
- }
- header := fmt.Sprintf("Select files to restore from snapshot %s — %s", m.snapshotID, dir)
- footer := "↑/↓ move • space toggle • → open • ← back • enter confirm • ctrl+c cancel"
-
- hStyle := pickerHeaderStyle
- fStyle := pickerFooterStyle
- if m.width > 0 {
- hStyle = hStyle.MaxWidth(m.width)
- fStyle = fStyle.MaxWidth(m.width)
- }
-
- var b strings.Builder
- b.WriteString(hStyle.Render(header))
- b.WriteString("\n\n")
- b.WriteString(m.picker.View())
- b.WriteString("\n")
- b.WriteString(fStyle.Render(footer))
-
- return tea.NewView(b.String())
-}
-
// promptBackup collects backup paths when none are configured in the preset.
func promptBackup(cfg *config.ResolvedConfig) (map[string][]string, error) {
if len(cfg.Arguments) > 0 {