Archived
Add restore (x): make a repository exactly what git records
Restores the selection and all submodules below it recursively, asking only if work would be lost. May clone/fetch missing objects (D23). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+30
-5
@@ -56,6 +56,9 @@ type Model struct {
|
||||
// exactly then. The input is focused while typing and blurred after enter.
|
||||
filtering bool
|
||||
|
||||
restore *restoreState // the restore in progress, nil if none
|
||||
notice string // an error to show above the footer until the next key
|
||||
|
||||
width, height int
|
||||
showDetail bool
|
||||
}
|
||||
@@ -156,7 +159,7 @@ func (m *Model) startFetch() tea.Cmd {
|
||||
|
||||
// busy reports whether something shows a spinner.
|
||||
func (m Model) busy() bool {
|
||||
if m.repo == nil || m.fetch.Running {
|
||||
if m.repo == nil || m.fetch.Running || (m.restore != nil && m.restore.phase != restoreAsking) {
|
||||
return true
|
||||
}
|
||||
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
|
||||
@@ -202,6 +205,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case changeMsg:
|
||||
return m.changed(msg)
|
||||
|
||||
case lossMsg:
|
||||
return m.lossChecked(msg)
|
||||
|
||||
case restoreDoneMsg:
|
||||
return m.restoreDone(msg)
|
||||
|
||||
case fetchDoneMsg:
|
||||
m.fetch.Done++
|
||||
if msg.err != nil {
|
||||
@@ -322,6 +331,13 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := m.keys
|
||||
if m.restore != nil && m.restore.phase == restoreAsking {
|
||||
return m.answerRestore(msg)
|
||||
}
|
||||
if m.notice != "" {
|
||||
m.notice = ""
|
||||
m.layout()
|
||||
}
|
||||
if m.filter.Focused() {
|
||||
return m.typeFilter(msg)
|
||||
}
|
||||
@@ -377,6 +393,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.filtering = true
|
||||
m.layout()
|
||||
return m, m.filter.Focus()
|
||||
case key.Matches(msg, k.Restore):
|
||||
return m.startRestore()
|
||||
case key.Matches(msg, k.Issues):
|
||||
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
|
||||
m.layout()
|
||||
@@ -438,9 +456,12 @@ func (m Model) filterView() string {
|
||||
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint)
|
||||
}
|
||||
|
||||
// extraLine reports whether a line above the footer is shown: the filter, or
|
||||
// the notice that only problems are shown.
|
||||
func (m Model) extraLine() bool { return m.filtering || m.tree.ProblemsOnly() }
|
||||
// extraLine reports whether a line above the footer is shown: a restore in
|
||||
// progress, a failure, the filter, or the notice that only problems are
|
||||
// shown.
|
||||
func (m Model) extraLine() bool {
|
||||
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != ""
|
||||
}
|
||||
|
||||
// problemsView is the line above the footer while only problems are shown, so
|
||||
// a short list is not mistaken for a healthy repository.
|
||||
@@ -512,7 +533,11 @@ func (m Model) View() string {
|
||||
|
||||
head := header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width, m.tree.RootSelected())
|
||||
parts := []string{zone.Mark(m.tree.RootZone(), head), body}
|
||||
if m.filtering {
|
||||
if m.restore != nil {
|
||||
parts = append(parts, m.restoreView())
|
||||
} else if m.notice != "" {
|
||||
parts = append(parts, m.noticeView())
|
||||
} else if m.filtering {
|
||||
parts = append(parts, m.filterView())
|
||||
} else if m.tree.ProblemsOnly() {
|
||||
parts = append(parts, m.problemsView())
|
||||
|
||||
@@ -7,7 +7,7 @@ type keyMap struct {
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Fetch, Reload, Filter, Issues key.Binding
|
||||
Copy, Help, Quit key.Binding
|
||||
Copy, Restore, Help, Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeys() keyMap {
|
||||
@@ -27,6 +27,7 @@ func defaultKeys() keyMap {
|
||||
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
|
||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
@@ -43,7 +44,7 @@ func (k keyMap) Navigation() []key.Binding {
|
||||
|
||||
// Submodule returns the bindings that act on the selected submodule. They
|
||||
// are shown in the detail pane, or in the footer if the pane can't show them.
|
||||
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy} }
|
||||
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.Restore} }
|
||||
|
||||
// Global returns the bindings that don't depend on the selection.
|
||||
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
||||
@@ -57,7 +58,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
|
||||
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
|
||||
{k.CollapseAll, k.ExpandAll},
|
||||
{k.Home, k.End, k.PageUp, k.PageDown},
|
||||
{k.Copy},
|
||||
{k.Copy, k.Restore},
|
||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
{k.Help, k.Quit},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
type restorePhase int
|
||||
|
||||
const (
|
||||
restoreChecking restorePhase = iota // reading what would be lost
|
||||
restoreAsking // waiting for y / n
|
||||
restoreRunning
|
||||
)
|
||||
|
||||
// restoreState is the restore in progress; only one runs at a time.
|
||||
type restoreState struct {
|
||||
path, name string // name is how the footer calls it
|
||||
phase restorePhase
|
||||
loss submodule.Loss
|
||||
}
|
||||
|
||||
// lossMsg is the result of the check before a restore.
|
||||
type lossMsg struct {
|
||||
path string
|
||||
loss submodule.Loss
|
||||
err error
|
||||
}
|
||||
|
||||
type restoreDoneMsg struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
// startRestore checks what restoring the selection would discard. If nothing
|
||||
// would be lost it restores at once, else it asks first.
|
||||
func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
||||
s := m.tree.Selected()
|
||||
r, ok := m.provider.(submodule.Restorer)
|
||||
var flash string
|
||||
switch {
|
||||
case s == nil:
|
||||
return m, nil
|
||||
case !ok:
|
||||
flash = "this data source can't restore"
|
||||
case m.restore != nil:
|
||||
flash = "wait for the restore of " + m.restore.name + " to finish"
|
||||
}
|
||||
if flash != "" {
|
||||
cmd := m.footer.Flash(flash)
|
||||
m.layout()
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
|
||||
m.layout()
|
||||
ctx, path := m.ctx, s.Path
|
||||
check := func() tea.Msg {
|
||||
loss, err := r.RestoreLoss(ctx, path)
|
||||
return lossMsg{path, loss, err}
|
||||
}
|
||||
return m, tea.Batch(check, m.tick())
|
||||
}
|
||||
|
||||
// lossChecked reacts to the check: restore, or ask.
|
||||
func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) {
|
||||
if m.restore == nil || m.restore.path != msg.path || m.restore.phase != restoreChecking {
|
||||
return m, nil
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.restore = nil
|
||||
m.notice = "can't check " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
m.restore.loss = msg.loss
|
||||
if msg.loss.Empty() {
|
||||
return m.runRestore()
|
||||
}
|
||||
m.restore.phase = restoreAsking
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// answerRestore handles a key while the restore waits for its answer: y
|
||||
// restores, anything else cancels.
|
||||
func (m Model) answerRestore(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case msg.Type == tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case msg.String() == "y":
|
||||
return m.runRestore()
|
||||
}
|
||||
m.restore = nil
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) runRestore() (tea.Model, tea.Cmd) {
|
||||
m.restore.phase = restoreRunning
|
||||
m.layout()
|
||||
r := m.provider.(submodule.Restorer)
|
||||
ctx, path := m.ctx, m.restore.path
|
||||
run := func() tea.Msg { return restoreDoneMsg{path, r.Restore(ctx, path)} }
|
||||
return m, tea.Batch(run, m.tick())
|
||||
}
|
||||
|
||||
// restoreDone shows the result and reloads everything: the structure may have
|
||||
// changed.
|
||||
func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
||||
name := "the superproject"
|
||||
if m.restore != nil {
|
||||
name = m.restore.name
|
||||
}
|
||||
m.restore = nil
|
||||
var cmds []tea.Cmd
|
||||
if msg.err != nil {
|
||||
m.notice = "restore of " + name + " failed: " + msg.err.Error()
|
||||
} else {
|
||||
cmds = append(cmds, m.footer.Flash("restored "+name))
|
||||
}
|
||||
cmds = append(cmds, m.reloadTree(true))
|
||||
m.layout()
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func restoreName(s *submodule.Submodule) string {
|
||||
if s == nil || s.IsRoot() {
|
||||
return "the superproject"
|
||||
}
|
||||
return s.Path
|
||||
}
|
||||
|
||||
// restoreView is the line above the footer while a restore is in progress.
|
||||
func (m Model) restoreView() string {
|
||||
r := m.restore
|
||||
left, right := "", ""
|
||||
switch r.phase {
|
||||
case restoreChecking:
|
||||
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Muted).Render("checking "+r.name+"…")
|
||||
case restoreRunning:
|
||||
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render("restoring "+r.name+"…")
|
||||
case restoreAsking:
|
||||
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+"?") + " " +
|
||||
theme.Fg(m.theme.Danger).Render("Discards "+lossText(r.loss)+".")
|
||||
right = theme.Fg(m.theme.Text).Bold(true).Render("y") + theme.Fg(m.theme.Muted).Render(" restore · ") +
|
||||
theme.Fg(m.theme.Text).Bold(true).Render("n") + theme.Fg(m.theme.Muted).Render(" cancel")
|
||||
}
|
||||
return m.line(left, right)
|
||||
}
|
||||
|
||||
// line lays out one line above the footer: left, right-aligned right. The
|
||||
// left part is cut if both don't fit.
|
||||
func (m Model) line(left, right string) string {
|
||||
w := m.width - 2
|
||||
if right != "" {
|
||||
left = ansi.Truncate(left, max(w-lipgloss.Width(right)-2, 1), "…")
|
||||
}
|
||||
gap := max(w-lipgloss.Width(left)-lipgloss.Width(right), 1)
|
||||
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(left + strings.Repeat(" ", gap) + right)
|
||||
}
|
||||
|
||||
// noticeView is the line above the footer after a restore failed.
|
||||
func (m Model) noticeView() string {
|
||||
return m.line(theme.Fg(m.theme.Danger).Render(m.notice), "")
|
||||
}
|
||||
|
||||
// lossText words what a restore discards, e.g. "7 changed files in 3
|
||||
// repositories, 2 commits on no branch, 1 untracked repository".
|
||||
func lossText(l submodule.Loss) string {
|
||||
var parts []string
|
||||
if l.Files > 0 {
|
||||
s := plural(l.Files, "changed file", "changed files")
|
||||
if l.Modules > 1 {
|
||||
s += fmt.Sprintf(" in %d repositories", l.Modules)
|
||||
}
|
||||
parts = append(parts, s)
|
||||
}
|
||||
if l.Operations > 0 {
|
||||
parts = append(parts, plural(l.Operations, "operation in progress", "operations in progress"))
|
||||
}
|
||||
if l.Commits > 0 {
|
||||
parts = append(parts, plural(l.Commits, "commit on no branch", "commits on no branch"))
|
||||
}
|
||||
if l.Repositories > 0 {
|
||||
parts = append(parts, plural(l.Repositories, "untracked repository", "untracked repositories"))
|
||||
}
|
||||
if l.Unknown > 0 {
|
||||
parts = append(parts, plural(l.Unknown, "repository of unknown state", "repositories of unknown state"))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return "1 " + one
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/submodule/mock"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
zone.NewGlobal()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestLossText(t *testing.T) {
|
||||
cases := []struct {
|
||||
in submodule.Loss
|
||||
want string
|
||||
}{
|
||||
{submodule.Loss{Files: 1, Modules: 1}, "1 changed file"},
|
||||
{submodule.Loss{Files: 7, Modules: 3, Commits: 2, Repositories: 1}, "7 changed files in 3 repositories, 2 commits on no branch, 1 untracked repository"},
|
||||
{submodule.Loss{Operations: 1, Modules: 1, Unknown: 2}, "1 operation in progress, 2 repositories of unknown state"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := lossText(c.in); got != c.want {
|
||||
t.Errorf("lossText(%+v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRestorer is the mock data with a restore that records its calls.
|
||||
type fakeRestorer struct {
|
||||
submodule.Provider
|
||||
loss submodule.Loss
|
||||
restored []string
|
||||
}
|
||||
|
||||
func (f *fakeRestorer) RestoreLoss(context.Context, string) (submodule.Loss, error) {
|
||||
return f.loss, nil
|
||||
}
|
||||
|
||||
func (f *fakeRestorer) Restore(_ context.Context, path string) error {
|
||||
f.restored = append(f.restored, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// run executes a command, flattening batches, and returns the messages.
|
||||
func run(cmd tea.Cmd) []tea.Msg {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
msg := cmd()
|
||||
if b, ok := msg.(tea.BatchMsg); ok {
|
||||
var out []tea.Msg
|
||||
for _, c := range b {
|
||||
if c != nil {
|
||||
out = append(out, run(c)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return []tea.Msg{msg}
|
||||
}
|
||||
|
||||
func press(m tea.Model, k string) (tea.Model, tea.Cmd) {
|
||||
return m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)})
|
||||
}
|
||||
|
||||
// newRestoreModel returns a model with the mock tree loaded.
|
||||
func newRestoreModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeRestorer) {
|
||||
t.Helper()
|
||||
f := &fakeRestorer{Provider: mock.New(), loss: loss}
|
||||
var m tea.Model = New(context.Background(), f)
|
||||
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
||||
repo, err := f.Tree(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ = m.Update(treeLoadedMsg{repo: repo})
|
||||
return m, f
|
||||
}
|
||||
|
||||
// feed delivers the messages of cmd to m, once.
|
||||
func feed(m tea.Model, cmd tea.Cmd) tea.Model {
|
||||
for _, msg := range run(cmd) {
|
||||
switch msg.(type) {
|
||||
case lossMsg, restoreDoneMsg:
|
||||
m, _ = m.Update(msg)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestRestoreWithoutLossRunsAtOnce(t *testing.T) {
|
||||
m, f := newRestoreModel(t, submodule.Loss{})
|
||||
m, cmd := press(m, "x")
|
||||
var next tea.Cmd
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, next = m.Update(l)
|
||||
}
|
||||
}
|
||||
m = feed(m, next)
|
||||
if len(f.restored) != 1 || f.restored[0] != "" {
|
||||
t.Fatalf("restored %q, want the superproject once", f.restored)
|
||||
}
|
||||
if m.(Model).restore != nil {
|
||||
t.Error("restore still in progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreAsksWhenSomethingIsLost(t *testing.T) {
|
||||
m, f := newRestoreModel(t, submodule.Loss{Files: 3, Modules: 1})
|
||||
m, cmd := press(m, "x")
|
||||
var next tea.Cmd
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, next = m.Update(l)
|
||||
}
|
||||
}
|
||||
if next != nil || m.(Model).restore == nil || m.(Model).restore.phase != restoreAsking {
|
||||
t.Fatalf("want a question, got %+v", m.(Model).restore)
|
||||
}
|
||||
|
||||
// any other key cancels
|
||||
c, _ := press(m, "n")
|
||||
if c.(Model).restore != nil || len(f.restored) != 0 {
|
||||
t.Fatal("n must cancel without restoring")
|
||||
}
|
||||
|
||||
// y restores
|
||||
m, cmd = press(m, "y")
|
||||
m = feed(m, cmd)
|
||||
if len(f.restored) != 1 {
|
||||
t.Fatalf("restored %q", f.restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreWithoutRestorerSaysSo(t *testing.T) {
|
||||
var m tea.Model = New(context.Background(), mock.New())
|
||||
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
||||
repo, _ := mock.New().Tree(context.Background())
|
||||
m, _ = m.Update(treeLoadedMsg{repo: repo})
|
||||
m, _ = press(m, "x")
|
||||
if m.(Model).restore != nil {
|
||||
t.Fatal("the mock can't restore")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user