Remove legacy standalone restore prompts

Amolith created

Change summary

cmd/root.go | 306 ------------------------------------------------------
1 file changed, 2 insertions(+), 304 deletions(-)

Detailed changes

cmd/root.go 🔗

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