From 89bfe7286d96d20b902c004dd5c89cfd4bfce1d9 Mon Sep 17 00:00:00 2001 From: Amolith Date: Thu, 26 Mar 2026 23:33:04 -0600 Subject: [PATCH] Add quit handler and help chrome to file picker --- cmd/root.go | 100 +++++++++++++++++++++++++++-- internal/picker/filepicker.go | 4 ++ internal/picker/filepicker_test.go | 22 +++++++ 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index b53420ab7a9a4d0f8590569f7eb8bb642a1aa995..d64e0d4fbce3e370c5bd59ab5c98565749e22a63 100644 --- a/cmd/root.go +++ b/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) { diff --git a/internal/picker/filepicker.go b/internal/picker/filepicker.go index a5aa2e7219fa3cd5b5319ad771b266e14d7e4d0b..e6db1a614f1b0a7576c27e83e55380a8325a2a70 100644 --- a/internal/picker/filepicker.go +++ b/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 diff --git a/internal/picker/filepicker_test.go b/internal/picker/filepicker_test.go index c5aa0a07ca81223a58857c82d442fbfda9e15f2a..e63238a39349f5586e0a796ba0003610d5fbf355 100644 --- a/internal/picker/filepicker_test.go +++ b/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))