@@ -11,6 +11,7 @@ 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"
@@ -450,7 +451,7 @@ func promptFileSelection(cfg *config.ResolvedConfig, snapshotID string) ([]strin
sfs := restic.NewSnapshotFS(nodes)
fp := picker.New(sfs)
- p := tea.NewProgram(pickerModel{picker: fp})
+ p := tea.NewProgram(pickerModel{picker: fp, snapshotID: snapshotID})
result, err := p.Run()
if err != nil {
return nil, fmt.Errorf("file picker: %w", err)
@@ -468,11 +469,13 @@ func promptFileSelection(cfg *config.ResolvedConfig, snapshotID string) ([]strin
// 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
}
@@ -481,25 +484,108 @@ func promptFileSelection(cfg *config.ResolvedConfig, snapshotID string) ([]strin
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. The picker follows the bubbles
-// component convention (returning a concrete type from Update and a
-// string from View), so this thin adapter bridges the gap.
+// 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
+ 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)
- return pickerModel{picker: updated}, cmd
+ m.picker = updated
+ return m, cmd
}
-func (m pickerModel) View() tea.View { return tea.NewView(m.picker.View()) }
+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) {
@@ -86,6 +86,7 @@ type KeyMap struct {
Open key.Binding
Select key.Binding
Toggle key.Binding
+ Quit key.Binding
}
// DefaultKeyMap defines the default keybindings.
@@ -101,6 +102,7 @@ func DefaultKeyMap() KeyMap {
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")),
+ Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "cancel")),
}
}
@@ -275,6 +277,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
case tea.KeyPressMsg:
switch {
+ case key.Matches(msg, m.KeyMap.Quit):
+ return m, tea.Quit
case key.Matches(msg, m.KeyMap.GoToTop):
m.selected = 0
m.minIdx = 0
@@ -68,6 +68,7 @@ func keyPress(code rune, text string) tea.KeyPressMsg {
func keyEnter() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
func keySpace() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} }
+func keyCtrlC() tea.KeyPressMsg { return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} }
func keyL() tea.KeyPressMsg { return keyPress('l', "l") }
func keyH() tea.KeyPressMsg { return keyPress('h', "h") }
func keyJ() tea.KeyPressMsg { return keyPress('j', "j") }
@@ -336,6 +337,27 @@ func TestFilePickerBackAtRoot(t *testing.T) {
}
}
+func TestFilePickerCtrlCQuits(t *testing.T) {
+ t.Parallel()
+ m := initPicker(t)
+
+ // Toggle something so we can verify it doesn't count as confirmed.
+ m, _ = m.Update(keySpace())
+
+ m, cmd := m.Update(keyCtrlC())
+
+ if m.Confirmed {
+ t.Error("ctrl+c should not set Confirmed")
+ }
+ if cmd == nil {
+ t.Fatal("ctrl+c should return a command")
+ }
+ msg := cmd()
+ if _, ok := msg.(tea.QuitMsg); !ok {
+ t.Errorf("expected tea.QuitMsg, got %T", msg)
+ }
+}
+
// fileNames returns the names of directory entries for error messages.
func fileNames(entries []fs.DirEntry) []string {
names := make([]string, len(entries))