forked from tangled.org/core
Monorepo for Tangled

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
··· 133 133 "sequence": func(n int) []struct{} { 134 134 return make([]struct{}, n) 135 135 }, 136 - "subslice": func(slice any, start, end int) any { 136 + // take atmost N items from this slice 137 + "take": func(slice any, n int) any { 137 138 v := reflect.ValueOf(slice) 138 139 if v.Kind() != reflect.Slice && v.Kind() != reflect.Array { 139 140 return nil 140 141 } 141 - if start < 0 || start > v.Len() || end > v.Len() || start > end { 142 + if v.Len() == 0 { 142 143 return nil 143 144 } 144 - return v.Slice(start, end).Interface() 145 + return v.Slice(0, min(n, v.Len()-1)).Interface() 145 146 }, 147 + 146 148 "markdown": func(text string) template.HTML { 147 149 rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 148 150 return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
+19 -1
appview/pages/pages.go
··· 871 871 Tags []*types.TagReference 872 872 Base string 873 873 Head string 874 + Diff *types.NiceDiff 874 875 875 876 Active string 876 877 } 877 878 878 879 func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error { 879 880 params.Active = "overview" 880 - return p.executeRepo("repo/compare", w, params) 881 + return p.executeRepo("repo/compare/compare", w, params) 882 + } 883 + 884 + type RepoCompareNewParams struct { 885 + LoggedInUser *oauth.User 886 + RepoInfo repoinfo.RepoInfo 887 + Forks []db.Repo 888 + Branches []types.Branch 889 + Tags []*types.TagReference 890 + Base string 891 + Head string 892 + 893 + Active string 894 + } 895 + 896 + func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error { 897 + params.Active = "overview" 898 + return p.executeRepo("repo/compare/new", w, params) 881 899 } 882 900 883 901 type RepoCompareAllowPullParams struct {
-166
appview/pages/templates/repo/compare.html
··· 1 - {{ define "title" }} 2 - {{ if and .Head .Base }} 3 - comparing {{ .Base }} and 4 - {{ .Head }} 5 - {{ else }} 6 - new comparison 7 - {{ end }} 8 - {{ end }} 9 - 10 - {{ define "repoContent" }} 11 - <section id="compare-select"> 12 - <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Compare changes 14 - </h2> 15 - <p>Choose any two refs to compare.</p> 16 - 17 - <form id="compare-form"> 18 - <div class="flex items-center gap-2 py-4"> 19 - <div> 20 - base: 21 - 22 - <select 23 - name="base" 24 - id="base-select" 25 - class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 26 - onchange="triggerCompare()" 27 - > 28 - <optgroup 29 - label="branches ({{ len .Branches }})" 30 - class="bold text-sm" 31 - > 32 - {{ range .Branches }} 33 - <option 34 - value="{{ .Reference.Name }}" 35 - class="py-1" 36 - {{ if .IsDefault }} 37 - selected 38 - {{ end }} 39 - > 40 - {{ .Reference.Name }} 41 - </option> 42 - {{ end }} 43 - </optgroup> 44 - <optgroup 45 - label="tags ({{ len .Tags }})" 46 - class="bold text-sm" 47 - > 48 - {{ range .Tags }} 49 - <option 50 - value="{{ .Reference.Name }}" 51 - class="py-1" 52 - > 53 - {{ .Reference.Name }} 54 - </option> 55 - {{ else }} 56 - <option class="py-1" disabled> 57 - no tags found 58 - </option> 59 - {{ end }} 60 - </optgroup> 61 - </select> 62 - </div> 63 - 64 - {{ i "arrow-left" "w-4 h-4" }} 65 - 66 - 67 - <div> 68 - compare: 69 - 70 - <select 71 - name="head" 72 - id="head-select" 73 - class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 74 - onchange="triggerCompare()" 75 - > 76 - <option value="" selected disabled hidden> 77 - select a branch or tag 78 - </option> 79 - <optgroup 80 - label="branches ({{ len .Branches }})" 81 - class="bold text-sm" 82 - > 83 - {{ range .Branches }} 84 - <option 85 - value="{{ .Reference.Name }}" 86 - class="py-1" 87 - > 88 - {{ .Reference.Name }} 89 - </option> 90 - {{ end }} 91 - </optgroup> 92 - <optgroup 93 - label="tags ({{ len .Tags }})" 94 - class="bold text-sm" 95 - > 96 - {{ range .Tags }} 97 - <option 98 - value="{{ .Reference.Name }}" 99 - class="py-1" 100 - > 101 - {{ .Reference.Name }} 102 - </option> 103 - {{ else }} 104 - <option class="py-1" disabled> 105 - no tags found 106 - </option> 107 - {{ end }} 108 - </optgroup> 109 - </select> 110 - </div> 111 - </div> 112 - </form> 113 - </section> 114 - 115 - <script> 116 - var templatedBase = `{{ .Base }}`; 117 - var templatedHead = `{{ .Head }}`; 118 - var selectedBase = ""; 119 - var selectedHead = ""; 120 - 121 - document.addEventListener('DOMContentLoaded', function() { 122 - if (templatedBase && templatedHead) { 123 - const baseSelect = document.getElementById('base-select'); 124 - const headSelect = document.getElementById('head-select'); 125 - 126 - // select the option that matches templated values 127 - for(let i = 0; i < baseSelect.options.length; i++) { 128 - if(baseSelect.options[i].value === templatedBase) { 129 - baseSelect.selectedIndex = i; 130 - break; 131 - } 132 - } 133 - 134 - for(let i = 0; i < headSelect.options.length; i++) { 135 - if(headSelect.options[i].value === templatedHead) { 136 - headSelect.selectedIndex = i; 137 - break; 138 - } 139 - } 140 - 141 - triggerCompare(); 142 - } 143 - }); 144 - 145 - function triggerCompare() { 146 - // if user has selected values, use those 147 - selectedBase = document.getElementById('base-select').value; 148 - selectedHead = document.getElementById('head-select').value; 149 - 150 - const baseToUse = templatedBase && !selectedBase ? templatedBase : selectedBase; 151 - const headToUse = templatedHead && !selectedHead ? templatedHead : selectedHead; 152 - 153 - if (baseToUse && headToUse) { 154 - const url = `/{{ .RepoInfo.FullName }}/compare/diff/${baseToUse}/${headToUse}`; 155 - 156 - // htmx.ajax('GET', url, { target: '#compare-diff' }) 157 - document.title = `comparing ${baseToUse} and ${headToUse}`; 158 - } 159 - } 160 - </script> 161 - {{ end }} 162 - 163 - {{ define "repoAfter" }} 164 - <div id="allow-pull"></div> 165 - <div id="compare-diff"></div> 166 - {{ end }}
+15
appview/pages/templates/repo/compare/compare.html
··· 1 + {{ define "title" }} 2 + comparing {{ .Base }} and {{ .Head }} on {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "repoContent" }} 6 + {{ template "repo/fragments/compareForm" . }} 7 + {{ $isPushAllowed := and .LoggedInUser .RepoInfo.Roles.IsPushAllowed }} 8 + {{ if $isPushAllowed }} 9 + {{ template "repo/fragments/compareAllowPull" . }} 10 + {{ end }} 11 + {{ end }} 12 + 13 + {{ define "repoAfter" }} 14 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 15 + {{ end }}
+31
appview/pages/templates/repo/compare/new.html
··· 1 + {{ define "title" }} 2 + compare refs on {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "repoContent" }} 6 + {{ template "repo/fragments/compareForm" . }} 7 + {{ end }} 8 + 9 + {{ define "repoAfter" }} 10 + <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"> 11 + <div class="flex flex-col items-center"> 12 + <p class="text-center text-black dark:text-white"> 13 + Recently updated branches in this repository: 14 + </p> 15 + {{ block "recentBranchList" $ }} {{ end }} 16 + </div> 17 + </section> 18 + {{ end }} 19 + 20 + {{ define "recentBranchList" }} 21 + <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"> 22 + {{ range $br := take .Branches 5 }} 23 + <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 24 + <div class="flex items-center justify-between p-2"> 25 + {{ $br.Name }} 26 + <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 27 + </div> 28 + </a> 29 + {{ end }} 30 + </div> 31 + {{ end }}
+73
appview/pages/templates/repo/fragments/compareForm.html
··· 1 + {{ define "repo/fragments/compareForm" }} 2 + <div id="compare-select"> 3 + <h2 class="font-bold text-sm mb-2 uppercase dark:text-white"> 4 + Compare changes 5 + </h2> 6 + <p>Choose any two refs to compare.</p> 7 + 8 + <form id="compare-form" class="flex items-center gap-2 py-4"> 9 + <div> 10 + <span class="hidden md:inline">base:</span> 11 + {{ block "dropdown" (list $ "base" $.Base) }} {{ end }} 12 + </div> 13 + <span class="flex-shrink-0"> 14 + {{ i "arrow-left" "w-4 h-4" }} 15 + </span> 16 + <div> 17 + <span class="hidden md:inline">compare:</span> 18 + {{ block "dropdown" (list $ "head" $.Head) }} {{ end }} 19 + </div> 20 + <button 21 + id="compare-button" 22 + class="btn disabled:opacity-50 disabled:cursor-not-allowed" 23 + type="button" 24 + hx-boost="true" 25 + onclick=" 26 + const base = document.getElementById('base-select').value; 27 + const head = document.getElementById('head-select').value; 28 + window.location.href = `/{{$.RepoInfo.FullName}}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`; 29 + "> 30 + go 31 + </button> 32 + </form> 33 + </div> 34 + <script> 35 + const baseSelect = document.getElementById('base-select'); 36 + const headSelect = document.getElementById('head-select'); 37 + const compareButton = document.getElementById('compare-button'); 38 + 39 + function toggleButtonState() { 40 + compareButton.disabled = baseSelect.value === headSelect.value; 41 + } 42 + 43 + baseSelect.addEventListener('change', toggleButtonState); 44 + headSelect.addEventListener('change', toggleButtonState); 45 + 46 + // Run once on page load 47 + toggleButtonState(); 48 + </script> 49 + {{ end }} 50 + 51 + {{ define "dropdown" }} 52 + {{ $root := index . 0 }} 53 + {{ $name := index . 1 }} 54 + {{ $default := index . 2 }} 55 + <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"> 56 + <optgroup label="branches ({{ len $root.Branches }})" class="bold text-sm"> 57 + {{ range $root.Branches }} 58 + <option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}> 59 + {{ .Reference.Name }} 60 + </option> 61 + {{ end }} 62 + </optgroup> 63 + <optgroup label="tags ({{ len $root.Tags }})" class="bold text-sm"> 64 + {{ range $root.Tags }} 65 + <option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}> 66 + {{ .Reference.Name }} 67 + </option> 68 + {{ else }} 69 + <option class="py-1" disabled>no tags found</option> 70 + {{ end }} 71 + </optgroup> 72 + </select> 73 + {{ end }}
+1 -1
appview/pages/templates/repo/index.html
··· 101 101 </button> 102 102 {{ end }} 103 103 <a 104 - href="/{{ .RepoInfo.FullName }}/compare" 104 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 105 105 class="btn flex items-center gap-2 no-underline hover:no-underline" 106 106 title="Compare branches or tags" 107 107 >
+62 -74
appview/state/repo.go
··· 12 12 "net/http" 13 13 "path" 14 14 "slices" 15 + "sort" 15 16 "strconv" 16 17 "strings" 17 18 "time" ··· 2056 2057 } 2057 2058 } 2058 2059 2059 - func (s *State) RepoCompare(w http.ResponseWriter, r *http.Request) { 2060 + func (s *State) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2060 2061 user := s.oauth.GetUser(r) 2061 2062 f, err := s.fullyResolvedRepo(r) 2062 2063 if err != nil { ··· 2064 2065 return 2065 2066 } 2066 2067 2067 - // if user is navigating to one of 2068 - // /compare/{base}/{head} 2069 - // /compare/{base}...{head} 2070 - base := chi.URLParam(r, "base") 2071 - head := chi.URLParam(r, "head") 2072 - if base == "" && head == "" { 2073 - rest := chi.URLParam(r, "*") // master...feature/xyz 2074 - parts := strings.SplitN(rest, "...", 2) 2075 - if len(parts) == 2 { 2076 - base = parts[0] 2077 - head = parts[1] 2078 - } 2079 - } 2080 - 2081 2068 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 2082 2069 if err != nil { 2083 2070 log.Printf("failed to create unsigned client for %s", f.Knot) ··· 2085 2072 return 2086 2073 } 2087 2074 2088 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 2075 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 2089 2076 if err != nil { 2090 2077 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2091 2078 log.Println("failed to reach knotserver", err) 2092 2079 return 2093 2080 } 2081 + branches := result.Branches 2082 + sort.Slice(branches, func(i int, j int) bool { 2083 + return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 2084 + }) 2085 + 2086 + var defaultBranch string 2087 + for _, b := range branches { 2088 + if b.IsDefault { 2089 + defaultBranch = b.Name 2090 + } 2091 + } 2092 + 2093 + base := defaultBranch 2094 + head := defaultBranch 2095 + 2096 + params := r.URL.Query() 2097 + queryBase := params.Get("base") 2098 + queryHead := params.Get("head") 2099 + if queryBase != "" { 2100 + base = queryBase 2101 + } 2102 + if queryHead != "" { 2103 + head = queryHead 2104 + } 2094 2105 2095 2106 tags, err := us.Tags(f.OwnerDid(), f.RepoName) 2096 2107 if err != nil { ··· 2099 2110 return 2100 2111 } 2101 2112 2102 - var forks []db.Repo 2103 - if user != nil { 2104 - var err error 2105 - forks, err = db.GetForksByDid(s.db, user.Did) 2106 - if err != nil { 2107 - s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2108 - log.Println("failed to get forks", err) 2109 - return 2110 - } 2111 - } 2112 - 2113 2113 repoinfo := f.RepoInfo(s, user) 2114 2114 2115 - s.pages.RepoCompare(w, pages.RepoCompareParams{ 2115 + s.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2116 2116 LoggedInUser: user, 2117 2117 RepoInfo: repoinfo, 2118 - Forks: forks, 2119 - Branches: branches.Branches, 2118 + Branches: branches, 2120 2119 Tags: tags.Tags, 2121 2120 Base: base, 2122 2121 Head: head, 2123 2122 }) 2124 - 2125 2123 } 2126 2124 2127 - func (s *State) RepoCompareAllowPullFragment(w http.ResponseWriter, r *http.Request) { 2125 + func (s *State) RepoCompare(w http.ResponseWriter, r *http.Request) { 2128 2126 user := s.oauth.GetUser(r) 2129 2127 f, err := s.fullyResolvedRepo(r) 2130 2128 if err != nil { ··· 2132 2130 return 2133 2131 } 2134 2132 2135 - s.pages.RepoCompareAllowPullFragment(w, pages.RepoCompareAllowPullParams{ 2136 - Head: chi.URLParam(r, "head"), 2137 - Base: chi.URLParam(r, "base"), 2138 - RepoInfo: f.RepoInfo(s, user), 2139 - LoggedInUser: user, 2140 - }) 2141 - } 2142 - 2143 - func (s *State) RepoCompareDiffFragment(w http.ResponseWriter, r *http.Request) { 2144 - f, err := s.fullyResolvedRepo(r) 2145 - if err != nil { 2146 - log.Println("failed to get repo and knot", err) 2147 - return 2148 - } 2149 - user := s.oauth.GetUser(r) 2150 - 2133 + // if user is navigating to one of 2134 + // /compare/{base}/{head} 2135 + // /compare/{base}...{head} 2151 2136 base := chi.URLParam(r, "base") 2152 2137 head := chi.URLParam(r, "head") 2138 + if base == "" && head == "" { 2139 + rest := chi.URLParam(r, "*") // master...feature/xyz 2140 + parts := strings.SplitN(rest, "...", 2) 2141 + if len(parts) == 2 { 2142 + base = parts[0] 2143 + head = parts[1] 2144 + } 2145 + } 2153 2146 2154 2147 if base == "" || head == "" { 2155 - s.pages.Notice(w, "compare-error", "Invalid ref format.") 2148 + log.Printf("invalid comparison") 2149 + s.pages.Error404(w) 2156 2150 return 2157 2151 } 2158 2152 2159 2153 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 2160 2154 if err != nil { 2155 + log.Printf("failed to create unsigned client for %s", f.Knot) 2156 + s.pages.Error503(w) 2157 + return 2158 + } 2159 + 2160 + branches, err := us.Branches(f.OwnerDid(), f.RepoName) 2161 + if err != nil { 2161 2162 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2162 2163 log.Println("failed to reach knotserver", err) 2163 2164 return 2164 2165 } 2165 2166 2166 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 2167 + tags, err := us.Tags(f.OwnerDid(), f.RepoName) 2167 2168 if err != nil { 2168 2169 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2169 - log.Println("failed to compare", err) 2170 + log.Println("failed to reach knotserver", err) 2170 2171 return 2171 2172 } 2172 - diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2173 2173 2174 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 2174 + formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 2175 2175 if err != nil { 2176 2176 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2177 - log.Println("failed to fetch branches", err) 2177 + log.Println("failed to compare", err) 2178 2178 return 2179 2179 } 2180 + diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2181 + log.Println(formatPatch) 2180 2182 2181 2183 repoinfo := f.RepoInfo(s, user) 2182 2184 2183 - w.Header().Add("Hx-Push-Url", fmt.Sprintf("/%s/compare/%s...%s", f.OwnerSlashRepo(), base, head)) 2184 - w.Header().Add("Content-Type", "text/html") 2185 - s.pages.RepoCompareDiff(w, pages.RepoCompareDiffParams{ 2185 + s.pages.RepoCompare(w, pages.RepoCompareParams{ 2186 2186 LoggedInUser: user, 2187 2187 RepoInfo: repoinfo, 2188 - Diff: diff, 2188 + Branches: branches.Branches, 2189 + Tags: tags.Tags, 2190 + Base: base, 2191 + Head: head, 2192 + Diff: &diff, 2189 2193 }) 2190 2194 2191 - // checks if pull is allowed and performs an htmx oob-swap 2192 - // by writing to the same http.ResponseWriter 2193 - if user != nil { 2194 - if slices.ContainsFunc(branches.Branches, func(branch types.Branch) bool { 2195 - return branch.Name == head || branch.Name == base 2196 - }) { 2197 - if repoinfo.Roles.IsPushAllowed() { 2198 - s.pages.RepoCompareAllowPullFragment(w, pages.RepoCompareAllowPullParams{ 2199 - LoggedInUser: user, 2200 - RepoInfo: repoinfo, 2201 - Base: base, 2202 - Head: head, 2203 - }) 2204 - } 2205 - } 2206 - } 2207 2195 }
+1 -3
appview/state/router.go
··· 119 119 }) 120 120 121 121 r.Route("/compare", func(r chi.Router) { 122 - r.Get("/", s.RepoCompare) 122 + r.Get("/", s.RepoCompareNew) // start an new comparison 123 123 124 124 // we have to wildcard here since we want to support GitHub's compare syntax 125 125 // /compare/{ref1}...{ref2} ··· 127 127 // /compare/master...some/feature 128 128 // /compare/master...example.com:another/feature <- this is a fork 129 129 r.Get("/{base}/{head}", s.RepoCompare) 130 - r.Get("/diff/{base}/{head}", s.RepoCompareDiffFragment) 131 - r.Get("/allow-pull/{base}/{head}", s.RepoCompareAllowPullFragment) 132 130 r.Get("/*", s.RepoCompare) 133 131 }) 134 132