Skip to content
quarry Artifact [be67ec8ad6]
Login

Artifact [be67ec8ad6]

Artifact be67ec8ad6e1b082d828430b84c195cdd87677b7311ba62bdf6cc27bb2f87d70:


package web

import (
	"bytes"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/ops"
)

// The new-repository flow: choose where the repository's history lives,
// set it up, review it, create it, and (for a copy) watch it arrive.
//
// Four paths, in two groups, because the first question is who owns the
// history afterwards: this forge (start empty, import and own) or an
// upstream it keeps following (a GitHub or a Fossil mirror). Every path
// ends at the one POST /new, which is repo.add.

// newRepoForm is what the setup and review pages carry: the reader's
// answers so far, sent back and forth as form fields rather than kept
// anywhere, so a Back or a refused create comes back filled in.
type newRepoForm struct {
	Kind string // empty | import | github | fossil
	Name string
	// Owner is the team to create it in; empty for the reader's own
	// namespace (RFC 0025). Teams are the ones the reader may choose,
	// and User is the reader, whom the empty choice means.
	Owner       string
	Teams       []string
	User        string
	Description string
	Private     bool
	From        string
	Source      string // import: fossil | git
	Format      string // flat | zit (Git only)
	Every       int    // mirrors: seconds between pulls
	Readme      bool
	Ignore      bool
	License     string
}

// newRepoKinds are the four paths, in the order the start page offers them.
var newRepoKinds = map[string]bool{"empty": true, "import": true, "github": true, "fossil": true}

// newRepoEvery are the pull intervals a mirror is offered.
var newRepoEvery = []struct {
	Seconds int
	Label   string
}{{900, "Every 15 minutes"}, {3600, "Every hour"}, {21600, "Every 6 hours"}, {86400, "Every day"}}

// newRepoKindOf reads the path a form came from. The older form's
// spellings (create, own, clone, git) are kept, so a script that posts
// them still creates what it asked for.
func newRepoKindOf(kind string) string {
	switch kind {
	case "own", "import":
		return "import"
	case "clone", "fossil":
		return "fossil"
	case "git", "github":
		return "github"
	}
	return "empty"
}

// newRepoFormOf reads a posted form. Unset choices take their defaults:
// flat manifests, hourly pulls, a README for a new repository.
func newRepoFormOf(req *http.Request) newRepoForm {
	f := newRepoForm{
		Kind:        newRepoKindOf(req.FormValue("kind")),
		Name:        strings.TrimSpace(req.FormValue("name")),
		Owner:       strings.TrimSpace(req.FormValue("owner")),
		Description: strings.TrimSpace(req.FormValue("description")),
		Private:     req.FormValue("private") == "true",
		From:        strings.TrimSpace(req.FormValue("from")),
		Source:      req.FormValue("source"),
		Format:      req.FormValue("format"),
		Readme:      req.FormValue("readme") == "true",
		Ignore:      req.FormValue("ignore") == "true",
		License:     req.FormValue("license"),
	}
	f.Every, _ = strconv.Atoi(req.FormValue("every"))
	if f.Every <= 0 {
		f.Every = 3600
	}
	if f.Source != "git" {
		f.Source = "fossil"
	}
	if f.Kind == "github" {
		f.Source = "git"
	}
	if f.Format != "zit" || f.Source != "git" || f.Kind == "fossil" || f.Kind == "empty" {
		f.Format = "flat"
	}
	if f.Name == "" && f.From != "" {
		f.Name = nameFromURL(f.From)
	}
	return f
}

// nameFromURL is the last path segment of an upstream's address, which
// is what a copy of it is usually called: github.com/NetBSD/src is src.
func nameFromURL(raw string) string {
	u, err := url.Parse(raw)
	if err != nil {
		return ""
	}
	parts := strings.Split(strings.Trim(u.Path, "/"), "/")
	name := strings.TrimSuffix(parts[len(parts)-1], ".git")
	if name == "" || name == "home" {
		name = strings.Split(u.Hostname(), ".")[0]
	}
	return name
}

// Git, Mirror and ZitOnly say what the form will make.
func (f newRepoForm) Git() bool {
	return f.Kind == "github" || (f.Kind == "import" && f.Source == "git")
}
func (f newRepoForm) Mirror() bool  { return f.Kind == "github" || f.Kind == "fossil" }
func (f newRepoForm) ZitOnly() bool { return f.Git() && f.Format == "zit" }

