forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

appview: rework compare page

trigger comparison on button click, this simplifes a variety of things:

- we can load a diff on page visit without javascript
- we can avoid modifying url using javascript and breaking back buttons
- we can avoid a lot of javascript code

Signed-off-by: oppiliappan <me@oppi.li>

Changed files
+207 -248
appview
+5 -3
appview/pages/funcmap.go
···
"sequence": func(n int) []struct{} {
return make([]struct{}, n)
},
-
"subslice": func(slice any, start, end int) any {
+
// take atmost N items from this slice
+
"take": func(slice any, n int) any {
v := reflect.ValueOf(slice)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return nil
}
-
if start < 0 || start > v.Len() || end > v.Len() || start > end {
+
if v.Len() == 0 {
return nil
}
-
return v.Slice(start, end).Interface()
+
return v.Slice(0, min(n, v.Len()-1)).Interface()
},
+
"markdown": func(text string) template.HTML {
rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault}
return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
+19 -1
appview/pages/pages.go
···
Tags []*types.TagReference
Base string
Head string
+
Diff *types.NiceDiff
Active string
}
func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error {
params.Active = "overview"
-
return p.executeRepo("repo/compare", w, params)
+
return p.executeRepo("repo/compare/compare", w, params)
+
}
+
+
type RepoCompareNewParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Forks []db.Repo
+
Branches []types.Branch
+
Tags []*types.TagReference
+
Base string
+
Head string
+
+
Active string
+
}
+
+
func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error {
+
params.Active = "overview"
+
return p.executeRepo("repo/compare/new", w, params)
}
type RepoCompareAllowPullParams struct {
-166
appview/pages/templates/repo/compare.html
···
-
{{ define "title" }}
-
{{ if and .Head .Base }}
-
comparing {{ .Base }} and
-
{{ .Head }}
-
{{ else }}
-
new comparison
-
{{ end }}
-
{{ end }}
-
-
{{ define "repoContent" }}
-
<section id="compare-select">
-
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
-
Compare changes
-
</h2>
-
<p>Choose any two refs to compare.</p>
-
-
<form id="compare-form">
-
<div class="flex items-center gap-2 py-4">
-
<div>
-
base:
-
-
<select
-
name="base"
-
id="base-select"
-
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
-
onchange="triggerCompare()"
-
>
-
<optgroup
-
label="branches ({{ len .Branches }})"
-
class="bold text-sm"
-
>
-
{{ range .Branches }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{ if .IsDefault }}
-
selected
-
{{ end }}
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ end }}
-
</optgroup>
-
<optgroup
-
label="tags ({{ len .Tags }})"
-
class="bold text-sm"
-
>
-
{{ range .Tags }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ else }}
-
<option class="py-1" disabled>
-
no tags found
-
</option>
-
{{ end }}
-
</optgroup>
-
</select>
-
</div>
-
-
{{ i "arrow-left" "w-4 h-4" }}
-
-
-
<div>
-
compare:
-
-
<select
-
name="head"
-
id="head-select"
-
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
-
onchange="triggerCompare()"
-
>
-
<option value="" selected disabled hidden>
-
select a branch or tag
-
</option>
-
<optgroup
-
label="branches ({{ len .Branches }})"
-
class="bold text-sm"
-
>
-
{{ range .Branches }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ end }}
-
</optgroup>
-
<optgroup
-
label="tags ({{ len .Tags }})"
-
class="bold text-sm"
-
>
-
{{ range .Tags }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ else }}
-
<option class="py-1" disabled>
-
no tags found
-
</option>
-
{{ end }}
-
</optgroup>
-
</select>
-
</div>
-
</div>
-
</form>
-
</section>
-
-
<script>
-
var templatedBase = `{{ .Base }}`;
-
var templatedHead = `{{ .Head }}`;
-
var selectedBase = "";
-
var selectedHead = "";
-
-
document.addEventListener('DOMContentLoaded', function() {
-
if (templatedBase && templatedHead) {
-
const baseSelect = document.getElementById('base-select');
-
const headSelect = document.getElementById('head-select');
-
-
// select the option that matches templated values
-
for(let i = 0; i < baseSelect.options.length; i++) {
-
if(baseSelect.options[i].value === templatedBase) {
-
baseSelect.selectedIndex = i;
-
break;
-
}
-
}
-
-
for(let i = 0; i < headSelect.options.length; i++) {
-
if(headSelect.options[i].value === templatedHead) {
-
headSelect.selectedIndex = i;
-
break;
-
}
-
}
-
-
triggerCompare();
-
}
-
});
-
-
function triggerCompare() {
-
// if user has selected values, use those
-
selectedBase = document.getElementById('base-select').value;
-
selectedHead = document.getElementById('head-select').value;
-
-
const baseToUse = templatedBase && !selectedBase ? templatedBase : selectedBase;
-
const headToUse = templatedHead && !selectedHead ? templatedHead : selectedHead;
-
-
if (baseToUse && headToUse) {
-
const url = `/{{ .RepoInfo.FullName }}/compare/diff/${baseToUse}/${headToUse}`;
-
-
// htmx.ajax('GET', url, { target: '#compare-diff' })
-
document.title = `comparing ${baseToUse} and ${headToUse}`;
-
}
-
}
-
</script>
-
{{ end }}
-
-
{{ define "repoAfter" }}
-
<div id="allow-pull"></div>
-
<div id="compare-diff"></div>
-
{{ end }}
+15
appview/pages/templates/repo/compare/compare.html
···
+
{{ define "title" }}
+
comparing {{ .Base }} and {{ .Head }} on {{ .RepoInfo.FullName }}
+
{{ end }}
+
+
{{ define "repoContent" }}
+
{{ template "repo/fragments/compareForm" . }}
+
{{ $isPushAllowed := and .LoggedInUser .RepoInfo.Roles.IsPushAllowed }}
+
{{ if $isPushAllowed }}
+
{{ template "repo/fragments/compareAllowPull" . }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "repoAfter" }}
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
+
{{ end }}
+31
appview/pages/templates/repo/compare/new.html
···
+
{{ define "title" }}
+
compare refs on {{ .RepoInfo.FullName }}
+
{{ end }}
+
+
{{ define "repoContent" }}
+
{{ template "repo/fragments/compareForm" . }}
+
{{ end }}
+
+
{{ define "repoAfter" }}
+
<section class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto">
+
<div class="flex flex-col items-center">
+
<p class="text-center text-black dark:text-white">
+
Recently updated branches in this repository:
+
</p>
+
{{ block "recentBranchList" $ }} {{ end }}
+
</div>
+
</section>
+
{{ end }}
+
+
{{ define "recentBranchList" }}
+
<div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2">
+
{{ range $br := take .Branches 5 }}
+
<a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline">
+
<div class="flex items-center justify-between p-2">
+
{{ $br.Name }}
+
<time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time>
+
</div>
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+73
appview/pages/templates/repo/fragments/compareForm.html
···
+
{{ define "repo/fragments/compareForm" }}
+
<div id="compare-select">
+
<h2 class="font-bold text-sm mb-2 uppercase dark:text-white">
+
Compare changes
+
</h2>
+
<p>Choose any two refs to compare.</p>
+
+
<form id="compare-form" class="flex items-center gap-2 py-4">
+
<div>
+
<span class="hidden md:inline">base:</span>
+
{{ block "dropdown" (list $ "base" $.Base) }} {{ end }}
+
</div>
+
<span class="flex-shrink-0">
+
{{ i "arrow-left" "w-4 h-4" }}
+
</span>
+
<div>
+
<span class="hidden md:inline">compare:</span>
+
{{ block "dropdown" (list $ "head" $.Head) }} {{ end }}
+
</div>
+
<button
+
id="compare-button"
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed"
+
type="button"
+
hx-boost="true"
+
onclick="
+
const base = document.getElementById('base-select').value;
+
const head = document.getElementById('head-select').value;
+
window.location.href = `/{{$.RepoInfo.FullName}}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
+
">
+
go
+
</button>
+
</form>
+
</div>
+
<script>
+
const baseSelect = document.getElementById('base-select');
+
const headSelect = document.getElementById('head-select');
+
const compareButton = document.getElementById('compare-button');
+
+
function toggleButtonState() {
+
compareButton.disabled = baseSelect.value === headSelect.value;
+
}
+
+
baseSelect.addEventListener('change', toggleButtonState);
+
headSelect.addEventListener('change', toggleButtonState);
+
+
// Run once on page load
+
toggleButtonState();
+
</script>
+
{{ end }}
+
+
{{ define "dropdown" }}
+
{{ $root := index . 0 }}
+
{{ $name := index . 1 }}
+
{{ $default := index . 2 }}
+
<select name="{{$name}}" id="{{$name}}-select" class="p-1 border max-w-32 md:max-w-64 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
+
<optgroup label="branches ({{ len $root.Branches }})" class="bold text-sm">
+
{{ range $root.Branches }}
+
<option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}>
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</optgroup>
+
<optgroup label="tags ({{ len $root.Tags }})" class="bold text-sm">
+
{{ range $root.Tags }}
+
<option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}>
+
{{ .Reference.Name }}
+
</option>
+
{{ else }}
+
<option class="py-1" disabled>no tags found</option>
+
{{ end }}
+
</optgroup>
+
</select>
+
{{ end }}
+1 -1
appview/pages/templates/repo/index.html
···
</button>
{{ end }}
<a
-
href="/{{ .RepoInfo.FullName }}/compare"
+
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
class="btn flex items-center gap-2 no-underline hover:no-underline"
title="Compare branches or tags"
>
+62 -74
appview/state/repo.go
···
"net/http"
"path"
"slices"
+
"sort"
"strconv"
"strings"
"time"
···
-
func (s *State) RepoCompare(w http.ResponseWriter, r *http.Request) {
+
func (s *State) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
f, err := s.fullyResolvedRepo(r)
if err != nil {
···
return
-
// if user is navigating to one of
-
// /compare/{base}/{head}
-
// /compare/{base}...{head}
-
base := chi.URLParam(r, "base")
-
head := chi.URLParam(r, "head")
-
if base == "" && head == "" {
-
rest := chi.URLParam(r, "*") // master...feature/xyz
-
parts := strings.SplitN(rest, "...", 2)
-
if len(parts) == 2 {
-
base = parts[0]
-
head = parts[1]
-
}
-
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
if err != nil {
log.Printf("failed to create unsigned client for %s", f.Knot)
···
return
-
branches, err := us.Branches(f.OwnerDid(), f.RepoName)
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
if err != nil {
s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
log.Println("failed to reach knotserver", err)
return
+
branches := result.Branches
+
sort.Slice(branches, func(i int, j int) bool {
+
return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
+
})
+
+
var defaultBranch string
+
for _, b := range branches {
+
if b.IsDefault {
+
defaultBranch = b.Name
+
}
+
}
+
+
base := defaultBranch
+
head := defaultBranch
+
+
params := r.URL.Query()
+
queryBase := params.Get("base")
+
queryHead := params.Get("head")
+
if queryBase != "" {
+
base = queryBase
+
}
+
if queryHead != "" {
+
head = queryHead
+
}
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
if err != nil {
···
return
-
var forks []db.Repo
-
if user != nil {
-
var err error
-
forks, err = db.GetForksByDid(s.db, user.Did)
-
if err != nil {
-
s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to get forks", err)
-
return
-
}
-
}
-
repoinfo := f.RepoInfo(s, user)
-
s.pages.RepoCompare(w, pages.RepoCompareParams{
+
s.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
LoggedInUser: user,
RepoInfo: repoinfo,
-
Forks: forks,
-
Branches: branches.Branches,
+
Branches: branches,
Tags: tags.Tags,
Base: base,
Head: head,
})
-
-
func (s *State) RepoCompareAllowPullFragment(w http.ResponseWriter, r *http.Request) {
+
func (s *State) RepoCompare(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
f, err := s.fullyResolvedRepo(r)
if err != nil {
···
return
-
s.pages.RepoCompareAllowPullFragment(w, pages.RepoCompareAllowPullParams{
-
Head: chi.URLParam(r, "head"),
-
Base: chi.URLParam(r, "base"),
-
RepoInfo: f.RepoInfo(s, user),
-
LoggedInUser: user,
-
})
-
}
-
-
func (s *State) RepoCompareDiffFragment(w http.ResponseWriter, r *http.Request) {
-
f, err := s.fullyResolvedRepo(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
user := s.oauth.GetUser(r)
-
+
// if user is navigating to one of
+
// /compare/{base}/{head}
+
// /compare/{base}...{head}
base := chi.URLParam(r, "base")
head := chi.URLParam(r, "head")
+
if base == "" && head == "" {
+
rest := chi.URLParam(r, "*") // master...feature/xyz
+
parts := strings.SplitN(rest, "...", 2)
+
if len(parts) == 2 {
+
base = parts[0]
+
head = parts[1]
+
}
+
}
if base == "" || head == "" {
-
s.pages.Notice(w, "compare-error", "Invalid ref format.")
+
log.Printf("invalid comparison")
+
s.pages.Error404(w)
return
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
if err != nil {
+
log.Printf("failed to create unsigned client for %s", f.Knot)
+
s.pages.Error503(w)
+
return
+
}
+
+
branches, err := us.Branches(f.OwnerDid(), f.RepoName)
+
if err != nil {
s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
log.Println("failed to reach knotserver", err)
return
-
formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
+
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
if err != nil {
s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to compare", err)
+
log.Println("failed to reach knotserver", err)
return
-
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
-
branches, err := us.Branches(f.OwnerDid(), f.RepoName)
+
formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
if err != nil {
s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to fetch branches", err)
+
log.Println("failed to compare", err)
return
+
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
+
log.Println(formatPatch)
repoinfo := f.RepoInfo(s, user)
-
w.Header().Add("Hx-Push-Url", fmt.Sprintf("/%s/compare/%s...%s", f.OwnerSlashRepo(), base, head))
-
w.Header().Add("Content-Type", "text/html")
-
s.pages.RepoCompareDiff(w, pages.RepoCompareDiffParams{
+
s.pages.RepoCompare(w, pages.RepoCompareParams{
LoggedInUser: user,
RepoInfo: repoinfo,
-
Diff: diff,
+
Branches: branches.Branches,
+
Tags: tags.Tags,
+
Base: base,
+
Head: head,
+
Diff: &diff,
})
-
// checks if pull is allowed and performs an htmx oob-swap
-
// by writing to the same http.ResponseWriter
-
if user != nil {
-
if slices.ContainsFunc(branches.Branches, func(branch types.Branch) bool {
-
return branch.Name == head || branch.Name == base
-
}) {
-
if repoinfo.Roles.IsPushAllowed() {
-
s.pages.RepoCompareAllowPullFragment(w, pages.RepoCompareAllowPullParams{
-
LoggedInUser: user,
-
RepoInfo: repoinfo,
-
Base: base,
-
Head: head,
-
})
-
}
-
}
-
}
+1 -3
appview/state/router.go
···
})
r.Route("/compare", func(r chi.Router) {
-
r.Get("/", s.RepoCompare)
+
r.Get("/", s.RepoCompareNew) // start an new comparison
// we have to wildcard here since we want to support GitHub's compare syntax
// /compare/{ref1}...{ref2}
···
// /compare/master...some/feature
// /compare/master...example.com:another/feature <- this is a fork
r.Get("/{base}/{head}", s.RepoCompare)
-
r.Get("/diff/{base}/{head}", s.RepoCompareDiffFragment)
-
r.Get("/allow-pull/{base}/{head}", s.RepoCompareAllowPullFragment)
r.Get("/*", s.RepoCompare)
})