This repository has been archived on 2026-10-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
submarine/internal/tui/restore.go
T
simonfrankenandClaude Opus 5.5 72500353cd Hard restore with X drops staged pins at every depth
X restores the selection to the committed pins without the staged or
committed question, and resets every restored repository's index to
HEAD, so staged pins below are dropped too. Dropped staged pins alone
don't make it ask; they are listed if something else is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:58:08 +02:00

346 lines
10 KiB
Go

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 (
restoreChoosing restorePhase = iota // staged or committed pin?
restoreChecking // 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
to submodule.RestoreTarget
loss submodule.Loss
branch *submodule.Branch // set for a branch switch, which reuses this flow
pinUpdate bool // a switch to the pin's ref (u); branch is set by the check
}
// lossMsg is the result of the check before a restore.
type lossMsg struct {
path string
loss submodule.Loss
err error
branch *submodule.Branch // the branch a pin update switches to
}
type restoreDoneMsg struct {
path string
branch string // the branch a switch ended on, "" if detached
err error
}
// startRestore checks what restoring the selection would discard. If nothing
// would be lost it restores at once, else it asks first. A submodule with a
// staged pin first asks which pin to restore to; a hard restore doesn't: it
// goes to the committed pins, at every depth.
func (m Model) startRestore(hard bool) (tea.Model, tea.Cmd) {
s := m.tree.Selected()
_, 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)}
if hard {
m.restore.to = submodule.RestoreHard
return m.checkRestore()
}
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
m.restore.phase = restoreChoosing
m.layout()
return m, nil
}
return m.checkRestore()
}
// checkRestore reads what the restore would discard.
func (m Model) checkRestore() (tea.Model, tea.Cmd) {
m.restore.phase = restoreChecking
m.layout()
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
check := func() tea.Msg {
var loss submodule.Loss
var err error
if b != nil {
loss, err = m.provider.(submodule.Switcher).SwitchLoss(ctx, path, *b)
} else {
loss, err = m.provider.(submodule.Restorer).RestoreLoss(ctx, path, to)
}
return lossMsg{path: path, loss: loss, err: err}
}
return m, tea.Batch(check, m.tick())
}
// choosePin handles a key while the restore asks for the pin: s the staged
// one, c the committed one (which unstages it), anything else cancels.
func (m Model) choosePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case msg.Type == tea.KeyCtrlC:
return m, tea.Quit
case msg.String() == "s":
m.restore.to = submodule.RestoreStaged
return m.checkRestore()
case msg.String() == "c":
m.restore.to = submodule.RestoreCommitted
return m.checkRestore()
}
m.restore = nil
m.layout()
return m, nil
}
// 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 {
verb := "check"
if m.restore.pinUpdate {
verb = "update"
}
m.restore = nil
m.notice = "can't " + verb + " " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
m.layout()
return m, nil
}
if msg.branch != nil {
m.restore.branch = msg.branch
}
m.restore.loss = msg.loss
// a hard restore is asked for to drop staged pins: alone they are no
// reason to ask, but are listed if something else is lost
lost := msg.loss
if m.restore.to == submodule.RestoreHard {
lost.StagedPins, lost.Modules = 0, 0
}
if lost.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()
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
run := func() tea.Msg {
if b != nil {
branch, err := m.provider.(submodule.Switcher).Switch(ctx, path, *b)
return restoreDoneMsg{path, branch, err}
}
return restoreDoneMsg{path, "", m.provider.(submodule.Restorer).Restore(ctx, path, to)}
}
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) {
r := m.restore
m.restore = nil
var cmds []tea.Cmd
switch {
case r != nil && r.branch != nil && msg.err != nil:
m.notice = r.noun() + " of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
case r != nil && r.branch != nil:
to := r.branch.Name
if msg.branch != "" {
to = msg.branch
} else if r.branch.Remote {
to += " (detached: local " + r.branch.Local + " can't be fast-forwarded)"
}
cmds = append(cmds, m.footer.Flash(r.verbEd()+" "+r.name+" to "+to))
default:
name := "the superproject"
if r != nil {
name = r.name + r.toText()
}
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...)
}
// toText is " to origin/feat" for a switch, " to the committed pin" if that
// was chosen, " to the committed pins" for a hard restore; the staged pin is
// the usual target and needs no words.
func (r *restoreState) toText() string {
switch {
case r.branch != nil:
return " to " + r.branch.Name
case r.to == submodule.RestoreHard:
return " to the committed pins"
case r.to == submodule.RestoreCommitted:
return " to the committed pin"
}
return ""
}
// verb, verbIng, verbEd and noun word the operation: a switch and a pin
// update are shown like a restore, but name their branch.
func (r *restoreState) verb() string {
switch {
case r.pinUpdate:
return "Update"
case r.branch != nil:
return "Switch"
}
return "Restore"
}
func (r *restoreState) verbIng() string {
switch {
case r.pinUpdate:
return "updating"
case r.branch != nil:
return "switching"
}
return "restoring"
}
func (r *restoreState) verbEd() string {
if r.pinUpdate {
return "updated"
}
return "switched"
}
func (r *restoreState) noun() string {
if r.pinUpdate {
return "update"
}
return "switch"
}
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 := "", ""
key := func(k string) string { return theme.Fg(m.theme.Text).Bold(true).Render(k) }
muted := func(s string) string { return theme.Fg(m.theme.Muted).Render(s) }
switch r.phase {
case restoreChoosing:
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+" to") + " " +
key("s") + muted(" staged pin · ") + key("c") + muted(" committed pin (unstages it)")
right = muted("any other key cancels")
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(r.verbIng()+" "+r.name+r.toText()+"…")
case restoreAsking:
left = theme.Fg(m.theme.Warn).Render(r.verb()+" "+r.name+r.toText()+"?") + " " +
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(" "+strings.ToLower(r.verb())+" · ") +
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.StagedPins > 0 {
parts = append(parts, plural(l.StagedPins, "staged pin", "staged pins"))
}
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)
}