diff --git a/cmd/root.go b/cmd/root.go index f0c61c6fba4a0374697e9858d5c65f3138c3cae4..edc1f9616a967bfb84746ffca9590d351b18ae74 100644 --- a/cmd/root.go +++ b/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 {