Add quit handler and help chrome to file picker

Amolith created

Change summary

cmd/root.go                        | 100 +++++++++++++++++++++++++++++--
internal/picker/filepicker.go      |   4 +
internal/picker/filepicker_test.go |  22 +++++++
3 files changed, 119 insertions(+), 7 deletions(-)

Detailed changes

cmd/root.go 🔗

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

internal/picker/filepicker.go 🔗

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

internal/picker/filepicker_test.go 🔗

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