// Upstream is the source as a reader names it: owner/name on GitHub,
// the host elsewhere.
func (f newRepoForm) Upstream() string {
	u, err := url.Parse(f.From)
	if err != nil || u.Host == "" {
		return f.From
	}
	if u.Host == "github.com" {
		return strings.TrimSuffix(strings.Trim(u.Path, "/"), ".git")
	}
	return u.Host + strings.TrimSuffix(u.Path, "/")
}

// EveryLabel is the pull interval as the setup page offered it.
func (f newRepoForm) EveryLabel() string {
	for _, e := range newRepoEvery {
		if e.Seconds == f.Every {
			return e.Label
		}
	}
	return "Every " + (time.Duration(f.Every) * time.Second).String()
}

// Title is the setup page's heading for the path.
func (f newRepoForm) Title() string {
	switch f.Kind {
	case "import":
		return "Import and own"
	case "github":
		return "Mirror a GitHub repository"
	case "fossil":
		return "Mirror a Fossil repository"
	}
	return "Start a new repository"
}

// Values are the form's answers as repo.add's input.
func (f newRepoForm) Values() url.Values {
	v := url.Values{"name": {f.Name}, "description": {f.Description}}
	if f.Owner != "" {
		v.Set("namespace", f.Owner)
	}
	if f.Private {
		v.Set("private", "true")
	}
	switch f.Kind {
	case "empty":
		v.Set("create", "true")
		if f.Readme {
			v.Set("readme", "true")
		}
		if f.Ignore {
			v.Set("ignore", "true")
		}
		v.Set("license", f.License)
		return v
	case "import":
		v.Set("noMirror", "true")
	default:
		v.Set("mirrorEvery", strconv.Itoa(f.Every))
	}
	v.Set("clone", f.From)
	if f.Git() {
		v.Set("git", "true")
	}
	if f.ZitOnly() {
		v.Set("zitOnly", "true")
	}
	return v
}

// newRepoCards are the start page's four paths.
var newRepoCards = []struct {
	Group                              string
	Kind, Icon, Title, Lead, Own, Verb string
}{
	{"Make this forge the home", "empty", "i-file", "Start a new repository", "An empty repository, ready for your first check-in.", "Changes are made here", "Start empty"},
	{"Make this forge the home", "import", "i-dir", "Import and own", "Bring an existing repository here once, then maintain it on this forge.", "This becomes the source of truth", "Import a repository"},
	{"Keep following an upstream", "github", "i-branch", "Mirror from GitHub", "Follow a GitHub repository's code, branches and tags.", "GitHub stays authoritative", "Set up GitHub mirror"},
	{"Keep following an upstream", "fossil", "i-branch", "Mirror from Fossil", "Keep a copy of a Fossil repository and pull its updates.", "The upstream stays authoritative", "Set up Fossil mirror"},
}

var newRepoPage = page(template.Must(template.Must(base.Clone()).Funcs(template.FuncMap{
	"newRepoCards": func() any { return newRepoCards },
}).Parse(`{{define "content"}}
<div class="repo-start" data-hook="new-start">
<div class="flow-intro"><h1>New repository</h1><p class="mirror-lead">Start something here, bring existing work, or keep a copy of an upstream.</p></div>
{{$group := ""}}{{range $i, $c := newRepoCards}}{{if ne $c.Group $group}}{{if $group}}</div></div>{{end}}{{$group = $c.Group}}
<div class="start-group"><h2>{{$c.Group}}</h2><div class="start-grid">{{end}}
<article class="start-card" data-hook="new-path" data-kind="{{$c.Kind}}"><div class="start-card-title"><svg class="ic" aria-hidden="true"><use href="#{{$c.Icon}}"></use></svg><h3>{{$c.Title}}</h3></div><p>{{$c.Lead}}</p><div class="start-card-foot"><span class="start-ownership">{{$c.Own}}</span><a class="btn" href="/new/{{$c.Kind}}">{{$c.Verb}}</a></div></article>
{{end}}</div></div>
</div>
{{end}}`)))

// newRepoSetupPage is one path's setup: the GitHub mirror in the grouped
// layout with its summary beside it, the others as one column.
var newRepoSetupPage = page(template.Must(template.Must(base.Clone()).Funcs(template.FuncMap{
	"newRepoEvery": func() any { return newRepoEvery },
}).Parse(`{{define "content"}}{{with .NewRepo}}
{{if eq .Kind "github"}}
<div class="mirror-setup" data-hook="new-setup" data-kind="github">
<div class="mirror-intro">
<p class="mirror-crumbs"><a href="/new">New repository</a> <span aria-hidden="true">/</span> Mirror from GitHub</p>
<h1>Mirror a GitHub repository</h1>
<p class="mirror-lead">Keep a copy here. GitHub remains the source of truth.</p>
</div>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
<form method="post" action="/new/review" class="mirror-form" data-signals="{owner: '{{js .Owner}}', name: '{{js .Name}}', from: '{{js .From}}', format: '{{js .Format}}', vis: '{{if .Private}}Private{{else}}Public{{end}}'}">
<input type="hidden" name="kind" value="github">
<div class="mirror-layout">
<div class="mirror-fields">
<section class="mirror-section" aria-labelledby="source-title">
<div class="mirror-section-head"><h2 id="source-title">Repository</h2></div>
<div class="mirror-field"><label for="source-url">GitHub repository</label><input id="source-url" name="from" type="url" value="{{.From}}" placeholder="https://github.com/owner/name" required spellcheck="false" autocomplete="off" data-bind:from></div>
<div class="mirror-pair">
{{- /* Where it is made: the reader's own namespace, or a team they
       manage (RFC 0025). Shown only when there is a choice. */ -}}
{{if .Teams}}<div class="mirror-field"><label for="new-owner">Owner</label><select id="new-owner" name="owner" data-bind:owner data-on:change="@get('/new/name?name=' + encodeURIComponent($name) + '&owner=' + encodeURIComponent($owner))"><option value=""{{if not .Owner}} selected{{end}}>{{.User}}</option>{{range .Teams}}<option value="{{.}}"{{if eq . $.NewRepo.Owner}} selected{{end}}>{{.}}</option>{{end}}</select></div>{{end}}
<div class="mirror-field"><label for="mirror-name">Name on this forge</label><input id="mirror-name" name="name" value="{{.Name}}" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._-]*" autocomplete="off" spellcheck="false" data-bind:name data-on:input__debounce.250ms="@get('/new/name?name=' + encodeURIComponent(evt.target.value) + '&owner=' + encodeURIComponent($owner))">
{{- /* Whether the name is free, said as it is typed: a taken name
       found only after the review and the create is a form that wasted
       the reader's time. The review asks again, so without script the
       answer comes one step later, never at the end. */ -}}
<p id="name-check" class="name-check" aria-live="polite"{{if .Name}} data-init="@get('/new/name?name={{urlquery .Name}}&owner={{urlquery .Owner}}')"{{end}}></p></div>
<div class="mirror-field"><span id="visibility-label" class="mirror-label">Visibility here</span><div class="mirror-visibility" role="group" aria-labelledby="visibility-label"><label for="public"><input type="radio" id="public" name="private" value="false"{{if not .Private}} checked{{end}} data-on:change="$vis = 'Public'"> Public</label><label for="private"><input type="radio" id="private" name="private" value="true"{{if .Private}} checked{{end}} data-on:change="$vis = 'Private'"> Private</label></div></div>
</div>
</section>
<section class="mirror-section" aria-labelledby="format-title">
<div class="mirror-section-head"><h2 id="format-title">Manifest format</h2><small class="tl-meta-inline">Choose at creation</small></div>
<div class="mirror-format-options">
<label class="mirror-format" for="flat"><span class="mirror-format-title"><input id="flat" name="format" type="radio" value="flat"{{if ne .Format "zit"}} checked{{end}} data-on:change="$format = 'flat'"> <strong>Flat manifests</strong></span><span class="mirror-format-clients">Fossil + zit</span><span class="mirror-format-detail">Compatible with both clients.</span><span class="mirror-format-badge">Default</span></label>
<label class="mirror-format" for="sub"><span class="mirror-format-title"><input id="sub" name="format" type="radio" value="zit"{{if eq .Format "zit"}} checked{{end}} data-on:change="$format = 'zit'"> <strong>Sub-manifests</strong></span><span class="mirror-format-clients">zit only</span><span class="mirror-format-detail">Reusable pieces for large source trees.</span></label>
</div>
</section>
<section class="mirror-section mirror-last" aria-labelledby="schedule-title">
<div class="mirror-section-head"><h2 id="schedule-title">Keep it current</h2></div>
<div class="mirror-field mirror-frequency"><label for="frequency">Check GitHub for updates</label><select id="frequency" name="every">{{range newRepoEvery}}<option value="{{.Seconds}}"{{if eq .Seconds $.NewRepo.Every}} selected{{end}}>{{.Label}}</option>{{end}}</select></div>
</section>
</div>
<aside class="mirror-summary" aria-labelledby="summary-heading">
<header><h2 id="summary-heading">Your mirror</h2><strong class="mirror-summary-name" data-text="$name || '—'">{{or .Name "—"}}</strong><p><span class="mirror-tag" data-text="$vis">{{if .Private}}Private{{else}}Public{{end}}</span> <span class="mirror-tag">Read-only copy</span></p></header>
<div class="mirror-summary-body"><div class="mirror-flow"><span>GitHub</span><strong data-text="$from.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '') || '—'">{{or .Upstream "—"}}</strong><span class="mirror-flow-arrow" aria-hidden="true">↓</span><span>This forge</span><strong data-text="$name || '—'">{{or .Name "—"}}</strong></div>
<dl><dt>Format</dt><dd data-text="$format == 'zit' ? 'Sub-manifests' : 'Flat manifests'">{{if .ZitOnly}}Sub-manifests{{else}}Flat manifests{{end}}</dd><dt>Clients</dt><dd data-text="$format == 'zit' ? 'zit only' : 'Fossil + zit'">{{if .ZitOnly}}zit only{{else}}Fossil + zit{{end}}</dd><dt>Includes</dt><dd>Code, branches and tags</dd></dl>
<button type="submit" class="mirror-review-button" data-hook="new-review">Review mirror</button>
</div></aside>
</div>
</form>
</div>
{{else}}
<div class="repo-basic" data-hook="new-setup" data-kind="{{.Kind}}">
<div class="flow-intro"><p class="mirror-crumbs"><a href="/new">New repository</a> <span aria-hidden="true">/</span> {{.Title}}</p><h1>{{.Title}}</h1></div>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
<form method="post" action="/new/review" class="basic-form" data-signals="{source: '{{js .Source}}', owner: '{{js .Owner}}', name: '{{js .Name}}'}">
<input type="hidden" name="kind" value="{{.Kind}}">
{{if ne .Kind "empty"}}<section class="mirror-section"><h2>Source</h2>
{{if eq .Kind "import"}}<div class="mirror-field"><label for="basic-source-type">Repository type</label><select id="basic-source-type" name="source" data-bind:source><option value="fossil"{{if eq .Source "fossil"}} selected{{end}}>Fossil repository</option><option value="git"{{if eq .Source "git"}} selected{{end}}>Git repository</option></select></div>{{end}}
<div class="mirror-field"><label for="basic-source">Repository URL</label><input type="url" id="basic-source" name="from" value="{{.From}}" placeholder="https://fossil-scm.org/home" required spellcheck="false" autocomplete="off"></div>
</section>{{end}}
<section class="mirror-section"><h2>Repository here</h2>
{{- /* Where it is made: the reader's own namespace, or a team they
       manage (RFC 0025). Shown only when there is a choice. */ -}}
{{if .Teams}}<div class="mirror-field"><label for="new-owner">Owner</label><select id="new-owner" name="owner" data-bind:owner data-on:change="@get('/new/name?name=' + encodeURIComponent($name) + '&owner=' + encodeURIComponent($owner))"><option value=""{{if not .Owner}} selected{{end}}>{{.User}}</option>{{range .Teams}}<option value="{{.}}"{{if eq . $.NewRepo.Owner}} selected{{end}}>{{.}}</option>{{end}}</select></div>{{end}}
<div class="mirror-field"><label for="basic-name">Name</label><input id="basic-name" name="name" value="{{.Name}}"{{if eq .Kind "empty"}} required{{end}} pattern="[A-Za-z0-9][A-Za-z0-9._-]*" maxlength="64" autocomplete="off" spellcheck="false" data-bind:name data-on:input__debounce.250ms="@get('/new/name?name=' + encodeURIComponent(evt.target.value) + '&owner=' + encodeURIComponent($owner))">
{{- /* Whether the name is free, said as it is typed: a taken name
       found only after the review and the create is a form that wasted
       the reader's time. The review asks again, so without script the
       answer comes one step later, never at the end. */ -}}
<p id="name-check" class="name-check" aria-live="polite"{{if .Name}} data-init="@get('/new/name?name={{urlquery .Name}}&owner={{urlquery .Owner}}')"{{end}}></p></div>
<div class="mirror-field"><label for="basic-description">Description <small class="tl-meta-inline">optional</small></label><input id="basic-description" name="description" value="{{.Description}}" maxlength="300"></div>
<label class="mirror-notify" for="basic-private"><input type="checkbox" id="basic-private" name="private" value="true"{{if .Private}} checked{{end}}> Private</label>
</section>
{{if eq .Kind "empty"}}<section class="mirror-section"><h2>Initial files</h2>
<label class="mirror-notify" for="basic-readme"><input type="checkbox" id="basic-readme" name="readme" value="true"{{if .Readme}} checked{{end}}> Add a README</label>
<label class="mirror-notify" for="basic-ignore"><input type="checkbox" id="basic-ignore" name="ignore" value="true"{{if .Ignore}} checked{{end}}> Add a .zitignore</label>
<div class="mirror-field"><label for="basic-license">License</label><select id="basic-license" name="license"><option value="">none</option><option value="mit"{{if eq .License "mit"}} selected{{end}}>MIT</option><option value="bsd-2"{{if eq .License "bsd-2"}} selected{{end}}>BSD 2-Clause</option></select></div>
</section>{{end}}
{{if eq .Kind "import"}}<section class="mirror-section" data-show="$source == 'git'"{{if ne .Source "git"}} style="display: none"{{end}}><h2>Manifest format</h2><div class="mirror-field"><label for="basic-format">Clients that can read it</label><select id="basic-format" name="format"><option value="flat">Flat manifests · Fossil + zit</option><option value="zit"{{if eq .Format "zit"}} selected{{end}}>Sub-manifests · zit only</option></select></div></section>{{end}}
{{if eq .Kind "fossil"}}<section class="mirror-section"><h2>Keep it current</h2><div class="mirror-field mirror-frequency"><label for="basic-frequency">Pull updates</label><select id="basic-frequency" name="every">{{range newRepoEvery}}<option value="{{.Seconds}}"{{if eq .Seconds $.NewRepo.Every}} selected{{end}}>{{.Label}}</option>{{end}}</select></div></section>{{end}}
<div class="flow-actions"><a class="btn" href="/new">Back</a><button type="submit" data-hook="new-review">Review repository</button></div>
</form>
</div>
{{end}}{{end}}{{end}}`)))

// newRepoReviewPage is the last look before anything is made: what
// will live here, and for a mirror what stays upstream. Every answer
// rides along as a hidden field, so Create sends exactly what is shown.
var newRepoReviewPage = page(template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{with .NewRepo}}
<div class="repo-review" data-hook="new-review-page" data-kind="{{.Kind}}">
<div class="flow-intro"><p class="mirror-crumbs"><a href="/new">New repository</a> <span aria-hidden="true">/</span> {{.Title}} <span aria-hidden="true">/</span> Review</p><h1>Review {{if .Mirror}}mirror{{else}}repository{{end}}</h1></div>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
<form method="post" action="/new" class="acting" data-signals:busy="false">
<input type="hidden" name="kind" value="{{.Kind}}"><input type="hidden" name="owner" value="{{.Owner}}"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="description" value="{{.Description}}">
<input type="hidden" name="private" value="{{if .Private}}true{{else}}false{{end}}"><input type="hidden" name="from" value="{{.From}}"><input type="hidden" name="source" value="{{.Source}}">
<input type="hidden" name="format" value="{{.Format}}"><input type="hidden" name="every" value="{{.Every}}">
<input type="hidden" name="readme" value="{{if .Readme}}true{{end}}"><input type="hidden" name="ignore" value="{{if .Ignore}}true{{end}}"><input type="hidden" name="license" value="{{.License}}">
<div class="review-layout"><section class="review-details">
<div class="review-source-box"><span class="mirror-tag">{{.Title}}</span><h2>{{if .Owner}}{{.Owner}}/{{end}}{{.Name}}</h2>{{if .From}}<p class="mirror-fine">{{if .Mirror}}Following{{else}}From{{end}} {{.Upstream}}</p>{{end}}</div>
<dl class="review-definition">
<dt>Name</dt><dd>{{.Name}}</dd>
{{if .Description}}<dt>Description</dt><dd>{{.Description}}</dd>{{end}}
<dt>Visibility</dt><dd>{{if .Private}}Private{{else}}Public{{end}}</dd>
{{if .From}}<dt>Source</dt><dd>{{.From}}</dd>{{end}}
{{if .Git}}<dt>Format</dt><dd>{{if .ZitOnly}}Sub-manifests · zit only{{else}}Flat manifests · Fossil + zit{{end}}</dd>{{end}}
{{if .Mirror}}<dt>Updates</dt><dd>{{.EveryLabel}}</dd><dt>Local commits</dt><dd>Refused; changes come from the upstream</dd>{{end}}
{{if eq .Kind "empty"}}<dt>Initial files</dt><dd>{{if or .Readme .Ignore .License}}{{if .Readme}}README {{end}}{{if .Ignore}}.zitignore {{end}}{{if .License}}{{.License}} license{{end}}{{else}}none{{end}}</dd>{{end}}
</dl>
{{if .ZitOnly}}<label class="mirror-confirm" for="zit-only-ack"><input type="checkbox" id="zit-only-ack" name="ack" value="zit" required> I understand that stock Fossil cannot read sub-manifests.</label>{{end}}
</section>
<aside class="review-action-box"><h2>Ready to begin</h2>
<button type="submit" data-hook="new-create" data-on:click="$busy = true" data-class:is-busy="$busy"><span class="b-idle">Create {{if .Mirror}}mirror{{else}}repository{{end}}</span><span class="b-busy">Creating…</span></button>
<button type="submit" formaction="/new/{{.Kind}}" formnovalidate class="flow-back-button" data-hook="new-back">Back to setup</button>
</aside></div>
</form>
</div>
{{end}}{{end}}`)))

// newRepoImport is a copy in progress as its creator watches it.
type newRepoImport struct {
	Name  string `json:"name"`
	State string `json:"state"`
	Error string `json:"error"`
	Bytes int64  `json:"bytes"`
	// Checkins of Total is a Git import's count. Its store appears only
	// when it completes, so for a Git import the count is the progress
	// and the bytes are not.
	Checkins int `json:"checkins"`
	Total    int `json:"total"`
}

// Copied is how far the copy has come, in the units a reader expects:
// check-ins for a Git import that has counted, otherwise what has landed
// on disk.
func (i newRepoImport) Copied() string {
	switch {
	case i.Total > 0:
		return fmt.Sprintf("%s of %s check-ins", thousands(i.Checkins), thousands(i.Total))
	case i.Bytes == 0:
		return "starting"
	case i.Bytes >= 1<<30:
		return fmt.Sprintf("%.1f GB so far", float64(i.Bytes)/(1<<30))
	case i.Bytes >= 1<<20:
		return fmt.Sprintf("%.1f MB so far", float64(i.Bytes)/(1<<20))
	case i.Bytes >= 1<<10:
		return fmt.Sprintf("%d KB so far", i.Bytes>>10)
	}
	return fmt.Sprintf("%d bytes so far", i.Bytes)
}

// newRepoProgressSource is the status block, the part the stream
// replaces; while the copy runs it opens the stream that replaces it.
const newRepoProgressSource = `{{define "new-progress"}}{{with .NewImport}}
<div id="import-status" class="import-status" data-hook="new-progress" data-state="{{.State}}"{{if eq .State "copying"}} data-init="@get('/new/progress/{{.Name}}/live')"{{end}}>
<div class="import-status-head"><strong>{{.Name}}</strong><span class="mirror-tag">{{if eq .State "ready"}}Ready{{else if eq .State "failed"}}Failed{{else}}Importing{{end}}</span></div>
<ol class="import-steps">
<li class="done"><span class="import-step-mark" aria-hidden="true">✓</span><span class="import-step-label">Repository registered</span></li>
<li class="{{if eq .State "copying"}}running{{else if eq .State "failed"}}failed{{else}}done{{end}}"><span class="import-step-mark" aria-hidden="true">{{if eq .State "failed"}}!{{else if eq .State "ready"}}✓{{else}}2{{end}}</span><span class="import-step-label">Copying history</span><span class="import-step-state">{{if eq .State "copying"}}{{.Copied}}{{else if eq .State "failed"}}stopped{{else}}done{{end}}</span></li>
<li class="{{if eq .State "ready"}}done{{end}}"><span class="import-step-mark" aria-hidden="true">{{if eq .State "ready"}}✓{{else}}3{{end}}</span><span class="import-step-label">Ready to browse</span></li>
</ol>
{{if eq .State "failed"}}<div class="status err" role="alert"><strong>The import stopped.</strong><p>{{.Error}}</p></div>{{end}}
<div class="flow-actions">{{if eq .State "ready"}}<a class="btn" href="/r/{{.Name}}" data-hook="new-open">Open repository</a>{{else if eq .State "failed"}}<a class="btn" href="/new">Start again</a>{{end}}</div>
</div>
{{end}}{{end}}`

var newRepoProgressPage = page(template.Must(template.Must(base.Clone()).Parse(newRepoProgressSource + `{{define "content"}}
<div class="repo-progress">
<div class="flow-intro"><p class="mirror-crumbs"><a href="/new">New repository</a> <span aria-hidden="true">/</span> {{.NewImport.Name}}</p><h1>Preparing {{.NewImport.Name}}</h1></div>
{{template "new-progress" .}}
</div>
{{end}}`)))

// newRepoProgressFrag renders just the status block, for the stream.
var newRepoProgressFrag = template.Must(template.New("frag").Parse(newRepoProgressSource))

// newRepoImportOf asks repo.import for name's copy, as the caller may see it.
func newRepoImportOf(r *ops.Registry, req *http.Request, name string) (newRepoImport, error) {
	out, err := invoke(req.Context(), r, "repo.import", url.Values{"name": {name}})
	if err != nil {
		return newRepoImport{}, err
	}
	var imp newRepoImport
	remarshal(out, &imp)
	return imp, nil
}

// newRepoRoutes serves the flow. Creation is open to any signed-in
// account (repo.add), so every page's floor is Token, not Admin.
func newRepoRoutes(mux *http.ServeMux, r *ops.Registry, verify Verify, sessions Sessions, flat bool) {
	gate := func(w http.ResponseWriter, req *http.Request) (*http.Request, bool) {
		return gateAt(verify, sessions, "", ops.Token, w, req)
	}
	setup := func(w http.ResponseWriter, req *http.Request, f newRepoForm, msg string, status int) {
		if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok {
			f.User = id.user
		}
		// A node that names repositories flat has no namespaces to
		// offer (RFC 0025).
		if out, err := invoke(req.Context(), r, "team.list", url.Values{"manages": {"true"}}); err == nil && !flat {
			var tl struct {
				Teams []teamRow `json:"teams"`
			}
			remarshal(out, &tl)
			for _, t := range tl.Teams {
				f.Teams = append(f.Teams, t.Name)
			}
		}
		renderStatus(r, w, req, newRepoSetupPage, pageData{Title: f.Title(), OwnH1: true, NewRepo: &f, Error: msg}, status)
	}
	// nameTaken is why name cannot be created by this reader, or "" when
	// it can (repo.name: the rules repo.add applies). A check that
	// cannot be made says nothing; the create still decides.
	nameTaken := func(req *http.Request, owner, name string) string {
		out, err := invoke(req.Context(), r, "repo.name", url.Values{"name": {name}, "namespace": {owner}})
		if err != nil {
			return ""
		}
		var a struct {
			Free bool   `json:"free"`
			Why  string `json:"why"`
		}
		remarshal(out, &a)
		if a.Free {
			return ""
		}
		return a.Why
	}
	// GET /new/name: the name field's answer as it is typed, patched in
	// under the field.
	mux.HandleFunc("GET /new/name", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gate(w, req)
		if !ok {
			return
		}
		name := strings.TrimSpace(req.URL.Query().Get("name"))
		owner := strings.TrimSpace(req.URL.Query().Get("owner"))
		p := `<p id="name-check" class="name-check" aria-live="polite"></p>`
		if name != "" {
			if why := nameTaken(req, owner, name); why != "" {
				p = `<p id="name-check" class="name-check err" aria-live="polite" data-hook="name-taken">` + template.HTMLEscapeString(why) + `</p>`
			} else {
				p = `<p id="name-check" class="name-check ok" aria-live="polite" data-hook="name-free">` + template.HTMLEscapeString(name) + ` is free.</p>`
			}
		}
		patchElements(w, p)
	})
	mux.HandleFunc("GET /new", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gate(w, req)
		if !ok {
			return
		}
		render(r, w, req, newRepoPage, pageData{Title: "New repository", OwnH1: true})
	})
	mux.HandleFunc("GET /new/{kind}", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gate(w, req)
		if !ok {
			return
		}
		kind := req.PathValue("kind")
		if !newRepoKinds[kind] {
			http.NotFound(w, req)
			return
		}
		f := newRepoForm{Kind: kind, Source: "fossil", Format: "flat", Every: 3600, Readme: true}
		if kind == "github" {
			f.Source = "git"
		}
		setup(w, req, f, "", http.StatusOK)
	})
	// Back from the review: the setup again, with the answers in it.
	mux.HandleFunc("POST /new/{kind}", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gate(w, req)
		if !ok {
			return
		}
		kind := req.PathValue("kind")
		if !newRepoKinds[kind] {
			http.NotFound(w, req)
			return
		}
		// The path names the kind; read before the answers, which
		// depend on it (a format is only a Git import's).
		req.ParseForm()
		req.Form.Set("kind", kind)
		setup(w, req, newRepoFormOf(req), "", http.StatusOK)
	})
	mux.HandleFunc("POST /new/review", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gate(w, req)
		if !ok {
			return
		}
		f := newRepoFormOf(req)
		if f.Kind != "empty" && f.From == "" {
			setup(w, req, f, "Give the repository's address.", http.StatusBadRequest)
			return
		}
		if f.Name == "" {
			setup(w, req, f, "Give the repository a name.", http.StatusBadRequest)
			return
		}
		if why := nameTaken(req, f.Owner, f.Name); why != "" {
			setup(w, req, f, why, http.StatusConflict)
			return
		}
		render(r, w, req, newRepoReviewPage, pageData{Title: "Review " + f.Name, OwnH1: true, NewRepo: &f})
	})
	mux.HandleFunc("POST /new", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gate(w, req)
		if !ok {
			return
		}
		f := newRepoFormOf(req)
		// The review page asks for this; a form that skipped it has
		// not been told that Fossil cannot read what it is making.
		if f.ZitOnly() && req.FormValue("ack") != "zit" {
			renderStatus(r, w, req, newRepoReviewPage, pageData{Title: "Review " + f.Name, OwnH1: true, NewRepo: &f,
				Error: "Confirm that stock Fossil cannot read sub-manifests."}, http.StatusBadRequest)
			return
		}
		out, err := invoke(req.Context(), r, "repo.add", f.Values())
		if err != nil {
			// A refused create comes back as its setup with the reason
			// on it: the reader has a correction to make, and an error
			// page would throw their answers away.
			setup(w, req, f, err.Error(), http.StatusBadRequest)
			return
		}
		var added struct {
			Name string `json:"name"`
		}
		remarshal(out, &added)
		if f.Kind == "empty" {
			http.Redirect(w, req, "/r/"+added.Name, http.StatusSeeOther)
			return
		}
		http.Redirect(w, req, "/new/progress/"+url.PathEscape(added.Name), http.StatusSeeOther)
	})
	mux.HandleFunc("GET /new/progress/{name}", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gate(w, req)
		if !ok {
			return
		}
		name := req.PathValue("name")
		imp, err := newRepoImportOf(r, req, name)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		render(r, w, req, newRepoProgressPage, pageData{Title: "Preparing " + name, OwnH1: true, NewImport: &imp})
	})
	mux.HandleFunc("GET /new/progress/{name}/live", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gate(w, req)
		if !ok {
			return
		}
		newRepoProgressStream(r, w, req, req.PathValue("name"))
	})
}

// newRepoProgressStream holds the progress page's stream open while the
// copy runs, sending the status block whenever it changes, and ends
// once the copy has finished either way.
func newRepoProgressStream(r *ops.Registry, w http.ResponseWriter, req *http.Request, name string) {
	w, fl, ok := liveStream(w)
	if !ok {
		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("X-Accel-Buffering", "no")
	fmt.Fprint(w, ": live\n\n")
	fl.Flush()
	poll := time.NewTicker(2 * time.Second)
	defer poll.Stop()
	last := ""
	for {
		select {
		case <-req.Context().Done():
			return
		case <-poll.C:
			imp, err := newRepoImportOf(r, req, name)
			if err != nil {
				return
			}
			var buf bytes.Buffer
			if err := newRepoProgressFrag.ExecuteTemplate(&buf, "new-progress", pageData{NewImport: &imp}); err != nil {
				return
			}
			if frag := buf.String(); frag != last {
				last = frag
				fmt.Fprint(w, "event: datastar-patch-elements\n")
				for _, line := range strings.Split(strings.TrimSpace(frag), "\n") {
					fmt.Fprintf(w, "data: elements %s\n", line)
				}
				fmt.Fprint(w, "\n")
				fl.Flush()
			}
			if imp.State != "copying" {
				return
			}
		}
	}
}